Skip to content

Make stations view work without GPS + add header station search overlay #110

Description

@ciotlosm

Summary

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.

What's already there (no work needed)

  • getStationBoardsNear / getStopsNear worker queries in
    src/lib/workers/gtfs/queries/stops.ts
    and
    stationBoards.ts.
  • 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.
  • userPrefs persistence pattern in
    src/lib/stores/userPrefs.svelte.ts
    for opt-in / dismiss state.
  • 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

  • Remove onMount(() => 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:

type FreshState = '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 filter
  anchorLat: 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.

Surface in the repo (src/lib/data/gtfs/repo.ts):

searchStops(
  text: string,
  anchorLat: number,
  anchorLon: number,
  limit?: number,
): Promise<StopWithDistance[]>

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.
  • Persisting the search query across sessions.
  • Per-station "favorites"-based empty-state behavior — unchanged.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or improvement

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions