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/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 42dc238..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,28 +52,43 @@ 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 });
}
+/** 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': {
@@ -91,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 43469b0..6db664e 100644
--- a/app/plan/page.tsx
+++ b/app/plan/page.tsx
@@ -1,48 +1,42 @@
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 { getPlanTranscript } from '../../lib/plan-transcript';
+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');
+ // 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 e94e186..7935659 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,52 @@ 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
+ // 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 +179,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 +397,7 @@ export function ChatPanel({
-
+
{messages.length === 0 ? (
{emptyHint}
@@ -341,6 +415,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 +496,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 +509,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..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';
@@ -20,8 +21,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 +45,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,12 +66,28 @@ 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);
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;
@@ -87,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) {
- if (busyRef.current) return;
+ // 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) {
@@ -110,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 {
@@ -124,94 +156,147 @@ 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 } });
+ // 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 = () => {
+ 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;
+
+ m.on('load', async () => {
+ attachMarkerImages(m); // styleimagemissing hook — survives setStyle image wipes (#2d)
+ installLayers(m);
+
+ // 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;
+ 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) => {
+ 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 }),
+ );
+ });
- // 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);
+ // 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 +304,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 +323,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(() => {
@@ -255,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/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/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
new file mode 100644
index 0000000..f52c30d
--- /dev/null
+++ b/components/plan/PlanShell.tsx
@@ -0,0 +1,285 @@
+'use client';
+import { useEffect, useRef, useState } from '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",
+ * ~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({ initialChatEvents }: { initialChatEvents?: unknown[] }) {
+ return (
+
+
+
+ );
+}
+
+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
+ // 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);
+ // 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);
+ 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() {
+ // "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 (
+ 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')} showPill={!SHEET} />
+
+
+ {/* 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. */}
+
+ {/* 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, showPill }: { onViewItinerary: () => void; showPill: boolean }) {
+ 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). Hidden in sheet mode
+ (the sheet is always present). */}
+ {showPill ? (
+
+ ) : null}
+ >
+ );
+}
diff --git a/components/plan/PlanTabBar.tsx b/components/plan/PlanTabBar.tsx
new file mode 100644
index 0000000..9fc3255
--- /dev/null
+++ b/components/plan/PlanTabBar.tsx
@@ -0,0 +1,106 @@
+'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, 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' },
+ {
+ key: 'itinerary',
+ label: 'Itinerary',
+ icon: ,
+ 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' },
+ ];
+
+ 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..e3a0b63
--- /dev/null
+++ b/components/plan/StopList.tsx
@@ -0,0 +1,248 @@
+'use client';
+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, 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;
+}) {
+ 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}
+ {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}
+
+ {
+ 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)}
+
+ {/* Keyboard-accessible reorder (P3.3): compact up/down, disabled at the ends. */}
+ onMove(s.id, 'up')}>
+
+
+ onMove(s.id, 'down')}>
+
+
+ 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.
+ * Keyboard reorder (P3.3) and per-day load chips (P3.4) layer on top.
+ */
+export function StopList() {
+ 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([]);
+ // 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]);
+
+ // 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() {
+ 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}
+ {/* 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
new file mode 100644
index 0000000..da5209b
--- /dev/null
+++ b/components/plan/TripActions.tsx
@@ -0,0 +1,134 @@
+'use client';
+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';
+
+/**
+ * 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, 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fork
+
+ Field brief
+
+
+ Offline pack
+
+
+ Export .ics
+
+
+ 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 ? (
+
+
+ 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..a517eb2 100644
--- a/components/plan/TripBuilder.tsx
+++ b/components/plan/TripBuilder.tsx
@@ -1,654 +1,42 @@
'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, 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 { 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, trips, 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,82 +55,23 @@ 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}
+
+
>
+ ) : 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
new file mode 100644
index 0000000..d2ca75e
--- /dev/null
+++ b/components/plan/TripHeader.tsx
@@ -0,0 +1,209 @@
+'use client';
+import { useState } from 'react';
+import { Badge, Button, Heading, HStack, Icon, Input, Text } from '@chakra-ui/react';
+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
+ * 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, 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;
+
+ 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('');
+ }
+ }
+ 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);
+
+ 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 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. */}
+
+
+ {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/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
new file mode 100644
index 0000000..b9111a7
--- /dev/null
+++ b/components/plan/useTripBuilder.tsx
@@ -0,0 +1,792 @@
+'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;
+ 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;
+ 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],
+ );
+
+ // 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;
+ 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],
+ );
+
+ // 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;
+ 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); // 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);
+ }
+ 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.');
+ }
+ }, []);
+
+ 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],
+ );
+
+ // 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<{ trip?: Trip | null; metrics?: TripMetrics | null }>(t.id, { op: 'applyDays' });
+ if (r.ok) {
+ setDayMap({});
+ applyMutation(r.data);
+ }
+ } finally {
+ opEnd('days');
+ }
+ }, [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
+ // 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,
+ setDates,
+ removeTrip,
+ addPark,
+ removeStop,
+ removeHike,
+ removeLodging,
+ persistReorder,
+ moveStop,
+ 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, setDates, removeTrip, addPark, removeStop, removeHike,
+ removeLodging, persistReorder, moveStop, checkAlerts, checkCost, checkConditions, share,
+ optimizeRoute, fork, setOrigin, suggestDayPlan, applyMutation, beginDrag, endDrag,
+ ],
+ );
+
+ return {children};
+}
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/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/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 f1f0801..ee59291 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', '**/plan-phase3.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();
+});
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);
+});