mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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.
240 lines
7.7 KiB
TypeScript
240 lines
7.7 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
}
|