Merge pull request #530 from trails-cool/propose-detail-catchup-specs

openspec: propose journal detail catch-up (sport type, stats, elevation)
This commit is contained in:
Ullrich Schäfer 2026-06-12 13:57:24 +02:00 committed by GitHub
commit ee76945f20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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,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<Union>()`
(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<SportType>()` (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.<sport>`), 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: <sportType>`) 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.

View file

@ -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 `<select>` (optional).
- Map the source sport into our enum on import: Komoot tours carry a sport
string today (passed through but discarded); normalize it. Garmin's
notification payload has no sport, so imported Garmin activities stay
unspecified.
- Surface the sport on the activity detail page, feed cards, and the profile
activity list as a small badge (icon + label), and use it to phrase the feed
verb.
- Serialize the sport as a `PropertyValue` attachment on the ActivityPub `Note`
so federated peers can see it (additive, non-breaking).
## Capabilities
### New Capabilities
- `activity-sport-type`: the sport/activity-type field on activities — its
enum, how it is set (create form + import normalization), how it is displayed
(badge + feed verb), and how it is federated.
### Modified Capabilities
<!-- None. Display in the feed/profile is folded into the new capability rather
than re-opening activity-feed / public-profiles requirements; those specs
describe aggregation/listing behavior, not per-activity attributes. -->
## Impact
- **Schema**: new nullable `sport_type` column on `journal.activities`
(additive — `pnpm db:push`, no data migration).
- **Wire contract**: `packages/api` activity read + create schemas gain an
optional `sportType`.
- **Journal app**: `ActivityInput`/`createActivity`, the create route + action,
the unified `importActivity` + Komoot import mapping, the detail/feed/profile
loaders and views, a shared `SportBadge` component, the ActivityPub
serializer (`federation-objects.server.ts`).
- **i18n**: `journal.activities.sport.*` keys (en + de).
- No breaking changes; the field is optional everywhere.

View file

@ -0,0 +1,62 @@
## ADDED Requirements
### Requirement: Activity sport type field
An activity SHALL have an optional sport type drawn from a fixed set: `hike`,
`walk`, `run`, `ride`, `gravel`, `mtb`, `ski`, `other`. The field MAY be unset
(unspecified), and an unset value SHALL be valid for any activity.
#### Scenario: Sport type omitted
- **WHEN** an activity is created without a sport type
- **THEN** the activity is stored with no sport type
- **AND** it renders without a sport badge and with a generic feed verb
#### Scenario: Sport type out of range is rejected
- **WHEN** a create or update request supplies a sport type outside the fixed set
- **THEN** the request is rejected by schema validation
### Requirement: Setting sport type on create
The activity create form SHALL offer an optional sport-type selector, and the
chosen value SHALL be persisted with the activity.
#### Scenario: User selects a sport on create
- **WHEN** a user picks "Gravel" in the sport selector and creates the activity
- **THEN** the activity is stored with sport type `gravel`
- **AND** the detail page shows the gravel badge
### Requirement: Sport type normalization on import
The system SHALL normalize a connected service's sport/activity descriptor into the fixed set when importing an activity; descriptors with no confident match SHALL normalize to `other`, and providers that supply no descriptor SHALL leave the sport type unset.
#### Scenario: Komoot tour with a known sport
- **WHEN** a Komoot tour with sport `mountainbike` is imported
- **THEN** the created activity has sport type `mtb`
#### Scenario: Komoot tour with an unrecognized sport
- **WHEN** a Komoot tour with an unrecognized sport string is imported
- **THEN** the created activity has sport type `other`
#### Scenario: Provider without a sport descriptor
- **WHEN** a Garmin activity (no sport in its notification payload) is imported
- **THEN** the created activity has no sport type
### Requirement: Sport type display
The UI SHALL show a set sport type as a badge (icon plus localized label) wherever an activity is presented (detail page, feed card, profile activity list), and the feed verb SHALL reflect the sport; an unset sport type SHALL show no badge.
#### Scenario: Badge on the detail page
- **WHEN** a user views an activity whose sport type is `hike`
- **THEN** a hike badge with a localized label appears next to the title
#### Scenario: Sport-aware feed verb
- **WHEN** an activity with sport type `run` appears in the feed
- **THEN** the card phrasing reflects running (e.g. "went for a run")
#### Scenario: Localized label
- **WHEN** the UI locale is German
- **THEN** the sport badge label is rendered in German
### Requirement: Sport type federation
The ActivityPub serializer SHALL include a set sport type as an attachment when serializing an activity, without altering the existing object shape.
#### Scenario: Sport included in the Note
- **WHEN** an activity with sport type `ride` is federated as a Note
- **THEN** the Note carries a `sport` property value of `ride`
- **AND** the previously-emitted properties (distance, duration, elevation) are unchanged

View file

@ -0,0 +1,30 @@
## 1. Data & contract
- [ ] 1.1 Add `SportType` union + `SPORT_TYPES` const to `@trails-cool/db`; add nullable `sportType: text("sport_type").$type<SportType>()` to `journal.activities`.
- [ ] 1.2 `pnpm db:push` against the local DB (additive nullable column, no data migration).
- [ ] 1.3 Add optional `sportType` to the activity read + create Zod schemas in `packages/api/src/activities.ts`.
## 2. Write path
- [ ] 2.1 Add `sportType?: SportType` to `ActivityInput` and persist it in `createActivity` (`apps/journal/app/lib/activities.server.ts`).
- [ ] 2.2 Add `mapSportType(raw: string): SportType` helper with the normalization table; unit-test it.
- [ ] 2.3 Thread `sportType` through the unified `importActivity` input; pass `mapSportType(tour.sport)` from the Komoot bulk import; leave Garmin unset.
- [ ] 2.4 Parse the `sportType` form field in `activitiesNewAction` and add the `<select>` 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.

View file

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

View file

@ -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.

View file

@ -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
<!-- None at the requirement level. The feed (`activity-feed`) and profile
(`public-profiles`) specs describe aggregation/listing, not the metric set;
the shared presentation is a new capability they consume. -->
## 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.

View file

@ -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

View file

@ -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.

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.