feat(workouts): enrich the per-workout detail view (#67)

The detail surface at /insights/workouts/[id] gains a real heart-rate
curve, effort zones, an aspect-true GPS route, and per-kilometre splits
— all read from data already stored, no new integration, no migration.

- HR curve: one server path with strict provenance — stored
  WorkoutSamples series first, a raw-PULSE window reconstruction second
  (quality-gated so sparse data hides rather than lies), nothing third.
  Adaptive bucketing keeps on-screen density constant across a 20-minute
  HIIT and a 6-hour ride; a min/max envelope surfaces intervals.
- Effort zones: five %HRmax bands via the Tanaka HRmax, with a device's
  own reported time-in-zone winning when present. Descriptive only.
- GPS route: self-rendered inline SVG with an equirectangular
  cos(midLat) longitude correction (fixes the ~40% horizontal stretch),
  aspect-clamped viewBox, RDP downsampling, start/end markers, and an
  optional elevation profile. Nothing leaves the origin — no map tiles.
- Per-km splits and a GPX export, both derived client/server-side from
  the geometry already on the payload.
- The endpoint extends additively (hrSeries, zones, splits, sportContext,
  a reserved insight seam) behind an opt-in compact=1 param that drops
  the raw sample/timestamp blobs for the web client; without the param
  the response is unchanged.
- Every data-less section now hides instead of rendering an empty card;
  the growing detail component is split into a workout-detail/ directory.
- List rows flag which sessions open into a rich detail.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 16:05:17 +02:00
1 parent 04320c415d
commit e61fe42bd1
38 files changed
+3466 -511

No files matched your search

+186 -2
View File
@@ -2668,14 +2668,23 @@ paths:
- Measurements
summary: Workout detail (v1.4.32)
description: Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that
resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Cross-user rows
surface as 404 (existence channel sealed).
resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive
enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`, reserved `aiInsight`) are computed
server-side. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and
`route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32
contract. Cross-user rows surface as 404 (existence channel sealed).
parameters:
- in: path
name: id
schema:
type: string
required: true
- in: query
name: compact
schema:
type: string
enum:
- "1"
responses:
"200":
description: Workout detail.
@@ -17429,6 +17438,10 @@ components:
anyOf:
- type: string
- type: "null"
hasRoute:
type: boolean
hasHrSeries:
type: boolean
required:
- id
- sportType
@@ -17441,6 +17454,8 @@ components:
- maxHr
- source
- externalId
- hasRoute
- hasHrSeries
additionalProperties: false
GetWorkoutResponse:
type: object
@@ -17553,6 +17568,26 @@ components:
anyOf:
- $ref: "#/components/schemas/WorkoutHrSeries"
- type: "null"
hrSeries:
anyOf:
- $ref: "#/components/schemas/WorkoutHrCurve"
- type: "null"
zones:
anyOf:
- $ref: "#/components/schemas/WorkoutZones"
- type: "null"
splits:
anyOf:
- type: array
items:
$ref: "#/components/schemas/WorkoutSplit"
- type: "null"
sportContext:
anyOf:
- $ref: "#/components/schemas/WorkoutSportContext"
- type: "null"
aiInsight:
type: "null"
canonicalId:
type: string
required:
@@ -17574,6 +17609,11 @@ components:
- metadata
- route
- samples
- hrSeries
- zones
- splits
- sportContext
- aiInsight
- canonicalId
additionalProperties: false
WorkoutRouteGeometry:
@@ -17602,6 +17642,150 @@ components:
- sampleCount
- samples
additionalProperties: false
WorkoutHrCurve:
type: object
properties:
source:
type: string
enum:
- workout_series
- pulse_window
bucketSec:
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
points:
type: array
items:
type: object
properties:
tSec:
type: integer
minimum: 0
maximum: 9007199254740991
mean:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
min:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
max:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
required:
- tSec
- mean
- min
- max
additionalProperties: false
envelope:
type: boolean
required:
- source
- bucketSec
- points
- envelope
additionalProperties: false
WorkoutZones:
type: object
properties:
model:
type: string
enum:
- whoop
- tanaka
hrMax:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
zones:
type: array
items:
type: object
properties:
zone:
type: integer
minimum: 1
maximum: 5
lowBpm:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
highBpm:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
seconds:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- zone
- lowBpm
- highBpm
- seconds
additionalProperties: false
required:
- model
- hrMax
- zones
additionalProperties: false
WorkoutSplit:
type: object
properties:
km:
type: integer
exclusiveMinimum: 0
maximum: 9007199254740991
durationSec:
type: integer
minimum: 0
maximum: 9007199254740991
paceSecPerKm:
type: integer
minimum: 0
maximum: 9007199254740991
required:
- km
- durationSec
- paceSecPerKm
additionalProperties: false
WorkoutSportContext:
type: object
properties:
count:
type: integer
minimum: 0
maximum: 9007199254740991
avgDurationSec:
type: integer
minimum: 0
maximum: 9007199254740991
avgDistanceM:
anyOf:
- type: number
- type: "null"
avgAvgHr:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
required:
- count
- avgDurationSec
- avgDistanceM
- avgAvgHr
additionalProperties: false
BatchWorkoutsResponse:
type: object
properties: