From 7b9aefbcc54ab5c02cae561d810e9c080aa00e74 Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 21 Jun 2026 10:12:33 -0400 Subject: [PATCH] Add UI to trips --- drb-c2-core/app/main.py | 3 +- drb-c2-core/app/models.py | 6 +- drb-c2-core/app/routers/places.py | 78 ++ drb-c2-core/app/routers/trips.py | 216 ++++- drb-frontend/app/trips/[id]/page.tsx | 943 +++++++++++++++---- drb-frontend/lib/c2api.ts | 15 + drb-frontend/lib/types.ts | 14 +- drb-server-discord-bot/app/commands/trips.py | 24 +- 8 files changed, 1078 insertions(+), 221 deletions(-) create mode 100644 drb-c2-core/app/routers/places.py diff --git a/drb-c2-core/app/main.py b/drb-c2-core/app/main.py index 2f5c82f..42ffc59 100644 --- a/drb-c2-core/app/main.py +++ b/drb-c2-core/app/main.py @@ -10,7 +10,7 @@ from app.internal.vocabulary_learner import vocabulary_induction_loop from app.internal.recorrelation_sweep import recorrelation_loop from app.config import settings from app.internal.auth import require_firebase_token, require_service_or_firebase_token -from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips +from app.routers import nodes, systems, calls, upload, tokens, incidents, alerts, admin, trips, places from app.internal import firestore as fstore @@ -69,6 +69,7 @@ app.include_router(tokens.router, dependencies=[Depends(require_service_or_fi app.include_router(incidents.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(alerts.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(trips.router, dependencies=[Depends(require_service_or_firebase_token)]) +app.include_router(places.router, dependencies=[Depends(require_service_or_firebase_token)]) app.include_router(upload.router) # auth is per-node, handled inline app.include_router(admin.router) # auth is per-endpoint (read: firebase, write: admin) diff --git a/drb-c2-core/app/models.py b/drb-c2-core/app/models.py index 2db9d5f..184e5ce 100644 --- a/drb-c2-core/app/models.py +++ b/drb-c2-core/app/models.py @@ -150,10 +150,12 @@ class TripCreate(BaseModel): class TripEventCreate(BaseModel): title: str - date: str # YYYY-MM-DD, must fall within parent trip range - time: Optional[str] = None # HH:MM (24h) + date: str # YYYY-MM-DD, must fall within parent trip range + start_time: Optional[str] = None # HH:MM (24h) + end_time: Optional[str] = None # HH:MM (24h) location: Optional[str] = None # inherits trip location if None maps_link: Optional[str] = None + place_id: Optional[str] = None # Google Place ID notes: Optional[str] = None diff --git a/drb-c2-core/app/routers/places.py b/drb-c2-core/app/routers/places.py new file mode 100644 index 0000000..2ce71db --- /dev/null +++ b/drb-c2-core/app/routers/places.py @@ -0,0 +1,78 @@ +import httpx +from fastapi import APIRouter, HTTPException, Query +from app.config import settings +from app.internal.logger import logger + +router = APIRouter(prefix="/places", tags=["places"]) + +PLACES_SEARCH_URL = "https://maps.googleapis.com/maps/api/place/textsearch/json" +DIRECTIONS_URL = "https://maps.googleapis.com/maps/api/directions/json" + + +@router.get("/search") +async def search_places(query: str = Query(...), near: str = Query("")): + if not settings.google_maps_api_key: + raise HTTPException(503, "Google Maps API not configured.") + + full_query = f"{query} {near}".strip() + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get( + PLACES_SEARCH_URL, + params={"query": full_query, "key": settings.google_maps_api_key}, + ) + r.raise_for_status() + data = r.json() + except Exception as e: + logger.error(f"Places search failed: {e}") + raise HTTPException(502, "Places search failed.") + + return [ + { + "name": p.get("name"), + "address": p.get("formatted_address"), + "place_id": p.get("place_id"), + "lat": p.get("geometry", {}).get("location", {}).get("lat"), + "lng": p.get("geometry", {}).get("location", {}).get("lng"), + "maps_link": f"https://www.google.com/maps/place/?q=place_id:{p.get('place_id')}", + "rating": p.get("rating"), + } + for p in data.get("results", [])[:6] + ] + + +@router.get("/directions") +async def get_directions( + origin: str = Query(...), + destination: str = Query(...), +): + if not settings.google_maps_api_key: + raise HTTPException(503, "Google Maps API not configured.") + + try: + async with httpx.AsyncClient(timeout=10) as client: + r = await client.get( + DIRECTIONS_URL, + params={ + "origin": origin, + "destination": destination, + "mode": "driving", + "key": settings.google_maps_api_key, + }, + ) + r.raise_for_status() + data = r.json() + except Exception as e: + logger.error(f"Directions failed: {e}") + raise HTTPException(502, "Directions request failed.") + + routes = data.get("routes", []) + if not routes: + return {"duration_text": None, "duration_seconds": None, "distance_text": None} + + leg = routes[0]["legs"][0] + return { + "duration_text": leg["duration"]["text"], + "duration_seconds": leg["duration"]["value"], + "distance_text": leg["distance"]["text"], + } diff --git a/drb-c2-core/app/routers/trips.py b/drb-c2-core/app/routers/trips.py index 8257bd6..584c8f5 100644 --- a/drb-c2-core/app/routers/trips.py +++ b/drb-c2-core/app/routers/trips.py @@ -1,12 +1,148 @@ import uuid +import json +import httpx from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, HTTPException +from pydantic import BaseModel from app.models import TripCreate, TripEventCreate, AttendeeAction from app.internal import firestore as fstore +from app.config import settings +from app.internal.logger import logger router = APIRouter(prefix="/trips", tags=["trips"]) +# --------------------------------------------------------------------------- +# AI assistant — tool definitions +# --------------------------------------------------------------------------- + +_TOOLS = [ + { + "type": "function", + "function": { + "name": "search_places", + "description": ( + "Search Google Maps for places (restaurants, bars, attractions, hotels, venues). " + "Use this whenever the user asks about specific places or you need to find options." + ), + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "What to search for, e.g. 'rooftop bars', 'Italian restaurants'", + }, + "near": { + "type": "string", + "description": "Location to search near, e.g. 'downtown Nashville, TN'", + }, + }, + "required": ["query", "near"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "propose_event", + "description": ( + "Propose a specific event to add to the itinerary. " + "The user will see a card and can approve or dismiss it. " + "Call this once per proposed event — do not bundle multiple events into one call." + ), + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "date": {"type": "string", "description": "YYYY-MM-DD — must be within the trip date range"}, + "start_time": {"type": "string", "description": "HH:MM (24h), e.g. '19:30'"}, + "end_time": {"type": "string", "description": "HH:MM (24h), e.g. '22:00'"}, + "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"}, + }, + "required": ["title"], + }, + }, + }, +] + + +async def _places_search(query: str, near: str) -> list[dict]: + if not settings.google_maps_api_key: + return [] + try: + async with httpx.AsyncClient(timeout=8) as client: + r = await client.get( + "https://maps.googleapis.com/maps/api/place/textsearch/json", + params={"query": f"{query} {near}".strip(), "key": settings.google_maps_api_key}, + ) + data = r.json() + return [ + { + "name": p.get("name"), + "address": p.get("formatted_address"), + "place_id": p.get("place_id"), + "maps_link": f"https://www.google.com/maps/place/?q=place_id:{p.get('place_id')}", + "rating": p.get("rating"), + } + for p in data.get("results", [])[:5] + ] + except Exception as e: + logger.error(f"Places search in assistant failed: {e}") + return [] + + +def _build_system_prompt(trip: dict, events: list[dict]) -> str: + by_date: dict[str, list] = {} + for e in sorted(events, key=lambda x: (x.get("date", ""), x.get("start_time") or "")): + by_date.setdefault(e["date"], []).append(e) + + lines = [] + for date, day_events in sorted(by_date.items()): + lines.append(f"\n {date}:") + for e in day_events: + t = "" + if e.get("start_time"): + t = f" {e['start_time']}" + if e.get("end_time"): + t += f"–{e['end_time']}" + loc = f" @ {e['location']}" if e.get("location") and not e.get("location_inherited") else "" + lines.append(f" • {e['title']}{t}{loc}") + if e.get("notes"): + lines.append(f" Notes: {e['notes']}") + + itinerary = "".join(lines) if lines else "\n (no events yet)" + attendees = ", ".join(trip.get("attendees", {}).values()) or "not specified" + + 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} + +Current itinerary:{itinerary} + +Guidelines: +- Be conversational and concise — don't over-explain. +- 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. +- 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. +- If you don't know a specific address, search for the place first.""" + + +class ChatMsg(BaseModel): + role: str + content: str + + +class ChatRequest(BaseModel): + message: str + history: list[ChatMsg] = [] + @router.get("") async def list_trips(): @@ -102,10 +238,12 @@ async def create_event(trip_id: str, body: TripEventCreate): "trip_id": trip_id, "title": body.title, "date": body.date, - "time": body.time, + "start_time": body.start_time, + "end_time": body.end_time, "location": body.location if body.location is not None else trip["location"], "location_inherited": body.location is None, "maps_link": body.maps_link, + "place_id": body.place_id, "notes": body.notes, "attendees": {}, "created_at": now, @@ -148,3 +286,79 @@ async def leave_event(trip_id: str, event_id: str, body: AttendeeAction): attendees.pop(body.discord_user_id, None) await fstore.doc_update("trip_events", event_id, {"attendees": attendees}) return {"ok": True} + + +# --------------------------------------------------------------------------- +# AI trip planning assistant +# --------------------------------------------------------------------------- + +@router.post("/{trip_id}/chat") +async def trip_chat(trip_id: str, body: ChatRequest): + if not settings.openai_api_key: + raise HTTPException(503, "OpenAI not configured.") + + trip = await fstore.doc_get("trips", trip_id) + if not trip: + raise HTTPException(404, f"Trip '{trip_id}' not found.") + + events = await fstore.collection_list("trip_events", trip_id=trip_id) + + from openai import AsyncOpenAI + oai = AsyncOpenAI(api_key=settings.openai_api_key) + + messages: list[dict] = [ + {"role": "system", "content": _build_system_prompt(trip, events)}, + *[{"role": m.role, "content": m.content} for m in body.history[-20:]], + {"role": "user", "content": body.message}, + ] + + suggestions: list[dict] = [] + reply = "" + + for _ in range(6): # max tool-call iterations + response = await oai.chat.completions.create( + model="gpt-4o-mini", + messages=messages, + tools=_TOOLS, + tool_choice="auto", + max_tokens=1000, + ) + msg = response.choices[0].message + + if not msg.tool_calls: + reply = msg.content or "" + break + + # Append assistant message with tool calls + messages.append({ + "role": "assistant", + "content": msg.content, + "tool_calls": [tc.model_dump() for tc in msg.tool_calls], + }) + + for tc in msg.tool_calls: + args = json.loads(tc.function.arguments) + + if tc.function.name == "search_places": + results = await _places_search(args.get("query", ""), args.get("near", "")) + messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": json.dumps(results), + }) + + elif tc.function.name == "propose_event": + suggestion = {k: args.get(k) for k in ( + "title", "date", "start_time", "end_time", "location", "maps_link", "notes" + )} + suggestions.append(suggestion) + messages.append({ + "role": "tool", + "tool_call_id": tc.id, + "content": json.dumps({"proposed": True, "title": args.get("title")}), + }) + + if not reply: + reply = f"Here {'are' if len(suggestions) != 1 else 'is'} {len(suggestions) or 'my'} suggestion{'s' if len(suggestions) != 1 else ''} for your trip." + + return {"reply": reply, "suggestions": suggestions} diff --git a/drb-frontend/app/trips/[id]/page.tsx b/drb-frontend/app/trips/[id]/page.tsx index ca4833a..6cd179b 100644 --- a/drb-frontend/app/trips/[id]/page.tsx +++ b/drb-frontend/app/trips/[id]/page.tsx @@ -1,32 +1,44 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useAuth } from "@/components/AuthProvider"; import { c2api } from "@/lib/c2api"; -import type { TripRecord, TripEvent } from "@/lib/types"; +import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types"; // --------------------------------------------------------------------------- -// Formatting helpers +// Helpers // --------------------------------------------------------------------------- +function toMin(t: string): number { + const [h, m] = t.split(":").map(Number); + return h * 60 + (m ?? 0); +} + function fmtDate(iso: string) { return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); } -function fmtDayLabel(iso: string) { +function fmtDayTab(iso: string) { + const d = new Date(`${iso}T12:00:00`); + return { + short: d.toLocaleDateString("en-US", { weekday: "short" }), + date: d.toLocaleDateString("en-US", { month: "short", day: "numeric" }), + }; +} + +function fmtDayHeading(iso: string) { return new Date(`${iso}T12:00:00`).toLocaleDateString("en-US", { - weekday: "long", month: "short", day: "numeric", + weekday: "long", month: "long", day: "numeric", }); } -function fmtTime(t: string | null) { - if (!t) return null; +function fmtTime(t: string | null | undefined): string { + if (!t) return ""; const [h, m] = t.split(":").map(Number); - const d = new Date(); - d.setHours(h, m, 0, 0); + const d = new Date(); d.setHours(h, m, 0, 0); return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }); } @@ -41,23 +53,123 @@ function dateRange(start: string, end: string): string[] { return dates; } +function detectConflicts(events: TripEvent[]): 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 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!); + const bE = timed[j].end_time ? toMin(timed[j].end_time!) : bS + 60; + if (aS < bE && bS < aE) { + conflicts.add(timed[i].event_id); + conflicts.add(timed[j].event_id); + } + } + } + return conflicts; +} + +// --------------------------------------------------------------------------- +// Places search dropdown (reusable within modal) +// --------------------------------------------------------------------------- + +function PlaceSearch({ + near, + onSelect, +}: { + near: string; + onSelect: (p: PlaceResult) => void; +}) { + const [query, setQuery] = useState(""); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + + async function search() { + if (!query.trim()) return; + setLoading(true); + try { + const r = await c2api.searchPlaces(query, near); + setResults(r); + } finally { + setLoading(false); + } + } + + return ( +
+
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())} + placeholder="Search a place…" + className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" + /> + +
+ {results.length > 0 && ( +
+ {results.map((p) => ( + + ))} +
+ )} +
+ ); +} + // --------------------------------------------------------------------------- // Add Event modal // --------------------------------------------------------------------------- -function AddEventModal({ trip, onClose, onAdd }: { +function AddEventModal({ + trip, + onClose, + onAdd, + prefill, +}: { trip: TripRecord; onClose: () => void; onAdd: (body: object) => Promise; + prefill?: Partial; }) { - const [title, setTitle] = useState(""); - const [date, setDate] = useState(trip.start_date); - const [time, setTime] = useState(""); - const [location, setLocation] = useState(""); - const [mapsLink, setMapsLink] = useState(""); - const [notes, setNotes] = useState(""); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); + const [title, setTitle] = useState(prefill?.title ?? ""); + const [date, setDate] = useState(prefill?.date ?? trip.start_date); + const [startTime, setStartTime] = useState(prefill?.start_time ?? ""); + const [endTime, setEndTime] = useState(prefill?.end_time ?? ""); + const [location, setLocation] = useState(prefill?.location ?? ""); + const [mapsLink, setMapsLink] = useState(prefill?.maps_link ?? ""); + const [placeId, setPlaceId] = useState(prefill?.place_id ?? ""); + const [notes, setNotes] = useState(prefill?.notes ?? ""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + function handlePlaceSelect(p: PlaceResult) { + if (!title) setTitle(p.name); + setLocation(p.address); + setMapsLink(p.maps_link); + setPlaceId(p.place_id); + } async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -67,37 +179,39 @@ function AddEventModal({ trip, onClose, onAdd }: { await onAdd({ title, date, - time: time || null, - location: location || null, - maps_link: mapsLink || null, - notes: notes || null, + start_time: startTime || null, + end_time: endTime || null, + location: location || null, + maps_link: mapsLink || null, + place_id: placeId || null, + notes: notes || null, }); onClose(); } catch { - setError("Failed to add event. Make sure the date is within the trip range."); + setError("Failed to add event. Check the date is within the trip range."); } finally { setSaving(false); } } return ( -
+
-

Add Event

+

Add Event

setTitle(e.target.value)} - placeholder="Dinner on Broadway" + placeholder="Dinner at the bar" className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" />
-
+
- + setTime(e.target.value)} + type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" + /> +
+
+ + setEndTime(e.target.value)} + min={startTime} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" />
+
+ + +
+
setLocation(e.target.value)} - placeholder={trip.location} + placeholder={`Inherits: ${trip.location}`} className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" />
-
- - setMapsLink(e.target.value)} - placeholder="https://maps.google.com/…" - className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500" - /> -
+ {mapsLink && ( +

+ Maps link attached —{" "} + + preview + +

+ )}
@@ -146,7 +274,7 @@ function AddEventModal({ trip, onClose, onAdd }: { {error &&

{error}

} -
+
@@ -163,107 +291,479 @@ function AddEventModal({ trip, onClose, onAdd }: { } // --------------------------------------------------------------------------- -// Event card +// Day timeline // --------------------------------------------------------------------------- -function EventCard({ event, isAdmin, onDelete }: { - event: TripEvent; +const PX_PER_MIN = 1.3; // 78px per hour + +function DayTimeline({ + events, + isAdmin, + onDelete, + driveSegments, +}: { + events: TripEvent[]; isAdmin: boolean; onDelete: (id: string) => void; + driveSegments: { fromId: string; toId: string; text: string }[]; }) { - const time = fmtTime(event.time); - const attendees = Object.values(event.attendees ?? {}); + 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); + + if (timed.length === 0 && untimed.length === 0) { + return ( +

+ No events on this day yet. +

+ ); + } + + // Dynamic time range, clamped to 0–1440, min 8h window + let rangeStart = 7 * 60; + let rangeEnd = 22 * 60; + if (timed.length > 0) { + const starts = timed.map((e) => toMin(e.start_time!)); + const ends = timed.map((e) => (e.end_time ? toMin(e.end_time) : toMin(e.start_time!) + 60)); + rangeStart = Math.max(0, Math.floor(Math.min(...starts) / 60) * 60 - 60); + rangeEnd = Math.min(24 * 60, Math.ceil(Math.max(...ends) / 60) * 60 + 60); + if (rangeEnd - rangeStart < 8 * 60) rangeEnd = Math.min(24 * 60, rangeStart + 8 * 60); + } + + const hours: number[] = []; + for (let h = Math.ceil(rangeStart / 60); h <= Math.floor(rangeEnd / 60); h++) hours.push(h); + const totalHeight = (rangeEnd - rangeStart) * PX_PER_MIN; + + const driveMap = new Map(driveSegments.map((s) => [s.fromId, s.text])); return ( -
-
- {time ? ( - {time} - ) : ( - - )} -
-
-
-

{event.title}

- {isAdmin && ( - - )} + + {h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`} + +
+
+ ))} + + {/* Event blocks */} + {timed.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; + const height = Math.max(36, (endMin - startMin) * PX_PER_MIN); + const isConflict = conflicts.has(e.event_id); + const drive = driveMap.get(e.event_id); + + return ( +
+
+
+
+

{e.title}

+ {height >= 44 && ( +

+ {fmtTime(e.start_time)} + {e.end_time ? ` – ${fmtTime(e.end_time)}` : ""} + {isConflict ? " ⚠ conflict" : ""} +

+ )} + {height >= 60 && !e.location_inherited && e.location && ( +

{e.location}

+ )} + {height >= 60 && e.notes && ( +

{e.notes}

+ )} +
+
+ {e.maps_link && ( + ev.stopPropagation()} + className="text-indigo-400 hover:text-indigo-300 text-xs opacity-0 group-hover:opacity-100 transition-opacity" + > + Maps + + )} + {isAdmin && ( + + )} +
+
+
+ {/* Drive time badge below this event, if present */} + {drive && ( +
+ {drive} drive +
+ )} +
+ ); + })}
- {!event.location_inherited && ( - + ); +} + +// --------------------------------------------------------------------------- +// AI Assistant panel +// --------------------------------------------------------------------------- + +interface ChatMessage { + id: string; + role: "user" | "assistant"; + content: string; + suggestions?: SuggestionCard[]; +} + +interface SuggestionCard { + title: string; + date?: string; + start_time?: string; + end_time?: string; + location?: string; + maps_link?: string; + notes?: string; + dismissed?: boolean; + added?: boolean; +} + +function AssistantPanel({ + trip, + onAddEvent, +}: { + trip: TripRecord & { events: TripEvent[] }; + onAddEvent: (event: TripEvent) => void; +}) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + async function send() { + const text = input.trim(); + if (!text || loading) return; + setInput(""); + + const userMsg: ChatMessage = { id: crypto.randomUUID(), role: "user", content: text }; + setMessages((prev) => [...prev, userMsg]); + setLoading(true); + + const history = messages.map((m) => ({ role: m.role, content: m.content })); + + try { + const res = await c2api.tripChat(trip.trip_id, text, history); + const assistantMsg: ChatMessage = { + id: crypto.randomUUID(), + role: "assistant", + content: res.reply, + suggestions: (res.suggestions as unknown as SuggestionCard[]) ?? [], + }; + setMessages((prev) => [...prev, assistantMsg]); + } catch { + setMessages((prev) => [ + ...prev, + { id: crypto.randomUUID(), role: "assistant", content: "Something went wrong. Try again." }, + ]); + } finally { + setLoading(false); + } + } + + async function approveSuggestion(msgId: string, idx: number, s: SuggestionCard) { + try { + const event = await c2api.createTripEvent(trip.trip_id, { + title: s.title, + date: s.date ?? trip.start_date, + start_time: s.start_time ?? null, + end_time: s.end_time ?? null, + location: s.location ?? null, + maps_link: s.maps_link ?? null, + notes: s.notes ?? null, + }); + onAddEvent(event); + setMessages((prev) => + prev.map((m) => + m.id !== msgId ? m : + { ...m, suggestions: m.suggestions?.map((sg, i) => i === idx ? { ...sg, added: true } : sg) } + ) + ); + } catch { + // fail silently — user can try again + } + } + + function dismissSuggestion(msgId: string, idx: number) { + setMessages((prev) => + prev.map((m) => + m.id !== msgId ? m : + { ...m, suggestions: m.suggestions?.map((sg, i) => i === idx ? { ...sg, dismissed: true } : sg) } + ) + ); + } + + return ( +
+ {/* Header */} +
+

Trip Assistant

+

+ Tell me what you want to do — I can search places and suggest events. +

+
+ + {/* Messages */} +
+ {messages.length === 0 && ( +
+ {[ + `Find dinner spots near ${trip.location}`, + "Plan a full day for us on the first day", + "Everyone needs to be at the hotel by 6 PM, what should we do before?", + "Suggest some bars or nightlife for the last night", + ].map((prompt) => ( + + ))}
)} - {event.location_inherited && event.maps_link && ( - ( +
+
+ {msg.content} +
+ + {/* Suggestion cards */} + {msg.suggestions?.map((s, idx) => { + if (s.dismissed) return null; + return ( +
+

{s.title}

+
+ {s.date && ( +

+ {fmtDate(s.date)} + {s.start_time && ` · ${fmtTime(s.start_time)}`} + {s.end_time && ` – ${fmtTime(s.end_time)}`} +

+ )} + {s.location &&

{s.location}

} + {s.notes &&

{s.notes}

} +
+ {s.maps_link && ( +
+ View on Maps + + )} + {!s.added ? ( +
+ + +
+ ) : ( +

Added to itinerary

+ )} +
+ ); + })} +
+ ))} + + {loading && ( +
+
+ Thinking… +
+
+ )} +
+
+ + {/* Input */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && (e.preventDefault(), send())} + placeholder="What do you want to do?" + disabled={loading} + className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50" + /> + +
); } // --------------------------------------------------------------------------- -// Page +// Main page // --------------------------------------------------------------------------- +type FullTrip = TripRecord & { events: TripEvent[] }; + export default function TripDetailPage() { - const { id } = useParams<{ id: string }>(); - const router = useRouter(); - const { isAdmin } = useAuth(); + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const { isAdmin } = useAuth(); - const [trip, setTrip] = useState<(TripRecord & { events: TripEvent[] }) | null>(null); - const [loading, setLoading] = useState(true); - const [showAdd, setShowAdd] = useState(false); + const [trip, setTrip] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedDay, setSelectedDay] = useState(""); + const [showAdd, setShowAdd] = useState(false); + const [driveTimes, setDriveTimes] = useState>({}); - async function load() { + const load = useCallback(async () => { try { const data = await c2api.getTrip(id); - setTrip(data as TripRecord & { events: TripEvent[] }); + setTrip(data as FullTrip); + if (!selectedDay) { + setSelectedDay(data.start_date); + } } catch (e) { console.error(e); } finally { setLoading(false); } - } + }, [id, selectedDay]); useEffect(() => { load(); }, [id]); + // Fetch drive times for a given day + useEffect(() => { + if (!trip || !selectedDay || driveTimes[selectedDay]) return; + + const dayEvents = trip.events + .filter((e) => e.date === selectedDay && e.start_time && !e.location_inherited && e.location) + .sort((a, b) => toMin(a.start_time!) - toMin(b.start_time!)); + + if (dayEvents.length < 2) return; + + (async () => { + const segments: { fromId: string; toId: string; text: string }[] = []; + for (let i = 0; i < dayEvents.length - 1; i++) { + try { + const r = await c2api.getDirections(dayEvents[i].location, dayEvents[i + 1].location); + if (r.duration_text) { + segments.push({ fromId: dayEvents[i].event_id, toId: dayEvents[i + 1].event_id, text: r.duration_text }); + } + } catch { /* skip on error */ } + } + if (segments.length > 0) { + setDriveTimes((prev) => ({ ...prev, [selectedDay]: segments })); + } + })(); + }, [trip, selectedDay]); + async function handleDeleteTrip() { if (!trip) return; - try { - await c2api.deleteTrip(trip.trip_id); - router.push("/trips"); - } catch (e) { console.error(e); } + try { await c2api.deleteTrip(trip.trip_id); router.push("/trips"); } + catch (e) { console.error(e); } } async function handleDeleteEvent(eventId: string) { @@ -279,123 +779,152 @@ export default function TripDetailPage() { const event = await c2api.createTripEvent(trip.trip_id, body); setTrip((prev) => { if (!prev) return prev; - const events = [...prev.events, event].sort((a, b) => - a.date.localeCompare(b.date) || (a.time ?? "").localeCompare(b.time ?? "") + const events = [...prev.events, event].sort( + (a, b) => a.date.localeCompare(b.date) || (a.start_time ?? "").localeCompare(b.start_time ?? "") + ); + return { ...prev, events }; + }); + // Switch to the event's day + if ((body as { date?: string }).date) { + setSelectedDay((body as { date: string }).date); + } + } + + function handleAssistantAddEvent(event: TripEvent) { + setTrip((prev) => { + if (!prev) return prev; + const events = [...prev.events, event].sort( + (a, b) => a.date.localeCompare(b.date) || (a.start_time ?? "").localeCompare(b.start_time ?? "") ); return { ...prev, events }; }); } - if (loading) { - return

Loading…

; - } + if (loading) return

Loading…

; + if (!trip) return

Trip not found.

; - if (!trip) { - return

Trip not found.

; - } - - const attendees = Object.values(trip.attendees ?? {}); - const eventsByDate: Record = {}; - for (const e of trip.events ?? []) { - (eventsByDate[e.date] ??= []).push(e); - } - const days = dateRange(trip.start_date, trip.end_date).filter((d) => eventsByDate[d]); + const days = dateRange(trip.start_date, trip.end_date); + 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; return ( -
- {/* Back */} - +
+ {/* Back + header */} +
+ - {/* Header */} -
-
-

{trip.name}

- {isAdmin && ( - - )} -
- -
- {trip.location} - {trip.maps_link && ( - - View on Maps - - )} - - - {fmtDate(trip.start_date)} — {fmtDate(trip.end_date)} - -
- - {attendees.length > 0 && ( -

- Going: {attendees.join(", ")} -

- )} -
- - {/* Itinerary */} -
-
-

Itinerary

- {isAdmin && ( - - )} -
- - {days.length === 0 ? ( -

- No events yet.{isAdmin ? " Use \"Add Event\" to build the itinerary." : ""} -

- ) : ( -
- {days.map((day) => ( -
-

- {fmtDayLabel(day)} -

-
- {(eventsByDate[day] ?? []).map((event) => ( - - ))} -
-
- ))} +
+
+

{trip.name}

+
+ {trip.location} + {trip.maps_link && ( + + Maps + + )} + + {fmtDate(trip.start_date)} — {fmtDate(trip.end_date)} + {attendees.length > 0 && ( + <> + + {attendees.join(", ")} + + )} +
- )} + +
+ {isAdmin && ( + <> + + + + )} +
+
- {showAdd && trip && ( + {/* Two-column layout */} +
+ {/* Left: Itinerary */} +
+ {/* Day tabs */} +
+ {days.map((day) => { + const { short, date } = fmtDayTab(day); + const count = trip.events.filter((e) => e.date === day).length; + const conflict = hasConflict(day); + return ( + + ); + })} +
+ + {/* Selected day */} +
+

+ {fmtDayHeading(selectedDay)} +

+ +
+
+ + {/* Right: AI Assistant — sticky */} +
+ +
+
+ + {/* Add event modal */} + {showAdd && ( setShowAdd(false)} onAdd={handleAddEvent} + prefill={selectedDay ? { date: selectedDay } : undefined} /> )}
diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index 8d822b8..d675c08 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -146,6 +146,21 @@ export const c2api = { request(`/trips/${tripId}/events`, { method: "POST", body: JSON.stringify(body) }), deleteTripEvent: (tripId: string, eventId: string) => request(`/trips/${tripId}/events/${eventId}`, { method: "DELETE" }), + tripChat: (tripId: string, message: string, history: { role: string; content: string }[]) => + request<{ reply: string; suggestions: import("@/lib/types").TripEvent[] }>( + `/trips/${tripId}/chat`, + { method: "POST", body: JSON.stringify({ message, history }) } + ), + + // Places + searchPlaces: (query: string, near: string) => + request( + `/places/search?${new URLSearchParams({ query, near }).toString()}` + ), + getDirections: (origin: string, destination: string) => + request<{ duration_text: string | null; duration_seconds: number | null; distance_text: string | null }>( + `/places/directions?${new URLSearchParams({ origin, destination }).toString()}` + ), // Per-system AI flag overrides setSystemAiFlags: (systemId: string, flags: { stt_enabled?: boolean | null; correlation_enabled?: boolean | null }) => diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 0058002..5f5bfed 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -103,15 +103,27 @@ export interface TripEvent { trip_id: string; title: string; date: string; - time: string | null; + start_time: string | null; + end_time: string | null; location: string; location_inherited: boolean; maps_link: string | null; + place_id: string | null; notes: string | null; attendees: Record; created_at: string; } +export interface PlaceResult { + name: string; + address: string; + place_id: string; + lat: number; + lng: number; + maps_link: string; + rating?: number; +} + export interface TripRecord { trip_id: string; name: string; diff --git a/drb-server-discord-bot/app/commands/trips.py b/drb-server-discord-bot/app/commands/trips.py index 6b24782..169bf5a 100644 --- a/drb-server-discord-bot/app/commands/trips.py +++ b/drb-server-discord-bot/app/commands/trips.py @@ -316,7 +316,8 @@ class TripCommands(commands.Cog): trip="The trip to add this event to.", title="Event title", date="Date of the event (YYYY-MM-DD or MM/DD/YYYY)", - time="Time of the event (e.g. 14:00 or 2:00 PM) — optional", + start_time="Start time (e.g. 14:00 or 2:00 PM) — optional", + end_time="End time (e.g. 16:00 or 4:00 PM) — optional", location="Location override (optional, inherits trip location if omitted)", maps_link="Google Maps link for this event (optional)", notes="Any additional notes (optional)", @@ -328,7 +329,8 @@ class TripCommands(commands.Cog): trip: str, title: str, date: str, - time: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, location: Optional[str] = None, maps_link: Optional[str] = None, notes: Optional[str] = None, @@ -340,17 +342,21 @@ class TripCommands(commands.Cog): await interaction.followup.send("Invalid date format. Use YYYY-MM-DD.") return - parsed_time = _parse_time(time) if time else None - if time and parsed_time is None: - await interaction.followup.send( - "Couldn't parse that time. Try `14:00` or `2:00 PM`." - ) + parsed_start = _parse_time(start_time) if start_time else None + parsed_end = _parse_time(end_time) if end_time else None + + if start_time and parsed_start is None: + await interaction.followup.send("Couldn't parse start time. Try `14:00` or `2:00 PM`.") + return + if end_time and parsed_end is None: + await interaction.followup.send("Couldn't parse end time. Try `16:00` or `4:00 PM`.") return event = await c2.create_trip_event(trip, { "title": title, "date": parsed_date.strftime("%Y-%m-%d"), - "time": parsed_time, + "start_time": parsed_start, + "end_time": parsed_end, "location": location, "maps_link": maps_link, "notes": notes, @@ -362,7 +368,7 @@ class TripCommands(commands.Cog): ) return - time_display = f" at {_fmt_time(parsed_time)}" if parsed_time else "" + time_display = f" at {_fmt_time(parsed_start)}" if parsed_start else "" await interaction.followup.send( f"Added **{title}**{time_display} on {_fmt_date(parsed_date.strftime('%Y-%m-%d'))}." )