chore(openspec): archive route-surface-breakdown

The route-surface-breakdown change is fully implemented (18/18 tasks,
all artifacts done). Archive it via `openspec archive` (CLI 1.6.0):

- Moves the change to openspec/changes/archive/2026-07-13-route-surface-breakdown/
- Promotes its 5 delta requirements into a new capability spec at
  openspec/specs/route-surface-breakdown/spec.md (surface/waytype
  breakdown from BRouter waytags, async Overpass backfill, live SSE
  update, proportion bars, localized labels)

Purpose paragraph filled in (the CLI leaves a TBD placeholder). Both the
spec and archived change pass `openspec validate --strict`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-07-13 21:21:58 +02:00
parent ea10e9dd19
commit fc5e361a11
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
6 changed files with 65 additions and 0 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-14

View file

@ -0,0 +1,90 @@
## Context
The Planner computes accurate per-coordinate surface/highway from BRouter's
`WayTags` (`apps/planner/.../route-merge.ts``EnrichedRoute`) and colours the
line with it, but it's session-only: `api.save-to-journal` POSTs only `{ gpx }`,
`routes` has no surface column, GPX carries no surface tags. The repo already
has a typed pg-boss job system (`apps/journal/app/jobs/payloads.ts`,
`@trails-cool/jobs`), an SSE broker (`/api/events` + EventSource hooks, e.g.
`useUnreadNotifications`), and an Overpass proxy used for Planner POIs
(`apps/planner/.../overpass.ts`, `/api/overpass`).
## Goals / Non-Goals
**Goals**
- Surface + waytype bars on route **and** activity detail.
- Two derivation paths: free/accurate for Planner routes; async backfill for
everything else, with a live SSE update.
**Non-Goals (v1)**
- Colouring the Journal **map line** by surface (needs per-segment data, not the
summary; a clean follow-up).
- Re-backfilling on every geometry edit (backfill is one-shot per row unless
re-triggered).
## Decisions
### D1: Two derivation paths into one stored summary
Both paths produce the same compact, distance-weighted summary —
`{ surface: { asphalt: <m>, gravel: <m>, … }, highway: { … } }` (metres per
category) — stored as nullable `surface_breakdown` jsonb on `routes` and
`activities`. The bars need proportions, not geometry, so we store a summary,
not per-segment data (per-segment is a future map-colouring concern).
- **Path 1 — synchronous, Planner routes (accurate, free).** The Planner has
100%-accurate per-segment tags; compute the summary at save and persist via an
extra optional `surfaceBreakdown` field in the handoff. No external call.
- **Path 2 — async backfill, everything else (best-effort).** For a row with
geometry but no summary, a job map-matches to OSM and computes it (D2).
### D2: The async backfill job
A typed `surface-backfill` pg-boss job keyed by `{ kind: "route" | "activity",
id }`:
1. Load the geometry; bail if absent or already has a summary.
2. Query Overpass for `way[highway]` within the route bbox (a journal-side
client mirroring the Planner's `buildQuery`/`parseResponse` + rate-limit
handling).
3. **Map-match**: for each route segment, find the nearest OSM way and read its
`surface`/`highway`; bucket the segment's length. Unmatched/untagged →
`unknown`.
4. Store the summary; emit the SSE event (D3).
- **Triggers:** enqueued after a GPX import (Komoot/Garmin) and after a manual
upload; plus a one-off sweep job to backfill pre-existing rows. Optionally
enqueued on-demand when a detail page without a summary is opened.
- **Resilience:** pg-boss retries cover Overpass rate-limits/outages; the job is
idempotent (skips if a summary already exists) and best-effort (failure leaves
the row with no bars, not an error).
### D3: SSE live update
On backfill completion the job publishes a `surface_breakdown` SSE event
(payload: `{ kind, id }`) via the existing broker. The detail page subscribes
(EventSource hook) and, when the event matches the row it's showing, re-fetches
the breakdown and fills the bars — so a user who opened the page before the job
finished sees it appear without reloading. While a backfill is known to be
in-flight, the page may show a small "computing…" affordance.
### D4: Rendering
A `SurfaceBreakdown` component: one horizontal stacked bar per dimension
(surface, then waytype), segments sized by percentage and coloured via
`SURFACE_COLORS`/`HIGHWAY_COLORS` (`DEFAULT_*` for unknown), with a legend
(category · km · %, largest first). Unknown/unmapped tags collapse into "other".
Hidden when the summary is null/empty. Labels via i18n; distances via `stats.ts`.
### D5: Privacy (backfill sends geometry to Overpass)
Path 1 sends nothing externally. Path 2's Overpass query exposes a route's
bounding box to the Overpass endpoint — a real consideration for a privacy-first
app. Mitigations: route the backfill through the **proxied / self-hostable**
Overpass (the parked `self-host-overpass` idea removes the third-party entirely),
and only the bbox + geometry needed for matching is sent. We document the
backfill's external lookup in the privacy manifest; a self-hosted Overpass is the
clean long-term answer.
## Risks / Trade-offs
- **Map-matching is approximate** — nearest-way snapping mismatches at junctions
/ parallel ways; acceptable for a proportional overview, and Path 1 (exact) is
preferred whenever available.
- **Overpass cost/limits** — bounded by the async queue + retries; the sweep
should throttle. A self-hosted Overpass removes the ceiling.
- **Two code paths** — both converge on one stored summary + one renderer, so
only the *derivation* differs.

