diff --git a/openspec/changes/activity-sport-type/.openspec.yaml b/openspec/changes/activity-sport-type/.openspec.yaml new file mode 100644 index 0000000..8fe2055 --- /dev/null +++ b/openspec/changes/activity-sport-type/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-12 diff --git a/openspec/changes/activity-sport-type/design.md b/openspec/changes/activity-sport-type/design.md new file mode 100644 index 0000000..6580d6e --- /dev/null +++ b/openspec/changes/activity-sport-type/design.md @@ -0,0 +1,79 @@ +## Context + +`journal.activities` stores `name`, `distance`, `duration`, `elevationGain/Loss`, +`geom`, and provenance via `sync_imports`, but nothing about the *kind* of +activity. The schema already models small closed sets as `text().$type()` +(see `visibility`, `audience`) rather than `pgEnum`, and imports already parse a +source sport string they currently discard (Komoot `tour.sport`). + +## Goals / Non-Goals + +**Goals** +- One nullable, normalized sport field, set on create or derived on import. +- Consistent display (badge + feed verb) across detail, feed, and profile. +- Federate the value without a breaking wire change. + +**Non-Goals** +- Routes do not get a sport field in this change (their `routingProfile` + already carries intent; revisit later if needed). +- No per-sport statistics, filtering UI, or sport-specific icons-as-data — this + change only lands the field and its basic display. Filtering/stats build on + it later. +- No backfill of historical imports (the column is nullable; old rows read as + "unspecified"). A one-off backfill can be a follow-up if desired. + +## Decisions + +### D1: Normalized enum, not passthrough +`SportType = "hike" | "walk" | "run" | "ride" | "gravel" | "mtb" | "ski" | "other"`, +defined as a TS union in `@trails-cool/db` and stored as +`text("sport_type").$type()` (nullable). Rationale: a closed set +gives stable icons/verbs/filters and matches the existing schema convention; +passing through raw provider strings would leak Komoot/Garmin taxonomies into +our UI and federation. + +### D2: Import normalization +A pure `mapSportType(raw: string): SportType` helper (unit-tested) maps source +strings to the enum, defaulting to `other`: + +| Source string (lowercased, normalized) | → | +|---|---| +| `hike`, `mountaineering`, `hiking` | `hike` | +| `walk`, `walking`, `snowshoe` | `walk` | +| `jogging`, `running`, `run` | `run` | +| `racebike`, `touringbicycle`, `citybike`, `e_racebike`, `e_touringbicycle`, `road` | `ride` | +| `gravel`, `gravelbike`, `gravelride` | `gravel` | +| `mountainbike`, `mountainbikeeasy`, `mountainbikeadvanced`, `e_mountainbike`, `mtb` | `mtb` | +| `skitour`, `nordic`, `skatingnordic`, `ski`, `crosscountryski` | `ski` | +| anything else / empty | `other` | + +Komoot's `tour.sport` is fed through this on bulk import. Garmin supplies no +sport → the import passes `undefined` (stored NULL). + +### D3: Display — a shared `SportBadge` +A small presentational component (icon + i18n label) rendered next to the +activity title on the detail page, on feed cards, and in the profile activity +list. When `sportType` is null it renders nothing (and the feed falls back to a +generic verb). The feed verb is derived from the sport via an i18n key +(`journal.activities.sport.verb.`), e.g. `run → "went for a run"`. +Icons are inline SVG keyed by sport (no new dependency). + +### D4: Federation +`activityToNote()` appends one more `PropertyValue` attachment +(`name: "sport", value: `) when set — mirroring how `distance-m` / +`duration-s` are already attached. Additive; consumers that ignore it are +unaffected. + +### D5: API contract +`sportType` is added as an optional field (`z.enum([...]).optional()`) to the +activity read schema and the create-request schema in +`packages/api/src/activities.ts`. Optional keeps it non-breaking for existing +clients. + +## Risks / Trade-offs + +- **Enum coverage**: e-bike and city-bike variants fold into `ride`/`mtb`; we + lose the e-bike distinction. Accepted for simplicity; can split later without + a breaking change (adding enum members is additive). +- **Clock/taxonomy drift on import**: provider strings evolve; `mapSportType` + defaults safely to `other` and is the single place to extend. diff --git a/openspec/changes/activity-sport-type/proposal.md b/openspec/changes/activity-sport-type/proposal.md new file mode 100644 index 0000000..bb0f2dc --- /dev/null +++ b/openspec/changes/activity-sport-type/proposal.md @@ -0,0 +1,52 @@ +## Why + +Activities have no notion of *what sport they are* — a hike, a gravel ride, and +a trail run are presented identically (a line on a map plus distance and +elevation). Komoot and Strava both lead with the sport: it drives the feed verb +("went hiking" / "was out on a ride"), an icon, filtering, and later per-sport +stats. We already receive the sport on import (Komoot sends it on every tour) +and throw it away. A single nullable field is the smallest net-new addition +that unblocks the most downstream presentation work. + +## What Changes + +- Add a nullable `sportType` enum to activities: `hike · walk · run · ride · + gravel · mtb · ski · other` (nullable = "unspecified", so all existing rows + and hand-created activities stay valid). +- Expose `sportType` on the activity create form as a `` to `activities.new.tsx` (optional, i18n labels). + +## 3. Read path & display + +- [ ] 3.1 Add `sportType` to the detail, feed, and profile loaders' activity projections. +- [ ] 3.2 Build a shared `SportBadge` (inline-SVG icon + i18n label); render it on the detail page, feed cards, and profile list. +- [ ] 3.3 Derive the feed verb from the sport (generic fallback when unset). +- [ ] 3.4 Add `journal.activities.sport.*` keys (label, per-sport names, per-sport verbs) to en + de. + +## 4. Federation + +- [ ] 4.1 Append a `sport` PropertyValue attachment in `activityToNote()` when set; extend the federation-outbox test to assert it. + +## 5. Tests & checks + +- [ ] 5.1 Unit: `mapSportType` table (known sports, unknown → `other`, empty → `other`). +- [ ] 5.2 Unit/integration: create→detail round-trip persists and returns `sportType`. +- [ ] 5.3 E2E: create an activity with a sport, assert the badge renders on the detail page. +- [ ] 5.4 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green. diff --git a/openspec/changes/activity-stats/.openspec.yaml b/openspec/changes/activity-stats/.openspec.yaml new file mode 100644 index 0000000..8fe2055 --- /dev/null +++ b/openspec/changes/activity-stats/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-12 diff --git a/openspec/changes/activity-stats/design.md b/openspec/changes/activity-stats/design.md new file mode 100644 index 0000000..da563d4 --- /dev/null +++ b/openspec/changes/activity-stats/design.md @@ -0,0 +1,66 @@ +## Context + +Activities store `distance` (m), `duration` (s, elapsed), `elevationGain/Loss` +(m), `startedAt`, and `gpx`. Routes store distance + elevation. The three +surfaces that render these (feed, detail, profile) each hand-roll their own +markup and pick a different subset. There is no shared formatter and no derived +pace/speed. + +## Goals / Non-Goals + +**Goals** +- One presentation component and one set of formatting/derivation helpers. +- Add average pace/speed and moving-vs-elapsed time as first-class headline + metrics. + +**Non-Goals** +- No imperial/metric toggle in this change — render metric (km, m, km/h, min/km) + as the baseline; a units preference is a separate future change. +- No per-point analytics charts (that's `journal-elevation-profile`). +- No schema or API changes; everything is derived from existing fields. + +## Decisions + +### D1: One `StatRow` component +A presentational `StatRow` takes an ordered list of `{ value, label, unit }` +items and renders the shared layout (value prominent, small caption beneath, +even spacing). Feed/detail/profile pass the items they have; the component never +fetches. A `formatStat` helper centralizes number/unit formatting and goes +through i18n. + +### D2: Canonical headline set +The headline set, in order, is: **distance · time · avg pace/speed · ascent · +descent**. Surfaces may render a subset (e.g. the compact feed card may show +distance · time · pace), but never reorder or relabel — the subset is a prefix +or documented selection of the canonical set. + +### D3: Average pace vs. speed (sport-aware) +`avgSpeed = distance / movingTime` (fallback elapsed). Presentation chooses: +- **pace** (min/km) for foot sports (`hike`, `walk`, `run`), +- **speed** (km/h) for wheeled/ski sports (`ride`, `gravel`, `mtb`, `ski`), +- default to **speed** when sport type is unset. +This depends on `activity-sport-type`; if that field is absent the rule still +resolves (speed). + +### D4: Moving vs. elapsed time +- **Elapsed** = stored `duration`. +- **Moving** = derived from GPX trackpoint timestamps by summing inter-point + intervals and excluding intervals where the athlete is effectively stationary + (below a small speed threshold, e.g. < 0.5 m/s, with a max-gap guard). The + derivation lives in `@trails-cool/gpx` as a pure, unit-tested helper. +- When the GPX has no trackpoint timestamps (planned routes, some imports), + moving time is unavailable and only elapsed is shown. + +### D5: Formatting +`formatStat` formats distance (km, 1 decimal < 100 km), elevation (m, integer), +time (h:mm or m:ss), speed (km/h, 1 decimal), pace (m:ss /km). Labels and unit +suffixes come from `journal.stats.*` i18n keys (en + de). + +## Risks / Trade-offs + +- **Moving-time heuristic**: stop detection is approximate; the threshold is a + single documented constant in the gpx helper, tunable later. When in doubt we + fall back to elapsed, so we never show a moving time longer than elapsed. +- **Subset discipline**: allowing surfaces to render a subset risks drift; the + canonical order + a shared component keeps it in check, and the spec forbids + reordering/relabeling. diff --git a/openspec/changes/activity-stats/proposal.md b/openspec/changes/activity-stats/proposal.md new file mode 100644 index 0000000..76954f7 --- /dev/null +++ b/openspec/changes/activity-stats/proposal.md @@ -0,0 +1,49 @@ +## Why + +Distance, duration, and elevation are rendered ad-hoc and inconsistently across +the feed card, the activity/route detail page, and the profile list — different +spacing, different label casing, and a different subset of metrics on each +surface. Komoot and Strava both lead with one crisp, repeated metric row. We +also discard derivable headline numbers: **average pace/speed** (we have +distance + duration) and **moving vs. elapsed time**. A single reusable stat +component plus those derived metrics makes every surface scannable and is a +natural piece of the launch-blocking visual redesign. + +## What Changes + +- Introduce one reusable `StatRow` presentation component (value + localized + label + unit), used identically on the feed card, the activity & route detail + pages, and the profile activity list. +- Define the canonical headline metric set and render it consistently: + distance, time, average pace **or** speed, ascent, descent. +- Derive **average pace or speed** and choose the right one per sport (pace for + foot sports, speed for wheeled/ski), degrading to speed when sport is unset. +- Show **moving time** alongside **elapsed time** when moving time can be + derived from GPX trackpoint timestamps; otherwise show elapsed only. +- Localized number/unit formatting via i18n. + +## Capabilities + +### New Capabilities + +- `activity-stats`: the canonical headline-metric set, the derived metrics + (average pace/speed, moving vs. elapsed time), and the reusable stat-row + presentation used across the feed, detail, and profile surfaces. + +### Modified Capabilities + + + +## Impact + +- **Journal app**: a new shared `StatRow` (and a small `formatStat`/derivation + helper); the feed, activity-detail, route-detail, and profile views adopt it; + their loaders expose the fields needed to derive the metrics (already mostly + present: distance, duration, startedAt, elevation, plus `gpx` for moving + time). +- **i18n**: `journal.stats.*` keys (en + de) for labels and units. +- **Builds on** `activity-sport-type` for the pace-vs-speed choice (graceful + fallback if that change is not yet merged). +- No schema or wire-contract change; metrics are derived from existing fields. diff --git a/openspec/changes/activity-stats/specs/activity-stats/spec.md b/openspec/changes/activity-stats/specs/activity-stats/spec.md new file mode 100644 index 0000000..177776d --- /dev/null +++ b/openspec/changes/activity-stats/specs/activity-stats/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Reusable stat row +The UI SHALL render activity and route headline metrics through one shared stat-row component (value, localized label, unit) used on the feed card, the activity and route detail pages, and the profile activity list. + +#### Scenario: Same component across surfaces +- **WHEN** distance is shown on the feed card, the detail page, and the profile list +- **THEN** all three render it via the shared stat-row component with the same label, unit, and formatting + +#### Scenario: Compact subset keeps order +- **WHEN** a surface renders a subset of the headline metrics +- **THEN** the subset preserves the canonical order and labels (no reordering or relabeling) + +### Requirement: Canonical headline metric set +The system SHALL define the canonical headline metric set, in order, as distance, time, average pace or speed, ascent, and descent. + +#### Scenario: Detail page shows the full set +- **WHEN** a user views an activity with distance, duration, and elevation data +- **THEN** distance, time, average pace/speed, ascent, and descent are shown in that order + +### Requirement: Average pace or speed, chosen by sport +The system SHALL compute an average rate from distance and time and SHALL present it as pace for foot sports (hike, walk, run) and as speed for wheeled or ski sports (ride, gravel, mtb, ski), defaulting to speed when the sport type is unset. + +#### Scenario: Pace for a run +- **WHEN** an activity with sport type `run` is shown +- **THEN** the rate is presented as pace (min/km) + +#### Scenario: Speed for a ride +- **WHEN** an activity with sport type `ride` is shown +- **THEN** the rate is presented as speed (km/h) + +#### Scenario: Default when sport is unset +- **WHEN** an activity has no sport type +- **THEN** the rate is presented as speed + +### Requirement: Moving time and elapsed time +The system SHALL display elapsed time and SHALL additionally display moving time when it can be derived from GPX trackpoint timestamps; when timestamps are unavailable, only elapsed time SHALL be shown. + +#### Scenario: Both shown when trackpoint times exist +- **WHEN** an activity's GPX has trackpoint timestamps with stationary periods +- **THEN** both moving time and elapsed time are shown, and moving time is not greater than elapsed time + +#### Scenario: Elapsed only when no timestamps +- **WHEN** an activity has no trackpoint timestamps +- **THEN** only elapsed time is shown + +### Requirement: Localized metric formatting +The system SHALL format metric values and unit suffixes through localized strings, covering distance, elevation, time, speed, and pace. + +#### Scenario: German units +- **WHEN** the UI locale is German +- **THEN** metric labels and unit suffixes render in German diff --git a/openspec/changes/activity-stats/tasks.md b/openspec/changes/activity-stats/tasks.md new file mode 100644 index 0000000..669b517 --- /dev/null +++ b/openspec/changes/activity-stats/tasks.md @@ -0,0 +1,27 @@ +## 1. Helpers + +- [ ] 1.1 Add a pure `movingTime(gpx)` helper to `@trails-cool/gpx` (sum inter-point intervals, exclude sub-threshold/stationary segments, max-gap guard); unit-test against fixtures with and without trackpoint times. +- [ ] 1.2 Add a `deriveRate({ distance, time, sportType })` helper returning `{ kind: "pace" | "speed", value }` with the sport→kind rule and speed default. +- [ ] 1.3 Add a `formatStat` helper (distance/elevation/time/speed/pace) routing labels + units through i18n. + +## 2. Component + +- [ ] 2.1 Build the shared `StatRow` (ordered `{ value, label, unit }` items; value-prominent layout; no data fetching). +- [ ] 2.2 Define the canonical headline order as a single exported constant the surfaces draw from. + +## 3. Adopt across surfaces + +- [ ] 3.1 Activity detail: replace the hand-rolled stats block with `StatRow` (full set incl. pace/speed + moving/elapsed). +- [ ] 3.2 Route detail: adopt `StatRow` (distance, ascent, descent; no time/pace). +- [ ] 3.3 Feed card: adopt `StatRow` compact subset (distance · time · pace/speed); expose the needed fields in the feed loader. +- [ ] 3.4 Profile activity list: adopt `StatRow` compact subset. + +## 4. i18n + +- [ ] 4.1 Add `journal.stats.*` keys (labels + unit suffixes for distance, elevation, time, speed, pace) to en + de. + +## 5. Tests & checks + +- [ ] 5.1 Unit: `movingTime` (with/without timestamps; moving ≤ elapsed), `deriveRate` (pace/speed/default), `formatStat`. +- [ ] 5.2 Component: `StatRow` renders ordered items; subset preserves order. +- [ ] 5.3 `pnpm typecheck && pnpm lint && pnpm test && pnpm test:e2e` green. diff --git a/openspec/changes/journal-elevation-profile/.openspec.yaml b/openspec/changes/journal-elevation-profile/.openspec.yaml new file mode 100644 index 0000000..8fe2055 --- /dev/null +++ b/openspec/changes/journal-elevation-profile/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-12 diff --git a/openspec/changes/journal-elevation-profile/design.md b/openspec/changes/journal-elevation-profile/design.md new file mode 100644 index 0000000..3c2ea37 --- /dev/null +++ b/openspec/changes/journal-elevation-profile/design.md @@ -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. diff --git a/openspec/changes/journal-elevation-profile/proposal.md b/openspec/changes/journal-elevation-profile/proposal.md new file mode 100644 index 0000000..ba6f36a --- /dev/null +++ b/openspec/changes/journal-elevation-profile/proposal.md @@ -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 + + + +## 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. diff --git a/openspec/changes/journal-elevation-profile/specs/journal-elevation-profile/spec.md b/openspec/changes/journal-elevation-profile/specs/journal-elevation-profile/spec.md new file mode 100644 index 0000000..cfce00c --- /dev/null +++ b/openspec/changes/journal-elevation-profile/specs/journal-elevation-profile/spec.md @@ -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 diff --git a/openspec/changes/journal-elevation-profile/tasks.md b/openspec/changes/journal-elevation-profile/tasks.md new file mode 100644 index 0000000..4ff4b2e --- /dev/null +++ b/openspec/changes/journal-elevation-profile/tasks.md @@ -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.