Compare commits

..

2 Commits

Author SHA1 Message Date
Logan 981f03ac06 allow overlap (note) tags 2026-06-21 15:52:15 -04:00
Logan 47430827d4 Fix discord trip itinerary 2026-06-21 15:47:07 -04:00
6 changed files with 113 additions and 34 deletions
+1
View File
@@ -147,6 +147,7 @@ class TripCreate(BaseModel):
start_date: str # YYYY-MM-DD start_date: str # YYYY-MM-DD
end_date: str # YYYY-MM-DD end_date: str # YYYY-MM-DD
available_tags: List[str] = [] # tag labels configured for this trip available_tags: List[str] = [] # tag labels configured for this trip
overlap_tags: List[str] = [] # subset of available_tags that allow time overlap
class TripEventCreate(BaseModel): class TripEventCreate(BaseModel):
+5 -3
View File
@@ -208,6 +208,7 @@ async def create_trip(body: TripCreate):
"end_date": body.end_date, "end_date": body.end_date,
"attendees": {}, # {discord_user_id: discord_username} "attendees": {}, # {discord_user_id: discord_username}
"available_tags": body.available_tags, "available_tags": body.available_tags,
"overlap_tags": body.overlap_tags,
"created_at": now, "created_at": now,
} }
await fstore.doc_set("trips", trip_id, doc, merge=False) await fstore.doc_set("trips", trip_id, doc, merge=False)
@@ -226,13 +227,14 @@ async def get_trip(trip_id: str):
@router.put("/{trip_id}/tags") @router.put("/{trip_id}/tags")
async def update_trip_tags(trip_id: str, body: dict): async def update_trip_tags(trip_id: str, body: dict):
"""Replace the trip's available tag list.""" """Replace the trip's available tag list and overlap-allowed tag list."""
trip = await fstore.doc_get("trips", trip_id) trip = await fstore.doc_get("trips", trip_id)
if not trip: if not trip:
raise HTTPException(404, f"Trip '{trip_id}' not found.") raise HTTPException(404, f"Trip '{trip_id}' not found.")
tags = [str(t) for t in body.get("available_tags", []) if t] tags = [str(t) for t in body.get("available_tags", []) if t]
await fstore.doc_update("trips", trip_id, {"available_tags": tags}) overlap = [str(t) for t in body.get("overlap_tags", []) if t and t in tags]
return {"available_tags": tags} await fstore.doc_update("trips", trip_id, {"available_tags": tags, "overlap_tags": overlap})
return {"available_tags": tags, "overlap_tags": overlap}
@router.delete("/{trip_id}") @router.delete("/{trip_id}")
+84 -17
View File
@@ -86,11 +86,14 @@ function TagPill({ tag, availableTags }: { tag: string; availableTags: string[]
); );
} }
function detectConflicts(events: TripEvent[]): Set<string> { function detectConflicts(events: TripEvent[], overlapTags: string[] = []): Set<string> {
const timed = events.filter((e) => e.start_time); const timed = events.filter((e) => e.start_time);
const conflicts = new Set<string>(); const conflicts = new Set<string>();
for (let i = 0; i < timed.length; i++) { for (let i = 0; i < timed.length; i++) {
for (let j = i + 1; j < timed.length; j++) { for (let j = i + 1; j < timed.length; j++) {
const aExempt = timed[i].tags?.some((t) => overlapTags.includes(t));
const bExempt = timed[j].tags?.some((t) => overlapTags.includes(t));
if (aExempt || bExempt) continue;
const aS = toMin(timed[i].start_time!); const aS = toMin(timed[i].start_time!);
const aE = timed[i].end_time ? toMin(timed[i].end_time!) : aS + 60; const aE = timed[i].end_time ? toMin(timed[i].end_time!) : aS + 60;
const bS = toMin(timed[j].start_time!); const bS = toMin(timed[j].start_time!);
@@ -365,6 +368,7 @@ function DayTimeline({
onEdit, onEdit,
driveSegments, driveSegments,
availableTags, availableTags,
overlapTags,
}: { }: {
events: TripEvent[]; events: TripEvent[];
isAdmin: boolean; isAdmin: boolean;
@@ -372,12 +376,16 @@ function DayTimeline({
onEdit: (event: TripEvent) => void; onEdit: (event: TripEvent) => void;
driveSegments: { fromId: string; toId: string; text: string }[]; driveSegments: { fromId: string; toId: string; text: string }[];
availableTags: string[]; availableTags: string[];
overlapTags: string[];
}) { }) {
const timed = [...events.filter((e) => e.start_time)].sort( const timed = [...events.filter((e) => e.start_time)].sort(
(a, b) => toMin(a.start_time!) - toMin(b.start_time!) (a, b) => toMin(a.start_time!) - toMin(b.start_time!)
); );
const untimed = events.filter((e) => !e.start_time); const untimed = events.filter((e) => !e.start_time);
const conflicts = detectConflicts(events); const isNote = (e: TripEvent) => !!e.tags?.some((t) => overlapTags.includes(t));
const noteEvents = timed.filter((e) => isNote(e));
const regularEvents = timed.filter((e) => !isNote(e));
const conflicts = detectConflicts(events, overlapTags);
if (timed.length === 0 && untimed.length === 0) { if (timed.length === 0 && untimed.length === 0) {
return ( return (
@@ -423,8 +431,45 @@ function DayTimeline({
</div> </div>
))} ))}
{/* Event blocks */} {/* Note events — overlap-allowed, rendered behind as subtle bands */}
{timed.map((e) => { {noteEvents.map((e) => {
const startMin = toMin(e.start_time!);
const endMin = e.end_time ? toMin(e.end_time) : startMin;
const top = (startMin - rangeStart) * PX_PER_MIN;
const height = Math.max(1, (endMin - startMin) * PX_PER_MIN);
return (
<div key={e.event_id} className="group">
{/* Shaded band (only if duration given) */}
{e.end_time && (
<div
style={{ top, height, left: 60, right: 0 }}
className="absolute bg-gray-800/30 border-l-2 border-gray-600/30 z-0 pointer-events-none"
/>
)}
{/* Dashed marker line + label */}
<div
style={{ top, left: 60, right: 0 }}
className="absolute z-10 flex items-center gap-2"
>
<div className="flex-1 border-t border-dashed border-gray-600/40" />
<span className="text-gray-500 text-[10px] font-mono shrink-0 pr-1">
{fmtTime(e.start_time)}
</span>
<span className="text-gray-500 text-[10px] truncate max-w-[120px] shrink-0">{e.title}</span>
{isAdmin && (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button onClick={() => onEdit(e)} className="text-gray-600 hover:text-indigo-400 text-[10px]">Edit</button>
<button onClick={() => onDelete(e.event_id)} className="text-gray-600 hover:text-red-400 text-xs leading-none">×</button>
</div>
)}
</div>
</div>
);
})}
{/* Regular event blocks */}
{regularEvents.map((e) => {
const startMin = toMin(e.start_time!); const startMin = toMin(e.start_time!);
const endMin = e.end_time ? toMin(e.end_time) : startMin + 60; const endMin = e.end_time ? toMin(e.end_time) : startMin + 60;
const top = (startMin - rangeStart) * PX_PER_MIN; const top = (startMin - rangeStart) * PX_PER_MIN;
@@ -436,7 +481,7 @@ function DayTimeline({
<div key={e.event_id}> <div key={e.event_id}>
<div <div
style={{ top, height, left: 60, right: 0 }} style={{ top, height, left: 60, right: 0 }}
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors ${ className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
isConflict isConflict
? "bg-red-950/70 border border-red-700/70" ? "bg-red-950/70 border border-red-700/70"
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70" : "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
@@ -495,11 +540,10 @@ function DayTimeline({
</div> </div>
</div> </div>
</div> </div>
{/* Drive time badge below this event, if present */}
{drive && ( {drive && (
<div <div
style={{ top: top + height + 2, left: 60 }} style={{ top: top + height + 2, left: 60 }}
className="absolute text-xs text-gray-600 font-mono flex items-center gap-1" className="absolute text-xs text-gray-600 font-mono flex items-center gap-1 z-20"
> >
<span className="text-gray-700"></span> {drive} drive <span className="text-gray-700"></span> {drive} drive
</div> </div>
@@ -965,17 +1009,27 @@ export default function TripDetailPage() {
if (!trip || !tagInput.trim()) return; if (!trip || !tagInput.trim()) return;
const tag = tagInput.trim(); const tag = tagInput.trim();
if (trip.available_tags?.includes(tag)) { setTagInput(""); return; } if (trip.available_tags?.includes(tag)) { setTagInput(""); return; }
const updated = [...(trip.available_tags ?? []), tag]; const available = [...(trip.available_tags ?? []), tag];
await c2api.updateTripTags(trip.trip_id, updated); const overlap = trip.overlap_tags ?? [];
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev); await c2api.updateTripTags(trip.trip_id, available, overlap);
setTrip((prev) => prev ? { ...prev, available_tags: available } : prev);
setTagInput(""); setTagInput("");
} }
async function handleRemoveTag(tag: string) { async function handleRemoveTag(tag: string) {
if (!trip) return; if (!trip) return;
const updated = (trip.available_tags ?? []).filter((t) => t !== tag); const available = (trip.available_tags ?? []).filter((t) => t !== tag);
await c2api.updateTripTags(trip.trip_id, updated); const overlap = (trip.overlap_tags ?? []).filter((t) => t !== tag);
setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev); await c2api.updateTripTags(trip.trip_id, available, overlap);
setTrip((prev) => prev ? { ...prev, available_tags: available, overlap_tags: overlap } : prev);
}
async function handleToggleOverlap(tag: string) {
if (!trip) return;
const current = trip.overlap_tags ?? [];
const overlap = current.includes(tag) ? current.filter((t) => t !== tag) : [...current, tag];
await c2api.updateTripTags(trip.trip_id, trip.available_tags ?? [], overlap);
setTrip((prev) => prev ? { ...prev, overlap_tags: overlap } : prev);
} }
if (loading) return <p className="text-gray-500 text-sm font-mono">Loading</p>; if (loading) return <p className="text-gray-500 text-sm font-mono">Loading</p>;
@@ -985,7 +1039,7 @@ export default function TripDetailPage() {
const attendees = Object.values(trip.attendees ?? {}); const attendees = Object.values(trip.attendees ?? {});
const dayEvents = trip.events.filter((e) => e.date === selectedDay); const dayEvents = trip.events.filter((e) => e.date === selectedDay);
const hasConflict = (day: string) => const hasConflict = (day: string) =>
detectConflicts(trip.events.filter((e) => e.date === day)).size > 0; detectConflicts(trip.events.filter((e) => e.date === day), trip.overlap_tags ?? []).size > 0;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -1043,14 +1097,26 @@ export default function TripDetailPage() {
{/* Tag manager */} {/* Tag manager */}
{(isAdmin || (trip.available_tags ?? []).length > 0) && ( {(isAdmin || (trip.available_tags ?? []).length > 0) && (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{(trip.available_tags ?? []).map((tag) => ( {(trip.available_tags ?? []).map((tag) => {
const isOverlap = (trip.overlap_tags ?? []).includes(tag);
return (
<span key={tag} className={`inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 border ${tagColor(tag, trip.available_tags ?? [])}`}> <span key={tag} className={`inline-flex items-center gap-1 text-xs rounded-full px-2.5 py-0.5 border ${tagColor(tag, trip.available_tags ?? [])}`}>
{tag} {tag}
{isAdmin && ( {isAdmin && (
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white transition-colors leading-none">×</button> <>
<button
onClick={() => handleToggleOverlap(tag)}
title={isOverlap ? "Allows overlap (click to disable)" : "Click to allow overlap"}
className={`leading-none transition-colors ${isOverlap ? "opacity-100" : "opacity-30 hover:opacity-70"}`}
>
</button>
<button onClick={() => handleRemoveTag(tag)} className="hover:text-white transition-colors leading-none opacity-60 hover:opacity-100">×</button>
</>
)} )}
</span> </span>
))} );
})}
{isAdmin && ( {isAdmin && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <input
@@ -1114,6 +1180,7 @@ export default function TripDetailPage() {
onEdit={setEditEvent} onEdit={setEditEvent}
driveSegments={driveTimes[selectedDay] ?? []} driveSegments={driveTimes[selectedDay] ?? []}
availableTags={trip.available_tags ?? []} availableTags={trip.available_tags ?? []}
overlapTags={trip.overlap_tags ?? []}
/> />
</div> </div>
</div> </div>
+2 -2
View File
@@ -142,8 +142,8 @@ export const c2api = {
request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }), request<import("@/lib/types").TripRecord>("/trips", { method: "POST", body: JSON.stringify(body) }),
deleteTrip: (id: string) => deleteTrip: (id: string) =>
request(`/trips/${id}`, { method: "DELETE" }), request(`/trips/${id}`, { method: "DELETE" }),
updateTripTags: (id: string, available_tags: string[]) => updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) =>
request<{ available_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags }) }), request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }),
createTripEvent: (tripId: string, body: object) => createTripEvent: (tripId: string, body: object) =>
request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }), request<import("@/lib/types").TripEvent>(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }),
updateTripEvent: (tripId: string, eventId: string, body: object) => updateTripEvent: (tripId: string, eventId: string, body: object) =>
+1
View File
@@ -134,6 +134,7 @@ export interface TripRecord {
end_date: string; end_date: string;
attendees: Record<string, string>; attendees: Record<string, string>;
available_tags: string[]; available_tags: string[];
overlap_tags: string[];
created_at: string; created_at: string;
events?: TripEvent[]; events?: TripEvent[];
} }
+15 -7
View File
@@ -181,8 +181,9 @@ class TripCommands(commands.Cog):
f"{_fmt_date(t.get('end_date', ''))}" f"{_fmt_date(t.get('end_date', ''))}"
) )
attendee_count = len(t.get("attendees", {})) attendee_count = len(t.get("attendees", {}))
field_name = f"{t['name']} [{status}]"[:256]
embed.add_field( embed.add_field(
name=f"{t['name']} [{status}]", name=field_name,
value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going", value=f"{t.get('location', '?')}\n{dates}\n{attendee_count} going",
inline=False, inline=False,
) )
@@ -215,8 +216,8 @@ class TripCommands(commands.Cog):
) )
embed = discord.Embed( embed = discord.Embed(
title=data["name"], title=data["name"][:256],
description="\n".join(desc_lines), description="\n".join(desc_lines)[:4096],
color=0x5865f2, color=0x5865f2,
) )
@@ -225,19 +226,21 @@ class TripCommands(commands.Cog):
for e in data.get("events", []): for e in data.get("events", []):
events_by_date.setdefault(e["date"], []).append(e) events_by_date.setdefault(e["date"], []).append(e)
# Track total embed chars (Discord limit: 6000)
embed_chars = len(embed.title or "") + len(embed.description or "")
field_count = 0 field_count = 0
for day_iso in _date_range(data["start_date"], data["end_date"]): for day_iso in _date_range(data["start_date"], data["end_date"]):
day_events = events_by_date.get(day_iso) day_events = events_by_date.get(day_iso)
if not day_events: if not day_events:
continue continue
if field_count >= 24: if field_count >= 24 or embed_chars >= 5800:
embed.add_field(name="...", value="More events not shown.", inline=False) embed.add_field(name="...", value="More events not shown.", inline=False)
break break
day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d") day_label = datetime.strptime(day_iso, "%Y-%m-%d").strftime("%A, %b %-d")
lines = [] lines = []
for e in sorted(day_events, key=lambda x: x.get("time") or ""): for e in sorted(day_events, key=lambda x: x.get("start_time") or ""):
time_str = _fmt_time(e.get("time")) time_str = _fmt_time(e.get("start_time"))
line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}" line = f"**{time_str}** {e['title']}" if time_str else f"- {e['title']}"
loc = e.get("location") loc = e.get("location")
@@ -258,7 +261,12 @@ class TripCommands(commands.Cog):
lines.append(line) lines.append(line)
embed.add_field(name=f"{day_label}", value="\n".join(lines), inline=False) field_name = f"{day_label}"
field_value = "\n".join(lines)
if len(field_value) > 1024:
field_value = field_value[:1021] + ""
embed.add_field(name=field_name, value=field_value, inline=False)
embed_chars += len(field_name) + len(field_value)
field_count += 1 field_count += 1
if not events_by_date: if not events_by_date: