Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion agent/tools/add_campground_to_trip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } };
},
});
4 changes: 3 additions & 1 deletion agent/tools/add_trail_to_trip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } } };
},
});
4 changes: 4 additions & 0 deletions agent/tools/suggest_day_plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
},
};
Expand Down
24 changes: 24 additions & 0 deletions app/api/plan/transcript/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
49 changes: 42 additions & 7 deletions app/api/trips/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
removeStop,
reorderStops,
renameTrip,
setTripDates,
applyDayPlan,
checkTripAlerts,
tripCost,
tripConditions,
Expand Down Expand Up @@ -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': {
Expand All @@ -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.
Expand Down
44 changes: 19 additions & 25 deletions app/plan/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Flex
<Box
position="fixed"
top="57px"
left={0}
right={0}
bottom={0}
direction={{ base: 'column', md: 'row' }}
data-fullscreen
// dvh (not bottom:0/100vh) so the pane tracks the *dynamic* mobile viewport — with bottom:0 the
// iOS/Android URL bar overlapped the chat input. @supports keeps a 100vh fallback for old browsers.
css={{
height: 'calc(100vh - 57px)',
'@supports (height: 100dvh)': { height: 'calc(100dvh - 57px)' },
}}
>
<Heading as="h1" srOnly>Plan a trip</Heading>
<Box
flex="1"
h={{ base: '55%', md: '100%' }}
minH={0}
borderRightWidth={{ md: '1px' }}
borderBottomWidth={{ base: '1px', md: 0 }}
>
<TripBuilder />
</Box>
<Box w={{ base: '100%', md: '400px' }} h={{ base: '45%', md: '100%' }} minH={0}>
<ChatPanel />
</Box>
</Flex>
<PlanShell initialChatEvents={events} />
</Box>
);
}
Loading
Loading