From c84492069aab22db66f32274dc633b3fecf21c4a Mon Sep 17 00:00:00 2001 From: William Lyon Date: Tue, 7 Jul 2026 13:45:02 -0600 Subject: [PATCH 1/2] redesign mobile plan view --- agent/tools/add_campground_to_trip.ts | 3 +- agent/tools/add_trail_to_trip.ts | 4 +- agent/tools/suggest_day_plan.ts | 4 + app/api/trips/[id]/route.ts | 21 +- app/plan/page.tsx | 40 +- components/chat/ChatPanel.tsx | 91 +++- components/plan/MapTripCanvas.tsx | 231 +++++--- components/plan/ParkSearchInput.tsx | 3 +- components/plan/PlanShell.tsx | 187 +++++++ components/plan/PlanTabBar.tsx | 94 ++++ components/plan/StopList.tsx | 187 +++++++ components/plan/TripActions.tsx | 89 ++++ components/plan/TripBuilder.tsx | 735 +------------------------- components/plan/TripHeader.tsx | 143 +++++ components/plan/TripInsights.tsx | 49 ++ components/plan/TripMap.tsx | 4 +- components/plan/TripSwitcher.tsx | 73 +++ components/plan/useTripBuilder.tsx | 705 ++++++++++++++++++++++++ lib/chat-trips.test.ts | 53 ++ lib/chat-trips.ts | 41 ++ lib/itinerary.test.ts | 16 +- lib/itinerary.ts | 11 + playwright.config.ts | 20 +- tests/e2e/dream-big-authed.spec.ts | 8 +- tests/e2e/dream-big-flagships.spec.ts | 14 +- tests/e2e/helpers/pane.ts | 20 + tests/e2e/plan-canvas.spec.ts | 11 +- tests/e2e/plan-hardening.spec.ts | 3 + tests/e2e/plan-mobile.spec.ts | 91 ++++ 29 files changed, 2103 insertions(+), 848 deletions(-) create mode 100644 components/plan/PlanShell.tsx create mode 100644 components/plan/PlanTabBar.tsx create mode 100644 components/plan/StopList.tsx create mode 100644 components/plan/TripActions.tsx create mode 100644 components/plan/TripHeader.tsx create mode 100644 components/plan/TripInsights.tsx create mode 100644 components/plan/TripSwitcher.tsx create mode 100644 components/plan/useTripBuilder.tsx create mode 100644 lib/chat-trips.test.ts create mode 100644 lib/chat-trips.ts create mode 100644 tests/e2e/helpers/pane.ts create mode 100644 tests/e2e/plan-mobile.spec.ts diff --git a/agent/tools/add_campground_to_trip.ts b/agent/tools/add_campground_to_trip.ts index 571178e..cd2b627 100644 --- a/agent/tools/add_campground_to_trip.ts +++ b/agent/tools/add_campground_to_trip.ts @@ -37,6 +37,7 @@ export default defineTool({ } const ok = await addLodgingToStop(userId, tripId, stopId, campgroundId, { date, nights }); if (!ok) return { kind: 'campground_card', data: { error: "I couldn't add that campground to your trip." } }; - return { kind: 'campground_card', data: { campground: cg, addedTo: { stopLabel, date } } }; + // `addedTo.tripId` marks a CONFIRMED trip write for the chat panel's trips-changed scanner (ADR-076). + return { kind: 'campground_card', data: { campground: cg, addedTo: { tripId, stopLabel, date } } }; }, }); diff --git a/agent/tools/add_trail_to_trip.ts b/agent/tools/add_trail_to_trip.ts index 67e273a..8353818 100644 --- a/agent/tools/add_trail_to_trip.ts +++ b/agent/tools/add_trail_to_trip.ts @@ -37,6 +37,8 @@ export default defineTool({ } const ok = await addTrailToStop(userId, tripId, stopId, trailId); if (!ok) return { kind: 'trail_detail_card', data: { error: "I couldn't add that hike to your trip." } }; - return { kind: 'trail_detail_card', data: { ...trail, addedTo: { stopLabel, day: stop.day } } }; + // `addedTo.tripId` marks a CONFIRMED trip write for the chat panel's trips-changed scanner (ADR-076) — + // previews (`pendingAdd`) deliberately never announce, since nothing was written. + return { kind: 'trail_detail_card', data: { ...trail, addedTo: { tripId, stopLabel, day: stop.day } } }; }, }); diff --git a/agent/tools/suggest_day_plan.ts b/agent/tools/suggest_day_plan.ts index a96cb1e..b13c5b8 100644 --- a/agent/tools/suggest_day_plan.ts +++ b/agent/tools/suggest_day_plan.ts @@ -32,6 +32,10 @@ export default defineTool({ // Include trip.id so the chat dedups all itinerary cards from one build to a single rendered // card (R5 §2.7) — build_itinerary/add_stop/fork all key by trip.id too. trip: { id: trip.id, name: trip.name, stops: stops.map((s) => ({ ...s, day: dayById.get(s.id) })) }, + // Read-only: the day plan is a suggestion in this card, NOT persisted to the trip. Marks the + // output so the plan panel's trips-changed scanner (lib/chat-trips.ts, ADR-076) doesn't treat it + // as a write — a spurious cross-pane refresh + "itinerary changed" tab flash. + readOnly: true, days: Math.max(0, ...assignments.map((a) => a.day)), }, }; diff --git a/app/api/trips/[id]/route.ts b/app/api/trips/[id]/route.ts index 42dc238..115a1fb 100644 --- a/app/api/trips/[id]/route.ts +++ b/app/api/trips/[id]/route.ts @@ -55,23 +55,30 @@ export async function DELETE(req: Request, { params }: Ctx) { return Response.json({ ok: true }); } +/** Ops that never touch ORS routing: pure reads over the trip + external NPS/weather lookups. They get + * their own roomier budget (ADR-076) — the plan shell's cross-pane refresh multiplies read traffic + * (every trip open auto-fires an `alerts` POST), and reads must never eat the edit budget. */ +const READ_OPS = new Set(['alerts', 'cost', 'conditions', 'suggestDays', 'diff']); + /** Sub-actions on a trip: addStop | removeStop | reorder | alerts. */ export async function POST(req: Request, { params }: Ctx) { const userId = await getUserId(req); if (!userId) return Response.json({ error: 'unauthorized' }, { status: 401 }); - // Cap trip mutations per user (audit C7): addStop/removeStop/reorder/optimize/fork each can fire an - // ORS routing call, so an unthrottled edit loop would burn the tight ORS free tier. - const rl = await rateLimit(rlUser(userId, 'tripmut'), 30, 60); + const { id } = await params; + const parsed = await parseBody(req, TripActionSchema); + if (!parsed.ok) return parsed.response; + const body = parsed.data; + // Two per-user budgets (ADR-076): `tripmut` caps the ops that fire ORS routing via recomputeSegments + // (audit C7 — an unthrottled edit loop would burn the tight ORS free tier); `tripread` caps the + // read-only checks (NPS/weather cost) without letting them starve real edits. + const scope = READ_OPS.has(body.op) ? 'tripread' : 'tripmut'; + const rl = await rateLimit(rlUser(userId, scope), scope === 'tripread' ? 60 : 30, 60); if (!rl.ok) { return Response.json( { error: 'rate_limited' }, { status: 429, headers: { 'Retry-After': String(Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000))) } }, ); } - const { id } = await params; - const parsed = await parseBody(req, TripActionSchema); - if (!parsed.ok) return parsed.response; - const body = parsed.data; switch (body.op) { case 'fork': { diff --git a/app/plan/page.tsx b/app/plan/page.tsx index 43469b0..ee9d8d5 100644 --- a/app/plan/page.tsx +++ b/app/plan/page.tsx @@ -1,48 +1,38 @@ import { redirect } from 'next/navigation'; -import { Flex, Box, Heading } from '@chakra-ui/react'; +import { Box, Heading } from '@chakra-ui/react'; import { getServerUserId } from '../../lib/session'; -import { TripBuilder } from '../../components/plan/TripBuilder'; -import { ChatPanel } from '../../components/chat/ChatPanel'; +import { PlanShell } from '../../components/plan/PlanShell'; /** * Trip planner. The ranger + trip builder only do anything for a signed-in user (memory/trip writes are * userId-scoped and 401 otherwise), so we gate the whole surface behind sign-in rather than letting it * fail silently (ADR-038). Browse surfaces stay public. * - * Single responsive layout (no `useBreakpointValue` branching — that caused an SSR↔CSR hydration - * mismatch, R2 §2.1): desktop is a two-pane row; mobile stacks the builder above the chat, each panel - * scrollable. Both panels mount exactly once (the Eve chat session isn't duplicated). - * - * Since the builder became a map-centric planning canvas (#9), it takes the flexible space and the ranger - * chat docks as a fixed-width sidebar (was reversed). + * The layout is PlanShell (ADR-076): one client CSS grid — md+ "itinerary | map | chat", base a single + * full-viewport pane behind a bottom tab bar. Everything mounts once (the Eve chat session isn't + * duplicated; the maplibre canvas doesn't churn); pane visibility is CSS, never a breakpoint hook + * (no `useBreakpointValue` markup branching — that caused an SSR↔CSR hydration mismatch, R2 §2.1). */ export default async function PlanPage() { const userId = await getServerUserId(); if (!userId) redirect('/signin'); return ( - Plan a trip - - - - - - - + + ); } diff --git a/components/chat/ChatPanel.tsx b/components/chat/ChatPanel.tsx index e94e186..5bc445f 100644 --- a/components/chat/ChatPanel.tsx +++ b/components/chat/ChatPanel.tsx @@ -8,6 +8,7 @@ import { Markdown } from './Markdown'; import { ToolActivityPill } from './ToolActivityPill'; import { summarizeActivity, type ActivityPart } from '../../lib/tool-activity'; import { decodeSeed } from '../../lib/graph-handoff'; +import { tripIdsFromParts } from '../../lib/chat-trips'; const DEFAULT_SUGGESTIONS: ChatSuggestion[] = [ '✨ Surprise me — plan something I\'d love', @@ -98,8 +99,10 @@ export function ChatPanel({ const active = busy && !stopped; const messages = agent.data.messages; const bottomRef = useRef(null); - const announcedTrips = useRef>(new Set()); + const threadRef = useRef(null); + const announcedTrips = useRef>(new Set()); // `${messageId}:${tripId}` — per-edit, not per-trip (ADR-076) const announcedGrades = useRef>(new Set()); + const announcedActivity = useRef>(new Set()); // assistant message ids → ranger-activity fired // The trip currently open in the sibling TripBuilder (P2.1). We can't share React state across the two // panes, so TripBuilder broadcasts it on a window event; we attach its dates as ephemeral Eve client // context on every send so dated dark-sky/astro answers reflect the trip window, not tonight. @@ -109,6 +112,31 @@ export function ChatPanel({ bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, busy]); + // Snap-to-bottom on reveal (ADR-076): the autoscroll above is a spec-level no-op while the pane is + // display:none (the element has no layout box), and nothing re-fires on reveal — a thread that streamed + // into a hidden pane (mobile tab switching) used to show a stale scroll offset when opened. Watch the + // scroller's height: on the 0→N transition, if messages arrived while hidden, jump with behavior:'auto' + // (smooth-scrolling from a reset offset looks broken). Self-contained — no coupling to the shell's tab + // state, and it fixes the same latent issue on /learn for free. + const msgCountRef = useRef(0); + msgCountRef.current = messages.length; + const hiddenAtCountRef = useRef(null); + useEffect(() => { + const el = threadRef.current; + if (!el) return; + const ro = new ResizeObserver(() => { + if (el.clientHeight === 0) { + hiddenAtCountRef.current ??= msgCountRef.current; + } else if (hiddenAtCountRef.current != null) { + const grew = msgCountRef.current > hiddenAtCountRef.current; + hiddenAtCountRef.current = null; + if (grew) bottomRef.current?.scrollIntoView({ behavior: 'auto' }); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + // Track the TripBuilder's open trip (P2.1) so send() can attach its dates as client context. useEffect(() => { function onActive(e: Event) { @@ -130,25 +158,50 @@ export function ChatPanel({ const codes = decodeSeed(sp.get('seed')); if (!codes.length) return; seededRef.current = true; - window.history.replaceState({}, '', window.location.pathname); + // Strip ONLY the handoff params so a refresh can't re-fire — never the whole query string: ?trip= + // (builder deep link) and ?pane= (shell tab) must survive this cleanup (ADR-076; the old bare-pathname + // replaceState wiped them, leaving deep links working only by effect-ordering luck). + const url = new URL(window.location.href); + url.searchParams.delete('seed'); + url.searchParams.delete('from'); + window.history.replaceState({}, '', url.toString()); void send('Plan a trip with the parks I picked on the graph.', { seedParkCodes: codes.join(','), seedFrom: 'graph' }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // When the ranger saves a trip (an itinerary_preview tool result with a trip), tell the trip builder - // to refresh + open it — without a page reload (R3 §4.2). Dispatch once per trip id. + // When a ranger turn CHANGES a trip — a saved itinerary_preview OR a confirmed nested add (hike / + // campground, marked by `addedTo.tripId`) — tell the trip builder to refresh (and the shell to badge) + // without a reload (R3 §4.2, retuned by ADR-076). Deduped per (assistant message, trip): a NEW turn + // editing the same trip re-announces — the old once-per-trip dedup fired only on a trip's FIRST save, + // so every later ranger edit was silent and the open builder went stale. Scan: lib/chat-trips.ts. useEffect(() => { - for (const m of messages) { - if (m.role !== 'assistant') continue; - for (const p of m.parts as { type?: string; state?: string; output?: unknown }[]) { - if (p.type !== 'dynamic-tool' || p.state !== 'output-available') continue; - const out = p.output as { kind?: string; data?: { trip?: { id?: string } } } | undefined; - const tripId = out?.kind === 'itinerary_preview' ? out?.data?.trip?.id : undefined; - if (tripId && !announcedTrips.current.has(tripId)) { - announcedTrips.current.add(tripId); - window.dispatchEvent(new CustomEvent('trailgraph:trips-changed', { detail: { tripId } })); - } + messages.forEach((m, i) => { + const mm = m as { id?: string; role: string; parts: unknown[] }; + if (mm.role !== 'assistant' || !mm.id) return; + // Fire once mid-stream (when the first write appears) AND once more when the turn SETTLES — the + // settled fire refetches the trip's final state, so a turn that edits the same trip twice (e.g. + // "add Zion and Bryce") isn't left showing only the first edit. `refreshTrip` fetches full current + // state, so a second refetch is idempotent (dayMap preserved). A message is settled once it is no + // longer the actively-streaming last one — hence `busy` in the deps. + const settled = !(busy && i === messages.length - 1); + for (const tripId of tripIdsFromParts(mm.parts as never)) { + const key = `${mm.id}:${tripId}:${settled ? 'final' : 'stream'}`; + if (announcedTrips.current.has(key)) continue; + announcedTrips.current.add(key); + window.dispatchEvent(new CustomEvent('trailgraph:trips-changed', { detail: { tripId } })); } + }); + }, [messages, busy]); + + // Unread signal for the plan shell (ADR-076): announce each assistant message ONCE when it first + // appears — mid-stream, so the Ranger tab's dot shows while a long answer is still streaming into a + // hidden pane. PlanShell alone decides visibility (it ignores this while the Ranger pane is active); + // surfaces without a listener (/learn) just drop the event. ChatPanel stays pane-agnostic. + useEffect(() => { + for (const m of messages as { id?: string; role: string; parts: unknown[] }[]) { + if (m.role !== 'assistant' || !m.id || announcedActivity.current.has(m.id)) continue; + announcedActivity.current.add(m.id); + window.dispatchEvent(new CustomEvent('trailgraph:ranger-activity', { detail: { messageId: m.id } })); } }, [messages]); @@ -323,7 +376,7 @@ export function ChatPanel({ - + {messages.length === 0 ? ( {emptyHint} @@ -341,6 +394,7 @@ export function ChatPanel({ cursor="pointer" px={3} py={1.5} + minH={{ base: '9', md: 'auto' }} // finger-sized starter chips on touch textAlign="start" whiteSpace="normal" _hover={{ bg: 'brand.muted' }} @@ -421,7 +475,11 @@ export function ChatPanel({
- + {/* pb includes the safe-area inset when this row sits at the viewport bottom (/learn, desktop /plan). + The inset rides a CSS variable so a host that puts its OWN bar below the chat (PlanShell's mobile + tab bar owns the home-indicator inset there) can zero it via --chat-safe-bottom and not double-pad + notched phones (ADR-076). Unset, it falls back to env() — a no-op outside notched viewports. */} + setInput(e.target.value)} @@ -430,6 +488,7 @@ export function ChatPanel({ disabled={busy} borderRadius="full" bg="bg.canvas" + enterKeyHint="send" /> {active ? ( diff --git a/components/plan/MapTripCanvas.tsx b/components/plan/MapTripCanvas.tsx index 92eddbd..8bdfca4 100644 --- a/components/plan/MapTripCanvas.tsx +++ b/components/plan/MapTripCanvas.tsx @@ -20,8 +20,17 @@ import type { TripMetrics } from '../../lib/trip-lab'; * builder (which re-renders + fires the `trailgraph:*` events that keep the ranger chat in sync). The numbered * route overlay (shared with `TripMap` via `lib/trip-map-render`) redraws stop-to-stop as the plan assembles. * + * Under the plan shell (ADR-076) this is the permanent map pane, so: + * • `tripId` is nullable — with no trip open the parks still render for browsing and the popup says + * "Open a trip to add parks" instead of the Add button; + * • maplibre construction is GATED on a non-zero container (the NvlGraph pattern): the pane can mount + * `display:none` on mobile, and the constructor `US_BOUNDS` fit + load-time trip fit compute from + * container size — `map.resize()` alone never recovers the camera from a 0×0 birth; + * • `cooperativeGestures` is a prop: true when embedded in a scrollable column (legacy default), false + * from the shell where the pane is full-bleed (matching MapExplorer). + * * Like every map here it FULLY re-creates on colorMode change, so all state lives in refs and re-applies in - * `map.on('load')`. Trip mutations are capped 30/60s server-side (ORS cost) — a 429 surfaces as a toast. + * `map.on('load')`. Trip mutations are rate-capped server-side (ORS cost) — a 429 surfaces as a note. */ export interface CanvasMutation { trip: { id: string; name: string; stops: unknown[] } | null; @@ -35,13 +44,16 @@ export function MapTripCanvas({ addedParkCodes, metrics, onMutated, + cooperativeGestures = true, }: { - tripId: string; + tripId: string | null; stops: TripMapStop[]; origin?: TripMapOrigin | null; addedParkCodes: string[]; metrics?: TripMetrics | null; onMutated: (data: CanvasMutation) => void; + /** One-finger-scroll cooperation — keep true inside scrollable columns; the shell passes false (full-bleed pane). */ + cooperativeGestures?: boolean; }) { const ref = useRef(null); const mapRef = useRef(null); @@ -53,6 +65,7 @@ export function MapTripCanvas({ const drawRef = useRef<{ stop: () => void } | null>(null); const tripIdRef = useRef(tripId); const framedTripRef = useRef(null); // last trip id the camera framed — reframe only on switch + const pendingFrameRef = useRef(false); // a trip switched while the pane was hidden (0×0) — reframe on reveal const onMutatedRef = useRef(onMutated); const busyRef = useRef(false); const abortRef = useRef(null); @@ -89,7 +102,7 @@ export function MapTripCanvas({ // POST addStop for a clicked park, then bubble the new trip + live metrics up to the builder (#9). async function addPark(parkCode: string, name: string) { - if (busyRef.current) return; + if (busyRef.current || !tripIdRef.current) return; busyRef.current = true; setNote(`Adding ${name}…`); try { @@ -124,94 +137,158 @@ export function MapTripCanvas({ registerMapProtocols(); const ac = new AbortController(); abortRef.current = ac; - let map: MlMap; - try { - map = new maplibregl.Map({ container, style: mapStyle(colorMode === 'dark' ? 'dark' : 'light'), bounds: US_BOUNDS, fitBoundsOptions: { padding: 24 } }); - attachBasemapFallback(map); - } catch (err) { - console.warn('[MapTripCanvas] map unavailable (WebGL?):', (err as Error).message); - return; - } - mapRef.current = map; - // The builder column scrolls: when added stops first overflow it, the scrollbar narrows this container - // but the GL canvas keeps its old pixel size (maplibre only tracks window resize) — the "map shrinks" - // bug. Observe the container and resize the canvas to match. - const ro = new ResizeObserver(() => mapRef.current?.resize()); - ro.observe(container); + let map: MlMap | null = null; + let disposed = false; - map.on('load', async () => { - attachMarkerImages(map); // designation glyphs rendered on demand (#2d) + // Source/layer creation, guarded so it can re-run after ANY style swap: the basemap fallback calls + // map.setStyle(demo) on a broken .pmtiles, which used to wipe canvas-parks permanently — the add flow + // died until a colorMode remount (the load-only install). MapExplorer's installLayers convention. + const installLayers = (m: MlMap) => { + if (m.getSource('canvas-parks')) return; const parkColorExpr = ['match', ['get', 'desigKey'], ...designationMatchStops(colorMode), designationDefaultColor(colorMode)] as unknown as ExpressionSpecification; - map.addSource('canvas-parks', { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, cluster: true, clusterMaxZoom: 8, clusterRadius: 50 }); - map.addLayer({ id: 'canvas-clusters', type: 'circle', source: 'canvas-parks', filter: ['has', 'point_count'], + m.addSource('canvas-parks', { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, cluster: true, clusterMaxZoom: 8, clusterRadius: 50 }); + m.addLayer({ id: 'canvas-clusters', type: 'circle', source: 'canvas-parks', filter: ['has', 'point_count'], paint: { 'circle-color': c.pine, 'circle-opacity': 0.8, 'circle-radius': ['step', ['get', 'point_count'], 15, 10, 20, 30, 26] } }); - map.addLayer({ id: 'canvas-cluster-count', type: 'symbol', source: 'canvas-parks', filter: ['has', 'point_count'], + m.addLayer({ id: 'canvas-cluster-count', type: 'symbol', source: 'canvas-parks', filter: ['has', 'point_count'], layout: { 'text-field': '{point_count_abbreviated}', 'text-size': 12, 'text-font': ['Noto Sans Medium'] }, paint: { 'text-color': '#fff' } }); - map.addLayer({ id: 'canvas-park', type: 'circle', source: 'canvas-parks', filter: ['!', ['has', 'point_count']], + m.addLayer({ id: 'canvas-park', type: 'circle', source: 'canvas-parks', filter: ['!', ['has', 'point_count']], paint: { 'circle-color': parkColorExpr, 'circle-radius': 6, 'circle-stroke-width': 1.5, 'circle-stroke-color': '#fff' } }); - map.addLayer({ id: 'canvas-park-icon', type: 'symbol', source: 'canvas-parks', filter: ['!', ['has', 'point_count']], + m.addLayer({ id: 'canvas-park-icon', type: 'symbol', source: 'canvas-parks', filter: ['!', ['has', 'point_count']], layout: { 'icon-image': ['get', 'icon'], 'icon-size': 0.55, 'icon-allow-overlap': true, 'icon-ignore-placement': true } }); + }; + + const init = () => { + if (disposed || map) return; + let m: MlMap; + try { + // cooperativeGestures per the prop: inside a scrollable column a bare map is a scroll trap — + // one-finger drags pan the map (mobile) and the wheel zooms it (desktop) instead of scrolling + // the pane. The shell's full-bleed pane turns it off (nothing behind the map to scroll). + m = new maplibregl.Map({ container, style: mapStyle(colorMode === 'dark' ? 'dark' : 'light'), bounds: US_BOUNDS, fitBoundsOptions: { padding: 24 }, cooperativeGestures }); + attachBasemapFallback(m); + } catch (err) { + console.warn('[MapTripCanvas] map unavailable (WebGL?):', (err as Error).message); + return; + } + map = m; + mapRef.current = m; + let initialized = false; - // Click an addable park → an "Add to trip" popup (setDOMContent so the button can carry a real handler). - map.on('mouseenter', 'canvas-park', () => { map.getCanvas().style.cursor = 'pointer'; }); - map.on('mouseleave', 'canvas-park', () => { map.getCanvas().style.cursor = ''; }); - map.on('click', 'canvas-park', (e) => { - const f = e.features?.[0]; - if (!f) return; - const props = f.properties as { parkCode: string; name: string; designation?: string }; - const [lng, lat] = (f.geometry as Point).coordinates; - const root = document.createElement('div'); - const title = document.createElement('strong'); - title.textContent = props.name ?? props.parkCode; - const sub = document.createElement('div'); - sub.style.cssText = 'color:var(--chakra-colors-fg-muted);font-size:12px;margin:2px 0 6px'; - sub.textContent = props.designation ?? ''; - const btn = document.createElement('button'); - btn.textContent = 'Add to trip'; - btn.style.cssText = `background:${c.pine};color:#fff;border:none;border-radius:6px;padding:4px 10px;font-size:12px;font-weight:600;cursor:pointer`; - const popup = new maplibregl.Popup({ closeButton: false }).setLngLat([lng, lat]); - btn.onclick = () => { addPark(props.parkCode, props.name); popup.remove(); }; - root.append(title, sub, btn); - popup.setDOMContent(root).addTo(map); + m.on('load', async () => { + attachMarkerImages(m); // styleimagemissing hook — survives setStyle image wipes (#2d) + installLayers(m); + + // Click an addable park → an "Add to trip" popup (setDOMContent so the button can carry a real + // handler). With NO trip open (the shell's browse state) the popup explains instead of adding. + // Layer-scoped handlers attach ONCE — they survive setStyle by id; re-adding double-fires. + m.on('mouseenter', 'canvas-park', () => { m.getCanvas().style.cursor = 'pointer'; }); + m.on('mouseleave', 'canvas-park', () => { m.getCanvas().style.cursor = ''; }); + m.on('click', 'canvas-park', (e) => { + const f = e.features?.[0]; + if (!f) return; + const props = f.properties as { parkCode: string; name: string; designation?: string }; + const [lng, lat] = (f.geometry as Point).coordinates; + const root = document.createElement('div'); + const title = document.createElement('strong'); + title.textContent = props.name ?? props.parkCode; + const sub = document.createElement('div'); + sub.style.cssText = 'color:var(--chakra-colors-fg-muted);font-size:12px;margin:2px 0 6px'; + sub.textContent = props.designation ?? ''; + const popup = new maplibregl.Popup({ closeButton: false }).setLngLat([lng, lat]); + if (tripIdRef.current) { + const btn = document.createElement('button'); + btn.textContent = 'Add to trip'; + // ≥40px tall + touch-action:manipulation — a finger-sized target with no 300ms double-tap delay. + btn.style.cssText = `background:${c.pine};color:#fff;border:none;border-radius:6px;padding:8px 14px;min-height:40px;font-size:13px;font-weight:600;cursor:pointer;touch-action:manipulation`; + btn.onclick = () => { addPark(props.parkCode, props.name); popup.remove(); }; + root.append(title, sub, btn); + } else { + const hint = document.createElement('div'); + hint.style.cssText = 'color:var(--chakra-colors-fg-muted);font-size:12px'; + hint.textContent = 'Open a trip to add parks.'; + root.append(title, sub, hint); + } + popup.setDOMContent(root).addTo(m); + }); + // Cluster → zoom to expand. + m.on('click', 'canvas-clusters', (e) => { + const f = m.queryRenderedFeatures(e.point, { layers: ['canvas-clusters'] })[0]; + if (!f) return; + (m.getSource('canvas-parks') as GeoJSONSource).getClusterExpansionZoom(f.properties?.cluster_id).then((zoom) => + m.easeTo({ center: (f.geometry as Point).coordinates as [number, number], zoom }), + ); + }); + + // Mark initialized BEFORE the awaited fetch: attachBasemapFallback can setStyle(demo) on a broken + // pmtiles host DURING this await, firing a style.load we must NOT gate off — else the wiped + // canvas-parks source is never reinstalled and the add flow dies until a colorMode remount. + initialized = true; + + // Load parks once, then paint the parks + the current route overlay. + if (!allParksRef.current) { + try { + const res = await fetch('/api/graph?op=parks-all', { signal: ac.signal }); + const { parks } = (await res.json()) as { parks: ParkPoint[] }; + allParksRef.current = parks ?? []; + } catch { + // Don't poison the cache on an abort (a colorMode swap mid-fetch) — leaving it null lets the + // recreated map refetch (matches MapExplorer.loadAllParks). Only a real error yields an empty set. + if (!ac.signal.aborted) allParksRef.current = []; + } + } + if (ac.signal.aborted) return; // the map may have been removed during the await — never touch it + // A fallback setStyle may be mid-flight (style not yet loaded): its style.load handler (now + // un-gated) will reinstall + repaint. Painting here would throw "Style is not done loading". + if (!m.isStyleLoaded()) return; + applyParks(m); + renderTripOverlay(m, stopsRef.current, c, markersRef, drawRef, true, true, originRef.current); // frame existing stops on open + framedTripRef.current = tripIdRef.current; }); - // Cluster → zoom to expand. - map.on('click', 'canvas-clusters', (e) => { - const f = map.queryRenderedFeatures(e.point, { layers: ['canvas-clusters'] })[0]; - if (!f) return; - (map.getSource('canvas-parks') as GeoJSONSource).getClusterExpansionZoom(f.properties?.cluster_id).then((zoom) => - map.easeTo({ center: (f.geometry as Point).coordinates as [number, number], zoom }), - ); + // After a style swap (the basemap fallback), re-install the wiped source/layers + re-paint. + // 'style.load' also fires on the initial style — gate on `initialized` so first paint stays + // the load handler's job (which awaits the parks fetch + frames the camera). + m.on('style.load', () => { + if (!initialized || ac.signal.aborted) return; + installLayers(m); + applyParks(m); + renderTripOverlay(m, stopsRef.current, c, markersRef, drawRef, false, false, originRef.current); }); + }; - // Load parks once, then paint the parks + the current route overlay. - if (!allParksRef.current) { - try { - const res = await fetch('/api/graph?op=parks-all', { signal: ac.signal }); - const { parks } = (await res.json()) as { parks: ParkPoint[] }; - allParksRef.current = parks ?? []; - } catch { - // Don't poison the cache on an abort (a colorMode swap mid-fetch) — leaving it null lets the - // recreated map refetch (matches MapExplorer.loadAllParks). Only a real error yields an empty set. - if (!ac.signal.aborted) allParksRef.current = []; + // Init gate + resize in ONE observer (ADR-076, the NvlGraph non-zero-mount pattern): under the plan + // shell this pane can mount display:none (mobile default pane = Itinerary), and a map born at 0×0 + // gets a degenerate camera nothing re-frames. Defer construction until the container has real size — + // the first tab reveal constructs against true dimensions, so the normal frame-on-open path just works. + if (container.clientWidth > 0 && container.clientHeight > 0) init(); + const ro = new ResizeObserver(() => { + if (!map) { + if (container.clientWidth > 0 && container.clientHeight > 0) init(); + } else { + // The pane can also narrow while visible (scrollbar appears) — maplibre only tracks window resize. + map.resize(); + // Reveal after a trip switched while the pane was hidden (ADR-076): the stops effect drew the + // markers but deferred the camera fit (fitBounds no-ops on a 0×0 canvas) — do it now. + if (pendingFrameRef.current && container.clientWidth > 0 && container.clientHeight > 0 && map.isStyleLoaded()) { + pendingFrameRef.current = false; + renderTripOverlay(map, stopsRef.current, c, markersRef, drawRef, true, true, originRef.current); + framedTripRef.current = tripIdRef.current; } } - if (ac.signal.aborted) return; // the map may have been removed during the await — never touch it - applyParks(map); - renderTripOverlay(map, stopsRef.current, c, markersRef, drawRef, true, true, originRef.current); // frame existing stops on open - framedTripRef.current = tripIdRef.current; }); + ro.observe(container); return () => { + disposed = true; ro.disconnect(); drawRef.current?.stop(); - markersRef.current.forEach((m) => m.remove()); + markersRef.current.forEach((mk) => mk.remove()); markersRef.current = []; ac.abort(); mapRef.current = null; - map.remove(); + map?.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [colorMode]); + }, [colorMode, cooperativeGestures]); // Re-paint the addable parks (added set changed) + redraw the route whenever the stops change. useEffect(() => { @@ -219,11 +296,17 @@ export function MapTripCanvas({ if (!map) return; const render = () => { applyParks(map); - // Reframe only when the active TRIP changed (switched trips) — not when a park was added to the current - // one (you just clicked it, it's on-screen; yanking the camera mid-build is jarring). (#9, review MEDIUM-3) + // Reframe only when the active TRIP changed (switched trips, opened the first one, or deselected) + // — not when a park was added to the current one (you just clicked it, it's on-screen; yanking the + // camera mid-build is jarring). (#9, review MEDIUM-3) const switched = framedTripRef.current !== tripId; - renderTripOverlay(map, stopsRef.current, c, markersRef, drawRef, true, switched, originRef.current); - framedTripRef.current = tripIdRef.current; + // If the pane is hidden (0×0 — a trip switched from the itinerary/ranger tab, ADR-076), draw the + // markers/line but DON'T fit: fitBounds no-ops on a zero-size canvas and would falsely mark the + // trip framed. Defer the fit to the reveal (the ResizeObserver), leaving framedTripRef untouched. + const hidden = map.getContainer().clientWidth === 0 || map.getContainer().clientHeight === 0; + renderTripOverlay(map, stopsRef.current, c, markersRef, drawRef, true, switched && !hidden, originRef.current); + if (switched && hidden) pendingFrameRef.current = true; + else framedTripRef.current = tripIdRef.current; }; // A stops update can land while the style is momentarily not loaded (mid style/terrain reload). The old // bail-with-no-retry dropped that render until the next map interaction ("stops don't show till you move @@ -232,7 +315,7 @@ export function MapTripCanvas({ else map.once('idle', render); return () => { map.off('idle', render); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [stops, addedParkCodes, origin]); + }, [stops, addedParkCodes, origin, tripId]); // The "Added X" / rate-limit note reads like a toast — let it auto-dismiss (and clear on unmount). useEffect(() => { diff --git a/components/plan/ParkSearchInput.tsx b/components/plan/ParkSearchInput.tsx index 8fc60c8..d533e17 100644 --- a/components/plan/ParkSearchInput.tsx +++ b/components/plan/ParkSearchInput.tsx @@ -79,6 +79,7 @@ export function ParkSearchInput({ onSelect }: { onSelect: (parkCode: string) => role="option" aria-selected={i === active} px={3} - py={2} + py={{ base: 2.5, md: 2 }} // comfortable tap height on touch cursor="pointer" bg={i === active ? 'bg.subtle' : undefined} _hover={{ bg: 'bg.subtle' }} diff --git a/components/plan/PlanShell.tsx b/components/plan/PlanShell.tsx new file mode 100644 index 0000000..2b7bad6 --- /dev/null +++ b/components/plan/PlanShell.tsx @@ -0,0 +1,187 @@ +'use client'; +import { useEffect, useRef, useState } from 'react'; +import { Box, Button, Grid } from '@chakra-ui/react'; +import { TripBuilderProvider, useTripBuilder, type Trip } from './useTripBuilder'; +import { TripBuilder } from './TripBuilder'; +import { MapTripCanvas } from './MapTripCanvas'; +import { PlanTabBar } from './PlanTabBar'; +import { ChatPanel } from '../chat/ChatPanel'; + +export type PlanPane = 'map' | 'itinerary' | 'ranger'; + +/** + * The /plan responsive frame (ADR-076): ONE client component owns a CSS grid whose children are the + * itinerary rail, the map cell, and the ranger chat. md+ shows all three ("itinerary map chat", + * ~380px · fill · 400px — the map finally leaves the scrolling builder column on desktop too); base + * shows one full-viewport pane at a time behind a bottom tab bar. EVERYTHING mounts once — visibility + * is pure CSS (`display`), never an unmount: the Eve chat store is in-memory per component (unmount + * loses the thread + session cursor) and the maplibre canvas must not churn on tab switches. The pane + * is client STATE, not a breakpoint hook — SSR and first CSR both render the Itinerary default, so no + * breakpoint-branched markup (ADR-017). + * + * URL contract (ADR-076): the initial pane resolves post-mount as from=graph → Ranger (the graph + * handoff auto-sends a chat message; it must stream where the user can see it) › explicit ?pane= › + * ?trip= → Itinerary › Itinerary. Tab switches write ?pane= via replaceState (no history spam — Back + * still leaves /plan; a reload restores the pane). Desktop never reads or writes ?pane (the tab bar + * and map pill are base-only, the only setPane callers). + */ +export function PlanShell() { + return ( + + + + ); +} + +function PlanShellInner() { + const [pane, setPaneState] = useState('itinerary'); + const [flashItinerary, setFlashItinerary] = useState(false); + const [rangerUnread, setRangerUnread] = useState(false); + const paneRef = useRef(pane); + paneRef.current = pane; + + // Capture the landing query string during the FIRST client render — before any child effect can touch + // it. ChatPanel's graph-handoff cleanup replaceStates seed/from away in ITS mount effect, and child + // effects run before this (parent) component's effects — a post-mount read here would lose from=graph. + // Render output never depends on this ref, so SSR/CSR stay identical. + const initialSearchRef = useRef(null); + if (typeof window !== 'undefined' && initialSearchRef.current === null) { + initialSearchRef.current = window.location.search; + } + + // Resolve the initial pane from the captured query string (mobile only in effect — on md+ every pane + // is visible so the value is inert until the viewport shrinks). + useEffect(() => { + const sp = new URLSearchParams(initialSearchRef.current ?? ''); + let target: PlanPane = 'itinerary'; + if (sp.get('from') === 'graph') target = 'ranger'; + else { + const p = sp.get('pane'); + if (p === 'map' || p === 'ranger' || p === 'itinerary') target = p; + // ?trip= implies Itinerary — already the default. + } + if (target !== 'itinerary') setPaneState(target); + }, []); + + function setPane(next: PlanPane) { + setPaneState(next); + if (next === 'itinerary') setFlashItinerary(false); + if (next === 'ranger') setRangerUnread(false); + // Write-back so a mid-session reload restores the pane; replaceState (never push) keeps Back = leave /plan. + const url = new URL(window.location.href); + url.searchParams.set('pane', next); + window.history.replaceState({}, '', url.toString()); + } + + // Cross-pane affordances (F8): PlanShell ALONE owns unread/flash state — ChatPanel dispatches its + // events pane-agnostically (it can't know about tabs; it's also mounted on /learn and on desktop where + // a dot would be wrong). Ignore events for the pane the user is already looking at. + useEffect(() => { + function onTripsChanged() { + if (paneRef.current !== 'itinerary') setFlashItinerary(true); + } + function onRangerActivity() { + if (paneRef.current !== 'ranger') setRangerUnread(true); + } + window.addEventListener('trailgraph:trips-changed', onTripsChanged); + window.addEventListener('trailgraph:ranger-activity', onRangerActivity); + return () => { + window.removeEventListener('trailgraph:trips-changed', onTripsChanged); + window.removeEventListener('trailgraph:ranger-activity', onRangerActivity); + }; + }, []); + + const paneDisplay = (key: PlanPane) => ({ base: pane === key ? 'flex' : 'none', md: 'flex' }); + + return ( + the 768px md breakpoint, + // collapsing the 1fr map track to 0 on 768–780px portrait tablets (and the map is unreachable — + // the tab bar is md-hidden). Mins sum to 760 < 768, so the map always keeps real width (ADR-076). + templateColumns={{ base: '1fr', md: 'minmax(260px, 380px) minmax(220px, 1fr) minmax(280px, 400px)' }} + templateRows={{ base: '1fr auto', md: '1fr' }} + > + {/* Itinerary rail — the ONE TripBuilder composition, both breakpoints (ADR-076). */} + + + + + {/* Map cell — the permanent build-on-map canvas. Construction is init-gated inside MapTripCanvas, + so mounting it display:none here (mobile default pane = Itinerary) is safe. */} + + setPane('itinerary')} /> + + + {/* Ranger chat — mounts exactly once per /plan visit (the Eve session). The chat input's safe-area + inset is zeroed on base (the tab bar below owns the home-indicator inset there); md+ keeps env() + because the input reaches the viewport bottom again. */} + + + + + + + ); +} + +/** The map region: canvas props from the shared provider + the base-only "View itinerary" pill. Pane + * switching rides the shell's `setPane` callback (context/props, not a window event — trailgraph:* stays + * reserved for genuinely cross-tree producers like ChatPanel). */ +function MapCell({ onViewItinerary }: { onViewItinerary: () => void }) { + const { trip, canvasStops, canvasOrigin, addedParkCodes, metrics, applyMutation } = useTripBuilder(); + return ( + <> + applyMutation({ trip: d.trip as unknown as Trip | null, metrics: d.metrics })} + cooperativeGestures={false} + /> + {/* bottom clears maplibre's attribution strip (~24px, z-indexed above siblings) — at 390px its + expanded inner div otherwise intercepts the tap (caught by plan-mobile e2e). */} + + + ); +} diff --git a/components/plan/PlanTabBar.tsx b/components/plan/PlanTabBar.tsx new file mode 100644 index 0000000..1bec5c4 --- /dev/null +++ b/components/plan/PlanTabBar.tsx @@ -0,0 +1,94 @@ +'use client'; +import { Badge, Box, Button, HStack, Icon, Text } from '@chakra-ui/react'; +import { LuMap, LuListChecks, LuSparkles } from 'react-icons/lu'; +import { useTripBuilder } from './useTripBuilder'; +import type { PlanPane } from './PlanShell'; + +/** + * The base-only bottom tab bar (ADR-076): Map / Itinerary / Ranger, each a full-viewport pane. Built from + * Button/Badge primitives — deliberately NOT Chakra `Tabs`, whose `hidden`-attr panel semantics fight the + * md+ all-visible grid. ≥44px targets; the bar owns the home-indicator safe-area inset on notched phones + * (PlanShell zeroes the chat input's own inset via --chat-safe-bottom so the two never stack). + * + * Badges: the Itinerary tab renders the open trip's stop count as a BARE numeric Badge (never the + * " stop(s)" string — that phrasing stays exclusive to the map metrics chip so existing e2e text + * selectors don't strict-mode-collide) and flashes when a ranger edit lands while the pane is hidden; + * the Ranger tab shows an unread dot while a reply streams into a hidden pane. Both clear on activation. + */ +export function PlanTabBar({ + pane, + onSelect, + flashItinerary, + rangerUnread, +}: { + pane: PlanPane; + onSelect: (pane: PlanPane) => void; + flashItinerary: boolean; + rangerUnread: boolean; +}) { + const { trip, stops } = useTripBuilder(); + const count = trip ? stops.length : null; + + const tabs: { key: PlanPane; label: string; icon: React.ReactNode; ariaLabel: string }[] = [ + { key: 'map', label: 'Map', icon: , ariaLabel: 'Map' }, + { + key: 'itinerary', + label: 'Itinerary', + icon: , + ariaLabel: count != null && count > 0 ? `Itinerary, ${count} stop${count === 1 ? '' : 's'}` : 'Itinerary', + }, + { key: 'ranger', label: 'Ranger', icon: , ariaLabel: rangerUnread ? 'Ranger, new activity' : 'Ranger' }, + ]; + + return ( + + {tabs.map((t) => { + const active = pane === t.key; + return ( + + ); + })} + + ); +} diff --git a/components/plan/StopList.tsx b/components/plan/StopList.tsx new file mode 100644 index 0000000..3a9e2ca --- /dev/null +++ b/components/plan/StopList.tsx @@ -0,0 +1,187 @@ +'use client'; +import { useEffect, useRef, useState } from 'react'; +import { Stack, Text, HStack, IconButton, Badge, Icon } from '@chakra-ui/react'; +import { Reorder, useDragControls } from 'motion/react'; +import { LuX, LuGripVertical, LuFootprints, LuTentTree } from 'react-icons/lu'; +import { useTripBuilder, stopLabel, touchTarget, type Stop } from './useTripBuilder'; + +/** + * One draggable stop row. Drag starts ONLY from the grip handle (`dragListener={false}` + drag controls): + * motion's default whole-row listener hijacked the pane's one-finger scroll on touch — the handle is the + * standard fix, and `touch-action:none` on it lets the drag claim the gesture before the browser scrolls. + */ +function StopItem({ + stop: s, + index: i, + day, + showDay, + onDragStart, + onDragEnd, + onRemove, + onRemoveHike, + onRemoveLodging, +}: { + stop: Stop; + index: number; + day?: number; + showDay: boolean; + onDragStart: () => void; + onDragEnd: () => void; + onRemove: (stopId: string) => void; + onRemoveHike: (stopId: string, trailId: string) => void; + onRemoveLodging: (stopId: string, campgroundId: string) => void; +}) { + const controls = useDragControls(); + return ( + + {showDay ? ( + + Day {day} + + ) : null} + + { + e.preventDefault(); // don't let the press select text or start a scroll before the drag claims it + controls.start(e); + }} + > + + + + {i + 1}. {stopLabel(s)} + + onRemove(s.id)}> + + + + {s.driveTo ? ( + + ↓ {Math.round(s.driveTo.miles)} mi · {Math.round(s.driveTo.minutes)} min + {s.driveTo.source === 'great_circle' ? ' (approx)' : ''} + + ) : null} + {/* Hikes nested under this park stop (ADR-071) — add via the ranger or a trail page; remove here. */} + {s.hikes?.length ? ( + + + Hikes here + + {s.hikes.map((h) => ( + + + + {h.name} + {h.lengthMiles != null || h.estTimeHrs != null ? ( + + {' '}· {[h.lengthMiles != null ? `${h.lengthMiles} mi` : null, h.estTimeHrs != null ? `~${h.estTimeHrs} hr` : null].filter(Boolean).join(' · ')} + + ) : null} + + {h.permitRequired ? permit : null} + onRemoveHike(s.id, h.id)}> + + + + ))} + + ) : null} + {/* Lodging nested under this stop (Campgrounds feature) — add via the ranger or a campground page; remove here. */} + {s.lodging ? ( + + + Sleeping here + + + + + {s.lodging.name} + {s.lodging.feeUSD != null ? · ${s.lodging.feeUSD}/night : null} + + onRemoveLodging(s.id, s.lodging!.id)}> + + + + + ) : null} + + ); +} + +/** + * The draggable stop list + the origin drive legs around it (ADR-074: the origin is not a stop, so its + * legs render outside the Reorder.Group). Owns motion's live values array (`localStops`) locally — it's + * pure drag state — and reports drag start/end to the provider so a ranger refresh landing mid-drag is + * stashed instead of yanking the rows (ADR-076); a stash applied on drop wins over the stale drag order. + */ +export function StopList() { + const { trip, dayMap, removeStop, removeHike, removeLodging, persistReorder, beginDrag, endDrag } = useTripBuilder(); + // A local copy of the stops the drag-reorder list mutates live (motion/react Reorder owns its values + // array); synced from `trip` on every change and persisted on drop. + const [localStops, setLocalStops] = useState([]); + // Current trip, read at drop time (not the render closure) so a 429 revert restores the LATEST server + // order — e.g. if a ranger refresh landed during the in-flight reorder POST. + const tripRef = useRef(trip); + tripRef.current = trip; + useEffect(() => { + setLocalStops(((trip?.stops ?? []).filter(Boolean) as Stop[])); + }, [trip]); + + if (!trip) return null; + + async function handleDragEnd() { + if (endDrag()) return; // a ranger refresh landed mid-drag — the fresh trip already replaced the list + const result = await persistReorder(localStops.map((s) => s.id)); + if (result === 'limited') setLocalStops(((tripRef.current?.stops ?? []).filter(Boolean) as Stop[])); // revert to the latest server order + } + + return ( + <> + {/* First leg: home/origin → stop 1 (the origin is not a draggable stop, so it renders above the list). */} + {trip.origin && trip.originLeg && localStops.length > 0 ? ( + + ⌂ {trip.origin.label ?? 'Start'} ↓ {Math.round(trip.originLeg.miles)} mi · {Math.round(trip.originLeg.minutes)} min + {trip.originLeg.source === 'great_circle' ? ' (approx)' : ''} + + ) : null} + {/* Drag a stop to reorder (#9) — from the grip handle only (see StopItem); the route + drive times + recompute on drop. Day headers ride inside each item so they stay direct children of the + Reorder.Group. Day falls back to the stop's PERSISTED `day` (not just the suggest-days map). */} + + {localStops.map((s, i) => { + const day = dayMap[s.id] ?? s.day ?? undefined; + const prevDay = i > 0 ? dayMap[localStops[i - 1].id] ?? localStops[i - 1].day ?? undefined : undefined; + return ( + + ); + })} + + {/* Return leg: last stop → home (round trip). */} + {trip.origin && trip.returnToOrigin && trip.returnLeg && localStops.length > 0 ? ( + + ↓ {Math.round(trip.returnLeg.miles)} mi · {Math.round(trip.returnLeg.minutes)} min back to ⌂ {trip.origin.label ?? 'start'} + {trip.returnLeg.source === 'great_circle' ? ' (approx)' : ''} + + ) : null} + + ); +} diff --git a/components/plan/TripActions.tsx b/components/plan/TripActions.tsx new file mode 100644 index 0000000..9c65e15 --- /dev/null +++ b/components/plan/TripActions.tsx @@ -0,0 +1,89 @@ +'use client'; +import { Button, HStack, Icon, IconButton, Menu, Portal, Stack, Text } from '@chakra-ui/react'; +import { LuChevronDown, LuCopy } from 'react-icons/lu'; +import { toast } from '../../lib/toast'; +import { useTripBuilder, touchTarget } from './useTripBuilder'; + +/** + * The action row (Phase 0 shape, unchanged selectors): the six everyday checks stay visible with + * `loading` states — these calls take seconds and used to give zero feedback — while Fork + the four + * export artifacts collapse into "More" (was 11 peer buttons wrapping to ~4 rows on a phone). Renders + * only when the trip has stops; the share row appears under it once a link exists. + */ +export function TripActions() { + const { trip, stops, busyOps, shareUrl, checkAlerts, checkCost, checkConditions, suggestDayPlan, optimizeRoute, share, fork } = useTripBuilder(); + if (!trip || stops.length === 0) return null; + + return ( + + + + + + + + + + + + + + + + Fork + + Field brief + + + Offline pack + + + Export .ics + + + Export .gpx + + + + + + + {shareUrl ? ( + + + Read-only link: {shareUrl} + + { + navigator.clipboard?.writeText(shareUrl).then( + () => toast.success('Share link copied'), + () => toast.error("Couldn't copy — select the link text instead."), + ); + }} + > + + + + ) : null} + + ); +} diff --git a/components/plan/TripBuilder.tsx b/components/plan/TripBuilder.tsx index 40dd844..4b64fcf 100644 --- a/components/plan/TripBuilder.tsx +++ b/components/plan/TripBuilder.tsx @@ -1,654 +1,40 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; -import { Box, Stack, Heading, Text, Input, Button, Flex, HStack, IconButton, Separator, Badge, Icon } from '@chakra-ui/react'; -import { Reorder } from 'motion/react'; -import { LuX, LuGripVertical, LuFootprints, LuTriangleAlert, LuTentTree, LuHouse, LuRepeat } from 'react-icons/lu'; -import { MapTripCanvas } from './MapTripCanvas'; +import { Box, Stack, Heading, Text, HStack, Icon, Separator } from '@chakra-ui/react'; +import { LuTriangleAlert } from 'react-icons/lu'; import { ParkSearchInput } from './ParkSearchInput'; -import { toast } from '../../lib/toast'; -import { TripDashboardCard } from '../conditions/ConditionCards'; -import { AlertList } from '../chat/Cards'; -import { decodeEntities } from '../../lib/html-entities'; -import { tripDayLoads } from '../../lib/itinerary'; -import type { TripDashboard } from '../../lib/conditions'; -import type { TripMetrics } from '../../lib/trip-lab'; - -/** Itinerary builder (C1-C4) — drives the Trip service via /api/trips. Fully functional without the agent. */ -interface TripHike { - id: string; - name: string; - lengthMiles?: number | null; - estTimeHrs?: number | null; - difficulty?: string | null; - permitRequired?: boolean; -} - -interface Stop { - id: string; - order: number; - day?: number | null; - parkCode?: string | null; - parkName?: string; - campgroundName?: string; - poiTitle?: string; - placeTitle?: string; - name?: string; - lat?: number | null; - lng?: number | null; - hikes?: TripHike[]; - lodging?: { id: string; name: string; feeUSD?: number | null; reservationUrl?: string | null } | null; - driveTo?: { miles: number; minutes: number; source: string } | null; -} - -/** Display label for any stop kind (park / campground / POI / place / custom). */ -const stopLabel = (s: Stop): string => - s.parkName ?? s.placeTitle ?? s.campgroundName ?? s.poiTitle ?? s.name ?? 'Stop'; -interface Trip { - id: string; - name: string; - startDate?: string | null; - endDate?: string | null; - // Trip origin (defaults from the user's home; editable per trip) + its computed drive legs. - origin?: { lat: number; lng: number; label: string | null } | null; - returnToOrigin?: boolean; - originLeg?: { miles: number; minutes: number; source: string } | null; - returnLeg?: { miles: number; minutes: number; source: string } | null; - stops: (Stop | null)[]; -} - +import { TripSwitcher } from './TripSwitcher'; +import { TripHeader } from './TripHeader'; +import { StopList } from './StopList'; +import { TripActions } from './TripActions'; +import { TripInsights } from './TripInsights'; +import { useTripBuilder } from './useTripBuilder'; + +/** + * Itinerary builder (C1-C4) — the ONE itinerary-pane composition, used at every breakpoint (ADR-076: + * everything mounts once; a desktop-only tree plus separate mobile panes would be breakpoint-branched + * markup, the ADR-017 hydration violation). All state lives in TripBuilderProvider (useTripBuilder.tsx); + * this file just composes the pure pieces. The map is NOT here — PlanShell renders MapTripCanvas in its + * own grid cell from the same provider. Fully functional without the agent. + */ export function TripBuilder() { - const [trips, setTrips] = useState<{ id: string; name: string; stops: number }[]>([]); - const [trip, setTrip] = useState(null); - const [newName, setNewName] = useState(''); - const [alerts, setAlerts] = useState<{ park: string; alerts: { category: string; title: string }[] }[] | null>(null); - const [cost, setCost] = useState<{ - perPark: { parkCode: string; parkName: string; fee: number }[]; - total: number; - atbPrice: number; - holdsAtb: boolean; - atbSaves: boolean; - } | null>(null); - const [costErr, setCostErr] = useState(null); - const [dashboard, setDashboard] = useState(null); - // Live running-total badge for the build-on-map canvas (#9): updated from every mutation response. - const [metrics, setMetrics] = useState(null); - // A local copy of the stops the drag-reorder list mutates live (motion/react Reorder owns its values array); - // synced from `trip` on every change and persisted on drop. - const [localStops, setLocalStops] = useState([]); - const [dayMap, setDayMap] = useState>({}); - const [shareUrl, setShareUrl] = useState(null); - const [err, setErr] = useState(null); - const [editingName, setEditingName] = useState(false); - const [nameDraft, setNameDraft] = useState(''); - const [editingOrigin, setEditingOrigin] = useState(false); - const [originDraft, setOriginDraft] = useState(''); - - async function loadTrips() { - const res = await fetch('/api/trips'); - if (res.status === 401) return setErr('Sign in to plan trips.'); - const { trips } = await res.json(); - setTrips(trips ?? []); - } - useEffect(() => { - loadTrips(); - // Deep-link: /plan?trip= opens that trip (used by "Start a trip from this tour" on park pages). - const id = new URLSearchParams(window.location.search).get('trip'); - if (id) openTrip(id); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Live-refresh when the ranger saves a trip from the chat panel (R3 §4.2). ChatPanel dispatches this - // with the new trip id so the sidebar updates — and we open it — without a reload. - useEffect(() => { - function onChanged(e: Event) { - loadTrips(); - const id = (e as CustomEvent<{ tripId?: string }>).detail?.tripId; - if (id) openTrip(id); - } - window.addEventListener('trailgraph:trips-changed', onChanged); - return () => window.removeEventListener('trailgraph:trips-changed', onChanged); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Broadcast the open trip's id + dates to the ranger chat (P2.1). ChatPanel attaches them as ephemeral - // Eve client context on every send, so dated dark-sky/astro/best-time answers reflect the trip window - // (the best night in it) instead of "tonight". Fires on open/create/rename/deselect (all call setTrip). - useEffect(() => { - window.dispatchEvent( - new CustomEvent('trailgraph:active-trip', { - detail: trip - ? { id: trip.id, name: trip.name, startDate: trip.startDate ?? null, endDate: trip.endDate ?? null } - : null, - }), - ); - }, [trip?.id, trip?.name, trip?.startDate, trip?.endDate]); - - // Keep the drag-reorder list in lockstep with the open trip's stops (after any add/remove/reorder/optimize). - useEffect(() => { - setLocalStops((trip?.stops ?? []).filter(Boolean) as Stop[]); - }, [trip]); - - // Every stop mutation returns the fresh trip + live metrics (#9) — apply both, invalidate the now-stale - // cost card, and refresh the sidebar stop counts. Shared by the canvas, search-add, remove, and reorder. - function applyMutation(data: { trip?: Trip | null; metrics?: TripMetrics | null }) { - if (data.trip) setTrip(data.trip); - // Keep the prior badge if metrics recompute transiently failed (null) — don't blink it off (#9 LOW-4). - setMetrics((prev) => data.metrics ?? prev); - setCost(null); - setCostErr(null); - loadTrips(); - } - - async function openTrip(id: string) { - if (trip?.id === id) return; // idempotent: re-clicking the open trip is a no-op, never deselects (§4.6) - const res = await fetch(`/api/trips/${id}?include=metrics`); - const { trip: opened, metrics: m } = await res.json(); - setTrip(opened); - setMetrics(m ?? null); - setAlerts(null); - setCost(null); - setCostErr(null); - setDashboard(null); - setDayMap({}); - setEditingName(false); - // Auto-load Closure/Danger alerts on open so safety items pin to the trip artifact without an extra - // click (P1.2). openTrip is idempotent (early-returns on the same id), so this never loops. - fetch(`/api/trips/${id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'alerts' }), - }) - .then((r) => (r.ok ? r.json() : null)) - .then((d) => { - if (d && Array.isArray(d.alerts)) setAlerts(d.alerts); - }) - .catch(() => {}); - } - - async function rename() { - if (!trip || !nameDraft.trim()) return; - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'rename', name: nameDraft.trim() }), - }); - const { trip: updated } = await res.json(); - if (updated) { - setTrip(updated); - setEditingName(false); - loadTrips(); - } - } - async function create() { - if (!newName.trim()) return; - const res = await fetch('/api/trips', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newName }), - }); - if (res.status === 401) return setErr('Sign in to plan trips.'); - const { id } = await res.json(); - setNewName(''); - await loadTrips(); - await openTrip(id); - } - // All trip mutations share one 30/60s `tripmut` budget server-side (ORS cost); the canvas can exhaust it, - // so every edit path surfaces a 429 the same way instead of silently no-opping (#9 LOW-1). - function rateLimited(res: Response): boolean { - if (res.status !== 429) return false; - toast.info('Slow down — too many trip edits. Try again in a moment.'); - return true; - } - async function addPark(code: string) { - if (!trip || !code) return; - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'addStop', stop: { kind: 'park', refId: code } }), - }); - if (rateLimited(res)) return; - if (res.ok) applyMutation(await res.json()); - } - async function removeStop(stopId: string) { - if (!trip) return; - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'removeStop', stopId }), - }); - if (rateLimited(res)) return; - if (res.ok) applyMutation(await res.json()); - } - // Detach a hike from a stop (ADR-071) — `(:Stop)-[:INCLUDES_TRAIL]->(:Trail)`. Adding hikes happens via the - // ranger or a trail page; the builder shows them and lets you drop one. - async function removeHike(stopId: string, trailId: string) { - if (!trip) return; - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'excludeTrail', stopId, trailId }), - }); - if (rateLimited(res)) return; - if (res.ok) applyMutation(await res.json()); - } - // Detach lodging from a stop (Campgrounds feature) — `(:Stop)-[:STAYS_AT]->(:Campground)`. Adding lodging - // happens via the ranger or the campground detail page; the builder shows it and lets you drop it. - async function removeLodging(stopId: string, campgroundId: string) { - if (!trip) return; - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'excludeCampground', stopId, campgroundId }), - }); - if (rateLimited(res)) return; - if (res.ok) applyMutation(await res.json()); - } - // Persist a drag-reorder on drop (#9). Skip the round-trip if the order didn't actually change. The 30/60s - // trip-mutation cap is server-side; a 429 surfaces as a toast and the next openTrip resyncs the true order. - async function persistReorder() { - if (!trip) return; - const ids = localStops.map((s) => s.id); - const current = ((trip.stops ?? []).filter(Boolean) as Stop[]).map((s) => s.id); - if (ids.length !== current.length || ids.every((id, i) => id === current[i])) return; - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'reorder', orderedStopIds: ids }), - }); - if (rateLimited(res)) { - setLocalStops((trip.stops ?? []).filter(Boolean) as Stop[]); // revert the optimistic drag order - return; - } - if (res.ok) { - setDayMap({}); // the route order changed → the prior day grouping no longer maps cleanly (#9 LOW-2) - applyMutation(await res.json()); - } - } - async function checkAlerts() { - if (!trip) return; - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'alerts' }), - }); - const { alerts } = await res.json(); - setAlerts(alerts ?? []); - } - async function checkCost() { - if (!trip) return; - try { - setCost(null); - setCostErr(null); - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'cost' }), - }); - if (!res.ok) { - setCost(null); - setCostErr('Failed to estimate trip cost. Please try again.'); - return; - } - const { cost } = await res.json(); - setCost(cost ?? null); - setCostErr(null); - } catch { - setCost(null); - setCostErr('Network error. Please try again.'); - } - } - async function checkConditions() { - if (!trip) return; - setDashboard(null); - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'conditions' }), - }); - if (!res.ok) { - toast.error('Could not load trip conditions.'); - return; - } - const { dashboard } = (await res.json()) as { dashboard: TripDashboard | null }; - setDashboard(dashboard); - } - async function share() { - if (!trip) return; - const res = await fetch(`/api/trips/${trip.id}/share`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role: 'read' }), - }); - if (res.ok) { - const { url } = (await res.json()) as { url: string }; - const full = `${window.location.origin}${url}`; - setShareUrl(full); - let copied = false; - if (navigator.clipboard?.writeText) { - copied = await navigator.clipboard.writeText(full).then(() => true).catch(() => false); - } - if (copied) { - toast.success('Share link copied', 'A read-only link to this trip is on your clipboard.'); - } else { - toast.success('Share link ready', 'Copy the read-only link shown below.'); - } - } else { - toast.error("Couldn't create share link", 'Please try again in a moment.'); - } - } - async function optimizeRoute() { - if (!trip) return; - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'optimize' }), - }); - if (rateLimited(res)) return; - if (res.ok) { - const data = await res.json(); - if (data.trip) setDayMap({}); // route changed → the prior day grouping no longer maps cleanly - applyMutation(data); - } - } - // Trip Lab (ADR-056): fork the open trip into a copy and switch to it, leaving the original untouched. - async function fork() { - if (!trip) return; - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'fork' }), - }); - if (!res.ok) { - toast.error("Couldn't fork this trip", 'Please try again in a moment.'); - return; - } - const { tripId, trip: forked } = await res.json(); - await loadTrips(); - if (forked) { - setTrip(forked); - setAlerts(null); - setCost(null); - setCostErr(null); - setDashboard(null); - setDayMap({}); - } else if (tripId) { - await openTrip(tripId); - } - toast.success('Trip forked', 'Editing the copy — your original is untouched.'); - } - // Trip origin (defaults from home): set from free text (geocoded server-side), toggle the round trip, - // or clear back to "starts at the first stop". - async function setOrigin(body: { place?: string; clearOrigin?: boolean; returnToOrigin?: boolean }) { - if (!trip) return; - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'setOrigin', ...body }), - }); - if (rateLimited(res)) return; - if (res.status === 404 && body.place) { - toast.info(`Couldn't find "${body.place}" — try a nearby city or town.`); - return; - } - if (res.ok) { - applyMutation(await res.json()); - setEditingOrigin(false); - setOriginDraft(''); - } - } - async function suggestDayPlan() { - if (!trip) return; - const res = await fetch(`/api/trips/${trip.id}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'suggestDays' }), - }); - const { days } = (await res.json()) as { days: { id: string; day: number }[] }; - setDayMap(Object.fromEntries((days ?? []).map((d) => [d.id, d.day]))); - } + const { err, trip, addPark, overPackedDays } = useTripBuilder(); if (err) return {err}; - const stops = (trip?.stops ?? []).filter(Boolean) as Stop[]; - // Memoize the canvas props keyed on `trip` (not on every render) — otherwise typing in the name box would - // hand MapTripCanvas fresh array identities each keystroke and restart its route-draw animation (#9 MEDIUM-1). - const canvasStops = useMemo( - () => ((trip?.stops ?? []).filter(Boolean) as Stop[]).map((s) => ({ lat: s.lat ?? null, lng: s.lng ?? null, label: stopLabel(s), order: s.order })), - [trip], - ); - const addedParkCodes = useMemo( - () => ((trip?.stops ?? []).filter(Boolean) as Stop[]).map((s) => s.parkCode).filter((code): code is string => !!code), - [trip], - ); - const canvasOrigin = useMemo( - () => - trip?.origin - ? { lat: trip.origin.lat, lng: trip.origin.lng, label: trip.origin.label, roundTrip: trip.returnToOrigin ?? false } - : null, - [trip], - ); - // Schedule-aware "over-packed day" warning (ADR-071): aggregate each day's hike hours + drive hours and - // flag the days over ~8 h. Uses dayMap (Suggest day plan) or the stop's persisted day; empty until days exist. - const overPackedDays = useMemo(() => { - const loadStops = ((trip?.stops ?? []).filter(Boolean) as Stop[]).map((s) => ({ - day: dayMap[s.id] ?? s.day ?? null, - driveMinutesToHere: s.driveTo?.minutes ?? 0, - hikeMiles: (s.hikes ?? []).reduce((m, h) => m + (h.lengthMiles ?? 0), 0), - hikeHours: (s.hikes ?? []).reduce((m, h) => m + (h.estTimeHrs ?? 0), 0), - })); - return tripDayLoads(loadStops).filter((d) => d.overPacked); - }, [trip, dayMap]); - return ( Trips - - setNewName(e.target.value)} /> - - - - {trips.map((t) => ( - - ))} - + {trip ? ( <> - {editingName ? ( - - setNameDraft(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && rename()} - /> - - - - ) : ( - - {decodeEntities(trip.name)} - {(() => { - // Persistent alert-count badge on the trip header (P1.2) so safety items are visible at a glance. - const n = alerts ? alerts.reduce((sum, a) => sum + a.alerts.length, 0) : 0; - return n > 0 ? ( - - {n} alert{n === 1 ? '' : 's'} - - ) : null; - })()} - - - )} - {/* Trip origin (user-feedback iteration): defaults from the saved home location; editable per - trip (fly-in trips), with a round-trip toggle that adds the drive home to the route. */} - - - {editingOrigin ? ( - <> - setOriginDraft(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && originDraft.trim() && setOrigin({ place: originDraft.trim() })} - /> - - - - ) : trip.origin ? ( - <> - - Starts from {trip.origin.label ?? 'your start point'} - - - - - - ) : ( - <> - No start point — route begins at the first stop. - - - )} - + {/* key by trip id: a real trip SWITCH resets TripHeader's local rename/origin editor state + (the monolith did this in openTrip); a same-id background refresh keeps the same key so a + ranger refresh never closes an open editor (ADR-076). */} + - {/* Build-on-map canvas (#9): click a park to add it; the route + running total assemble live. */} - - applyMutation({ trip: d.trip as unknown as Trip | null, metrics: d.metrics })} - /> - - - {/* First leg: home/origin → stop 1 (the origin is not a draggable stop, so it renders above the list). */} - {trip.origin && trip.originLeg && localStops.length > 0 ? ( - - ⌂ {trip.origin.label ?? 'Start'} ↓ {Math.round(trip.originLeg.miles)} mi · {Math.round(trip.originLeg.minutes)} min - {trip.originLeg.source === 'great_circle' ? ' (approx)' : ''} - - ) : null} - {/* Drag a stop to reorder (#9); the route + drive times recompute on drop. Day headers ride inside - each item so they stay direct children of the Reorder.Group. */} - - {localStops.map((s, i) => { - const day = dayMap[s.id]; - const prevDay = i > 0 ? dayMap[localStops[i - 1].id] : undefined; - return ( - - {day && day !== prevDay ? ( - - Day {day} - - ) : null} - - - - - - {i + 1}. {stopLabel(s)} - - removeStop(s.id)}> - - - - {s.driveTo ? ( - - ↓ {Math.round(s.driveTo.miles)} mi · {Math.round(s.driveTo.minutes)} min - {s.driveTo.source === 'great_circle' ? ' (approx)' : ''} - - ) : null} - {/* Hikes nested under this park stop (ADR-071) — add via the ranger or a trail page; remove here. */} - {s.hikes?.length ? ( - - - Hikes here - - {s.hikes.map((h) => ( - - - - {h.name} - {h.lengthMiles != null || h.estTimeHrs != null ? ( - - {' '}· {[h.lengthMiles != null ? `${h.lengthMiles} mi` : null, h.estTimeHrs != null ? `~${h.estTimeHrs} hr` : null].filter(Boolean).join(' · ')} - - ) : null} - - {h.permitRequired ? permit : null} - removeHike(s.id, h.id)}> - - - - ))} - - ) : null} - {/* Lodging nested under this stop (Campgrounds feature) — add via the ranger or a campground page; remove here. */} - {s.lodging ? ( - - - Sleeping here - - - - - {s.lodging.name} - {s.lodging.feeUSD != null ? · ${s.lodging.feeUSD}/night : null} - - removeLodging(s.id, s.lodging!.id)}> - - - - - ) : null} - - ); - })} - - {/* Return leg: last stop → home (round trip). */} - {trip.origin && trip.returnToOrigin && trip.returnLeg && localStops.length > 0 ? ( - - ↓ {Math.round(trip.returnLeg.miles)} mi · {Math.round(trip.returnLeg.minutes)} min back to ⌂ {trip.origin.label ?? 'start'} - {trip.returnLeg.source === 'great_circle' ? ' (approx)' : ''} - - ) : null} + {overPackedDays.length ? ( @@ -667,79 +53,8 @@ export function TripBuilder() { ) : null} - {stops.length > 0 ? ( - - - - - - - - - - - - - - - {shareUrl ? ( - - Read-only link: {shareUrl} - - ) : null} - - ) : null} - {/* Structured, shared alert card (P1.2) — same component the chat uses, instead of a bespoke list. */} - {alerts ? : null} - {cost ? ( - - - Estimated entrance fees - ${cost.total.toFixed(0)} - - {cost.holdsAtb ? ( - - Covered by your America the Beautiful annual pass. ✓ - - ) : cost.atbSaves ? ( - - The ${cost.atbPrice} America the Beautiful annual pass would save you ${(cost.perPark.reduce((s, p) => s + p.fee, 0) - cost.atbPrice).toFixed(0)} here. - - ) : ( - Per-vehicle entrance fees, summed across your parks. - )} - - ) : null} - {costErr ? {costErr} : null} - {dashboard ? ( - dashboard.stops.length === 0 ? ( - Add a park stop to see its conditions. - ) : ( - } /> - ) - ) : null} + + ) : ( Create or select a trip to start building an itinerary. diff --git a/components/plan/TripHeader.tsx b/components/plan/TripHeader.tsx new file mode 100644 index 0000000..fc3cede --- /dev/null +++ b/components/plan/TripHeader.tsx @@ -0,0 +1,143 @@ +'use client'; +import { useState } from 'react'; +import { Badge, Button, Heading, HStack, Icon, Input, Text } from '@chakra-ui/react'; +import { LuHouse, LuRepeat } from 'react-icons/lu'; +import { decodeEntities } from '../../lib/html-entities'; +import { useTripBuilder } from './useTripBuilder'; + +/** + * The open trip's header: name/rename, the persistent alert-count badge (P1.2 — safety at a glance), a + * compact live-metrics line (ADR-076: on mobile the map chip lives in a hidden pane, so the Itinerary + * pane needs its own running total — deliberately WITHOUT the map chip's " stop(s)" phrasing, which + * stays exclusive to the chip so e2e text selectors never collide), and the trip-origin row (ADR-074). + */ +export function TripHeader() { + const { trip, alerts, metrics, rename, setOrigin } = useTripBuilder(); + const [editingName, setEditingName] = useState(false); + const [nameDraft, setNameDraft] = useState(''); + const [editingOrigin, setEditingOrigin] = useState(false); + const [originDraft, setOriginDraft] = useState(''); + + if (!trip) return null; + + async function saveName() { + if (!nameDraft.trim()) return; + // Close the editor only on success — a 429/500/network failure keeps it open so the typed draft + // isn't silently discarded. + if (await rename(nameDraft)) setEditingName(false); + } + async function saveOrigin(body: { place?: string; clearOrigin?: boolean; returnToOrigin?: boolean }) { + const ok = await setOrigin(body); + if (ok) { + setEditingOrigin(false); + setOriginDraft(''); + } + } + + const hrs = (min: number | null | undefined) => (min == null ? null : Math.round((min / 60) * 10) / 10); + + return ( + <> + {editingName ? ( + + setNameDraft(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && saveName()} + /> + + + + ) : ( + + {decodeEntities(trip.name)} + {(() => { + // Persistent alert-count badge on the trip header (P1.2) so safety items are visible at a glance. + const n = alerts ? alerts.reduce((sum, a) => sum + a.alerts.length, 0) : 0; + return n > 0 ? ( + + {n} alert{n === 1 ? '' : 's'} + + ) : null; + })()} + + + )} + {/* Compact running total (ADR-076): same TripMetrics the map chip renders, phrased without the + chip's " stop(s)" string. Gives the mobile typeahead-add same-pane feedback. */} + {metrics && metrics.stops > 0 && (metrics.driveMiles > 0 || metrics.costTotal > 0 || metrics.darkHoursTotal != null) ? ( + + {metrics.driveMiles > 0 ? {Math.round(metrics.driveMiles)} mi · {hrs(metrics.driveMinutes)} h drive : null} + {metrics.costTotal > 0 ? ${metrics.costTotal} fees : null} + {metrics.darkHoursTotal != null ? {Math.round(metrics.darkHoursTotal)} dark h : null} + + ) : null} + {/* Trip origin (ADR-074): defaults from the saved home location; editable per trip (fly-in trips), + with a round-trip toggle that adds the drive home to the route. */} + + + {editingOrigin ? ( + <> + setOriginDraft(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && originDraft.trim() && saveOrigin({ place: originDraft.trim() })} + /> + + + + ) : trip.origin ? ( + <> + + Starts from {trip.origin.label ?? 'your start point'} + + + + + + ) : ( + <> + No start point — route begins at the first stop. + + + )} + + + ); +} diff --git a/components/plan/TripInsights.tsx b/components/plan/TripInsights.tsx new file mode 100644 index 0000000..5d3d774 --- /dev/null +++ b/components/plan/TripInsights.tsx @@ -0,0 +1,49 @@ +'use client'; +import { Box, HStack, Text } from '@chakra-ui/react'; +import { TripDashboardCard } from '../conditions/ConditionCards'; +import { AlertList } from '../chat/Cards'; +import { useTripBuilder } from './useTripBuilder'; + +/** + * The result cards the action row produces (alerts / cost / conditions). Attaches the provider's reveal + * refs so the check ops can scroll their card into view when the button produced it (never on the + * auto-loaded open-trip alerts) — the one place the ops layer touches this pane's DOM (ADR-076). + */ +export function TripInsights() { + const { trip, alerts, cost, costErr, dashboard, alertsRef, costRef, condRef } = useTripBuilder(); + if (!trip) return null; + + return ( + <> + {/* Structured, shared alert card (P1.2) — same component the chat uses, instead of a bespoke list. */} + {alerts ? : null} + {cost ? ( + + + Estimated entrance fees + ${cost.total.toFixed(0)} + + {cost.holdsAtb ? ( + + Covered by your America the Beautiful annual pass. ✓ + + ) : cost.atbSaves ? ( + + The ${cost.atbPrice} America the Beautiful annual pass would save you ${(cost.perPark.reduce((s, p) => s + p.fee, 0) - cost.atbPrice).toFixed(0)} here. + + ) : ( + Per-vehicle entrance fees, summed across your parks. + )} + + ) : null} + {costErr ? {costErr} : null} + {dashboard ? ( + dashboard.stops.length === 0 ? ( + Add a park stop to see its conditions. + ) : ( + } /> + ) + ) : null} + + ); +} diff --git a/components/plan/TripMap.tsx b/components/plan/TripMap.tsx index 874aedb..a6199f5 100644 --- a/components/plan/TripMap.tsx +++ b/components/plan/TripMap.tsx @@ -33,7 +33,9 @@ export function TripMap({ stops, origin }: { stops: TripMapStop[]; origin?: Trip registerMapProtocols(); let map: MlMap; try { - map = new maplibregl.Map({ container, style: mapStyle(colorMode === 'dark' ? 'dark' : 'light'), center: US_CENTER, zoom: 3 }); + // cooperativeGestures — this map embeds in scrollable pages (trip builder, shared trip, park pages), + // where a bare map hijacks one-finger scrolling on touch and wheel-scrolling on desktop. + map = new maplibregl.Map({ container, style: mapStyle(colorMode === 'dark' ? 'dark' : 'light'), center: US_CENTER, zoom: 3, cooperativeGestures: true }); attachBasemapFallback(map); } catch (err) { console.warn('[TripMap] map unavailable (WebGL?):', (err as Error).message); diff --git a/components/plan/TripSwitcher.tsx b/components/plan/TripSwitcher.tsx new file mode 100644 index 0000000..0ebfdff --- /dev/null +++ b/components/plan/TripSwitcher.tsx @@ -0,0 +1,73 @@ +'use client'; +import { useState } from 'react'; +import { Button, Flex, HStack, Input, Icon, Menu, Portal } from '@chakra-ui/react'; +import { LuChevronDown } from 'react-icons/lu'; +import { decodeEntities } from '../../lib/html-entities'; +import { useTripBuilder } from './useTripBuilder'; + +/** How many trips render as flat chips before the switcher collapses into a Menu (F9: the chip row used + * to wrap and eat the pane once a user had a handful of trips). */ +const CHIP_LIMIT = 6; + +/** + * Create-a-trip input + the open-trip switcher. Chips up to CHIP_LIMIT trips; beyond that, a Menu keyed + * by the same accessible names (`{name} ({stops})`) so the e2e chip selectors keep working for small + * fixtures while real accounts stop losing the pane to wrapping chips. + */ +export function TripSwitcher() { + const { trips, trip, openingId, openTrip, create } = useTripBuilder(); + const [newName, setNewName] = useState(''); + + async function handleCreate() { + if (!newName.trim()) return; + const name = newName; + setNewName(''); + await create(name); + } + + return ( + <> + + {/* fontSize ≥16px on base: iOS Safari auto-zooms any focused input below 16px (same on every input here). */} + setNewName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleCreate()} /> + + + {trips.length > CHIP_LIMIT ? ( + + + + + + + + {trips.map((t) => ( + openTrip(t.id)}> + {decodeEntities(t.name)} ({t.stops}) + + ))} + + + + + ) : ( + + {trips.map((t) => ( + + ))} + + )} + + ); +} diff --git a/components/plan/useTripBuilder.tsx b/components/plan/useTripBuilder.tsx new file mode 100644 index 0000000..6c3807d --- /dev/null +++ b/components/plan/useTripBuilder.tsx @@ -0,0 +1,705 @@ +'use client'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from 'react'; +import { toast } from '../../lib/toast'; +import { sameIdSet, tripDayLoads, type DayLoad } from '../../lib/itinerary'; +import type { TripDashboard } from '../../lib/conditions'; +import type { TripMetrics } from '../../lib/trip-lab'; +import type { TripMapStop, TripMapOrigin } from '../../lib/trip-map-render'; + +/** + * The single trip-state store for /plan (ADR-076). `TripBuilderProvider` mounts ONCE inside PlanShell, + * wrapping all three panes (itinerary / map / chat cells) so the itinerary composition, the map canvas, + * and the tab bar read one `trips`/`trip`/`metrics` truth — the pre-shell TripBuilder owned all of this + * as one 900-line component. ChatPanel deliberately does NOT consume this context (it's reused standalone + * by the Ranger School lesson player); chat ↔ builder coupling stays on the `trailgraph:*` window events. + * + * Design rules carried from the monolith: + * • Canvas props (`canvasStops`/`canvasOrigin`/`addedParkCodes`) are memoized keyed on `trip` — fresh + * array identities each render would restart MapTripCanvas's route-draw animation (#9 MEDIUM-1). + * • `trailgraph:active-trip` dispatches from HERE, exactly once app-wide (multiple consumers of the + * context must not each broadcast it). + * • Every op goes through `postOp` for uniform 401/429 handling (ADR-076): a 429 always surfaces as the + * `rateLimited` toast — the monolith let rename/suggest-days fail silently and `checkAlerts` render a + * false "no alerts" empty state on a 429. + * • A ranger-driven `refreshTrip` PRESERVES ephemeral client state: the unsaved `dayMap` survives while + * the stop-id set is unchanged, and a refresh landing mid-drag is stashed + applied on drop (never + * yank the list out from under a finger). + */ +export interface TripHike { + id: string; + name: string; + lengthMiles?: number | null; + estTimeHrs?: number | null; + difficulty?: string | null; + permitRequired?: boolean; +} + +export interface Stop { + id: string; + order: number; + day?: number | null; + parkCode?: string | null; + parkName?: string; + campgroundName?: string; + poiTitle?: string; + placeTitle?: string; + name?: string; + lat?: number | null; + lng?: number | null; + hikes?: TripHike[]; + lodging?: { id: string; name: string; feeUSD?: number | null; reservationUrl?: string | null } | null; + driveTo?: { miles: number; minutes: number; source: string } | null; +} + +export interface Trip { + id: string; + name: string; + startDate?: string | null; + endDate?: string | null; + // Trip origin (defaults from the user's home; editable per trip) + its computed drive legs. + origin?: { lat: number; lng: number; label: string | null } | null; + returnToOrigin?: boolean; + originLeg?: { miles: number; minutes: number; source: string } | null; + returnLeg?: { miles: number; minutes: number; source: string } | null; + stops: (Stop | null)[]; +} + +export interface TripSummary { + id: string; + name: string; + stops: number; +} + +export interface TripAlerts { + park: string; + alerts: { category: string; title: string }[]; +} + +export interface TripCost { + perPark: { parkCode: string; parkName: string; fee: number }[]; + total: number; + atbPrice: number; + holdsAtb: boolean; + atbSaves: boolean; +} + +/** Display label for any stop kind (park / campground / POI / place / custom). */ +export const stopLabel = (s: Stop): string => + s.parkName ?? s.placeTitle ?? s.campgroundName ?? s.poiTitle ?? s.name ?? 'Stop'; + +/** Touch-friendly sizing: ≥40px targets on mobile (Chakra token 10), compact on md+ where a pointer is precise. */ +export const touchTarget = { minW: { base: '10', md: '6' }, minH: { base: '10', md: '6' } } as const; + +const liveStops = (trip: Trip | null): Stop[] => ((trip?.stops ?? []).filter(Boolean) as Stop[]); + +type OpResult = { ok: true; data: T } | { ok: false; status: number }; + +export interface TripBuilderValue { + trips: TripSummary[]; + trip: Trip | null; + stops: Stop[]; + metrics: TripMetrics | null; + alerts: TripAlerts[] | null; + cost: TripCost | null; + costErr: string | null; + dashboard: TripDashboard | null; + dayMap: Record; + shareUrl: string | null; + err: string | null; + busyOps: Set; + openingId: string | null; + overPackedDays: DayLoad[]; + // Memoized map-canvas inputs (identity-stable per trip — see the header comment). + canvasStops: TripMapStop[]; + canvasOrigin: TripMapOrigin | null; + addedParkCodes: string[]; + // Result-card reveal targets (TripInsights attaches them; the check ops scroll to them). + alertsRef: RefObject; + costRef: RefObject; + condRef: RefObject; + // Ops. + loadTrips: () => Promise; + openTrip: (id: string) => Promise; + create: (name: string) => Promise; + rename: (name: string) => Promise; + addPark: (code: string) => Promise; + removeStop: (stopId: string) => Promise; + removeHike: (stopId: string, trailId: string) => Promise; + removeLodging: (stopId: string, campgroundId: string) => Promise; + persistReorder: (orderedStopIds: string[]) => Promise<'ok' | 'unchanged' | 'limited'>; + checkAlerts: () => Promise; + checkCost: () => Promise; + checkConditions: () => Promise; + share: () => Promise; + optimizeRoute: () => Promise; + fork: () => Promise; + setOrigin: (body: { place?: string; clearOrigin?: boolean; returnToOrigin?: boolean }) => Promise; + suggestDayPlan: () => Promise; + applyMutation: (data: { trip?: Trip | null; metrics?: TripMetrics | null }) => void; + // Drag choreography (StopList): a refresh landing mid-drag is stashed; endDrag applies it and reports + // whether it did (true → the drag order is stale, skip persisting it). + beginDrag: () => void; + endDrag: () => boolean; +} + +const TripBuilderContext = createContext(null); + +export function useTripBuilder(): TripBuilderValue { + const ctx = useContext(TripBuilderContext); + if (!ctx) throw new Error('useTripBuilder must be used inside '); + return ctx; +} + +export function TripBuilderProvider({ children }: { children: ReactNode }) { + const [trips, setTrips] = useState([]); + const [trip, setTrip] = useState(null); + const [alerts, setAlerts] = useState(null); + const [cost, setCost] = useState(null); + const [costErr, setCostErr] = useState(null); + const [dashboard, setDashboard] = useState(null); + // Live running-total badge for the build-on-map canvas (#9): updated from every mutation response. + const [metrics, setMetrics] = useState(null); + const [dayMap, setDayMap] = useState>({}); + const [shareUrl, setShareUrl] = useState(null); + const [err, setErr] = useState(null); + // Per-op busy flags (a Set: ops can overlap) drive `loading` on the action buttons. openingId does the + // same for trip chips. + const [busyOps, setBusyOps] = useState>(new Set()); + const [openingId, setOpeningId] = useState(null); + // Result cards render below the action row — scroll each into view when its button produced it (never + // on the auto-loaded open-trip alerts). + const alertsRef = useRef(null); + const costRef = useRef(null); + const condRef = useRef(null); + + // Stable-callback plumbing: ops are useCallback([]) and read the CURRENT trip through this ref (the + // old monolith's [] listeners closed over first-render state — the openTrip idempotence check always + // compared against null). + const tripRef = useRef(null); + tripRef.current = trip; + // Mid-drag refresh stash (ADR-076): see beginDrag/endDrag. + const draggingRef = useRef(false); + const pendingRefreshRef = useRef<{ trip: Trip; metrics: TripMetrics | null } | null>(null); + // Trips the ranger already auto-opened once (the once-per-trip auto-open UX survives the retuned + // per-message event — later edits refresh without yanking the user back to that trip). + const autoOpenedRef = useRef>(new Set()); + + function opStart(k: string) { + setBusyOps((prev) => new Set(prev).add(k)); + } + function opEnd(k: string) { + setBusyOps((prev) => { + const next = new Set(prev); + next.delete(k); + return next; + }); + } + const reveal = useCallback((ref: RefObject) => { + // Next frame: the card must be in the DOM before it can be scrolled to. + requestAnimationFrame(() => ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })); + }, []); + + /** Shared op runner (ADR-076): one place surfaces 401/429 for EVERY trip op — the monolith let some ops + * fail silently and checkAlerts render a false "no alerts" on a 429. `silent` skips the 429 toast for + * non-user-initiated calls (the auto-alerts on open). Other failures return {ok:false} for the caller's + * own error UX. */ + const postOp = useCallback(async function postOp( + tripId: string, + body: Record, + opts: { silent?: boolean } = {}, + ): Promise> { + let res: Response; + try { + res = await fetch(`/api/trips/${encodeURIComponent(tripId)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch { + return { ok: false, status: 0 }; + } + if (res.status === 429) { + if (!opts.silent) toast.info('Slow down — too many trip edits. Try again in a moment.'); + return { ok: false, status: 429 }; + } + if (res.status === 401) { + setErr('Sign in to plan trips.'); + return { ok: false, status: 401 }; + } + if (!res.ok) return { ok: false, status: res.status }; + return { ok: true, data: (await res.json()) as T }; + }, []); + + const loadTrips = useCallback(async () => { + const res = await fetch('/api/trips'); + if (res.status === 401) { + setErr('Sign in to plan trips.'); + return; + } + const { trips: list } = await res.json(); + setTrips(list ?? []); + }, []); + + // Every stop mutation returns the fresh trip + live metrics (#9) — apply both, invalidate the now-stale + // cost card, and refresh the sidebar stop counts. Shared by the canvas, search-add, remove, and reorder. + const applyMutation = useCallback( + (data: { trip?: Trip | null; metrics?: TripMetrics | null }) => { + if (data.trip) setTrip(data.trip); + // Keep the prior badge if metrics recompute transiently failed (null) — don't blink it off (#9 LOW-4). + setMetrics((prev) => data.metrics ?? prev); + setCost(null); + setCostErr(null); + void loadTrips(); + }, + [loadTrips], + ); + + const openTrip = useCallback( + async (id: string) => { + if (tripRef.current?.id === id) return; // idempotent: re-clicking the open trip is a no-op, never deselects (§4.6) + setOpeningId(id); + try { + const res = await fetch(`/api/trips/${encodeURIComponent(id)}?include=metrics`); + const { trip: opened, metrics: m } = await res.json(); + setTrip(opened); + setMetrics(m ?? null); + setAlerts(null); + setCost(null); + setCostErr(null); + setDashboard(null); + setDayMap({}); + pendingRefreshRef.current = null; + } finally { + setOpeningId(null); + } + // Auto-load Closure/Danger alerts on open so safety items pin to the trip artifact without an extra + // click (P1.2). openTrip is idempotent (early-returns on the same id), so this never loops. Silent: + // a 429 here isn't user-initiated (and no longer competes with edits — the reads budget, ADR-076). + const r = await postOp<{ alerts?: TripAlerts[] }>(id, { op: 'alerts' }, { silent: true }); + if (r.ok && Array.isArray(r.data.alerts)) setAlerts(r.data.alerts); + }, + [postOp], + ); + + /** Force-refetch the OPEN trip after a ranger edit (ADR-076) — bypasses openTrip's idempotence guard, + * preserves the unsaved day plan while the stop-id set is unchanged, and defers mid-drag. */ + const refreshTrip = useCallback(async (id: string) => { + if (tripRef.current?.id !== id) return; + const res = await fetch(`/api/trips/${encodeURIComponent(id)}?include=metrics`); + if (!res.ok) return; + const { trip: fresh, metrics: m } = (await res.json()) as { trip: Trip | null; metrics: TripMetrics | null }; + if (!fresh || tripRef.current?.id !== fresh.id) return; // the user switched trips mid-fetch + if (draggingRef.current) { + pendingRefreshRef.current = { trip: fresh, metrics: m ?? null }; + return; + } + const prevIds = liveStops(tripRef.current).map((s) => s.id); + const nextIds = liveStops(fresh).map((s) => s.id); + setTrip(fresh); + setMetrics((prev) => m ?? prev); + setCost(null); + setCostErr(null); + if (!sameIdSet(prevIds, nextIds)) setDayMap({}); + }, []); + + const create = useCallback( + async (name: string) => { + if (!name.trim()) return; + const res = await fetch('/api/trips', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + if (res.status === 401) { + setErr('Sign in to plan trips.'); + return; + } + const { id } = await res.json(); + await loadTrips(); + await openTrip(id); + }, + [loadTrips, openTrip], + ); + + const rename = useCallback( + async (name: string): Promise => { + const t = tripRef.current; + if (!t || !name.trim()) return false; + const r = await postOp<{ trip?: Trip | null }>(t.id, { op: 'rename', name: name.trim() }); + if (r.ok && r.data.trip) { + setTrip(r.data.trip); + void loadTrips(); + return true; + } + return false; // 401/429/500 → caller keeps the rename editor open so the draft isn't lost + }, + [postOp, loadTrips], + ); + + const addPark = useCallback( + async (code: string) => { + const t = tripRef.current; + if (!t || !code) return; + const r = await postOp<{ trip?: Trip | null; metrics?: TripMetrics | null }>(t.id, { + op: 'addStop', + stop: { kind: 'park', refId: code }, + }); + if (r.ok) applyMutation(r.data); + }, + [postOp, applyMutation], + ); + + const removeStop = useCallback( + async (stopId: string) => { + const t = tripRef.current; + if (!t) return; + const r = await postOp<{ trip?: Trip | null; metrics?: TripMetrics | null }>(t.id, { op: 'removeStop', stopId }); + if (r.ok) applyMutation(r.data); + }, + [postOp, applyMutation], + ); + + // Detach a hike from a stop (ADR-071) — `(:Stop)-[:INCLUDES_TRAIL]->(:Trail)`. + const removeHike = useCallback( + async (stopId: string, trailId: string) => { + const t = tripRef.current; + if (!t) return; + const r = await postOp<{ trip?: Trip | null }>(t.id, { op: 'excludeTrail', stopId, trailId }); + if (r.ok) applyMutation(r.data); + }, + [postOp, applyMutation], + ); + + // Detach lodging from a stop (Campgrounds feature) — `(:Stop)-[:STAYS_AT]->(:Campground)`. + const removeLodging = useCallback( + async (stopId: string, campgroundId: string) => { + const t = tripRef.current; + if (!t) return; + const r = await postOp<{ trip?: Trip | null }>(t.id, { op: 'excludeCampground', stopId, campgroundId }); + if (r.ok) applyMutation(r.data); + }, + [postOp, applyMutation], + ); + + // Persist a drag-reorder on drop (#9). Skip the round-trip if the order didn't actually change; a 429 + // reports 'limited' so StopList reverts its optimistic order. + const persistReorder = useCallback( + async (orderedStopIds: string[]): Promise<'ok' | 'unchanged' | 'limited'> => { + const t = tripRef.current; + if (!t) return 'unchanged'; + const current = liveStops(t).map((s) => s.id); + if (orderedStopIds.length !== current.length || orderedStopIds.every((id, i) => id === current[i])) return 'unchanged'; + const r = await postOp<{ trip?: Trip | null; metrics?: TripMetrics | null }>(t.id, { op: 'reorder', orderedStopIds }); + if (!r.ok) return r.status === 429 ? 'limited' : 'unchanged'; + setDayMap({}); // the route order changed → the prior day grouping no longer maps cleanly (#9 LOW-2) + applyMutation(r.data); + return 'ok'; + }, + [postOp, applyMutation], + ); + + const checkAlerts = useCallback(async () => { + const t = tripRef.current; + if (!t) return; + opStart('alerts'); + try { + const r = await postOp<{ alerts?: TripAlerts[] }>(t.id, { op: 'alerts' }); + // Only apply on success (ADR-076): the monolith set [] from an undefined 429 body — a rate limit + // rendered as a reassuring "no alerts". + if (r.ok) { + setAlerts(r.data.alerts ?? []); + reveal(alertsRef); + } + } finally { + opEnd('alerts'); + } + }, [postOp, reveal]); + + const checkCost = useCallback(async () => { + const t = tripRef.current; + if (!t) return; + opStart('cost'); + try { + setCost(null); + setCostErr(null); + const r = await postOp<{ cost?: TripCost | null }>(t.id, { op: 'cost' }); + if (!r.ok) { + if (r.status !== 429) setCostErr(r.status === 0 ? 'Network error. Please try again.' : 'Failed to estimate trip cost. Please try again.'); + return; + } + setCost(r.data.cost ?? null); + reveal(costRef); + } finally { + opEnd('cost'); + } + }, [postOp, reveal]); + + const checkConditions = useCallback(async () => { + const t = tripRef.current; + if (!t) return; + opStart('conditions'); + setDashboard(null); + try { + const r = await postOp<{ dashboard: TripDashboard | null }>(t.id, { op: 'conditions' }); + if (!r.ok) { + if (r.status !== 429) toast.error('Could not load trip conditions.'); + return; + } + setDashboard(r.data.dashboard); + reveal(condRef); + } finally { + opEnd('conditions'); + } + }, [postOp, reveal]); + + const share = useCallback(async () => { + const t = tripRef.current; + if (!t) return; + opStart('share'); + const res = await fetch(`/api/trips/${encodeURIComponent(t.id)}/share`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: 'read' }), + }).finally(() => opEnd('share')); + if (res.ok) { + const { url } = (await res.json()) as { url: string }; + const full = `${window.location.origin}${url}`; + setShareUrl(full); + let copied = false; + if (navigator.clipboard?.writeText) { + copied = await navigator.clipboard.writeText(full).then(() => true).catch(() => false); + } + if (copied) { + toast.success('Share link copied', 'A read-only link to this trip is on your clipboard.'); + } else { + toast.success('Share link ready', 'Copy the read-only link shown below.'); + } + } else { + toast.error("Couldn't create share link", 'Please try again in a moment.'); + } + }, []); + + const optimizeRoute = useCallback(async () => { + const t = tripRef.current; + if (!t) return; + opStart('optimize'); + try { + const r = await postOp<{ trip?: Trip | null; metrics?: TripMetrics | null }>(t.id, { op: 'optimize' }); + if (r.ok) { + if (r.data.trip) setDayMap({}); // route changed → the prior day grouping no longer maps cleanly + applyMutation(r.data); + } + } finally { + opEnd('optimize'); + } + }, [postOp, applyMutation]); + + // Trip Lab (ADR-056): fork the open trip into a copy and switch to it, leaving the original untouched. + const fork = useCallback(async () => { + const t = tripRef.current; + if (!t) return; + opStart('fork'); + try { + const r = await postOp<{ tripId?: string; trip?: Trip | null }>(t.id, { op: 'fork' }); + if (!r.ok) { + if (r.status !== 429) toast.error("Couldn't fork this trip", 'Please try again in a moment.'); + return; + } + await loadTrips(); + if (r.data.trip) { + setTrip(r.data.trip); + setAlerts(null); + setCost(null); + setCostErr(null); + setDashboard(null); + setDayMap({}); + } else if (r.data.tripId) { + await openTrip(r.data.tripId); + } + toast.success('Trip forked', 'Editing the copy — your original is untouched.'); + } finally { + opEnd('fork'); + } + }, [postOp, loadTrips, openTrip]); + + // Trip origin (defaults from home): set from free text (geocoded server-side), toggle the round trip, + // or clear back to "starts at the first stop". Returns whether the edit applied (TripHeader closes its + // editor only on success). + const setOrigin = useCallback( + async (body: { place?: string; clearOrigin?: boolean; returnToOrigin?: boolean }): Promise => { + const t = tripRef.current; + if (!t) return false; + const r = await postOp<{ trip?: Trip | null; metrics?: TripMetrics | null }>(t.id, { op: 'setOrigin', ...body }); + if (!r.ok) { + if (r.status === 404 && body.place) { + toast.info(`Couldn't find "${body.place}" — try a nearby city or town.`); + } + return false; + } + applyMutation(r.data); + return true; + }, + [postOp, applyMutation], + ); + + const suggestDayPlan = useCallback(async () => { + const t = tripRef.current; + if (!t) return; + opStart('days'); + try { + const r = await postOp<{ days?: { id: string; day: number }[] }>(t.id, { op: 'suggestDays' }); + if (r.ok) setDayMap(Object.fromEntries((r.data.days ?? []).map((d) => [d.id, d.day]))); + } finally { + opEnd('days'); + } + }, [postOp]); + + // Drag choreography (ADR-076): while a stop row is mid-drag, an incoming ranger refresh is stashed + // instead of replacing the list under the user's finger. endDrag applies the stash — the fresh server + // trip wins over the (now stale) drag order — and returns true so StopList skips persisting the drag. + const beginDrag = useCallback(() => { + draggingRef.current = true; + }, []); + const endDrag = useCallback((): boolean => { + draggingRef.current = false; + const pending = pendingRefreshRef.current; + if (!pending) return false; + pendingRefreshRef.current = null; + const prevIds = liveStops(tripRef.current).map((s) => s.id); + const nextIds = liveStops(pending.trip).map((s) => s.id); + setTrip(pending.trip); + setMetrics((prev) => pending.metrics ?? prev); + setCost(null); + setCostErr(null); + if (!sameIdSet(prevIds, nextIds)) setDayMap({}); + toast.info('The ranger updated this trip — list refreshed.'); + return true; + }, []); + + // Mount: load the sidebar + honor the /plan?trip= deep link (used by "Start a trip from this tour"). + useEffect(() => { + void loadTrips(); + const id = new URLSearchParams(window.location.search).get('trip'); + if (id) void openTrip(id); + }, [loadTrips, openTrip]); + + // Live-refresh when a ranger turn changes a trip (F8, retuned by ADR-076). ChatPanel dispatches per + // (assistant message, trip). Behavior: always refresh the sidebar; a NEVER-seen trip auto-opens once + // (the original save-and-open UX); an edit to the OPEN trip force-refetches it (the monolith's openTrip + // idempotence guard silently dropped these — the builder went stale on every ranger edit after the + // first save); an edit to some OTHER already-seen trip only refreshes the sidebar (never yank the user). + useEffect(() => { + function onChanged(e: Event) { + void loadTrips(); + const id = (e as CustomEvent<{ tripId?: string }>).detail?.tripId; + if (!id) return; + if (tripRef.current?.id === id) { + void refreshTrip(id); + } else if (!autoOpenedRef.current.has(id)) { + autoOpenedRef.current.add(id); + void openTrip(id); + } + } + window.addEventListener('trailgraph:trips-changed', onChanged); + return () => window.removeEventListener('trailgraph:trips-changed', onChanged); + }, [loadTrips, openTrip, refreshTrip]); + + // Broadcast the open trip's id + dates to the ranger chat (P2.1). ChatPanel attaches them as ephemeral + // Eve client context on every send, so dated dark-sky/astro/best-time answers reflect the trip window + // (the best night in it) instead of "tonight". Fires on open/create/rename/deselect (all call setTrip). + // Lives in the PROVIDER so it dispatches exactly once app-wide no matter how many panes consume the + // context (ADR-076). + useEffect(() => { + window.dispatchEvent( + new CustomEvent('trailgraph:active-trip', { + detail: trip + ? { id: trip.id, name: trip.name, startDate: trip.startDate ?? null, endDate: trip.endDate ?? null } + : null, + }), + ); + }, [trip?.id, trip?.name, trip?.startDate, trip?.endDate]); + + const stops = useMemo(() => liveStops(trip), [trip]); + // Memoize the canvas props keyed on `trip` (not on every render) — otherwise a rename keystroke would + // hand MapTripCanvas fresh array identities and restart its route-draw animation (#9 MEDIUM-1). + const canvasStops = useMemo( + () => liveStops(trip).map((s) => ({ lat: s.lat ?? null, lng: s.lng ?? null, label: stopLabel(s), order: s.order })), + [trip], + ); + const addedParkCodes = useMemo( + () => liveStops(trip).map((s) => s.parkCode).filter((code): code is string => !!code), + [trip], + ); + const canvasOrigin = useMemo( + () => + trip?.origin + ? { lat: trip.origin.lat, lng: trip.origin.lng, label: trip.origin.label, roundTrip: trip.returnToOrigin ?? false } + : null, + [trip], + ); + // Schedule-aware "over-packed day" warning (ADR-071): aggregate each day's hike hours + drive hours and + // flag the days over ~8 h. Uses dayMap (Suggest day plan) or the stop's persisted day; empty until days exist. + const overPackedDays = useMemo(() => { + const loadStops = liveStops(trip).map((s) => ({ + day: dayMap[s.id] ?? s.day ?? null, + driveMinutesToHere: s.driveTo?.minutes ?? 0, + hikeMiles: (s.hikes ?? []).reduce((m, h) => m + (h.lengthMiles ?? 0), 0), + hikeHours: (s.hikes ?? []).reduce((m, h) => m + (h.estTimeHrs ?? 0), 0), + })); + return tripDayLoads(loadStops).filter((d) => d.overPacked); + }, [trip, dayMap]); + + const value = useMemo( + () => ({ + trips, + trip, + stops, + metrics, + alerts, + cost, + costErr, + dashboard, + dayMap, + shareUrl, + err, + busyOps, + openingId, + overPackedDays, + canvasStops, + canvasOrigin, + addedParkCodes, + alertsRef, + costRef, + condRef, + loadTrips, + openTrip, + create, + rename, + addPark, + removeStop, + removeHike, + removeLodging, + persistReorder, + checkAlerts, + checkCost, + checkConditions, + share, + optimizeRoute, + fork, + setOrigin, + suggestDayPlan, + applyMutation, + beginDrag, + endDrag, + }), + [ + trips, trip, stops, metrics, alerts, cost, costErr, dashboard, dayMap, shareUrl, err, busyOps, openingId, + overPackedDays, canvasStops, canvasOrigin, addedParkCodes, + loadTrips, openTrip, create, rename, addPark, removeStop, removeHike, removeLodging, persistReorder, + checkAlerts, checkCost, checkConditions, share, optimizeRoute, fork, setOrigin, suggestDayPlan, + applyMutation, beginDrag, endDrag, + ], + ); + + return {children}; +} diff --git a/lib/chat-trips.test.ts b/lib/chat-trips.test.ts new file mode 100644 index 0000000..3e8ce3a --- /dev/null +++ b/lib/chat-trips.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { tripIdsFromParts } from './chat-trips'; + +const outputPart = (output: unknown, state = 'output-available') => ({ type: 'dynamic-tool', state, output }); + +describe('tripIdsFromParts', () => { + it('extracts the trip id from a saved itinerary_preview', () => { + const parts = [outputPart({ kind: 'itinerary_preview', data: { trip: { id: 't1', name: 'Utah Loop' } } })]; + expect(tripIdsFromParts(parts)).toEqual(['t1']); + }); + + it('extracts the trip id from a confirmed nested add (addedTo), any card kind', () => { + const parts = [ + outputPart({ kind: 'trail_detail_card', data: { name: 'Storm Point', addedTo: { tripId: 't2', stopLabel: 'Yellowstone' } } }), + outputPart({ kind: 'campground_card', data: { campground: {}, addedTo: { tripId: 't3', stopLabel: 'Zion' } } }), + ]; + expect(tripIdsFromParts(parts)).toEqual(['t2', 't3']); + }); + + it('never announces a preview: pendingAdd carries a tripId but wrote nothing', () => { + const parts = [ + outputPart({ kind: 'trail_detail_card', data: { name: 'Storm Point', pendingAdd: { tripId: 't2', stopId: 's1' } } }), + ]; + expect(tripIdsFromParts(parts)).toEqual([]); + }); + + it('ignores draft itinerary_preview outputs without a trip id (propose_itinerary)', () => { + const parts = [outputPart({ kind: 'itinerary_preview', data: { trip: { name: 'Draft' } } })]; + expect(tripIdsFromParts(parts)).toEqual([]); + }); + + it('ignores a read-only itinerary_preview (suggest_day_plan persists nothing)', () => { + const parts = [outputPart({ kind: 'itinerary_preview', data: { readOnly: true, trip: { id: 't1' } } })]; + expect(tripIdsFromParts(parts)).toEqual([]); + }); + + it('ignores parts that are not completed dynamic-tool outputs', () => { + const parts = [ + { type: 'text', text: 'Saved your trip t1!' }, + outputPart({ kind: 'itinerary_preview', data: { trip: { id: 't1' } } }, 'input-available'), + ]; + expect(tripIdsFromParts(parts as never)).toEqual([]); + }); + + it('dedups ids within one message and rejects non-string ids', () => { + const parts = [ + outputPart({ kind: 'itinerary_preview', data: { trip: { id: 't1' } } }), + outputPart({ kind: 'itinerary_preview', data: { trip: { id: 't1' } } }), + outputPart({ kind: 'trail_detail_card', data: { addedTo: { tripId: 42 } } }), + ]; + expect(tripIdsFromParts(parts)).toEqual(['t1']); + }); +}); diff --git a/lib/chat-trips.ts b/lib/chat-trips.ts new file mode 100644 index 0000000..72cc333 --- /dev/null +++ b/lib/chat-trips.ts @@ -0,0 +1,41 @@ +/** + * Bridge the ranger chat → the trip builder (ADR-076): pull the trip ids a ranger turn actually CHANGED + * out of a message's tool outputs, so ChatPanel can dispatch `trailgraph:trips-changed` per edit (deduped + * per assistant message, not once-per-trip — the old per-trip dedup missed every edit after a trip's + * first save). Two output shapes mark a confirmed write: + * • `itinerary_preview` with `data.trip.id` — build_itinerary / add_stop / start_trip_from_tour return + * the SAVED trip. `suggest_day_plan` ALSO returns one (so the chat render-dedup collapses it) but + * persists NOTHING — it sets `data.readOnly`, which we skip here; + * • any kind with `data.addedTo.tripId` — the confirmed nested adds (add_trail_to_trip, + * add_campground_to_trip). Their `pendingAdd` previews also carry a tripId but wrote NOTHING, so + * they deliberately never announce. + * Mirrors `chat-parks.ts` (pure + tested). + */ +interface ToolPart { + type?: string; + state?: string; + output?: unknown; +} + +export function tripIdsFromParts(parts: ToolPart[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const part of parts ?? []) { + if (part.type !== 'dynamic-tool' || part.state !== 'output-available') continue; + const o = part.output as + | { kind?: string; data?: { readOnly?: unknown; trip?: { id?: unknown }; addedTo?: { tripId?: unknown } } } + | undefined; + // `readOnly` outputs (suggest_day_plan) carry a trip.id for render-dedup but persist nothing — skip. + const savedTrip = + o?.kind === 'itinerary_preview' && o.data?.readOnly !== true && typeof o.data?.trip?.id === 'string' + ? o.data.trip.id + : null; + const nestedAdd = typeof o?.data?.addedTo?.tripId === 'string' ? o.data.addedTo.tripId : null; + const id = savedTrip ?? nestedAdd; + if (id && !seen.has(id)) { + seen.add(id); + out.push(id); + } + } + return out; +} diff --git a/lib/itinerary.test.ts b/lib/itinerary.test.ts index 4547d51..731f44b 100644 --- a/lib/itinerary.test.ts +++ b/lib/itinerary.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { suggestDays, suggestLodging, type LodgingCandidate } from './itinerary'; +import { sameIdSet, suggestDays, suggestLodging, type LodgingCandidate } from './itinerary'; describe('suggestLodging (Campgrounds feature)', () => { const cands = (over: Partial[]): LodgingCandidate[] => @@ -83,3 +83,17 @@ describe('suggestDays', () => { expect(days).toEqual([{ id: 'big', day: 1 }]); }); }); + +describe('sameIdSet (ADR-076 dayMap preservation)', () => { + it('matches the same ids regardless of order (a ranger reorder keeps the day plan)', () => { + expect(sameIdSet(['a', 'b', 'c'], ['c', 'a', 'b'])).toBe(true); + }); + it('rejects an add or remove (the day plan no longer maps)', () => { + expect(sameIdSet(['a', 'b'], ['a', 'b', 'c'])).toBe(false); + expect(sameIdSet(['a', 'b', 'c'], ['a', 'b'])).toBe(false); + expect(sameIdSet(['a', 'b'], ['a', 'c'])).toBe(false); + }); + it('treats empty lists as equal', () => { + expect(sameIdSet([], [])).toBe(true); + }); +}); diff --git a/lib/itinerary.ts b/lib/itinerary.ts index a07c635..c28f475 100644 --- a/lib/itinerary.ts +++ b/lib/itinerary.ts @@ -43,6 +43,17 @@ export function suggestDays( return out; } +/** + * Whether two stop-id lists contain the same ids, order-insensitive (stop ids are unique). The plan + * provider uses this to decide whether a background `refreshTrip` may keep the client-only day plan + * (ADR-076): same ids → the suggested days still map onto the stops; any add/remove → reset. + */ +export function sameIdSet(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + const set = new Set(a); + return b.every((id) => set.has(id)); +} + export interface DayLoadStop { day: number | null; driveMinutesToHere?: number | null; diff --git a/playwright.config.ts b/playwright.config.ts index f1f0801..cc5cfae 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,7 +27,25 @@ export default defineConfig({ // Software WebGL so MapLibre renders in headless CI. launchOptions: { args: ['--enable-unsafe-swiftshader', '--use-gl=swiftshader'] }, }, - projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + // Mobile project (ADR-076): Chromium-based emulation with iPhone-13 metrics, NOT `devices['iPhone 13']` + // — that descriptor defaults to WebKit, but CI installs chromium only and the shared swiftshader + // launchOptions above are Chromium-specific. Real-Safari fidelity waits for the Phase-2 gesture work. + // Scoped to the plan-surface specs the shell affects; the shared openPane() helper makes them + // layout-agnostic (it taps the tab bar only when it's visible). + { + name: 'mobile', + use: { + browserName: 'chromium', + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + }, + testMatch: ['**/plan-canvas.spec.ts', '**/plan-hardening.spec.ts', '**/home-origin.spec.ts', '**/plan-mobile.spec.ts'], + }, + ], webServer: { // Run e2e against a PRODUCTION build, not `pnpm dev`. Dev mode emits Emotion class-hash hydration // *false positives* (React dev double-renders + on-demand style insertion) that the hydration gate diff --git a/tests/e2e/dream-big-authed.spec.ts b/tests/e2e/dream-big-authed.spec.ts index 757c951..b9a4471 100644 --- a/tests/e2e/dream-big-authed.spec.ts +++ b/tests/e2e/dream-big-authed.spec.ts @@ -29,9 +29,11 @@ test('Trip Dashboard + GPX/ICS export return valid files (ADR-042/048)', async ( await page.getByRole('button', { name: 'Trip conditions' }).click(); await expect(page.getByText('Crowds').first()).toBeVisible(); // Yellowstone has a seeded crowd level - // Export endpoints: fetch the anchor hrefs and assert real file bodies. - const gpxHref = await page.getByRole('link', { name: 'Export .gpx' }).getAttribute('href'); - const icsHref = await page.getByRole('link', { name: 'Export .ics' }).getAttribute('href'); + // Export endpoints: the export anchors live in the "More" action menu (Plan UX Phase 0) — open it, + // then fetch the hrefs and assert real file bodies. Menu items are role=menuitem, not link. + await page.getByRole('button', { name: 'More', exact: true }).click(); + const gpxHref = await page.getByRole('menuitem', { name: 'Export .gpx' }).getAttribute('href'); + const icsHref = await page.getByRole('menuitem', { name: 'Export .ics' }).getAttribute('href'); expect(gpxHref).toMatch(/\/api\/trips\/.+\/gpx/); const gpx = await page.request.get(gpxHref!); expect(gpx.status()).toBe(200); diff --git a/tests/e2e/dream-big-flagships.spec.ts b/tests/e2e/dream-big-flagships.spec.ts index b0d67ae..6525b2a 100644 --- a/tests/e2e/dream-big-flagships.spec.ts +++ b/tests/e2e/dream-big-flagships.spec.ts @@ -60,18 +60,22 @@ test('Trip Lab: fork a trip + field brief / offline pack export (ADR-056/057)', await page.getByText('Yellowstone National Park').click(); await expect(page.getByText(/1\. Yellowstone/)).toBeVisible(); - // Fork → the builder switches to the "(copy)". `exact` avoids matching the "Lab Trip E2E" selector button. - await page.getByRole('button', { name: 'Fork', exact: true }).click(); + // Fork lives in the "More" action menu (Plan UX Phase 0) → open it, fork, builder switches to the + // "(copy)". Clicking a menu item closes the menu. + await page.getByRole('button', { name: 'More', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Fork', exact: true }).click(); await expect(page.getByRole('heading', { name: /Lab Trip E2E \(copy\)/ })).toBeVisible({ timeout: 15000 }); - // Field brief + offline pack endpoints return real artifacts. - const briefHref = await page.getByRole('link', { name: 'Field brief' }).getAttribute('href'); + // Field brief + offline pack endpoints return real artifacts. The anchors are menuitems in "More"; + // reading hrefs doesn't close the menu, so one open covers both. + await page.getByRole('button', { name: 'More', exact: true }).click(); + const briefHref = await page.getByRole('menuitem', { name: 'Field brief' }).getAttribute('href'); expect(briefHref).toMatch(/\/api\/trips\/.+\/brief/); const brief = await page.request.get(briefHref!); expect(brief.status()).toBe(200); expect(await brief.text()).toContain('field brief'); - const offlineHref = await page.getByRole('link', { name: 'Offline pack' }).getAttribute('href'); + const offlineHref = await page.getByRole('menuitem', { name: 'Offline pack' }).getAttribute('href'); const offline = await page.request.get(offlineHref!); expect(offline.status()).toBe(200); expect(offline.headers()['content-type']).toContain('application/zip'); diff --git a/tests/e2e/helpers/pane.ts b/tests/e2e/helpers/pane.ts new file mode 100644 index 0000000..d350e32 --- /dev/null +++ b/tests/e2e/helpers/pane.ts @@ -0,0 +1,20 @@ +import type { Page } from '@playwright/test'; + +/** + * Switch to a /plan pane on the mobile shell (ADR-076); a NO-OP on desktop, where all three panes are + * simultaneously visible and the tab bar is display:none. Lets one spec source serve both Playwright + * projects — sprinkle `openPane` before assertions that target a pane the mobile default (Itinerary) + * hides, instead of forking the spec per layout. + */ +export async function openPane(page: Page, pane: 'map' | 'itinerary' | 'ranger'): Promise { + const bar = page.getByTestId('plan-tab-bar'); + if (!(await bar.isVisible().catch(() => false))) return; + // Accessible names carry state suffixes ("Itinerary, 2 stops" / "Ranger, new activity") — match the prefix. + const name = { map: /^Map/, itinerary: /^Itinerary/, ranger: /^Ranger/ }[pane]; + await bar.getByRole('button', { name }).click(); +} + +/** Whether this run is the mobile (tabbed) layout — for skipping tab-bar-specific tests on desktop. */ +export function isMobileViewport(page: Page): boolean { + return (page.viewportSize()?.width ?? 1280) < 768; +} diff --git a/tests/e2e/plan-canvas.spec.ts b/tests/e2e/plan-canvas.spec.ts index 4cdb5c9..069dcbc 100644 --- a/tests/e2e/plan-canvas.spec.ts +++ b/tests/e2e/plan-canvas.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; +import { openPane } from './helpers/pane'; /** Fresh email+password user (E2E_TEST_MODE) so /plan is authenticated. */ async function signUp(page: Page): Promise { @@ -12,7 +13,9 @@ async function signUp(page: Page): Promise { /** * Build-on-map canvas (#9): adding parks bubbles the live running-total metrics badge. We add via the name * typeahead (deterministic; clicking the WebGL canvas isn't), and assert the badge (a DOM overlay) updates — - * which exercises the addStop → metrics-in-response → applyMutation → badge path end-to-end. + * which exercises the addStop → metrics-in-response → applyMutation → badge path end-to-end. Under the + * mobile shell (ADR-076) the badge lives in the Map pane, so openPane switches there before each badge + * assertion (a no-op on desktop, where all panes are visible). */ test('build-on-map: adding parks surfaces the live metrics badge (#9)', async ({ page }) => { await signUp(page); @@ -25,9 +28,13 @@ test('build-on-map: adding parks surfaces the live metrics badge (#9)', async ({ await search.fill('Yellowstone'); await page.getByText('Yellowstone National Park').click(); await expect(page.getByText(/1\. Yellowstone/)).toBeVisible(); - await expect(page.getByText(/1 stop\b/)).toBeVisible(); // live metrics badge + await openPane(page, 'map'); + await expect(page.getByText(/1 stop\b/)).toBeVisible(); // live metrics badge (map-pane overlay) + await openPane(page, 'itinerary'); await search.fill('Glacier'); await page.getByText('Glacier National Park').click(); + await expect(page.getByText(/2\. Glacier/)).toBeVisible(); + await openPane(page, 'map'); await expect(page.getByText(/2 stops/)).toBeVisible(); // badge updates as the plan grows }); diff --git a/tests/e2e/plan-hardening.spec.ts b/tests/e2e/plan-hardening.spec.ts index 9dbb931..5d2a393 100644 --- a/tests/e2e/plan-hardening.spec.ts +++ b/tests/e2e/plan-hardening.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { openPane } from './helpers/pane'; /** * Plan-ranger quality hardening — the non-ranger-dependent surfaces (e2e runs with DISABLE_EVE, so the @@ -19,6 +20,8 @@ async function signUp(page: import('@playwright/test').Page): Promise { test('the chat empty state offers the Surprise-me + Field-trip starters (P1.5/P2.4)', async ({ page }) => { await signUp(page); await page.goto('/plan'); + // Under the mobile shell the chat is a hidden pane behind the Ranger tab (no-op on desktop). + await openPane(page, 'ranger'); // The empty-state lead + the new starter chips (rendered statically, no ranger turn needed). await expect(page.getByText('Ask the ranger to plan a trip, find parks, or check conditions.')).toBeVisible(); await expect(page.getByText(/Surprise me/)).toBeVisible(); diff --git a/tests/e2e/plan-mobile.spec.ts b/tests/e2e/plan-mobile.spec.ts new file mode 100644 index 0000000..1862655 --- /dev/null +++ b/tests/e2e/plan-mobile.spec.ts @@ -0,0 +1,91 @@ +import { test, expect, type Page } from '@playwright/test'; +import { openPane, isMobileViewport } from './helpers/pane'; + +/** + * The /plan mobile shell (ADR-076): tab bar + one-pane-at-a-time panes over a single mounted tree. + * Runs in BOTH Playwright projects — the hydration gate matters everywhere (the anonymous + * hydration.spec.ts never renders the authed shell: /plan redirects to /signin), while the tab-bar + * tests self-skip on desktop where all three panes are simultaneously visible. DOM assertions only + * (no WebGL), per the suite convention. + */ +async function signUp(page: Page): Promise { + const email = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}@example.test`; + const res = await page.request.post('/api/auth/sign-up/email', { + data: { name: 'E2E User', email, password: 'test-password-123' }, + }); + expect(res.ok(), `sign-up failed: ${res.status()}`).toBeTruthy(); +} + +const HYDRATION_RX = /hydrat|did not match|text content does not match|tree hydrated|css-\w+/i; + +test('authed /plan hydrates clean (the shell adds no breakpoint-branched markup)', async ({ page }) => { + const problems: string[] = []; + page.on('console', (m) => { + if ((m.type() === 'error' || m.type() === 'warning') && HYDRATION_RX.test(m.text())) { + problems.push(`[console.${m.type()}] ${m.text()}`); + } + }); + page.on('pageerror', (e) => { + if (HYDRATION_RX.test(e.message)) problems.push(`[pageerror] ${e.message}`); + }); + await signUp(page); + await page.goto('/plan', { waitUntil: 'load' }); + await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => {}); + expect(problems, problems.join('\n')).toHaveLength(0); +}); + +test('tab switching preserves pane state — nothing unmounts (ADR-076)', async ({ page }) => { + test.skip(!isMobileViewport(page), 'tab bar is mobile-only; desktop shows all panes'); + await signUp(page); + await page.goto('/plan'); + await expect(page.getByTestId('plan-tab-bar')).toBeVisible(); + + // Default pane: Itinerary. The chat pane exists but is CSS-hidden (mounted once, never unmounted). + await expect(page.getByPlaceholder('New trip name')).toBeVisible(); + const emptyState = page.getByText('Ask the ranger to plan a trip, find parks, or check conditions.'); + await expect(emptyState).toBeHidden(); + + // Type a draft into the chat input, leave, come back: a remount would wipe the draft (the Eve chat + // store is per-component and in-memory) — its survival IS the no-unmount guarantee under test. + await openPane(page, 'ranger'); + await expect(emptyState).toBeVisible(); + const input = page.getByPlaceholder(/Plan a trip with the ranger/); + await input.fill('draft: dark-sky trip in Utah'); + await openPane(page, 'map'); + await expect(input).toBeHidden(); + await openPane(page, 'ranger'); + await expect(input).toHaveValue('draft: dark-sky trip in Utah'); +}); + +test('typeahead add updates the tab count, then the map badge; the pill returns (ADR-076)', async ({ page }) => { + test.skip(!isMobileViewport(page), 'tab bar is mobile-only; desktop shows all panes'); + await signUp(page); + await page.goto('/plan'); + await page.getByPlaceholder('New trip name').fill('Shell E2E'); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByRole('heading', { name: 'Shell E2E' })).toBeVisible(); + + const search = page.getByPlaceholder(/Search parks by name/); + await search.fill('Yellowstone'); + await page.getByText('Yellowstone National Park').click(); + await expect(page.getByText(/1\. Yellowstone/)).toBeVisible(); + // Same-pane feedback: the tab-bar count (bare numeral — never the map chip's " stop(s)" string). + await expect(page.getByTestId('plan-tab-stops')).toHaveText('1'); + + // Cross-pane truth: the Map pane's metrics overlay shows the same add. + await openPane(page, 'map'); + await expect(page.getByText(/1 stop\b/)).toBeVisible(); + + // The map pane's "View itinerary" pill switches back (the setPane context path). + await page.getByRole('button', { name: 'View itinerary' }).click(); + await expect(page.getByText(/1\. Yellowstone/)).toBeVisible(); +}); + +test('?pane=ranger deep link opens the Ranger pane (ADR-076)', async ({ page }) => { + test.skip(!isMobileViewport(page), 'tab bar is mobile-only; desktop shows all panes'); + await signUp(page); + await page.goto('/plan?pane=ranger'); + await expect(page.getByText('Ask the ranger to plan a trip, find parks, or check conditions.')).toBeVisible(); + // The itinerary pane is mounted but hidden. + await expect(page.getByPlaceholder('New trip name')).toBeHidden(); +}); From 3c4f81b31c6cd31fa69905e170a77d234b55e500 Mon Sep 17 00:00:00 2001 From: William Lyon Date: Tue, 7 Jul 2026 14:22:19 -0600 Subject: [PATCH 2/2] improve tripplanner --- app/api/plan/transcript/route.ts | 24 ++++ app/api/trips/[id]/route.ts | 28 ++++ app/plan/page.tsx | 6 +- components/chat/ChatPanel.tsx | 21 +++ components/plan/MapTripCanvas.tsx | 98 +++++++++----- components/plan/PlanSheet.tsx | 146 +++++++++++++++++++++ components/plan/PlanShell.tsx | 156 ++++++++++++++++++----- components/plan/PlanTabBar.tsx | 16 ++- components/plan/StopList.tsx | 75 ++++++++++- components/plan/TripActions.tsx | 49 ++++++- components/plan/TripBuilder.tsx | 22 +++- components/plan/TripHeader.tsx | 70 +++++++++- components/plan/plan-events.ts | 23 ++++ components/plan/useTripBuilder.tsx | 111 ++++++++++++++-- db/migrations/029_plan_transcript.cypher | 5 + lib/plan-transcript.ts | 47 +++++++ lib/trips.ts | 56 +++++++- lib/validation.ts | 11 +- playwright.config.ts | 2 +- tests/e2e/plan-phase3.spec.ts | 104 +++++++++++++++ 20 files changed, 974 insertions(+), 96 deletions(-) create mode 100644 app/api/plan/transcript/route.ts create mode 100644 components/plan/PlanSheet.tsx create mode 100644 components/plan/plan-events.ts create mode 100644 db/migrations/029_plan_transcript.cypher create mode 100644 lib/plan-transcript.ts create mode 100644 tests/e2e/plan-phase3.spec.ts diff --git a/app/api/plan/transcript/route.ts b/app/api/plan/transcript/route.ts new file mode 100644 index 0000000..50d3530 --- /dev/null +++ b/app/api/plan/transcript/route.ts @@ -0,0 +1,24 @@ +import { getUserId } from '../../../../lib/session'; +import { getPlanTranscript, savePlanTranscript } from '../../../../lib/plan-transcript'; + +/** + * The `/plan` ranger-chat transcript (ADR-076 P3.9): GET returns the saved Eve event stream so ChatPanel + * rehydrates the thread (with cards) on reload; POST upserts it after each turn (ChatPanel's `persistUrl` + * onFinish). One conversation per user — userId is server-bound from the Better Auth session, never a + * client id (R4). Anonymous → an empty transcript (the page itself gates on sign-in). + */ +export const dynamic = 'force-dynamic'; + +export async function GET(req: Request) { + const userId = await getUserId(req); + if (!userId) return Response.json({ events: [] }); + return Response.json(await getPlanTranscript(userId)); +} + +export async function POST(req: Request) { + const userId = await getUserId(req); + if (!userId) return Response.json({ ok: false }, { status: 401 }); + const body = (await req.json().catch(() => ({}))) as { events?: unknown[] }; + await savePlanTranscript(userId, { events: body.events ?? [] }); + return Response.json({ ok: true }); +} diff --git a/app/api/trips/[id]/route.ts b/app/api/trips/[id]/route.ts index 115a1fb..bc56d32 100644 --- a/app/api/trips/[id]/route.ts +++ b/app/api/trips/[id]/route.ts @@ -6,6 +6,8 @@ import { removeStop, reorderStops, renameTrip, + setTripDates, + applyDayPlan, checkTripAlerts, tripCost, tripConditions, @@ -50,6 +52,14 @@ export async function GET(req: Request, { params }: Ctx) { export async function DELETE(req: Request, { params }: Ctx) { const userId = await getUserId(req); if (!userId) return Response.json({ error: 'unauthorized' }, { status: 401 }); + // Rate-limit deletion under the same edit budget (ADR-076): the client also confirms first. + const rl = await rateLimit(rlUser(userId, 'tripmut'), 30, 60); + if (!rl.ok) { + return Response.json( + { error: 'rate_limited' }, + { status: 429, headers: { 'Retry-After': String(Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000))) } }, + ); + } const { id } = await params; await deleteTrip(userId, id); return Response.json({ ok: true }); @@ -98,6 +108,24 @@ export async function POST(req: Request, { params }: Ctx) { await renameTrip(userId, id, name); return Response.json({ trip: await getTrip(userId, id) }); } + case 'setDates': { + // Each end is independently settable (undefined = leave, null = clear); reject an inverted window. + if (body.startDate === undefined && body.endDate === undefined) { + return Response.json({ error: 'startDate or endDate required' }, { status: 400 }); + } + if (body.startDate && body.endDate && body.endDate < body.startDate) { + return Response.json({ error: 'endDate must be on or after startDate' }, { status: 400 }); + } + const ok = await setTripDates(userId, id, { startDate: body.startDate, endDate: body.endDate }); + if (!ok) return Response.json({ error: 'not found' }, { status: 404 }); + return Response.json({ trip: await getTrip(userId, id) }); + } + case 'applyDays': { + // Persist the pacing to stop.day so the plan survives a reload (P3.8), then hand back the fresh trip. + const ok = await applyDayPlan(userId, id, body.maxHoursPerDay); + if (!ok) return Response.json({ error: 'not found' }, { status: 404 }); + return Response.json({ trip: await getTrip(userId, id), metrics: await liveMetrics(userId, id) }); + } case 'setOrigin': { // Three forms: free-text place (geocoded server-side, ORS key stays private), explicit coords // (browser geolocation), or clearOrigin; returnToOrigin can ride along with any of them. diff --git a/app/plan/page.tsx b/app/plan/page.tsx index ee9d8d5..6db664e 100644 --- a/app/plan/page.tsx +++ b/app/plan/page.tsx @@ -1,6 +1,7 @@ import { redirect } from 'next/navigation'; import { Box, Heading } from '@chakra-ui/react'; import { getServerUserId } from '../../lib/session'; +import { getPlanTranscript } from '../../lib/plan-transcript'; import { PlanShell } from '../../components/plan/PlanShell'; /** @@ -17,6 +18,9 @@ export default async function PlanPage() { const userId = await getServerUserId(); if (!userId) redirect('/signin'); + // Rehydrate the ranger chat from the saved transcript (P3.9). Failure degrades to an empty thread. + const { events } = await getPlanTranscript(userId).catch(() => ({ events: [] as unknown[] })); + return ( Plan a trip - + ); } diff --git a/components/chat/ChatPanel.tsx b/components/chat/ChatPanel.tsx index 5bc445f..7935659 100644 --- a/components/chat/ChatPanel.tsx +++ b/components/chat/ChatPanel.tsx @@ -112,6 +112,27 @@ export function ChatPanel({ bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages, busy]); + // Seed the announce-dedup sets from a REPLAYED transcript on mount (ADR-076 P3.9): when the store is + // constructed from `initialEvents` (the /plan reload-persistence path, and /learn), the messages effects + // below would otherwise re-fire trips-changed / ranger-activity / quiz-graded for every historical + // message. This effect runs once, BEFORE those effects on the same commit (declaration order), marking + // the replayed messages as already-announced so only genuinely new turns dispatch. No-op with no seed. + const replaySeededRef = useRef(false); + useEffect(() => { + if (replaySeededRef.current || !initialEvents) return; + replaySeededRef.current = true; + for (const m of messages as { id?: string; role: string; parts: unknown[] }[]) { + if (m.role !== 'assistant' || !m.id) continue; + announcedActivity.current.add(m.id); + announcedGrades.current.add(m.id); + for (const tripId of tripIdsFromParts(m.parts as never)) { + announcedTrips.current.add(`${m.id}:${tripId}:stream`); + announcedTrips.current.add(`${m.id}:${tripId}:final`); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + // Snap-to-bottom on reveal (ADR-076): the autoscroll above is a spec-level no-op while the pane is // display:none (the element has no layout box), and nothing re-fires on reveal — a thread that streamed // into a hidden pane (mobile tab switching) used to show a stale scroll offset when opened. Watch the diff --git a/components/plan/MapTripCanvas.tsx b/components/plan/MapTripCanvas.tsx index 8bdfca4..c9d0946 100644 --- a/components/plan/MapTripCanvas.tsx +++ b/components/plan/MapTripCanvas.tsx @@ -3,7 +3,8 @@ import { useEffect, useRef, useState } from 'react'; import maplibregl, { type Map as MlMap, type Marker as MlMarker, type GeoJSONSource, type ExpressionSpecification } from 'maplibre-gl'; import type { FeatureCollection, Point } from 'geojson'; import 'maplibre-gl/dist/maplibre-gl.css'; -import { Box, HStack, Text } from '@chakra-ui/react'; +import { Box, Button, HStack, IconButton, Icon, Stack, Text } from '@chakra-ui/react'; +import { LuX } from 'react-icons/lu'; import { mapStyle, US_BOUNDS, registerMapProtocols, attachBasemapFallback } from '../../lib/mapStyle'; import { useColorMode } from '../ui/color-mode'; import { brandColors } from '../../lib/brandColors'; @@ -72,6 +73,21 @@ export function MapTripCanvas({ const { colorMode } = useColorMode(); const c = brandColors(colorMode); const [note, setNote] = useState(null); + // The park tapped on the map (P3.7): drives a React bottom card instead of a maplibre popup, so the Add + // button is a real ≥40px control and the card reads like the rest of the UI. Coords ride along for the pulse. + const [selected, setSelected] = useState<{ parkCode: string; name: string; designation?: string; lng: number; lat: number } | null>(null); + + // A short ring pulse at the just-added park (P3.7) — Web Animations, no global keyframe needed. + function pulseAt(lng: number, lat: number) { + const map = mapRef.current; + if (!map || typeof document === 'undefined') return; + const el = document.createElement('div'); + Object.assign(el.style, { width: '20px', height: '20px', borderRadius: '50%', border: `2px solid ${c.pine}`, pointerEvents: 'none' }); + const marker = new maplibregl.Marker({ element: el }).setLngLat([lng, lat]).addTo(map); + const anim = el.animate?.([{ transform: 'scale(0.5)', opacity: 0.9 }, { transform: 'scale(2.6)', opacity: 0 }], { duration: 850, easing: 'ease-out' }); + if (anim) anim.onfinish = () => marker.remove(); + else window.setTimeout(() => marker.remove(), 850); + } stopsRef.current = stops; originRef.current = origin ?? null; @@ -100,16 +116,18 @@ export function MapTripCanvas({ (map.getSource('canvas-parks') as GeoJSONSource | undefined)?.setData(fc); } - // POST addStop for a clicked park, then bubble the new trip + live metrics up to the builder (#9). - async function addPark(parkCode: string, name: string) { + // POST addStop for a chosen park, then bubble the new trip + live metrics up to the builder (#9) and pulse + // the spot (P3.7). Called by the React bottom card's Add button. + async function addPark(park: { parkCode: string; name: string; lng: number; lat: number }) { if (busyRef.current || !tripIdRef.current) return; busyRef.current = true; - setNote(`Adding ${name}…`); + setSelected(null); + setNote(`Adding ${park.name}…`); try { const res = await fetch(`/api/trips/${encodeURIComponent(tripIdRef.current)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ op: 'addStop', stop: { kind: 'park', refId: parkCode } }), + body: JSON.stringify({ op: 'addStop', stop: { kind: 'park', refId: park.parkCode } }), signal: abortRef.current?.signal, }); if (res.status === 429) { @@ -123,7 +141,8 @@ export function MapTripCanvas({ } const data = (await res.json()) as CanvasMutation; onMutatedRef.current(data); - setNote(`Added ${name}`); + pulseAt(park.lng, park.lat); + setNote(`Added ${park.name}`); } catch { /* aborted / network — leave the trip unchanged */ } finally { @@ -155,6 +174,10 @@ export function MapTripCanvas({ paint: { 'circle-color': parkColorExpr, 'circle-radius': 6, 'circle-stroke-width': 1.5, 'circle-stroke-color': '#fff' } }); m.addLayer({ id: 'canvas-park-icon', type: 'symbol', source: 'canvas-parks', filter: ['!', ['has', 'point_count']], layout: { 'icon-image': ['get', 'icon'], 'icon-size': 0.55, 'icon-allow-overlap': true, 'icon-ignore-placement': true } }); + // Invisible fat hit target (P3.7): the visible dot is 6px, murder to tap on touch — a transparent + // ~14px circle around each dot makes the whole ~24px area tappable. Handlers bind to this layer. + m.addLayer({ id: 'canvas-park-hit', type: 'circle', source: 'canvas-parks', filter: ['!', ['has', 'point_count']], + paint: { 'circle-color': '#000', 'circle-opacity': 0, 'circle-radius': 14 } }); }; const init = () => { @@ -178,37 +201,22 @@ export function MapTripCanvas({ attachMarkerImages(m); // styleimagemissing hook — survives setStyle image wipes (#2d) installLayers(m); - // Click an addable park → an "Add to trip" popup (setDOMContent so the button can carry a real - // handler). With NO trip open (the shell's browse state) the popup explains instead of adding. - // Layer-scoped handlers attach ONCE — they survive setStyle by id; re-adding double-fires. - m.on('mouseenter', 'canvas-park', () => { m.getCanvas().style.cursor = 'pointer'; }); - m.on('mouseleave', 'canvas-park', () => { m.getCanvas().style.cursor = ''; }); - m.on('click', 'canvas-park', (e) => { + // Tap an addable park (on the fat hit layer, P3.7) → open the React bottom card via state. Layer- + // scoped handlers attach ONCE — they survive setStyle by id; re-adding double-fires. setSelected is + // a stable useState setter, safe to call from this load-time closure. + m.on('mouseenter', 'canvas-park-hit', () => { m.getCanvas().style.cursor = 'pointer'; }); + m.on('mouseleave', 'canvas-park-hit', () => { m.getCanvas().style.cursor = ''; }); + m.on('click', 'canvas-park-hit', (e) => { const f = e.features?.[0]; if (!f) return; const props = f.properties as { parkCode: string; name: string; designation?: string }; const [lng, lat] = (f.geometry as Point).coordinates; - const root = document.createElement('div'); - const title = document.createElement('strong'); - title.textContent = props.name ?? props.parkCode; - const sub = document.createElement('div'); - sub.style.cssText = 'color:var(--chakra-colors-fg-muted);font-size:12px;margin:2px 0 6px'; - sub.textContent = props.designation ?? ''; - const popup = new maplibregl.Popup({ closeButton: false }).setLngLat([lng, lat]); - if (tripIdRef.current) { - const btn = document.createElement('button'); - btn.textContent = 'Add to trip'; - // ≥40px tall + touch-action:manipulation — a finger-sized target with no 300ms double-tap delay. - btn.style.cssText = `background:${c.pine};color:#fff;border:none;border-radius:6px;padding:8px 14px;min-height:40px;font-size:13px;font-weight:600;cursor:pointer;touch-action:manipulation`; - btn.onclick = () => { addPark(props.parkCode, props.name); popup.remove(); }; - root.append(title, sub, btn); - } else { - const hint = document.createElement('div'); - hint.style.cssText = 'color:var(--chakra-colors-fg-muted);font-size:12px'; - hint.textContent = 'Open a trip to add parks.'; - root.append(title, sub, hint); - } - popup.setDOMContent(root).addTo(m); + setSelected({ parkCode: props.parkCode, name: props.name ?? props.parkCode, designation: props.designation, lng, lat }); + }); + // Tap the map background (not on a park dot) → dismiss the card. The layer handler above runs first; + // this general handler queries the hit layer so it doesn't clobber a fresh selection. + m.on('click', (e) => { + if (m.queryRenderedFeatures(e.point, { layers: ['canvas-park-hit'] }).length === 0) setSelected(null); }); // Cluster → zoom to expand. m.on('click', 'canvas-clusters', (e) => { @@ -338,6 +346,30 @@ export function MapTripCanvas({ {metrics.darkHoursTotal != null ? {Math.round(metrics.darkHoursTotal)} dark h : null} ) : null} + {/* Selected-park bottom card (P3.7): replaces the maplibre popup with a real UI card — name + + designation + a finger-sized Add (or a browse hint with no trip open). Sits above the note/pill. */} + {selected ? ( + + + + {selected.name} + {selected.designation ? {selected.designation} : null} + + setSelected(null)}> + + + + {tripId ? ( // the reactive prop (not the ref) so the button appears the moment a trip opens + + ) : ( + Open a trip to add parks. + )} + + ) : null} {note ? ( {note} diff --git a/components/plan/PlanSheet.tsx b/components/plan/PlanSheet.tsx new file mode 100644 index 0000000..1dfaaeb --- /dev/null +++ b/components/plan/PlanSheet.tsx @@ -0,0 +1,146 @@ +'use client'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { Box } from '@chakra-ui/react'; +import { motion, useMotionValue, useDragControls, animate, type PanInfo } from 'motion/react'; +import { prefersReducedMotion } from '../../lib/trip-map-render'; + +/** + * AllTrails-style draggable bottom sheet over the full-bleed map (Phase 2, ADR-076), behind + * NEXT_PUBLIC_PLAN_SHEET. Three snaps: peek (handle + a peek header) · half (50%) · full (92%). The sheet + * body scrolls natively ONLY at `full`; at peek/half the whole sheet drags. Collapsing/expanding is via the + * handle (drag with velocity settle, or TAP to cycle — which is also the `prefers-reduced-motion` path). + * Everything inside stays mounted — the sheet is a transform, never an unmount. + * + * NB: the "over-drag the list at scrollTop 0 grabs the sheet" handoff (plan §5) is a deliberate follow-up — + * it needs iOS-Safari rubber-band tuning the plan itself flags as the phase's risk. Handle-drag collapse is + * robust and ships first; the flag stays off by default until the mobile e2e is stable on it. + */ +export type SheetSnap = 'peek' | 'half' | 'full'; +const MotionDiv = motion.div; +const PEEK_PX = 96; + +export function PlanSheet({ peek, children }: { peek: ReactNode; children: ReactNode }) { + const containerRef = useRef(null); + const scrollRef = useRef(null); + const controls = useDragControls(); + const y = useMotionValue(0); + const [snap, setSnap] = useState('peek'); + const [h, setH] = useState(0); + const snapRef = useRef('peek'); + snapRef.current = snap; + + // Measure the container so snap offsets are real pixels (motion animates numbers, not dvh). + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const ro = new ResizeObserver(() => setH(el.clientHeight)); + ro.observe(el); + setH(el.clientHeight); + return () => ro.disconnect(); + }, []); + + const sheetH = h * 0.92; + // translateY offset per snap: 0 = fully up (full); larger = pushed down (less visible). + const offset = (s: SheetSnap): number => + s === 'full' ? 0 : s === 'half' ? Math.max(0, sheetH - h * 0.5) : Math.max(0, sheetH - PEEK_PX); + + // Animate to the active snap whenever it (or the measured height) changes. + useEffect(() => { + if (!h) return; + const target = offset(snap); + if (prefersReducedMotion()) y.set(target); + else animate(y, target, { type: 'spring', stiffness: 400, damping: 42 }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [snap, h]); + + function settle(_e: unknown, info: PanInfo) { + // Project ~150ms of momentum, then snap to the nearest of the three offsets. + const projected = y.get() + info.velocity.y * 0.15; + const candidates: SheetSnap[] = ['full', 'half', 'peek']; + let best: SheetSnap = 'peek'; + let bestDist = Infinity; + for (const s of candidates) { + const d = Math.abs(projected - offset(s)); + if (d < bestDist) { bestDist = d; best = s; } + } + setSnap(best); + } + + // Tap the handle to cycle peek → half → full → peek (the reduced-motion affordance, and a handy shortcut). + function cycle() { + setSnap((s) => (s === 'peek' ? 'half' : s === 'half' ? 'full' : 'peek')); + } + + // Start a sheet drag from the body only when there's nothing to scroll away first (at full the list + // scrolls; at peek/half the body isn't scrollable, so any drag moves the sheet). + function bodyPointerDown(e: React.PointerEvent) { + if (snapRef.current !== 'full' || (scrollRef.current?.scrollTop ?? 0) <= 0) controls.start(e); + } + + const atFull = snap === 'full'; + + return ( + + + + {/* Drag handle + peek header — the always-visible strip; drag or tap to change snap. */} + controls.start(e)} + onClick={cycle} + role="button" + tabIndex={0} + aria-label="Drag or tap to resize the itinerary sheet" + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); cycle(); } }} + > + + {peek} + + {/* Body — scrolls natively only at full; otherwise the whole sheet drags. */} + + {children} + + + + + ); +} diff --git a/components/plan/PlanShell.tsx b/components/plan/PlanShell.tsx index 2b7bad6..f52c30d 100644 --- a/components/plan/PlanShell.tsx +++ b/components/plan/PlanShell.tsx @@ -1,14 +1,22 @@ 'use client'; import { useEffect, useRef, useState } from 'react'; -import { Box, Button, Grid } from '@chakra-ui/react'; +import { Box, Button, Grid, HStack, Icon, IconButton, Text } from '@chakra-ui/react'; +import { LuSparkles, LuX } from 'react-icons/lu'; import { TripBuilderProvider, useTripBuilder, type Trip } from './useTripBuilder'; import { TripBuilder } from './TripBuilder'; import { MapTripCanvas } from './MapTripCanvas'; import { PlanTabBar } from './PlanTabBar'; +import { PlanSheet } from './PlanSheet'; +import { onPlanPaneRequest } from './plan-events'; import { ChatPanel } from '../chat/ChatPanel'; export type PlanPane = 'map' | 'itinerary' | 'ranger'; +/** Phase 2 (ADR-076): the AllTrails-style full-bleed map + draggable itinerary sheet, behind + * NEXT_PUBLIC_PLAN_SHEET. A build-time NEXT_PUBLIC_ flag → the same value on server and client, so + * branching layout on it introduces no hydration mismatch. Off by default: the Phase-1 tabs ship. */ +const SHEET = process.env.NEXT_PUBLIC_PLAN_SHEET === '1'; + /** * The /plan responsive frame (ADR-076): ONE client component owns a CSS grid whose children are the * itinerary rail, the map cell, and the ranger chat. md+ shows all three ("itinerary map chat", @@ -25,20 +33,25 @@ export type PlanPane = 'map' | 'itinerary' | 'ranger'; * still leaves /plan; a reload restores the pane). Desktop never reads or writes ?pane (the tab bar * and map pill are base-only, the only setPane callers). */ -export function PlanShell() { +export function PlanShell({ initialChatEvents }: { initialChatEvents?: unknown[] }) { return ( - + ); } -function PlanShellInner() { +function PlanShellInner({ initialChatEvents }: { initialChatEvents?: unknown[] }) { const [pane, setPaneState] = useState('itinerary'); const [flashItinerary, setFlashItinerary] = useState(false); const [rangerUnread, setRangerUnread] = useState(false); + // Sheet mode (Phase 2): the map is full-bleed + a draggable itinerary sheet; the ranger opens as an + // overlay from a FAB instead of a tab. `rangerOpen` toggles that overlay (base only). + const [rangerOpen, setRangerOpen] = useState(false); const paneRef = useRef(pane); paneRef.current = pane; + const rangerOpenRef = useRef(rangerOpen); + rangerOpenRef.current = rangerOpen; // Capture the landing query string during the FIRST client render — before any child effect can touch // it. ChatPanel's graph-handoff cleanup replaceStates seed/from away in ITS mount effect, and child @@ -67,6 +80,8 @@ function PlanShellInner() { setPaneState(next); if (next === 'itinerary') setFlashItinerary(false); if (next === 'ranger') setRangerUnread(false); + // In sheet mode the ranger is a FAB overlay, not a tab — a 'ranger' request opens it; map/itinerary close it. + if (SHEET) setRangerOpen(next === 'ranger'); // Write-back so a mid-session reload restores the pane; replaceState (never push) keeps Back = leave /plan. const url = new URL(window.location.href); url.searchParams.set('pane', next); @@ -81,17 +96,28 @@ function PlanShellInner() { if (paneRef.current !== 'itinerary') setFlashItinerary(true); } function onRangerActivity() { - if (paneRef.current !== 'ranger') setRangerUnread(true); + // "Ranger visible" is the open overlay in sheet mode, else the active tab. + const visible = SHEET ? rangerOpenRef.current : paneRef.current === 'ranger'; + if (!visible) setRangerUnread(true); } window.addEventListener('trailgraph:trips-changed', onTripsChanged); window.addEventListener('trailgraph:ranger-activity', onRangerActivity); + // In-pane handoffs (e.g. the no-trips hero's "Ask the ranger", P3.5) request a pane switch. + const offPane = onPlanPaneRequest((p) => setPane(p)); return () => { window.removeEventListener('trailgraph:trips-changed', onTripsChanged); window.removeEventListener('trailgraph:ranger-activity', onRangerActivity); + offPane(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const paneDisplay = (key: PlanPane) => ({ base: pane === key ? 'flex' : 'none', md: 'flex' }); + // Sheet mode reshapes the BASE layout only (md+ is always the three-region grid): the itinerary rail + // hides (the sheet hosts it), the map is full-bleed, and the chat is a FAB-toggled overlay. + const itinDisplay = SHEET ? { base: 'none', md: 'flex' } : paneDisplay('itinerary'); + const mapDisplay = SHEET ? { base: 'flex', md: 'flex' } : paneDisplay('map'); + const chatDisplay = SHEET ? { base: rangerOpen ? 'flex' : 'none', md: 'flex' } : paneDisplay('ranger'); return ( - setPane('itinerary')} /> + + setPane('itinerary')} showPill={!SHEET} /> {/* Ranger chat — mounts exactly once per /plan visit (the Eve session). The chat input's safe-area @@ -128,11 +154,12 @@ function PlanShellInner() { because the input reaches the viewport bottom again. */} - + {/* Reload persistence (P3.9): seed the thread from the saved transcript (cards included) and persist + each turn. initialEvents replays for DISPLAY only — no initialSession, so the next send starts a + fresh Eve session (mirrors /learn; a stale server session can't wedge the next turn). */} + + {/* Sheet mode: a close affordance since there's no tab bar to switch away from the ranger overlay. */} + {SHEET ? ( + setRangerOpen(false)} + > + + + ) : null} - + {/* Phase 2 sheet overlay + Ranger FAB (base only; same grid cell as the map, layered above it). The + sheet's own container is pointer-transparent except the sheet itself, so the map stays interactive + above the peek strip. */} + {SHEET ? ( + + }> + + + {!rangerOpen ? ( + setPane('ranger')} + > + + {rangerUnread ? : null} + + ) : null} + + ) : null} + + {!SHEET ? ( + + ) : null} ); } +/** The always-visible peek header inside the sheet (Phase 2): trip name + a compact running total. */ +function SheetPeek() { + const { trip, stops, metrics } = useTripBuilder(); + const hrs = (min: number | null | undefined) => (min == null ? null : Math.round((min / 60) * 10) / 10); + return ( + + + {trip ? trip.name : 'Your trips'} + + {trip && metrics && metrics.stops > 0 ? ( + + {stops.length} stop{stops.length === 1 ? '' : 's'} + {metrics.driveMiles > 0 ? ` · ${Math.round(metrics.driveMiles)} mi · ${hrs(metrics.driveMinutes)} h` : ''} + + ) : null} + + ); +} + /** The map region: canvas props from the shared provider + the base-only "View itinerary" pill. Pane * switching rides the shell's `setPane` callback (context/props, not a window event — trailgraph:* stays * reserved for genuinely cross-tree producers like ChatPanel). */ -function MapCell({ onViewItinerary }: { onViewItinerary: () => void }) { +function MapCell({ onViewItinerary, showPill }: { onViewItinerary: () => void; showPill: boolean }) { const { trip, canvasStops, canvasOrigin, addedParkCodes, metrics, applyMutation } = useTripBuilder(); return ( <> @@ -165,23 +260,26 @@ function MapCell({ onViewItinerary }: { onViewItinerary: () => void }) { cooperativeGestures={false} /> {/* bottom clears maplibre's attribution strip (~24px, z-indexed above siblings) — at 390px its - expanded inner div otherwise intercepts the tap (caught by plan-mobile e2e). */} - + expanded inner div otherwise intercepts the tap (caught by plan-mobile e2e). Hidden in sheet mode + (the sheet is always present). */} + {showPill ? ( + + ) : null} ); } diff --git a/components/plan/PlanTabBar.tsx b/components/plan/PlanTabBar.tsx index 1bec5c4..9fc3255 100644 --- a/components/plan/PlanTabBar.tsx +++ b/components/plan/PlanTabBar.tsx @@ -26,8 +26,11 @@ export function PlanTabBar({ flashItinerary: boolean; rangerUnread: boolean; }) { - const { trip, stops } = useTripBuilder(); + const { trip, stops, alerts } = useTripBuilder(); const count = trip ? stops.length : null; + // Mirror the TripHeader alert count on the Itinerary tab (P3.10) so the surface's only safety signal is + // visible from the Map/Ranger panes on mobile. + const alertCount = alerts ? alerts.reduce((sum, a) => sum + a.alerts.length, 0) : 0; const tabs: { key: PlanPane; label: string; icon: React.ReactNode; ariaLabel: string }[] = [ { key: 'map', label: 'Map', icon: , ariaLabel: 'Map' }, @@ -35,7 +38,10 @@ export function PlanTabBar({ key: 'itinerary', label: 'Itinerary', icon: , - ariaLabel: count != null && count > 0 ? `Itinerary, ${count} stop${count === 1 ? '' : 's'}` : 'Itinerary', + ariaLabel: [ + count != null && count > 0 ? `Itinerary, ${count} stop${count === 1 ? '' : 's'}` : 'Itinerary', + alertCount > 0 ? `${alertCount} alert${alertCount === 1 ? '' : 's'}` : null, + ].filter(Boolean).join(', '), }, { key: 'ranger', label: 'Ranger', icon: , ariaLabel: rangerUnread ? 'Ranger, new activity' : 'Ranger' }, ]; @@ -82,6 +88,12 @@ export function PlanTabBar({ {count} ) : null} + {/* Alert indicator (P3.10): a small red dot on the Itinerary tab when the trip has active + Closure/Danger alerts — the safety signal, visible from any pane. Distinct from the + stop-count badge (top-right) and its flash. */} + {t.key === 'itinerary' && alertCount > 0 ? ( + + ) : null} {t.key === 'ranger' && rangerUnread ? ( ) : null} diff --git a/components/plan/StopList.tsx b/components/plan/StopList.tsx index 3a9e2ca..e3a0b63 100644 --- a/components/plan/StopList.tsx +++ b/components/plan/StopList.tsx @@ -1,32 +1,40 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; -import { Stack, Text, HStack, IconButton, Badge, Icon } from '@chakra-ui/react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Stack, Text, HStack, IconButton, Badge, Icon } from '@chakra-ui/react'; import { Reorder, useDragControls } from 'motion/react'; -import { LuX, LuGripVertical, LuFootprints, LuTentTree } from 'react-icons/lu'; +import { LuX, LuGripVertical, LuFootprints, LuTentTree, LuChevronUp, LuChevronDown, LuTriangleAlert } from 'react-icons/lu'; +import { tripDayLoads, type DayLoad } from '../../lib/itinerary'; import { useTripBuilder, stopLabel, touchTarget, type Stop } from './useTripBuilder'; /** * One draggable stop row. Drag starts ONLY from the grip handle (`dragListener={false}` + drag controls): * motion's default whole-row listener hijacked the pane's one-finger scroll on touch — the handle is the * standard fix, and `touch-action:none` on it lets the drag claim the gesture before the browser scrolls. + * Up/Down buttons are the KEYBOARD-accessible reorder path (P3.3) — motion's Reorder is pointer-only. */ function StopItem({ stop: s, index: i, + count, day, showDay, + dayLoad, onDragStart, onDragEnd, + onMove, onRemove, onRemoveHike, onRemoveLodging, }: { stop: Stop; index: number; + count: number; day?: number; showDay: boolean; + dayLoad?: DayLoad; onDragStart: () => void; onDragEnd: () => void; + onMove: (stopId: string, dir: 'up' | 'down') => void; onRemove: (stopId: string) => void; onRemoveHike: (stopId: string, trailId: string) => void; onRemoveLodging: (stopId: string, campgroundId: string) => void; @@ -34,10 +42,28 @@ function StopItem({ const controls = useDragControls(); return ( + {/* Day-section header (P3.4): a bordered band with the per-day load chip (hike + drive hours), instead + of a bare "Day N". Rides inside the item so items stay direct Reorder.Group children. */} {showDay ? ( - - Day {day} - + + Day {day} + {dayLoad ? ( + + {[dayLoad.hikeHours ? `${dayLoad.hikeHours} h hiking` : null, dayLoad.driveHours ? `${dayLoad.driveHours} h drive` : null].filter(Boolean).join(' · ') || 'light day'} + + ) : null} + {dayLoad?.overPacked ? : null} + ) : null} {i + 1}. {stopLabel(s)} + {/* Keyboard-accessible reorder (P3.3): compact up/down, disabled at the ends. */} + onMove(s.id, 'up')}> + + + onMove(s.id, 'down')}> + + onRemove(s.id)}> @@ -121,9 +154,10 @@ function StopItem({ * legs render outside the Reorder.Group). Owns motion's live values array (`localStops`) locally — it's * pure drag state — and reports drag start/end to the provider so a ranger refresh landing mid-drag is * stashed instead of yanking the rows (ADR-076); a stash applied on drop wins over the stale drag order. + * Keyboard reorder (P3.3) and per-day load chips (P3.4) layer on top. */ export function StopList() { - const { trip, dayMap, removeStop, removeHike, removeLodging, persistReorder, beginDrag, endDrag } = useTripBuilder(); + const { trip, dayMap, removeStop, removeHike, removeLodging, persistReorder, moveStop, beginDrag, endDrag } = useTripBuilder(); // A local copy of the stops the drag-reorder list mutates live (motion/react Reorder owns its values // array); synced from `trip` on every change and persisted on drop. const [localStops, setLocalStops] = useState([]); @@ -135,6 +169,19 @@ export function StopList() { setLocalStops(((trip?.stops ?? []).filter(Boolean) as Stop[])); }, [trip]); + // Per-day loads for the section-header chips (P3.4). Keyed by day number; empty until a plan exists. + const dayLoadByDay = useMemo(() => { + const loads = tripDayLoads( + localStops.map((s) => ({ + day: dayMap[s.id] ?? s.day ?? null, + driveMinutesToHere: s.driveTo?.minutes ?? 0, + hikeMiles: (s.hikes ?? []).reduce((m, h) => m + (h.lengthMiles ?? 0), 0), + hikeHours: (s.hikes ?? []).reduce((m, h) => m + (h.estTimeHrs ?? 0), 0), + })), + ); + return new Map(loads.map((d) => [d.day, d])); + }, [localStops, dayMap]); + if (!trip) return null; async function handleDragEnd() { @@ -164,10 +211,13 @@ export function StopList() { key={s.id} stop={s} index={i} + count={localStops.length} day={day} showDay={day != null && day !== prevDay} + dayLoad={day != null ? dayLoadByDay.get(day) : undefined} onDragStart={beginDrag} onDragEnd={handleDragEnd} + onMove={moveStop} onRemove={removeStop} onRemoveHike={removeHike} onRemoveLodging={removeLodging} @@ -182,6 +232,17 @@ export function StopList() { {trip.returnLeg.source === 'great_circle' ? ' (approx)' : ''} ) : null} + {/* Empty-trip checklist (P3.5): a just-created trip has no stops yet — show the three add paths. */} + {localStops.length === 0 ? ( + + This trip is empty — add your first stop: + + • Search a park by name above + • Tap a park dot on the map + • Ask the ranger to plan it for you + + + ) : null} ); } diff --git a/components/plan/TripActions.tsx b/components/plan/TripActions.tsx index 9c65e15..da5209b 100644 --- a/components/plan/TripActions.tsx +++ b/components/plan/TripActions.tsx @@ -1,7 +1,9 @@ 'use client'; -import { Button, HStack, Icon, IconButton, Menu, Portal, Stack, Text } from '@chakra-ui/react'; +import { useState } from 'react'; +import { Button, CloseButton, Dialog, HStack, Icon, IconButton, Menu, Portal, Stack, Text } from '@chakra-ui/react'; import { LuChevronDown, LuCopy } from 'react-icons/lu'; import { toast } from '../../lib/toast'; +import { decodeEntities } from '../../lib/html-entities'; import { useTripBuilder, touchTarget } from './useTripBuilder'; /** @@ -11,9 +13,21 @@ import { useTripBuilder, touchTarget } from './useTripBuilder'; * only when the trip has stops; the share row appears under it once a link exists. */ export function TripActions() { - const { trip, stops, busyOps, shareUrl, checkAlerts, checkCost, checkConditions, suggestDayPlan, optimizeRoute, share, fork } = useTripBuilder(); + const { trip, stops, busyOps, shareUrl, checkAlerts, checkCost, checkConditions, suggestDayPlan, optimizeRoute, share, fork, removeTrip } = useTripBuilder(); + const [confirmDelete, setConfirmDelete] = useState(false); + const [deleting, setDeleting] = useState(false); if (!trip || stops.length === 0) return null; + async function handleDelete() { + setDeleting(true); + try { + await removeTrip(); + setConfirmDelete(false); + } finally { + setDeleting(false); + } + } + return ( @@ -58,11 +72,42 @@ export function TripActions() { Export .gpx + + setConfirmDelete(true)}> + Delete trip… + + + {/* Destructive: confirm before deleting (P3.2). The API DELETE has no undo. */} + setConfirmDelete(e.open)}> + + + + + Delete this trip? + + + “{decodeEntities(trip.name)}” and its {stops.length} stop{stops.length === 1 ? '' : 's'} will be + permanently deleted. This can’t be undone. + + + + + + + + + + + + + + + {shareUrl ? ( diff --git a/components/plan/TripBuilder.tsx b/components/plan/TripBuilder.tsx index 4b64fcf..a517eb2 100644 --- a/components/plan/TripBuilder.tsx +++ b/components/plan/TripBuilder.tsx @@ -1,6 +1,8 @@ 'use client'; -import { Box, Stack, Heading, Text, HStack, Icon, Separator } from '@chakra-ui/react'; -import { LuTriangleAlert } from 'react-icons/lu'; +import { Box, Button, Stack, Heading, Text, HStack, Icon, Separator } from '@chakra-ui/react'; +import { LuTriangleAlert, LuMapPinned, LuSparkles } from 'react-icons/lu'; +import { EmptyState } from '../ui/empty-state'; +import { requestPlanPane } from './plan-events'; import { ParkSearchInput } from './ParkSearchInput'; import { TripSwitcher } from './TripSwitcher'; import { TripHeader } from './TripHeader'; @@ -17,7 +19,7 @@ import { useTripBuilder } from './useTripBuilder'; * own grid cell from the same provider. Fully functional without the agent. */ export function TripBuilder() { - const { err, trip, addPark, overPackedDays } = useTripBuilder(); + const { err, trips, trip, addPark, overPackedDays } = useTripBuilder(); if (err) return {err}; @@ -56,8 +58,20 @@ export function TripBuilder() { + ) : trips.length === 0 ? ( + // No-trips hero (P3.5): first-run — name a trip above, or hand off to the ranger. + } + title="Plan your first trip" + description="Name a trip above to start adding parks, or let the ranger draft one from what you love." + > + + ) : ( - Create or select a trip to start building an itinerary. + Select a trip above to keep building, or create a new one. )} ); diff --git a/components/plan/TripHeader.tsx b/components/plan/TripHeader.tsx index fc3cede..d2ca75e 100644 --- a/components/plan/TripHeader.tsx +++ b/components/plan/TripHeader.tsx @@ -1,10 +1,15 @@ 'use client'; import { useState } from 'react'; import { Badge, Button, Heading, HStack, Icon, Input, Text } from '@chakra-ui/react'; -import { LuHouse, LuRepeat } from 'react-icons/lu'; +import { LuHouse, LuRepeat, LuCalendar } from 'react-icons/lu'; +import { toast } from '../../lib/toast'; import { decodeEntities } from '../../lib/html-entities'; import { useTripBuilder } from './useTripBuilder'; +/** A stored trip date is a YYYY-MM-DD string (or an older free-form value) — take the first 10 chars for + * the native date input, which needs exactly that shape. */ +const ymd = (d?: string | null): string => (d ? d.slice(0, 10) : ''); + /** * The open trip's header: name/rename, the persistent alert-count badge (P1.2 — safety at a glance), a * compact live-metrics line (ADR-076: on mobile the map chip lives in a hidden pane, so the Itinerary @@ -12,11 +17,14 @@ import { useTripBuilder } from './useTripBuilder'; * stays exclusive to the chip so e2e text selectors never collide), and the trip-origin row (ADR-074). */ export function TripHeader() { - const { trip, alerts, metrics, rename, setOrigin } = useTripBuilder(); + const { trip, alerts, metrics, rename, setOrigin, setDates } = useTripBuilder(); const [editingName, setEditingName] = useState(false); const [nameDraft, setNameDraft] = useState(''); const [editingOrigin, setEditingOrigin] = useState(false); const [originDraft, setOriginDraft] = useState(''); + const [editingDates, setEditingDates] = useState(false); + const [startDraft, setStartDraft] = useState(''); + const [endDraft, setEndDraft] = useState(''); if (!trip) return null; @@ -33,6 +41,19 @@ export function TripHeader() { setOriginDraft(''); } } + function openDatesEditor() { + setStartDraft(ymd(trip?.startDate)); + setEndDraft(ymd(trip?.endDate)); + setEditingDates(true); + } + async function saveDates() { + if (startDraft && endDraft && endDraft < startDraft) { + toast.info('The end date must be on or after the start date.'); + return; + } + // Empty input → null (clear that end); a value → set it. + if (await setDates({ startDate: startDraft || null, endDate: endDraft || null })) setEditingDates(false); + } const hrs = (min: number | null | undefined) => (min == null ? null : Math.round((min / 60) * 10) / 10); @@ -85,6 +106,51 @@ export function TripHeader() { {metrics.darkHoursTotal != null ? {Math.round(metrics.darkHoursTotal)} dark h : null} ) : null} + {/* Trip window (P3.1): drives dark-hours, the ICS export, and the ranger's dated answers. Native + date inputs — accessible + a real mobile date picker. */} + + + {editingDates ? ( + <> + setStartDraft(e.target.value)} + /> + + setEndDraft(e.target.value)} + /> + + + + ) : trip.startDate || trip.endDate ? ( + <> + + {ymd(trip.startDate) || '—'} → {ymd(trip.endDate) || '—'} + + + + + ) : ( + <> + No dates set. + + + )} + {/* Trip origin (ADR-074): defaults from the saved home location; editable per trip (fly-in trips), with a round-trip toggle that adds the drive home to the route. */} diff --git a/components/plan/plan-events.ts b/components/plan/plan-events.ts new file mode 100644 index 0000000..f1653dd --- /dev/null +++ b/components/plan/plan-events.ts @@ -0,0 +1,23 @@ +import type { PlanPane } from './PlanShell'; + +/** + * A request from within a pane to switch the plan shell to another pane (ADR-076 P3.5) — e.g. the + * no-trips hero's "Ask the ranger" handoff. TripBuilder isn't a PlanShell context consumer, so it asks + * via a window event (the established cross-tree idiom); PlanShell listens and calls setPane. On desktop + * every pane is visible, so switching is a harmless focus hint. + */ +const EVENT = 'trailgraph:plan-pane'; + +export function requestPlanPane(pane: PlanPane): void { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent(EVENT, { detail: { pane } })); +} + +export function onPlanPaneRequest(handler: (pane: PlanPane) => void): () => void { + const listener = (e: Event) => { + const pane = (e as CustomEvent<{ pane?: PlanPane }>).detail?.pane; + if (pane) handler(pane); + }; + window.addEventListener(EVENT, listener); + return () => window.removeEventListener(EVENT, listener); +} diff --git a/components/plan/useTripBuilder.tsx b/components/plan/useTripBuilder.tsx index 6c3807d..b9111a7 100644 --- a/components/plan/useTripBuilder.tsx +++ b/components/plan/useTripBuilder.tsx @@ -122,11 +122,16 @@ export interface TripBuilderValue { openTrip: (id: string) => Promise; create: (name: string) => Promise; rename: (name: string) => Promise; + setDates: (dates: { startDate?: string | null; endDate?: string | null }) => Promise; + removeTrip: () => Promise; addPark: (code: string) => Promise; removeStop: (stopId: string) => Promise; removeHike: (stopId: string, trailId: string) => Promise; removeLodging: (stopId: string, campgroundId: string) => Promise; persistReorder: (orderedStopIds: string[]) => Promise<'ok' | 'unchanged' | 'limited'>; + /** Keyboard-accessible reorder (P3.3): move a stop one slot up/down and persist. motion's Reorder is + * pointer-only, so this is the a11y path. */ + moveStop: (stopId: string, dir: 'up' | 'down') => Promise; checkAlerts: () => Promise; checkCost: () => Promise; checkConditions: () => Promise; @@ -336,6 +341,52 @@ export function TripBuilderProvider({ children }: { children: ReactNode }) { [postOp, loadTrips], ); + // Set the trip window (P3.1). Each end is nullable to clear; the server rejects an inverted window (the + // client also guards). Returns whether it applied so TripHeader closes its editor only on success. + const setDates = useCallback( + async (dates: { startDate?: string | null; endDate?: string | null }): Promise => { + const t = tripRef.current; + if (!t) return false; + const r = await postOp<{ trip?: Trip | null }>(t.id, { op: 'setDates', ...dates }); + if (r.ok && r.data.trip) { + setTrip(r.data.trip); + // Dates changed → the conditions/cost cards may be stale; drop them (the active-trip broadcast + // re-fires from the trip effect, so dated ranger answers pick up the new window for free). + setDashboard(null); + setCost(null); + setCostErr(null); + return true; + } + return false; + }, + [postOp], + ); + + // Delete the open trip (P3.2). Callers confirm first (destructive); the DELETE route is rate-limited. + const removeTrip = useCallback(async () => { + const t = tripRef.current; + if (!t) return; + const res = await fetch(`/api/trips/${encodeURIComponent(t.id)}`, { method: 'DELETE' }); + if (res.status === 429) { + toast.info('Slow down — too many trip edits. Try again in a moment.'); + return; + } + if (!res.ok) { + toast.error("Couldn't delete this trip", 'Please try again in a moment.'); + return; + } + autoOpenedRef.current.delete(t.id); + setTrip(null); // deselect → the itinerary pane falls back to the switcher / empty state + setMetrics(null); + setAlerts(null); + setCost(null); + setCostErr(null); + setDashboard(null); + setDayMap({}); + await loadTrips(); + toast.success('Trip deleted'); + }, [loadTrips]); + const addPark = useCallback( async (code: string) => { const t = tripRef.current; @@ -398,6 +449,26 @@ export function TripBuilderProvider({ children }: { children: ReactNode }) { [postOp, applyMutation], ); + // Keyboard reorder (P3.3): swap the stop with its neighbor and persist via the reorder op. Reads the + // CURRENT order from tripRef (not a render closure) so rapid presses compose correctly. + const moveStop = useCallback( + async (stopId: string, dir: 'up' | 'down') => { + const t = tripRef.current; + if (!t) return; + const ids = liveStops(t).map((s) => s.id); + const i = ids.indexOf(stopId); + const j = dir === 'up' ? i - 1 : i + 1; + if (i < 0 || j < 0 || j >= ids.length) return; + [ids[i], ids[j]] = [ids[j], ids[i]]; + const r = await postOp<{ trip?: Trip | null; metrics?: TripMetrics | null }>(t.id, { op: 'reorder', orderedStopIds: ids }); + if (r.ok) { + setDayMap({}); // reorder clears the day plan (server-side too) — the prior grouping no longer maps + applyMutation(r.data); + } + }, + [postOp, applyMutation], + ); + const checkAlerts = useCallback(async () => { const t = tripRef.current; if (!t) return; @@ -464,16 +535,22 @@ export function TripBuilderProvider({ children }: { children: ReactNode }) { if (res.ok) { const { url } = (await res.json()) as { url: string }; const full = `${window.location.origin}${url}`; - setShareUrl(full); + setShareUrl(full); // the copy row is always shown as a backstop (P3.6) + // Native share sheet where available (mobile / Safari) — P3.6. It must run in the click's user + // activation; the POST above is fast, but if activation lapsed navigator.share throws → clipboard. + if (typeof navigator.share === 'function') { + try { + await navigator.share({ title: t.name, text: `Trip plan: ${t.name}`, url: full }); + return; + } catch (e) { + if ((e as Error)?.name === 'AbortError') return; // the user dismissed the sheet — not a failure + } + } let copied = false; if (navigator.clipboard?.writeText) { copied = await navigator.clipboard.writeText(full).then(() => true).catch(() => false); } - if (copied) { - toast.success('Share link copied', 'A read-only link to this trip is on your clipboard.'); - } else { - toast.success('Share link ready', 'Copy the read-only link shown below.'); - } + toast.success(copied ? 'Share link copied' : 'Share link ready', copied ? 'A read-only link to this trip is on your clipboard.' : 'Copy the read-only link shown below.'); } else { toast.error("Couldn't create share link", 'Please try again in a moment.'); } @@ -542,17 +619,24 @@ export function TripBuilderProvider({ children }: { children: ReactNode }) { [postOp, applyMutation], ); + // Suggest a day plan AND persist it (P3.8): `applyDays` writes stop.day so the plan survives a trip + // switch / reload (the old `suggestDays` op only previewed and was lost on reopen). The fresh trip + // carries the days on `stop.day`, so we clear the ephemeral dayMap and let the rows read the persisted + // value. A reorder later clears the days (server-side), which is correct — reordering invalidates pacing. const suggestDayPlan = useCallback(async () => { const t = tripRef.current; if (!t) return; opStart('days'); try { - const r = await postOp<{ days?: { id: string; day: number }[] }>(t.id, { op: 'suggestDays' }); - if (r.ok) setDayMap(Object.fromEntries((r.data.days ?? []).map((d) => [d.id, d.day]))); + const r = await postOp<{ trip?: Trip | null; metrics?: TripMetrics | null }>(t.id, { op: 'applyDays' }); + if (r.ok) { + setDayMap({}); + applyMutation(r.data); + } } finally { opEnd('days'); } - }, [postOp]); + }, [postOp, applyMutation]); // Drag choreography (ADR-076): while a stop row is mid-drag, an incoming ranger refresh is stashed // instead of replacing the list under the user's finger. endDrag applies the stash — the fresh server @@ -675,11 +759,14 @@ export function TripBuilderProvider({ children }: { children: ReactNode }) { openTrip, create, rename, + setDates, + removeTrip, addPark, removeStop, removeHike, removeLodging, persistReorder, + moveStop, checkAlerts, checkCost, checkConditions, @@ -695,9 +782,9 @@ export function TripBuilderProvider({ children }: { children: ReactNode }) { [ trips, trip, stops, metrics, alerts, cost, costErr, dashboard, dayMap, shareUrl, err, busyOps, openingId, overPackedDays, canvasStops, canvasOrigin, addedParkCodes, - loadTrips, openTrip, create, rename, addPark, removeStop, removeHike, removeLodging, persistReorder, - checkAlerts, checkCost, checkConditions, share, optimizeRoute, fork, setOrigin, suggestDayPlan, - applyMutation, beginDrag, endDrag, + loadTrips, openTrip, create, rename, setDates, removeTrip, addPark, removeStop, removeHike, + removeLodging, persistReorder, moveStop, checkAlerts, checkCost, checkConditions, share, + optimizeRoute, fork, setOrigin, suggestDayPlan, applyMutation, beginDrag, endDrag, ], ); diff --git a/db/migrations/029_plan_transcript.cypher b/db/migrations/029_plan_transcript.cypher new file mode 100644 index 0000000..7128716 --- /dev/null +++ b/db/migrations/029_plan_transcript.cypher @@ -0,0 +1,5 @@ +// Per-user /plan ranger-chat transcript replay (ADR-076 P3.9), the sibling of the lesson TutorTranscript: +// persist the client's authoritative Eve event stream per user so a reload / pull-to-refresh on the plan +// surface rehydrates the chat — WITH its cards — instead of emptying it. One node per user; the app keys +// it by userId (a single ranger conversation per user), so the constraint is on PlanTranscript.userId. +CREATE CONSTRAINT plantranscript_userid IF NOT EXISTS FOR (t:PlanTranscript) REQUIRE t.userId IS UNIQUE; diff --git a/lib/plan-transcript.ts b/lib/plan-transcript.ts new file mode 100644 index 0000000..902f7d8 --- /dev/null +++ b/lib/plan-transcript.ts @@ -0,0 +1,47 @@ +import { readGraph, writeGraph } from './neo4j'; + +/** + * Per-user `/plan` ranger-chat transcript (ADR-076 P3.9), the sibling of `lib/learn-transcript.ts`. We + * persist the CLIENT's authoritative Eve event stream (`agent.events`) per user — ONE ranger conversation + * per user, not per lesson — so a reload / pull-to-refresh on mobile restores the thread WITH its cards + * instead of emptying it. Events are stored as an opaque JSON string, round-tripped verbatim into + * `initialEvents`. The session cursor is deliberately NOT persisted (mirrors the lesson player): a reload + * replays the thread for display but starts a fresh Eve session on the next send, so a stale/expired + * server session can never wedge the next turn. + */ +export interface PlanTranscript { + events: unknown[]; +} + +const EMPTY: PlanTranscript = { events: [] }; + +function safeParse(raw: string | null, fallback: T): T { + if (!raw) return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +/** Load a user's saved /plan chat transcript (empty when none stored yet). */ +export async function getPlanTranscript(userId: string): Promise { + const rows = await readGraph<{ events: string | null }>( + `MATCH (:User {userId: $userId})-[:HAS_PLAN_TRANSCRIPT]->(t:PlanTranscript {userId: $userId}) + RETURN t.events AS events`, + { userId }, + ); + const r = rows[0]; + if (!r) return EMPTY; + return { events: safeParse(r.events, [] as unknown[]) }; +} + +/** Upsert a user's /plan chat transcript (idempotent MERGE on userId — one per user). */ +export async function savePlanTranscript(userId: string, data: PlanTranscript): Promise { + await writeGraph( + `MERGE (u:User {userId: $userId}) + MERGE (u)-[:HAS_PLAN_TRANSCRIPT]->(t:PlanTranscript {userId: $userId}) + SET t.events = $events, t.updatedAt = datetime()`, + { userId, events: JSON.stringify(data.events ?? []) }, + ); +} diff --git a/lib/trips.ts b/lib/trips.ts index f23eedd..f8ec5ec 100644 --- a/lib/trips.ts +++ b/lib/trips.ts @@ -4,6 +4,7 @@ import { type LatLng } from './routing'; import { cachedDriveSegments } from './drive-cache'; import { considerPark, getHomeLocation } from './bridges'; import { buildParkConditions, type ConditionsCardData, type TripDashboard } from './conditions'; +import { suggestDays } from './itinerary'; import { decodeEntities } from './html-entities'; /** @@ -89,6 +90,57 @@ export async function renameTrip(userId: string, tripId: string, name: string): ); } +/** + * Set the trip window (ADR-076 P3.1). Stored as plain YYYY-MM-DD strings (every consumer — tripMetrics + * dark-hours, the ICS export, the ranger's active-trip context — parses strings; no graph date() migration). + * Each end is nullable to clear it independently. No server recompute: conditions/day-plans read dates at + * render time, so the client just invalidates its cards. + */ +export async function setTripDates( + userId: string, + tripId: string, + dates: { startDate?: string | null; endDate?: string | null }, +): Promise { + const rows = await writeGraph<{ ok: boolean }>( + `MATCH (t:Trip {id: $tripId, userId: $userId}) + SET t.startDate = CASE WHEN $setStart THEN $startDate ELSE t.startDate END, + t.endDate = CASE WHEN $setEnd THEN $endDate ELSE t.endDate END + RETURN true AS ok`, + { + userId, + tripId, + setStart: dates.startDate !== undefined, + setEnd: dates.endDate !== undefined, + startDate: dates.startDate ?? null, + endDate: dates.endDate ?? null, + }, + ); + return rows.length > 0; +} + +/** + * Persist a suggested day plan to `stop.day` (ADR-076 P3.8) so it survives a trip switch / reload — the + * `suggestDays` op computes but never wrote, so the plan died on reopen. Recomputes the same pure pacing, + * then writes each stop's day. Returns false when the trip has no stops. + */ +export async function applyDayPlan(userId: string, tripId: string, maxHoursPerDay?: number): Promise { + const trip = await getTrip(userId, tripId); + if (!trip) return false; + const stops = (trip.stops ?? []).filter(Boolean) as { id: string; driveTo?: { minutes: number } | null }[]; + if (stops.length === 0) return false; + const assignments = suggestDays( + stops.map((s) => ({ id: s.id, driveMinutesToHere: s.driveTo?.minutes ?? 0 })), + maxHoursPerDay ? { maxMinutesPerDay: maxHoursPerDay * 60 } : {}, + ); + await writeGraph( + `UNWIND $days AS d + MATCH (t:Trip {id: $tripId, userId: $userId})-[:HAS_STOP]->(s:Stop {id: d.id}) + SET s.day = d.day`, + { userId, tripId, days: assignments }, + ); + return true; +} + export async function listTrips(userId: string) { return readGraph( `MATCH (t:Trip {userId: $userId}) @@ -394,13 +446,13 @@ export async function reorderStops(userId: string, tripId: string, orderedStopId await writeGraph( `MATCH (t:Trip {id:$tripId, userId:$userId})-[:HAS_STOP]->(s:Stop) WITH s, $ids AS ids - SET s.order = apoc.coll.indexOf(ids, s.id)`, + SET s.order = apoc.coll.indexOf(ids, s.id), s.day = null`, { userId, tripId, ids: orderedStopIds }, ).catch(async () => { // No APOC? fall back to per-stop updates. for (let i = 0; i < orderedStopIds.length; i++) { await writeGraph( - `MATCH (t:Trip {id:$tripId, userId:$userId})-[:HAS_STOP]->(s:Stop {id:$sid}) SET s.order = $o`, + `MATCH (t:Trip {id:$tripId, userId:$userId})-[:HAS_STOP]->(s:Stop {id:$sid}) SET s.order = $o, s.day = null`, { userId, tripId, sid: orderedStopIds[i], o: i }, ); } diff --git a/lib/validation.ts b/lib/validation.ts index f91d683..9d0392a 100644 --- a/lib/validation.ts +++ b/lib/validation.ts @@ -59,10 +59,15 @@ export const CreateTripSchema = z.object({ fromTourId: id.optional(), }); +// A calendar date, YYYY-MM-DD, or null to clear (ADR-076 P3.1 dates editor). Nullable so `setDates` can +// unset one end; the route additionally rejects endDate < startDate. +const ymdDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'expected YYYY-MM-DD').nullable(); + export const TripActionSchema = z.object({ op: z.enum([ 'addStop', 'removeStop', 'reorder', 'alerts', 'cost', 'conditions', - 'suggestDays', 'optimize', 'rename', 'fork', 'diff', + 'suggestDays', 'applyDays', 'optimize', 'rename', 'fork', 'diff', + 'setDates', // P3.1: persist the trip window (start/end), each YYYY-MM-DD or null to clear 'includeTrail', 'excludeTrail', 'includeCampground', 'excludeCampground', // Campgrounds feature: (:Stop)-[:STAYS_AT]->(:Campground) 'setOrigin', // trip origin (defaults from home): place text OR coords OR clear, + returnToOrigin toggle @@ -76,6 +81,10 @@ export const TripActionSchema = z.object({ campgroundId: z.string().max(200).optional(), // (:Stop)-[:STAYS_AT]->(:Campground) nights: z.number().int().min(1).max(60).optional(), date: z.string().max(20).optional(), // YYYY-MM-DD the lodging covers + // setDates: the trip window; each nullable so one end can be cleared independently. + startDate: ymdDate.optional(), + endDate: ymdDate.optional(), + maxHoursPerDay: z.number().min(2).max(14).optional(), // applyDays pacing budget // setOrigin: free-text place (geocoded server-side) OR explicit coords OR clearOrigin; toggle rides along. place: z.string().max(200).optional(), origin: latLng.optional(), diff --git a/playwright.config.ts b/playwright.config.ts index cc5cfae..ee59291 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -43,7 +43,7 @@ export default defineConfig({ isMobile: true, hasTouch: true, }, - testMatch: ['**/plan-canvas.spec.ts', '**/plan-hardening.spec.ts', '**/home-origin.spec.ts', '**/plan-mobile.spec.ts'], + testMatch: ['**/plan-canvas.spec.ts', '**/plan-hardening.spec.ts', '**/home-origin.spec.ts', '**/plan-mobile.spec.ts', '**/plan-phase3.spec.ts'], }, ], webServer: { diff --git a/tests/e2e/plan-phase3.spec.ts b/tests/e2e/plan-phase3.spec.ts new file mode 100644 index 0000000..248856d --- /dev/null +++ b/tests/e2e/plan-phase3.spec.ts @@ -0,0 +1,104 @@ +import { test, expect, type Page } from '@playwright/test'; +import { openPane } from './helpers/pane'; + +/** + * Phase 3 polish (ADR-076): trip dates editor (P3.1), trip delete (P3.2), persisted day plans (P3.8), + * keyboard reorder (P3.3), and the empty-trip checklist (P3.5). Runs in both Playwright projects; the + * shared `openPane` helper reaches the Itinerary pane on mobile (a no-op on desktop). E2E runs with + * DISABLE_EVE, so these assert builder UI that renders without a model turn. + */ +async function signUp(page: Page): Promise { + const email = `e2e-${Date.now()}-${Math.floor(Math.random() * 1e6)}@example.test`; + const res = await page.request.post('/api/auth/sign-up/email', { + data: { name: 'E2E User', email, password: 'test-password-123' }, + }); + expect(res.ok(), `sign-up failed: ${res.status()}`).toBeTruthy(); +} + +async function createTrip(page: Page, name: string): Promise { + await openPane(page, 'itinerary'); + await page.getByPlaceholder('New trip name').fill(name); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByRole('heading', { name })).toBeVisible(); +} + +async function addPark(page: Page, query: string, exactOption: string): Promise { + const search = page.getByPlaceholder(/Search parks by name/); + await search.fill(query); + await page.getByText(exactOption).click(); +} + +test('trip dates editor sets and clears the trip window (P3.1)', async ({ page }) => { + await signUp(page); + await page.goto('/plan'); + await createTrip(page, 'Dates E2E'); + + await page.getByRole('button', { name: 'Add dates' }).click(); + await page.getByLabel('Trip start date').fill('2026-08-10'); + await page.getByLabel('Trip end date').fill('2026-08-14'); + await page.getByRole('button', { name: 'Set', exact: true }).click(); + await expect(page.getByText('2026-08-10 → 2026-08-14')).toBeVisible(); + + // Clear reverts to the no-dates state. + await page.getByRole('button', { name: 'Clear' }).click(); + await expect(page.getByText('No dates set.')).toBeVisible(); +}); + +test('the empty-trip checklist shows the three add paths (P3.5)', async ({ page }) => { + await signUp(page); + await page.goto('/plan'); + await createTrip(page, 'Empty E2E'); + await expect(page.getByText('This trip is empty — add your first stop:')).toBeVisible(); + await expect(page.getByText('Ask the ranger to plan it for you')).toBeVisible(); +}); + +test('suggested day plan persists across a reload (P3.8)', async ({ page }) => { + await signUp(page); + await page.goto('/plan'); + await createTrip(page, 'Days E2E'); + await addPark(page, 'Yellowstone', 'Yellowstone National Park'); + await expect(page.getByText(/1\. Yellowstone/)).toBeVisible(); + await addPark(page, 'Glacier', 'Glacier National Park'); + await expect(page.getByText(/2\. Glacier/)).toBeVisible(); + + await page.getByRole('button', { name: 'Suggest day plan' }).click(); + await expect(page.getByText(/Day 1/).first()).toBeVisible(); + + // Reload: the persisted stop.day (applyDays) keeps the day headers — the old ephemeral dayMap was lost. + await page.reload(); + await openPane(page, 'itinerary'); + await page.getByRole('button', { name: /Days E2E/ }).click(); + await expect(page.getByText(/Day 1/).first()).toBeVisible(); +}); + +test('keyboard reorder moves a stop with the up/down buttons (P3.3)', async ({ page }) => { + await signUp(page); + await page.goto('/plan'); + await createTrip(page, 'Reorder E2E'); + await addPark(page, 'Yellowstone', 'Yellowstone National Park'); + await expect(page.getByText(/1\. Yellowstone/)).toBeVisible(); + await addPark(page, 'Glacier', 'Glacier National Park'); + await expect(page.getByText(/2\. Glacier/)).toBeVisible(); + + // Move Glacier up → it becomes stop 1. + await page.getByRole('button', { name: 'Move Glacier National Park up' }).click(); + await expect(page.getByText(/1\. Glacier/)).toBeVisible(); + await expect(page.getByText(/2\. Yellowstone/)).toBeVisible(); +}); + +test('delete removes the trip after confirmation (P3.2)', async ({ page }) => { + await signUp(page); + await page.goto('/plan'); + await createTrip(page, 'Delete E2E'); + await addPark(page, 'Yellowstone', 'Yellowstone National Park'); + await expect(page.getByText(/1\. Yellowstone/)).toBeVisible(); + + await page.getByRole('button', { name: 'More', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Delete trip…' }).click(); + await expect(page.getByText('Delete this trip?')).toBeVisible(); + await page.getByRole('button', { name: 'Delete trip', exact: true }).click(); + + // Heading gone; the switcher chip for it is gone too. + await expect(page.getByRole('heading', { name: 'Delete E2E' })).toHaveCount(0); + await expect(page.getByRole('button', { name: /Delete E2E/ })).toHaveCount(0); +});