trails/openspec/changes/route-surface-breakdown/tasks.md
Ullrich Schäfer 3a1c34317d
route-surface-breakdown (Phase 1): Planner-path surface/waytype bars
Synchronous path + rendering for the surface/waytype breakdown:
- map-core `computeSurfaceBreakdown(coords, surfaces, highways)` → distance-
  weighted metres per surface + waytype category (unit-tested);
- `SurfaceBreakdownSchema` in @trails-cool/api;
- nullable `surfaceBreakdown` jsonb on routes + activities;
- Planner `SaveToJournalButton` computes it from the BRouter waytags already in
  routeData and sends it; `api.save-to-journal` forwards it; the journal route
  callback validates + persists (journal is the authoritative validator);
- `SurfaceBreakdown` component (stacked bars per dimension, map-core palettes,
  legend category · % · km largest-first, unknown → "other", hidden when empty)
  on route + activity detail; journal gains a @trails-cool/map-core dep;
- i18n journal.surface.* (en + de).

Phase 2 (async Overpass backfill + SSE for imports/uploads, and the e2e that
seeds a breakdown) follows in a separate PR.

Tests: computeSurfaceBreakdown unit (map-core 36); SurfaceBreakdown component
(jsdom, journal 326). typecheck + lint green; verified in the browser.

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

2.9 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 — follow-up PR)

  • 5.1 Journal-side Overpass client: way[highway] in bbox + parse (mirror the planner's buildQuery/parseResponse + rate-limit handling; route via the /api/overpass proxy).
  • 5.2 Map-match helper: nearest OSM way per route segment → surface/highway; unmatched → unknown. Unit-test the matcher on a small fixture.
  • 5.3 Typed surface-backfill pg-boss job ({ kind, id }): load geometry → skip if breakdown exists → Overpass → map-match → store. Idempotent, retry-safe, best-effort.
  • 5.4 Enqueue the job after GPX import (Komoot/Garmin) and manual upload; add a one-off sweep entrypoint for pre-existing rows.
  • 5.5 Emit a surface_breakdown SSE event ({ kind, id }) on completion; detail page subscribes (EventSource hook) and re-fetches the breakdown when it matches; optional "computing…" state.

6. Privacy

  • 6.1 Document the backfill's Overpass lookup in the privacy manifest; route via the proxy (self-hostable Overpass is the long-term answer — see docs/ideas/self-host-overpass).

7. Tests & checks

  • 7.1 Unit: computeSurfaceBreakdown weighting + unknown bucket (map-matcher unit is Phase 2).
  • 7.2 Component (jsdom): bars/proportions, largest-first, hidden when empty/null.
  • 7.3 E2E: a route/activity with a stored breakdown shows the bars — Phase 2 (needs a seed path; deferred to land with the backfill that creates the data). Phase 1 render verified in the browser + component test.
  • 7.4 Phase 1: typecheck + lint + unit (map-core 36, journal 326) green; verified in the browser.