feat(workouts): accept an optional HR series on the batch contract

Extend POST /api/workouts/batch with an optional per-workout `samples`
array — a route-independent heart-rate series so indoor workouts still
yield a strain-relevant signal. Each entry is `{ t, hr?, speedMs?,
power?, cadence? }`; the array is capped at 30 000 points
(MAX_WORKOUT_HR_SAMPLES), covering a long endurance session sampled at
1 Hz while bounding the largest new workout-side write stream the same
way the 20 000-point route cap bounds the geometry.

Persistence rides the existing route-attachment path: the freshly
written workout id is resolved once (by composite key, or the manual
tuple), then both the route geometry and the HR series attach by FK
with skipDuplicates so a re-submitted batch stays idempotent. A workout
may carry both a GPS route and a canonical HR series, or just one.

GET /api/workouts/{id} returns the series alongside the route. OpenAPI
regenerated.

`samples` keeps first-write-wins/overwrite parity with the workout row
it hangs off: the 1:1 FK plus skipDuplicates means a re-ingest of an
existing workout never duplicates or mutates its series.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 22:02:46 +02:00
1 parent d247f6966a
commit 30be0c4e0d
8 files changed
+463 -13

No files matched your search

+13 -1
View File
@@ -125,7 +125,7 @@ coachPrefsSchema.meta({
createBatchWorkoutSchema.meta({
id: "CreateBatchWorkoutRequest",
description:
"v1.4.25 W16b — typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).",
"Typed workout batch ingest. Each entry is an HKWorkout-aligned record with an optional nested GeoJSON LineString route AND an optional route-independent per-workout heart-rate series (`samples`: `[{ t, hr?, speedMs?, power?, cadence? }]`, up to 30 000 points). The `samples` series is the strain-engine input for indoor workouts that have no GPS route. Up to 100 workouts per call; nested route geometry capped at 20 000 points. Withings server-to-server callers pass source: WITHINGS and ship no route (Withings reports aggregates only).",
});
const coachMessageFeedbackBody = z
@@ -501,6 +501,17 @@ const workoutRouteGeometry = z
})
.meta({ id: "WorkoutRouteGeometry" });
// v1.10.0 — route-independent per-workout heart-rate series. Present
// for indoor and outdoor workouts that shipped a `samples` array on
// ingest. `samples` mirrors the ingest shape `[{ t, hr?, speedMs?,
// power?, cadence? }]`; `sampleCount` is the denormalised length.
const workoutHrSeries = z
.object({
sampleCount: z.number().int().nonnegative(),
samples: z.unknown(),
})
.meta({ id: "WorkoutHrSeries" });
const workoutDetailResponse = z
.object({
id: z.string(),
@@ -520,6 +531,7 @@ const workoutDetailResponse = z
externalId: z.string().nullable(),
metadata: z.unknown().nullable(),
route: workoutRouteGeometry.nullable(),
samples: workoutHrSeries.nullable(),
canonicalId: z.string(),
})
.meta({ id: "WorkoutDetailResponse" });