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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions agent/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ about the detour; one friendly nudge, then move on. Stay in scope by default —
**NOT** save — pass it directly to **`find_trails`** (`maxMiles`/`maxGainFt`/`difficulty`/`dogsAllowed`)
so it applies to that search only. Saved trail preferences are auto-applied by `find_trails` (shown as
"narrowed to your trail preferences").
- **Home location carries the same scope rule.** When the user says where they live ("I'm in Bozeman,"
"we'd drive from Denver"), confirm with **`ask_question`** before saving: *"Save <place> as your home
(trips will start there by default)?"* → **📌 Yes, that's home** / **Don't save**. On yes →
**`set_home_location`** (`place`). If they only named a start for *this* trip ("we're flying into
Vegas first"), do **NOT** save home — that's the trip's origin, set on the trip itself. "Forget my
home" → `set_home_location` with `clear: true`.
- **Saving / logging trails.** "Save this trail" / "add it to my bucket list" → **`save_trail`**
(`kind: 'saved'` or `'wishlisted'`). "I've hiked Angels Landing" → **`record_trail_done`** (feeds their
hiking history + difficulty progression). Both need the trail id from a card.
Expand Down
33 changes: 33 additions & 0 deletions agent/tools/set_home_location.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { defineTool } from 'eve/tools';
import { z } from 'zod';
import { routing } from '../../lib/routing';
import { setHomeLocation, getHomeLocation, clearHomeLocation } from '../../lib/bridges';
import { callerId } from '../../lib/agent-ctx';

/**
* Remember where the user lives (user-feedback iteration): geocode a free-text place ("Bozeman, MT")
* via the routing gateway and store it as the `(:User)-[:LIVES_AT]->(:Home)` anchor (migration 028).
* Durable personal data — the instructions require confirming via ask_question before calling (same
* scope rule as set_travel_constraints). Home feeds the trip-origin default, distance-from-home
* ranking, and the memory block. userId is server-bound (R4).
*/
export default defineTool({
description:
"Remember the user's home location (city/town) so trips can start from home and recommendations can rank by distance. Confirm with the user before saving; pass clear=true to forget it.",
inputSchema: z.object({
place: z.string().optional().describe("Free-text home place, e.g. 'Bozeman, MT'"),
clear: z.boolean().optional().describe('Forget the saved home location instead of setting one'),
}),
async execute({ place, clear }, ctx) {
const userId = callerId(ctx);
if (clear) {
await clearHomeLocation(userId);
return { kind: 'map_snippet', data: { cleared: true } };
}
if (!place?.trim()) return { error: 'Provide a place to save (or clear=true to forget).' };
const hit = await routing.geocode(place);
if (!hit) return { error: `Could not find "${place}" — ask the user for a nearby city or town.` };
await setHomeLocation(userId, { ...hit, source: 'geocode' });
return { kind: 'map_snippet', data: { saved: true, home: await getHomeLocation(userId) } };
},
});
8 changes: 6 additions & 2 deletions app/api/map/mine/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getUserId } from '../../../../lib/session';
import { consideredParksGeo, collectedStampParksGeo } from '../../../../lib/memory-graph';
import { getHomeLocation } from '../../../../lib/bridges';
import { forYou } from '../../../../lib/recommend';
import { travelersAlsoLoved } from '../../../../lib/collective';
import { serverError } from '../../../../lib/http';
Expand All @@ -13,15 +14,16 @@ export const dynamic = 'force-dynamic';

