trails/openspec/changes/route-surface-breakdown/tasks.md
Ullrich Schäfer 279734c607
route-surface-breakdown (Phase 2): async Overpass backfill + SSE
Covers routes/activities that enter the journal without BRouter waytags
(imports, uploads, pre-existing rows):

- overpass-ways.server.ts: server-side Overpass client (way[highway] + geom in
  a bbox, configurable OVERPASS_URLS, timeout + oversized-bbox guard).
- surface-match.server.ts: pure nearest-way map-matcher → per-segment
  surface/highway (unmatched → unknown). Unit-tested.
- surface-backfill pg-boss job: load geom → skip if breakdown exists → Overpass
  → match → computeSurfaceBreakdown → store → emit `surface_breakdown` SSE to
  the owner. Idempotent, retry-safe, best-effort; registered in server.ts.
- Enqueued from createActivity (imports/uploads) + owner-on-open in the route &
  activity detail loaders (non-Planner routes, old rows), deduped via singletonKey.
- useSurfaceBackfillUpdates: detail pages subscribe to /api/events and
  revalidate() when their row's backfill lands (live bars, no reload).
- Privacy manifest updated (DE + EN) for the Overpass bbox lookup.

Tests: matchSurfaces unit; surface-backfill job (mocked Overpass/db/events:
store + emit, skip-if-present, skip-if-no-ways). Verified the real
Overpass→match→breakdown pipeline against a live Berlin bbox (509 ways →
plausible asphalt/paving_stones/footway mix). typecheck + lint + unit
(journal 333) green.

Note: the worker runs via server.ts (prod/staging), not `react-router dev`, so
the live job + SSE exercise on a deployed instance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:18:03 +02:00

3.1 KiB

1. Shared derivation + shape

  • 1.1 computeSurfaceBreakdown(coordinates, surfaces, highways) in @trails-cool/map-core{ surface, highway } metres; unit-tested.
  • 1.2 SurfaceBreakdownSchema (Zod) in @trails-cool/api.

2. Schema

  • 2.1 Nullable surfaceBreakdown jsonb on journal.routes and journal.activities; pushed to dev + e2e DBs.

3. Path 1 — synchronous (Planner routes)

  • 3.1 SaveToJournalButton computes the breakdown from readComputedRoute and sends it; api.save-to-journal forwards it (journal is the authoritative validator — planner has no @trails-cool/api dep).
  • 3.2 Journal route callback validates the optional field with SurfaceBreakdownSchema and persists via updateRoute (overwrite on re-save).

4. Render

  • 4.1 Route + activity detail loaders expose surfaceBreakdown.
  • 4.2 SurfaceBreakdown component (stacked bar per dimension, map-core palettes, legend category · % · km largest-first, unknown → "other", hidden when empty); mounted on routes.$id.tsx and activities.$id.tsx.
  • 4.3 i18n journal.surface.* (surface/waytype labels, common categories, "other") en + de.

5. Path 2 — async backfill + SSE (PHASE 2 — this PR)

  • 5.1 Journal-side Overpass client (overpass-ways.server.ts): way[highway] in bbox + out tags geom, configurable OVERPASS_URLS upstreams, timeout + oversized-bbox guard.
  • 5.2 Map-matcher (surface-match.server.ts, pure): nearest OSM way per segment → surface/highway; unmatched → unknown. Unit-tested.
  • 5.3 Typed surface-backfill pg-boss job: load geom → skip if breakdown exists → Overpass → match → computeSurfaceBreakdown → store. Idempotent, retry-safe, best-effort; registered in server.ts.
  • 5.4 Enqueued from createActivity (covers Komoot/Garmin imports + manual uploads) and owner-on-open in the route + activity detail loaders (covers non-Planner routes + pre-existing rows), deduped via singletonKey. (A dedicated sweep CLI is unneeded — on-open covers existing rows lazily.)
  • 5.5 Emits a surface_breakdown SSE event ({ kind, id }) to the owner; detail pages subscribe (useSurfaceBackfillUpdates) and revalidate() when it matches.

6. Privacy

  • 6.1 Documented the backfill's Overpass bbox lookup in the privacy manifest (DE + EN); OVERPASS_URLS can point at a self-hosted instance.

7. Tests & checks

  • 7.1 Unit: computeSurfaceBreakdown weighting + unknown bucket; matchSurfaces nearest-way + unknown bucket.
  • 7.2 Component (jsdom): bars/proportions, largest-first, hidden when empty/null.
  • 7.3 Job test (mocked Overpass/db/events): stores + emits; skips when breakdown exists or no ways. (A full live e2e is intentionally skipped — external Overpass + SSE timing are flaky; rendering is covered by the component test + browser, the job by its mocked test.)
  • 7.4 typecheck + lint + unit (map-core 36, journal 333) green; Phase 1 verified in the browser.