View file

@ -0,0 +1,57 @@
## Why
"How much of this is gravel?" is a top-three question for choosing a route, and
Komoot's surface/waytype bars are one of its most-used features.
`@trails-cool/map-core` already ships the surface/highway colour palettes, and
the Planner already *computes* accurate per-segment surface/highway from BRouter
(`route-merge``EnrichedRoute.surfaces[]`) — then **discards it on save**.
This change captures that data, shows it as proportion bars, and **backfills
everything else** (imports, uploads, older rows) asynchronously so the bars
aren't limited to Planner routes.
## What Changes
- On **route and activity detail**, show surface and waytype **proportion bars**
(e.g. "62% asphalt · 28% gravel · 10% path"), coloured with the `map-core`
`SURFACE_COLORS` / `HIGHWAY_COLORS` palettes, with per-category km/percent.
- **Path 1 — synchronous (Planner routes):** compute a distance-weighted
surface + waytype distribution from the BRouter waytags at save time, send it
in the planner→journal handoff, store it. Accurate, free, no external call.
- **Path 2 — async backfill (everything else):** for a route/activity that has
geometry but no breakdown (imports, manual uploads, pre-existing rows), a
background job map-matches the geometry to OSM ways via Overpass, computes the
breakdown, stores it, and **pushes an SSE event** so an open detail page fills
the bars in live.
- Degrade gracefully: no geometry / not-yet-backfilled → no bars (or a
"computing…" state while a backfill is in flight).
## Capabilities
### New Capabilities
- `route-surface-breakdown`: the surface/waytype proportion bars on route &
activity detail — derived synchronously from BRouter waytags (Planner) or
asynchronously via an Overpass backfill job with an SSE live-update.
### Modified Capabilities
<!-- `planner-journal-handoff` gains one optional payload field; `background-jobs`
and `sse-broker` gain a new job + event, but those are additive uses of
existing transports, owned by this new capability. -->
## Impact
- **Schema**: nullable `surface_breakdown` jsonb on `journal.routes` **and**
`journal.activities` (additive — `pnpm db:push`).
- **Planner**: `api.save-to-journal` computes + sends the breakdown.
- **Journal**:
- route callback stores the synchronous breakdown;
- a typed `surface-backfill` pg-boss job (Overpass map-match → breakdown →
store → SSE), enqueued after imports/uploads and available as a sweep;
- a journal-side Overpass way/surface client (reusing the existing
`/api/overpass` proxy pattern);
- an SSE `surface_breakdown` event + a detail-page subscription;
- a `SurfaceBreakdown` component (reuses `map-core` palettes + `stats.ts`).
- **i18n**: `journal.surface.*` (en + de).
- **Privacy**: the backfill sends route geometry to Overpass — see design for
the proxy/self-host + visibility considerations.

View file

