diff --git a/.env.example b/.env.example index f140788..0efe821 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # ─── NPS Data API ────────────────────────────────────────────── NPS_API_KEY= # sent as X-Api-Key header +# ─── RIDB (Recreation.gov data, multi-agency campgrounds) ────── +# RIDB_API_KEY= # free instant signup at ridb.recreation.gov (Account → API key); sent as +# # the `apikey` header. Needed for SYNC_CAMPGROUNDS / `pnpm campgrounds:sync`. + # ─── Sync (Vercel Cron) ──────────────────────────────────────── CRON_SECRET= # Vercel Cron Authorization bearer. Cron routes fail CLOSED when this # is unset (sync/digests/memory-reconcile 401), so set it in EVERY @@ -53,10 +57,29 @@ NEXT_PUBLIC_MAP_TILES_URL=/basemap/us.pmtiles # self-hosted Protomaps `.pmtile # SYNC_TRAIL_ELEVATION=1 # also derive elevation profiles + difficulty (needs ELEVATION_API_URL below). # ELEVATION_API_URL= # opentopodata-compatible host (…/v1/) — server-side elevation sampler # # (the DEM terrain-RGB sampler is scaffolded; this is the shipped path). +# TRAIL_ELEV_THROTTLE_MS=1100 # inter-call throttle (default 1100 ≈ public ~1 req/s). Set 0 for a self-hosted +# # opentopodata with no rate limit. On HTTP 429 the run stops + resumes next time. +# TRAIL_ELEV_MAX_SAMPLES= # cap sample points per run (e.g. 90000 ≈ 900 calls/day) to stay under a quota. # EMBED_TRAILS=1 # embed trails for semantic vibe-search (trail_embedding; spends gateway tokens). # ENRICH_OSM_TRAILS=1 # OSM-fill (Overpass) for NPS-empty parks only (ODbL attribution). # TRAIL_SIMPLIFY_TOLERANCE=0.00008 # Douglas–Peucker tolerance for the simplified Blob geometry (default shown). +# ─── Campgrounds (multi-agency, ADR — Campgrounds feature) ───── +# SYNC_CAMPGROUNDS=1 # enable the RIDB campground steps in the slow sync (camp-ridbids → +# # campgrounds-ridb → campsites-ridb → derive-camp-near). Or run standalone: +# # `pnpm campgrounds:sync`. Needs RIDB_API_KEY above. +# DERIVE_BOOKING_DIFFICULTY=1 # fold RIDB historical reservation data into books-out / weekend-fill stats. +# ENRICH_OSM_CAMP=1 # OSM-fill (Overpass) for state/private/dispersed campgrounds (ODbL attribution). +# RESOLVE_CAMPGROUNDS=1 # cross-source entity-resolution: dedup OSM vs the federal canon (name+geodistance). +# OVERPASS_URL= # override the public Overpass endpoint (shared with trail OSM-fill). +# RIDB_EXPORT_DIR=data/RIDBFullExport_V1_CSV # offline initial load from the full RIDB CSV export +# # (`pnpm campgrounds:load`) — ~5,754 campgrounds + ~130k campsites, no rate +# # limits. Complements the live SYNC_CAMPGROUNDS delta sync. Default shown. +# CAMP_AVAILABILITY_ENABLED=1 # turn ON live recreation.gov availability polling (UNOFFICIAL endpoint — +# # ships OFF; every surface deep-links to recreation.gov until enabled. Read +# # the ToS-risk note in the feature plan before flipping this in production). +# CAMP_AVAILABILITY_UA= # clear identifying User-Agent for the availability fetch (default shown in env.ts). + # ─── Models / embeddings (via Vercel AI Gateway + OIDC) ───────── # No provider keys in code on Vercel. For local dev set a gateway key if needed. AI_GATEWAY_API_KEY= diff --git a/.gitignore b/.gitignore index cb003ff..f7ca728 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ pnpm-debug.log* # data files public/trails/ +# RIDB full export (~800MB local operator data for `pnpm campgrounds:load`; never commit) +/data/ diff --git a/README.md b/README.md index e2b3c96..353b86b 100644 --- a/README.md +++ b/README.md @@ -194,8 +194,10 @@ ADR-059). Self-guided audio + multimedia is opt-in behind `SYNC_MULTIMEDIA=1` (l **Real trails** are a separate ingest (ADR-066–073): `pnpm trails:sync` — or the slow sync with `SYNC_TRAILS=1` — pulls NPS Public Trails GIS into `:Trail` nodes, simplifies the geometry to **Vercel Blob** (`BLOB_READ_WRITE_TOKEN`; local `public/trails/` in dev), joins curated NPS hikes, and derives the -loop-builder `CONNECTS` network. Add `SYNC_TRAIL_ELEVATION=1` (+ `ELEVATION_API_URL`) for elevation profiles, -`EMBED_TRAILS=1` for trail vibe-search, and `ENRICH_OSM_TRAILS=1` for OSM-fill of NPS-empty parks. On Vercel +loop-builder `CONNECTS` network. Add `SYNC_TRAIL_ELEVATION=1` (+ `ELEVATION_API_URL`, opentopodata-compatible) +for elevation profiles — it throttles to the public ~1 req/s (`TRAIL_ELEV_THROTTLE_MS=0` for a self-hosted host) +and on the daily-quota `429` stops + resumes next run — `EMBED_TRAILS=1` for trail vibe-search, and +`ENRICH_OSM_TRAILS=1` for OSM-fill of NPS-empty parks. On Vercel the Blob token is **required** for trails (the local-file fallback is dev-only) — see [`docs/DEPLOY-MAP-DATA.md`](docs/DEPLOY-MAP-DATA.md). diff --git a/TrailGraph_Business_Plan.docx b/TrailGraph_Business_Plan.docx new file mode 100644 index 0000000..b18312e Binary files /dev/null and b/TrailGraph_Business_Plan.docx differ diff --git a/TrailGraph_Strategy_Deck.pptx b/TrailGraph_Strategy_Deck.pptx new file mode 100644 index 0000000..21ff778 Binary files /dev/null and b/TrailGraph_Strategy_Deck.pptx differ diff --git a/agent/instructions.md b/agent/instructions.md index bdfc56c..7fd5050 100644 --- a/agent/instructions.md +++ b/agent/instructions.md @@ -140,6 +140,29 @@ about the detour; one friendly nudge, then move on. Stay in scope by default — `tripId`, the `stopId` of the park stop, and the `trailId`. Like a tour, it returns a **preview** first (the trail + which day/stop) and writes nothing; only call again with `confirmed: true` after the user agrees. A hike nests under a park stop — it is never a peer stop. + - **Campgrounds (the camp concierge).** For a structured campsite ask ("a quiet tent site near Yosemite + under $30," "full-hookup RV park near Zion") call **`find_campgrounds`** — it filters NPS/USFS/BLM/state/ + dispersed campgrounds by agency, booking type, site type, hookups, RV length, ADA, pets, dark-sky, price, + and proximity to a park (including nearby forest/BLM sites). It auto-applies saved camp preferences. For + **"what's open July 3–5"** call **`check_campsite_availability`** (one campground + dates) — availability is + best-effort from an **unofficial** feed and is **OFF by default**, so it often degrades to a recreation.gov + deep link; always tell the user to verify there. For **"tell me when a site opens"** call **`set_camp_watch`** + (cancellation alerting — scope-confirm first, it's durable); **`list_camp_watches`**/**`clear_camp_watch`** + to manage. For **"when can I book?"** call **`campground_booking_window`** (when the rolling window opens + + how far ahead it books) and offer a watch as a reminder. + - **Camp preferences carry the same scope rule.** A standing camp profile ("we tow a 28-ft trailer, need + 30-amp, traveling with a dog") → confirm scope with **`ask_question`** (standing / just this trip / don't + save), then **`set_camp_preferences`** (`rig`, `maxLengthFt`, `hookups`, `tentOk`, `ada`, `pets`, `quiet`, + `budget`) for a standing one. For a **just-this-trip** need, pass it straight to **`find_campgrounds`** and + save nothing. **`save_campground`** keeps a campground handy (needs the id from a card). + - **Adding lodging to a trip** ((:Stop)-[:STAYS_AT]->(:Campground)): call **`add_campground_to_trip`** with + the `tripId`, the `stopId`, and the `campgroundId`. Like a hike, it returns a **preview** first and writes + nothing; only `confirmed: true` persists. Lodging nests UNDER a park stop (where you sleep that night) — it + is never a peer stop, and a stop sleeps in one place. + - **Never book, reserve, hold, or pay.** The ranger surfaces availability and the recreation.gov deep link, + and (with permission) sets a watch — nothing else. Be proactive with **books-out warnings** ("Upper Pines + books out ~5 months ahead; the 6-month window for your dates opens Tue Aug 12 — want a reminder?"), and say + availability is operator-reported and can be stale — **"verify on recreation.gov before you rely on it."** When they mention holding an entrance pass ("I have the annual pass"), call **`record_pass`** so trip costs treat those parks as covered. When they give travel dates ("the second week of September"), call **`set_availability`** so events during diff --git a/agent/tools/add_campground_to_trip.ts b/agent/tools/add_campground_to_trip.ts new file mode 100644 index 0000000..571178e --- /dev/null +++ b/agent/tools/add_campground_to_trip.ts @@ -0,0 +1,42 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; +import { addLodgingToStop, getTrip } from '../../lib/trips'; +import { campgroundDetail } from '../../lib/campgrounds'; +import { callerId } from '../../lib/agent-ctx'; + +/** + * Attach a campground to a stop as that night's lodging (Campgrounds feature): `(:Stop)-[:STAYS_AT]-> + * (:Campground)`, nested UNDER a park stop (like add_trail_to_trip nests a hike), NOT a peer stop. A stop + * sleeps in ONE place, so this replaces any prior lodging. Confirm-before-save: the first call (no + * `confirmed`) returns a preview and writes nothing; only `confirmed:true` persists. userId-bound (R4). + * The ranger never books — adding lodging to a plan is not a reservation. + */ +export default defineTool({ + description: + "Add a campground as the lodging for a stop on the user's trip ((:Stop)-[:STAYS_AT]->(:Campground), nested under a park stop). WITHOUT `confirmed` it returns a preview (the campground + which stop) and writes nothing; pass `confirmed:true` only after the user agrees. Needs tripId, the stopId of a stop on that trip, and the campgroundId. This plans where to sleep — it does NOT book or hold the site.", + inputSchema: z.object({ + tripId: z.string(), + stopId: z.string().describe('The stop this campground is the lodging for'), + campgroundId: z.string(), + date: z.string().optional().describe('Night this lodging covers, YYYY-MM-DD'), + nights: z.number().optional(), + confirmed: z.boolean().optional().describe('Persist the lodging. Omit/false to show a preview first.'), + }), + async execute({ tripId, stopId, campgroundId, date, nights, confirmed }, ctx) { + const userId = callerId(ctx); + const cg = await campgroundDetail(campgroundId); + if (!cg) return { kind: 'campground_card', data: { error: `No campground found for id "${campgroundId}".` } }; + + const trip = await getTrip(userId, tripId); + const stop = trip?.stops.find((s) => s && s.id === stopId); + if (!trip || !stop) return { kind: 'campground_card', data: { error: "I couldn't find that stop on your trip." } }; + const stopLabel = stop.parkName || stop.name || `Stop ${stop.order + 1}`; + + if (!confirmed) { + return { kind: 'campground_card', data: { campground: cg, pendingAdd: { tripId, stopId, stopLabel, date } } }; + } + const ok = await addLodgingToStop(userId, tripId, stopId, campgroundId, { date, nights }); + if (!ok) return { kind: 'campground_card', data: { error: "I couldn't add that campground to your trip." } }; + return { kind: 'campground_card', data: { campground: cg, addedTo: { stopLabel, date } } }; + }, +}); diff --git a/agent/tools/campground_booking_window.ts b/agent/tools/campground_booking_window.ts new file mode 100644 index 0000000..9f8f640 --- /dev/null +++ b/agent/tools/campground_booking_window.ts @@ -0,0 +1,41 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; +import { campgroundDetail, bookingUrlFor, bookingWindowOpenDate } from '../../lib/campgrounds'; +import { callerId } from '../../lib/agent-ctx'; + +/** + * Booking-window intelligence (Campgrounds feature): for a hard-to-get campground, when does the rolling + * reservation window open for the user's arrival date, and how far ahead does it book out? Offers to set a + * reminder (a Camp Watch). Pure date math; books-out comes from the (gated) historical-difficulty derive. + * userId-bound (R4). Never books. + */ +export default defineTool({ + description: + "Tell the user when the rolling reservation window OPENS for a campground + arrival date (recreation.gov is usually 6 months out), and how far ahead it typically books. Give campgroundId + arrivalDate (YYYY-MM-DD). Offer to set a Camp Watch reminder for the morning the window opens. Never books.", + inputSchema: z.object({ + campgroundId: z.string(), + arrivalDate: z.string().describe('Arrival date, YYYY-MM-DD'), + rollingWindowMonths: z.number().default(6).describe('Reservation window length; rec.gov is typically 6 months'), + }), + async execute({ campgroundId, arrivalDate, rollingWindowMonths }, ctx) { + callerId(ctx); + const cg = await campgroundDetail(campgroundId); + if (!cg) return { kind: 'booking_window_card', data: { error: `No campground found for id "${campgroundId}".` } }; + const today = new Date().toISOString().slice(0, 10); + const win = bookingWindowOpenDate(arrivalDate, rollingWindowMonths, today); + return { + kind: 'booking_window_card', + data: { + name: cg.name, + arrivalDate, + rollingWindowMonths, + windowOpensOn: win.windowOpensOn, + opensInPast: win.opensInPast, + daysUntilOpen: win.daysUntilOpen, + booksOutDays: cg.booksOutDays ?? null, + weekendFillRate: cg.weekendFillRate ?? null, + bookingUrl: bookingUrlFor(cg), + }, + }; + }, +}); diff --git a/agent/tools/check_campsite_availability.ts b/agent/tools/check_campsite_availability.ts new file mode 100644 index 0000000..6e0bd1d --- /dev/null +++ b/agent/tools/check_campsite_availability.ts @@ -0,0 +1,55 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; +import { campgroundDetail, bookingUrlFor } from '../../lib/campgrounds'; +import { getCampgroundAvailability, enumerateNights } from '../../lib/datasources/campAvailability'; +import { recreationUrl } from '../../lib/datasources/recreation'; +import { env } from '../../lib/env'; +import { callerId } from '../../lib/agent-ctx'; + +/** + * On-demand campsite availability for one campground over a date range (Campgrounds feature). Hits the + * recreation.gov month endpoint, which is UNOFFICIAL — so this DEGRADES to a recreation.gov deep link + * whenever live availability is disabled or unreachable. Never authoritative, never books. userId-bound (R4). + */ +export default defineTool({ + description: + "Check what's open at ONE campground for specific dates. Give either campgroundId (from a card) or ridbId, plus startDate/endDate (YYYY-MM-DD). Returns per-night open counts when live availability is enabled, otherwise a recreation.gov deep link to check there. Best-effort, not authoritative — always tell the user to verify before booking. Never books or holds a site.", + inputSchema: z.object({ + campgroundId: z.string().optional(), + ridbId: z.string().optional(), + startDate: z.string().describe('First night, YYYY-MM-DD'), + endDate: z.string().describe('Last night, YYYY-MM-DD'), + }), + async execute({ campgroundId, ridbId, startDate, endDate }, ctx) { + callerId(ctx); // server-bound identity (read-only tool, but keep the auth gate consistent) + + let name = 'this campground'; + let resolvedRidb = ridbId ?? null; + let bookingUrl: string | null = ridbId ? recreationUrl(ridbId) : null; + if (campgroundId) { + const cg = await campgroundDetail(campgroundId); + if (!cg) return { kind: 'availability_card', data: { error: `No campground found for id "${campgroundId}".` } }; + name = cg.name; + resolvedRidb = cg.ridbId ?? ridbId ?? null; + bookingUrl = bookingUrlFor(cg); + } + + if (!resolvedRidb || !env.camp.availabilityEnabled) { + return { kind: 'availability_card', data: { name, ridbId: resolvedRidb, degraded: true, bookingUrl, nights: [] } }; + } + + const nights = enumerateNights(startDate, endDate); + const months = [...new Set(nights.map((d) => d.slice(0, 7)))]; + const data = await Promise.all(months.map((m) => getCampgroundAvailability(resolvedRidb!, `${m}-01`))); + if (data.every((m) => m === null)) { + return { kind: 'availability_card', data: { name, ridbId: resolvedRidb, degraded: true, bookingUrl, nights: [] } }; + } + const byDate = new Map }>(); + for (const m of data) for (const dd of m?.days ?? []) byDate.set(dd.date, { sitesOpen: dd.sitesOpen, byType: dd.byType }); + const out = nights.map((date) => ({ date, sitesOpen: byDate.get(date)?.sitesOpen ?? 0, byType: byDate.get(date)?.byType ?? {} })); + return { + kind: 'availability_card', + data: { name, ridbId: resolvedRidb, degraded: false, bookingUrl, nights: out, anyOpen: out.some((n) => n.sitesOpen > 0) }, + }; + }, +}); diff --git a/agent/tools/clear_camp_watch.ts b/agent/tools/clear_camp_watch.ts new file mode 100644 index 0000000..3ddedf9 --- /dev/null +++ b/agent/tools/clear_camp_watch.ts @@ -0,0 +1,15 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; +import { deleteCampWatch, listCampWatches } from '../../lib/camp-watches'; +import { callerId } from '../../lib/agent-ctx'; + +/** Remove a Camp Watch (Campgrounds feature) — parity with clear_watch. userId-bound so a user can only clear their own (R4). */ +export default defineTool({ + description: "Remove one of the user's Camp Watches by its watchId (from list_camp_watches / the camp watch card).", + inputSchema: z.object({ watchId: z.string() }), + async execute({ watchId }, ctx) { + const userId = callerId(ctx); + await deleteCampWatch(userId, watchId); + return { kind: 'camp_watch_card', data: { watches: await listCampWatches(userId), cleared: watchId } }; + }, +}); diff --git a/agent/tools/find_campgrounds.ts b/agent/tools/find_campgrounds.ts new file mode 100644 index 0000000..a883b25 --- /dev/null +++ b/agent/tools/find_campgrounds.ts @@ -0,0 +1,78 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; +import { searchCampgrounds } from '../../lib/campgrounds'; +import { getCampPreferences } from '../../lib/bridges'; +import { callerId } from '../../lib/agent-ctx'; + +/** + * Multi-agency campground finder (Campgrounds feature) — ONE traversal filters NPS/USFS/BLM/state/dispersed + * campgrounds by agency, booking type, site type, hookups, RV length, accessibility, pets, amenities, + * dark-sky, price, and proximity to a park (incl. cross-boundary NEAR sites). Prefer this over prose for a + * structured ask ("quiet tent-only site near Yosemite under $30"). The user's saved `PREFERS_CAMP` + * preferences are applied as DEFAULTS (explicit args win) unless `usePreferences:false`. userId-bound (R4). + */ +export default defineTool({ + description: + "Find campgrounds (NPS/USFS/BLM/state/dispersed) by agency, booking (reservable/first-come/free), site type, hookups, min RV length, ADA, pets, dump station, dark-sky, max price, or near a park (incl. nearby forest/BLM sites). The structured graph search — use it for 'quiet tent site near Yosemite under $30'. Returns campground cards. Applies the user's saved camp preferences as defaults. Does NOT check live availability (use check_campsite_availability for that) and never books.", + inputSchema: z.object({ + q: z.string().optional().describe('Free-text campground-name search'), + nearParkCode: z.string().optional().describe('Park code — includes campgrounds IN the park and nearby (NEAR) forest/BLM sites'), + agency: z.enum(['NPS', 'USFS', 'BLM', 'USACE', 'STATE']).optional(), + reservable: z.boolean().optional(), + fcfs: z.boolean().optional().describe('First-come, first-served'), + free: z.boolean().optional(), + dispersed: z.boolean().optional().describe('Free dispersed / overland camping'), + siteType: z.enum(['tent', 'rv', 'group', 'cabin', 'walk-in', 'equestrian']).optional(), + hookups: z.boolean().optional(), + minAmps: z.number().optional().describe('Minimum electric hookup amps, e.g. 30 or 50'), + maxRvLength: z.number().optional().describe('Min RV/trailer length the site must accept (ft)'), + ada: z.boolean().optional(), + pets: z.boolean().optional(), + dumpStation: z.boolean().optional(), + darkSky: z.boolean().optional(), + maxPriceUSD: z.number().optional(), + usePreferences: z.boolean().default(true).describe("Apply the user's saved camp preferences as defaults"), + limit: z.number().min(1).max(24).default(8), + }), + async execute(args, ctx) { + const userId = callerId(ctx); + const prefs = args.usePreferences ? await getCampPreferences(userId) : null; + + // Saved prefs fill only the constraints the caller left unset — an explicit arg always wins. + const applied: string[] = []; + const maxRvLength = args.maxRvLength ?? prefs?.maxLengthFt ?? undefined; + const hookups = args.hookups ?? (prefs?.hookups && prefs.hookups !== 'none' ? true : undefined); + const ada = args.ada ?? (prefs?.ada ? true : undefined); + const pets = args.pets ?? (prefs?.pets ? true : undefined); + const siteType = args.siteType ?? (prefs?.rig === 'rv' ? 'rv' : prefs?.rig === 'tent' || prefs?.tentOk ? 'tent' : undefined); + const maxPriceUSD = args.maxPriceUSD ?? prefs?.budget ?? undefined; + if (prefs) { + if (args.maxRvLength == null && prefs.maxLengthFt != null) applied.push(`fits a ${prefs.maxLengthFt}-ft rig`); + if (args.hookups == null && prefs.hookups && prefs.hookups !== 'none') applied.push(prefs.hookups); + if (args.ada == null && prefs.ada) applied.push('ADA'); + if (args.pets == null && prefs.pets) applied.push('pets ok'); + if (args.maxPriceUSD == null && prefs.budget != null) applied.push(`≤ $${prefs.budget}`); + } + + const { items, total } = await searchCampgrounds({ + q: args.q, + nearParkCode: args.nearParkCode, + agency: args.agency, + reservable: args.reservable, + fcfs: args.fcfs, + free: args.free, + dispersed: args.dispersed, + siteType, + hookups, + minAmps: args.minAmps, + maxRvLength, + ada, + pets, + dumpStation: args.dumpStation, + darkSky: args.darkSky, + maxPriceUSD, + limit: args.limit, + }); + return { kind: 'campground_card', data: { campgrounds: items, total, appliedPreferences: applied } }; + }, +}); diff --git a/agent/tools/list_camp_watches.ts b/agent/tools/list_camp_watches.ts new file mode 100644 index 0000000..abb2555 --- /dev/null +++ b/agent/tools/list_camp_watches.ts @@ -0,0 +1,14 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; +import { listCampWatches } from '../../lib/camp-watches'; +import { callerId } from '../../lib/agent-ctx'; + +/** List the user's Camp Watches (Campgrounds feature) — management parity with list_watches. userId-bound (R4). */ +export default defineTool({ + description: "List the user's active Camp Watches (campground cancellation alerts), so they can review or clear them.", + inputSchema: z.object({}), + async execute(_args, ctx) { + const userId = callerId(ctx); + return { kind: 'camp_watch_card', data: { watches: await listCampWatches(userId) } }; + }, +}); diff --git a/agent/tools/save_campground.ts b/agent/tools/save_campground.ts new file mode 100644 index 0000000..ac0f0b2 --- /dev/null +++ b/agent/tools/save_campground.ts @@ -0,0 +1,19 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; +import { saveCampground } from '../../lib/bridges'; +import { callerId } from '../../lib/agent-ctx'; + +/** + * Save a campground to the user's list (Campgrounds feature): `(:User)-[:SAVED]->(:Campground)`. Surfaced + * in "your memory" + injected each turn. Needs the campgroundId from a card. userId-bound (R4). + */ +export default defineTool({ + description: "Save a campground to the user's list so it's easy to find later. Needs the campgroundId from a campground card.", + inputSchema: z.object({ campgroundId: z.string() }), + async execute({ campgroundId }, ctx) { + const userId = callerId(ctx); + const ok = await saveCampground(userId, campgroundId); + if (!ok) return { kind: 'map_snippet', data: { error: `No campground found for id "${campgroundId}".` } }; + return { kind: 'map_snippet', data: { saved: true, campgroundId } }; + }, +}); diff --git a/agent/tools/set_camp_preferences.ts b/agent/tools/set_camp_preferences.ts new file mode 100644 index 0000000..befd942 --- /dev/null +++ b/agent/tools/set_camp_preferences.ts @@ -0,0 +1,39 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; +import { setCampPreferences, getCampPreferences, setCampAmenityNeeds } from '../../lib/bridges'; +import { callerId } from '../../lib/agent-ctx'; + +/** + * Remember the user's STANDING camp preferences (Campgrounds feature): a single `(:User)-[:PREFERS_CAMP]-> + * (:CampPrefs)` anchor (mirrors set_trail_preferences). Honored as defaults by find_campgrounds, and + * injected into the system prompt every turn. Durable — confirm scope first (standing vs just-this-trip): + * for a one-trip-only filter, pass the constraints to find_campgrounds instead. A null field keeps the + * saved value. Setting a hookup level also records the matching amenity REQUIRES so search honors it free. + * userId-bound (R4). + */ +export default defineTool({ + description: + "Remember the user's STANDING camp preferences (rig type + length, hookups, tent ok, ADA, pets, quiet, max $/night) so every campground search honors them. Durable — confirm the scope first (standing vs just this trip). For a one-trip-only need, pass those constraints to find_campgrounds instead of saving here.", + inputSchema: z.object({ + rig: z.enum(['tent', 'rv', 'trailer', 'van', 'cabin']).optional(), + maxLengthFt: z.number().optional().describe('RV/trailer length in feet'), + hookups: z.enum(['none', '30amp', '50amp', 'full']).optional(), + tentOk: z.boolean().optional(), + ada: z.boolean().optional(), + pets: z.boolean().optional(), + quiet: z.boolean().optional().describe('Prefers quiet / no generators'), + budget: z.number().optional().describe('Max $/night'), + }), + async execute(args, ctx) { + const userId = callerId(ctx); + await setCampPreferences(userId, args); + // A hookup preference also becomes an amenity REQUIRES so find_campgrounds honors it for free. + const amenIds = + args.hookups === '30amp' ? ['amen:hookup-30amp'] + : args.hookups === '50amp' ? ['amen:hookup-50amp'] + : args.hookups === 'full' ? ['amen:full-hookup'] + : []; + if (amenIds.length) await setCampAmenityNeeds(userId, amenIds); + return { kind: 'map_snippet', data: { saved: true, campPreferences: await getCampPreferences(userId) } }; + }, +}); diff --git a/agent/tools/set_camp_watch.ts b/agent/tools/set_camp_watch.ts new file mode 100644 index 0000000..1cc161e --- /dev/null +++ b/agent/tools/set_camp_watch.ts @@ -0,0 +1,56 @@ +import { defineTool } from 'eve/tools'; +import { z } from 'zod'; +import { searchCampgrounds } from '../../lib/campgrounds'; +import { createCampWatch, listCampWatches } from '../../lib/camp-watches'; +import { callerId } from '../../lib/agent-ctx'; + +/** + * Set a Camp Watch — cancellation alerting (Campgrounds feature). A `:CampWatch` stores the criteria; the + * poller diffs availability every 15 min and alerts (in-app + opt-in email) when a matching site frees up. + * This is durable — confirm the user actually wants ongoing alerts before saving. Give explicit + * campgroundIds OR a parkCode (we resolve the in/near-park reservable campgrounds). userId-bound (R4). + * The ranger never books — a watch only WATCHES and (with permission) emails a deep link. + */ +export default defineTool({ + description: + "Set a Camp Watch so the user is alerted when a matching campsite opens up (cancellation alerting). Give campgroundIds (from cards) OR a parkCode (we'll watch its reservable campgrounds), plus startDate/endDate (YYYY-MM-DD) and optional siteType/hookups/ada/weekendOnly/minNights. Only call when the user asks to watch/monitor/get notified. It WATCHES only — it never books or holds a site.", + inputSchema: z.object({ + campgroundIds: z.array(z.string()).optional(), + parkCode: z.string().optional().describe('Watch the reservable campgrounds in/near this park'), + startDate: z.string().describe('First night, YYYY-MM-DD'), + endDate: z.string().describe('Last night, YYYY-MM-DD'), + nights: z.number().optional(), + minNights: z.number().optional(), + siteType: z.enum(['tent', 'rv', 'group', 'any']).optional(), + weekendOnly: z.boolean().optional(), + hookups: z.enum(['none', '30amp', '50amp', 'full']).optional(), + ada: z.boolean().optional(), + label: z.string().optional().describe('A short name for the watch, e.g. "North Rim in October"'), + }), + async execute(args, ctx) { + const userId = callerId(ctx); + let campgroundIds = args.campgroundIds ?? []; + if (!campgroundIds.length && args.parkCode) { + const { items } = await searchCampgrounds({ nearParkCode: args.parkCode, reservable: true, hasRidb: true, limit: 12 }); + campgroundIds = items.map((c) => c.id); + } + if (!campgroundIds.length) { + return { kind: 'camp_watch_card', data: { error: 'Give me at least one campground (or a park) with reservable sites to watch.' } }; + } + const res = await createCampWatch(userId, { + campgroundIds, + recAreaId: null, + startDate: args.startDate, + endDate: args.endDate, + nights: args.nights ?? null, + minNights: args.minNights ?? null, + siteType: args.siteType ?? null, + weekendOnly: args.weekendOnly ?? false, + hookups: args.hookups ?? null, + ada: args.ada ?? false, + label: args.label ?? null, + }); + if ('error' in res) return { kind: 'camp_watch_card', data: { error: res.error } }; + return { kind: 'camp_watch_card', data: { watches: await listCampWatches(userId), justCreated: res.id } }; + }, +}); diff --git a/app/api/camp-watch/route.ts b/app/api/camp-watch/route.ts new file mode 100644 index 0000000..f92feb3 --- /dev/null +++ b/app/api/camp-watch/route.ts @@ -0,0 +1,134 @@ +import { Resend } from 'resend'; +import { assertCron } from '../../../lib/cron-auth'; +import { env } from '../../../lib/env'; +import { + usersWithCampWatches, + expireCampWatches, + recordCampWatchSnapshot, + freshOpenings, + type CampWatch, +} from '../../../lib/camp-watches'; +import { getCampgroundAvailability, enumerateNights } from '../../../lib/datasources/campAvailability'; +import { campgroundDetail } from '../../../lib/campgrounds'; +import { recreationUrl } from '../../../lib/datasources/recreation'; +import { appendDigestItem } from '../../../lib/digest'; +import { campAlertEmailHtml, type CampAlertHit } from '../../../lib/camp-alert-email'; + +/** + * Camp Watch poller (Campgrounds feature, Phase 2). A SEPARATE cron from the daily digest (availability + * changes by the minute): every 15 min it scans active, non-expired `:CampWatch`es, fetches availability + * for each watched campground (backoff-aware, bounded), diffs the set of open `campgroundId|date|siteId` + * keys against the watch's `lastSnapshot`, and on a FRESH opening drops an item into the in-app inbox + + * (opt-in) sends a Resend "a site opened" email with a one-tap Book ↗ link. Throttled (`lastNotifiedAt`, + * 30-min mute) so a still-open site doesn't re-alert. Returns early when the availability flag is off. + * + * Mirrors /api/digests (assertCron + maxDuration + Resend opt-in). Never books or holds anything. + */ +export const dynamic = 'force-dynamic'; +export const maxDuration = 300; + +const resend = process.env.RESEND_API_KEY ? new Resend(process.env.RESEND_API_KEY) : null; +const MUTE_MS = 30 * 60_000; +const MAX_CAMPGROUNDS_PER_WATCH = 20; + +function isWeekendNight(date: string): boolean { + const d = new Date(`${date}T00:00:00Z`).getUTCDay(); // 0=Sun … 6=Sat + return d === 5 || d === 6; // Fri / Sat nights +} + +export async function GET(req: Request) { + const deny = assertCron(req); + if (deny) return deny; + + const expired = await expireCampWatches(); + if (!env.camp.availabilityEnabled) { + return Response.json({ skipped: 'availability disabled', expired }); + } + + const baseUrl = process.env.BETTER_AUTH_URL ?? 'https://trailgraph.app'; + const emailFrom = process.env.EMAIL_FROM; + const rows = await usersWithCampWatches(); + + // Resolve campground metadata (ridbId / name / bookingUrl) once across all watches. + const cgCache = new Map(); + const resolveCg = async (id: string) => { + if (cgCache.has(id)) return cgCache.get(id)!; + const d = await campgroundDetail(id).catch(() => null); + const meta = d + ? { ridbId: d.ridbId, name: d.name, bookingUrl: d.reservationUrl ?? (d.ridbId ? recreationUrl(d.ridbId) : null) } + : { ridbId: null, name: id, bookingUrl: null }; + cgCache.set(id, meta); + return meta; + }; + + let polled = 0; + let matched = 0; + let emailed = 0; + + for (const { watch, email, emailOptIn, unsubToken } of rows) { + const nights = enumerateNights(watch.startDate, watch.endDate).filter((d) => !watch.weekendOnly || isWeekendNight(d)); + if (!nights.length) continue; + const months = [...new Set(nights.map((d) => d.slice(0, 7)))]; + const nightSet = new Set(nights); + + const openKeys = new Set(); // "campgroundId|date|siteId" + + for (const cgId of (watch.campgroundIds ?? []).slice(0, MAX_CAMPGROUNDS_PER_WATCH)) { + const meta = await resolveCg(cgId); + if (!meta.ridbId) continue; + for (const m of months) { + const data = await getCampgroundAvailability(meta.ridbId, `${m}-01`); + polled++; + if (!data) continue; // backoff/disabled/error → skip silently + for (const [siteId, byDate] of Object.entries(data.perSite)) { + if (watch.siteType && watch.siteType !== 'any' && data.siteType[siteId] !== watch.siteType) continue; + for (const d of Object.keys(byDate)) { + if (byDate[d] === 'open' && nightSet.has(d)) openKeys.add(`${cgId}|${d}|${siteId}`); + } + } + } + } + + const fresh = freshOpenings(watch.lastSnapshot, [...openKeys]); + const muted = watch.lastNotifiedAt != null && Date.now() - Date.parse(watch.lastNotifiedAt) < MUTE_MS; + let notified = false; + + if (fresh.length && !muted) { + matched++; + // Reconstruct per-(campground,date) alert hits from the fresh keys (siteId dropped for display). + const freshHits: CampAlertHit[] = fresh.map((k) => { + const [cgId, date] = k.split('|'); + const meta = cgCache.get(cgId); + return { campgroundId: cgId, campgroundName: meta?.name ?? cgId, date, bookingUrl: meta?.bookingUrl ?? baseUrl }; + }); + const names = [...new Set(freshHits.map((h) => h.campgroundName))].slice(0, 3).join(', '); + await appendDigestItem(watch.userId, { + kind: 'campavail', + title: `A campsite opened for ${watch.label ?? 'your watch'}`, + detail: `${fresh.length} newly-open site-night${fresh.length === 1 ? '' : 's'} at ${names}. Book on recreation.gov before it's gone.`, + tone: 'good', + }).catch(() => {}); + notified = true; + + if (emailOptIn && email && unsubToken && resend && emailFrom) { + const unsubscribeUrl = `${baseUrl}/api/unsubscribe?token=${encodeURIComponent(unsubToken)}`; + try { + await resend.emails.send({ + from: emailFrom, + to: email, + subject: `🏕️ A site opened for ${watch.label ?? 'your watch'}`, + html: campAlertEmailHtml(watch as CampWatch, freshHits, unsubscribeUrl), + }); + emailed++; + } catch { + // a failed send shouldn't abort the fan-out + } + } + } + + // Always persist the snapshot so the next diff is correct even when muted/disabled-by-throttle. + await recordCampWatchSnapshot(watch.id, JSON.stringify([...openKeys]), notified).catch(() => {}); + } + + return Response.json({ watches: rows.length, polled, matched, emailed, expired }); +} diff --git a/app/api/campgrounds/[id]/sites/route.ts b/app/api/campgrounds/[id]/sites/route.ts new file mode 100644 index 0000000..4e97c56 --- /dev/null +++ b/app/api/campgrounds/[id]/sites/route.ts @@ -0,0 +1,28 @@ +import { readCampgroundSites } from '../../../../../lib/blob-campsites'; +import { readGraph } from '../../../../../lib/neo4j'; + +/** + * Serve a campground's site/loop geometry — the mirror of `/api/trails/[parkCode]`. Reads the + * campground's `sitesGeoUrl` to locate the Blob FeatureCollection, degrading to an empty FC so a + * RIDB-only campground (no per-site geometry) or an unsynced one never errors. The detail page falls + * back to the site list (campsitesForCampground) when this is empty. + * + * Campground ids carry colons ('ridb:232449') → params arrive URL-encoded in production (decode first). + */ +export const dynamic = 'force-dynamic'; + +const EMPTY = { type: 'FeatureCollection' as const, features: [] }; + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const { id: raw } = await params; + const id = decodeURIComponent(raw); + if (!/^[A-Za-z0-9:_-]+$/.test(id)) return Response.json({ error: 'invalid id' }, { status: 400 }); + const rows = await readGraph<{ url: string | null }>( + `MATCH (c:Campground {id: $id}) RETURN c.sitesGeoUrl AS url`, + { id }, + ).catch(() => []); + const fc = await readCampgroundSites(id, rows[0]?.url ?? null); + return Response.json(fc ?? EMPTY, { + headers: { 'Cache-Control': 'public, s-maxage=86400, stale-while-revalidate=3600' }, + }); +} diff --git a/app/api/campgrounds/availability/route.ts b/app/api/campgrounds/availability/route.ts new file mode 100644 index 0000000..ddad20b --- /dev/null +++ b/app/api/campgrounds/availability/route.ts @@ -0,0 +1,68 @@ +import { rateLimit, rlIp, clientIp } from '../../../../lib/rate-limit'; +import { getCampgroundAvailability, enumerateNights } from '../../../../lib/datasources/campAvailability'; +import { searchAvailability } from '../../../../lib/campgrounds'; +import { env } from '../../../../lib/env'; + +/** + * On-demand campsite availability (Campgrounds feature, Phase 2). Two modes: + * ?ridbId=&start=YYYY-MM-DD&end=YYYY-MM-DD → per-night open counts for one campground + * ?parkCode=&start=…&end=…[&siteType&hookups&ada] → "what's open near this park" (graph-filtered) + * + * IP-rate-limited (each call does live rec.gov fetches), and ALWAYS degrades to a recreation.gov deep link + * when CAMP_AVAILABILITY_ENABLED is off / the unofficial endpoint is unreachable. Never authoritative. + */ +export const dynamic = 'force-dynamic'; + +const ISO = /^\d{4}-\d{2}-\d{2}$/; + +export async function GET(req: Request) { + const url = new URL(req.url); + const start = url.searchParams.get('start') ?? ''; + const end = url.searchParams.get('end') ?? ''; + if (!ISO.test(start) || !ISO.test(end)) return Response.json({ error: 'start/end must be YYYY-MM-DD' }, { status: 400 }); + + const rl = await rateLimit(rlIp(clientIp(req), 'camp-availability'), 30, 60); + if (!rl.ok) { + return Response.json( + { error: 'rate_limited' }, + { status: 429, headers: { 'Retry-After': String(Math.max(1, Math.ceil((rl.resetAt - Date.now()) / 1000))) } }, + ); + } + + const ridbId = url.searchParams.get('ridbId'); + const parkCode = url.searchParams.get('parkCode'); + + if (parkCode) { + const out = await searchAvailability({ + parkCode, + startDate: start, + endDate: end, + siteType: url.searchParams.get('siteType') ?? undefined, + hookups: url.searchParams.get('hookups') === '1' || undefined, + ada: url.searchParams.get('ada') === '1' || undefined, + }); + return Response.json(out, { headers: { 'Cache-Control': 'private, max-age=300' } }); + } + + if (ridbId) { + const nights = enumerateNights(start, end); + const bookingUrl = `https://www.recreation.gov/camping/campgrounds/${encodeURIComponent(ridbId)}`; + if (!env.camp.availabilityEnabled) { + return Response.json({ ridbId, degraded: true, bookingUrl, nights: [] }); + } + const months = [...new Set(nights.map((d) => d.slice(0, 7)))]; + const data = await Promise.all(months.map((m) => getCampgroundAvailability(ridbId, `${m}-01`))); + if (data.every((m) => m === null)) { + return Response.json({ ridbId, degraded: true, bookingUrl, nights: [] }); + } + const byDate = new Map }>(); + for (const m of data) for (const d of m?.days ?? []) byDate.set(d.date, { sitesOpen: d.sitesOpen, byType: d.byType }); + const out = nights.map((date) => ({ date, sitesOpen: byDate.get(date)?.sitesOpen ?? 0, byType: byDate.get(date)?.byType ?? {} })); + return Response.json( + { ridbId, degraded: false, bookingUrl, nights: out, anyOpen: out.some((n) => n.sitesOpen > 0) }, + { headers: { 'Cache-Control': 'private, max-age=300' } }, + ); + } + + return Response.json({ error: 'provide ridbId or parkCode' }, { status: 400 }); +} diff --git a/app/api/graph/route.ts b/app/api/graph/route.ts index c375c03..3f3a82f 100644 --- a/app/api/graph/route.ts +++ b/app/api/graph/route.ts @@ -5,7 +5,7 @@ import { parksInBBox, allParksGeo, vibeSearch, - campgroundsInBBox, + campgroundMarkersInBBox, visitorCentersInBBox, thingsToDoInBBox, alertParksInBBox, @@ -76,7 +76,8 @@ export async function GET(req: Request) { // Layer toggles (B3); default = parks. switch (url.searchParams.get('layer')) { case 'campgrounds': - return Response.json({ items: await campgroundsInBBox(box) }); + // Faceted markers (free/dispersed/hookups/ada/fcfs) so the camp-lens can recolor them (Phase 4). + return Response.json({ items: await campgroundMarkersInBBox(box) }); case 'visitorcenters': return Response.json({ items: await visitorCentersInBBox(box) }); case 'thingstodo': diff --git a/app/api/trips/[id]/route.ts b/app/api/trips/[id]/route.ts index 44350ba..f7cbb36 100644 --- a/app/api/trips/[id]/route.ts +++ b/app/api/trips/[id]/route.ts @@ -11,6 +11,8 @@ import { tripConditions, addTrailToStop, removeTrailFromStop, + addLodgingToStop, + removeCampgroundFromStop, } from '../../../../lib/trips'; import { suggestDays } from '../../../../lib/itinerary'; import { nearestNeighborOrder } from '../../../../lib/route-order'; @@ -114,6 +116,17 @@ export async function POST(req: Request, { params }: Ctx) { await removeTrailFromStop(userId, id, body.stopId, body.trailId); return Response.json({ trip: await getTrip(userId, id) }); } + case 'includeCampground': { + if (!body.stopId || !body.campgroundId) return Response.json({ error: 'stopId and campgroundId required' }, { status: 400 }); + const ok = await addLodgingToStop(userId, id, body.stopId, body.campgroundId, { date: body.date, nights: body.nights }); + if (!ok) return Response.json({ error: 'not found' }, { status: 404 }); + return Response.json({ trip: await getTrip(userId, id) }); + } + case 'excludeCampground': { + if (!body.stopId || !body.campgroundId) return Response.json({ error: 'stopId and campgroundId required' }, { status: 400 }); + await removeCampgroundFromStop(userId, id, body.stopId, body.campgroundId); + return Response.json({ trip: await getTrip(userId, id) }); + } case 'alerts': return Response.json({ alerts: await checkTripAlerts(userId, id) }); case 'cost': diff --git a/app/campgrounds/[id]/page.tsx b/app/campgrounds/[id]/page.tsx new file mode 100644 index 0000000..e8888dc --- /dev/null +++ b/app/campgrounds/[id]/page.tsx @@ -0,0 +1,251 @@ +import type { ReactNode } from 'react'; +import { Box, Badge, Button, Container, Heading, HStack, Icon, SimpleGrid, Stack, Text, Link as CLink } from '@chakra-ui/react'; +import NextLink from 'next/link'; +import { notFound } from 'next/navigation'; +import { + LuArrowLeft, + LuMapPin, + LuPlug, + LuDollarSign, + LuTriangleAlert, + LuExternalLink, + LuTentTree, + LuAccessibility, + LuPawPrint, + LuMoon, + LuShowerHead, + LuDroplets, + LuSignal, + LuTruck, +} from 'react-icons/lu'; +import { campgroundDetail, campsitesForCampground } from '../../../lib/campgrounds'; +import { getWeather } from '../../../lib/datasources/weather'; +import { recreationUrl } from '../../../lib/datasources/recreation'; + +/** + * Campground detail (Campgrounds feature) — RSC. Metadata + site-level inventory + nearby trails/parks + * from the graph; current weather from the runtime adapter. The id carries colons ('ridb:232449'), so it + * arrives URL-encoded in prod — decode it before the lookup (the documented dynamic-param gotcha). + * + * Phase 1: availability + the Set-a-Camp-Watch action + the pitch-level site map are Phase-2/3 overlays; + * here we surface the Book ↗ deep link and a "coming" note for those, so nothing ships as a dead button. + */ +export const dynamic = 'force-dynamic'; + +const AGENCY_LABEL: Record = { + NPS: 'National Park Service', + USFS: 'Forest Service', + BLM: 'Bureau of Land Management', + USACE: 'Army Corps of Engineers', + STATE: 'State Parks', + PRIVATE: 'Private', +}; + +export default async function CampgroundDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id: raw } = await params; + const id = decodeURIComponent(raw); + const cg = await campgroundDetail(id); + if (!cg) notFound(); + + const [sites, weather] = await Promise.all([ + cg.sites.length ? Promise.resolve(cg.sites) : campsitesForCampground(id).catch(() => []), + cg.lat != null && cg.lng != null ? getWeather(cg.lat, cg.lng).catch(() => null) : Promise.resolve(null), + ]); + + const bookUrl = cg.reservationUrl ?? (cg.ridbId ? recreationUrl(cg.ridbId) : null); + 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'); + const sourceLabel = + ({ + 'nps+ridb': 'NPS + Recreation.gov (RIDB)', + ridb: 'Recreation.gov (RIDB)', + usfs: 'USFS Recreation Sites GIS', + osm: 'OpenStreetMap (ODbL)', + overture: 'Overture Maps', + state: 'State reservation system', + nps: 'NPS Data API', + } as Record)[cg.source] ?? 'NPS Data API'; + + // Site-mix summary from the real :Campsite inventory. + const byType = new Map(); + let amps30 = 0; + let amps50 = 0; + let adaSites = 0; + for (const s of sites) { + if (s.type) byType.set(s.type, (byType.get(s.type) ?? 0) + 1); + if (s.electricAmps && s.electricAmps >= 50) amps50++; + else if (s.electricAmps && s.electricAmps >= 30) amps30++; + if (s.ada) adaSites++; + } + const typeSummary = [...byType.entries()].sort((a, b) => b[1] - a[1]).map(([t, n]) => `${n} ${t}`).join(' · '); + + const stat = (icon: ReactNode, label: string, value: string) => ( + + {icon} + {label}: {value} + + ); + + return ( + + + All campgrounds + + + + {cg.name} + + {agencyLabel} + {where ? ( + <> + + {cg.parkCode ? ( + {where} + ) : ( + {where} + )} + + ) : null} + + + + {/* At-a-glance stats */} + + {cg.totalSites != null ? stat(, 'Sites', String(cg.totalSites)) : null} + {cg.sitesReservable != null ? stat(, 'Reservable', String(cg.sitesReservable)) : null} + {cg.sitesFirstCome != null ? stat(, 'First-come', String(cg.sitesFirstCome)) : null} + {cg.hasHookups ? stat(, 'Hookups', cg.maxAmps ? `up to ${cg.maxAmps}A` : 'yes') : null} + {cg.free ? stat(, 'Fee', 'Free') : cg.feeUSD != null ? stat(, 'Fee', `$${cg.feeUSD}/night`) : null} + {cg.booksOutDays != null ? stat(, 'Books out', `~${cg.booksOutDays} days ahead`) : null} + {weather?.currentTempF != null ? ( + + {weather.emoji} + Now: {weather.currentTempF}°F {weather.condition} + + ) : null} + + + {/* Amenity / accessibility badges */} + + {cg.ada ? Accessible sites : null} + {cg.petsAllowed ? Pets : null} + {cg.dumpStation ? Dump station : null} + {cg.showers ? Showers : null} + {cg.drinkingWater ? Potable water : null} + {cg.cellReception ? Cell reception : null} + {cg.darkSky ? Dark sky : null} + {cg.dispersed ? Dispersed : null} + + + {/* CTAs */} + + {bookUrl ? ( + + ) : null} + + + + {/* Site/loop map — pitch-level geometry is a later phase; degrade to a note + the inventory below. */} + + A site/loop map appears once pitch-level geometry is available. For now, see the site inventory below. + + + {/* Site-level inventory (the differentiator) */} + {sites.length > 0 ? ( + + Sites + {typeSummary ? {sites.length} sites — {typeSummary}{amps30 ? ` · ${amps30} with 30A` : ''}{amps50 ? ` · ${amps50} with 50A` : ''}{adaSites ? ` · ${adaSites} ADA` : ''} : null} + + + Loop + Site + Type + Max RV + Electric + Notes + + + {sites.slice(0, 200).map((s) => ( + + {s.loop ?? '—'} + {s.number ?? '—'} + {s.type ?? '—'} + {s.maxRvLengthFt ? `${s.maxRvLengthFt} ft` : '—'} + {s.electricAmps ? `${s.electricAmps}A` : '—'} + + {s.hasWater ? water : null} + {s.hasSewer ? sewer : null} + {s.pullThrough ? pull-through : null} + {s.ada ? ADA : null} + {s.reservable ? reservable : first-come} + + + ))} + + + {sites.length > 200 ? Showing 200 of {sites.length} sites. : null} + + ) : null} + + {/* Amenities */} + {cg.amenities.length > 0 ? ( + + Amenities + + {cg.amenities.map((a) => ( + {a.name} + ))} + + + ) : null} + + {/* Nearby trails + park entrance */} + {cg.nearTrails.length > 0 || cg.nearParks.length > 0 ? ( + + Nearby + + {cg.nearParks.length > 0 ? ( + + Parks: + {cg.nearParks.slice(0, 6).map((p) => ( + + {p.name} ({p.miles} mi) + + ))} + + ) : null} + {cg.nearTrails.length > 0 ? ( + + Trailheads: + {cg.nearTrails.map((t) => ( + + {t.name} ({t.miles} mi) + + ))} + + ) : null} + + + ) : null} + + {/* Safety + provenance */} + + Verify before you book or tow + + Site counts, RV length limits, hookups, and accessibility are reported by the operator and merged from + {' '}{sourceLabel} ({confidence} confidence) — they change. Confirm current rates, closures, fire bans, and + length limits on Recreation.gov or with the managing agency before you rely on them. + {cg.dispersed ? ' This is dispersed camping: no reservation, stay limits apply, pack it in / pack it out (Leave No Trace).' : ''} + + + + + {cg.parkCode ? View {cg.parkName} → : null} + Find more campgrounds → + + + ); +} diff --git a/app/campgrounds/compare/page.tsx b/app/campgrounds/compare/page.tsx new file mode 100644 index 0000000..103fad3 --- /dev/null +++ b/app/campgrounds/compare/page.tsx @@ -0,0 +1,124 @@ +import { Box, Badge, Container, Heading, HStack, Stack, Text, Link as CLink } from '@chakra-ui/react'; +import NextLink from 'next/link'; +import { campgroundDetail, type CampgroundDetail } from '../../../lib/campgrounds'; +import { campgroundScores, campgroundCompareData } from '../../../lib/camp-charts'; +import { CampgroundCompare } from '../../../components/campgrounds/charts/CampgroundCompare'; +import { PageHeader } from '../../../components/ui/page-header'; +import { EmptyState } from '../../../components/ui/empty-state'; +import { LuTentTree, LuArrowLeft } from 'react-icons/lu'; + +/** + * Campground comparison scorecard (Campgrounds feature, Phase 3 viz). `/campgrounds/compare?ids=a,b,c` — + * loads 2–4 campgrounds and renders a radar overlay (`CampgroundCompare`) + a side-by-side fact table over + * the structured inventory. RSC; ids carry colons (`ridb:…`) so they're decoded per-id. + */ +export const dynamic = 'force-dynamic'; + +type SP = Record; + +const AGENCY_LABEL: Record = { NPS: 'NPS', USFS: 'Forest Service', BLM: 'BLM', USACE: 'Army Corps', STATE: 'State', PRIVATE: 'Private' }; + +function row(label: string, values: (string | number | null | undefined)[]) { + return ( + + {label} + {values.map((v, i) => ( + {v == null || v === '' ? '—' : v} + ))} + + ); +} + +export default async function CampgroundComparePage({ searchParams }: { searchParams: Promise }) { + const sp = await searchParams; + const ids = (sp.ids ?? '') + .split(',') + .map((s) => decodeURIComponent(s.trim())) + .filter(Boolean) + .slice(0, 4); + + const cgs = (await Promise.all(ids.map((id) => campgroundDetail(id).catch(() => null)))).filter( + (c): c is CampgroundDetail => !!c, + ); + + if (cgs.length < 2) { + return ( + + + + } + title="Pick 2–4 campgrounds to compare" + description="Open a campground and choose “Compare,” or pass ?ids=a,b,c. We line them up across amenities, price, hookups, accessibility, size, connectivity, dark sky, and booking ease." + py={16} + > + Browse campgrounds → + + + + ); + } + + const scored = cgs.map((c, i) => ({ + key: `c${i}`, + name: c.name, + cg: c, + scores: campgroundScores({ + totalSites: c.totalSites, + feeUSD: c.feeUSD, + free: c.free, + hasHookups: c.hasHookups, + maxAmps: c.maxAmps, + ada: c.ada, + cellReception: c.cellReception, + darkSky: c.darkSky, + amenityCount: c.amenities.length, + booksOutDays: c.booksOutDays, + }), + })); + const radarData = campgroundCompareData(scored.map((s) => ({ key: s.key, scores: s.scores }))); + + return ( + + c.name).join(' vs ')} contour /> + + + All campgrounds + + + ({ key: s.key, name: s.name }))} /> + + {/* Side-by-side fact table */} + + + Campground + {cgs.map((c) => ( + + {c.name} + + ))} + + {row('Agency', cgs.map((c) => AGENCY_LABEL[c.agency ?? ''] ?? c.agency))} + {row('Sites', cgs.map((c) => c.totalSites))} + {row('Price', cgs.map((c) => (c.free ? 'Free' : c.feeUSD != null ? `$${c.feeUSD}/night` : null)))} + {row('Hookups', cgs.map((c) => (c.hasHookups ? (c.maxAmps ? `${c.maxAmps}A` : 'yes') : 'no')))} + {row('ADA sites', cgs.map((c) => (c.ada ? 'yes' : 'no')))} + {row('Dump station', cgs.map((c) => (c.dumpStation ? 'yes' : 'no')))} + {row('Cell reception', cgs.map((c) => (c.cellReception ? 'yes' : 'no')))} + {row('Dark sky', cgs.map((c) => (c.darkSky ? 'yes' : 'no')))} + {row('Books out', cgs.map((c) => (c.booksOutDays != null ? `~${c.booksOutDays} days` : 'unknown')))} + {row('Nearest park', cgs.map((c) => c.parkName ?? c.nearParks[0]?.name ?? c.recAreaName))} + + + + + {cgs.map((c) => ( + {c.name}: {c.dataConfidence ?? 'medium'} confidence + ))} + + Scores are derived from operator-reported inventory — verify on recreation.gov before you book. + + + + ); +} diff --git a/app/campgrounds/page.tsx b/app/campgrounds/page.tsx new file mode 100644 index 0000000..edd9833 --- /dev/null +++ b/app/campgrounds/page.tsx @@ -0,0 +1,306 @@ +import { + Box, + Button, + Checkbox, + Container, + Field, + Flex, + Icon, + Input, + NativeSelect, + SimpleGrid, + Stack, + Text, +} from '@chakra-ui/react'; +import NextLink from 'next/link'; +import { unstable_cache } from 'next/cache'; +import { LuChevronLeft, LuChevronRight, LuSearch, LuTentTree } from 'react-icons/lu'; +import { searchCampgrounds, campgroundFacets, campAvailabilityForList, type AvailabilityChipData } from '../../lib/campgrounds'; +import { CampgroundCard } from '../../components/campgrounds/CampgroundCard'; +import { PageHeader } from '../../components/ui/page-header'; +import { EmptyState } from '../../components/ui/empty-state'; +import { SectionHeading } from '../../components/ui/section-heading'; + +// Facets change only on a sync → cache for an hour (like /trails). +const cachedCampgroundFacets = unstable_cache(campgroundFacets, ['campground-facets'], { revalidate: 3600 }); + +/** + * Campgrounds finder (Campgrounds feature) — RSC, faceted GET form (works without JS), results from Neo4j. + * The multi-agency analogue of /trails: NPS · USFS · BLM · USACE · dispersed campgrounds with site-level + * inventory. Availability is a Phase-2 overlay (always nullable); the card degrades to a recreation.gov link. + */ +export const dynamic = 'force-dynamic'; + +type SP = Record; +const BOOKING = ['reservable', 'fcfs', 'free']; +const SITE_TYPES = ['tent', 'rv', 'group', 'cabin', 'walk-in', 'equestrian']; +const AMPS = ['20', '30', '50']; +const MAX_RV = ['20', '25', '30', '35', '40']; +const MAX_PRICE = ['0', '15', '25', '40']; +const MAX_ELEV = ['3000', '6000', '9000']; +const AGENCY_LABEL: Record = { + NPS: 'National Park Service', + USFS: 'Forest Service', + BLM: 'BLM', + USACE: 'Army Corps', + STATE: 'State', + PRIVATE: 'Private', +}; + +export default async function CampgroundsPage({ searchParams }: { searchParams: Promise }) { + const sp = await searchParams; + const pageSize = 24; + const page = Math.max(1, Number(sp.page) || 1); + const anyFilter = Object.keys(sp).some((k) => sp[k] && k !== 'page'); + + const opts = { + q: sp.q, + nearParkCode: sp.park, + agency: sp.agency, + reservable: sp.booking === 'reservable' || undefined, + fcfs: sp.booking === 'fcfs' || undefined, + free: sp.booking === 'free' || undefined, + siteType: sp.siteType, + minAmps: sp.amps ? Number(sp.amps) : undefined, + maxRvLength: sp.maxRv ? Number(sp.maxRv) : undefined, + ada: sp.ada === '1' || undefined, + pets: sp.pets === '1' || undefined, + dumpStation: sp.dump === '1' || undefined, + showers: sp.showers === '1' || undefined, + drinkingWater: sp.water === '1' || undefined, + cellReception: sp.cell === '1' || undefined, + darkSky: sp.darksky === '1' || undefined, + maxPriceUSD: sp.maxPrice ? Number(sp.maxPrice) : undefined, + elevationMax: sp.maxElevation ? Number(sp.maxElevation) : undefined, + }; + + const [search, f] = await Promise.all([ + searchCampgrounds({ ...opts, limit: pageSize, offset: (page - 1) * pageSize }).catch(() => ({ items: [], total: 0 })), + cachedCampgroundFacets().catch(() => ({ agencies: [], siteTypes: [], parks: [], recAreas: [], maxFeeUSD: null, maxRvLengthFt: null })), + ]); + + // Discovery rails (only on the unfiltered landing view) — each degrades to [] on a cold data layer. + const rails = anyFilter + ? [] + : await Promise.all([ + searchCampgrounds({ dispersed: true, free: true, limit: 8 }).then((r) => ({ title: 'Free & dispersed', items: r.items })).catch(() => null), + searchCampgrounds({ hookups: true, minAmps: 30, limit: 8 }).then((r) => ({ title: 'Full-hookup RV', items: r.items })).catch(() => null), + searchCampgrounds({ darkSky: true, limit: 8 }).then((r) => ({ title: 'Dark-sky campgrounds', items: r.items })).catch(() => null), + ]).then((rs) => rs.filter((r): r is { title: string; items: typeof search.items } => !!r && r.items.length > 0)); + + const results = search.items; + const total = search.total; + + // Availability chips: only when a date range is set. Gated/unreachable → every chip degrades to + // "Check on recreation.gov ↗" (the adapter returns state:'unavailable'). Never blocks the page. + const ISO = /^\d{4}-\d{2}-\d{2}$/; + const avail: Record = + ISO.test(sp.from ?? '') && ISO.test(sp.to ?? '') + ? await campAvailabilityForList( + results.map((c) => ({ id: c.id, ridbId: c.ridbId, totalSites: c.totalSites })), + { from: sp.from!, to: sp.to! }, + ).catch(() => ({})) + : {}; + + const firstIdx = total === 0 ? 0 : (page - 1) * pageSize + 1; + const lastIdx = (page - 1) * pageSize + results.length; + const hasPrev = page > 1; + const hasNext = lastIdx < total; + const pageHref = (p: number) => { + const qs = new URLSearchParams(); + for (const [k, v] of Object.entries(sp)) if (v && k !== 'page') qs.set(k, v); + if (p > 1) qs.set('page', String(p)); + const s = qs.toString(); + return s ? `/campgrounds?${s}` : '/campgrounds'; + }; + const agencyOptions = f.agencies.length ? f.agencies : ['NPS', 'USFS', 'BLM', 'USACE', 'STATE']; + + return ( + + + + + {/* Filter bar — plain GET form, works without JS. */} + +
+ + + Search + + + + + + + + + + + + + + + + + Open from + + + + to + + + + + + + + + + + + + +
+
+ + + {total === 0 ? '0 campgrounds' : `Showing ${firstIdx}–${lastIdx} of ${total} campground${total === 1 ? '' : 's'}`} + + + {results.length === 0 ? ( + <> + } + title={anyFilter ? 'No campgrounds matched' : 'Campground data is on its way'} + description={ + anyFilter + ? 'Try clearing a filter, widening the price range, or searching near a different park.' + : "We're ingesting site-level campground inventory from NPS, Recreation.gov (RIDB), and the Forest Service. Meanwhile, browse parks or trails." + } + py={16} + > + + + + {rails.length > 0 ? ( + + {rails.map((rail) => ( + + + + {rail.items.slice(0, 8).map((cg) => ( + + ))} + + + ))} + + ) : null} + + ) : ( + <> + + {results.map((cg) => ( + + ))} + + {hasPrev || hasNext ? ( + + {hasPrev ? ( + + ) : ( + + )} + Page {page} + {hasNext ? ( + + ) : ( + + )} + + ) : null} + + )} +
+
+ ); +} + +function ParkSelect({ value, options }: { value?: string; options: { parkCode: string; name: string }[] }) { + if (options.length === 0) return null; + return ( + + Near park + + + + {options.map((o) => ( + + ))} + + + + + ); +} + +function FacetSelect({ + name, + label, + value, + options, + capitalize, + suffix, + priceLabel, + labelMap, +}: { + name: string; + label: string; + value?: string; + options: string[]; + capitalize?: boolean; + suffix?: string; + priceLabel?: boolean; + labelMap?: Record; +}) { + const optionLabel = (o: string) => { + if (priceLabel) return o === '0' ? 'Free' : `$${o}`; + if (labelMap) return labelMap[o] ?? o; + return `${o}${suffix ?? ''}`; + }; + return ( + + {label} + + + + {options.filter(Boolean).map((o) => ( + + ))} + + + + + ); +} + +function FacetCheck({ name, label, checked }: { name: string; label: string; checked: boolean }) { + return ( + + + + {label} + + ); +} diff --git a/app/search/page.tsx b/app/search/page.tsx index 040dfb2..49b05ef 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -4,6 +4,7 @@ import NextImage from 'next/image'; import { LuSearch, LuSparkles } from 'react-icons/lu'; import { headers } from 'next/headers'; import { vibeSearch, semanticSearch, semanticArticles, semanticTrails, type SemanticHit, type ArticleHit, type TrailSummary } from '../../lib/queries'; +import { searchCampgrounds, type CampgroundSummary } from '../../lib/campgrounds'; import { embedQuery } from '../../lib/embed-cache'; import { rateLimit, rlIp, clientIpFrom } from '../../lib/rate-limit'; import { ParkCard } from '../../components/ParkCard'; @@ -37,6 +38,7 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi type ParkHit = Awaited>[number]; let parks: ParkHit[] = []; let trails: TrailSummary[] = []; + let campgrounds: CampgroundSummary[] = []; let places: SemanticHit[] = []; let people: SemanticHit[] = []; let articles: ArticleHit[] = []; @@ -50,6 +52,8 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi if (!ok) { throttled = true; } else { + // Campgrounds use a fulltext name match (no embedding index) — independent of the shared vector. + campgrounds = await searchCampgrounds({ q, limit }).then((r) => r.items).catch(() => [] as CampgroundSummary[]); const vec = await embedQuery(q).catch(() => null); if (vec) { [parks, trails, places, people, articles] = await Promise.all([ @@ -133,6 +137,18 @@ export default async function SearchPage({ searchParams }: { searchParams: Promi )} +
+ {campgrounds.length === 0 ? ( + + ) : ( + + {campgrounds.map((c) => ( + + ))} + + )} +
+
{places.length === 0 ? ( @@ -222,6 +238,36 @@ function TrailHitCard({ hit }: { hit: TrailSummary }) { ); } +const AGENCY_HIT_LABEL: Record = { NPS: 'NPS', USFS: 'Forest Service', BLM: 'BLM', USACE: 'Army Corps', STATE: 'State', PRIVATE: 'Private' }; + +/** Campground result card — links to the campground detail page (id carries colons → encoded). */ +function CampgroundHitCard({ hit }: { hit: CampgroundSummary }) { + const stats = [ + hit.totalSites != null ? `${hit.totalSites} sites` : null, + hit.free ? 'Free' : hit.feeUSD != null ? `$${hit.feeUSD}/night` : null, + hit.hasHookups ? (hit.maxAmps ? `${hit.maxAmps}A hookups` : 'hookups') : null, + ] + .filter(Boolean) + .join(' · '); + return ( + + + + + + {AGENCY_HIT_LABEL[hit.agency ?? ''] ?? 'camp'} + {hit.name} + {hit.dispersed ? dispersed : null} + + {hit.parkName ?? hit.recAreaName ? {hit.parkName ?? hit.recAreaName} : null} + {stats ? {stats} : null} + + + + + ); +} + /** Place/person result card — links to its top related park (no place/person detail route). */ function NodeCard({ hit, type }: { hit: SemanticHit; type: 'place' | 'person' }) { const park = hit.parks[0]; diff --git a/components/MapExplorer.tsx b/components/MapExplorer.tsx index ec48687..fd82695 100644 --- a/components/MapExplorer.tsx +++ b/components/MapExplorer.tsx @@ -26,6 +26,7 @@ import { } from '../lib/mapLegend'; import { markerImageId, attachMarkerImages, MARKER_SVGS } from '../lib/mapMarkers'; import { MAP_LENSES, lensColorExpr, lensLegend, type LensKey } from '../lib/mapLenses'; +import { CAMP_LENSES, campLensColorExpr, campLensLegend, type CampLensKey } from '../lib/campLenses'; import { conditionMatchStops, conditionDefaultColor, conditionLegend } from '../lib/conditions-map'; import { BasemapSwitcher } from './map/BasemapSwitcher'; import { useColorMode } from './ui/color-mode'; @@ -118,6 +119,11 @@ export function MapExplorer({ const lensRef = useRef(initialView?.lens ?? 'none'); const [lens, setLens] = useState(initialView?.lens ?? 'none'); const applyLensRef = useRef<((map: MlMap) => void) | null>(null); + // Campground lens (Campgrounds feature, Phase 4): recolors the poi-campgrounds layer by a camp facet + // (free/dispersed/hookups/ada/fcfs). Same ref-survives-remount discipline as the parks lens above. + const campLensRef = useRef('none'); + const [campLens, setCampLens] = useState('none'); + const applyCampLensRef = useRef<((map: MlMap) => void) | null>(null); // The full parks FeatureCollection, fetched once and reused across style swaps (no per-pan refetch). const parksFcRef = useRef(null); // The viewport each POI layer was last fetched for → skip refetch when panning within loaded bounds. @@ -558,7 +564,9 @@ export function MapExplorer({ begin(); try { const res = await fetch(`/api/graph?${bboxParams(map, { layer: key })}`, { signal: abortRef.current?.signal }); - const { items } = (await res.json()) as { items: { id?: string; parkCode?: string; name: string; lat: number; lng: number }[] }; + const { items } = (await res.json()) as { + items: { id?: string; parkCode?: string; name: string; lat: number; lng: number; free?: boolean; dispersed?: boolean; hasHookups?: boolean; ada?: boolean; fcfs?: boolean }[]; + }; const fc: FeatureCollection = { type: 'FeatureCollection', features: (items ?? []) @@ -566,7 +574,13 @@ export function MapExplorer({ .map((i) => ({ type: 'Feature' as const, geometry: { type: 'Point' as const, coordinates: [i.lng, i.lat] }, - properties: { id: i.id ?? i.parkCode ?? '', name: i.name, parkCode: i.parkCode ?? '', icon: markerImageId(poiIcon(key)) }, + properties: { + id: i.id ?? i.parkCode ?? '', name: i.name, parkCode: i.parkCode ?? '', icon: markerImageId(poiIcon(key)), + // Camp facets for the camp-lens (Phase 4) — only the campgrounds layer carries them. + ...(key === 'campgrounds' + ? { free: !!i.free, dispersed: !!i.dispersed, hasHookups: !!i.hasHookups, ada: !!i.ada, fcfs: !!i.fcfs } + : {}), + }, })), }; (map.getSource(`poi-${key}`) as GeoJSONSource | undefined)?.setData(fc); @@ -683,6 +697,14 @@ export function MapExplorer({ if (map) applyLensRef.current?.(map); } + // Switch the campground lens (Phase 4): recolor poi-campgrounds + persist for re-installs/remounts. + function changeCampLens(next: CampLensKey) { + campLensRef.current = next; + setCampLens(next); + const map = mapRef.current; + if (map) applyCampLensRef.current?.(map); + } + useEffect(() => { if (!ref.current) return; registerMapProtocols(); @@ -728,6 +750,15 @@ export function MapExplorer({ } applyLensRef.current = applyLens; + // Camp-lens (Phase 4): recolor the unclustered poi-campgrounds layer by a camp facet, or fall back to + // the agency-brown default for 'none'. Re-applied on every (re)install like applyLens. + function applyCampLens(map: MlMap) { + if (!map.getLayer('poi-campgrounds')) return; + const expr = campLensColorExpr(campLensRef.current, colorMode) as unknown as ExpressionSpecification | null; + map.setPaintProperty('poi-campgrounds', 'circle-color', expr ?? poiColor('campgrounds', colorMode)); + } + applyCampLensRef.current = applyCampLens; + // One POI layer "group" (#11): clustered like parks (so dense areas collapse instead of a dot field), // in the layer's distinct color (#2). Three layers per key — the unclustered points keep the bare // `poi-${key}` id so the existing toggle + popup handler still target them. cluster-count needs a @@ -857,6 +888,7 @@ export function MapExplorer({ loadConnections(map); // Apply the active recolor (conditions > lens > designation) so a basemap swap / remount keeps it. applyLens(map); + applyCampLens(map); // camp-lens survives the same way (Phase 4) // Re-apply "your map" mode visibility + data after a (re)install (#6). applyMode(map); } @@ -1118,6 +1150,33 @@ export function MapExplorer({ + {/* Campground lens (Phase 4): recolor the campgrounds layer by a camp facet — shown only when that + layer is on. "Free & dispersed on the map," the feature plan's reach goal. */} + {enabled.campgrounds ? ( + + + Color campgrounds by + + + changeCampLens(e.currentTarget.value as CampLensKey)}> + {CAMP_LENSES.map((l) => ( + + ))} + + + + {campLensLegend(campLens, colorMode).length ? ( + + {campLensLegend(campLens, colorMode).map((e) => ( + + + {e.label} + + ))} + + ) : null} + + ) : null} {/* Condition-aware mode (#4): recolor parks by "good to visit?" for tonight / this weekend. */} diff --git a/components/SiteNav.tsx b/components/SiteNav.tsx index dbc5df5..53adefb 100644 --- a/components/SiteNav.tsx +++ b/components/SiteNav.tsx @@ -29,6 +29,7 @@ const LINKS = [ { href: '/map', label: 'Map' }, { href: '/graph', label: 'Graph' }, { href: '/trails', label: 'Trails' }, + { href: '/campgrounds', label: 'Campgrounds' }, { href: '/learn', label: 'Learn' }, { href: '/plan', label: 'Plan' }, ]; diff --git a/components/campgrounds/CampgroundCard.tsx b/components/campgrounds/CampgroundCard.tsx new file mode 100644 index 0000000..ee40063 --- /dev/null +++ b/components/campgrounds/CampgroundCard.tsx @@ -0,0 +1,188 @@ +import { Badge, Box, Card, HStack, Icon, Stack, Text, Link as CLink } from '@chakra-ui/react'; +import NextLink from 'next/link'; +import { + LuMapPin, + LuPlug, + LuSignal, + LuDroplets, + LuShowerHead, + LuTruck, + LuAccessibility, + LuPawPrint, + LuMoon, + LuTentTree, +} from 'react-icons/lu'; +import type { CampgroundSummary } from '../../lib/campgrounds'; + +/** Managing-agency → accent-bar color (mirrors TrailCard's DIFF_COLOR). */ +const AGENCY_COLOR: Record = { + NPS: 'pine.500', + USFS: 'green.600', + BLM: 'sand.500', + USACE: 'blue.500', + STATE: 'trail.500', + PRIVATE: 'purple.500', +}; + +const AGENCY_LABEL: Record = { + NPS: 'NPS', + USFS: 'Forest Service', + BLM: 'BLM', + USACE: 'Army Corps', + STATE: 'State', + PRIVATE: 'Private', +}; + +/** Data-provenance label by `source` (NOT the managing agency). */ +const SOURCE_LABEL: Record = { + 'nps+ridb': 'NPS + RIDB', + ridb: 'Recreation.gov', + usfs: 'USFS GIS', + osm: 'OSM (ODbL)', + overture: 'Overture', + state: 'State system', + nps: 'NPS', +}; + +export interface CampAvailabilityChip { + sitesOpen: number | null; + total: number | null; + state: 'ok' | 'unavailable'; +} + +/** + * The availability chip is the one heavily-nullable element. Absence of availability is shown as + * "unknown → go verify", NEVER as a positive (open/green) or false-negative (closed/red): + * undefined → no chip (dates not queried) + * state 'unavailable' / sitesOpen null → muted "Check on recreation.gov ↗" + * sitesOpen === 0 → "Booked out" + * sitesOpen > 0 → "{open} of {total} open" + */ +function AvailabilityChip({ + availability, + bookingUrl, +}: { + availability?: CampAvailabilityChip; + bookingUrl: string | null; +}) { + if (!availability) return null; + if (availability.state === 'unavailable' || availability.sitesOpen == null) { + return bookingUrl ? ( + + Check on recreation.gov ↗ + + ) : ( + + Availability unknown + + ); + } + if (availability.sitesOpen === 0) { + return Booked out; + } + return ( + + {availability.sitesOpen}{availability.total != null ? ` of ${availability.total}` : ''} open + + ); +} + +/** + * Campground finder card (Campgrounds feature). Metadata-driven, mirroring TrailCard: a single link to the + * detail page (id carries colons → encoded here, decoded in the route). Server component — actions + * (Set Watch / Book / Add to trip) live on the detail page. Availability is always nullable (see above). + */ +export function CampgroundCard({ + cg, + availability, +}: { + cg: CampgroundSummary; + availability?: CampAvailabilityChip; +}) { + const bar = AGENCY_COLOR[cg.agency ?? ''] ?? 'border.emphasized'; + const agencyLabel = AGENCY_LABEL[cg.agency ?? ''] ?? cg.agency ?? 'Campground'; + const where = cg.parkName ?? cg.recAreaName ?? null; + const confidence = cg.dataConfidence ?? (cg.source === 'nps+ridb' ? 'high' : 'medium'); + const sourceLabel = SOURCE_LABEL[cg.source] ?? 'NPS'; + + return ( + + + + + + + + + {cg.name} + + {agencyLabel} + + {where ? ( + + + {where} + + ) : null} + + + + {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}` : ''} + + ) : null} + {cg.distanceMiles != null ? ( + {cg.distanceMiles} mi + ) : null} + {cg.free ? ( + Free + ) : cg.feeUSD != null ? ( + ${cg.feeUSD}/night + ) : null} + + + {/* Amenity icon row — only those present. */} + + {cg.hasHookups ? ( + {cg.maxAmps ? `${cg.maxAmps}A` : 'Hookups'} + ) : null} + {cg.dumpStation ? Dump : null} + {cg.showers ? : null} + {cg.drinkingWater ? : null} + {cg.cellReception ? : null} + + + {cg.ada || cg.petsAllowed || cg.darkSky || cg.dispersed ? ( + + {cg.ada ? ( + + ADA + + ) : null} + {cg.petsAllowed ? ( + Pets + ) : null} + {cg.darkSky ? ( + Dark sky + ) : null} + {cg.dispersed ? ( + Dispersed + ) : null} + + ) : null} + + + + + {sourceLabel} · {confidence} confidence · verify before you book + + + + + + ); +} diff --git a/components/campgrounds/charts/CampgroundCompare.tsx b/components/campgrounds/charts/CampgroundCompare.tsx new file mode 100644 index 0000000..d0056c4 --- /dev/null +++ b/components/campgrounds/charts/CampgroundCompare.tsx @@ -0,0 +1,45 @@ +'use client'; +import { Chart, useChart } from '@chakra-ui/charts'; +import { PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart, Tooltip, Legend } from 'recharts'; +import { ChartCard } from '../../parks/charts/ChartCard'; +import type { CompareDatum } from '../../../lib/camp-charts'; + +const SERIES_COLORS = ['pine.solid', 'trail.solid', 'sand.solid', 'blue.solid']; + +/** + * Campground comparison radar (Campgrounds feature, Phase 3 viz) — overlays 2–4 campgrounds across the fixed + * 0–100 axes (amenities, affordability, hookups, accessibility, size, connectivity, dark sky, booking ease). + * Pure-data-in (the `campgroundCompareData` shaper); booking-ease greys for campgrounds with unknown difficulty. + */ +export function CampgroundCompare({ data, campgrounds }: { data: CompareDatum[]; campgrounds: { key: string; name: string }[] }) { + const chart = useChart({ + data, + series: campgrounds.map((c, i) => ({ name: c.key, label: c.name, color: SERIES_COLORS[i % SERIES_COLORS.length] })), + }); + if (!data.length || !campgrounds.length) return null; + return ( + + + + + + + } /> + } /> + {chart.series.map((s) => ( + + ))} + + + + ); +} diff --git a/components/chat/Cards.tsx b/components/chat/Cards.tsx index e85281b..5c6db11 100644 --- a/components/chat/Cards.tsx +++ b/components/chat/Cards.tsx @@ -82,6 +82,14 @@ export function ToolCard({ kind, data: raw, onAnswer }: { kind: string; data: un return ; case 'loop_card': return ; + case 'campground_card': + return ; + case 'availability_card': + return ; + case 'camp_watch_card': + return ; + case 'booking_window_card': + return ; case 'graph_result': return ; case 'why_this': @@ -817,6 +825,206 @@ function TrailDetailCard({ data }: { data: Record }) { ); } +// ── Campgrounds feature chat cards ──────────────────────────────────────────────────────────────── + +interface CampgroundCardView { + id: string; + name: string; + agency?: string | null; + source?: string | null; + parkName?: string | null; + recAreaName?: string | null; + totalSites?: number | null; + free?: boolean; + feeUSD?: number | null; + hasHookups?: boolean; + maxAmps?: number | null; + ada?: boolean; + dispersed?: boolean; + dataConfidence?: string | null; +} + +const AGENCY_BADGE: Record = { NPS: 'pine', USFS: 'green', BLM: 'sand', USACE: 'blue', STATE: 'trail', PRIVATE: 'purple' }; + +function campMetaLine(c: CampgroundCardView): string { + return [ + c.totalSites != null ? `${c.totalSites} sites` : null, + c.free ? 'free' : c.feeUSD != null ? `$${c.feeUSD}/night` : null, + c.hasHookups ? (c.maxAmps ? `${c.maxAmps}A hookups` : 'hookups') : null, + ] + .filter(Boolean) + .join(' · '); +} + +/** 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 }) { + const single = data.campground as CampgroundCardView | undefined; + const pendingAdd = data.pendingAdd as { stopLabel: string; date?: string | null } | undefined; + const addedTo = data.addedTo as { stopLabel: string; date?: string | null } | undefined; + if (single) { + return ( + + + + + {single.name} + + {single.agency ? {single.agency} : null} + {single.dispersed ? dispersed : null} + + {single.parkName ?? single.recAreaName ? {single.parkName ?? single.recAreaName} : null} + {campMetaLine(single) ? {campMetaLine(single)} : null} + {pendingAdd ? ( + + Set {single.name} as the lodging for {pendingAdd.stopLabel}{pendingAdd.date ? ` (${pendingAdd.date})` : ''}? Say the word and I'll save it. (I won't book it — you'll reserve on recreation.gov.) + + ) : null} + {addedTo ? ( + + Added as lodging for {addedTo.stopLabel}{addedTo.date ? ` (${addedTo.date})` : ''}. Reserve it on recreation.gov when you're ready. + + ) : null} + + + ); + } + + const camps = (data.campgrounds ?? []) as CampgroundCardView[]; + const applied = (data.appliedPreferences as string[] | undefined)?.filter(Boolean) ?? []; + const total = typeof data.total === 'number' ? data.total : camps.length; + if (!camps.length) return No campgrounds matched those filters.; + return ( + + {applied.length ? Narrowed to your camp preferences: {applied.join(' · ')} : null} + {camps.map((c) => ( + + + + + + {c.name} + {c.agency ? {c.agency} : null} + {c.free ? free : null} + {c.ada ? ADA : null} + + {c.parkName ?? c.recAreaName ? {c.parkName ?? c.recAreaName} : null} + {campMetaLine(c) ? {campMetaLine(c)} : null} + + + + + ))} + {total > camps.length ? Showing {camps.length} of {total} — ask me to narrow further. : null} + + ); +} + +/** Per-campground availability in chat (check_campsite_availability → availability_card). HEAVILY nullable: + * renders the honest "couldn't reach recreation.gov — check there ↗" degrade rather than a misleading grid. */ +function AvailabilityCard({ data }: { data: Record }) { + const name = (data.name as string) ?? 'this campground'; + const degraded = data.degraded === true; + const bookingUrl = data.bookingUrl as string | null | undefined; + const nights = (data.nights ?? []) as { date: string; sitesOpen: number }[]; + return ( + + + {name} — availability + {degraded || !nights.length ? ( + + I couldn't pull live availability (it's a best-effort, unofficial feed).{' '} + {bookingUrl ? ( + Check on recreation.gov ↗ + ) : ( + 'Check on recreation.gov.' + )} + + ) : ( + <> + + {nights.map((n) => ( + + {n.date} + 0 ? 'pine' : 'red'} variant={n.sitesOpen > 0 ? 'solid' : 'surface'}> + {n.sitesOpen > 0 ? `${n.sitesOpen} open` : 'booked'} + + + ))} + + + Best-effort — verify on{' '} + {bookingUrl ? recreation.gov ↗ : 'recreation.gov'} before you rely on it. I never book. + + + )} + + + ); +} + +interface CampWatchView { + id: string; + label?: string | null; + startDate: string; + endDate: string; + siteType?: string | null; + campgroundIds?: string[]; +} + +/** Camp Watch list in chat (set/list/clear_camp_watch → camp_watch_card). Renders even when empty. */ +function CampWatchCard({ data }: { data: Record }) { + const watches = (data.watches ?? []) as CampWatchView[]; + const justCreated = data.justCreated as string | undefined; + if (!watches.length) return You have no Camp Watches set. I can watch a campground or park for cancellations — just ask.; + return ( + + + Camp Watches ({watches.length}/{WATCH_CAP}) + + {watches.map((w) => ( + + {w.id === justCreated ? new : null} + {w.label ?? `${(w.campgroundIds ?? []).length} campground(s)`} + {w.startDate} → {w.endDate}{w.siteType && w.siteType !== 'any' ? ` · ${w.siteType}` : ''} + + ))} + + I'll alert you when a matching site frees up. I watch only — booking happens on recreation.gov. + + + ); +} + +/** Booking-window intelligence in chat (campground_booking_window → booking_window_card). */ +function BookingWindowCard({ data }: { data: Record }) { + const name = (data.name as string) ?? 'this campground'; + const windowOpensOn = data.windowOpensOn as string | undefined; + const opensInPast = data.opensInPast === true; + const daysUntilOpen = data.daysUntilOpen as number | undefined; + const booksOutDays = data.booksOutDays as number | null | undefined; + const arrivalDate = data.arrivalDate as string | undefined; + const bookingUrl = data.bookingUrl as string | null | undefined; + return ( + + + {name} — booking window + {booksOutDays != null ? Typically books out ~{booksOutDays} days ahead. : null} + {windowOpensOn ? ( + + For arrival {arrivalDate ?? ''}, the reservation window {opensInPast ? 'is already open (opened' : 'opens'}{' '} + {windowOpensOn}{opensInPast ? ')' : daysUntilOpen != null ? ` — in ${daysUntilOpen} day${daysUntilOpen === 1 ? '' : 's'}` : ''}. + + ) : null} + + Want me to set a Camp Watch reminder for the morning it opens?{' '} + {bookingUrl ? Open on recreation.gov ↗ : null} + + + + ); +} + interface SuggestedLoopView { trailIds: string[]; names: string[]; diff --git a/components/plan/TripBuilder.tsx b/components/plan/TripBuilder.tsx index 26d7358..0bd3355 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 } from 'react-icons/lu'; +import { LuX, LuGripVertical, LuFootprints, LuTriangleAlert, LuTentTree } from 'react-icons/lu'; import { MapTripCanvas } from './MapTripCanvas'; import { ParkSearchInput } from './ParkSearchInput'; import { toast } from '../../lib/toast'; @@ -36,6 +36,7 @@ interface Stop { lat?: number | null; lng?: number | null; hikes?: TripHike[]; + lodging?: { id: string; name: string; feeUSD?: number | null; reservationUrl?: string | null } | null; driveTo?: { miles: number; minutes: number; source: string } | null; } @@ -223,6 +224,18 @@ export function TripBuilder() { if (rateLimited(res)) return; if (res.ok) applyMutation(await res.json()); } + // Detach lodging from a stop (Campgrounds feature) — `(:Stop)-[:STAYS_AT]->(:Campground)`. Adding lodging + // happens via the ranger or the campground detail page; the builder shows it and lets you drop it. + async function removeLodging(stopId: string, campgroundId: string) { + if (!trip) return; + const res = await fetch(`/api/trips/${trip.id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ op: 'excludeCampground', stopId, campgroundId }), + }); + if (rateLimited(res)) return; + if (res.ok) applyMutation(await res.json()); + } // Persist a drag-reorder on drop (#9). Skip the round-trip if the order didn't actually change. The 30/60s // trip-mutation cap is server-side; a 429 surfaces as a toast and the next openTrip resyncs the true order. async function persistReorder() { @@ -514,6 +527,24 @@ export function TripBuilder() { ))} ) : null} + {/* Lodging nested under this stop (Campgrounds feature) — add via the ranger or a campground page; remove here. */} + {s.lodging ? ( + + + Sleeping here + + + + + {s.lodging.name} + {s.lodging.feeUSD != null ? · ${s.lodging.feeUSD}/night : null} + + removeLodging(s.id, s.lodging!.id)}> + + + + + ) : null} ); })} diff --git a/db/migrations/026_campgrounds_multiagency.cypher b/db/migrations/026_campgrounds_multiagency.cypher new file mode 100644 index 0000000..7545008 --- /dev/null +++ b/db/migrations/026_campgrounds_multiagency.cypher @@ -0,0 +1,39 @@ +// Multi-agency campgrounds & site-level campsites (Campgrounds feature, Phase 1). Adds :Campsite / +// :Agency / :RecArea and the federation key index on :Campground.ridbId so the RIDB import unifies with +// existing NPS campgrounds in place (one node per facility, never a duplicate). campground_id + +// campground_location (001) and the F3 inventory RANGE indexes (013) already exist and are reused. +// Idempotent (IF NOT EXISTS); the runner (db/migrate.ts) splits on ';' and skips '//' lines. + +// ─── Uniqueness constraints: new natural keys ────────────────────────────────── +CREATE CONSTRAINT campsite_id IF NOT EXISTS FOR (s:Campsite) REQUIRE s.id IS UNIQUE; +CREATE CONSTRAINT agency_id IF NOT EXISTS FOR (a:Agency) REQUIRE a.id IS UNIQUE; +CREATE CONSTRAINT recarea_id IF NOT EXISTS FOR (r:RecArea) REQUIRE r.id IS UNIQUE; + +// ─── Federation key: unify an NPS :Campground with its RIDB facility by ridbId ── +// sync-campgrounds-ridb OPTIONAL MATCHes (c:Campground {ridbId}) to merge in place; this index makes +// that lookup point-fast across ~3,600 facilities. +CREATE RANGE INDEX campground_ridbid IF NOT EXISTS FOR (c:Campground) ON (c.ridbId); + +// ─── Campground multi-agency discovery facets (searchCampgrounds predicates) ──── +CREATE RANGE INDEX campground_source IF NOT EXISTS FOR (c:Campground) ON (c.source); +CREATE RANGE INDEX campground_agency IF NOT EXISTS FOR (c:Campground) ON (c.agency); +CREATE RANGE INDEX campground_reservable IF NOT EXISTS FOR (c:Campground) ON (c.reservable); +CREATE RANGE INDEX campground_fcfs IF NOT EXISTS FOR (c:Campground) ON (c.fcfs); +CREATE RANGE INDEX campground_dispersed IF NOT EXISTS FOR (c:Campground) ON (c.dispersed); +CREATE RANGE INDEX campground_fee IF NOT EXISTS FOR (c:Campground) ON (c.feeUSD); +CREATE RANGE INDEX campground_pets IF NOT EXISTS FOR (c:Campground) ON (c.petsAllowed); +CREATE RANGE INDEX campground_rvmax IF NOT EXISTS FOR (c:Campground) ON (c.rvMaxLengthFt); + +// ─── Campsite point + range indexes (site-level filters) ─────────────────────── +// geometry holds a representative point() (pitch polygons live in Blob) so the point index works. +CREATE POINT INDEX campsite_location IF NOT EXISTS FOR (s:Campsite) ON (s.geometry); +CREATE RANGE INDEX campsite_campground IF NOT EXISTS FOR (s:Campsite) ON (s.campgroundId); +CREATE RANGE INDEX campsite_type IF NOT EXISTS FOR (s:Campsite) ON (s.type); +CREATE RANGE INDEX campsite_maxrv IF NOT EXISTS FOR (s:Campsite) ON (s.maxRvLengthFt); +CREATE RANGE INDEX campsite_amps IF NOT EXISTS FOR (s:Campsite) ON (s.electricAmps); +CREATE RANGE INDEX campsite_ada IF NOT EXISTS FOR (s:Campsite) ON (s.ada); +CREATE RANGE INDEX campsite_reservable IF NOT EXISTS FOR (s:Campsite) ON (s.reservable); + +// ─── Fulltext: campground name search (searchCampgrounds q=) + rec-area name search ───────────── +CREATE FULLTEXT INDEX campground_fulltext IF NOT EXISTS FOR (c:Campground) ON EACH [c.name]; +CREATE FULLTEXT INDEX recarea_fulltext IF NOT EXISTS FOR (r:RecArea) ON EACH [r.name]; diff --git a/db/migrations/027_camp_context.cypher b/db/migrations/027_camp_context.cypher new file mode 100644 index 0000000..ddbf52c --- /dev/null +++ b/db/migrations/027_camp_context.cypher @@ -0,0 +1,12 @@ +// Campground context graph (memory + Camp Watch). The per-user camp-preferences anchor backs the +// PREFERS_CAMP bridge (mirrors :TrailPrefs / migration 025), and :CampWatch holds cancellation-alert +// criteria + the poller's per-watch diff snapshot. Availability itself is never graph nodes — it's a +// rolling fetch cache + :CampWatch.lastSnapshot. All idempotent. + +// Per-user camp preferences anchor — (:User)-[:PREFERS_CAMP]->(:CampPrefs {userId}). +CREATE CONSTRAINT campprefs_user IF NOT EXISTS FOR (cp:CampPrefs) REQUIRE cp.userId IS UNIQUE; + +// Camp Watch — (:User)-[:WATCHING]->(:CampWatch {id}). The poller scans active, non-expired watches. +CREATE CONSTRAINT campwatch_id IF NOT EXISTS FOR (w:CampWatch) REQUIRE w.id IS UNIQUE; +CREATE INDEX campwatch_user IF NOT EXISTS FOR (w:CampWatch) ON (w.userId); +CREATE INDEX campwatch_active IF NOT EXISTS FOR (w:CampWatch) ON (w.active); diff --git a/lib/blob-campsites.ts b/lib/blob-campsites.ts new file mode 100644 index 0000000..9bc00e8 --- /dev/null +++ b/lib/blob-campsites.ts @@ -0,0 +1,60 @@ +import './server-guard'; // BLOB_READ_WRITE_TOKEN + fs; keep out of the client bundle +import type { FeatureCollection } from 'geojson'; + +/** + * Per-campground site/loop geometry storage — the exact mirror of blob-trails.ts. Pitch polygons (OSM + * `camp_pitch`, Phase 4) / USFS site points live in Vercel Blob when `BLOB_READ_WRITE_TOKEN` is set, else + * a local `public/campsites/` fallback. Geometry lives HERE — never in Neo4j; the readable URL is stashed + * on `:Campground.sitesGeoUrl`. RIDB-only campgrounds have no per-site geometry, so `sitesGeoUrl` stays + * null and the detail page falls back to the campsitesForCampground() list. + */ + +// Campground ids carry colons ('ridb:232449' / NPS GUIDs) — slugify for a safe path/filename. +const safe = (id: string) => id.replace(/[^a-z0-9]+/gi, '-').toLowerCase(); +const fileName = (id: string) => `${safe(id)}.geojson`; +const blobPath = (id: string) => `campsites/${fileName(id)}`; + +export function hasBlob(): boolean { + return !!process.env.BLOB_READ_WRITE_TOKEN; +} + +/** Write a campground's site FeatureCollection; returns its readable URL. */ +export async function putCampgroundSites(campgroundId: string, fc: FeatureCollection): Promise { + const body = JSON.stringify(fc); + if (hasBlob()) { + const { put } = await import('@vercel/blob'); + const res = await put(blobPath(campgroundId), body, { + access: 'public', + contentType: 'application/geo+json', + addRandomSuffix: false, + allowOverwrite: true, + }); + return res.url; + } + const { mkdir, writeFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const dir = join(process.cwd(), 'public', 'campsites'); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, fileName(campgroundId)), body, 'utf8'); + return `/campsites/${fileName(campgroundId)}`; +} + +/** Read a campground's site FeatureCollection back (for the detail map). Null on any miss. */ +export async function readCampgroundSites( + campgroundId: string, + url?: string | null, +): Promise { + try { + if (url && /^https?:\/\//.test(url)) { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) return null; + return (await res.json()) as FeatureCollection; + } + const { readFile } = await import('node:fs/promises'); + const { join } = await import('node:path'); + const txt = await readFile(join(process.cwd(), 'public', 'campsites', fileName(campgroundId)), 'utf8'); + return JSON.parse(txt) as FeatureCollection; + } catch { + return null; + } +} diff --git a/lib/bridges.test.ts b/lib/bridges.test.ts index c62e1d1..907c702 100644 --- a/lib/bridges.test.ts +++ b/lib/bridges.test.ts @@ -11,11 +11,59 @@ vi.mock('./tombstone', () => ({ isSuppressed: vi.fn(), })); -import { mergeConstraints, removeRequiredAmenity, type TravelConstraints } from './bridges'; +import { + mergeConstraints, + removeRequiredAmenity, + setCampPreferences, + setCampAmenityNeeds, + saveCampground, + CAMP_AMENITY_NAMES, + type TravelConstraints, +} from './bridges'; import { writeGraph } from './neo4j'; const NONE: TravelConstraints = { wheelchair: false, rvMaxLengthFt: null, requiredAmenities: [] }; +describe('camp memory bridges (Campgrounds feature)', () => { + const mockWrite = vi.mocked(writeGraph); + beforeEach(() => mockWrite.mockReset()); + + it('setCampPreferences uses the null-keeps-prior CASE idiom + passes nulls for unset fields', async () => { + mockWrite.mockResolvedValue([] as never); + await setCampPreferences('u1', { rig: 'rv', maxLengthFt: 28, hookups: '30amp' }); + const [cypher, params] = mockWrite.mock.calls[0] as [string, Record]; + expect(cypher).toContain('PREFERS_CAMP]->(cp:CampPrefs {userId:$userId})'); + expect(cypher).toContain('cp.rig = CASE WHEN $rig IS NULL THEN cp.rig ELSE $rig END'); + expect(cypher).toContain('toInteger($maxLengthFt)'); + expect(params).toMatchObject({ userId: 'u1', rig: 'rv', maxLengthFt: 28, hookups: '30amp' }); + // omitted fields are passed as null so the CASE keeps the saved value + expect(params).toMatchObject({ tentOk: null, ada: null, pets: null, quiet: null, budget: null }); + }); + + it('setCampAmenityNeeds keeps only canonical amen ids + returns them (drops junk)', async () => { + mockWrite.mockResolvedValue([] as never); + const applied = await setCampAmenityNeeds('u1', ['amen:hookup-30amp', 'amen:not-real', 'amen:dump-station']); + expect(applied).toEqual(['amen:hookup-30amp', 'amen:dump-station']); + const [cypher, params] = mockWrite.mock.calls[0] as [string, Record]; + expect(cypher).toContain('(u)-[:REQUIRES]->(am)'); + expect(params.ids).toEqual(['amen:hookup-30amp', 'amen:dump-station']); + expect(CAMP_AMENITY_NAMES['amen:hookup-30amp']).toBe('30-amp Hookup'); + }); + + it('setCampAmenityNeeds is a no-op (no write) when nothing is valid', async () => { + mockWrite.mockResolvedValue([] as never); + expect(await setCampAmenityNeeds('u1', ['garbage'])).toEqual([]); + expect(mockWrite).not.toHaveBeenCalled(); + }); + + it('saveCampground returns true only when the campground exists (a row comes back)', async () => { + mockWrite.mockResolvedValueOnce([{ ok: true }] as never); + expect(await saveCampground('u1', 'cg-canyon')).toBe(true); + mockWrite.mockResolvedValueOnce([] as never); + expect(await saveCampground('u1', 'nope')).toBe(false); + }); +}); + describe('mergeConstraints (R5 §2.2 — trip-scoped over durable)', () => { it('applies a one-trip need when the user has none saved (the over-persistence fix)', () => { const merged = mergeConstraints(NONE, { wheelchair: true }); diff --git a/lib/bridges.ts b/lib/bridges.ts index 0475f69..6cb37d0 100644 --- a/lib/bridges.ts +++ b/lib/bridges.ts @@ -330,6 +330,132 @@ export async function unsaveTrail(userId: string, trailId: string, kind: TrailSa ); } +// ── Camp memory (Campgrounds feature, Phase 3): preferences + amenity needs + saved campgrounds ──────── +export interface CampPreferences { + rig: string | null; // 'tent' | 'rv' | 'trailer' | 'van' | 'cabin' + maxLengthFt: number | null; + hookups: string | null; // 'none' | '30amp' | '50amp' | 'full' + tentOk: boolean; + ada: boolean; + pets: boolean; + quiet: boolean; + budget: number | null; // max $/night +} + +/** + * Camp preferences — a single per-user `(:User)-[:PREFERS_CAMP]->(:CampPrefs {userId})` anchor (mirrors + * `setTrailPreferences`; the `:CampPrefs` UNIQUE constraint is migration 027). Partial: a null field keeps + * the saved value. The ranger sets these once (scope-confirmed); find_campgrounds honors them. + */ +export async function setCampPreferences( + userId: string, + p: { + rig?: string | null; + maxLengthFt?: number | null; + hookups?: string | null; + tentOk?: boolean | null; + ada?: boolean | null; + pets?: boolean | null; + quiet?: boolean | null; + budget?: number | null; + }, +): Promise { + await writeGraph( + `MERGE (u:User {userId:$userId}) + MERGE (u)-[:PREFERS_CAMP]->(cp:CampPrefs {userId:$userId}) + SET cp.rig = CASE WHEN $rig IS NULL THEN cp.rig ELSE $rig END, + cp.maxLengthFt = CASE WHEN $maxLengthFt IS NULL THEN cp.maxLengthFt ELSE toInteger($maxLengthFt) END, + cp.hookups = CASE WHEN $hookups IS NULL THEN cp.hookups ELSE $hookups END, + cp.tentOk = CASE WHEN $tentOk IS NULL THEN cp.tentOk ELSE $tentOk END, + cp.ada = CASE WHEN $ada IS NULL THEN cp.ada ELSE $ada END, + cp.pets = CASE WHEN $pets IS NULL THEN cp.pets ELSE $pets END, + cp.quiet = CASE WHEN $quiet IS NULL THEN cp.quiet ELSE $quiet END, + cp.budget = CASE WHEN $budget IS NULL THEN cp.budget ELSE toFloat($budget) END, + cp.at = datetime()`, + { + userId, + rig: p.rig ?? null, + maxLengthFt: p.maxLengthFt ?? null, + hookups: p.hookups ?? null, + tentOk: p.tentOk ?? null, + ada: p.ada ?? null, + pets: p.pets ?? null, + quiet: p.quiet ?? null, + budget: p.budget ?? null, + }, + ); +} + +export async function getCampPreferences(userId: string): Promise { + const rows = await readGraph( + `MATCH (u:User {userId:$userId}) + OPTIONAL MATCH (u)-[:PREFERS_CAMP]->(cp:CampPrefs) + RETURN cp.rig AS rig, cp.maxLengthFt AS maxLengthFt, cp.hookups AS hookups, + coalesce(cp.tentOk, false) AS tentOk, coalesce(cp.ada, false) AS ada, + coalesce(cp.pets, false) AS pets, coalesce(cp.quiet, false) AS quiet, cp.budget AS budget`, + { userId }, + ); + const r = rows[0]; + return { + rig: r?.rig ?? null, + maxLengthFt: r?.maxLengthFt ?? null, + hookups: r?.hookups ?? null, + tentOk: r?.tentOk ?? false, + ada: r?.ada ?? false, + pets: r?.pets ?? false, + quiet: r?.quiet ?? false, + budget: r?.budget ?? null, + }; +} + +export async function clearCampPreferences(userId: string): Promise { + await writeGraph(`MATCH (:User {userId:$userId})-[:PREFERS_CAMP]->(cp:CampPrefs) DETACH DELETE cp`, { userId }); +} + +/** Canonical camp-amenity ids the user can require (reuses the F5 REQUIRES → :Amenity bridge so search honors them). */ +export const CAMP_AMENITY_NAMES: Record = { + 'amen:hookup-30amp': '30-amp Hookup', + 'amen:hookup-50amp': '50-amp Hookup', + 'amen:full-hookup': 'Full Hookup', + 'amen:dump-station': 'Dump Station', + 'amen:shower': 'Shower', + 'amen:potable-water': 'Potable Water', +}; +export const CAMP_AMENITY_IDS = Object.keys(CAMP_AMENITY_NAMES); + +/** Record camp-amenity needs as `(:User)-[:REQUIRES]->(:Amenity {camp})` (mirror of setAccessibilityNeeds). */ +export async function setCampAmenityNeeds(userId: string, featureIds: string[]): Promise { + const valid = [...new Set(featureIds)].filter((id) => id in CAMP_AMENITY_NAMES); + if (!valid.length) return []; + await writeGraph( + `MERGE (u:User {userId: $userId}) + WITH u UNWIND $ids AS aid + MERGE (am:Amenity {id: aid}) ON CREATE SET am.name = $names[aid], am.camp = true + MERGE (u)-[:REQUIRES]->(am)`, + { userId, ids: valid, names: CAMP_AMENITY_NAMES }, + ); + return valid; +} + +/** Save a campground: `(:User)-[:SAVED]->(:Campground)` (mirror of saveTrail). False on unknown id. */ +export async function saveCampground(userId: string, campgroundId: string): Promise { + const rows = await writeGraph<{ ok: boolean }>( + `MATCH (c:Campground {id:$campgroundId}) + MERGE (u:User {userId:$userId}) + MERGE (u)-[r:SAVED]->(c) SET r.at = datetime() + RETURN true AS ok`, + { userId, campgroundId }, + ); + return rows.length > 0; +} + +export async function unsaveCampground(userId: string, campgroundId: string): Promise { + await writeGraph( + `MATCH (:User {userId:$userId})-[r:SAVED]->(:Campground {id:$campgroundId}) DELETE r`, + { userId, campgroundId }, + ); +} + /** 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-alert-email.test.ts b/lib/camp-alert-email.test.ts new file mode 100644 index 0000000..ef88a3c --- /dev/null +++ b/lib/camp-alert-email.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { summarizeHits, campAlertEmailHtml, type CampAlertHit } from './camp-alert-email'; +import type { CampWatch } from './camp-watches'; + +const watch: CampWatch = { + id: 'w1', userId: 'u1', campgroundIds: ['cg-canyon'], recAreaId: null, + startDate: '2026-07-03', endDate: '2026-07-05', nights: 2, minNights: null, + siteType: 'tent', weekendOnly: false, hookups: null, ada: false, active: true, + lastNotifiedAt: null, lastSnapshot: null, label: 'North Rim weekend', createdAt: null, +}; + +describe('summarizeHits', () => { + it('collapses per-(campground,date) and sorts dates', () => { + const hits: CampAlertHit[] = [ + { campgroundId: 'cg-canyon', campgroundName: 'Canyon', date: '2026-07-05', bookingUrl: 'u' }, + { campgroundId: 'cg-canyon', campgroundName: 'Canyon', date: '2026-07-03', bookingUrl: 'u' }, + { campgroundId: 'cg-other', campgroundName: 'Other', date: '2026-07-03', bookingUrl: 'v' }, + ]; + const out = summarizeHits(hits); + expect(out).toHaveLength(2); + const canyon = out.find((g) => g.campgroundName === 'Canyon')!; + expect(canyon.dates).toEqual(['2026-07-03', '2026-07-05']); + }); +}); + +describe('campAlertEmailHtml', () => { + it('includes a Book now link, the watch label, and the unofficial-source disclaimer', () => { + const html = campAlertEmailHtml(watch, [{ campgroundId: 'cg-canyon', campgroundName: 'Canyon', date: '2026-07-03', bookingUrl: 'https://recreation.gov/x' }], 'https://trailgraph.app/api/unsubscribe?token=t'); + expect(html).toContain('Book now ↗'); + expect(html).toContain('North Rim weekend'); + expect(html).toContain('https://recreation.gov/x'); + expect(html).toContain('unofficial'); + expect(html).toContain('Unsubscribe'); + }); + it('escapes HTML in campground names', () => { + const html = campAlertEmailHtml(watch, [{ campgroundId: 'x', campgroundName: '', date: '2026-07-03', bookingUrl: 'u' }], 'u'); + expect(html).not.toContain('