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,