You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Build the planner view: a map-first surface where a rider picks an origin
and destination from the loaded GTFS feed and gets a vehicle-only itinerary
(transit legs with optional walking between them).
The placeholder is already gone — src/routes/+page.svelte is now the
Stations view; there is no /planner route. The bottom-nav slot is free.
Goal
A rider picks an origin and a destination, and the planner returns
vehicle-only itineraries (transit legs composed of trips, stops, and
transfers from the loaded feed). No walking-route generation, no car
routing.
What it should be
Map-first surface. Default view is a map centred on the user
(GPS if opted in) or feedsStore.byId(boundFeedId).center as
fallback — that field is always present per the neary-gtfs schema,
and Stations already uses this anchor.
Stop markers on the map. Nearby stops render as tappable markers
so the rider can set origin / destination by clicking.
Side-panel stop picker. A pair of StopPicker slots (From / To)
in a side <aside> for typed search. Same selection contract as the
map — whichever surface the user prefers writes to the same origin
/ destination state.
Tap or pin-drop on the map. Clicking a stop marker sets origin
or destination (whichever slot is active). Dropping a pin at an
arbitrary location snaps to the nearest stop within a configurable
radius (default 500 m, matches getStopsNearby).
Vehicle-only itineraries. Routes are composed of transit legs
only. Walking between stops is surfaced as walk N min from stop X to stop Y but never the primary movement.
Line swaps allowed. The itinerary can chain trips with a transfer
at a shared stop. Use transfers.txt when present; fall back to a
configurable minimum-walk-time at any shared stop. Cluj does not
currently publish transfers.txt, so the fallback is the path that
ships first.
Live awareness. When live data is available for a leg, surface
it (current bus position, predicted ETA at boarding stop). When not,
fall back to the static schedule. Use the same reconciler logic as
the rest of the app (src/lib/domain/reconcile.ts + docs/specs/live-data-pipeline.md).
What it should NOT be
A walking / cycling / driving router. Out of scope. If we ever need
that, it goes behind a separate provider — not in this planner.
A turn-by-turn navigator. The planner's job ends at "here is the
next leg you should take".
The header station search overlay (PR #137) and its iOS zoom fix
(PR #139) deliver most of the picker surface. The planner folds
those in rather than building a parallel search stack.
Important
The picker is the overlay. Do not rebuild the input UI.
Required refactors before the planner can reuse these
Make HeaderSearchOverlay's selection overridable.
At src/lib/ui/HeaderSearchOverlay.svelte:226-229, selectStop
currently does onclose(); goto('/station/${id}'). Add an optional onselect?: (stopId: string) => void prop. Default behaviour
stays unchanged so the global header icon still routes to /station/[id].
Extract a shared <Map /> Svelte component.
Leaflet is currently used inline in the route map page
(src/routes/map/route/[id]/[[selected]]/+page.svelte:585-625).
Lift the L.map(...) setup, marker layer, and polyline layer
into src/lib/ui/Map.svelte so planner + route map share one
implementation. Expose onclick({latlng}) as a prop so the
planner can handle pin-drop.
Add plannerSelectionStore.svelte.ts.
Mirror the searchOverlayStore pattern
(src/lib/stores/searchOverlayStore.svelte.ts, 20 lines). Holds { origin: StopSelection | null, destination: StopSelection | null, setOrigin, setDestination, swap, clear }. Persist to URL via
SvelteKit $app/state for shareable itineraries.
Note
These three refactors are small (combined diff < 200 lines) and
unblock the planner cleanly. The planner page then composes them —
no new search code.
Genuinely new work (not extraction)
Itinerary algorithm. Naive BFS over (route, trip, stop_times)
with a findItineraries(originStopId, destStopId, options): Itinerary[]
interface, so RAPTOR / CSA can replace it later without rewriting
consumers. Cluj-scale (~80 routes, ~2k stops) makes naive BFS
tractable in a single SQL pass on OPFS-SQLite.
transfers.txt consumer. No transfers handling exists today
(grep -rn "transfers" src/ is empty). Implement the
minimum-walk-time fallback first (Cluj's actual case); add transfers.txt parsing as a follow-up.
Walking-between-stops labelling.StopWithDistance.distance
is already populated by searchStops / getStopsNear — use it
directly to label inter-stop walks.
Design decisions (resolved by recent work)
Question
Resolution
Algorithm
Naive BFS behind findItineraries(origin, dest, opts) interface; swap in RAPTOR later if profiling demands
transfers.txt absent (Cluj's case)
Default 120 s minimum-walk-time at shared stops; per-feed override in feeds.json as planner.transferMinWalkSeconds (fits PR #133's feed-config-extraction pattern)
Pin-drop snap radius
Default 500 m, matches getStopsNearby; per-feed override planner.pinSnapRadiusMeters if a feed needs it
Caching
In-memory LRU keyed by (originStopId, destStopId, timeBucket). OPFS-SQLite already serves the raw trip data; itineraries don't need to persist
Origin / Destination state
New plannerSelectionStore.svelte.ts (mirror of searchOverlayStore). URL-persisted via $app/state
Out of scope for the first cut
Multi-modal routing (bike + transit, etc.).
Saved itineraries.
Reverse routing ("when do I need to leave to arrive by X?") — file
as a follow-up once the forward case is solid.
Acceptance criteria
Origin / Destination slots: each has a StopPicker (the
reusable HeaderSearchOverlay body) and a map marker slot.
Tapping a stop marker on the map sets origin (or destination,
whichever is active). Dropping a pin snaps to the nearest stop
via repo.getStopsNear(lat, lon, radius, 1).
When both slots are filled, the planner renders at least one
itinerary with transit legs only.
Itineraries chain trips across transfers using either transfers.txt (when present) or the configured planner.transferMinWalkSeconds fallback.
Walking between two stops is labelled walk N min from stop X to stop Y, never rendered as a primary leg.
Live data: when reconciler output is available for a leg,
surface predicted ETA at the boarding stop; otherwise static
schedule.
GPS-opt-in flow uses the same locationStore.enable() entry
point as Stations.
Summary
Build the planner view: a map-first surface where a rider picks an origin
and destination from the loaded GTFS feed and gets a vehicle-only itinerary
(transit legs with optional walking between them).
The placeholder is already gone —
src/routes/+page.svelteis now theStations view; there is no
/plannerroute. The bottom-nav slot is free.Goal
A rider picks an origin and a destination, and the planner returns
vehicle-only itineraries (transit legs composed of trips, stops, and
transfers from the loaded feed). No walking-route generation, no car
routing.
What it should be
(GPS if opted in) or
feedsStore.byId(boundFeedId).centerasfallback — that field is always present per the neary-gtfs schema,
and Stations already uses this anchor.
so the rider can set origin / destination by clicking.
StopPickerslots (From / To)in a side
<aside>for typed search. Same selection contract as themap — whichever surface the user prefers writes to the same origin
/ destination state.
or destination (whichever slot is active). Dropping a pin at an
arbitrary location snaps to the nearest stop within a configurable
radius (default 500 m, matches
getStopsNearby).only. Walking between stops is surfaced as
walk N min from stop X to stop Ybut never the primary movement.at a shared stop. Use
transfers.txtwhen present; fall back to aconfigurable minimum-walk-time at any shared stop. Cluj does not
currently publish
transfers.txt, so the fallback is the path thatships first.
it (current bus position, predicted ETA at boarding stop). When not,
fall back to the static schedule. Use the same reconciler logic as
the rest of the app (
src/lib/domain/reconcile.ts+docs/specs/live-data-pipeline.md).What it should NOT be
that, it goes behind a separate provider — not in this planner.
next leg you should take".
Implementation: build on PRs #137 + #139
The header station search overlay (PR #137) and its iOS zoom fix
(PR #139) deliver most of the picker surface. The planner folds
those in rather than building a parallel search stack.
Important
The picker is the overlay. Do not rebuild the input UI.
Reuse, don't duplicate
searchStops(NFD + strip combining marks)src/lib/workers/gtfs/queries/stops.ts:77-122anchorderivedsrc/lib/ui/HeaderSearchOverlay.svelte:64-69rollupByParentsrc/lib/workers/gtfs/queries/stops.ts:177-207EXISTS stop_timesclausesrc/lib/workers/gtfs/queries/stops.ts:155-160HeaderSearchOverlay.svelte:75-85HeaderSearchOverlay.svelte:103-123getRoutesForStops+ post-fetch effectHeaderSearchOverlay.svelte:193-224StopSearchCardselectableonselect: (stopId: string) => voidpropsrc/lib/ui/StopSearchCard.svelte:25text-baseon mobilelocationStore.enable()src/lib/stores/locationStore.svelte.ts:93-96Required refactors before the planner can reuse these
Make
HeaderSearchOverlay's selection overridable.At
src/lib/ui/HeaderSearchOverlay.svelte:226-229,selectStopcurrently does
onclose(); goto('/station/${id}'). Add an optionalonselect?: (stopId: string) => voidprop. Default behaviourstays unchanged so the global header icon still routes to
/station/[id].Extract a shared
<Map />Svelte component.Leaflet is currently used inline in the route map page
(
src/routes/map/route/[id]/[[selected]]/+page.svelte:585-625).Lift the
L.map(...)setup, marker layer, and polyline layerinto
src/lib/ui/Map.svelteso planner + route map share oneimplementation. Expose
onclick({latlng})as a prop so theplanner can handle pin-drop.
Add
plannerSelectionStore.svelte.ts.Mirror the
searchOverlayStorepattern(
src/lib/stores/searchOverlayStore.svelte.ts, 20 lines). Holds{ origin: StopSelection | null, destination: StopSelection | null, setOrigin, setDestination, swap, clear }. Persist to URL viaSvelteKit
$app/statefor shareable itineraries.Note
These three refactors are small (combined diff < 200 lines) and
unblock the planner cleanly. The planner page then composes them —
no new search code.
Genuinely new work (not extraction)
(route, trip, stop_times)with a
findItineraries(originStopId, destStopId, options): Itinerary[]interface, so RAPTOR / CSA can replace it later without rewriting
consumers. Cluj-scale (~80 routes, ~2k stops) makes naive BFS
tractable in a single SQL pass on OPFS-SQLite.
transfers.txtconsumer. No transfers handling exists today(
grep -rn "transfers" src/is empty). Implement theminimum-walk-time fallback first (Cluj's actual case); add
transfers.txtparsing as a follow-up.StopWithDistance.distanceis already populated by
searchStops/getStopsNear— use itdirectly to label inter-stop walks.
Design decisions (resolved by recent work)
findItineraries(origin, dest, opts)interface; swap in RAPTOR later if profiling demandstransfers.txtabsent (Cluj's case)feeds.jsonasplanner.transferMinWalkSeconds(fits PR #133's feed-config-extraction pattern)getStopsNearby; per-feed overrideplanner.pinSnapRadiusMetersif a feed needs it(originStopId, destStopId, timeBucket). OPFS-SQLite already serves the raw trip data; itineraries don't need to persistplannerSelectionStore.svelte.ts(mirror ofsearchOverlayStore). URL-persisted via$app/stateOut of scope for the first cut
as a follow-up once the forward case is solid.
Acceptance criteria
StopPicker(thereusable
HeaderSearchOverlaybody) and a map marker slot.whichever is active). Dropping a pin snaps to the nearest stop
via
repo.getStopsNear(lat, lon, radius, 1).itinerary with transit legs only.
transfers.txt(when present) or the configuredplanner.transferMinWalkSecondsfallback.walk N min from stop X to stop Y, never rendered as a primary leg.surface predicted ETA at the boarding stop; otherwise static
schedule.
locationStore.enable()entrypoint as Stations.
feedsStore.byId(boundFeedId).centerper PR feat(stations): GPS opt-in flow + header station search overlay #137's pattern./planner?origin=<id>&destination=<id>survivesreload and is shareable.
console.login production code.npm run check && npm test && npm run buildall pass.References
src/lib/workers/gtfs/queries/stops.ts(searchStops,getStopsNear)src/lib/data/gtfs/types.ts:100-106(GtfsRepo.searchStops)src/lib/ui/HeaderSearchOverlay.sveltesrc/lib/ui/StopSearchCard.sveltesrc/lib/stores/searchOverlayStore.svelte.tssrc/lib/domain/reconcile.tsdocs/specs/live-data-pipeline.mdfeeds.jsonoverrides):src/routes/map/route/[id]/[[selected]]/+page.svelte:585-625