diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py index 184e5ce..0b9ea89 100644 --- a/drb-c2-core/app/models.py +++ b/drb-c2-core/app/models.py @@ -146,6 +146,7 @@ class TripCreate(BaseModel): maps_link: Optional[str] = None start_date: str # YYYY-MM-DD end_date: str # YYYY-MM-DD + available_tags: List[str] = [] # tag labels configured for this trip class TripEventCreate(BaseModel): @@ -157,6 +158,7 @@ class TripEventCreate(BaseModel): maps_link: Optional[str] = None place_id: Optional[str] = None # Google Place ID notes: Optional[str] = None + tags: List[str] = [] # tag labels applied to this event class AttendeeAction(BaseModel): diff --git a/drb-c2-core/app/routers/trips.py b/drb-c2-core/app/routers/trips.py index ef9d769..d6aa653 100644 --- a/drb-c2-core/app/routers/trips.py +++ b/drb-c2-core/app/routers/trips.py @@ -47,6 +47,26 @@ _TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "add_tag", + "description": ( + "Add a new tag to the trip's available tag list so it can be used on events. " + "Use this when you want to apply a tag that doesn't exist yet." + ), + "parameters": { + "type": "object", + "properties": { + "tag": { + "type": "string", + "description": "Short tag label, e.g. 'must-do', 'nightlife', 'food'", + }, + }, + "required": ["tag"], + }, + }, + }, { "type": "function", "function": { @@ -66,6 +86,7 @@ _TOOLS = [ "location": {"type": "string", "description": "Full address or place name"}, "maps_link": {"type": "string", "description": "Google Maps URL"}, "notes": {"type": "string", "description": "Brief tips or reasoning"}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply — must be from the trip's available tags list"}, }, "required": ["title"], }, @@ -133,13 +154,15 @@ def _build_system_prompt(trip: dict, events: list[dict]) -> str: itinerary = "".join(lines) if lines else "\n (no events yet)" attendees = ", ".join(trip.get("attendees", {}).values()) or "not specified" + available_tags = trip.get("available_tags") or [] + tags_section = f"\nAvailable tags: {', '.join(available_tags)}" if available_tags else "" return f"""You are a trip planning assistant for the following trip. Trip: {trip["name"]} Destination: {trip["location"]} Dates: {trip["start_date"]} to {trip["end_date"]} -Attendees: {attendees} +Attendees: {attendees}{tags_section} Current itinerary:{itinerary} @@ -148,6 +171,7 @@ Guidelines: - Format all responses using Markdown: use **bold** for place names and key details, bullet lists for options, and [links](url) for Maps links. - When the user mentions places, activities, or asks for suggestions, search for them with search_places before proposing. - Use propose_event for each concrete suggestion — one call per event. The user will approve or skip each one. +- When proposing events, apply relevant tags from the available tags list if any are defined. - Be mindful of the existing schedule when assigning times. Avoid obvious conflicts. - All proposed dates must fall between {trip["start_date"]} and {trip["end_date"]}. - If the user says something like "everyone should be there by 6", factor that into your time proposals. @@ -183,6 +207,7 @@ async def create_trip(body: TripCreate): "start_date": body.start_date, "end_date": body.end_date, "attendees": {}, # {discord_user_id: discord_username} + "available_tags": body.available_tags, "created_at": now, } await fstore.doc_set("trips", trip_id, doc, merge=False) @@ -199,6 +224,17 @@ async def get_trip(trip_id: str): return {**trip, "events": events} +@router.put("/{trip_id}/tags") +async def update_trip_tags(trip_id: str, body: dict): + """Replace the trip's available 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} + + @router.delete("/{trip_id}") async def delete_trip(trip_id: str, _: dict = Depends(require_service_key_or_admin)): trip = await fstore.doc_get("trips", trip_id) @@ -275,6 +311,7 @@ async def create_event(trip_id: str, body: TripEventCreate): "maps_link": body.maps_link, "place_id": body.place_id, "notes": body.notes, + "tags": body.tags, "attendees": {}, "created_at": now, } @@ -403,7 +440,19 @@ async def trip_chat( for tc in msg.tool_calls: args = json.loads(tc.function.arguments) - if tc.function.name == "search_places": + if tc.function.name == "add_tag": + new_tag = str(args.get("tag", "")).strip()[:50] + if new_tag and new_tag not in trip.get("available_tags", []): + updated_tags = list(trip.get("available_tags") or []) + [new_tag] + trip["available_tags"] = updated_tags + await fstore.doc_update("trips", trip_id, {"available_tags": updated_tags}) + messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": json.dumps({"available_tags": trip.get("available_tags", [])}), + }) + + elif tc.function.name == "search_places": # Limit query string lengths before hitting the Maps API query = str(args.get("query", ""))[:200] near = str(args.get("near", ""))[:200] @@ -416,8 +465,10 @@ async def trip_chat( elif tc.function.name == "propose_event": suggestion = {k: args.get(k) for k in ( - "title", "date", "start_time", "end_time", "location", "maps_link", "notes" + "title", "date", "start_time", "end_time", "location", "maps_link", "notes", "tags" )} + if not isinstance(suggestion.get("tags"), list): + suggestion["tags"] = [] suggestions.append(suggestion) messages.append({ "role": "tool", diff --git a/drb-frontend/app/trips/[id]/page.tsx b/drb-frontend/app/trips/[id]/page.tsx index d2f8505..807c7d4 100644 --- a/drb-frontend/app/trips/[id]/page.tsx +++ b/drb-frontend/app/trips/[id]/page.tsx @@ -58,6 +58,34 @@ function dateRange(start: string, end: string): string[] { return dates; } +// --------------------------------------------------------------------------- +// Tag helpers +// --------------------------------------------------------------------------- + +const TAG_PALETTE = [ + "bg-violet-900/60 text-violet-300 border-violet-700/50", + "bg-sky-900/60 text-sky-300 border-sky-700/50", + "bg-emerald-900/60 text-emerald-300 border-emerald-700/50", + "bg-amber-900/60 text-amber-300 border-amber-700/50", + "bg-rose-900/60 text-rose-300 border-rose-700/50", + "bg-fuchsia-900/60 text-fuchsia-300 border-fuchsia-700/50", + "bg-cyan-900/60 text-cyan-300 border-cyan-700/50", + "bg-orange-900/60 text-orange-300 border-orange-700/50", +]; + +function tagColor(tag: string, availableTags: string[]): string { + const idx = availableTags.indexOf(tag); + return TAG_PALETTE[(idx >= 0 ? idx : 0) % TAG_PALETTE.length]; +} + +function TagPill({ tag, availableTags }: { tag: string; availableTags: string[] }) { + return ( + + {tag} + + ); +} + function detectConflicts(events: TripEvent[]): Set { const timed = events.filter((e) => e.start_time); const conflicts = new Set(); @@ -158,6 +186,7 @@ function AddEventModal({ onAdd: (body: object) => Promise; prefill?: Partial; }) { + const availableTags = trip.available_tags ?? []; const [title, setTitle] = useState(prefill?.title ?? ""); const [date, setDate] = useState(prefill?.date ?? trip.start_date); const [startTime, setStartTime] = useState(prefill?.start_time ?? ""); @@ -166,6 +195,7 @@ function AddEventModal({ const [mapsLink, setMapsLink] = useState(prefill?.maps_link ?? ""); const [placeId, setPlaceId] = useState(prefill?.place_id ?? ""); const [notes, setNotes] = useState(prefill?.notes ?? ""); + const [tags, setTags] = useState(prefill?.tags ?? []); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -190,6 +220,7 @@ function AddEventModal({ maps_link: mapsLink || null, place_id: placeId || null, notes: notes || null, + tags, }); onClose(); } catch { @@ -277,6 +308,29 @@ function AddEventModal({ /> + {availableTags.length > 0 && ( +
+ +
+ {availableTags.map((tag) => { + const active = tags.includes(tag); + return ( + + ); + })} +
+
+ )} + {error &&

{error}

}
@@ -306,11 +360,13 @@ function DayTimeline({ isAdmin, onDelete, driveSegments, + availableTags, }: { events: TripEvent[]; isAdmin: boolean; onDelete: (id: string) => void; driveSegments: { fromId: string; toId: string; text: string }[]; + availableTags: string[]; }) { const timed = [...events.filter((e) => e.start_time)].sort( (a, b) => toMin(a.start_time!) - toMin(b.start_time!) @@ -397,6 +453,11 @@ function DayTimeline({ {height >= 60 && e.notes && (

{e.notes}

)} + {e.tags?.length > 0 && ( +
+ {e.tags.map((t) => )} +
+ )}
{e.maps_link && ( @@ -451,6 +512,11 @@ function DayTimeline({

{e.location}

)} {e.notes &&

{e.notes}

} + {e.tags?.length > 0 && ( +
+ {e.tags.map((t) => )} +
+ )}
{e.maps_link && ( @@ -493,6 +559,7 @@ interface SuggestionCard { location?: string; maps_link?: string; notes?: string; + tags?: string[]; dismissed?: boolean; added?: boolean; } @@ -567,6 +634,7 @@ function AssistantPanel({ location: s.location ?? null, maps_link: s.maps_link ?? null, notes: s.notes ?? null, + tags: s.tags ?? [], }); onAddEvent(event); setMessages((prev) => @@ -672,6 +740,11 @@ function AssistantPanel({ )} {s.location &&

{s.location}

} {s.notes &&

{s.notes}

} + {s.tags && s.tags.length > 0 && ( +
+ {s.tags.map((t) => )} +
+ )}
{s.maps_link && ( (""); const [showAdd, setShowAdd] = useState(false); const [driveTimes, setDriveTimes] = useState>({}); + const [tagInput, setTagInput] = useState(""); const load = useCallback(async () => { try { @@ -836,6 +910,25 @@ export default function TripDetailPage() { ); return { ...prev, events }; }); + // Refresh trip to pick up any new tags the AI may have created + c2api.getTrip(id).then((data) => setTrip((prev) => prev ? { ...prev, available_tags: (data as FullTrip).available_tags } : prev)).catch(() => {}); + } + + async function handleAddTag() { + 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); + 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); } if (loading) return

Loading…

; @@ -899,6 +992,32 @@ export default function TripDetailPage() { )} + + {/* Tag manager */} + {(isAdmin || (trip.available_tags ?? []).length > 0) && ( +
+ {(trip.available_tags ?? []).map((tag) => ( + + {tag} + {isAdmin && ( + + )} + + ))} + {isAdmin && ( +
+ setTagInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())} + placeholder="Add tag…" + className="bg-transparent border border-gray-700 rounded-full px-2.5 py-0.5 text-xs text-gray-400 placeholder-gray-600 focus:outline-none focus:border-gray-500 w-24" + /> + +
+ )} +
+ )} {/* Two-column layout */} @@ -946,6 +1065,7 @@ export default function TripDetailPage() { isAdmin={isAdmin} onDelete={handleDeleteEvent} driveSegments={driveTimes[selectedDay] ?? []} + availableTags={trip.available_tags ?? []} /> diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index d675c08..43e3718 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -142,6 +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 }) }), createTripEvent: (tripId: string, body: object) => request(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }), deleteTripEvent: (tripId: string, eventId: string) => diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 5f5bfed..cd5f716 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -110,6 +110,7 @@ export interface TripEvent { maps_link: string | null; place_id: string | null; notes: string | null; + tags: string[]; attendees: Record; created_at: string; } @@ -132,6 +133,7 @@ export interface TripRecord { start_date: string; end_date: string; attendees: Record; + available_tags: string[]; created_at: string; events?: TripEvent[]; } diff --git a/drb-server-discord-bot/app/commands/trips.py b/drb-server-discord-bot/app/commands/trips.py index 169bf5a..a2826a9 100644 --- a/drb-server-discord-bot/app/commands/trips.py +++ b/drb-server-discord-bot/app/commands/trips.py @@ -248,6 +248,10 @@ class TripCommands(commands.Cog): if e.get("notes"): line += f"\n\u3000\u3000_{e['notes']}_" + event_tags = e.get("tags") or [] + if event_tags: + line += f"\n\u3000\u3000`{'` `'.join(event_tags)}`" + event_att = list(e.get("attendees", {}).values()) if event_att: line += f"\n\u3000\u3000{', '.join(event_att)}"