Files
server-26/drb-frontend/app/trips/[id]/page.tsx
T
Logan CusanoandClaude Opus 5.5 f16f0a9f93 fix(frontend): readable API errors and no more silent action failures (#284, #286)
#286: request() now throws C2ApiError — message is FastAPI's `detail` or a
plain sentence for the status (401/403/404/429/5xx), with toString() so the
many String(e) call sites read cleanly. status/body stay on the error; the
Discord parsers rebuild the legacy "C2 API error N: body" text via
legacyErrorText(), so their invite/code parsing is unchanged.

#284: Resolve, Regenerate summary, alert-rule toggle/delete/load,
Acknowledge, trip deletes, system delete and Restart OP25 now surface
failures (ErrorBanner where the page has one, alert() where the page
already uses it). Rules tab no longer says "No alert rules configured"
when the load failed. Confirms added for Resolve (no reopen path exists),
rule delete and trip delete. Reopen deliberately not built — manual
resolve doesn't set resolved_at, so reopening is a lifecycle change.

Frontend unverified: no Node on the authoring box (#207).

Closes #286

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 21:38:13 -04:00

1286 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/components/AuthProvider";
import { c2api } from "@/lib/c2api";
import { errorMessage } from "@/lib/c2error";
import type { TripEvent, TripRecord, PlaceResult } from "@/lib/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function uid(): string {
try { return crypto.randomUUID(); } catch { return Math.random().toString(36).slice(2) + Date.now().toString(36); }
}
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 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: "long", day: "numeric",
});
}
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);
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
}
function dateRange(start: string, end: string): string[] {
const dates: string[] = [];
const cur = new Date(`${start}T12:00:00`);
const endDate = new Date(`${end}T12:00:00`);
while (cur <= endDate) {
dates.push(cur.toISOString().slice(0, 10));
cur.setDate(cur.getDate() + 1);
}
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 (
<span className={`inline-block text-[10px] font-medium rounded-full px-2 py-0.5 border ${tagColor(tag, availableTags)}`}>
{tag}
</span>
);
}
function detectConflicts(events: TripEvent[], overlapTags: string[] = []): Set<string> {
const timed = events.filter((e) => e.start_time);
const conflicts = new Set<string>();
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!);
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<PlaceResult[]>([]);
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 (
<div className="space-y-2">
<div className="flex gap-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), search())}
placeholder="Search a place…"
className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
<button
type="button"
onClick={search}
disabled={loading}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 text-white text-sm rounded-lg px-3 py-2 transition-colors"
>
{loading ? "…" : "Search"}
</button>
</div>
{results.length > 0 && (
<div className="bg-raised border border-line-strong rounded-lg overflow-hidden divide-y divide-line-strong">
{results.map((p) => (
<button
key={p.place_id}
type="button"
onClick={() => { onSelect(p); setResults([]); setQuery(""); }}
className="w-full text-left px-3 py-2 hover:bg-gray-700 transition-colors"
>
<p className="text-white text-sm font-medium">{p.name}</p>
<p className="text-ink-2 text-xs">{p.address}</p>
{p.rating && (
<p className="text-yellow-400 text-xs">★ {p.rating}</p>
)}
</button>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Add Event modal
// ---------------------------------------------------------------------------
function AddEventModal({
trip,
onClose,
onAdd,
prefill,
editEventId,
}: {
trip: TripRecord;
onClose: () => void;
onAdd: (body: object) => Promise<void>;
prefill?: Partial<TripEvent>;
editEventId?: string;
}) {
const isEdit = !!editEventId;
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 ?? "");
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 [tags, setTags] = useState<string[]>(prefill?.tags ?? []);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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();
setSaving(true);
setError(null);
try {
await onAdd({
title,
date,
start_time: startTime || null,
end_time: endTime || null,
location: location || null,
maps_link: mapsLink || null,
place_id: placeId || null,
notes: notes || null,
tags,
});
onClose();
} catch {
setError(isEdit ? "Failed to save changes." : "Failed to add event. Check the date is within the trip range.");
} finally {
setSaving(false);
}
}
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<form
onSubmit={handleSubmit}
className="bg-surface border border-line-strong rounded-xl p-6 w-full max-w-lg space-y-4 max-h-[90vh] overflow-y-auto"
>
<h2 className="text-white font-bold text-lg">{isEdit ? "Edit Event" : "Add Event"}</h2>
<div>
<label className="text-xs text-ink-2 block mb-1">Title</label>
<input
required value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="Dinner at the bar"
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-ink-2 block mb-1">Date</label>
<input
required type="date"
min={trip.start_date} max={trip.end_date}
value={date} onChange={(e) => setDate(e.target.value)}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-ink-2 block mb-1">Start</label>
<input
type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
<div>
<label className="text-xs text-ink-2 block mb-1">End</label>
<input
type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)}
min={startTime}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
<div>
<label className="text-xs text-ink-2 block mb-1">Find a place</label>
<PlaceSearch near={trip.location} onSelect={handlePlaceSelect} />
</div>
<div>
<label className="text-xs text-ink-2 block mb-1">
Location{" "}
<span className="text-ink-muted">(auto-filled from search, or type manually)</span>
</label>
<input
value={location} onChange={(e) => setLocation(e.target.value)}
placeholder={`Inherits: ${trip.location}`}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500"
/>
</div>
{mapsLink && (
<p className="text-xs text-indigo-400">
Maps link attached —{" "}
<a href={mapsLink} target="_blank" rel="noopener noreferrer" className="underline">
preview
</a>
</p>
)}
<div>
<label className="text-xs text-ink-2 block mb-1">Notes (optional)</label>
<textarea
value={notes} onChange={(e) => setNotes(e.target.value)} rows={2}
className="w-full bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none resize-none"
/>
</div>
{availableTags.length > 0 && (
<div>
<label className="text-xs text-ink-2 block mb-1">Tags</label>
<div className="flex flex-wrap gap-1.5">
{availableTags.map((tag) => {
const active = tags.includes(tag);
return (
<button
key={tag}
type="button"
onClick={() => setTags((prev) => active ? prev.filter((t) => t !== tag) : [...prev, tag])}
className={`text-xs rounded-full px-2.5 py-1 border transition-colors ${
active ? tagColor(tag, availableTags) : "bg-raised text-ink-muted border-line-strong hover:border-line-strong hover:text-ink-2"
}`}
>
{tag}
</button>
);
})}
</div>
</div>
)}
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-3 justify-end pt-1">
<button type="button" onClick={onClose} className="text-sm text-ink-2 hover:text-ink px-4 py-2">
Cancel
</button>
<button
type="submit" disabled={saving}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white text-sm rounded-lg px-4 py-2"
>
{saving ? (isEdit ? "Saving…" : "Adding…") : (isEdit ? "Save Changes" : "Add Event")}
</button>
</div>
</form>
</div>
);
}
// ---------------------------------------------------------------------------
// Day timeline
// ---------------------------------------------------------------------------
const PX_PER_MIN = 1.3; // 78px per hour
function DayTimeline({
events,
isAdmin,
onDelete,
onEdit,
driveSegments,
availableTags,
overlapTags,
}: {
events: TripEvent[];
isAdmin: boolean;
onDelete: (id: string) => void;
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 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 (
<p className="text-ink-muted text-sm font-mono py-8 text-center">
No events on this day yet.
</p>
);
}
// 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 (
<div className="space-y-6">
{/* Timed events — visual timeline */}
{timed.length > 0 && (
<div className="relative select-none" style={{ height: totalHeight }}>
{/* Hour gridlines */}
{hours.map((h) => (
<div
key={h}
style={{ top: (h * 60 - rangeStart) * PX_PER_MIN }}
className="absolute inset-x-0 flex items-center pointer-events-none"
>
<span className="w-14 text-right pr-3 text-xs text-ink-muted font-mono shrink-0 -translate-y-2">
{h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`}
</span>
<div className="flex-1 border-t border-line/70" />
</div>
))}
{/* 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 (
<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-raised/30 border-l-2 border-line-strong/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-line-strong/40" />
<span className="text-ink-muted text-[10px] font-mono shrink-0 pr-1">
{fmtTime(e.start_time)}
</span>
<span className="text-ink-muted 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-ink-muted hover:text-indigo-400 text-[10px]">Edit</button>
<button onClick={() => onDelete(e.event_id)} className="text-ink-muted 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 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 (
<div key={e.event_id}>
<div
style={{ top, height, left: 60, right: 0 }}
className={`absolute rounded-lg px-3 py-2 overflow-hidden group transition-colors z-20 ${
isConflict
? "bg-red-950/70 border border-red-700/70"
: "bg-indigo-950/60 border border-indigo-800/50 hover:border-indigo-600/70"
}`}
>
<div className="flex items-start justify-between gap-2 h-full">
<div className="min-w-0 flex-1">
<p className="text-white text-xs font-semibold leading-tight truncate">{e.title}</p>
{height >= 44 && (
<p className={`text-xs font-mono mt-0.5 ${isConflict ? "text-red-400" : "text-indigo-400"}`}>
{fmtTime(e.start_time)}
{e.end_time ? ` – ${fmtTime(e.end_time)}` : ""}
{isConflict ? " ⚠ conflict" : ""}
</p>
)}
{height >= 60 && !e.location_inherited && e.location && (
<p className="text-ink-muted text-xs mt-0.5 truncate">{e.location}</p>
)}
{height >= 60 && e.notes && (
<p className="text-ink-muted text-xs mt-0.5 italic truncate">{e.notes}</p>
)}
{e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{e.maps_link && (
<a
href={e.maps_link}
target="_blank"
rel="noopener noreferrer"
onClick={(ev) => ev.stopPropagation()}
className="text-indigo-400 hover:text-indigo-300 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
Maps
</a>
)}
{isAdmin && (
<>
<button
onClick={() => onEdit(e)}
className="text-ink-muted hover:text-indigo-400 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
>
Edit
</button>
<button
onClick={() => onDelete(e.event_id)}
className="text-ink-muted hover:text-red-400 text-base leading-none opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
</>
)}
</div>
</div>
</div>
{drive && (
<div
style={{ top: top + height + 2, left: 60 }}
className="absolute text-xs text-ink-muted font-mono flex items-center gap-1 z-20"
>
<span className="text-ink-muted">↓</span> {drive} drive
</div>
)}
</div>
);
})}
</div>
)}
{/* Untimed / all-day events */}
{untimed.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-mono text-ink-muted uppercase tracking-wider">Unscheduled</p>
{untimed.map((e) => (
<div
key={e.event_id}
className="bg-surface border border-line rounded-lg px-4 py-2.5 flex items-center justify-between group"
>
<div className="min-w-0">
<p className="text-white text-sm">{e.title}</p>
{!e.location_inherited && e.location && (
<p className="text-ink-muted text-xs mt-0.5">{e.location}</p>
)}
{e.notes && <p className="text-ink-muted text-xs italic mt-0.5">{e.notes}</p>}
{e.tags?.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{e.tags.map((t) => <TagPill key={t} tag={t} availableTags={availableTags} />)}
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{e.maps_link && (
<a href={e.maps_link} target="_blank" rel="noopener noreferrer"
className="text-xs text-indigo-400 hover:text-indigo-300 opacity-0 group-hover:opacity-100 transition-opacity">
Maps
</a>
)}
{isAdmin && (
<>
<button onClick={() => onEdit(e)}
className="text-xs text-ink-muted hover:text-indigo-400 opacity-0 group-hover:opacity-100 transition-opacity">
Edit
</button>
<button onClick={() => onDelete(e.event_id)}
className="text-ink-muted hover:text-red-400 text-sm opacity-0 group-hover:opacity-100 transition-opacity">
×
</button>
</>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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;
tags?: string[];
dismissed?: boolean;
added?: boolean;
}
const CHAT_STORAGE_KEY = (tripId: string) => `drb-trip-chat-${tripId}`;
function AssistantPanel({
trip,
onAddEvent,
}: {
trip: TripRecord & { events: TripEvent[] };
onAddEvent: (event: TripEvent) => void;
}) {
const storageKey = CHAT_STORAGE_KEY(trip.trip_id);
const [messages, setMessages] = useState<ChatMessage[]>(() => {
try {
const saved = localStorage.getItem(storageKey);
return saved ? JSON.parse(saved) : [];
} catch { return []; }
});
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
useEffect(() => {
try { localStorage.setItem(storageKey, JSON.stringify(messages)); } catch { /* quota */ }
}, [messages, storageKey]);
async function send() {
const text = input.trim();
if (!text || loading) return;
setInput("");
const userMsg: ChatMessage = { id: uid(), 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: uid(),
role: "assistant",
content: res.reply,
suggestions: (res.suggestions as unknown as SuggestionCard[]) ?? [],
};
setMessages((prev) => [...prev, assistantMsg]);
} catch {
setMessages((prev) => [
...prev,
{ id: uid(), role: "assistant", content: "Something went wrong. Try again." },
]);
} finally {
setLoading(false);
inputRef.current?.focus();
}
}
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,
tags: s.tags ?? [],
});
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) }
)
);
}
function clearChat() {
setMessages([]);
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
}
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-3 border-b border-line shrink-0 flex items-start justify-between gap-2">
<div>
<p className="text-white text-sm font-semibold">Trip Assistant</p>
<p className="text-ink-muted text-xs mt-0.5">
Tell me what you want to do — I can search places and suggest events.
</p>
</div>
{messages.length > 0 && (
<button
onClick={clearChat}
className="text-xs text-ink-muted hover:text-ink-2 transition-colors shrink-0 mt-0.5"
>
Clear
</button>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{messages.length === 0 && (
<div className="space-y-2">
{[
`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) => (
<button
key={prompt}
onClick={() => { setInput(prompt); }}
className="w-full text-left bg-raised/60 hover:bg-raised border border-line-strong/50 rounded-lg px-3 py-2 text-ink-2 text-xs transition-colors"
>
{prompt}
</button>
))}
</div>
)}
{messages.map((msg) => (
<div key={msg.id} className={`flex flex-col ${msg.role === "user" ? "items-end" : "items-start"} gap-2`}>
<div
className={`rounded-xl px-3 py-2 text-sm max-w-[90%] ${
msg.role === "user"
? "bg-indigo-600 text-white"
: "bg-raised text-ink"
}`}
>
{msg.role === "user" ? msg.content : (
<ReactMarkdown
components={{
p: ({ children }) => <p className="mb-1 last:mb-0">{children}</p>,
ul: ({ children }) => <ul className="list-disc list-inside space-y-0.5 my-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside space-y-0.5 my-1">{children}</ol>,
li: ({ children }) => <li>{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-white">{children}</strong>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:text-indigo-300 underline">
{children}
</a>
),
}}
>
{msg.content}
</ReactMarkdown>
)}
</div>
{/* Suggestion cards */}
{msg.suggestions?.map((s, idx) => {
if (s.dismissed) return null;
return (
<div
key={idx}
className={`w-full border rounded-xl p-3 space-y-1.5 transition-all ${
s.added
? "border-green-800/50 bg-green-950/30"
: "border-line-strong bg-surface"
}`}
>
<p className="text-white text-sm font-semibold">{s.title}</p>
<div className="text-xs text-ink-2 space-y-0.5">
{s.date && (
<p>
{fmtDate(s.date)}
{s.start_time && ` · ${fmtTime(s.start_time)}`}
{s.end_time && ` – ${fmtTime(s.end_time)}`}
</p>
)}
{s.location && <p className="truncate">{s.location}</p>}
{s.notes && <p className="text-ink-muted italic">{s.notes}</p>}
{s.tags && s.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-0.5">
{s.tags.map((t) => <TagPill key={t} tag={t} availableTags={trip.available_tags ?? []} />)}
</div>
)}
</div>
{s.maps_link && (
<a href={s.maps_link} target="_blank" rel="noopener noreferrer"
className="text-xs text-indigo-400 hover:text-indigo-300">
View on Maps
</a>
)}
{!s.added ? (
<div className="flex gap-2 pt-1">
<button
onClick={() => approveSuggestion(msg.id, idx, s)}
className="flex-1 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded-lg py-1.5 transition-colors"
>
Add to Itinerary
</button>
<button
onClick={() => dismissSuggestion(msg.id, idx)}
className="px-3 text-ink-muted hover:text-ink text-xs transition-colors"
>
Skip
</button>
</div>
) : (
<p className="text-green-400 text-xs pt-1">Added to itinerary</p>
)}
</div>
);
})}
</div>
))}
{loading && (
<div className="flex items-start">
<div className="bg-raised rounded-xl px-3 py-2 text-ink-2 text-sm">
<span className="animate-pulse">Thinking…</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{/* Input */}
<div className="px-3 py-3 border-t border-line shrink-0">
<div className="flex gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => { setInput(e.target.value); e.target.style.height = "auto"; e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`; }}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && (e.preventDefault(), send())}
placeholder="What do you want to do?"
disabled={loading}
rows={1}
className="flex-1 bg-raised border border-line-strong rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-indigo-500 disabled:opacity-50 resize-none overflow-hidden"
/>
<button
onClick={send}
disabled={loading || !input.trim()}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-40 text-white text-sm rounded-lg px-4 py-2 transition-colors shrink-0"
>
Send
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
type FullTrip = TripRecord & { events: TripEvent[] };
export default function TripDetailPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const { isAdmin, user } = useAuth();
const [trip, setTrip] = useState<FullTrip | null>(null);
const [loading, setLoading] = useState(true);
const [selectedDay, setSelectedDay] = useState<string>("");
const [showAdd, setShowAdd] = useState(false);
const [editEvent, setEditEvent] = useState<TripEvent | null>(null);
const [driveTimes, setDriveTimes] = useState<Record<string, { fromId: string; toId: string; text: string }[]>>({});
const [tagInput, setTagInput] = useState("");
const [inviteInput, setInviteInput] = useState("");
const load = useCallback(async () => {
try {
const data = await c2api.getTrip(id);
setTrip(data as FullTrip);
if (!selectedDay) {
setSelectedDay(data.start_date);
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}, [id, selectedDay]);
useEffect(() => { if (user) load(); }, [id, user]);
// 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) { alert(`Couldn't delete the trip: ${errorMessage(e)}`); }
}
async function handleDeleteEvent(eventId: string) {
if (!trip) return;
try {
await c2api.deleteTripEvent(trip.trip_id, eventId);
setTrip((prev) => prev ? { ...prev, events: prev.events.filter((e) => e.event_id !== eventId) } : prev);
} catch (e) { alert(`Couldn't delete the event: ${errorMessage(e)}`); }
}
async function handleUpdateEvent(body: object) {
if (!trip || !editEvent) return;
const updated = await c2api.updateTripEvent(trip.trip_id, editEvent.event_id, body);
setTrip((prev) => {
if (!prev) return prev;
const events = prev.events.map((e) => e.event_id === updated.event_id ? updated : e)
.sort((a, b) => a.date.localeCompare(b.date) || (a.start_time ?? "").localeCompare(b.start_time ?? ""));
return { ...prev, events };
});
setEditEvent(null);
}
async function handleAddEvent(body: object) {
if (!trip) return;
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.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 };
});
// 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 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 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 handleToggleVisibility() {
if (!trip) return;
const next = trip.visibility === "private" ? "public" : "private";
await c2api.setTripVisibility(trip.trip_id, next);
setTrip((prev) => prev ? { ...prev, visibility: next } : prev);
}
async function handleInvite() {
const discordId = inviteInput.trim();
if (!trip || !discordId) return;
if ((trip.invited_discord_ids ?? []).includes(discordId)) { setInviteInput(""); return; }
await c2api.inviteToTrip(trip.trip_id, discordId);
setTrip((prev) => prev ? { ...prev, invited_discord_ids: [...(prev.invited_discord_ids ?? []), discordId] } : prev);
setInviteInput("");
}
async function handleRevokeInvite(discordId: string) {
if (!trip) return;
await c2api.revokeInvite(trip.trip_id, discordId);
setTrip((prev) => prev ? { ...prev, invited_discord_ids: (prev.invited_discord_ids ?? []).filter((id) => id !== discordId) } : 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-ink-muted text-sm font-mono">Loading…</p>;
if (!trip) return <p className="text-ink-muted text-sm font-mono">Trip not found.</p>;
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), trip.overlap_tags ?? []).size > 0;
return (
<div className="space-y-6">
{/* Back + header */}
<div className="space-y-4">
<button
onClick={() => router.push("/trips")}
className="text-sm font-mono text-ink-muted hover:text-ink transition-colors"
>
← Trips
</button>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<h1 className="text-white text-2xl font-bold">{trip.name}</h1>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-2">
<span>{trip.location}</span>
{trip.maps_link && (
<a href={trip.maps_link} target="_blank" rel="noopener noreferrer"
className="text-indigo-400 hover:text-indigo-300 text-xs transition-colors">
Maps
</a>
)}
<span className="text-ink-muted">•</span>
<span className="font-mono text-xs">{fmtDate(trip.start_date)} — {fmtDate(trip.end_date)}</span>
{attendees.length > 0 && (
<>
<span className="text-ink-muted">•</span>
<span className="text-xs">{attendees.join(", ")}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Visibility badge */}
{trip.visibility === "private" && (
<span className="text-xs font-mono text-amber-500 border border-amber-800/50 rounded-full px-2 py-0.5">
🔒 private
</span>
)}
{isAdmin && (
<>
<button
onClick={() => setShowAdd(true)}
className="bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-lg px-4 py-2 transition-colors"
>
+ Add Event
</button>
<button
onClick={handleToggleVisibility}
className="text-xs text-ink-muted hover:text-ink transition-colors border border-line-strong rounded-lg px-3 py-2"
>
{trip.visibility === "private" ? "Make public" : "Make private"}
</button>
<button
onClick={handleDeleteTrip}
className="text-xs text-red-500 hover:text-red-400 transition-colors"
>
Delete trip
</button>
</>
)}
</div>
</div>
{/* Tag manager */}
{(isAdmin || (trip.available_tags ?? []).length > 0) && (
<div className="flex flex-wrap items-center gap-2">
{(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 ?? [])}`}>
{tag}
{isAdmin && (
<>
<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>
);
})}
{isAdmin && (
<div className="flex items-center gap-1">
<input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleAddTag())}
placeholder="Add tag…"
className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-24"
/>
<button onClick={handleAddTag} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
</div>
)}
</div>
)}
{/* Invite management — admin only, only when private */}
{isAdmin && trip.visibility === "private" && (
<div className="space-y-2">
<p className="text-xs text-ink-muted uppercase tracking-wider font-mono">Invited</p>
<div className="flex flex-wrap items-center gap-2">
{(trip.invited_discord_ids ?? []).length === 0 && (
<span className="text-xs text-ink-muted">No invites yet</span>
)}
{(trip.invited_discord_ids ?? []).map((discordId) => (
<span key={discordId} className="inline-flex items-center gap-1 text-xs bg-raised border border-line-strong rounded-full px-2.5 py-0.5 text-ink-2">
<span className="font-mono">{discordId}</span>
<button
onClick={() => handleRevokeInvite(discordId)}
className="opacity-60 hover:opacity-100 hover:text-red-400 transition-colors leading-none"
>
×
</button>
</span>
))}
<div className="flex items-center gap-1">
<input
value={inviteInput}
onChange={(e) => setInviteInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleInvite())}
placeholder="Discord user ID…"
className="bg-transparent border border-line-strong rounded-full px-2.5 py-0.5 text-xs text-ink-2 placeholder-ink-muted focus:outline-none focus:border-gray-500 w-36"
/>
<button onClick={handleInvite} className="text-xs text-ink-muted hover:text-ink transition-colors">+</button>
</div>
</div>
</div>
)}
</div>
{/* Two-column layout */}
<div className="flex gap-5 items-start">
{/* Left: Itinerary */}
<div className="flex-1 min-w-0 space-y-5">
{/* Day tabs */}
<div className="flex gap-1 flex-wrap">
{days.map((day) => {
const { short, date } = fmtDayTab(day);
const count = trip.events.filter((e) => e.date === day).length;
const conflict = hasConflict(day);
return (
<button
key={day}
onClick={() => setSelectedDay(day)}
className={`flex flex-col items-center px-3 py-2 rounded-xl text-xs transition-colors relative ${
day === selectedDay
? "bg-indigo-600 text-white"
: "bg-surface border border-line text-ink-2 hover:border-line-strong hover:text-ink"
}`}
>
<span className="font-mono font-bold">{short}</span>
<span className="font-mono">{date}</span>
{count > 0 && (
<span className={`text-xs mt-0.5 ${day === selectedDay ? "text-indigo-200" : "text-ink-muted"}`}>
{count}
</span>
)}
{conflict && (
<span className="absolute -top-1 -right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</button>
);
})}
</div>
{/* Selected day */}
<div className="space-y-4">
<p className="text-sm font-mono text-ink-2 uppercase tracking-wider">
{fmtDayHeading(selectedDay)}
</p>
<DayTimeline
events={dayEvents}
isAdmin={isAdmin}
onDelete={handleDeleteEvent}
onEdit={setEditEvent}
driveSegments={driveTimes[selectedDay] ?? []}
availableTags={trip.available_tags ?? []}
overlapTags={trip.overlap_tags ?? []}
/>
</div>
</div>
{/* Right: AI Assistant — sticky */}
<div className="w-80 xl:w-96 shrink-0 sticky top-20 h-[calc(100vh-240px)] flex flex-col bg-surface border border-line rounded-xl overflow-hidden">
<AssistantPanel trip={trip} onAddEvent={handleAssistantAddEvent} />
</div>
</div>
{/* Add event modal */}
{showAdd && (
<AddEventModal
trip={trip}
onClose={() => setShowAdd(false)}
onAdd={handleAddEvent}
prefill={selectedDay ? { date: selectedDay } : undefined}
/>
)}
{/* Edit event modal */}
{editEvent && (
<AddEventModal
trip={trip}
onClose={() => setEditEvent(null)}
onAdd={handleUpdateEvent}
prefill={editEvent}
editEventId={editEvent.event_id}
/>
)}
</div>
);
}