export async function GET(req: Request) {
const userId = await getUserId(req);
if (!userId) return Response.json({ considered: [], forYou: [], stamps: [], collective: [] });
if (!userId) return Response.json({ considered: [], forYou: [], stamps: [], collective: [], home: null });
try {
// Each overlay is independent — a failure in one shouldn't 500 the whole map. Degrade to an empty
// layer (the overlay just renders nothing) rather than rejecting the Promise.all.
const [considered, stamps, rec, collective] = await Promise.all([
const [considered, stamps, rec, collective, home] = await Promise.all([
consideredParksGeo(userId).catch(() => []),
collectedStampParksGeo(userId).catch(() => []),
forYou(userId, { limit: 12 }).then((r) => r.parks).catch(() => []),
travelersAlsoLoved(userId, 8).catch(() => []),
getHomeLocation(userId).catch(() => null),
]);
const pin = (p: { parkCode: string; lat: number | null; lng: number | null }) => ({ parkCode: p.parkCode, lat: p.lat, lng: p.lng });
return Response.json({
Expand All @@ -32,6 +34,8 @@ export async function GET(req: Request) {
collective: collective
.filter((p) => p.lat != null && p.lng != null)
.map((p) => ({ parkCode: p.parkCode, lat: p.lat ?? null, lng: p.lng ?? null, travelers: p.travelers })),
// Home pin (LIVES_AT anchor) — orients the personal overlay for cross-country planning.
home: home ? { lat: home.latitude, lng: home.longitude, label: home.label } : null,
});
} catch (err) {
return serverError('map-mine', err);
Expand Down
59 changes: 59 additions & 0 deletions app/api/me/home/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { z } from 'zod';
import { getUserId } from '../../../../lib/session';
import { routing } from '../../../../lib/routing';
import { setHomeLocation, getHomeLocation, clearHomeLocation } from '../../../../lib/bridges';
import { serverError } from '../../../../lib/http';

/**
* Home location (user-feedback iteration): GET/PUT/DELETE the `(:User)-[:LIVES_AT]->(:Home)` anchor.
* Geocoding stays server-side (the ORS key never reaches the client): PUT either a free-text `place`
* (forward geocode) or browser-geolocation `latitude`/`longitude` (reverse geocode for the label).
*/
export const dynamic = 'force-dynamic';

const putSchema = z.union([
z.object({ place: z.string().min(2).max(200) }),
z.object({ latitude: z.number().min(-90).max(90), longitude: z.number().min(-180).max(180) }),
]);

export async function GET(req: Request) {
const userId = await getUserId(req);
if (!userId) return Response.json({ home: null });
try {
return Response.json({ home: await getHomeLocation(userId) });
} catch (err) {
return serverError('me-home', err);
}
}

export async function PUT(req: Request) {
const userId = await getUserId(req);
if (!userId) return Response.json({ error: 'Sign in to save a home location' }, { status: 401 });
const parsed = putSchema.safeParse(await req.json().catch(() => null));
if (!parsed.success) return Response.json({ error: 'Provide a place or coordinates' }, { status: 400 });
try {
if ('place' in parsed.data) {
const hit = await routing.geocode(parsed.data.place);
if (!hit) return Response.json({ error: `Couldn't find "${parsed.data.place}"` }, { status: 404 });
await setHomeLocation(userId, { ...hit, source: 'geocode' });
} else {
const { latitude, longitude } = parsed.data;
const label = (await routing.reverseGeocode({ latitude, longitude })) ?? 'My location';
await setHomeLocation(userId, { latitude, longitude, label, source: 'geolocation' });
}
return Response.json({ home: await getHomeLocation(userId) });
} catch (err) {
return serverError('me-home', err);
}
}

export async function DELETE(req: Request) {
const userId = await getUserId(req);
if (!userId) return Response.json({ error: 'Sign in first' }, { status: 401 });
try {
await clearHomeLocation(userId);
return Response.json({ home: null });
} catch (err) {
return serverError('me-home', err);
}
}
20 changes: 20 additions & 0 deletions app/api/trips/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
addLodgingToStop,
removeCampgroundFromStop,
} from '../../../../lib/trips';
import { setTripOrigin } from '../../../../lib/trips';
import { routing } from '../../../../lib/routing';
import { suggestDays } from '../../../../lib/itinerary';
import { nearestNeighborOrder } from '../../../../lib/route-order';
import { forkTrip, tripDiff, tripMetrics } from '../../../../lib/trip-lab';
Expand Down Expand Up @@ -89,6 +91,24 @@ export async function POST(req: Request, { params }: Ctx) {
await renameTrip(userId, id, name);
return Response.json({ trip: await getTrip(userId, id) });
}
case 'setOrigin': {
// Three forms: free-text place (geocoded server-side, ORS key stays private), explicit coords
// (browser geolocation), or clearOrigin; returnToOrigin can ride along with any of them.
let origin: { latitude: number; longitude: number; label?: string } | null | undefined;
if (body.clearOrigin) origin = null;
else if (body.origin) origin = body.origin;
else if (body.place?.trim()) {
const hit = await routing.geocode(body.place.trim());
if (!hit) return Response.json({ error: `Couldn't find "${body.place.trim()}"` }, { status: 404 });
origin = hit;
}
if (origin === undefined && body.returnToOrigin === undefined) {
return Response.json({ error: 'place, origin, clearOrigin, or returnToOrigin required' }, { status: 400 });
}
const ok = await setTripOrigin(userId, id, { origin, returnToOrigin: body.returnToOrigin });
if (!ok) return Response.json({ error: 'not found' }, { status: 404 });
return Response.json({ trip: await getTrip(userId, id), metrics: await liveMetrics(userId, id) });
}
case 'addStop': {
if (!body.stop) return Response.json({ error: 'stop required' }, { status: 400 });
const stopId = await addStop(userId, id, body.stop);
Expand Down
44 changes: 42 additions & 2 deletions app/campgrounds/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import NextLink from 'next/link';
import { notFound } from 'next/navigation';
import {
LuArrowLeft,
LuCalendarCheck,
LuMapPin,
LuPlug,
LuDollarSign,
Expand All @@ -19,6 +20,7 @@ import {
LuTruck,
} from 'react-icons/lu';
import { campgroundDetail, campsitesForCampground } from '../../../lib/campgrounds';
import { bookingSignal, BOOKING_PALETTE } from '../../../lib/camp-booking';
import { getWeather } from '../../../lib/datasources/weather';
import { recreationUrl } from '../../../lib/datasources/recreation';

Expand Down Expand Up @@ -53,6 +55,7 @@ export default async function CampgroundDetailPage({ params }: { params: Promise
]);

const bookUrl = cg.reservationUrl ?? (cg.ridbId ? recreationUrl(cg.ridbId) : null);
const booking = bookingSignal(cg); // the plain-English "reservation vs first-come" answer
const agencyLabel = cg.agencyName ?? AGENCY_LABEL[cg.agency ?? ''] ?? 'Campground';
const where = cg.parkName ?? cg.recAreaName ?? null;
const confidence = cg.dataConfidence ?? (cg.source === 'nps+ridb' ? 'high' : 'medium');
Expand Down Expand Up @@ -138,11 +141,45 @@ export default async function CampgroundDetailPage({ params }: { params: Promise
{cg.dispersed ? <Badge colorPalette="trail" variant="subtle" gap={1}><Icon boxSize={3}><LuTentTree /></Icon> Dispersed</Badge> : null}
</HStack>

{/* CTAs */}
{/* Booking clarity — the plain-English "do I reserve or just show up?" callout (user feedback). */}
<Box
colorPalette={BOOKING_PALETTE[booking.kind]}
borderWidth="1px"
borderColor="colorPalette.muted"
borderLeftWidth="4px"
borderLeftColor="colorPalette.solid"
borderRadius="md"
bg="colorPalette.subtle"
p={4}
mb={5}
>
<HStack gap={2} align="start">
<Icon boxSize={4} mt={0.5} color="colorPalette.fg">
{booking.kind === 'fcfs' ? <LuTentTree /> : booking.kind === 'unknown' ? <LuTriangleAlert /> : <LuCalendarCheck />}
</Icon>
<Stack gap={0.5}>
<Text fontWeight="bold" fontSize="sm" color="colorPalette.fg">{booking.label}</Text>
{booking.detail ? <Text fontSize="sm">{booking.detail}</Text> : null}
{booking.kind === 'fcfs' ? (
<Text fontSize="sm">No reservations — arrive early to claim a site.</Text>
) : null}
</Stack>
</HStack>
</Box>

{/* CTAs — ONE booking action (the callout above stays informational), labeled to match the booking
kind: "Book" would contradict a first-come or unknown-booking campground. */}
<HStack gap={3} flexWrap="wrap" mb={6}>
{bookUrl ? (
<Button asChild colorPalette="pine">
<a href={bookUrl} target="_blank" rel="noopener noreferrer">Book on Recreation.gov <Icon boxSize={4}><LuExternalLink /></Icon></a>
<a href={bookUrl} target="_blank" rel="noopener noreferrer">
{booking.kind === 'reservation' || booking.kind === 'mixed'
? 'Book on Recreation.gov'
: booking.kind === 'fcfs'
? 'View on Recreation.gov'
: 'Check on Recreation.gov'}{' '}
<Icon boxSize={4}><LuExternalLink /></Icon>
</a>
</Button>
) : null}
<Button variant="outline" disabled title="Cancellation alerts arrive with availability (coming soon)">Set a Camp Watch (soon)</Button>
Expand Down Expand Up @@ -176,9 +213,12 @@ export default async function CampgroundDetailPage({ params }: { params: Promise
<Text flex="0 0 70px">{s.maxRvLengthFt ? `${s.maxRvLengthFt} ft` : '—'}</Text>
<Text flex="0 0 70px">{s.electricAmps ? `${s.electricAmps}A` : '—'}</Text>
<HStack flex="1" gap={1.5} flexWrap="wrap" fontSize="xs" color="fg.muted">
{s.maxPeople != null ? <Text>up to {s.maxPeople} people</Text> : null}
{s.hasWater ? <Text>water</Text> : null}
{s.hasSewer ? <Text>sewer</Text> : null}
{s.pullThrough ? <Text>pull-through</Text> : null}
{s.campfireAllowed != null ? <Text>{s.campfireAllowed ? 'campfire ok' : 'no campfires'}</Text> : null}
{s.shade ? <Text>shade</Text> : null}
{s.ada ? <Text>ADA</Text> : null}
{s.reservable ? <Text>reservable</Text> : <Text>first-come</Text>}
</HStack>
Expand Down
30 changes: 27 additions & 3 deletions app/explore/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import { LuChevronLeft, LuChevronRight, LuSearch, LuTelescope } from 'react-icon
import { searchParks, facets } from '../../lib/queries';
import { forYou } from '../../lib/recommend';
import { getServerUserId } from '../../lib/session';
import { getTravelConstraints } from '../../lib/bridges';
import { getTravelConstraints, getHomeLocation } from '../../lib/bridges';
import { greatCircleMiles } from '../../lib/routing';
import { ParkCard } from '../../components/ParkCard';
import { RankPanel } from '../../components/explore/RankPanel';
import { WhyThisPark } from '../../components/parks/WhyThisPark';
Expand All @@ -44,6 +45,8 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
const userId = await getServerUserId();
const pageSize = 24;
const page = Math.max(1, Number(sp.page) || 1);
// Saved home (LIVES_AT anchor) unlocks the distance-from-home sort + the "~N mi from home" card line.
const home = userId ? await getHomeLocation(userId).catch(() => null) : null;
const [search, f, recs] = await Promise.all([
searchParks({
q: sp.q,
Expand All @@ -60,6 +63,8 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
firstCome: sp.firstCome === '1',
groupSites: sp.groupSites === '1',
region: sp.region,
home: home ? { latitude: home.latitude, longitude: home.longitude } : undefined,
sort: sp.sort === 'home' && home ? 'home' : 'default',
limit: pageSize,
offset: (page - 1) * pageSize,
}),
Expand Down Expand Up @@ -145,6 +150,18 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
{f.regions.length > 0 ? (
<FacetSelect name="region" label="Region" value={sp.region} options={f.regions} />
) : null}
{home ? (
<Field.Root w={{ base: 'full', sm: '190px' }}>
<Field.Label>Sort</Field.Label>
<NativeSelect.Root>
<NativeSelect.Field name="sort" defaultValue={sp.sort ?? ''}>
<option value="">Name</option>
<option value="home">Distance from home</option>
</NativeSelect.Field>
<NativeSelect.Indicator />
</NativeSelect.Root>
</Field.Root>
) : null}
<Button type="submit" colorPalette="pine">
Apply
</Button>
Expand Down Expand Up @@ -193,9 +210,16 @@ export default async function ExplorePage({ searchParams }: { searchParams: Prom
</EmptyState>
) : (
<>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} gap={5}>
<SimpleGrid columns={{ base: 1, sm: 2, md: 3, lg: 4 }} gap={5} data-testid="explore-results">
{results.map((p) => (
<ParkCard key={p.parkCode} park={p} />
<Box key={p.parkCode}>
<ParkCard park={p} />
{home && p.lat != null && p.lng != null ? (
<Text fontSize="xs" color="fg.muted" mt={1.5}>
~{Math.round(greatCircleMiles({ latitude: home.latitude, longitude: home.longitude }, { latitude: p.lat, longitude: p.lng }))} mi from home
</Text>
) : null}
</Box>
))}
</SimpleGrid>
{hasPrev || hasNext ? (
Expand Down
6 changes: 5 additions & 1 deletion app/me/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import NextLink from 'next/link';
import { LuBrain } from 'react-icons/lu';
import { getServerUserId } from '../../lib/session';
import { getUserMemory, userContextGraph } from '../../lib/memory-graph';
import { getHomeLocation } from '../../lib/bridges';
import { HomeLocationCard } from '../../components/memory/HomeLocationCard';
import { getLearningMemory } from '../../lib/learn-queries';
import { allBadges } from '../../lib/learn-badges';
import { MemoryList } from '../../components/memory/MemoryList';
Expand Down Expand Up @@ -33,11 +35,12 @@ export default async function MePage() {
</Box>
);
}
const [memory, context, learning, badges] = await Promise.all([
const [memory, context, learning, badges, home] = await Promise.all([
getUserMemory(userId),
userContextGraph(userId).catch(() => null),
getLearningMemory(userId),
allBadges(),
getHomeLocation(userId).catch(() => null),
]);
return (
<Box>
Expand All @@ -49,6 +52,7 @@ export default async function MePage() {
/>
<Container maxW="3xl" px={{ base: 4, md: 8 }} py={{ base: 8, md: 10 }}>
{context ? <ContextGraph nodes={context.nodes} rels={context.rels} /> : null}
<HomeLocationCard initial={home} />
<MemoryList initial={memory} />
<LearningSummary learning={learning} badges={badges} />
<DigestInbox />
Expand Down
Loading
Loading