@ -0,0 +1,60 @@
## ADDED Requirements
### Requirement: Surface breakdown derived from BRouter waytags
The system SHALL compute a distance-weighted surface and waytype distribution for a Planner-created route from its per-segment BRouter waytags at save time, and persist it with the route.
#### Scenario: Breakdown captured on save
- **WHEN** a route is saved from the Planner with per-segment surface/highway waytags
- **THEN** the route stores a distance-weighted breakdown of metres per surface category and per waytype category
#### Scenario: No waytags available
- **WHEN** a route is saved without waytags
- **THEN** no breakdown is stored synchronously (an async backfill may fill it later)
### Requirement: Async surface backfill via Overpass
The system SHALL asynchronously derive a surface breakdown for a route or activity that has geometry but no stored breakdown, by map-matching its geometry to OpenStreetMap ways via Overpass in a background job, and SHALL store the resulting distance-weighted breakdown.
#### Scenario: Imported activity is backfilled
- **WHEN** an activity is imported (e.g. from Komoot) with geometry but no waytags
- **THEN** a backfill job map-matches its geometry to OSM ways and stores a surface/waytype breakdown
#### Scenario: Backfill is best-effort and idempotent
- **WHEN** a backfill job runs for a row that already has a breakdown
- **THEN** it does no work; and **WHEN** Overpass is unavailable, the row is left without a breakdown rather than erroring
#### Scenario: Untagged segments
- **WHEN** map-matched ways have no surface tag
- **THEN** those segments' distance is bucketed as unknown
### Requirement: Live update when a backfill completes
The detail page SHALL update its breakdown without a reload when a backfill for the row being viewed completes, via a server-sent event.
#### Scenario: Bars appear after backfill
- **WHEN** a user is viewing a route/activity detail page whose backfill finishes
- **THEN** the page receives a `surface_breakdown` event for that row and renders the bars without a manual reload
### Requirement: Surface and waytype proportion bars
The route and activity detail pages SHALL render the surface and waytype breakdown as proportion bars when the row has breakdown data, coloured with the shared surface/highway palettes, each segment labelled with its category, distance, and percentage.
#### Scenario: Bars shown for a row with a breakdown
- **WHEN** a user views a route or activity that has a stored surface breakdown
- **THEN** a surface proportion bar and a waytype proportion bar are shown, each segment coloured by category with the largest category first
#### Scenario: Percentages reflect distance share
- **WHEN** a route is 62% asphalt and 28% gravel and 10% path by distance
- **THEN** the surface bar's segments are sized 62% / 28% / 10% and labelled accordingly
#### Scenario: Unknown tags collapse to "other"
- **WHEN** some segments have no recognized surface tag
- **THEN** their distance is shown as an "other" segment rather than omitted
#### Scenario: Hidden without data
- **WHEN** a row has no stored breakdown
- **THEN** no breakdown bars are rendered
### Requirement: Localized breakdown labels
The breakdown SHALL render its category and unit labels through localized strings.
#### Scenario: German labels
- **WHEN** the UI locale is German
- **THEN** the breakdown's category and unit labels render in German

View file

@ -0,0 +1,41 @@
<!-- PHASE 1 (this PR): synchronous Planner path + bars. PHASE 2 (follow-up):
async Overpass backfill + SSE (sections 56, e2e in 7.3). -->
## 1. Shared derivation + shape
- [x] 1.1 `computeSurfaceBreakdown(coordinates, surfaces, highways)` in `@trails-cool/map-core``{ surface, highway }` metres; unit-tested.
- [x] 1.2 `SurfaceBreakdownSchema` (Zod) in `@trails-cool/api`.
## 2. Schema
- [x] 2.1 Nullable `surfaceBreakdown` jsonb on `journal.routes` **and** `journal.activities`; pushed to dev + e2e DBs.
## 3. Path 1 — synchronous (Planner routes)
- [x] 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).
- [x] 3.2 Journal route callback validates the optional field with `SurfaceBreakdownSchema` and persists via `updateRoute` (overwrite on re-save).
## 4. Render
- [x] 4.1 Route + activity detail loaders expose `surfaceBreakdown`.
- [x] 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`.
- [x] 4.3 i18n `journal.surface.*` (surface/waytype labels, common categories, "other") en + de.
## 5. Path 2 — async backfill + SSE (PHASE 2 — this PR)
- [x] 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.
- [x] 5.2 Map-matcher (`surface-match.server.ts`, pure): nearest OSM way per segment → surface/highway; unmatched → unknown. Unit-tested.
- [x] 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`.
- [x] 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.)
- [x] 5.5 Emits a `surface_breakdown` SSE event (`{ kind, id }`) to the owner; detail pages subscribe (`useSurfaceBackfillUpdates`) and `revalidate()` when it matches.
## 6. Privacy
- [x] 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
- [x] 7.1 Unit: `computeSurfaceBreakdown` weighting + unknown bucket; `matchSurfaces` nearest-way + unknown bucket.
- [x] 7.2 Component (jsdom): bars/proportions, largest-first, hidden when empty/null.
- [x] 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.)
- [x] 7.4 typecheck + lint + unit (map-core 36, journal 333) green; Phase 1 verified in the browser.