trails/openspec/changes/route-surface-breakdown/design.md
Ullrich Schäfer 47f185598d
openspec: propose route-surface-breakdown
Surface + waytype proportion bars on route detail, from the BRouter waytags the
Planner already computes (and currently discards on save). Design records the
data-source decision: persist a compact distance-weighted breakdown for
Planner-created routes (accurate, no external call); Overpass map-matching for
imports/uploads is parked as a future option.

Validates with `openspec validate --strict`. Specs only; no code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:13:44 +02:00

3.7 KiB

Context

The Planner computes accurate per-coordinate surface/highway from BRouter's messages/WayTags (apps/planner/.../route-merge.tsEnrichedRoute), and ColoredRoute already renders the route coloured by surface. But this metadata is session-only: the planner→journal handoff (api.save-to-journal) POSTs only { gpx } to the Journal callback (api.routes.$id.callback), the routes table has no surface column, and GPX carries no surface extensions. So today no saved route has surface data.

Goals / Non-Goals

Goals

  • Surface + waytype proportion bars on route detail, using data we already compute, with no new external dependency.

Non-Goals (v1)

  • Activities / imported / manually-uploaded GPX — they have no waytags (bare GPX). They show no breakdown for now (see "Data source" → future Overpass).
  • Colouring the Journal map line by surface — needs per-segment data, not just the summary; a clean follow-up once the bars land.
  • Selectable surface-vs-waytype toggle UI beyond showing both bars.

Decisions

D1: Data source — persist BRouter waytags at save (Planner routes only)

Two ways to get a breakdown onto the Journal detail page:

  • (A, chosen) BRouter-at-save — the Planner already has 100%-accurate per-segment surface/highway; compute the distribution there at save and persist it. Accurate, cheap, no external call. Limitation: only Planner-created routes (activities/uploads have no waytags).
  • (B, future) Overpass map-matching — snap arbitrary route geometry to OSM ways and read surface/highway server-side. Covers imports/uploads, but it's expensive, approximate, rate-limited, and an external dependency. Parked for when we want imports covered (the repo already proxies Overpass for POIs).

v1 ships (A). Routes without the data degrade to no bars.

D2: Store a compact distance-weighted summary, not per-segment data

The bars need proportions, not geometry. At save, sum the haversine length of each coordinate segment into its surface bucket (and highway bucket), yielding { surface: { asphalt: <m>, gravel: <m>, … }, highway: { … } } (metres per category). This is tiny (a handful of keys), stored as a nullable surface_breakdown jsonb on routes. Per-segment data (for future map line colouring) is deliberately not persisted in v1 — that's a bigger payload and a separate feature.

D3: Handoff carries it

api.save-to-journal adds surfaceBreakdown to the POST body alongside gpx; the Journal callback validates (Zod, all values ≥ 0) and stores it. The field is optional, so the handoff stays backward-compatible and non-Planner callers (if any) are unaffected. Re-saving a route recomputes/overwrites it.

D4: Rendering

A SurfaceBreakdown component renders one horizontal stacked bar per dimension (surface, then waytype): segments sized by percentage, coloured via SURFACE_COLORS / HIGHWAY_COLORS (DEFAULT_* for unknown), with a legend listing each category + km + percent (largest first). Unknown/unmapped tags collapse into an "other" segment. Hidden entirely when surface_breakdown is null or empty. Labels via i18n; distances via stats.ts.

Risks / Trade-offs

  • Coverage gap — only Planner routes get bars in v1. Acceptable: routes are designed in the Planner, which is exactly where "how much gravel" matters. Imports get bars later via (B).
  • Tag sparsity — OSM surface is often missing; those segments bucket as unknown/"other". We show it honestly rather than guessing.
  • Stale on edit — editing a route's geometry elsewhere without re-routing could leave the breakdown stale; recomputing on every Planner save keeps it fresh for the normal flow.