openspec: propose journal route/activity detail catch-up changes

Three OpenSpec proposals to bring the journal's route/activity detail and feed
closer to best-in-class outdoor apps, in the order we'll implement them:

- activity-sport-type: nullable sport enum on activities (hike/walk/run/
  ride/gravel/mtb/ski/other), set on create or normalized on import,
  shown as a badge + feed verb, federated as a Note PropertyValue.
- activity-stats: one reusable StatRow + canonical metric set, sport-aware
  avg pace/speed, moving-vs-elapsed time. Derive-only, no schema change.
- journal-elevation-profile: elevation profile chart + chart<->map
  "active distance" hover/click sync on the read-only detail pages.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-06-12 13:40:53 +02:00
parent e3e01a34ae
commit 26b59b9c95
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
15 changed files with 602 additions and 0 deletions

View file

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

View file

@ -0,0 +1,60 @@
## Context
`@trails-cool/gpx` already parses trackpoints with elevation; the detail loaders
already build GeoJSON for the map. The Planner's `elevation-map-interaction`
spec defines the hover/click/drag behavior in an editing context (Yjs-backed).
The Journal detail pages are read-only React Router routes that render a Leaflet
map (`ClientMap` / `RouteMapThumbnail.client`) but no chart.
## Goals / Non-Goals
**Goals**
- A self-contained, read-only elevation profile on both detail pages.
- A shared "active distance" so the map and chart highlight the same point.
**Non-Goals**
- No editing (the Planner owns reshape/drag-select-to-zoom); clicking the chart
centers the map but changes no data.
- No multi-series overlay (HR/pace/cadence) — we don't store per-point fitness
data, and it's out of scope/ethos.
- No schema or API changes; the series is derived from existing `gpx`.
## Decisions
### D1: Series derivation
A helper returns an array of `{ distance, elevation, lat, lng }` samples from
the GPX (reusing the gpx package's parse), downsampled to a sensible cap (e.g.
~500 points) for chart performance. Computed in the loader (server) so the chart
component stays presentational.
### D2: Chart rendering
An SVG area chart (no charting dependency): x = cumulative distance, y =
elevation, gradient fill keyed to gradient steepness using the existing
`map-core` elevation/gradient color scale. A summary strip beneath shows ascent,
descent, highest, and lowest — formatted via the shared stat helpers from
`activity-stats` when present, else local formatting.
### D3: Shared "active distance" state
The detail page lifts an `activeDistance | null` state above both the map and
the chart:
- Hovering the chart sets `activeDistance`; the map shows a marker at the
interpolated lat/lng for that distance.
- Hovering the route polyline on the map sets `activeDistance` (nearest sample);
the chart draws a crosshair/marker at that x.
- Clicking the chart pans/centers the map on that point.
This mirrors the Planner's interaction conceptually but is read-only and
implemented independently for the Journal surface.
### D4: Graceful absence
If the derived series has no elevation variation or no elevation data (e.g. a
flat import or a planned route without elevation), the chart is not rendered and
the existing gain/loss numbers stand alone.
## Risks / Trade-offs
- **Map↔chart coupling**: the shared state must avoid render thrash on hover;
throttle pointer updates and memoize the interpolation.
- **Reuse vs. duplication with the Planner**: we deliberately implement a
separate read-only component rather than lifting the Planner's editing
component, to keep the Journal free of Yjs/editing concerns. The shared piece
is the gpx series helper and the map-core color scale, not the component.

View file

@ -0,0 +1,50 @@
## Why
The Journal's route and activity detail pages show elevation only as two numbers
(gain/loss) even though the full elevation series is already parsed from the
GPX. Both Komoot and Strava treat the elevation profile as the second hero
object after the map, and the interaction that makes a route feel
"inspectable" — hovering the profile to move a marker on the map, and
vice-versa — is the single most satisfying detail-page feature. We already have
this interaction specced and built on the **Planner**
(`elevation-map-interaction`); this change ports a read-only version to the
Journal detail pages.
## What Changes
- Render an elevation profile chart on the route detail and activity detail
pages: a gradient-filled area chart (x = distance, y = elevation) with a
summary strip (ascent · descent · highest · lowest).
- Add a shared "active distance" interaction: hovering the chart highlights the
corresponding point on the Leaflet map (a marker), and hovering the route line
highlights the corresponding position on the chart. Both stay in sync.
- Clicking a point on the chart centers the map on that location (read-only —
no editing, unlike the Planner).
- Degrade gracefully: when the GPX has no usable elevation series, the chart is
omitted (the gain/loss numbers remain).
## Capabilities
### New Capabilities
- `journal-elevation-profile`: the elevation profile chart and the
chart↔map "active distance" interaction on the Journal's read-only route and
activity detail pages.
### Modified Capabilities
<!-- None. `elevation-map-interaction` describes the Planner's editing-context
interaction; this is a separate read-only capability on the Journal detail
pages. They share the "active distance" concept but not the same surface or
requirements. -->
## Impact
- **Journal app**: a new `ElevationProfile` chart component, a shared
"active distance" state lifted over the detail page's map + chart, and wiring
in `routes.$id.tsx` and `activities.$id.tsx`. Loaders expose the elevation
series (derived from existing `gpx`).
- **Packages**: reuse `@trails-cool/gpx` for the elevation/distance series and
`@trails-cool/map-core` for the elevation color scale; no new dependency.
- **i18n**: `journal.elevation.*` keys (en + de) for the summary labels.
- No schema or wire-contract change.

