Files
HealthLog/src/lib/workouts/hr-series.ts
T
Marc-André Bombeck e61fe42bd1 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.
2026-07-18 16:05:17 +02:00

240 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Per-workout heart-rate curve — one server code path, one DTO with
* explicit provenance.
*
* Source priority (strict):
* 1. `WorkoutSamples` (stored series) — the session's own sensor
* stream, highest fidelity. Used whenever the parsed series yields
* ≥ 2 usable HR points.
* 2. Pulse-window reconstruction — raw `PULSE` measurement rows in
* `[startedAt − 5 min, endedAt + 5 min]`, bucketed at session
* grain. Only attempted for workouts younger than the dense
* retention window (beyond it only hourly folds survive, which are
* useless for a session curve), and only when the fallback quality
* gate passes.
* 3. Nothing — the caller hides the HR-curve card. The avg/max/min
* aggregates already live in the stats grid; an empty chart lies.
*
* The fallback gate keeps the same posture as the tension detector:
* silence over speculation. A watchless ride with two opportunistic BPM
* readings must not paint a two-point "curve".
*/
import { prisma } from "@/lib/db";
/** Dense raw-PULSE retention. Older days keep only hourly folds. */
const DENSE_INTRADAY_RETENTION_DAYS = 90;
/** ± padding around the session for the fallback window. */
const WINDOW_PAD_MS = 5 * 60 * 1000;
/** Defensive read cap — dense accounts emit ~1 PULSE sample/second. */
const MAX_WINDOW_PULSE_ROWS = 6000;
/** Fallback gate: raw samples required in the padded window. */
const MIN_FALLBACK_SAMPLES = 8;
/** Fallback gate: fraction of session buckets that must be non-empty. */
const MIN_BUCKET_COVERAGE = 0.4;
/** Envelope band renders once buckets are dense enough to be meaningful. */
const ENVELOPE_MIN_MEDIAN_DENSITY = 3;
export type HrSeriesSource = "workout_series" | "pulse_window";
export interface HrSeriesPoint {
/** Elapsed seconds from the workout start (bucket left edge). */
tSec: number;
mean: number;
min: number;
max: number;
}
export interface WorkoutHrSeries {
source: HrSeriesSource;
bucketSec: number;
points: HrSeriesPoint[];
/**
* True when per-bucket density supports a min→max envelope band —
* this is what makes intervals read as spiky instead of averaged
* away. Below the threshold the chart draws the mean line only.
*/
envelope: boolean;
}
interface RawHrSample {
tMs: number;
hr: number;
}
function clamp(value: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, value));
}
/**
* Adaptive bucket width targeting a constant on-screen density: a
* 20-minute HIIT and a 6-hour ride both land at ≤ ~240–480 points.
*/
export function adaptiveBucketSec(durationSec: number): number {
return clamp(Math.ceil(durationSec / 240), 5, 60);
}
function median(values: number[]): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
/**
* Fold raw HR samples into session-grain buckets. Samples outside the
* session window `[0, durationSec)` are dropped from the curve (they
* only widen the DB query so a boundary reading still lands in bucket
* 0 / the last bucket). Empty buckets stay as gaps — no interpolation.
* Pure; exported for unit testing.
*/
export function foldHrBuckets(
samples: readonly RawHrSample[],
startMs: number,
durationSec: number,
bucketSec: number,
): { points: HrSeriesPoint[]; bucketCount: number; medianDensity: number } {
const bucketCount = Math.max(1, Math.ceil(durationSec / bucketSec));
const acc = new Map<
number,
{ sum: number; min: number; max: number; n: number }
>();
for (const s of samples) {
const rel = (s.tMs - startMs) / 1000;
if (rel < 0 || rel >= durationSec) continue;
const idx = Math.floor(rel / bucketSec);
const bucket = acc.get(idx);
if (bucket) {
bucket.sum += s.hr;
bucket.n += 1;
if (s.hr < bucket.min) bucket.min = s.hr;
if (s.hr > bucket.max) bucket.max = s.hr;
} else {
acc.set(idx, { sum: s.hr, min: s.hr, max: s.hr, n: 1 });
}
}
const points: HrSeriesPoint[] = [];
const densities: number[] = [];
for (const [idx, b] of [...acc.entries()].sort((a, c) => a[0] - c[0])) {
points.push({
tSec: idx * bucketSec,
mean: Math.round(b.sum / b.n),
min: b.min,
max: b.max,
});
densities.push(b.n);
}
return { points, bucketCount, medianDensity: median(densities) };
}
/**
* Parse the stored `WorkoutSamples.samples` JSONB into usable HR
* samples. The blob is validated at ingest against
* `workoutHrSamplesSchema`, so this stays a lean, defensive read rather
* than a full re-validation of up to 30 000 rows.
*/
function parseStoredSamples(raw: unknown): RawHrSample[] {
if (!Array.isArray(raw)) return [];
const out: RawHrSample[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object") continue;
const rec = entry as Record<string, unknown>;
const hr = rec.hr;
const t = rec.t;
if (typeof hr !== "number" || !Number.isFinite(hr)) continue;
if (typeof t !== "string") continue;
const tMs = Date.parse(t);
if (!Number.isFinite(tMs)) continue;
out.push({ tMs, hr });
}
return out;
}
export interface WorkoutHrSeriesInput {
userId: string;
startedAt: Date;
endedAt: Date;
durationSec: number;
storedSamples: unknown;
/** Reference clock for the retention cut — defaults to `now`. */
now?: Date;
}
/**
* Resolve the workout's HR curve. Returns `null` when neither a stored
* series nor an adequate pulse window exists.
*/
export async function buildWorkoutHrSeries(
input: WorkoutHrSeriesInput,
): Promise<WorkoutHrSeries | null> {
const { userId, startedAt, endedAt, durationSec, storedSamples } = input;
const now = input.now ?? new Date();
const startMs = startedAt.getTime();
const bucketSec = adaptiveBucketSec(durationSec);
// 1. Stored series — the native, highest-fidelity path.
const stored = parseStoredSamples(storedSamples);
if (stored.length >= 2) {
const { points, medianDensity } = foldHrBuckets(
stored,
startMs,
durationSec,
bucketSec,
);
if (points.length >= 2) {
return {
source: "workout_series",
bucketSec,
points,
envelope: medianDensity >= ENVELOPE_MIN_MEDIAN_DENSITY,
};
}
}
// 2. Pulse-window fallback — only within the dense-retention horizon.
const ageDays = (now.getTime() - startMs) / (24 * 60 * 60 * 1000);
if (ageDays > DENSE_INTRADAY_RETENTION_DAYS) return null;
const windowStart = new Date(startMs - WINDOW_PAD_MS);
const windowEnd = new Date(endedAt.getTime() + WINDOW_PAD_MS);
const rows = await prisma.measurement.findMany({
where: {
userId,
type: "PULSE",
deletedAt: null,
measuredAt: { gte: windowStart, lte: windowEnd },
},
orderBy: { measuredAt: "asc" },
take: MAX_WINDOW_PULSE_ROWS,
select: { value: true, measuredAt: true, externalId: true },
});
// Raw per-sample rows only — a consolidated `stats:` fold is a day
// shape, not a session reading.
const raw: RawHrSample[] = rows
.filter((r) => !r.externalId?.startsWith("stats:"))
.map((r) => ({ tMs: r.measuredAt.getTime(), hr: r.value }));
// Gate part 1: enough raw samples in the padded window.
if (raw.length < MIN_FALLBACK_SAMPLES) return null;
const { points, bucketCount, medianDensity } = foldHrBuckets(
raw,
startMs,
durationSec,
bucketSec,
);
// Gate part 2: enough of the session is actually covered.
if (points.length / bucketCount < MIN_BUCKET_COVERAGE) return null;
if (points.length < 2) return null;
return {
source: "pulse_window",
bucketSec,
points,
envelope: medianDensity >= ENVELOPE_MIN_MEDIAN_DENSITY,
};
}