diff --git a/prisma/migrations/0107_v1100_workout_hr_series/migration.sql b/prisma/migrations/0107_v1100_workout_hr_series/migration.sql new file mode 100644 index 000000000..bf5dcb61c --- /dev/null +++ b/prisma/migrations/0107_v1100_workout_hr_series/migration.sql @@ -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 +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2881102b3..82065d908 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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