trails/openspec/changes/activity-stats/design.md
Ullrich Schäfer 26b59b9c95
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>
2026-06-12 13:56:24 +02:00

3 KiB

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.