feat(workouts): add a route-independent per-workout heart-rate series

Indoor workouts had no home for their per-sample heart-rate series: it
rode on WorkoutRoute.sampleTimestamps, which only exists when a workout
carries GPS geometry. A treadmill or trainer session therefore lost the
signal the training-strain reader needs.

Add a WorkoutSamples child (workout_samples), 1:1 with the workout like
WorkoutRoute, holding a JSONB `[{ t, hr?, speedMs?, power?, cadence? }]`
array plus a denormalised sample_count. JSONB keeps the self-hosted
Postgres extension-free, mirroring the route table's decision. Migration
0107.

Also retire the stale cross-source workout-dedup marker on the Workout
model: that work landed as the write-time dedupeWorkoutBatch() picker
plus the read-time pickCanonicalWorkoutRows() picker, both resolving the
user's source-priority ladder. The comment now documents the two layers
instead of tracking them as open.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 22:02:46 +02:00
1 parent 14d79ab453
commit d247f6966a
2 files changed
+101 -11

No files matched your search

@@ -0,0 +1,44 @@
-- v1.10.0 — per-workout heart-rate series (route-independent).
--
-- A companion to `workout_routes` that holds the per-sample HR series
-- WITHOUT requiring GPS geometry. The route table only exists when a
-- workout has a GeoJSON LineString, so an INDOOR session (treadmill,
-- trainer, strength) had nowhere to store its heart-rate samples even
-- though HealthKit ships them. That series feeds the training-strain
-- engine (TRIMP / time-in-HR-zone), so decoupling it from the route is
-- what unblocks indoor strain.
--
-- Shape rationale:
-- * `samples` JSONB — `[{ t: ISO string, hr?: int, speedMs?, power?,
-- cadence? }]`, one entry per recorded sample. Same JSONB decision
-- as `workout_routes.geometry` / `sample_timestamps`: no PostGIS
-- extension dependency on a self-hosted Postgres install, and the
-- blob mirrors HealthKit's per-sample shape directly.
-- * `sample_count` — denormalised `samples.length` so the strain
-- reader + observability can size the series without deserialising
-- the JSONB blob. The application caps the array length on write
-- (MAX_WORKOUT_HR_SAMPLES in src/lib/validations/workout.ts) so the
-- per-workout payload — and the largest new workout-side write
-- stream — stays bounded.
--
-- 1:1 with the workout: `workout_id` is UNIQUE with an ON DELETE CASCADE
-- FK, exactly like `workout_routes`. A re-submitted batch that wins the
-- workout-row race but loses the sample-row race is an idempotent no-op
-- (the ingest path uses `skipDuplicates`), not a hard error.
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility:
-- DROP TABLE IF EXISTS "workout_samples";
-- A roll-back loses the captured per-workout HR series; the workout row,
-- its aggregates (avg/max/min HR), and the route survive untouched.
CREATE TABLE IF NOT EXISTS "workout_samples" (
"id" TEXT PRIMARY KEY,
"workout_id" TEXT NOT NULL UNIQUE REFERENCES "workouts"("id") ON DELETE CASCADE,
-- JSONB array: [{ t: ISO string, hr?: int, speedMs?, power?, cadence? }]
"samples" JSONB NOT NULL,
-- Denormalised samples.length — sized without parsing the blob.
"sample_count" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+57 -11
View File
@@ -758,21 +758,28 @@ model Workout {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
route WorkoutRoute?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
route WorkoutRoute?
samples WorkoutSamples?
/// Ingest dedup. NULL externalId is distinct so two manual
/// entries on the same day never collide here.
///
/// TODO(v1.5 iOS): cross-source workout dedup. The current key is
/// (userId, source, externalId) — so the SAME workout ingested from
/// MANUAL plus the iOS HealthKit passthrough lands two rows because
/// `source` differs. Measurements already solved this via
/// `pickCanonicalSourceRows()` (the read-time canonical-source
/// picker in src/lib/analytics/source-priority.ts); workouts need
/// the symmetric helper before the dashboard "recent workouts" tile
/// can show one row per workout instead of one per source. Tracked
/// for v1.5 P1 alongside the iOS app.
/// Cross-source dedup (the SAME logical workout arriving from two
/// paired sensors — e.g. Apple Watch + Withings ScanWatch — under
/// different `source`/`externalId` pairs) is handled in two layers,
/// mirroring the measurement-side `pickCanonicalSourceRows()`:
/// * WRITE-time — `dedupeWorkoutBatch()` in
/// src/lib/workouts/canonical-rows.ts groups an inbound batch by
/// (userId, activityType, startedAt ± 90 s) and keeps one
/// canonical row per logical workout BEFORE the insert, so the
/// duplicate never reaches storage.
/// * READ-time — `pickCanonicalWorkoutRows()` in
/// src/lib/measurements/pick-canonical-workout-rows.ts collapses
/// any cross-batch twins (e.g. one source's row from Monday's
/// batch + the other's from Tuesday's backfill) so the dashboard
/// "recent workouts" tile shows one row per logical workout.
/// Both pickers resolve the user's `sourcePriorityJson` ladder.
@@unique([userId, source, externalId])
/// Dashboard "recent workouts" query path.
@@index([userId, startedAt])
@@ -801,6 +808,45 @@ model WorkoutRoute {
@@map("workout_routes")
}
// ─── Per-workout heart-rate (+ optional speed/power/cadence) series ──
//
// v1.10.0 — a route-independent companion to `WorkoutRoute`.
//
// The route table only exists when a workout has GPS geometry, so an
// INDOOR session (treadmill, bike trainer, strength) had no home for
// its per-sample heart-rate series even though HealthKit ships one.
// That series is the input the training-strain engine (TRIMP /
// time-in-HR-zone) needs, so decoupling it from the route is what
// unblocks indoor strain.
//
// `samples` is a JSONB array `[{ t, hr?, speedMs?, power?, cadence? }]`
// — `t` is an ISO timestamp, `hr` beats/min. Same JSONB-over-PostGIS
// decision as `WorkoutRoute` (no extension dependency on a self-hosted
// Postgres). 1:1 with the workout (FK `workout_id` UNIQUE) so a
// re-ingested batch that wins the workout-row race but loses the
// sample-row race is an idempotent no-op, exactly like the route table.
//
// `sample_count` is denormalised so the strain reader + observability
// can size the series without deserialising the JSONB blob. The ingest
// path caps the count (see MAX_WORKOUT_HR_SAMPLES in
// src/lib/validations/workout.ts) to keep the per-workout payload — and
// the largest new workout-side write stream — bounded.
model WorkoutSamples {
id String @id @default(cuid())
workoutId String @unique @map("workout_id")
/// JSONB array of `{ t: ISO string, hr?: int, speedMs?, power?,
/// cadence? }`. One entry per recorded sample; ordered by `t`
/// ascending at ingest. Capped at MAX_WORKOUT_HR_SAMPLES.
samples Json
/// Denormalised `samples.length` — sized without parsing the blob.
sampleCount Int @map("sample_count")
createdAt DateTime @default(now()) @map("created_at")
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
@@map("workout_samples")
}
// ─── Personal records (v1.4.25 W8d — schema only) ──────────
//
// MAX vs MIN direction is stored on the row so the future detection