View file

@ -0,0 +1,41 @@
## ADDED Requirements
### Requirement: Elevation profile chart on detail pages
The route and activity detail pages SHALL render an elevation profile chart (distance on the x-axis, elevation on the y-axis) with a gradient fill and a summary strip showing ascent, descent, highest point, and lowest point, whenever the activity or route has a usable elevation series.
#### Scenario: Chart shown for a route with elevation
- **WHEN** a user views a route whose GPX contains an elevation series
- **THEN** an elevation profile chart is shown with a summary strip (ascent, descent, highest, lowest)
#### Scenario: Chart omitted without elevation
- **WHEN** an activity's GPX has no usable elevation data
- **THEN** no chart is rendered and the existing gain/loss figures remain
### Requirement: Chart-to-map highlight
The detail page SHALL highlight the corresponding location on the map when the user points at a position on the elevation chart.
#### Scenario: Hover chart highlights map
- **WHEN** the user moves the pointer along the elevation chart
- **THEN** a marker appears on the map at the location matching that distance along the route
### Requirement: Map-to-chart highlight
The detail page SHALL highlight the corresponding position on the elevation chart when the user points at the route line on the map.
#### Scenario: Hover map highlights chart
- **WHEN** the user moves the pointer along the route line on the map
- **THEN** the elevation chart marks the position matching the nearest point on the route
### Requirement: Click chart to center map
The detail page SHALL center the map on the corresponding location when the user clicks a position on the elevation chart, without modifying any data.
#### Scenario: Click chart pans map
- **WHEN** the user clicks a point on the elevation chart
- **THEN** the map centers on the matching location
- **AND** no route or activity data is changed
### Requirement: Localized summary labels
The elevation summary strip SHALL render its labels and units through localized strings.
#### Scenario: German summary labels
- **WHEN** the UI locale is German
- **THEN** the ascent, descent, highest, and lowest labels render in German

View file

@ -0,0 +1,28 @@
## 1. Series helper
- [ ] 1.1 Add a helper (in `@trails-cool/gpx` or the journal lib) that turns GPX into `{ distance, elevation, lat, lng }[]`, downsampled to ~500 points; unit-test it (incl. the no-elevation case → empty/invalid series).
- [ ] 1.2 Expose the series + summary (ascent/descent/high/low) from the route and activity detail loaders.
## 2. Chart component
- [ ] 2.1 Build a presentational `ElevationProfile` SVG area chart (gradient fill via the `map-core` elevation/gradient scale) with the ascent/descent/high/low summary strip.
- [ ] 2.2 Render nothing when the series has no usable elevation.
## 3. Active-distance interaction
- [ ] 3.1 Lift an `activeDistance | null` state above the detail page's map + chart.
- [ ] 3.2 Chart hover → set `activeDistance`; map shows a marker at the interpolated lat/lng.
- [ ] 3.3 Map route-line hover → set `activeDistance` (nearest sample); chart shows a crosshair/marker.
- [ ] 3.4 Chart click → center the map on that location (read-only). Throttle pointer updates; memoize interpolation.
## 4. Wire-in & i18n
- [ ] 4.1 Mount `ElevationProfile` + shared state in `routes.$id.tsx` and `activities.$id.tsx`.
- [ ] 4.2 Add `journal.elevation.*` keys (ascent, descent, highest, lowest) to en + de.
## 5. Tests & checks
- [ ] 5.1 Unit: series helper (sampling, summary numbers, no-elevation → omitted).
- [ ] 5.2 Component: chart renders for a series; renders nothing for an empty series.
- [ ] 5.3 E2E: open a route detail with elevation, assert the chart + summary appear; hovering the chart shows a map marker.
- [ ] 5.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green.