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
The Stations view (/) currently auto-triggers the browser's geolocation
prompt on first visit via onMount(() => locationStore.start()) in src/routes/+page.svelte. This
issue makes GPS strictly opt-in, gives a useful default experience
without it, and adds a station search overlay (reusable from any view via
the header) so the rider can always find a stop.
A hardcoded Cluj city-center fallback in +page.svelte
(FALLBACK_LAT / FALLBACK_LON) — gets deleted in §1.
Feed.center: { lat, lon } on src/lib/data/feeds.ts —
published by neary-gtfs and
derived from the feed's bbox in make-sqlite.js. This is the
distance anchor for the GPS-off fallback.
The header (src/lib/ui/Header.svelte) already has the GPS dot — the
search icon lives next to it.
What changes
1. Make GPS strictly opt-in
RemoveonMount(() => locationStore.start()) from src/routes/+page.svelte. The browser must not prompt on first
visit.
Delete the hardcoded FALLBACK_LAT / FALLBACK_LON constants. The
fallback anchor becomes feedsStore.byId(feedsStore.boundFeedId)?.center (or a sensible default
if the feed's center is missing — file a follow-up if that ever
happens).
The gpsState derivation in +page.svelte gains a fourth value:
'not-opted-in' — user has never tapped Enable.
'pending' — opted in, waiting for first fix.
'available' / 'unavailable' — unchanged.
The current 'pending' covers both "opted in, waiting" and "never
asked"; the new value separates them so the banner can target the
opt-in case.
2. In-page "Enable location" banner with transparency copy
When gpsState === 'not-opted-in', render a single horizontal banner at
the top of the Stations view (replacing today's "No GPS" indicator). Copy:
📍 See stops near you — Neary uses your location to sort nearby
stations and put real-time arrivals closer to you first. We never store
it, never send it to a server, and never track you in the background. [Enable location][Not now]
Enable location → calls locationStore.start(), which triggers the
browser's native permission dialog.
Not now → dismisses the banner; persists in userPrefs via a new gpsPromptDismissedAt: number | null (timestamp). Cleared (set back to null) the moment the user opts in.
When gpsState === 'unavailable' (permission denied), the banner copy
changes to: "Location is off in your browser settings. Open Settings →
Site permissions → Location to allow it." The Enable button is hidden.
When gpsState === 'available', the banner is gone entirely.
3. Header GPS dot gets an off state
FreshState in src/lib/stores/locationStore.svelte.ts becomes:
typeFreshState='off'|'idle'|'ok'|'stale'|'error';
'off' — user has never opted in. Tooltip: "GPS off — tap to enable."
Tapping the header dot also calls locationStore.start() (second
entry point next to the in-page banner).
'idle' — opted in, waiting for first fix. Existing behavior.
'ok' / 'stale' / 'error' — unchanged.
The existing gpsState derivation in +page.svelte and the header dot
(src/lib/ui/Header.svelte) both consume the new state.
4. Header search icon + dimmed-overlay search
New magnifying-glass icon button in the header (Header.svelte),
next to the GPS dot. Header is global, so the icon is visible on
every view (Stations, Favorites, Settings, future Planner).
Tap → opens a full-screen overlay with:
A dimmed backdrop behind it (a semi-transparent layer that
dismisses the overlay on tap-outside).
A centered search box in the middle of the screen. On mobile,
this is the area that gets pushed up by the soft keyboard, so the
search stays usable without scrolling.
A scrollable list of matching stations under the input.
A small "Cancel" affordance top-right of the overlay to close.
Input behavior:
Empty input → debounce 150ms, then show the 25 nearest stations
to the current anchor (GPS position if available, else feedsStore.activeFeed.center).
Non-empty input → debounce 150ms, then call the new worker query
(§5). Show results sorted by distance from the anchor ascending.
Tapping a result → goto('/station/[id]') and close the overlay.
No URL state for now — the search lives in component state. If we
want shareable searches later, add /?q=... then.
The header's search icon is always visible — the only requirement is
that a feed is selected. If userPrefs.feedId == null, hide the icon
(no stations to search).
5. New worker query: searchStops
Add to src/lib/workers/gtfs/queries/stops.ts:
searchStops(db: Database,text: string,// '' → nearest-only, no name filteranchorLat: number,anchorLon: number,limit: number,// default 25): StopWithDistance[]
Implementation:
text === '' → just call the existing getStopsNear(...) and return
its result. Zero new code for the empty-input case.
text !== '' → SQL WHERE stop_name LIKE ? COLLATE NOCASE with the
same bounding-box prefilter as getStopsNear (uses the lat/lon index),
then Haversine distance in JS, then sort + slice.
Substring match (%${text}%). Diacritic-insensitive: normalize both
the input and stop_name with String.prototype.normalize('NFD') and
strip combining marks, so "piata" matches "Piața". Cheap, no extra
index.
Keep the EXISTS (SELECT 1 FROM stop_times ...) filter that getStopsNear already has, so legacy / terminus-pad stops never
surface in the search results.
6. (Forward-looking) "Show on map" in search results
Deferred — blocked on #104 (Planner view). When that ships, each
search-result row gets a small map-pin icon that navigates to the
planner's origin-pick surface pre-filled with the stop id.
Tracked as a follow-up comment on this issue, not a separate issue, so
the dependency stays visible.
Acceptance criteria
First visit, no feed selected → existing empty state.
First visit, feed selected, GPS off → fallback board renders using
the feed's center; the opt-in banner is visible.
Tap Enable location → browser permission dialog appears. Grant
→ board re-queries with GPS position. Deny → banner switches to
"permission denied" copy.
Tap Not now → banner disappears, persists across reloads
(userPrefs.gpsPromptDismissedAt is set), reappears after a manual
reset (out of scope to expose the reset; just document it).
Header GPS dot shows 'off' (grey, tooltip "GPS off — tap to
enable") when not opted in. Tapping it opts in.
Search icon is visible in the header on every view except /settings and the empty state. Tapping it opens the overlay
with a dimmed backdrop and a centered search input.
Overlay empty input → 25 nearest stations to the current anchor
(GPS or feed center), sorted ascending by distance.
Type aeroport → list filters to matching stops, sorted by
distance from the anchor. "piata" matches "Piața".
Tap a result → navigates to /station/[id], overlay closes.
Mobile: open the search overlay, focus the input → the keyboard
pushes the input up; the input stays visible and the result list
shrinks but remains scrollable. Tap the dimmed backdrop → overlay
dismisses.
npm run check && npm test && npm run build all pass.
No new console.log in production code.
Out of scope (separate issues if needed)
The Feed type's center is assumed to always be present. If a feed
ever ships without one, file a follow-up for a per-feed override.
Likewise, a separate "downtown" coordinate distinct from the bbox
centroid would be a new optional field on the neary-gtfs feeds.json
schema, not an app-only change.
Reverse geocoding — turning the fallback position into a human-readable
place name. The feed name in the header is enough for now.
Summary
The Stations view (
/) currently auto-triggers the browser's geolocationprompt on first visit via
onMount(() => locationStore.start())insrc/routes/+page.svelte. Thisissue makes GPS strictly opt-in, gives a useful default experience
without it, and adds a station search overlay (reusable from any view via
the header) so the rider can always find a stop.
What's already there (no work needed)
getStationBoardsNear/getStopsNearworker queries insrc/lib/workers/gtfs/queries/stops.tsand
stationBoards.ts.+page.svelte(
FALLBACK_LAT/FALLBACK_LON) — gets deleted in §1.Feed.center: { lat, lon }onsrc/lib/data/feeds.ts—published by neary-gtfs and
derived from the feed's
bboxinmake-sqlite.js. This is thedistance anchor for the GPS-off fallback.
userPrefspersistence pattern insrc/lib/stores/userPrefs.svelte.tsfor opt-in / dismiss state.
src/lib/ui/Header.svelte) already has the GPS dot — thesearch icon lives next to it.
What changes
1. Make GPS strictly opt-in
onMount(() => locationStore.start())fromsrc/routes/+page.svelte. The browser must not prompt on firstvisit.
FALLBACK_LAT/FALLBACK_LONconstants. Thefallback anchor becomes
feedsStore.byId(feedsStore.boundFeedId)?.center(or a sensible defaultif the feed's
centeris missing — file a follow-up if that everhappens).
gpsStatederivation in+page.sveltegains a fourth value:'not-opted-in'— user has never tapped Enable.'pending'— opted in, waiting for first fix.'available'/'unavailable'— unchanged.The current
'pending'covers both "opted in, waiting" and "neverasked"; the new value separates them so the banner can target the
opt-in case.
2. In-page "Enable location" banner with transparency copy
When
gpsState === 'not-opted-in', render a single horizontal banner atthe top of the Stations view (replacing today's "No GPS" indicator). Copy:
locationStore.start(), which triggers thebrowser's native permission dialog.
userPrefsvia a newgpsPromptDismissedAt: number | null(timestamp). Cleared (set back tonull) the moment the user opts in.gpsState === 'unavailable'(permission denied), the banner copychanges to: "Location is off in your browser settings. Open Settings →
Site permissions → Location to allow it." The Enable button is hidden.
gpsState === 'available', the banner is gone entirely.3. Header GPS dot gets an
offstateFreshStateinsrc/lib/stores/locationStore.svelte.tsbecomes:'off'— user has never opted in. Tooltip: "GPS off — tap to enable."Tapping the header dot also calls
locationStore.start()(secondentry point next to the in-page banner).
'idle'— opted in, waiting for first fix. Existing behavior.'ok' / 'stale' / 'error'— unchanged.The existing
gpsStatederivation in+page.svelteand the header dot(
src/lib/ui/Header.svelte) both consume the new state.4. Header search icon + dimmed-overlay search
Header.svelte),next to the GPS dot. Header is global, so the icon is visible on
every view (Stations, Favorites, Settings, future Planner).
dismisses the overlay on tap-outside).
this is the area that gets pushed up by the soft keyboard, so the
search stays usable without scrolling.
to the current anchor (GPS position if available, else
feedsStore.activeFeed.center).(§5). Show results sorted by distance from the anchor ascending.
goto('/station/[id]')and close the overlay.want shareable searches later, add
/?q=...then.that a feed is selected. If
userPrefs.feedId == null, hide the icon(no stations to search).
5. New worker query:
searchStopsAdd to
src/lib/workers/gtfs/queries/stops.ts:Implementation:
text === ''→ just call the existinggetStopsNear(...)and returnits result. Zero new code for the empty-input case.
text !== ''→ SQLWHERE stop_name LIKE ? COLLATE NOCASEwith thesame bounding-box prefilter as
getStopsNear(uses the lat/lon index),then Haversine distance in JS, then sort + slice.
%${text}%). Diacritic-insensitive: normalize boththe input and
stop_namewithString.prototype.normalize('NFD')andstrip combining marks, so
"piata"matches"Piața". Cheap, no extraindex.
EXISTS (SELECT 1 FROM stop_times ...)filter thatgetStopsNearalready has, so legacy / terminus-pad stops neversurface in the search results.
Surface in the repo (
src/lib/data/gtfs/repo.ts):6. (Forward-looking) "Show on map" in search results
Deferred — blocked on #104 (Planner view). When that ships, each
search-result row gets a small map-pin icon that navigates to the
planner's origin-pick surface pre-filled with the stop id.
Tracked as a follow-up comment on this issue, not a separate issue, so
the dependency stays visible.
Acceptance criteria
the feed's
center; the opt-in banner is visible.→ board re-queries with GPS position. Deny → banner switches to
"permission denied" copy.
(
userPrefs.gpsPromptDismissedAtis set), reappears after a manualreset (out of scope to expose the reset; just document it).
'off'(grey, tooltip "GPS off — tap toenable") when not opted in. Tapping it opts in.
/settingsand the empty state. Tapping it opens the overlaywith a dimmed backdrop and a centered search input.
(GPS or feed center), sorted ascending by distance.
aeroport→ list filters to matching stops, sorted bydistance from the anchor.
"piata"matches"Piața"./station/[id], overlay closes.pushes the input up; the input stays visible and the result list
shrinks but remains scrollable. Tap the dimmed backdrop → overlay
dismisses.
npm run check && npm test && npm run buildall pass.console.login production code.Out of scope (separate issues if needed)
Feedtype'scenteris assumed to always be present. If a feedever ships without one, file a follow-up for a per-feed override.
Likewise, a separate "downtown" coordinate distinct from the bbox
centroid would be a new optional field on the neary-gtfs
feeds.jsonschema, not an app-only change.
place name. The feed name in the header is enough for now.
References
feeds.jsonschema:schemas/feeds.schema.json.src/routes/+page.sveltesrc/lib/stores/locationStore.svelte.tssrc/lib/stores/userPrefs.svelte.tssrc/lib/ui/Header.sveltesrc/lib/workers/gtfs/queries/stops.tssrc/lib/workers/gtfs/queries/stationBoards.tssrc/lib/data/feeds.ts