diff --git a/agent/instructions.md b/agent/instructions.md
index 7fd5050..12893d9 100644
--- a/agent/instructions.md
+++ b/agent/instructions.md
@@ -133,6 +133,12 @@ about the detour; one friendly nudge, then move on. Stay in scope by default β
**NOT** save β pass it directly to **`find_trails`** (`maxMiles`/`maxGainFt`/`difficulty`/`dogsAllowed`)
so it applies to that search only. Saved trail preferences are auto-applied by `find_trails` (shown as
"narrowed to your trail preferences").
+ - **Home location carries the same scope rule.** When the user says where they live ("I'm in Bozeman,"
+ "we'd drive from Denver"), confirm with **`ask_question`** before saving: *"Save <place> as your home
+ (trips will start there by default)?"* β **π Yes, that's home** / **Don't save**. On yes β
+ **`set_home_location`** (`place`). If they only named a start for *this* trip ("we're flying into
+ Vegas first"), do **NOT** save home β that's the trip's origin, set on the trip itself. "Forget my
+ home" β `set_home_location` with `clear: true`.
- **Saving / logging trails.** "Save this trail" / "add it to my bucket list" β **`save_trail`**
(`kind: 'saved'` or `'wishlisted'`). "I've hiked Angels Landing" β **`record_trail_done`** (feeds their
hiking history + difficulty progression). Both need the trail id from a card.
diff --git a/agent/tools/set_home_location.ts b/agent/tools/set_home_location.ts
new file mode 100644
index 0000000..05efafe
--- /dev/null
+++ b/agent/tools/set_home_location.ts
@@ -0,0 +1,33 @@
+import { defineTool } from 'eve/tools';
+import { z } from 'zod';
+import { routing } from '../../lib/routing';
+import { setHomeLocation, getHomeLocation, clearHomeLocation } from '../../lib/bridges';
+import { callerId } from '../../lib/agent-ctx';
+
+/**
+ * Remember where the user lives (user-feedback iteration): geocode a free-text place ("Bozeman, MT")
+ * via the routing gateway and store it as the `(:User)-[:LIVES_AT]->(:Home)` anchor (migration 028).
+ * Durable personal data β the instructions require confirming via ask_question before calling (same
+ * scope rule as set_travel_constraints). Home feeds the trip-origin default, distance-from-home
+ * ranking, and the memory block. userId is server-bound (R4).
+ */
+export default defineTool({
+ description:
+ "Remember the user's home location (city/town) so trips can start from home and recommendations can rank by distance. Confirm with the user before saving; pass clear=true to forget it.",
+ inputSchema: z.object({
+ place: z.string().optional().describe("Free-text home place, e.g. 'Bozeman, MT'"),
+ clear: z.boolean().optional().describe('Forget the saved home location instead of setting one'),
+ }),
+ async execute({ place, clear }, ctx) {
+ const userId = callerId(ctx);
+ if (clear) {
+ await clearHomeLocation(userId);
+ return { kind: 'map_snippet', data: { cleared: true } };
+ }
+ if (!place?.trim()) return { error: 'Provide a place to save (or clear=true to forget).' };
+ const hit = await routing.geocode(place);
+ if (!hit) return { error: `Could not find "${place}" β ask the user for a nearby city or town.` };
+ await setHomeLocation(userId, { ...hit, source: 'geocode' });
+ return { kind: 'map_snippet', data: { saved: true, home: await getHomeLocation(userId) } };
+ },
+});
diff --git a/app/api/map/mine/route.ts b/app/api/map/mine/route.ts
index 8e262c0..6f51b15 100644
--- a/app/api/map/mine/route.ts
+++ b/app/api/map/mine/route.ts
@@ -1,5 +1,6 @@
import { getUserId } from '../../../../lib/session';
import { consideredParksGeo, collectedStampParksGeo } from '../../../../lib/memory-graph';
+import { getHomeLocation } from '../../../../lib/bridges';
import { forYou } from '../../../../lib/recommend';
import { travelersAlsoLoved } from '../../../../lib/collective';
import { serverError } from '../../../../lib/http';
@@ -13,15 +14,16 @@ export const dynamic = 'force-dynamic';
export async function GET(req: Request) {
const userId = await getUserId(req);
- if (!userId) return Response.json({ considered: [], forYou: [], stamps: [], collective: [] });
+ if (!userId) return Response.json({ considered: [], forYou: [], stamps: [], collective: [], home: null });
try {
// Each overlay is independent β a failure in one shouldn't 500 the whole map. Degrade to an empty
// layer (the overlay just renders nothing) rather than rejecting the Promise.all.
- const [considered, stamps, rec, collective] = await Promise.all([
+ const [considered, stamps, rec, collective, home] = await Promise.all([
consideredParksGeo(userId).catch(() => []),
collectedStampParksGeo(userId).catch(() => []),
forYou(userId, { limit: 12 }).then((r) => r.parks).catch(() => []),
travelersAlsoLoved(userId, 8).catch(() => []),
+ getHomeLocation(userId).catch(() => null),
]);
const pin = (p: { parkCode: string; lat: number | null; lng: number | null }) => ({ parkCode: p.parkCode, lat: p.lat, lng: p.lng });
return Response.json({
@@ -32,6 +34,8 @@ export async function GET(req: Request) {
collective: collective
.filter((p) => p.lat != null && p.lng != null)
.map((p) => ({ parkCode: p.parkCode, lat: p.lat ?? null, lng: p.lng ?? null, travelers: p.travelers })),
+ // Home pin (LIVES_AT anchor) β orients the personal overlay for cross-country planning.
+ home: home ? { lat: home.latitude, lng: home.longitude, label: home.label } : null,
});
} catch (err) {
return serverError('map-mine', err);
diff --git a/app/api/me/home/route.ts b/app/api/me/home/route.ts
new file mode 100644
index 0000000..b2f1331
--- /dev/null
+++ b/app/api/me/home/route.ts
@@ -0,0 +1,59 @@
+import { z } from 'zod';
+import { getUserId } from '../../../../lib/session';
+import { routing } from '../../../../lib/routing';
+import { setHomeLocation, getHomeLocation, clearHomeLocation } from '../../../../lib/bridges';
+import { serverError } from '../../../../lib/http';
+
+/**
+ * Home location (user-feedback iteration): GET/PUT/DELETE the `(:User)-[:LIVES_AT]->(:Home)` anchor.
+ * Geocoding stays server-side (the ORS key never reaches the client): PUT either a free-text `place`
+ * (forward geocode) or browser-geolocation `latitude`/`longitude` (reverse geocode for the label).
+ */
+export const dynamic = 'force-dynamic';
+
+const putSchema = z.union([
+ z.object({ place: z.string().min(2).max(200) }),
+ z.object({ latitude: z.number().min(-90).max(90), longitude: z.number().min(-180).max(180) }),
+]);
+
+export async function GET(req: Request) {
+ const userId = await getUserId(req);
+ if (!userId) return Response.json({ home: null });
+ try {
+ return Response.json({ home: await getHomeLocation(userId) });
+ } catch (err) {
+ return serverError('me-home', err);
+ }
+}
+
+export async function PUT(req: Request) {
+ const userId = await getUserId(req);
+ if (!userId) return Response.json({ error: 'Sign in to save a home location' }, { status: 401 });
+ const parsed = putSchema.safeParse(await req.json().catch(() => null));
+ if (!parsed.success) return Response.json({ error: 'Provide a place or coordinates' }, { status: 400 });
+ try {
+ if ('place' in parsed.data) {
+ const hit = await routing.geocode(parsed.data.place);
+ if (!hit) return Response.json({ error: `Couldn't find "${parsed.data.place}"` }, { status: 404 });
+ await setHomeLocation(userId, { ...hit, source: 'geocode' });
+ } else {
+ const { latitude, longitude } = parsed.data;
+ const label = (await routing.reverseGeocode({ latitude, longitude })) ?? 'My location';
+ await setHomeLocation(userId, { latitude, longitude, label, source: 'geolocation' });
+ }
+ return Response.json({ home: await getHomeLocation(userId) });
+ } catch (err) {
+ return serverError('me-home', err);
+ }
+}
+
+export async function DELETE(req: Request) {
+ const userId = await getUserId(req);
+ if (!userId) return Response.json({ error: 'Sign in first' }, { status: 401 });
+ try {
+ await clearHomeLocation(userId);
+ return Response.json({ home: null });
+ } catch (err) {
+ return serverError('me-home', err);
+ }
+}
diff --git a/app/api/trips/[id]/route.ts b/app/api/trips/[id]/route.ts
index f7cbb36..42dc238 100644
--- a/app/api/trips/[id]/route.ts
+++ b/app/api/trips/[id]/route.ts
@@ -14,6 +14,8 @@ import {
addLodgingToStop,
removeCampgroundFromStop,
} from '../../../../lib/trips';
+import { setTripOrigin } from '../../../../lib/trips';
+import { routing } from '../../../../lib/routing';
import { suggestDays } from '../../../../lib/itinerary';
import { nearestNeighborOrder } from '../../../../lib/route-order';
import { forkTrip, tripDiff, tripMetrics } from '../../../../lib/trip-lab';
@@ -89,6 +91,24 @@ export async function POST(req: Request, { params }: Ctx) {
await renameTrip(userId, id, name);
return Response.json({ trip: await getTrip(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.
+ let origin: { latitude: number; longitude: number; label?: string } | null | undefined;
+ if (body.clearOrigin) origin = null;
+ else if (body.origin) origin = body.origin;
+ else if (body.place?.trim()) {
+ const hit = await routing.geocode(body.place.trim());
+ if (!hit) return Response.json({ error: `Couldn't find "${body.place.trim()}"` }, { status: 404 });
+ origin = hit;
+ }
+ if (origin === undefined && body.returnToOrigin === undefined) {
+ return Response.json({ error: 'place, origin, clearOrigin, or returnToOrigin required' }, { status: 400 });
+ }
+ const ok = await setTripOrigin(userId, id, { origin, returnToOrigin: body.returnToOrigin });
+ if (!ok) return Response.json({ error: 'not found' }, { status: 404 });
+ return Response.json({ trip: await getTrip(userId, id), metrics: await liveMetrics(userId, id) });
+ }
case 'addStop': {
if (!body.stop) return Response.json({ error: 'stop required' }, { status: 400 });
const stopId = await addStop(userId, id, body.stop);
diff --git a/app/campgrounds/[id]/page.tsx b/app/campgrounds/[id]/page.tsx
index e8888dc..673be40 100644
--- a/app/campgrounds/[id]/page.tsx
+++ b/app/campgrounds/[id]/page.tsx
@@ -4,6 +4,7 @@ import NextLink from 'next/link';
import { notFound } from 'next/navigation';
import {
LuArrowLeft,
+ LuCalendarCheck,
LuMapPin,
LuPlug,
LuDollarSign,
@@ -19,6 +20,7 @@ import {
LuTruck,
} from 'react-icons/lu';
import { campgroundDetail, campsitesForCampground } from '../../../lib/campgrounds';
+import { bookingSignal, BOOKING_PALETTE } from '../../../lib/camp-booking';
import { getWeather } from '../../../lib/datasources/weather';
import { recreationUrl } from '../../../lib/datasources/recreation';
@@ -53,6 +55,7 @@ export default async function CampgroundDetailPage({ params }: { params: Promise
]);
const bookUrl = cg.reservationUrl ?? (cg.ridbId ? recreationUrl(cg.ridbId) : null);
+ const booking = bookingSignal(cg); // the plain-English "reservation vs first-come" answer
const agencyLabel = cg.agencyName ?? AGENCY_LABEL[cg.agency ?? ''] ?? 'Campground';
const where = cg.parkName ?? cg.recAreaName ?? null;
const confidence = cg.dataConfidence ?? (cg.source === 'nps+ridb' ? 'high' : 'medium');
@@ -138,11 +141,45 @@ export default async function CampgroundDetailPage({ params }: { params: Promise
{cg.dispersed ? Dispersed : null}
- {/* CTAs */}
+ {/* Booking clarity β the plain-English "do I reserve or just show up?" callout (user feedback). */}
+
+
+
+ {booking.kind === 'fcfs' ? : booking.kind === 'unknown' ? : }
+
+
+ {booking.label}
+ {booking.detail ? {booking.detail} : null}
+ {booking.kind === 'fcfs' ? (
+ No reservations β arrive early to claim a site.
+ ) : null}
+
+
+
+
+ {/* CTAs β ONE booking action (the callout above stays informational), labeled to match the booking
+ kind: "Book" would contradict a first-come or unknown-booking campground. */}
{bookUrl ? (
) : null}
@@ -176,9 +213,12 @@ export default async function CampgroundDetailPage({ params }: { params: Promise
{s.maxRvLengthFt ? `${s.maxRvLengthFt} ft` : 'β'}
{s.electricAmps ? `${s.electricAmps}A` : 'β'}
+ {s.maxPeople != null ? up to {s.maxPeople} people : null}
{s.hasWater ? water : null}
{s.hasSewer ? sewer : null}
{s.pullThrough ? pull-through : null}
+ {s.campfireAllowed != null ? {s.campfireAllowed ? 'campfire ok' : 'no campfires'} : null}
+ {s.shade ? shade : null}
{s.ada ? ADA : null}
{s.reservable ? reservable : first-come}
diff --git a/app/explore/page.tsx b/app/explore/page.tsx
index 3b3e926..8a9897c 100644
--- a/app/explore/page.tsx
+++ b/app/explore/page.tsx
@@ -19,7 +19,8 @@ import { LuChevronLeft, LuChevronRight, LuSearch, LuTelescope } from 'react-icon
import { searchParks, facets } from '../../lib/queries';
import { forYou } from '../../lib/recommend';
import { getServerUserId } from '../../lib/session';
-import { getTravelConstraints } from '../../lib/bridges';
+import { getTravelConstraints, getHomeLocation } from '../../lib/bridges';
+import { greatCircleMiles } from '../../lib/routing';
import { ParkCard } from '../../components/ParkCard';
import { RankPanel } from '../../components/explore/RankPanel';
import { WhyThisPark } from '../../components/parks/WhyThisPark';
@@ -44,6 +45,8 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
const userId = await getServerUserId();
const pageSize = 24;
const page = Math.max(1, Number(sp.page) || 1);
+ // Saved home (LIVES_AT anchor) unlocks the distance-from-home sort + the "~N mi from home" card line.
+ const home = userId ? await getHomeLocation(userId).catch(() => null) : null;
const [search, f, recs] = await Promise.all([
searchParks({
q: sp.q,
@@ -60,6 +63,8 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
firstCome: sp.firstCome === '1',
groupSites: sp.groupSites === '1',
region: sp.region,
+ home: home ? { latitude: home.latitude, longitude: home.longitude } : undefined,
+ sort: sp.sort === 'home' && home ? 'home' : 'default',
limit: pageSize,
offset: (page - 1) * pageSize,
}),
@@ -145,6 +150,18 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
{f.regions.length > 0 ? (
) : null}
+ {home ? (
+
+ Sort
+
+
+
+
+
+
+
+
+ ) : null}
@@ -193,9 +210,16 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
) : (
<>
-
+
{results.map((p) => (
-
+
+
+ {home && p.lat != null && p.lng != null ? (
+
+ ~{Math.round(greatCircleMiles({ latitude: home.latitude, longitude: home.longitude }, { latitude: p.lat, longitude: p.lng }))} mi from home
+
+ ) : null}
+
))}
{hasPrev || hasNext ? (
diff --git a/app/me/page.tsx b/app/me/page.tsx
index d8110b0..98f8f67 100644
--- a/app/me/page.tsx
+++ b/app/me/page.tsx
@@ -3,6 +3,8 @@ import NextLink from 'next/link';
import { LuBrain } from 'react-icons/lu';
import { getServerUserId } from '../../lib/session';
import { getUserMemory, userContextGraph } from '../../lib/memory-graph';
+import { getHomeLocation } from '../../lib/bridges';
+import { HomeLocationCard } from '../../components/memory/HomeLocationCard';
import { getLearningMemory } from '../../lib/learn-queries';
import { allBadges } from '../../lib/learn-badges';
import { MemoryList } from '../../components/memory/MemoryList';
@@ -33,11 +35,12 @@ export default async function MePage() {
);
}
- const [memory, context, learning, badges] = await Promise.all([
+ const [memory, context, learning, badges, home] = await Promise.all([
getUserMemory(userId),
userContextGraph(userId).catch(() => null),
getLearningMemory(userId),
allBadges(),
+ getHomeLocation(userId).catch(() => null),
]);
return (
@@ -49,6 +52,7 @@ export default async function MePage() {
/>
{context ? : null}
+
diff --git a/app/parks/[parkCode]/page.tsx b/app/parks/[parkCode]/page.tsx
index 68a834c..d191a62 100644
--- a/app/parks/[parkCode]/page.tsx
+++ b/app/parks/[parkCode]/page.tsx
@@ -13,10 +13,11 @@ import {
} from '@chakra-ui/react';
import NextImage from 'next/image';
import NextLink from 'next/link';
-import { LuCalendar, LuClock, LuFootprints, LuMoon, LuStar, LuTicket, LuUsers } from 'react-icons/lu';
+import { LuCalendar, LuClock, LuFootprints, LuHouse, LuMoon, LuStar, LuTicket, LuUsers } from 'react-icons/lu';
import { StatCard } from '../../../components/ui/stat-card';
import { parkDetail, similarParks, nearbyParks, oftenPlannedTogether, parkGraph, peopleForPark, toursForPark, stampsForPark, eventsForPark, placesForPark, articlesForPark, parkingForPark, accessibilityScorecard, newsForPark, mediaForPark, checkOpen, lessonPlansForPark, type AccessibilityScorecard, type ParkMedia, type LessonPlanSummary } from '../../../lib/queries';
-import { getAvailability } from '../../../lib/bridges';
+import { getAvailability, getHomeLocation } from '../../../lib/bridges';
+import { greatCircleMiles } from '../../../lib/routing';
import { darkSkyRating, monthNames, difficultyDot, getWeather, getConditions, getAstro, sqmFromBortle, type Difficulty } from '../../../lib/datasources';
import { explainForParks } from '../../../lib/explain';
import { getServerUserId } from '../../../lib/session';
@@ -177,6 +178,12 @@ export default async function ParkPage({ params }: { params: Promise<{ parkCode:
const statesLabel = (park.states as { name: string }[]).map((s) => s.name).filter(Boolean).join(', ');
const hours = park.operatingHours as { name: string; description: string }[];
const openSeasons = (park.openSeasons as string[]) ?? [];
+ // Distance from the saved home (LIVES_AT anchor) β great-circle, clearly approximate, never a drive time.
+ const home = userId ? await getHomeLocation(userId).catch(() => null) : null;
+ const homeMiles =
+ home && park.lat != null && park.lng != null
+ ? Math.round(greatCircleMiles({ latitude: home.latitude, longitude: home.longitude }, { latitude: park.lat as number, longitude: park.lng as number }))
+ : null;
return (
@@ -192,6 +199,9 @@ export default async function ParkPage({ params }: { params: Promise<{ parkCode:
{/* "At a glance" stat row (R4 Β§3). */}
+ {homeMiles != null ? (
+
+ ) : null}
{dark ? (
s.lat != null && s.lng != null) ? (
- ({ lat: s.lat ?? null, lng: s.lng ?? null, label: s.parkName ?? s.name ?? 'Stop', order: s.order }))} />
+ ({ lat: s.lat ?? null, lng: s.lng ?? null, label: s.parkName ?? s.name ?? 'Stop', order: s.order }))}
+ origin={trip.origin ? { lat: trip.origin.lat, lng: trip.origin.lng, label: trip.origin.label, roundTrip: trip.returnToOrigin ?? false } : null}
+ />
) : null}
diff --git a/components/MapExplorer.tsx b/components/MapExplorer.tsx
index fd82695..862c223 100644
--- a/components/MapExplorer.tsx
+++ b/components/MapExplorer.tsx
@@ -31,7 +31,6 @@ import { conditionMatchStops, conditionDefaultColor, conditionLegend } from '../
import { BasemapSwitcher } from './map/BasemapSwitcher';
import { useColorMode } from './ui/color-mode';
import { brandColors, type BrandColors } from '../lib/brandColors';
-import { pine } from '../theme/colors';
import type { ParkPoint } from '../lib/queries';
import { encodeMapView, type MapView } from '../lib/map-deeplink';
import { toast } from '../lib/toast';
@@ -62,6 +61,7 @@ interface MineData {
forYou: MinePin[];
stamps: MinePin[];
collective: (MinePin & { travelers: number })[];
+ home: { lat: number; lng: number; label: string | null } | null;
}
/** The feature.properties the parks source carries (baked in loadAllParks) β for applyParkFilter casts. */
@@ -79,7 +79,7 @@ const POI_ZOOM_MIN = 5;
const MOVE_DEBOUNCE_MS = 250;
// "Your map" mode (#6) hides the base parks and shows the personal overlay (and vice versa).
const BASE_PARK_LAYERS = ['clusters', 'cluster-count', 'park-point', 'park-point-icon'];
-const MINE_LAYERS = ['mine-collective', 'mine-considered', 'mine-foryou', 'mine-stamps'];
+const MINE_LAYERS = ['mine-collective', 'mine-considered', 'mine-foryou', 'mine-stamps', 'mine-home', 'mine-home-label'];
// Park boundary polygons fade in past this zoom; fetched lazily, capped per pan to bound NPS fan-out (#2c).
const BOUNDARY_ZOOM_MIN = 7;
const BOUNDARY_FETCH_CAP = 8;
@@ -438,6 +438,11 @@ export function MapExplorer({
(map.getSource('mine-foryou') as GeoJSONSource | undefined)?.setData(fc(data.forYou));
(map.getSource('mine-stamps') as GeoJSONSource | undefined)?.setData(fc(data.stamps));
(map.getSource('mine-collective') as GeoJSONSource | undefined)?.setData(fc(data.collective));
+ (map.getSource('mine-home') as GeoJSONSource | undefined)?.setData(
+ data.home
+ ? { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: { type: 'Point', coordinates: [data.home.lng, data.home.lat] }, properties: { label: data.home.label ?? 'Home' } }] }
+ : { type: 'FeatureCollection', features: [] },
+ );
}
// Apply the current mode's visibility (base parks vs personal overlay); in 'mine' also (lazy-)load the data.
function applyMode(map: MlMap) {
@@ -853,6 +858,13 @@ export function MapExplorer({
map.addSource('mine-stamps', { type: 'geojson', data: emptyFC() });
map.addLayer({ id: 'mine-stamps', type: 'circle', source: 'mine-stamps', layout: { visibility: 'none' },
paint: { 'circle-radius': 6, 'circle-color': c.stamps, 'circle-stroke-width': 1.5, 'circle-stroke-color': '#fff' } });
+ // Home pin (user-feedback iteration): the LIVES_AT anchor, an orienting β in "mine" mode.
+ map.addSource('mine-home', { type: 'geojson', data: emptyFC() });
+ map.addLayer({ id: 'mine-home', type: 'circle', source: 'mine-home', layout: { visibility: 'none' },
+ paint: { 'circle-radius': 8, 'circle-color': c.trail, 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' } });
+ map.addLayer({ id: 'mine-home-label', type: 'symbol', source: 'mine-home', layout: { visibility: 'none',
+ 'text-field': 'β', 'text-size': 11, 'text-font': ['Noto Sans Medium'], 'text-allow-overlap': true },
+ paint: { 'text-color': '#fff' } });
// Ranger command-bar highlight (#7), topmost: a soft halo + a bold ring around the parks the ranger
// surfaced, in the AI/accent tone. Non-clustered so highlights survive at any zoom (like the overlays).
@@ -911,7 +923,7 @@ export function MapExplorer({
: p.kind === 'activity' ? `${p.weight} shared activit${p.weight === 1 ? 'y' : 'ies'}`
: p.via ? `On the β${p.via}β journey`
: 'On this journey';
- new maplibregl.Popup().setLngLat(e.lngLat).setHTML(`Why connected
${escapeHtml(label)}`).addTo(map);
+ new maplibregl.Popup().setLngLat(e.lngLat).setHTML(`Why connected
${escapeHtml(label)}`).addTo(map);
});
for (const key of POI_ORDER) {
// Unclustered POI point β popup.
@@ -923,9 +935,9 @@ export function MapExplorer({
// A trailhead links to the trail detail; every other POI links to its park.
const link =
key === 'trails' && props.id
- ? `
View trail β`
+ ? `
View trail β`
: props.parkCode
- ? `
View park β`
+ ? `
View park β`
: '';
new maplibregl.Popup().setLngLat([lng, lat]).setHTML(`${escapeHtml(props.name)}${link}`).addTo(map);
// Focusing a trailhead loads that park's trail route lines (ADR-066).
@@ -954,7 +966,7 @@ export function MapExplorer({
const p = f.properties as { parkCode: string; name: string; designation: string };
const [lng, lat] = (f.geometry as Point).coordinates;
new maplibregl.Popup().setLngLat([lng, lat]).setHTML(
- `${escapeHtml(p.name)}
${escapeHtml(p.designation ?? '')}
View park β`,
+ `${escapeHtml(p.name)}
${escapeHtml(p.designation ?? '')}
View park β`,
).addTo(map);
});
diff --git a/components/campgrounds/CampgroundCard.tsx b/components/campgrounds/CampgroundCard.tsx
index ee40063..4168f80 100644
--- a/components/campgrounds/CampgroundCard.tsx
+++ b/components/campgrounds/CampgroundCard.tsx
@@ -13,6 +13,7 @@ import {
LuTentTree,
} from 'react-icons/lu';
import type { CampgroundSummary } from '../../lib/campgrounds';
+import { bookingSignal, BOOKING_BADGE_LABEL, BOOKING_PALETTE } from '../../lib/camp-booking';
/** Managing-agency β accent-bar color (mirrors TrailCard's DIFF_COLOR). */
const AGENCY_COLOR: Record = {
@@ -100,10 +101,12 @@ export function CampgroundCard({
availability?: CampAvailabilityChip;
}) {
const bar = AGENCY_COLOR[cg.agency ?? ''] ?? 'border.emphasized';
- const agencyLabel = AGENCY_LABEL[cg.agency ?? ''] ?? cg.agency ?? 'Campground';
+ // No badge when the agency is unknown β a generic "Campground" chip is noise next to the card title.
+ const agencyLabel = AGENCY_LABEL[cg.agency ?? ''] ?? cg.agency ?? null;
const where = cg.parkName ?? cg.recAreaName ?? null;
const confidence = cg.dataConfidence ?? (cg.source === 'nps+ridb' ? 'high' : 'medium');
const sourceLabel = SOURCE_LABEL[cg.source] ?? 'NPS';
+ const booking = bookingSignal(cg); // reservation vs first-come, in plain English
return (
@@ -113,10 +116,14 @@ export function CampgroundCard({
-
+ {/* Two lines before truncating β "Fishing Bridge RV Park" / "Gallatin Dispersed Area"
+ were clamping to "Fishing Bridgeβ¦" with plenty of card height to spare. */}
+
{cg.name}
- {agencyLabel}
+ {agencyLabel ? (
+ {agencyLabel}
+ ) : null}
{where ? (
@@ -128,13 +135,12 @@ export function CampgroundCard({
{cg.totalSites != null ? {cg.totalSites} sites : null}
- {cg.sitesReservable != null || cg.sitesFirstCome != null ? (
-
- {cg.sitesReservable != null ? `R${cg.sitesReservable}` : ''}
- {cg.sitesReservable != null && cg.sitesFirstCome != null ? ' Β· ' : ''}
- {cg.sitesFirstCome != null ? `FCFS${cg.sitesFirstCome}` : ''}
-
+ {booking.kind !== 'unknown' ? (
+
+ {BOOKING_BADGE_LABEL[booking.kind]}
+
) : null}
+ {booking.detail ? {booking.detail} : null}
{cg.distanceMiles != null ? (
{cg.distanceMiles} mi
) : null}
diff --git a/components/chat/Cards.tsx b/components/chat/Cards.tsx
index 5c6db11..de0525f 100644
--- a/components/chat/Cards.tsx
+++ b/components/chat/Cards.tsx
@@ -13,6 +13,7 @@ import { seedToNvl, type SeedNode, type SeedLink } from '../../lib/graph-nvl';
import { SourceInfo } from '../ui/SourceInfo';
import { decodeEntities } from '../../lib/html-entities';
import { WATCH_CAP } from '../../lib/watch-cap';
+import { bookingSignal, BOOKING_BADGE_LABEL, BOOKING_PALETTE } from '../../lib/camp-booking';
/** Renders a tool's `{kind,data}` output as a structured card (ADR-013, D5). Graph-grounded only.
* `onAnswer` is passed only for interactive cards (the `question_card`) and only on the latest turn β it
@@ -835,6 +836,10 @@ interface CampgroundCardView {
parkName?: string | null;
recAreaName?: string | null;
totalSites?: number | null;
+ reservable?: boolean | null;
+ fcfs?: boolean | null;
+ sitesReservable?: number | null;
+ sitesFirstCome?: number | null;
free?: boolean;
feeUSD?: number | null;
hasHookups?: boolean;
@@ -849,6 +854,7 @@ const AGENCY_BADGE: Record = { NPS: 'pine', USFS: 'green', BLM:
function campMetaLine(c: CampgroundCardView): string {
return [
c.totalSites != null ? `${c.totalSites} sites` : null,
+ bookingSignal(c).detail ?? null, // e.g. "42 reservable Β· 18 first-come"
c.free ? 'free' : c.feeUSD != null ? `$${c.feeUSD}/night` : null,
c.hasHookups ? (c.maxAmps ? `${c.maxAmps}A hookups` : 'hookups') : null,
]
@@ -856,6 +862,17 @@ function campMetaLine(c: CampgroundCardView): string {
.join(' Β· ');
}
+/** Reservation-vs-first-come badge (booking clarity, user feedback). Hidden when there is no signal. */
+function BookingBadge({ c }: { c: CampgroundCardView }) {
+ const b = bookingSignal(c);
+ if (b.kind === 'unknown') return null;
+ return (
+
+ {BOOKING_BADGE_LABEL[b.kind]}
+
+ );
+}
+
/** Campground finder results in chat (find_campgrounds β campground_card). Also handles the single-campground
* add-to-trip preview/confirmation (add_campground_to_trip returns {campground, pendingAdd|addedTo}). */
function CampgroundResultsCard({ data }: { data: Record }) {
@@ -871,6 +888,7 @@ function CampgroundResultsCard({ data }: { data: Record }) {
{single.name}
{single.agency ? {single.agency} : null}
+
{single.dispersed ? dispersed : null}
{single.parkName ?? single.recAreaName ? {single.parkName ?? single.recAreaName} : null}
@@ -905,6 +923,7 @@ function CampgroundResultsCard({ data }: { data: Record }) {
{c.name}
{c.agency ? {c.agency} : null}
+
{c.free ? free : null}
{c.ada ? ADA : null}
diff --git a/components/memory/HomeLocationCard.tsx b/components/memory/HomeLocationCard.tsx
new file mode 100644
index 0000000..6a79cca
--- /dev/null
+++ b/components/memory/HomeLocationCard.tsx
@@ -0,0 +1,109 @@
+'use client';
+import { useState } from 'react';
+import { Box, Heading, HStack, Text, Button, Input, Icon } from '@chakra-ui/react';
+import { LuHouse, LuLocateFixed } from 'react-icons/lu';
+import type { HomeLocation } from '../../lib/bridges';
+
+/**
+ * Home location editor on /me (user-feedback iteration): the durable "trips start from home" anchor.
+ * Three capture paths β free-text geocode, browser geolocation (reverse-geocoded server-side so the
+ * ORS key stays private), or the ranger's set_home_location tool. Clearing DETACH DELETEs the :Home node.
+ */
+export function HomeLocationCard({ initial }: { initial: HomeLocation | null }) {
+ const [home, setHome] = useState(initial);
+ const [draft, setDraft] = useState('');
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState(null);
+
+ async function save(body: Record) {
+ setBusy(true);
+ setError(null);
+ try {
+ const res = await fetch('/api/me/home', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const data = (await res.json()) as { home?: HomeLocation | null; error?: string };
+ if (!res.ok) setError(data.error ?? 'Could not save your home location');
+ else {
+ setHome(data.home ?? null);
+ setDraft('');
+ }
+ } catch {
+ setError('Could not save your home location');
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ function useMyLocation() {
+ if (!navigator.geolocation) {
+ setError('Location is not available in this browser');
+ return;
+ }
+ setBusy(true);
+ setError(null);
+ navigator.geolocation.getCurrentPosition(
+ (pos) => save({ latitude: pos.coords.latitude, longitude: pos.coords.longitude }),
+ () => {
+ setBusy(false);
+ setError('Location permission denied β type your city instead');
+ },
+ );
+ }
+
+ async function clear() {
+ setBusy(true);
+ setError(null);
+ try {
+ await fetch('/api/me/home', { method: 'DELETE' });
+ setHome(null);
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
+ Home location
+
+
+ New trips start from home by default, and parks can be ranked by distance from you.
+
+ {home ? (
+
+ {home.label}
+
+
+ ) : (
+
+ setDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && draft.trim()) save({ place: draft.trim() });
+ }}
+ />
+
+
+
+ )}
+ {error ? (
+ {error}
+ ) : null}
+
+ );
+}
diff --git a/components/memory/MemoryList.tsx b/components/memory/MemoryList.tsx
index 46df034..f179cae 100644
--- a/components/memory/MemoryList.tsx
+++ b/components/memory/MemoryList.tsx
@@ -125,7 +125,8 @@ export function MemoryList({ initial }: { initial: UserMemory }) {
) : null}
- Durable constraints that apply to every trip the ranger plans. A companion's
+ {/* {' '} is deliberate β the compiled JSX drops the plain space after here. */}
+ Durable constraints that apply to every{' '}trip the ranger plans. A companion's
one-trip need isn't saved here β just tell the ranger it's only for that trip.
diff --git a/components/plan/MapTripCanvas.tsx b/components/plan/MapTripCanvas.tsx
index 5dd3ba7..92eddbd 100644
--- a/components/plan/MapTripCanvas.tsx
+++ b/components/plan/MapTripCanvas.tsx
@@ -9,7 +9,7 @@ import { useColorMode } from '../ui/color-mode';
import { brandColors } from '../../lib/brandColors';
import { designationKey, designationIcon, designationMatchStops, designationDefaultColor } from '../../lib/mapLegend';
import { markerImageId, attachMarkerImages } from '../../lib/mapMarkers';
-import { renderTripOverlay, type TripMapStop } from '../../lib/trip-map-render';
+import { renderTripOverlay, type TripMapStop, type TripMapOrigin } from '../../lib/trip-map-render';
import type { ParkPoint } from '../../lib/queries';
import type { TripMetrics } from '../../lib/trip-lab';
@@ -31,12 +31,14 @@ export interface CanvasMutation {
export function MapTripCanvas({
tripId,
stops,
+ origin,
addedParkCodes,
metrics,
onMutated,
}: {
tripId: string;
stops: TripMapStop[];
+ origin?: TripMapOrigin | null;
addedParkCodes: string[];
metrics?: TripMetrics | null;
onMutated: (data: CanvasMutation) => void;
@@ -45,6 +47,7 @@ export function MapTripCanvas({
const mapRef = useRef(null);
const allParksRef = useRef(null);
const stopsRef = useRef(stops);
+ const originRef = useRef(origin ?? null);
const addedRef = useRef>(new Set(addedParkCodes));
const markersRef = useRef([]);
const drawRef = useRef<{ stop: () => void } | null>(null);
@@ -58,6 +61,7 @@ export function MapTripCanvas({
const [note, setNote] = useState(null);
stopsRef.current = stops;
+ originRef.current = origin ?? null;
tripIdRef.current = tripId;
onMutatedRef.current = onMutated;
addedRef.current = new Set(addedParkCodes);
@@ -115,19 +119,25 @@ export function MapTripCanvas({
}
useEffect(() => {
- if (!ref.current) return;
+ const container = ref.current;
+ if (!container) return;
registerMapProtocols();
const ac = new AbortController();
abortRef.current = ac;
let map: MlMap;
try {
- map = new maplibregl.Map({ container: ref.current, style: mapStyle(colorMode === 'dark' ? 'dark' : 'light'), bounds: US_BOUNDS, fitBoundsOptions: { padding: 24 } });
+ 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);
map.on('load', async () => {
attachMarkerImages(map); // designation glyphs rendered on demand (#2d)
@@ -154,7 +164,7 @@ export function MapTripCanvas({
const title = document.createElement('strong');
title.textContent = props.name ?? props.parkCode;
const sub = document.createElement('div');
- sub.style.cssText = 'color:#777;font-size:12px;margin:2px 0 6px';
+ 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';
@@ -187,15 +197,17 @@ export function MapTripCanvas({
}
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); // frame existing stops on open
+ renderTripOverlay(map, stopsRef.current, c, markersRef, drawRef, true, true, originRef.current); // frame existing stops on open
framedTripRef.current = tripIdRef.current;
});
return () => {
+ ro.disconnect();
drawRef.current?.stop();
markersRef.current.forEach((m) => m.remove());
markersRef.current = [];
ac.abort();
+ mapRef.current = null;
map.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -204,15 +216,23 @@ export function MapTripCanvas({
// Re-paint the addable parks (added set changed) + redraw the route whenever the stops change.
useEffect(() => {
const map = mapRef.current;
- if (!map || !map.isStyleLoaded()) return;
- 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)
- const switched = framedTripRef.current !== tripId;
- renderTripOverlay(map, stops, c, markersRef, drawRef, true, switched);
- framedTripRef.current = tripId;
+ 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)
+ const switched = framedTripRef.current !== tripId;
+ renderTripOverlay(map, stopsRef.current, c, markersRef, drawRef, true, switched, originRef.current);
+ 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
+ // the map") β retry on the next idle instead.
+ if (map.isStyleLoaded()) render();
+ else map.once('idle', render);
+ return () => { map.off('idle', render); };
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [stops, addedParkCodes]);
+ }, [stops, addedParkCodes, origin]);
// The "Added X" / rate-limit note reads like a toast β let it auto-dismiss (and clear on unmount).
useEffect(() => {
diff --git a/components/plan/TripBuilder.tsx b/components/plan/TripBuilder.tsx
index 0bd3355..40dd844 100644
--- a/components/plan/TripBuilder.tsx
+++ b/components/plan/TripBuilder.tsx
@@ -2,7 +2,7 @@
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 } from 'react-icons/lu';
+import { LuX, LuGripVertical, LuFootprints, LuTriangleAlert, LuTentTree, LuHouse, LuRepeat } from 'react-icons/lu';
import { MapTripCanvas } from './MapTripCanvas';
import { ParkSearchInput } from './ParkSearchInput';
import { toast } from '../../lib/toast';
@@ -48,6 +48,11 @@ interface Trip {
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)[];
}
@@ -75,6 +80,8 @@ export function TripBuilder() {
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');
@@ -369,6 +376,26 @@ export function TripBuilder() {
}
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}`, {
@@ -393,6 +420,13 @@ export function TripBuilder() {
() => ((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(() => {
@@ -460,18 +494,77 @@ export function TripBuilder() {
)}
+ {/* 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.
+
+ >
+ )}
+
{/* 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. */}
@@ -549,6 +642,13 @@ export function TripBuilder() {
);
})}
+ {/* 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 ? (
diff --git a/components/plan/TripMap.tsx b/components/plan/TripMap.tsx
index 91ce6a9..874aedb 100644
--- a/components/plan/TripMap.tsx
+++ b/components/plan/TripMap.tsx
@@ -7,16 +7,17 @@ import { LuPlay, LuSquare } from 'react-icons/lu';
import { mapStyle, US_CENTER, registerMapProtocols, attachBasemapFallback, enableTerrain, disableTerrain, terrainConfigured } from '../../lib/mapStyle';
import { useColorMode } from '../ui/color-mode';
import { brandColors } from '../../lib/brandColors';
-import { renderTripOverlay, prefersReducedMotion, type TripMapStop } from '../../lib/trip-map-render';
+import { renderTripOverlay, prefersReducedMotion, type TripMapStop, type TripMapOrigin } from '../../lib/trip-map-render';
import { runFlyThrough, type FlyLeg } from '../../lib/fly-through';
/** Itinerary overlay (B4): numbered stop markers + a route line for the selected trip. */
export type { TripMapStop };
-export function TripMap({ stops }: { stops: TripMapStop[] }) {
+export function TripMap({ stops, origin }: { stops: TripMapStop[]; origin?: TripMapOrigin | null }) {
const ref = useRef(null);
const mapRef = useRef(null);
const stopsRef = useRef(stops);
+ const originRef = useRef(origin ?? null);
const markersRef = useRef([]);
const drawRef = useRef<{ stop: () => void } | null>(null);
const flyAbortRef = useRef(null);
@@ -24,21 +25,27 @@ export function TripMap({ stops }: { stops: TripMapStop[] }) {
const c = brandColors(colorMode);
const [playing, setPlaying] = useState(false);
stopsRef.current = stops;
+ originRef.current = origin ?? null;
useEffect(() => {
- if (!ref.current) return;
+ const container = ref.current;
+ if (!container) return;
registerMapProtocols();
let map: MlMap;
try {
- map = new maplibregl.Map({ container: ref.current, style: mapStyle(colorMode === 'dark' ? 'dark' : 'light'), center: US_CENTER, zoom: 3 });
+ map = new maplibregl.Map({ container, style: mapStyle(colorMode === 'dark' ? 'dark' : 'light'), center: US_CENTER, zoom: 3 });
attachBasemapFallback(map);
} catch (err) {
console.warn('[TripMap] map unavailable (WebGL?):', (err as Error).message);
return;
}
mapRef.current = map;
- map.on('load', () => renderTripOverlay(map, stopsRef.current, c, markersRef, drawRef, true));
+ map.on('load', () => renderTripOverlay(map, stopsRef.current, c, markersRef, drawRef, true, true, originRef.current));
+ // Keep the GL canvas in sync with container size changes (maplibre only tracks window resize).
+ const ro = new ResizeObserver(() => mapRef.current?.resize());
+ ro.observe(container);
return () => {
+ ro.disconnect();
flyAbortRef.current?.abort();
drawRef.current?.stop();
markersRef.current.forEach((m) => m.remove());
@@ -50,11 +57,17 @@ export function TripMap({ stops }: { stops: TripMapStop[] }) {
}, [colorMode]);
// Re-render markers/line when stops change β and re-draw the route so the plan visibly assembles.
+ // If the style is momentarily not loaded (terrain/style reload), retry on idle instead of dropping the
+ // render (which left new stops invisible until the next map interaction).
useEffect(() => {
const map = mapRef.current;
- if (map && map.isStyleLoaded()) renderTripOverlay(map, stops, c, markersRef, drawRef, true);
+ if (!map) return;
+ const render = () => renderTripOverlay(map, stopsRef.current, c, markersRef, drawRef, true, true, originRef.current);
+ if (map.isStyleLoaded()) render();
+ else map.once('idle', render);
+ return () => { map.off('idle', render); };
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [stops]);
+ }, [stops, origin]);
const located: FlyLeg[] = stops
.filter((s) => s.lat != null && s.lng != null)
@@ -76,7 +89,7 @@ export function TripMap({ stops }: { stops: TripMapStop[] }) {
if (mapRef.current === map) {
if (hadTerrain) disableTerrain(map);
map.easeTo({ pitch: 0, bearing: 0, duration: 600 }); // back to flat
- renderTripOverlay(map, stopsRef.current, c, markersRef, drawRef, false);
+ renderTripOverlay(map, stopsRef.current, c, markersRef, drawRef, false, true, originRef.current);
}
if (flyAbortRef.current === ac) flyAbortRef.current = null;
setPlaying(false);
diff --git a/db/migrations/028_home_location.cypher b/db/migrations/028_home_location.cypher
new file mode 100644
index 0000000..5afdb4b
--- /dev/null
+++ b/db/migrations/028_home_location.cypher
@@ -0,0 +1,6 @@
+// Home location (user-feedback iteration): a per-user anchor for where the user lives β
+// (:User)-[:LIVES_AT]->(:Home {userId, location, label, source}). Mirrors the :TrailPrefs/:CampPrefs
+// per-user-node pattern (migrations 025/027). `location` is a spatial point; `label` a human place name
+// ("Bozeman, MT"); `source` records how it was captured ('geocode' | 'geolocation'). Feeds the trip-origin
+// default, the memory block, distance-from-home ranking, and the /map home pin. Idempotent.
+CREATE CONSTRAINT home_user IF NOT EXISTS FOR (h:Home) REQUIRE h.userId IS UNIQUE;
diff --git a/lib/bridges.ts b/lib/bridges.ts
index 6cb37d0..316dcd3 100644
--- a/lib/bridges.ts
+++ b/lib/bridges.ts
@@ -456,6 +456,44 @@ export async function unsaveCampground(userId: string, campgroundId: string): Pr
);
}
+export interface HomeLocation {
+ latitude: number;
+ longitude: number;
+ label: string;
+ source: 'geocode' | 'geolocation';
+}
+
+/**
+ * Home location β a single per-user `(:User)-[:LIVES_AT]->(:Home {userId})` anchor (migration 028,
+ * mirrors the :TrailPrefs/:CampPrefs pattern). Durable personal data: the ranger confirms before saving
+ * (set_home_location scope rule), and /me can edit or clear it. Feeds the trip-origin default,
+ * the memory block, distance-from-home ranking, and the /map home pin.
+ */
+export async function setHomeLocation(userId: string, home: HomeLocation): Promise {
+ await writeGraph(
+ `MERGE (u:User {userId:$userId})
+ MERGE (u)-[:LIVES_AT]->(h:Home {userId:$userId})
+ SET h.location = point({latitude: $latitude, longitude: $longitude}),
+ h.label = $label, h.source = $source, h.at = datetime()`,
+ { userId, latitude: home.latitude, longitude: home.longitude, label: home.label, source: home.source },
+ );
+}
+
+export async function getHomeLocation(userId: string): Promise {
+ const rows = await readGraph(
+ `MATCH (:User {userId:$userId})-[:LIVES_AT]->(h:Home)
+ RETURN h.location.latitude AS latitude, h.location.longitude AS longitude,
+ h.label AS label, h.source AS source`,
+ { userId },
+ );
+ const r = rows[0];
+ return r && r.latitude != null ? r : null;
+}
+
+export async function clearHomeLocation(userId: string): Promise {
+ await writeGraph(`MATCH (:User {userId:$userId})-[:LIVES_AT]->(h:Home) DETACH DELETE h`, { userId });
+}
+
/** Clear accessibility/travel constraints (the TRAVELS_WITH constraint + all REQUIRES edges). */
export async function clearTravelConstraints(userId: string): Promise {
await writeGraph(
diff --git a/lib/camp-booking.test.ts b/lib/camp-booking.test.ts
new file mode 100644
index 0000000..3948b32
--- /dev/null
+++ b/lib/camp-booking.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from 'vitest';
+import { bookingSignal, BOOKING_BADGE_LABEL, BOOKING_PALETTE } from './camp-booking';
+
+describe('bookingSignal', () => {
+ it('mixed when both reservable and first-come sites exist (with the count split)', () => {
+ const s = bookingSignal({ reservable: true, fcfs: true, sitesReservable: 42, sitesFirstCome: 18 });
+ expect(s.kind).toBe('mixed');
+ expect(s.label).toBe('Reservations + first-come');
+ expect(s.detail).toBe('42 reservable Β· 18 first-come');
+ });
+
+ it('mixed from both flags alone omits the count detail (no honest split to show)', () => {
+ const s = bookingSignal({ reservable: true, fcfs: true, sitesReservable: null, sitesFirstCome: null });
+ expect(s.kind).toBe('mixed');
+ expect(s.detail).toBeUndefined();
+ });
+
+ it('reservation when reservable and not fcfs', () => {
+ const s = bookingSignal({ reservable: true, fcfs: false, sitesReservable: 42, sitesFirstCome: 0 });
+ expect(s.kind).toBe('reservation');
+ expect(s.label).toBe('Reservation required');
+ expect(s.detail).toBe('42 reservable sites');
+ });
+
+ it('fcfs when first-come and not reservable', () => {
+ const s = bookingSignal({ reservable: false, fcfs: true, sitesReservable: 0, sitesFirstCome: 1 });
+ expect(s.kind).toBe('fcfs');
+ expect(s.label).toBe('First-come, first-served');
+ expect(s.detail).toBe('1 first-come site'); // singular
+ });
+
+ it('infers the mode from counts alone when the flags are absent', () => {
+ expect(bookingSignal({ sitesReservable: 10 }).kind).toBe('reservation');
+ expect(bookingSignal({ sitesFirstCome: 6 }).kind).toBe('fcfs');
+ expect(bookingSignal({ sitesReservable: 10, sitesFirstCome: 6 }).kind).toBe('mixed');
+ });
+
+ it('a zero count is NOT a signal (0 reservable β reservable)', () => {
+ expect(bookingSignal({ sitesReservable: 0, sitesFirstCome: 0 }).kind).toBe('unknown');
+ expect(bookingSignal({ fcfs: true, sitesReservable: 0 }).kind).toBe('fcfs');
+ });
+
+ it('unknown when there is no signal at all (nulls/undefined)', () => {
+ expect(bookingSignal({})).toEqual({ kind: 'unknown', label: 'Booking info unavailable' });
+ expect(bookingSignal({ reservable: null, fcfs: null, sitesReservable: null, sitesFirstCome: null }).kind).toBe(
+ 'unknown',
+ );
+ expect(bookingSignal({ reservable: false, fcfs: false }).kind).toBe('unknown');
+ });
+
+ it('every kind has a badge label and a colorPalette', () => {
+ for (const kind of ['reservation', 'fcfs', 'mixed', 'unknown'] as const) {
+ expect(BOOKING_BADGE_LABEL[kind]).toBeTruthy();
+ expect(BOOKING_PALETTE[kind]).toBeTruthy();
+ }
+ });
+});
diff --git a/lib/camp-booking.ts b/lib/camp-booking.ts
new file mode 100644
index 0000000..7bee66a
--- /dev/null
+++ b/lib/camp-booking.ts
@@ -0,0 +1,72 @@
+/**
+ * Booking-clarity signal (Campgrounds feature): turn the raw :Campground reservability props
+ * (`reservable`/`fcfs` flags + `sitesReservable`/`sitesFirstCome` counts) into ONE plain-English
+ * "do I need a reservation or can I just show up?" answer β the top user-feedback ask. Pure +
+ * unit-tested; shared by the finder card, the detail-page callout, and the ranger chat cards so
+ * the wording never drifts. Counts alone infer the flags (a count > 0 implies that booking mode),
+ * and absence of every signal is honestly "unknown" β never rendered as either mode.
+ */
+
+export type BookingKind = 'reservation' | 'fcfs' | 'mixed' | 'unknown';
+
+export interface BookingSignal {
+ kind: BookingKind;
+ label: string;
+ detail?: string;
+}
+
+/** Compact badge wording per kind (finder card + chat cards share it). */
+export const BOOKING_BADGE_LABEL: Record = {
+ reservation: 'Reservation',
+ fcfs: 'First-come',
+ mixed: 'Res + FCFS',
+ unknown: 'Booking unknown',
+};
+
+/** Chakra colorPalette per kind (reservation β trail, fcfs β pine, mixed β sand, unknown β muted). */
+export const BOOKING_PALETTE: Record = {
+ reservation: 'trail',
+ fcfs: 'pine',
+ mixed: 'sand',
+ unknown: 'gray',
+};
+
+const plural = (n: number) => (n === 1 ? '' : 's');
+
+/** Classify a campground's booking mode. Counts > 0 imply the corresponding flag when it's absent. */
+export function bookingSignal(input: {
+ reservable?: boolean | null;
+ fcfs?: boolean | null;
+ sitesReservable?: number | null;
+ sitesFirstCome?: number | null;
+}): BookingSignal {
+ const resCount = input.sitesReservable ?? null;
+ const fcfsCount = input.sitesFirstCome ?? null;
+ const hasRes = input.reservable === true || (resCount != null && resCount > 0);
+ const hasFcfs = input.fcfs === true || (fcfsCount != null && fcfsCount > 0);
+
+ if (hasRes && hasFcfs) {
+ return {
+ kind: 'mixed',
+ label: 'Reservations + first-come',
+ // Counts only when both are reported β a flags-only mixed signal has no honest split to show.
+ detail:
+ resCount != null && fcfsCount != null ? `${resCount} reservable Β· ${fcfsCount} first-come` : undefined,
+ };
+ }
+ if (hasRes) {
+ return {
+ kind: 'reservation',
+ label: 'Reservation required',
+ detail: resCount != null && resCount > 0 ? `${resCount} reservable site${plural(resCount)}` : undefined,
+ };
+ }
+ if (hasFcfs) {
+ return {
+ kind: 'fcfs',
+ label: 'First-come, first-served',
+ detail: fcfsCount != null && fcfsCount > 0 ? `${fcfsCount} first-come site${plural(fcfsCount)}` : undefined,
+ };
+ }
+ return { kind: 'unknown', label: 'Booking info unavailable' };
+}
diff --git a/lib/campgrounds.ts b/lib/campgrounds.ts
index 9705660..8d91834 100644
--- a/lib/campgrounds.ts
+++ b/lib/campgrounds.ts
@@ -213,6 +213,9 @@ export interface CampsiteRow {
pullThrough: boolean;
ada: boolean;
reservable: boolean;
+ maxPeople: number | null; // occupancy β null when unreported, NEVER coalesced to 0
+ campfireAllowed: boolean | null; // null = not reported (distinct from an explicit no)
+ shade: boolean;
}
export interface CampgroundDetail extends CampgroundSummary {
@@ -235,12 +238,12 @@ export async function campgroundDetail(id: string): Promise(
`MATCH (c:Campground {id: $id})
CALL { WITH c OPTIONAL MATCH (c)-[:HAS_SITE]->(s:Campsite)
- RETURN collect(DISTINCT s{.id, .loop, .number, .type, .maxRvLengthFt, .electricAmps,
- hasWater: coalesce(s.hasWater, false), hasSewer: coalesce(s.hasSewer, false),
+ RETURN collect(DISTINCT s{.id, .loop, .number, .type, .maxRvLengthFt, .electricAmps, .maxPeople,
+ .campfireAllowed, hasWater: coalesce(s.hasWater, false), hasSewer: coalesce(s.hasSewer, false),
pullThrough: coalesce(s.pullThrough, false), ada: coalesce(s.ada, false),
- reservable: coalesce(s.reservable, false)})[..600] AS sites }
+ reservable: coalesce(s.reservable, false), shade: coalesce(s.shade, false)})[..600] AS sites }
CALL { WITH c OPTIONAL MATCH (c)-[:HAS_AMENITY]->(a:Amenity)
- RETURN collect(DISTINCT a{.id, .name}) AS amenities }
+ RETURN [x IN collect(DISTINCT a{.id, .name}) WHERE x.name IS NOT NULL] AS amenities }
CALL { WITH c OPTIONAL MATCH (c)-[:MANAGED_BY]->(ag:Agency)
RETURN head(collect(ag{.name, .kind})) AS agency }
CALL { WITH c OPTIONAL MATCH (c)-[:IN_RECAREA]->(ra:RecArea)
@@ -283,7 +286,8 @@ export async function campsitesForCampground(id: string): Promise
s.maxRvLengthFt AS maxRvLengthFt, s.electricAmps AS electricAmps,
coalesce(s.hasWater, false) AS hasWater, coalesce(s.hasSewer, false) AS hasSewer,
coalesce(s.pullThrough, false) AS pullThrough, coalesce(s.ada, false) AS ada,
- coalesce(s.reservable, false) AS reservable
+ coalesce(s.reservable, false) AS reservable,
+ s.maxPeople AS maxPeople, s.campfireAllowed AS campfireAllowed, coalesce(s.shade, false) AS shade
ORDER BY s.loop, s.number`,
{ id },
);
diff --git a/lib/datasources/ridb.test.ts b/lib/datasources/ridb.test.ts
index cfc7b0a..6f176b5 100644
--- a/lib/datasources/ridb.test.ts
+++ b/lib/datasources/ridb.test.ts
@@ -56,7 +56,7 @@ describe('campsiteAttrs', () => {
const attrs = (pairs: [string, string][]): RidbAttribute[] =>
pairs.map(([AttributeName, AttributeValue]) => ({ AttributeName, AttributeValue }));
- it('parses length, amps, water/sewer, pull-through', () => {
+ it('parses length, amps, water/sewer, pull-through, occupancy, campfire, shade', () => {
const a = campsiteAttrs(
attrs([
['Max Vehicle Length', '40'],
@@ -64,6 +64,9 @@ describe('campsiteAttrs', () => {
['Water Hookup', 'Yes'],
['Sewer Hookup', 'No'],
['Driveway Type', 'Pull-Through'],
+ ['Max Num of People', '8'],
+ ['Campfire Allowed', 'Yes'],
+ ['Shade', 'Yes'],
]),
);
expect(a).toEqual({
@@ -72,9 +75,17 @@ describe('campsiteAttrs', () => {
hasWater: true,
hasSewer: false,
pullThrough: true,
+ maxPeople: 8,
+ campfireAllowed: true,
+ shade: true,
});
});
+ it('distinguishes an explicit "Campfire Allowed: No" from an unreported campfire attr', () => {
+ expect(campsiteAttrs(attrs([['Campfire Allowed', 'No']])).campfireAllowed).toBe(false);
+ expect(campsiteAttrs(attrs([])).campfireAllowed).toBeNull();
+ });
+
it('detects pull-through from the full-export "Driveway Entry" key (not just API "Driveway Type")', () => {
expect(campsiteAttrs(attrs([['Driveway Entry', 'Pull-Through']])).pullThrough).toBe(true);
expect(campsiteAttrs(attrs([['Driveway Entry', 'Back-In']])).pullThrough).toBe(false);
@@ -92,6 +103,9 @@ describe('campsiteAttrs', () => {
hasWater: false,
hasSewer: false,
pullThrough: false,
+ maxPeople: null,
+ campfireAllowed: null,
+ shade: false,
});
});
});
diff --git a/lib/datasources/ridb.ts b/lib/datasources/ridb.ts
index 8b87758..7f6705b 100644
--- a/lib/datasources/ridb.ts
+++ b/lib/datasources/ridb.ts
@@ -183,6 +183,9 @@ export interface CampsiteAttrs {
hasWater: boolean;
hasSewer: boolean;
pullThrough: boolean;
+ maxPeople: number | null;
+ campfireAllowed: boolean | null; // null = not reported (distinct from an explicit 'No')
+ shade: boolean;
}
/** Derive structured site equipment from RIDB campsite ATTRIBUTES (nameβvalue pairs). */
@@ -194,6 +197,7 @@ export function campsiteAttrs(attrs?: RidbAttribute[] | null): CampsiteAttrs {
// The live API uses 'Driveway Type'; the full RIDB export uses 'Driveway Entry' (value 'Pull-Through' /
// 'Back-In' / 'Parallel') β check both so the offline loader detects pull-through sites.
const driveway = byName.get('driveway entry') ?? byName.get('driveway type') ?? '';
+ const campfire = byName.get('campfire allowed');
return {
maxRvLengthFt: firstInt(byName.get('max vehicle length') ?? byName.get('max rv length')),
// "30/50 amp" β 50; "Yes"/"30 amp" β that number; absent or "No" β null.
@@ -202,6 +206,9 @@ export function campsiteAttrs(attrs?: RidbAttribute[] | null): CampsiteAttrs {
hasWater: truthyAttr(byName.get('water hookup')),
hasSewer: truthyAttr(byName.get('sewer hookup')),
pullThrough: /pull[\s-]?through/i.test(driveway),
+ maxPeople: firstInt(byName.get('max num of people')),
+ campfireAllowed: campfire == null ? null : truthyAttr(campfire),
+ shade: truthyAttr(byName.get('shade')),
};
}
diff --git a/lib/graph-analytics.ts b/lib/graph-analytics.ts
index 3c01cf3..c98c2c7 100644
--- a/lib/graph-analytics.ts
+++ b/lib/graph-analytics.ts
@@ -64,7 +64,10 @@ export async function ensureNearProjection(): Promise {
if (!(await gdsAvailable())) return false;
const r = await readGraph<{ exists: boolean }>('CALL gds.graph.exists($name) YIELD exists RETURN exists', { name: NEAR_GRAPH });
if (r[0]?.exists) return true;
- const has = await readGraph<{ has: boolean }>('RETURN EXISTS { ()-[:NEAR]->() } AS has');
+ // ParkβPark ONLY: the Campgrounds feature also uses NEAR for (:Campground)-[:NEAR]->(:Park), which the
+ // Park-scoped native projection excludes β an untyped check here would "succeed" into an EMPTY near graph
+ // (no fallback to the topical path) whenever camp edges exist but deriveNear hasn't run.
+ const has = await readGraph<{ has: boolean }>('RETURN EXISTS { (:Park)-[:NEAR]->(:Park) } AS has');
if (!has[0]?.has) return false;
await projectNear();
return true;
diff --git a/lib/graph-nvl.test.ts b/lib/graph-nvl.test.ts
index d586401..e42cae2 100644
--- a/lib/graph-nvl.test.ts
+++ b/lib/graph-nvl.test.ts
@@ -107,7 +107,7 @@ describe('contextToNvl (/me context graph + two-graph overlay merge keys, ADR-04
stamps: [{ id: 's1', label: 'Canyon stamp' }],
availability: { start: '2026-02-10', end: '2026-02-20' },
trailPreferences: { maxMiles: null, maxGainFt: null, difficulty: null, avoidExposure: false, dogsRequired: false },
- trailHistory: { saved: [], wishlisted: [], done: [] }, campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null }, campHistory: { saved: [] },
+ trailHistory: { saved: [], wishlisted: [], done: [] }, campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null }, campHistory: { saved: [] }, home: { label: null, latitude: null, longitude: null },
};
it('anchors a "You" node and links every bridge with its literal relationship caption', () => {
@@ -137,6 +137,15 @@ describe('contextToNvl (/me context graph + two-graph overlay merge keys, ADR-04
expect(isContextParkId('ctx:Activity:Stargazing')).toBe(false);
});
+ it('renders the home anchor as a LIVES_AT context node (ADR-074)', () => {
+ const { nodes, rels } = contextToNvl({ ...memory, home: { label: 'Bozeman, MT, USA', latitude: 45.68, longitude: -111.04 } });
+ expect(nodes.find((n) => n.id === 'ctx:Home:home')?.caption).toBe('Bozeman, MT, USA');
+ expect(rels.some((r) => r.caption === 'LIVES_AT')).toBe(true);
+ // No home β no node.
+ const bare = contextToNvl(memory);
+ expect(bare.nodes.find((n) => n.id === 'ctx:Home:home')).toBeUndefined();
+ });
+
it('renders trail-preference and saved/wishlisted/did trail bridges (ADR-071)', () => {
const withTrails = {
...memory,
@@ -156,7 +165,7 @@ describe('contextToNvl (/me context graph + two-graph overlay merge keys, ADR-04
});
it('renders just the You node for empty memory (nothing to overlay)', () => {
- const empty = { preferences: [], considered: [], planned: [], travel: { wheelchair: false, rvMaxLengthFt: null, requiredAmenities: [] }, passes: [], stamps: [], availability: { start: null, end: null }, trailPreferences: { maxMiles: null, maxGainFt: null, difficulty: null, avoidExposure: false, dogsRequired: false }, trailHistory: { saved: [], wishlisted: [], done: [] }, campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null }, campHistory: { saved: [] } };
+ const empty = { preferences: [], considered: [], planned: [], travel: { wheelchair: false, rvMaxLengthFt: null, requiredAmenities: [] }, passes: [], stamps: [], availability: { start: null, end: null }, trailPreferences: { maxMiles: null, maxGainFt: null, difficulty: null, avoidExposure: false, dogsRequired: false }, trailHistory: { saved: [], wishlisted: [], done: [] }, campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null }, campHistory: { saved: [] }, home: { label: null, latitude: null, longitude: null }, };
const { nodes, rels } = contextToNvl(empty);
expect(nodes).toHaveLength(1);
expect(rels).toHaveLength(0);
@@ -177,7 +186,7 @@ describe('contextToNvl edge de-duplication (review finding)', () => {
stamps: [],
availability: { start: null, end: null },
trailPreferences: { maxMiles: null, maxGainFt: null, difficulty: null, avoidExposure: false, dogsRequired: false },
- trailHistory: { saved: [], wishlisted: [], done: [] }, campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null }, campHistory: { saved: [] },
+ trailHistory: { saved: [], wishlisted: [], done: [] }, campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null }, campHistory: { saved: [] }, home: { label: null, latitude: null, longitude: null },
};
const { nodes, rels } = contextToNvl(mem);
// one Stargazing node, one Restrooms node (deduped), and exactly one edge each.
@@ -270,7 +279,7 @@ describe('bridgesToRels (#8 β you-in-the-graph)', () => {
stamps: [{ id: 's1', label: 'Yellowstone' }],
availability: { start: null, end: null },
trailPreferences: { maxMiles: null, maxGainFt: null, difficulty: null, avoidExposure: false, dogsRequired: false },
- trailHistory: { saved: [], wishlisted: [], done: [] }, campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null }, campHistory: { saved: [] },
+ trailHistory: { saved: [], wishlisted: [], done: [] }, campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null }, campHistory: { saved: [] }, home: { label: null, latitude: null, longitude: null },
};
const ctx = contextToNvl(mem);
const ctxIds = new Set(ctx.nodes.map((n) => n.id));
@@ -332,7 +341,7 @@ describe('provenanceSubgraphIds (#9 β why this park is in your world)', () =>
stamps: [],
availability: { start: null, end: null },
trailPreferences: { maxMiles: null, maxGainFt: null, difficulty: null, avoidExposure: false, dogsRequired: false },
- trailHistory: { saved: [], wishlisted: [], done: [] }, campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null }, campHistory: { saved: [] },
+ trailHistory: { saved: [], wishlisted: [], done: [] }, campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null }, campHistory: { saved: [] }, home: { label: null, latitude: null, longitude: null },
};
const ctx = contextToNvl(mem);
const ctxNodeIds = new Set(ctx.nodes.map((n) => n.id));
diff --git a/lib/graph-nvl.ts b/lib/graph-nvl.ts
index 607984f..f275ae9 100644
--- a/lib/graph-nvl.ts
+++ b/lib/graph-nvl.ts
@@ -241,6 +241,11 @@ export function contextToNvl(memory: UserMemory): { nodes: NvlNode[]; rels: NvlR
addNode(id, parts.join(' Β· ') || 'trail prefs');
addEdge(id, 'PREFERS_TRAIL');
}
+ if (memory.home.label) {
+ const id = `${CONTEXT_PREFIX}Home:home`;
+ addNode(id, memory.home.label);
+ addEdge(id, 'LIVES_AT');
+ }
for (const [list, rel] of [
[memory.trailHistory.saved, 'SAVED'],
[memory.trailHistory.wishlisted, 'WISHLISTED'],
diff --git a/lib/memory-block.test.ts b/lib/memory-block.test.ts
index e1f0710..3797ab5 100644
--- a/lib/memory-block.test.ts
+++ b/lib/memory-block.test.ts
@@ -20,6 +20,7 @@ function mem(over: Partial = {}): UserMemory {
trailHistory: { saved: [], wishlisted: [], done: [] },
campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null },
campHistory: { saved: [] },
+ home: { label: null, latitude: null, longitude: null },
...over,
};
}
@@ -29,6 +30,13 @@ describe('renderMemoryBlock', () => {
expect(renderMemoryBlock(mem())).toBe('');
});
+ it('renders the home location line (trip-origin default)', () => {
+ const out = renderMemoryBlock(mem({ home: { label: 'Bozeman, MT, USA', latitude: 45.68, longitude: -111.04 } }));
+ expect(out).toContain('- Home: Bozeman, MT, USA (default trip start point)');
+ // Coordinates are for ranking/routing, never the prompt.
+ expect(out).not.toContain('45.68');
+ });
+
it('renders each populated field once, with the load-bearing header + recall guidance', () => {
const out = renderMemoryBlock(
mem({
diff --git a/lib/memory-block.ts b/lib/memory-block.ts
index a9eb55c..8586276 100644
--- a/lib/memory-block.ts
+++ b/lib/memory-block.ts
@@ -10,6 +10,8 @@ import type { UserMemory } from './memory-graph';
export function renderMemoryBlock(m: UserMemory): string {
const lines: string[] = [];
+ if (m.home.label) lines.push(`- Home: ${m.home.label} (default trip start point)`);
+
const prefs = m.preferences.map((p) => p.name).filter(Boolean).sort();
if (prefs.length) lines.push(`- Prefers: ${prefs.join(', ')}`);
diff --git a/lib/memory-graph.test.ts b/lib/memory-graph.test.ts
index b5d1a93..ad6f6ac 100644
--- a/lib/memory-graph.test.ts
+++ b/lib/memory-graph.test.ts
@@ -49,6 +49,7 @@ describe('getUserMemory (E3 β context subgraph read)', () => {
trailHistory: { saved: [], wishlisted: [], done: [] },
campPreferences: { rig: null, maxLengthFt: null, hookups: null, tentOk: false, ada: false, pets: false, quiet: false, budget: null },
campHistory: { saved: [] },
+ home: { label: null, latitude: null, longitude: null },
});
});
});
diff --git a/lib/memory-graph.ts b/lib/memory-graph.ts
index dbad4e6..0e74d1c 100644
--- a/lib/memory-graph.ts
+++ b/lib/memory-graph.ts
@@ -19,6 +19,8 @@ export interface UserMemory {
// Camp memory (Campgrounds feature, Phase 3): preferences anchor + saved campgrounds.
campPreferences: { rig: string | null; maxLengthFt: number | null; hookups: string | null; tentOk: boolean; ada: boolean; pets: boolean; quiet: boolean; budget: number | null };
campHistory: { saved: { id: string; name: string }[] };
+ // Home location (migration 028): where the user lives β trip-origin default + distance-from-home.
+ home: { label: string | null; latitude: number | null; longitude: number | null };
}
/**
@@ -96,6 +98,9 @@ export async function getUserMemory(userId: string): Promise {
cpQuiet: boolean;
cpBudget: number | null;
campHistory: { id: string; name: string }[];
+ homeLabel: string | null;
+ homeLat: number | null;
+ homeLng: number | null;
}
>(
`
@@ -129,6 +134,8 @@ export async function getUserMemory(userId: string): Promise {
OPTIONAL MATCH (u)-[:SAVED]->(sc:Campground)
WITH u, preferences, considered, planned, con, requiredAmenities, passes, stamps, tp, trailHistory, cpr,
[x IN collect(DISTINCT CASE WHEN sc IS NULL THEN null ELSE {id: sc.id, name: sc.name} END) WHERE x IS NOT NULL] AS campHistory
+ OPTIONAL MATCH (u)-[:LIVES_AT]->(hm:Home)
+ WITH u, preferences, considered, planned, con, requiredAmenities, passes, stamps, tp, trailHistory, cpr, campHistory, hm
OPTIONAL MATCH (u)-[av:AVAILABLE]->(:Season)
RETURN preferences, considered, planned,
con.wheelchair AS wheelchair, con.rvMaxLengthFt AS rvMaxLengthFt, requiredAmenities, passes, stamps,
@@ -139,6 +146,7 @@ export async function getUserMemory(userId: string): Promise {
coalesce(cpr.tentOk, false) AS cpTentOk, coalesce(cpr.ada, false) AS cpAda,
coalesce(cpr.pets, false) AS cpPets, coalesce(cpr.quiet, false) AS cpQuiet, cpr.budget AS cpBudget,
campHistory,
+ hm.label AS homeLabel, hm.location.latitude AS homeLat, hm.location.longitude AS homeLng,
av.start AS availStart, av.end AS availEnd
`,
{ userId },
@@ -179,6 +187,7 @@ export async function getUserMemory(userId: string): Promise {
budget: r?.cpBudget ?? null,
},
campHistory: { saved: r?.campHistory ?? [] },
+ home: { label: r?.homeLabel ?? null, latitude: r?.homeLat ?? null, longitude: r?.homeLng ?? null },
};
}
diff --git a/lib/queries.ts b/lib/queries.ts
index 5d806b5..a958c1f 100644
--- a/lib/queries.ts
+++ b/lib/queries.ts
@@ -74,10 +74,14 @@ export async function searchParks(opts: {
firstCome?: boolean;
groupSites?: boolean;
region?: string;
+ // Distance-from-home sort (user-feedback iteration): pass the saved home point to order results by
+ // proximity (located parks first). Display distance is computed by the caller from the returned lat/lng.
+ home?: { latitude: number; longitude: number };
+ sort?: 'default' | 'home';
limit?: number;
offset?: number;
}): Promise<{ items: ParkSummary[]; total: number }> {
- const { q, stateCode, activity, topic, amenity, designation, darkSky, feeFree, evParking, hookups, firstCome, groupSites, region, limit = 24, offset = 0 } = opts;
+ const { q, stateCode, activity, topic, amenity, designation, darkSky, feeFree, evParking, hookups, firstCome, groupSites, region, home, sort, limit = 24, offset = 0 } = opts;
const where: string[] = [];
if (stateCode) where.push('(p)-[:LOCATED_IN]->(:State {code:$stateCode})');
if (activity) where.push('(p)-[:OFFERS]->(:Activity {name:$activity})');
@@ -103,7 +107,13 @@ export async function searchParks(opts: {
const source = ftq
? `CALL db.index.fulltext.queryNodes('park_fulltext', $q) YIELD node AS p, score ${whereClause}`
: `MATCH (p:Park) ${whereClause} WITH p, 0.0 AS score`;
- const order = ftq ? 'ORDER BY score DESC, name ASC' : 'ORDER BY name ASC';
+ const order =
+ sort === 'home' && home
+ ? // Located parks by distance from home; unlocated ones last (point.distance on a null location is null).
+ 'ORDER BY CASE WHEN p.location IS NULL THEN 1 ELSE 0 END ASC, point.distance(p.location, point({latitude:$homeLat, longitude:$homeLng})) ASC'
+ : ftq
+ ? 'ORDER BY score DESC, name ASC'
+ : 'ORDER BY name ASC';
const params = {
q: ftq || null,
@@ -113,6 +123,8 @@ export async function searchParks(opts: {
amenity: amenity ?? null,
designation: designation ?? null,
region: region ?? null,
+ homeLat: home?.latitude ?? null,
+ homeLng: home?.longitude ?? null,
limit,
offset,
};
diff --git a/lib/routing.test.ts b/lib/routing.test.ts
index 0d797fc..77fba90 100644
--- a/lib/routing.test.ts
+++ b/lib/routing.test.ts
@@ -46,6 +46,15 @@ describe('routing.driveSegments', () => {
expect(segs[0].toIndex).toBe(1);
});
+ it('falls back to great_circle on a non-OK ORS response', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 429 }));
+ const segs = await routing.driveSegments([
+ { latitude: 44.6, longitude: -110.5 },
+ { latitude: 43.7, longitude: -110.7 },
+ ]);
+ expect(segs[0].source).toBe('great_circle');
+ });
+
it('uses ORS matrix results when available', async () => {
vi.stubGlobal(
'fetch',
@@ -72,3 +81,74 @@ describe('routing.driveSegments', () => {
expect(segs[0].minutes).toBe(120);
});
});
+
+describe('routing.geocode (ADR-074 β home-location entry)', () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('maps the first Pelias feature to {latitude, longitude, label}', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ features: [
+ { geometry: { coordinates: [-111.0429, 45.6793] }, properties: { label: 'Bozeman, MT, USA' } },
+ { geometry: { coordinates: [0, 0] }, properties: { label: 'Wrong one' } },
+ ],
+ }),
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ const hit = await routing.geocode('Bozeman, MT');
+ expect(hit).toEqual({ latitude: 45.6793, longitude: -111.0429, label: 'Bozeman, MT, USA' });
+ // US-biased, single result, GET with api_key as a query param (unlike the POST matrix header).
+ const url = new URL(String(fetchMock.mock.calls[0][0]));
+ expect(url.pathname).toContain('/geocode/search');
+ expect(url.searchParams.get('text')).toBe('Bozeman, MT');
+ expect(url.searchParams.get('boundary.country')).toBe('US');
+ expect(url.searchParams.get('size')).toBe('1');
+ expect(url.searchParams.get('api_key')).toBeTruthy();
+ });
+
+ it('falls back to the query text when the feature has no label', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ features: [{ geometry: { coordinates: [-100, 40] }, properties: {} }] }),
+ }));
+ const hit = await routing.geocode('Somewhere');
+ expect(hit?.label).toBe('Somewhere');
+ });
+
+ it('returns null when nothing matches', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ features: [] }) }));
+ expect(await routing.geocode('xyzzy-nowhere')).toBeNull();
+ });
+
+ it('returns null on a non-OK response and on a network error (never throws)', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
+ expect(await routing.geocode('Bozeman')).toBeNull();
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
+ expect(await routing.geocode('Bozeman')).toBeNull();
+ });
+});
+
+describe('routing.reverseGeocode (ADR-074 β geolocation labeling)', () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('returns the first feature label for coordinates', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ features: [{ properties: { label: 'Bozeman, MT, USA' } }] }),
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ expect(await routing.reverseGeocode({ latitude: 45.68, longitude: -111.04 })).toBe('Bozeman, MT, USA');
+ const url = new URL(String(fetchMock.mock.calls[0][0]));
+ expect(url.pathname).toContain('/geocode/reverse');
+ expect(url.searchParams.get('point.lat')).toBe('45.68');
+ expect(url.searchParams.get('point.lon')).toBe('-111.04');
+ });
+
+ it('returns null on no match / error (caller degrades to a generic label)', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ features: [] }) }));
+ expect(await routing.reverseGeocode({ latitude: 0, longitude: 0 })).toBeNull();
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network')));
+ expect(await routing.reverseGeocode({ latitude: 0, longitude: 0 })).toBeNull();
+ });
+});
diff --git a/lib/routing.ts b/lib/routing.ts
index 218732b..ff4a12b 100644
--- a/lib/routing.ts
+++ b/lib/routing.ts
@@ -43,9 +43,18 @@ export function approxDriveMinutes(miles: number, avgMph = 45): number {
return (miles / avgMph) * 60;
}
+export interface GeocodeResult extends LatLng {
+ /** Human place label, e.g. "Bozeman, MT, USA". */
+ label: string;
+}
+
export interface RoutingGateway {
/** Real road distance/time between consecutive stops, in order. */
driveSegments(stops: LatLng[]): Promise;
+ /** Free-text place β coordinates (home-location entry). Null when nothing matches / ORS is down. */
+ geocode(text: string): Promise;
+ /** Coordinates β place label (labels the browser-geolocation capture). Null on failure. */
+ reverseGeocode(point: LatLng): Promise;
}
class OrsRoutingGateway implements RoutingGateway {
@@ -100,6 +109,45 @@ class OrsRoutingGateway implements RoutingGateway {
}
return segments;
}
+
+ // ORS bundles a Pelias geocoder on the same key (GET, api_key as query param β unlike the POST matrix
+ // above, which authorizes via header). US-biased: TrailGraph is a U.S. parks app.
+ async geocode(text: string): Promise {
+ try {
+ const url = new URL(`${env.routing.baseUrl}/geocode/search`);
+ url.searchParams.set('api_key', env.routing.apiKey);
+ url.searchParams.set('text', text);
+ url.searchParams.set('boundary.country', 'US');
+ url.searchParams.set('size', '1');
+ const res = await fetch(url);
+ if (!res.ok) return null;
+ const data = (await res.json()) as {
+ features?: { geometry: { coordinates: [number, number] }; properties: { label?: string } }[];
+ };
+ const f = data.features?.[0];
+ if (!f) return null;
+ const [longitude, latitude] = f.geometry.coordinates;
+ return { latitude, longitude, label: f.properties.label ?? text };
+ } catch {
+ return null;
+ }
+ }
+
+ async reverseGeocode(point: LatLng): Promise {
+ try {
+ const url = new URL(`${env.routing.baseUrl}/geocode/reverse`);
+ url.searchParams.set('api_key', env.routing.apiKey);
+ url.searchParams.set('point.lat', String(point.latitude));
+ url.searchParams.set('point.lon', String(point.longitude));
+ url.searchParams.set('size', '1');
+ const res = await fetch(url);
+ if (!res.ok) return null;
+ const data = (await res.json()) as { features?: { properties: { label?: string } }[] };
+ return data.features?.[0]?.properties.label ?? null;
+ } catch {
+ return null;
+ }
+ }
}
export const routing: RoutingGateway = new OrsRoutingGateway();
diff --git a/lib/sync/sync-campsites-ridb.ts b/lib/sync/sync-campsites-ridb.ts
index 5dc770a..a6d672d 100644
--- a/lib/sync/sync-campsites-ridb.ts
+++ b/lib/sync/sync-campsites-ridb.ts
@@ -98,7 +98,10 @@ export async function syncCampsitesRidb(): Promise {
return { resource: RESOURCE, counts: { facilities: facilities.length, sites, skipped: skippedFacilities }, ms };
}
-/** Deterministic content-hash input for one facility's campsites (order-independent). */
+/** Deterministic content-hash input for one facility's campsites (order-independent). The new
+ * occupancy/campfire/shade fields are hashed too so changed RIDB data re-syncs β but a facility whose
+ * RIDB content is UNCHANGED keeps its old hash and is skipped, so already-synced sites won't backfill
+ * the new props until their content changes or a `SYNC_FORCE=1` run. */
function hashPayload(sites: RidbCampsite[]): string {
return sites
.map((s) => {
@@ -113,6 +116,9 @@ function hashPayload(sites: RidbCampsite[]): string {
at.hasWater ? 1 : 0,
at.hasSewer ? 1 : 0,
at.pullThrough ? 1 : 0,
+ at.maxPeople ?? '',
+ at.campfireAllowed == null ? '' : at.campfireAllowed ? 1 : 0,
+ at.shade ? 1 : 0,
s.CampsiteAccessible ? 1 : 0,
s.CampsiteReservable ? 1 : 0,
].join('|');
@@ -140,6 +146,9 @@ export async function upsertRidbCampsites(
hasWater: at.hasWater,
hasSewer: at.hasSewer,
pullThrough: at.pullThrough,
+ maxPeople: at.maxPeople,
+ campfireAllowed: at.campfireAllowed,
+ shade: at.shade,
ada: s.CampsiteAccessible === true,
reservable: s.CampsiteReservable === true,
};
@@ -155,6 +164,7 @@ export async function upsertRidbCampsites(
SET s.campgroundId = c.id, s.loop = row.loop, s.number = row.number, s.type = row.type,
s.maxRvLengthFt = row.maxRvLengthFt, s.electricAmps = row.electricAmps,
s.hasWater = row.hasWater, s.hasSewer = row.hasSewer, s.pullThrough = row.pullThrough,
+ s.maxPeople = row.maxPeople, s.campfireAllowed = row.campfireAllowed, s.shade = row.shade,
s.ada = row.ada, s.reservable = row.reservable,
s.geometry = coalesce(s.geometry, loc), s.lastSyncedAt = datetime()
MERGE (c)-[:HAS_SITE]->(s)
diff --git a/lib/trip-gpx.test.ts b/lib/trip-gpx.test.ts
index f9b7f60..bc43af9 100644
--- a/lib/trip-gpx.test.ts
+++ b/lib/trip-gpx.test.ts
@@ -48,4 +48,32 @@ describe('tripToGpx (ADR-048)', () => {
expect((gpx.match(/ {
+ const gpx = tripToGpx(
+ trip({ origin: { lat: 45.6, lng: -111.0, label: 'Bozeman, MT' }, returnToOrigin: true }),
+ { time: TIME },
+ );
+ expect(gpx).toContain('β Bozeman, MT');
+ expect(gpx).toContain('Round trip β the route returns here.');
+ // Track: origin + 2 located stops + origin again = 4 points.
+ expect((gpx.match(/ [Number(m[1]), Number(m[2])]);
+ expect(pts[0]).toEqual([45.6, -111]);
+ expect(pts[pts.length - 1]).toEqual([45.6, -111]);
+ });
+
+ it('adds only the start leg when returnToOrigin is off', () => {
+ const gpx = tripToGpx(trip({ origin: { lat: 45.6, lng: -111.0, label: 'Bozeman, MT' }, returnToOrigin: false }), { time: TIME });
+ expect((gpx.match(/ {
+ const gpx = tripToGpx(trip({ origin: { lat: 45.6, lng: -111.0, label: 'Bozeman, MT' }, returnToOrigin: true, stops: [] }), { time: TIME });
+ expect(gpx).not.toContain('β');
+ expect((gpx.match(/ 0 ? trip.origin : null;
+ const originWaypoint: GpxWaypoint[] = origin
+ ? [{ lat: origin.lat, lon: origin.lng, name: `β ${origin.label ?? 'Trip start'}`, type: 'origin', desc: trip.returnToOrigin ? 'Round trip β the route returns here.' : undefined }]
+ : [];
+ const routePoints = [
+ ...(origin ? [{ lat: origin.lat, lon: origin.lng }] : []),
+ ...stops.map((s) => ({ lat: s.lat as number, lon: s.lng as number })),
+ ...(origin && trip.returnToOrigin ? [{ lat: origin.lat, lon: origin.lng }] : []),
+ ];
const tracks: GpxTrackSeg[] = [
- { name: `${trip.name ?? 'TrailGraph Trip'} route`, points: stops.map((s) => ({ lat: s.lat as number, lon: s.lng as number })) },
+ { name: `${trip.name ?? 'TrailGraph Trip'} route`, points: routePoints },
...(opts.hikeTracks ?? []),
];
@@ -58,7 +69,7 @@ export function tripToGpx(trip: Trip, opts: { time: string; hikeTracks?: GpxTrac
time: opts.time,
desc: 'TrailGraph itinerary. Track is a straight stop-to-stop connector, not turn-by-turn routing. Verify access/closures at nps.gov.',
},
- [...waypoints, ...lodgingWaypoints],
+ [...originWaypoint, ...waypoints, ...lodgingWaypoints],
tracks,
);
}
diff --git a/lib/trip-lab.ts b/lib/trip-lab.ts
index 25d49b1..6d33714 100644
--- a/lib/trip-lab.ts
+++ b/lib/trip-lab.ts
@@ -57,7 +57,8 @@ export async function forkTrip(userId: string, tripId: string, name?: string): P
MERGE (u:User {userId:$userId})
CREATE (f:Trip {id:$newId, userId:$userId})
SET f.name = $name, f.startDate = orig.startDate, f.endDate = orig.endDate,
- f.startPoint = orig.startPoint, f.endPoint = orig.endPoint,
+ f.startPoint = orig.startPoint, f.startLabel = orig.startLabel,
+ f.returnToOrigin = orig.returnToOrigin, f.endPoint = orig.endPoint,
f.parentId = orig.id, f.version = coalesce(orig.version, 1) + 1, f.createdAt = datetime()
MERGE (u)-[:PLANNED]->(f)
`,
@@ -127,8 +128,11 @@ export async function tripMetrics(
{ userId, tripId },
);
const stops = (trip.stops ?? []).filter(Boolean) as NonNullable;
- const driveMiles = round1(stops.reduce((s, st) => s + (st.driveTo?.miles ?? 0), 0));
- const driveMinutes = Math.round(stops.reduce((s, st) => s + (st.driveTo?.minutes ?? 0), 0));
+ // Stop-to-stop DRIVE_TO plus the origin legs (home β first stop / last stop β home on a round trip).
+ const legMiles = (trip.originLeg?.miles ?? 0) + (trip.returnLeg?.miles ?? 0);
+ const legMinutes = (trip.originLeg?.minutes ?? 0) + (trip.returnLeg?.minutes ?? 0);
+ const driveMiles = round1(stops.reduce((s, st) => s + (st.driveTo?.miles ?? 0), 0) + legMiles);
+ const driveMinutes = Math.round(stops.reduce((s, st) => s + (st.driveTo?.minutes ?? 0), 0) + legMinutes);
const start = trip.startDate ? trip.startDate.slice(0, 10) : undefined;
const parkStops = stops.filter((s) => s.kind === 'park' && s.lat != null && s.lng != null);
diff --git a/lib/trip-map-render.ts b/lib/trip-map-render.ts
index f47abb4..f557de3 100644
--- a/lib/trip-map-render.ts
+++ b/lib/trip-map-render.ts
@@ -18,6 +18,14 @@ export interface TripMapStop {
order: number;
}
+/** The trip's origin (home by default) for the overlay: start pin + first/return route legs. */
+export interface TripMapOrigin {
+ lat: number;
+ lng: number;
+ label: string | null;
+ roundTrip: boolean;
+}
+
export function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true;
}
@@ -32,11 +40,19 @@ export function renderTripOverlay(
// The read-only preview reframes on every change; the build-on-map canvas passes false after the initial
// render so adding a park (already on-screen, just clicked) doesn't yank the camera mid-browse (#9).
fitCamera = true,
+ // Trip origin (user-feedback iteration): prepends the homeβfirst-stop leg (and appends the return leg on
+ // a round trip) to the polyline, plus a distinct β start marker. Null/undefined β the old stop-only route.
+ origin?: TripMapOrigin | null,
) {
const located = stops.filter(
(s): s is TripMapStop & { lat: number; lng: number } => s.lat != null && s.lng != null,
);
- const coords: Coord[] = located.map((s) => [s.lng, s.lat]);
+ const stopCoords: Coord[] = located.map((s) => [s.lng, s.lat]);
+ const originCoord: Coord | null = origin ? [origin.lng, origin.lat] : null;
+ const coords: Coord[] =
+ originCoord && located.length >= 1
+ ? [originCoord, ...stopCoords, ...(origin!.roundTrip ? [originCoord] : [])]
+ : stopCoords;
// GeoJSON spec: LineString requires β₯2 positions. Use an empty FeatureCollection when there are fewer.
const safeData = (cs: Coord[]) =>
cs.length >= 2
@@ -75,6 +91,22 @@ export function renderTripOverlay(
// TripMap's markers too). Staggered drop-in unless reduced motion.
markersRef.current.forEach((m) => m.remove());
markersRef.current = [];
+ if (originCoord) {
+ // Distinct β start pin (trail orange, not numbered β the numbers stay 1:1 with stops).
+ const el = document.createElement('div');
+ el.className = 'trip-stop-marker trip-origin-marker';
+ el.textContent = 'β';
+ Object.assign(el.style, {
+ background: c.trail, color: '#fff', borderRadius: '6px', width: '22px', height: '22px',
+ display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '14px', fontWeight: '600',
+ border: '2px solid #fff',
+ });
+ const marker = new maplibregl.Marker({ element: el })
+ .setLngLat(originCoord)
+ .setPopup(new maplibregl.Popup().setText(origin!.label ?? 'Trip start'))
+ .addTo(map);
+ markersRef.current.push(marker);
+ }
located.forEach((s, i) => {
const el = document.createElement('div');
el.className = 'trip-stop-marker';
diff --git a/lib/trips.test.ts b/lib/trips.test.ts
index e5383a3..07c06a1 100644
--- a/lib/trips.test.ts
+++ b/lib/trips.test.ts
@@ -9,10 +9,18 @@ vi.mock('./neo4j', () => ({
writeGraph: (...a: unknown[]) => writeGraph(...a),
}));
vi.mock('./routing', () => ({ routing: {} }));
-vi.mock('./bridges', () => ({ considerPark: vi.fn() }));
+const getHomeLocation = vi.fn().mockResolvedValue(null);
+vi.mock('./bridges', () => ({
+ considerPark: vi.fn(),
+ getHomeLocation: (...a: unknown[]) => getHomeLocation(...a),
+}));
vi.mock('./conditions', () => ({ buildParkConditions: vi.fn() }));
+const cachedDriveSegments = vi.fn();
+vi.mock('./drive-cache', () => ({
+ cachedDriveSegments: (...a: unknown[]) => cachedDriveSegments(...a),
+}));
-import { tripCost, tripConditions, createTrip } from './trips';
+import { tripCost, tripConditions, createTrip, setTripOrigin, recomputeSegments } from './trips';
import { buildParkConditions } from './conditions';
beforeEach(() => {
@@ -72,6 +80,8 @@ describe('tripCost (P2 fees / break-even cost model)', () => {
});
describe('createTrip (R5 Β§2.1 β decode model HTML entities at the write boundary)', () => {
+ beforeEach(() => getHomeLocation.mockReset().mockResolvedValue(null));
+
it('stores a real ampersand, not the double-encoded entity', async () => {
await createTrip('u1', { name: 'Four Corners Ancestral Puebloan & Dark Skies' });
// The name write is the writeGraph call whose params carry a `name`.
@@ -79,6 +89,34 @@ describe('createTrip (R5 Β§2.1 β decode model HTML entities at the write bound
expect(nameCall).toBeDefined();
expect((nameCall![1] as { name: string }).name).toBe('Four Corners Ancestral Puebloan & Dark Skies');
});
+
+ it('defaults the origin from the saved home location, round trip on', async () => {
+ getHomeLocation.mockResolvedValue({ latitude: 45.68, longitude: -111.04, label: 'Bozeman, MT, USA', source: 'geocode' });
+ await createTrip('u1', { name: 'Big Loop' });
+ const call = writeGraph.mock.calls.find((c) => (c[1] as { name?: string })?.name !== undefined);
+ const params = call![1] as { startPoint: unknown; startLabel: string; returnToOrigin: boolean };
+ expect(params.startPoint).toEqual({ latitude: 45.68, longitude: -111.04 });
+ expect(params.startLabel).toBe('Bozeman, MT, USA');
+ expect(params.returnToOrigin).toBe(true);
+ });
+
+ it('leaves the origin unset (no round trip) when there is no home', async () => {
+ await createTrip('u1', { name: 'No Home' });
+ const call = writeGraph.mock.calls.find((c) => (c[1] as { name?: string })?.name !== undefined);
+ const params = call![1] as { startPoint: unknown; returnToOrigin: boolean };
+ expect(params.startPoint).toBeNull();
+ expect(params.returnToOrigin).toBe(false);
+ });
+
+ it('prefers an explicit startPoint over the saved home', async () => {
+ getHomeLocation.mockResolvedValue({ latitude: 45.68, longitude: -111.04, label: 'Bozeman, MT, USA', source: 'geocode' });
+ await createTrip('u1', { name: 'Fly-in', startPoint: { latitude: 36.08, longitude: -115.15, label: 'Las Vegas, NV' }, returnToOrigin: false });
+ const call = writeGraph.mock.calls.find((c) => (c[1] as { name?: string })?.name !== undefined);
+ const params = call![1] as { startPoint: { latitude: number }; startLabel: string; returnToOrigin: boolean };
+ expect(params.startPoint.latitude).toBe(36.08);
+ expect(params.startLabel).toBe('Las Vegas, NV');
+ expect(params.returnToOrigin).toBe(false);
+ });
});
describe('tripConditions (Trip Dashboard aggregation, ADR-042)', () => {
@@ -124,3 +162,106 @@ describe('tripConditions (Trip Dashboard aggregation, ADR-042)', () => {
expect(dash!.stops.map((s) => s.parkCode)).toEqual(['yell']);
});
});
+
+describe('setTripOrigin (ADR-074 β per-trip origin + round-trip toggle)', () => {
+ beforeEach(() => {
+ getHomeLocation.mockReset().mockResolvedValue(null);
+ cachedDriveSegments.mockReset().mockResolvedValue([]);
+ });
+
+ it('returns false (and skips the recompute) when the trip is not the callerβs', async () => {
+ writeGraph.mockResolvedValueOnce([]); // MATCH found nothing
+ expect(await setTripOrigin('u1', 'nope', { origin: { latitude: 1, longitude: 2, label: 'X' } })).toBe(false);
+ expect(readGraph).not.toHaveBeenCalled(); // no getTrip β no recompute
+ });
+
+ it('maps origin:null to clear=true and a set origin to a point param', async () => {
+ // Call 1: the SET write. Then recomputeSegments β getTrip (readGraph, no trip β early return).
+ writeGraph.mockResolvedValueOnce([{ ok: true }]);
+ readGraph.mockResolvedValueOnce([]);
+ await setTripOrigin('u1', 't1', { origin: null, returnToOrigin: false });
+ expect(writeGraph.mock.calls[0][1]).toMatchObject({ clear: true, startPoint: null, returnToOrigin: false });
+
+ writeGraph.mockReset().mockResolvedValueOnce([{ ok: true }]);
+ readGraph.mockReset().mockResolvedValueOnce([]);
+ await setTripOrigin('u1', 't1', { origin: { latitude: 45.6, longitude: -111, label: 'Bozeman' } });
+ expect(writeGraph.mock.calls[0][1]).toMatchObject({
+ clear: false,
+ startPoint: { latitude: 45.6, longitude: -111 },
+ startLabel: 'Bozeman',
+ returnToOrigin: null, // toggle untouched
+ });
+ });
+});
+
+describe('recomputeSegments (ADR-074 β origin legs live on :Trip, DRIVE_TO stays stop-to-stop)', () => {
+ const tripRow = (over: Record = {}) => [{
+ id: 't1', name: 'Loop', startDate: null, endDate: null,
+ origin: { lat: 45.6, lng: -111.0, label: 'Bozeman' },
+ returnToOrigin: true, originLeg: null, returnLeg: null,
+ stops: [
+ { id: 's1', order: 0, kind: 'park', parkName: 'Yellowstone', lat: 44.6, lng: -110.5 },
+ { id: 's2', order: 1, kind: 'park', parkName: 'Glacier', lat: 48.7, lng: -113.8 },
+ ],
+ ...over,
+ }];
+
+ beforeEach(() => {
+ cachedDriveSegments.mockReset();
+ });
+
+ it('writes stop DRIVE_TO edges plus origin + return legs on the trip', async () => {
+ readGraph.mockResolvedValueOnce(tripRow());
+ cachedDriveSegments
+ .mockResolvedValueOnce([{ fromIndex: 0, toIndex: 1, miles: 350, minutes: 330, source: 'ors' }]) // stops
+ .mockResolvedValueOnce([{ fromIndex: 0, toIndex: 1, miles: 90, minutes: 95, source: 'ors' }]) // originβfirst
+ .mockResolvedValueOnce([{ fromIndex: 0, toIndex: 1, miles: 260, minutes: 250, source: 'ors' }]); // lastβorigin
+ await recomputeSegments('u1', 't1');
+
+ // 3 cachedDriveSegments calls: stop chain, origin leg (originβfirst), return leg (lastβorigin).
+ expect(cachedDriveSegments).toHaveBeenCalledTimes(3);
+ expect(cachedDriveSegments.mock.calls[1][0]).toEqual([
+ { latitude: 45.6, longitude: -111.0 },
+ { latitude: 44.6, longitude: -110.5 },
+ ]);
+ expect(cachedDriveSegments.mock.calls[2][0]).toEqual([
+ { latitude: 48.7, longitude: -113.8 },
+ { latitude: 45.6, longitude: -111.0 },
+ ]);
+ // Final write persists both legs as :Trip props.
+ const legWrite = writeGraph.mock.calls.find((c) => (c[1] as { om?: number })?.om !== undefined);
+ expect(legWrite![1]).toMatchObject({ om: 90, omin: 95, osrc: 'ors', rm: 260, rmin: 250, rsrc: 'ors' });
+ });
+
+ it('skips the return leg when returnToOrigin is off, and clears legs when there is no origin', async () => {
+ readGraph.mockResolvedValueOnce(tripRow({ returnToOrigin: false }));
+ cachedDriveSegments
+ .mockResolvedValueOnce([{ fromIndex: 0, toIndex: 1, miles: 350, minutes: 330, source: 'ors' }])
+ .mockResolvedValueOnce([{ fromIndex: 0, toIndex: 1, miles: 90, minutes: 95, source: 'ors' }]);
+ await recomputeSegments('u1', 't1');
+ expect(cachedDriveSegments).toHaveBeenCalledTimes(2); // no return-leg call
+ let legWrite = writeGraph.mock.calls.find((c) => (c[1] as { om?: number })?.om !== undefined);
+ expect(legWrite![1]).toMatchObject({ om: 90, rm: null, rmin: null, rsrc: null });
+
+ writeGraph.mockClear();
+ readGraph.mockResolvedValueOnce(tripRow({ origin: null }));
+ cachedDriveSegments.mockReset().mockResolvedValueOnce([{ fromIndex: 0, toIndex: 1, miles: 350, minutes: 330, source: 'ors' }]);
+ await recomputeSegments('u1', 't1');
+ legWrite = writeGraph.mock.calls.find((c) => Object.prototype.hasOwnProperty.call(c[1] as object, 'om'));
+ expect(legWrite![1]).toMatchObject({ om: null, rm: null }); // stale legs never survive an origin clear
+ });
+
+ it('computes the origin leg even for a single-stop trip (no stop segments)', async () => {
+ readGraph.mockResolvedValueOnce(tripRow({
+ stops: [{ id: 's1', order: 0, kind: 'park', parkName: 'Yellowstone', lat: 44.6, lng: -110.5 }],
+ }));
+ cachedDriveSegments
+ .mockResolvedValueOnce([{ fromIndex: 0, toIndex: 1, miles: 90, minutes: 95, source: 'great_circle' }]) // originβonly stop
+ .mockResolvedValueOnce([{ fromIndex: 0, toIndex: 1, miles: 90, minutes: 95, source: 'great_circle' }]); // stopβorigin
+ await recomputeSegments('u1', 't1');
+ // located.length < 2 β NO stop-chain call; the two calls are the origin + return legs.
+ expect(cachedDriveSegments).toHaveBeenCalledTimes(2);
+ const legWrite = writeGraph.mock.calls.find((c) => (c[1] as { om?: number })?.om !== undefined);
+ expect(legWrite![1]).toMatchObject({ om: 90, osrc: 'great_circle', rm: 90 });
+ });
+});
diff --git a/lib/trips.ts b/lib/trips.ts
index c164728..f23eedd 100644
--- a/lib/trips.ts
+++ b/lib/trips.ts
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto';
import { readGraph, writeGraph } from './neo4j';
import { type LatLng } from './routing';
import { cachedDriveSegments } from './drive-cache';
-import { considerPark } from './bridges';
+import { considerPark, getHomeLocation } from './bridges';
import { buildParkConditions, type ConditionsCardData, type TripDashboard } from './conditions';
import { decodeEntities } from './html-entities';
@@ -20,6 +20,8 @@ export interface NewTrip {
endDate?: string;
startPoint?: LatLng & { label?: string };
endPoint?: LatLng & { label?: string };
+ /** Route back to the origin at the end (road-trip loop). Defaults true when the trip has an origin. */
+ returnToOrigin?: boolean;
}
export interface NewStop {
@@ -39,13 +41,24 @@ async function ensureUser(userId: string) {
export async function createTrip(userId: string, t: NewTrip): Promise {
await ensureUser(userId);
+ // Per-trip origin, defaulting from the durable home anchor (LIVES_AT, migration 028): unless the caller
+ // set an explicit start, new trips begin at home β overridable per trip (fly-in trips) via setTripOrigin.
+ let start = t.startPoint ?? null;
+ if (!start) {
+ const home = await getHomeLocation(userId).catch(() => null);
+ if (home) start = { latitude: home.latitude, longitude: home.longitude, label: home.label };
+ }
const id = randomUUID();
+ // startPoint/endPoint are stored as real spatial points (a bare map param is not a legal property value).
await writeGraph(
`
MATCH (u:User {userId: $userId})
CREATE (t:Trip {id: $id, userId: $userId})
SET t.name = $name, t.startDate = $startDate, t.endDate = $endDate,
- t.startPoint = $startPoint, t.endPoint = $endPoint, t.createdAt = datetime()
+ t.startPoint = CASE WHEN $startPoint IS NULL THEN null ELSE point($startPoint) END,
+ t.startLabel = $startLabel, t.returnToOrigin = $returnToOrigin,
+ t.endPoint = CASE WHEN $endPoint IS NULL THEN null ELSE point($endPoint) END,
+ t.createdAt = datetime()
MERGE (u)-[:PLANNED]->(t)
`,
{
@@ -54,7 +67,10 @@ export async function createTrip(userId: string, t: NewTrip): Promise {
name: decodeEntities(t.name),
startDate: t.startDate ?? null,
endDate: t.endDate ?? null,
- startPoint: t.startPoint ? point(t.startPoint) : null,
+ startPoint: start ? point(start) : null,
+ startLabel: start?.label ?? null,
+ // Round trip is the road-trip default β but only meaningful once there IS an origin.
+ returnToOrigin: start ? (t.returnToOrigin ?? true) : false,
endPoint: t.endPoint ? point(t.endPoint) : null,
},
);
@@ -84,12 +100,28 @@ export async function listTrips(userId: string) {
);
}
+/** The trip's start point (defaulted from home, editable per trip) + its computed drive legs. */
+export interface TripOrigin {
+ lat: number;
+ lng: number;
+ label: string | null;
+}
+export interface OriginLeg {
+ miles: number;
+ minutes: number;
+ source: string;
+}
+
export async function getTrip(userId: string, tripId: string) {
const rows = await readGraph<{
id: string;
name: string;
startDate: string | null;
endDate: string | null;
+ origin: TripOrigin | null;
+ returnToOrigin: boolean;
+ originLeg: OriginLeg | null;
+ returnLeg: OriginLeg | null;
stops: StopRow[];
}>(
`
@@ -117,6 +149,13 @@ export async function getTrip(userId: string, tripId: string) {
lat: lcg.location.latitude, lng: lcg.location.longitude, nights: r.nights, date: r.date})) AS lodging
}
RETURN t.id AS id, t.name AS name, t.startDate AS startDate, t.endDate AS endDate,
+ CASE WHEN t.startPoint IS NULL THEN null
+ ELSE {lat: t.startPoint.latitude, lng: t.startPoint.longitude, label: t.startLabel} END AS origin,
+ coalesce(t.returnToOrigin, false) AS returnToOrigin,
+ CASE WHEN t.originMiles IS NULL THEN null
+ ELSE {miles: t.originMiles, minutes: t.originMinutes, source: t.originSource} END AS originLeg,
+ CASE WHEN t.returnMiles IS NULL THEN null
+ ELSE {miles: t.returnMiles, minutes: t.returnMinutes, source: t.returnSource} END AS returnLeg,
collect(CASE WHEN s IS NULL THEN null ELSE {
id: s.id, order: s.order, day: s.day, nights: s.nights, name: s.name,
kind: s.kind,
@@ -380,12 +419,42 @@ async function renumber(userId: string, tripId: string) {
);
}
-/** Recompute :DRIVE_TO edges between consecutive stops via the RoutingGateway (ADR-004). */
+/**
+ * Set / clear the trip's origin and round-trip flag (user-feedback iteration). `origin: null` clears it;
+ * `origin: undefined` leaves the point untouched (toggle-only update). Recomputes the origin legs.
+ */
+export async function setTripOrigin(
+ userId: string,
+ tripId: string,
+ opts: { origin?: (LatLng & { label?: string }) | null; returnToOrigin?: boolean },
+): Promise {
+ const rows = await writeGraph<{ ok: boolean }>(
+ `MATCH (t:Trip {id:$tripId, userId:$userId})
+ SET t.startPoint = CASE WHEN $clear THEN null WHEN $startPoint IS NULL THEN t.startPoint ELSE point($startPoint) END,
+ t.startLabel = CASE WHEN $clear THEN null WHEN $startPoint IS NULL THEN t.startLabel ELSE $startLabel END,
+ t.returnToOrigin = CASE WHEN $returnToOrigin IS NULL THEN coalesce(t.returnToOrigin, false) ELSE $returnToOrigin END
+ RETURN true AS ok`,
+ {
+ userId,
+ tripId,
+ clear: opts.origin === null,
+ startPoint: opts.origin ? point(opts.origin) : null,
+ startLabel: opts.origin?.label ?? null,
+ returnToOrigin: opts.returnToOrigin ?? null,
+ },
+ );
+ if (!rows.length) return false;
+ await recomputeSegments(userId, tripId);
+ return true;
+}
+
+/** Recompute :DRIVE_TO edges between consecutive stops via the RoutingGateway (ADR-004), plus the
+ * origin legs (origin β first stop, and last stop β origin on a round trip). The origin is not a
+ * :Stop, so its legs live as props on :Trip rather than DRIVE_TO edges. */
export async function recomputeSegments(userId: string, tripId: string): Promise {
- const stops = (await getTrip(userId, tripId))?.stops?.filter(Boolean) as
- | { id: string; lat: number | null; lng: number | null }[]
- | undefined;
- if (!stops || stops.length < 2) return;
+ const trip = await getTrip(userId, tripId);
+ if (!trip) return;
+ const stops = (trip.stops ?? []).filter(Boolean) as { id: string; lat: number | null; lng: number | null }[];
// clear existing segments
await writeGraph(
@@ -394,20 +463,49 @@ export async function recomputeSegments(userId: string, tripId: string): Promise
);
const located = stops.filter((s) => s.lat != null && s.lng != null);
- if (located.length < 2) return;
- const segments = await cachedDriveSegments(
- located.map((s) => ({ latitude: s.lat as number, longitude: s.lng as number })),
- );
- for (const seg of segments) {
- const from = located[seg.fromIndex];
- const to = located[seg.toIndex];
- await writeGraph(
- `MATCH (a:Stop {id:$from}), (b:Stop {id:$to})
- MERGE (a)-[d:DRIVE_TO]->(b)
- SET d.miles = $miles, d.minutes = $minutes, d.source = $source, d.computedAt = datetime()`,
- { from: from.id, to: to.id, miles: seg.miles, minutes: seg.minutes, source: seg.source },
+ if (located.length >= 2) {
+ const segments = await cachedDriveSegments(
+ located.map((s) => ({ latitude: s.lat as number, longitude: s.lng as number })),
);
+ for (const seg of segments) {
+ const from = located[seg.fromIndex];
+ const to = located[seg.toIndex];
+ await writeGraph(
+ `MATCH (a:Stop {id:$from}), (b:Stop {id:$to})
+ MERGE (a)-[d:DRIVE_TO]->(b)
+ SET d.miles = $miles, d.minutes = $minutes, d.source = $source, d.computedAt = datetime()`,
+ { from: from.id, to: to.id, miles: seg.miles, minutes: seg.minutes, source: seg.source },
+ );
+ }
}
+
+ // Origin legs β computed even for a single-stop trip (drive out from home still matters).
+ let originLeg: { miles: number; minutes: number; source: string } | null = null;
+ let returnLeg: { miles: number; minutes: number; source: string } | null = null;
+ if (trip.origin && located.length >= 1) {
+ const originPt: LatLng = { latitude: trip.origin.lat, longitude: trip.origin.lng };
+ const first = located[0];
+ const last = located[located.length - 1];
+ originLeg = (await cachedDriveSegments([originPt, { latitude: first.lat as number, longitude: first.lng as number }]))[0] ?? null;
+ if (trip.returnToOrigin) {
+ returnLeg = (await cachedDriveSegments([{ latitude: last.lat as number, longitude: last.lng as number }, originPt]))[0] ?? null;
+ }
+ }
+ await writeGraph(
+ `MATCH (t:Trip {id:$tripId, userId:$userId})
+ SET t.originMiles = $om, t.originMinutes = $omin, t.originSource = $osrc,
+ t.returnMiles = $rm, t.returnMinutes = $rmin, t.returnSource = $rsrc`,
+ {
+ userId,
+ tripId,
+ om: originLeg?.miles ?? null,
+ omin: originLeg?.minutes ?? null,
+ osrc: originLeg?.source ?? null,
+ rm: returnLeg?.miles ?? null,
+ rmin: returnLeg?.minutes ?? null,
+ rsrc: returnLeg?.source ?? null,
+ },
+ );
}
/**
diff --git a/lib/validation.test.ts b/lib/validation.test.ts
index b3ab304..df0b4d4 100644
--- a/lib/validation.test.ts
+++ b/lib/validation.test.ts
@@ -72,6 +72,17 @@ describe('TripActionSchema', () => {
expect(TripActionSchema.safeParse({ op: 'reorder', orderedStopIds: Array(201).fill('x') }).success).toBe(false);
expect(TripActionSchema.safeParse({ op: 'addStop', stop: { kind: 'spaceship' } }).success).toBe(false);
});
+
+ it('accepts every setOrigin form and bounds its payloads (ADR-074)', () => {
+ expect(TripActionSchema.safeParse({ op: 'setOrigin', place: 'Bozeman, MT' }).success).toBe(true);
+ expect(TripActionSchema.safeParse({ op: 'setOrigin', origin: { latitude: 45.6, longitude: -111, label: 'Bozeman' } }).success).toBe(true);
+ expect(TripActionSchema.safeParse({ op: 'setOrigin', clearOrigin: true }).success).toBe(true);
+ expect(TripActionSchema.safeParse({ op: 'setOrigin', returnToOrigin: false }).success).toBe(true);
+ // Bounds: place length, coordinate ranges.
+ expect(TripActionSchema.safeParse({ op: 'setOrigin', place: 'x'.repeat(201) }).success).toBe(false);
+ expect(TripActionSchema.safeParse({ op: 'setOrigin', origin: { latitude: 91, longitude: 0 } }).success).toBe(false);
+ expect(TripActionSchema.safeParse({ op: 'setOrigin', origin: { latitude: 0, longitude: 181 } }).success).toBe(false);
+ });
});
describe('MemoryActionSchema', () => {
diff --git a/lib/validation.ts b/lib/validation.ts
index d88692d..f91d683 100644
--- a/lib/validation.ts
+++ b/lib/validation.ts
@@ -65,6 +65,7 @@ export const TripActionSchema = z.object({
'suggestDays', 'optimize', 'rename', 'fork', 'diff',
'includeTrail', 'excludeTrail',
'includeCampground', 'excludeCampground', // Campgrounds feature: (:Stop)-[:STAYS_AT]->(:Campground)
+ 'setOrigin', // trip origin (defaults from home): place text OR coords OR clear, + returnToOrigin toggle
]),
stop: NewStopSchema.optional(),
stopId: id.optional(),
@@ -75,6 +76,11 @@ 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
+ // setOrigin: free-text place (geocoded server-side) OR explicit coords OR clearOrigin; toggle rides along.
+ place: z.string().max(200).optional(),
+ origin: latLng.optional(),
+ clearOrigin: z.boolean().optional(),
+ returnToOrigin: z.boolean().optional(),
});
// ββ Memory βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
diff --git a/tests/e2e/campgrounds.spec.ts b/tests/e2e/campgrounds.spec.ts
index 0850427..40c3c3a 100644
--- a/tests/e2e/campgrounds.spec.ts
+++ b/tests/e2e/campgrounds.spec.ts
@@ -87,6 +87,27 @@ test('detail page decodes a colon-laden RIDB id (prod dynamic-param gotcha) + di
await expect(page.getByText(/dispersed camping: no reservation/i)).toBeVisible();
});
+test('booking callout answers reservation-vs-first-come on the detail page (ADR-075)', async ({ page }) => {
+ // cg-canyon: reservable=true, fcfs=false, 273 reservable sites β the reservation callout + ONE book CTA.
+ await page.goto('/campgrounds/cg-canyon');
+ await expect(page.getByText('Reservation required')).toBeVisible();
+ await expect(page.getByText('273 reservable sites')).toBeVisible();
+ await expect(page.getByRole('link', { name: /Book on Recreation\.gov/i })).toHaveCount(1);
+
+ // ridb:999001 (USFS dispersed): fcfs=true β the first-come callout with the arrive-early guidance.
+ await page.goto(`/campgrounds/${encodeURIComponent('ridb:999001')}`);
+ await expect(page.getByText('First-come, first-served').first()).toBeVisible();
+ await expect(page.getByText('No reservations β arrive early to claim a site.')).toBeVisible();
+});
+
+test('finder cards carry the booking badge instead of cryptic counts (ADR-075)', async ({ page }) => {
+ await page.goto('/campgrounds');
+ // Badge wording from BOOKING_BADGE_LABEL; the old "R273 Β· FCFS0" shorthand is gone.
+ await expect(page.getByText('Reservation', { exact: true }).first()).toBeVisible();
+ await expect(page.getByText('First-come', { exact: true }).first()).toBeVisible();
+ await expect(page.getByText(/R\d+ Β· FCFS\d+/)).toHaveCount(0);
+});
+
test('detail page shows amenities, the verify box, and the (gated) Set-a-Camp-Watch affordance', async ({ page }) => {
await page.goto('/campgrounds/cg-canyon');
await expect(page.getByText('Wheelchair Accessible')).toBeVisible(); // amenity badge
diff --git a/tests/e2e/home-origin.spec.ts b/tests/e2e/home-origin.spec.ts
new file mode 100644
index 0000000..ce513ba
--- /dev/null
+++ b/tests/e2e/home-origin.spec.ts
@@ -0,0 +1,102 @@
+import { test, expect, type Page } from '@playwright/test';
+
+/** Fresh email+password user (E2E_TEST_MODE) so /me and /plan are authenticated. */
+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();
+}
+
+/** Save a home via coordinates (the geolocation path β no ORS key in e2e, so the label degrades). */
+async function saveHome(page: Page): Promise {
+ const res = await page.request.put('/api/me/home', {
+ data: { latitude: 45.6793, longitude: -111.0429 }, // Bozeman, MT
+ });
+ expect(res.ok(), `home save failed: ${res.status()}`).toBeTruthy();
+}
+
+/**
+ * Home location + trip origin (ADR-074). The coordinate path works keyless (reverse geocode degrades to
+ * "My location"); the free-text path needs ORS, so it isn't exercised here beyond the error copy.
+ */
+test('home card on /me: save via coordinates, then forget', async ({ page }) => {
+ await signUp(page);
+ await saveHome(page);
+ await page.goto('/me');
+ await expect(page.getByRole('heading', { name: 'Home location' })).toBeVisible();
+ await expect(page.getByText('My location')).toBeVisible(); // reverse-geocode degraded label
+ await page.getByRole('button', { name: 'Forget home' }).click();
+ await expect(page.getByPlaceholder(/City, state/)).toBeVisible(); // back to the capture form
+ await expect(page.getByRole('button', { name: 'Use my location' })).toBeVisible();
+});
+
+test('new trips start from home with a round trip; toggle and clear per trip', async ({ page }) => {
+ await signUp(page);
+ await saveHome(page);
+ await page.goto('/plan');
+ await page.getByPlaceholder('New trip name').fill('Home Loop E2E');
+ await page.getByRole('button', { name: 'Create' }).click();
+ await expect(page.getByRole('heading', { name: 'Home Loop E2E' })).toBeVisible();
+
+ // Origin defaulted from home, round trip on.
+ await expect(page.getByText(/Starts from/)).toBeVisible();
+ await expect(page.getByRole('button', { name: /Round trip: on/ })).toBeVisible();
+
+ // Add a park so the origin leg (home β stop 1) renders.
+ 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();
+ await expect(page.getByText(/β My location β \d+ mi/)).toBeVisible(); // first leg
+ await expect(page.getByText(/mi Β· \d+ min back to β/)).toBeVisible(); // return leg
+
+ // Toggle the round trip off β the return leg goes away, the first leg stays.
+ await page.getByRole('button', { name: /Round trip: on/ }).click();
+ await expect(page.getByRole('button', { name: /Round trip: off/ })).toBeVisible();
+ await expect(page.getByText(/back to β/)).toHaveCount(0);
+ await expect(page.getByText(/β My location β \d+ mi/)).toBeVisible();
+
+ // Clear the origin β the trip reverts to first-stop start; home itself is untouched.
+ await page.getByRole('button', { name: 'Clear' }).click();
+ await expect(page.getByText(/No start point β route begins at the first stop/)).toBeVisible();
+ const home = await page.request.get('/api/me/home');
+ expect(((await home.json()) as { home: { label: string } | null }).home?.label).toBe('My location');
+});
+
+test('a trip origin can be set from scratch after clearing (input form)', async ({ page }) => {
+ await signUp(page); // NO saved home β trips start unset
+ await page.goto('/plan');
+ await page.getByPlaceholder('New trip name').fill('No Home E2E');
+ await page.getByRole('button', { name: 'Create' }).click();
+ await expect(page.getByText(/No start point/)).toBeVisible();
+ await page.getByRole('button', { name: 'Set a start point' }).click();
+ await expect(page.getByPlaceholder(/Start from β e\.g\. Bozeman/)).toBeVisible();
+ // Without an ORS key the geocode 404s server-side and surfaces the friendly toast, not a crash.
+ await page.getByPlaceholder(/Start from β e\.g\. Bozeman/).fill('Bozeman, MT');
+ await page.getByRole('button', { name: 'Set', exact: true }).click();
+ await expect(page.getByText(/Couldn't find "Bozeman, MT"|Starts from/)).toBeVisible();
+});
+
+test('explore gains the distance-from-home sort and card distance line when home is set', async ({ page }) => {
+ await signUp(page);
+ await saveHome(page);
+ await page.goto('/explore');
+ await expect(page.getByLabel('Sort')).toBeVisible();
+ await expect(page.getByText(/~\d+ mi from home/).first()).toBeVisible();
+
+ // Sort by distance: Yellowstone (nearest seeded park to Bozeman) leads. Scope to the RESULTS grid β
+ // the "For you" rail above it renders park links in its own (recommendation) order.
+ await page.getByLabel('Sort').selectOption('home');
+ await page.getByRole('button', { name: 'Apply' }).click();
+ await expect(page).toHaveURL(/sort=home/);
+ const firstCard = page.getByTestId('explore-results').locator('a[href^="/parks/"]').first();
+ await expect(firstCard).toHaveAttribute('href', '/parks/yell');
+});
+
+test('anonymous explore shows no home sort or distance lines', async ({ page }) => {
+ await page.goto('/explore');
+ await expect(page.getByLabel('Sort')).toHaveCount(0);
+ await expect(page.getByText(/mi from home/)).toHaveCount(0);
+});
diff --git a/tests/e2e/map-popup-dark.spec.ts b/tests/e2e/map-popup-dark.spec.ts
new file mode 100644
index 0000000..85e84d0
--- /dev/null
+++ b/tests/e2e/map-popup-dark.spec.ts
@@ -0,0 +1,46 @@
+import { test, expect } from '@playwright/test';
+
+/**
+ * Dark-mode map popups (user feedback): maplibre-gl.css paints popups white with NO text color, so the
+ * theme's token rules must OUT-SPECIFY it (`.maplibregl-popup .maplibregl-popup-content`) β at equal
+ * specificity maplibre's sheet loads later and its white background wins while the text goes dark-mode
+ * light β white-on-white. This asserts the computed styles of a popup subtree on a page that bundles
+ * maplibre-gl.css (/parks/[code] renders MiniMap), in both color modes.
+ */
+async function popupStyles(page: import('@playwright/test').Page) {
+ return page.evaluate(() => {
+ const wrap = document.createElement('div');
+ wrap.className = 'maplibregl-popup maplibregl-popup-anchor-bottom';
+ const content = document.createElement('div');
+ content.className = 'maplibregl-popup-content';
+ content.textContent = 'probe';
+ wrap.appendChild(content);
+ document.body.appendChild(wrap);
+ const s = getComputedStyle(content);
+ const out = { bg: s.backgroundColor, color: s.color };
+ wrap.remove();
+ return out;
+ });
+}
+
+const luminance = (rgb: string): number => {
+ const [r, g, b] = (rgb.match(/\d+/g) ?? ['0', '0', '0']).map(Number);
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
+};
+
+test('map popups are dark-on-light in light mode', async ({ page }) => {
+ await page.addInitScript(() => localStorage.setItem('theme', 'light'));
+ await page.goto('/parks/yell');
+ const { bg, color } = await popupStyles(page);
+ expect(luminance(bg)).toBeGreaterThan(0.6); // light panel
+ expect(luminance(color)).toBeLessThan(0.5); // dark text
+});
+
+test('map popups are light-on-dark in dark mode (the reported bug)', async ({ page }) => {
+ await page.addInitScript(() => localStorage.setItem('theme', 'dark'));
+ await page.goto('/parks/yell');
+ const { bg, color } = await popupStyles(page);
+ expect(bg).not.toBe('rgb(255, 255, 255)'); // maplibre's default must not win
+ expect(luminance(bg)).toBeLessThan(0.4); // dark panel
+ expect(luminance(color)).toBeGreaterThan(0.5); // light text
+});
diff --git a/tests/integration/agent-memory-block.itest.ts b/tests/integration/agent-memory-block.itest.ts
index 869e87a..5e4e13d 100644
--- a/tests/integration/agent-memory-block.itest.ts
+++ b/tests/integration/agent-memory-block.itest.ts
@@ -16,6 +16,10 @@ import { renderMemoryBlock } from '../../lib/memory-block';
*/
describeIntegration('deterministic memory block round-trip (P1.4, Neo4j)', () => {
const userId = `test-${randomUUID()}`;
+ // Unique, non-vocabulary topic name: a raw shared-vocab name (e.g. 'dark skies') would persist as an
+ // orphan :Topic and shadow the canonicalize.ts synonym table for every LATER test file (order-dependent
+ // flake in memory.itest's tombstone test). Cleaned up in afterAll.
+ const topicName = `test-topic-${userId}`;
let tripId: string;
beforeAll(async () => {
@@ -26,8 +30,8 @@ describeIntegration('deterministic memory block round-trip (P1.4, Neo4j)', () =>
await recordPass(userId); // atb-annual
// A PREFERS edge written directly so the read test doesn't depend on the canonical vocab being seeded.
await writeGraph(
- `MERGE (u:User {userId:$userId}) MERGE (t:Topic {name:'dark skies'}) MERGE (u)-[:PREFERS]->(t)`,
- { userId },
+ `MERGE (u:User {userId:$userId}) MERGE (t:Topic {name:$topicName}) MERGE (u)-[:PREFERS]->(t)`,
+ { userId, topicName },
);
tripId = await createTrip(userId, { name: 'Utah Dark Skies' });
});
@@ -36,6 +40,7 @@ describeIntegration('deterministic memory block round-trip (P1.4, Neo4j)', () =>
if (tripId) await deleteTrip(userId, tripId);
await writeGraph(`MATCH (:User {userId:$userId})-[:TRAVELS_WITH]->(c:Constraint) DETACH DELETE c`, { userId });
await writeGraph(`MATCH (s:Season {userId:$userId}) DETACH DELETE s`, { userId }); // per-user availability anchor
+ await writeGraph(`MATCH (t:Topic {name:$topicName}) DETACH DELETE t`, { topicName }); // the artificial topic
await writeGraph(`MATCH (u:User {userId:$userId}) DETACH DELETE u`, { userId });
await closeDriver();
});
@@ -43,7 +48,7 @@ describeIntegration('deterministic memory block round-trip (P1.4, Neo4j)', () =>
it('getUserMemory feeds renderMemoryBlock with everything the user saved', async () => {
const block = renderMemoryBlock(await getUserMemory(userId));
expect(block).toContain('load-bearing');
- expect(block).toContain('dark skies'); // PREFERS
+ expect(block).toContain(topicName); // PREFERS
expect(block).toContain('needs wheelchair-accessible sites'); // TRAVELS_WITH
expect(block).toContain('RV β€ 30 ft');
expect(block).toContain('America the Beautiful'); // HOLDS β EntrancePass
diff --git a/tests/integration/home-origin.itest.ts b/tests/integration/home-origin.itest.ts
new file mode 100644
index 0000000..08005ea
--- /dev/null
+++ b/tests/integration/home-origin.itest.ts
@@ -0,0 +1,119 @@
+import { it, expect, beforeAll, afterAll } from 'vitest';
+import { randomUUID } from 'node:crypto';
+import { describeIntegration } from './db';
+import { seedTestData } from '../../scripts/seed-test-data';
+import { closeDriver, writeGraph } from '../../lib/neo4j';
+import { setHomeLocation, getHomeLocation, clearHomeLocation } from '../../lib/bridges';
+import { getUserMemory } from '../../lib/memory-graph';
+import { renderMemoryBlock } from '../../lib/memory-block';
+import { searchParks } from '../../lib/queries';
+import { createTrip, addStop, getTrip, setTripOrigin, deleteTrip } from '../../lib/trips';
+
+/**
+ * Home location + trip origin (ADR-074) against a real Neo4j: the :Home anchor round-trips as a
+ * spatial point, defaults new trips' origins, and recomputeSegments persists the origin/return legs
+ * as :Trip props. Isolated by a random userId; cleans up.
+ */
+describeIntegration('home location + trip origin (ADR-074, Neo4j)', () => {
+ const userId = `test-${randomUUID()}`;
+ const BOZEMAN = { latitude: 45.6793, longitude: -111.0429, label: 'Bozeman, MT, USA', source: 'geocode' as const };
+ let tripId: string | null = null;
+
+ beforeAll(async () => {
+ await seedTestData();
+ });
+ afterAll(async () => {
+ if (tripId) await deleteTrip(userId, tripId);
+ await writeGraph(
+ `MATCH (u:User {userId:$userId}) OPTIONAL MATCH (u)-[:LIVES_AT]->(h:Home) DETACH DELETE u, h`,
+ { userId },
+ );
+ await closeDriver();
+ });
+
+ it('round-trips the :Home anchor as a spatial point and overwrites in place', async () => {
+ expect(await getHomeLocation(userId)).toBeNull();
+ await setHomeLocation(userId, BOZEMAN);
+ const home = await getHomeLocation(userId);
+ expect(home?.label).toBe('Bozeman, MT, USA');
+ expect(home?.latitude).toBeCloseTo(45.6793, 3);
+ expect(home?.source).toBe('geocode');
+
+ // Second write MERGEs onto the same node (home_user constraint) β no duplicates.
+ await setHomeLocation(userId, { latitude: 39.7392, longitude: -104.9903, label: 'Denver, CO, USA', source: 'geolocation' });
+ const rows = await writeGraph<{ n: number }>(
+ `MATCH (:User {userId:$userId})-[:LIVES_AT]->(h:Home) RETURN count(h) AS n`,
+ { userId },
+ );
+ expect(rows[0].n).toBe(1);
+ expect((await getHomeLocation(userId))?.label).toBe('Denver, CO, USA');
+
+ await setHomeLocation(userId, BOZEMAN); // restore for the rest of the suite
+ });
+
+ it('surfaces home in getUserMemory and the memory block (label only, never coords)', async () => {
+ const mem = await getUserMemory(userId);
+ expect(mem.home.label).toBe('Bozeman, MT, USA');
+ expect(mem.home.latitude).toBeCloseTo(45.6793, 3);
+ const block = renderMemoryBlock(mem);
+ expect(block).toContain('- Home: Bozeman, MT, USA (default trip start point)');
+ expect(block).not.toContain('45.67');
+ });
+
+ it('defaults a new tripβs origin from home (round trip on) and computes origin legs', async () => {
+ tripId = await createTrip(userId, { name: 'Home Loop' });
+ await addStop(userId, tripId, { kind: 'park', refId: 'yell' });
+ await addStop(userId, tripId, { kind: 'park', refId: 'glac' });
+
+ const trip = await getTrip(userId, tripId);
+ expect(trip!.origin?.label).toBe('Bozeman, MT, USA');
+ expect(trip!.returnToOrigin).toBe(true);
+ // Both legs exist (real ORS or great-circle fallback β either way, non-zero miles).
+ expect(trip!.originLeg?.miles).toBeGreaterThan(0);
+ expect(trip!.returnLeg?.miles).toBeGreaterThan(0);
+ });
+
+ it('setTripOrigin overrides per trip (fly-in), toggles the round trip, and clears', async () => {
+ // Override the origin (fly-in trip) β home itself is untouched.
+ await setTripOrigin(userId, tripId!, { origin: { latitude: 36.08, longitude: -115.15, label: 'Las Vegas, NV' } });
+ let trip = await getTrip(userId, tripId!);
+ expect(trip!.origin?.label).toBe('Las Vegas, NV');
+ expect((await getHomeLocation(userId))?.label).toBe('Bozeman, MT, USA');
+
+ // Toggle-only update keeps the point but drops the return leg.
+ await setTripOrigin(userId, tripId!, { returnToOrigin: false });
+ trip = await getTrip(userId, tripId!);
+ expect(trip!.origin?.label).toBe('Las Vegas, NV');
+ expect(trip!.returnToOrigin).toBe(false);
+ expect(trip!.returnLeg).toBeNull();
+ expect(trip!.originLeg?.miles).toBeGreaterThan(0);
+
+ // Clear β no origin, no stale legs.
+ await setTripOrigin(userId, tripId!, { origin: null });
+ trip = await getTrip(userId, tripId!);
+ expect(trip!.origin).toBeNull();
+ expect(trip!.originLeg).toBeNull();
+ expect(trip!.returnLeg).toBeNull();
+ });
+
+ it('other users cannot set this tripβs origin (userId scope)', async () => {
+ expect(await setTripOrigin(`other-${randomUUID()}`, tripId!, { origin: { latitude: 1, longitude: 2 } })).toBe(false);
+ });
+
+ it('searchParks sort=home orders by distance from the home point', async () => {
+ const { items } = await searchParks({
+ home: { latitude: BOZEMAN.latitude, longitude: BOZEMAN.longitude },
+ sort: 'home',
+ limit: 10,
+ });
+ expect(items.length).toBeGreaterThan(1);
+ // Yellowstone is the nearest seeded park to Bozeman; Zion/Grand Canyon are far south.
+ expect(items[0].parkCode).toBe('yell');
+ const located = items.filter((p) => p.lat != null && p.lng != null);
+ const dist = (p: { lat: number | null; lng: number | null }) =>
+ Math.hypot((p.lat as number) - BOZEMAN.latitude, (p.lng as number) - BOZEMAN.longitude);
+ for (let i = 1; i < located.length; i++) {
+ expect(dist(located[i])).toBeGreaterThanOrEqual(dist(located[i - 1]));
+ }
+ });
+});
diff --git a/theme/index.ts b/theme/index.ts
index 2d18ee9..22f0c74 100644
--- a/theme/index.ts
+++ b/theme/index.ts
@@ -30,6 +30,27 @@ const config = defineConfig({
'body:has([data-fullscreen]) footer': {
display: 'none',
},
+ // MapLibre popups ship a fixed white background with NO text color, so popup text inherits the
+ // `fg` token above β near-white in dark mode β white-on-white. Restyle content + tip (the tip is
+ // drawn with CSS borders, one side per anchor) so popups follow color mode. `!important` is
+ // LOAD-BEARING: Chakra emits globalCss inside `@layer base`, and layered rules lose to the
+ // un-layered maplibre-gl.css no matter the specificity β importance inverts layer order, so these
+ // are the only declarations here that can beat it. Values are raw token vars (they flip per mode).
+ '.maplibregl-popup-content': {
+ background: 'var(--chakra-colors-bg-panel) !important',
+ color: 'var(--chakra-colors-fg) !important',
+ borderRadius: 'var(--chakra-radii-md) !important',
+ boxShadow: 'var(--chakra-shadows-md) !important',
+ },
+ '.maplibregl-popup-close-button': {
+ color: 'var(--chakra-colors-fg-muted) !important',
+ },
+ '.maplibregl-popup-anchor-top .maplibregl-popup-tip, .maplibregl-popup-anchor-top-left .maplibregl-popup-tip, .maplibregl-popup-anchor-top-right .maplibregl-popup-tip':
+ { borderBottomColor: 'var(--chakra-colors-bg-panel) !important' },
+ '.maplibregl-popup-anchor-bottom .maplibregl-popup-tip, .maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip, .maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip':
+ { borderTopColor: 'var(--chakra-colors-bg-panel) !important' },
+ '.maplibregl-popup-anchor-left .maplibregl-popup-tip': { borderRightColor: 'var(--chakra-colors-bg-panel) !important' },
+ '.maplibregl-popup-anchor-right .maplibregl-popup-tip': { borderLeftColor: 'var(--chakra-colors-bg-panel) !important' },
},
theme: {
tokens,