mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user