mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
04320c415d
commit
e61fe42bd1
38 files changed
+3466
-511
No files matched your search
+186
-2
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user