diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py index e82e797..8eed24c 100644 --- a/drb-c2-core/app/models.py +++ b/drb-c2-core/app/models.py @@ -147,6 +147,7 @@ class TripCreate(BaseModel): start_date: str # YYYY-MM-DD end_date: str # YYYY-MM-DD 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): diff --git a/drb-c2-core/app/routers/trips.py b/drb-c2-core/app/routers/trips.py index 3c4a420..2410c2e 100644 --- a/drb-c2-core/app/routers/trips.py +++ b/drb-c2-core/app/routers/trips.py @@ -208,6 +208,7 @@ async def create_trip(body: TripCreate): "end_date": body.end_date, "attendees": {}, # {discord_user_id: discord_username} "available_tags": body.available_tags, + "overlap_tags": body.overlap_tags, "created_at": now, } 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") 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) if not trip: raise HTTPException(404, f"Trip '{trip_id}' not found.") tags = [str(t) for t in body.get("available_tags", []) if t] - await fstore.doc_update("trips", trip_id, {"available_tags": tags}) - return {"available_tags": tags} + overlap = [str(t) for t in body.get("overlap_tags", []) if t and t in 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}") diff --git a/drb-frontend/app/trips/[id]/page.tsx b/drb-frontend/app/trips/[id]/page.tsx index c246361..c6f7917 100644 --- a/drb-frontend/app/trips/[id]/page.tsx +++ b/drb-frontend/app/trips/[id]/page.tsx @@ -86,11 +86,14 @@ function TagPill({ tag, availableTags }: { tag: string; availableTags: string[] ); } -function detectConflicts(events: TripEvent[]): Set { +function detectConflicts(events: TripEvent[], overlapTags: string[] = []): Set { const timed = events.filter((e) => e.start_time); const conflicts = new Set(); for (let i = 0; i < timed.length; i++) { 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 aE = timed[i].end_time ? toMin(timed[i].end_time!) : aS + 60; const bS = toMin(timed[j].start_time!); @@ -365,6 +368,7 @@ function DayTimeline({ onEdit, driveSegments, availableTags, + overlapTags, }: { events: TripEvent[]; isAdmin: boolean; @@ -372,12 +376,16 @@ function DayTimeline({ onEdit: (event: TripEvent) => void; driveSegments: { fromId: string; toId: string; text: string }[]; availableTags: string[]; + overlapTags: string[]; }) { const timed = [...events.filter((e) => e.start_time)].sort( (a, b) => toMin(a.start_time!) - toMin(b.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) { return ( @@ -423,8 +431,45 @@ function DayTimeline({ ))} - {/* Event blocks */} - {timed.map((e) => { + {/* Note events — overlap-allowed, rendered behind as subtle bands */} + {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 ( +
+ {/* Shaded band (only if duration given) */} + {e.end_time && ( +
+ )} + {/* Dashed marker line + label */} +
+
+ + {fmtTime(e.start_time)} + + {e.title} + {isAdmin && ( +
+ + +
+ )} +
+
+ ); + })} + + {/* Regular event blocks */} + {regularEvents.map((e) => { const startMin = toMin(e.start_time!); const endMin = e.end_time ? toMin(e.end_time) : startMin + 60; const top = (startMin - rangeStart) * PX_PER_MIN; @@ -436,7 +481,7 @@ function DayTimeline({
- {/* Drive time badge below this event, if present */} {drive && (
{drive} drive
@@ -965,17 +1009,27 @@ export default function TripDetailPage() { if (!trip || !tagInput.trim()) return; const tag = tagInput.trim(); if (trip.available_tags?.includes(tag)) { setTagInput(""); return; } - const updated = [...(trip.available_tags ?? []), tag]; - await c2api.updateTripTags(trip.trip_id, updated); - setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev); + const available = [...(trip.available_tags ?? []), tag]; + const overlap = trip.overlap_tags ?? []; + await c2api.updateTripTags(trip.trip_id, available, overlap); + setTrip((prev) => prev ? { ...prev, available_tags: available } : prev); setTagInput(""); } async function handleRemoveTag(tag: string) { if (!trip) return; - const updated = (trip.available_tags ?? []).filter((t) => t !== tag); - await c2api.updateTripTags(trip.trip_id, updated); - setTrip((prev) => prev ? { ...prev, available_tags: updated } : prev); + const available = (trip.available_tags ?? []).filter((t) => t !== tag); + const overlap = (trip.overlap_tags ?? []).filter((t) => t !== tag); + 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

Loading…

; @@ -985,7 +1039,7 @@ export default function TripDetailPage() { const attendees = Object.values(trip.attendees ?? {}); const dayEvents = trip.events.filter((e) => e.date === selectedDay); 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 (
@@ -1043,14 +1097,26 @@ export default function TripDetailPage() { {/* Tag manager */} {(isAdmin || (trip.available_tags ?? []).length > 0) && (
- {(trip.available_tags ?? []).map((tag) => ( - - {tag} - {isAdmin && ( - - )} - - ))} + {(trip.available_tags ?? []).map((tag) => { + const isOverlap = (trip.overlap_tags ?? []).includes(tag); + return ( + + {tag} + {isAdmin && ( + <> + + + + )} + + ); + })} {isAdmin && (
diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index e893fe3..808d894 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -142,8 +142,8 @@ export const c2api = { request("/trips", { method: "POST", body: JSON.stringify(body) }), deleteTrip: (id: string) => request(`/trips/${id}`, { method: "DELETE" }), - updateTripTags: (id: string, available_tags: string[]) => - request<{ available_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags }) }), + updateTripTags: (id: string, available_tags: string[], overlap_tags: string[]) => + request<{ available_tags: string[]; overlap_tags: string[] }>(`/trips/${id}/tags`, { method: "PUT", body: JSON.stringify({ available_tags, overlap_tags }) }), createTripEvent: (tripId: string, body: object) => request(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }), updateTripEvent: (tripId: string, eventId: string, body: object) => diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index cd5f716..05241d7 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -134,6 +134,7 @@ export interface TripRecord { end_date: string; attendees: Record; available_tags: string[]; + overlap_tags: string[]; created_at: string; events?: TripEvent[]; }