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:
parent
ea10e9dd19
commit
fc5e361a11
6 changed files with 65 additions and 0 deletions
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-06-14
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<!-- PHASE 1 (this PR): synchronous Planner path + bars. PHASE 2 (follow-up):
|
||||
async Overpass backfill + SSE (sections 5–6, 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue