Carry the workout heart-rate curve to the end of the session

`durationSec` is a denormalised duration, and not every source reports
the elapsed one. Strava sends `moving_time` while the row's `endedAt` is
`startedAt + elapsed_time`, so a ride with twenty minutes of stops
carries a duration twenty minutes shorter than its own span. The curve
folded against that duration, which dropped every reading after the
moving-time mark: the tail of the session vanished with no gap and no
marker, and the detail page showed a complete-looking profile of an
incomplete session.

Fold over the span instead, with the duration as the floor for rows
whose `endedAt` is not after their `startedAt`. The bucket width tracks
the span too, so a stop-heavy ride keeps the same on-screen density as a
continuous one of the same length.

The reconstruction path also gets an integration suite over the real
route and a real Postgres. Two boundaries decide whether it is honest
and neither was provable against a mocked client: the query pads by five
minutes so a boundary reading still lands in the first or last bucket,
and a reading inside that pad must not extend the curve past the finish;
the measurement read is owner-scoped, and another account's pulse at the
same minute must not enter this curve. Both fixtures place a reading on
the far side with a bpm value no in-window reading has, so a leak shows
up in the numbers rather than only in a count. Verified by breaking each
boundary in turn and watching the matching assertion fail.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 14:41:24 +02:00
1 parent d5f176ac4d
commit 2f14379066
3 files changed
+382 -9

No files matched your search

@@ -180,3 +180,74 @@ describe("buildWorkoutHrSeries", () => {
expect(findMany).not.toHaveBeenCalled();
});
});
/**
* `durationSec` is a denormalised DURATION, and not every source reports
* the elapsed one: Strava sends `moving_time` while the row's `endedAt`
* is `startedAt + elapsed_time`. A ride with 20 minutes of stops
* therefore carries a `durationSec` 20 minutes shorter than its own
* wall-clock span, and folding the curve against it drops every reading
* after the moving-time mark — the tail of the session vanishes with no
* gap, no marker, nothing that reads as missing.
*/
describe("buildWorkoutHrSeries — sessions whose duration is not their span", () => {
const SPAN_SEC = 3600;
const MOVING_SEC = 2400;
const strava = {
userId: "u1",
startedAt: START,
endedAt: new Date(startMs + SPAN_SEC * 1000),
durationSec: MOVING_SEC,
now: new Date(startMs + SPAN_SEC * 1000 + 1000),
};
it("carries a stored series to the end of the session, not to the moving-time mark", async () => {
const stored = Array.from({ length: SPAN_SEC / 15 }, (_, i) =>
storedSample(i * 15, 120 + (i % 20)),
);
const result = await buildWorkoutHrSeries({
...strava,
storedSamples: stored,
});
expect(result).not.toBeNull();
const lastTSec = result!.points[result!.points.length - 1].tSec;
// The final bucket sits within one bucket width of the session end.
expect(lastTSec).toBeGreaterThanOrEqual(SPAN_SEC - result!.bucketSec);
expect(lastTSec).toBeLessThan(SPAN_SEC);
});
it("carries a pulse-window series past the moving-time mark too", async () => {
const rows = Array.from({ length: SPAN_SEC / 20 }, (_, i) => ({
value: 125 + (i % 9),
measuredAt: new Date(startMs + i * 20_000),
externalId: null,
}));
findMany.mockResolvedValue(rows);
const result = await buildWorkoutHrSeries({
...strava,
storedSamples: null,
});
expect(result).not.toBeNull();
expect(result!.source).toBe("pulse_window");
const lastTSec = result!.points[result!.points.length - 1].tSec;
expect(lastTSec).toBeGreaterThan(MOVING_SEC);
// The rows sit 20 s apart, so the final one can miss the very last
// bucket; landing within a sample interval of the end is the claim.
expect(lastTSec).toBeGreaterThanOrEqual(SPAN_SEC - 20 - result!.bucketSec);
});
it("keeps the duration as the floor when the row carries no usable span", async () => {
const stored = Array.from({ length: 40 }, (_, i) =>
storedSample(i * 15, 130),
);
const result = await buildWorkoutHrSeries({
...strava,
// A row whose end is not after its start: the span is unusable, so
// the denormalised duration has to stay the fold window.
endedAt: START,
storedSamples: stored,
});
expect(result).not.toBeNull();
expect(result!.bucketSec).toBe(adaptiveBucketSec(MOVING_SEC));
});
});
+41 -9
View File
@@ -82,27 +82,55 @@ function median(values: number[]): number {
: sorted[mid];
}
/**
* The session's own length on the wall clock.
*
* `durationSec` is a denormalised DURATION, and not every source reports
* the elapsed one — Strava sends `moving_time` while the row's `endedAt`
* is `startedAt + elapsed_time` (`src/lib/strava/client.ts`). A ride with
* twenty minutes of stops therefore carries a `durationSec` twenty
* minutes shorter than its own span, and a curve folded against it ends
* at the moving-time mark with no gap and no marker: the tail of the
* session simply is not there. The curve covers the session, so the span
* is what it folds over.
*
* The duration stays the floor. A row whose `endedAt` is not after its
* `startedAt` (a manual entry saved with one timestamp, a provider that
* shipped an end it could not resolve) has no usable span, and every
* source that computes `endedAt` from the duration lands on the same
* number either way.
*/
export function sessionSpanSec(
startedAt: Date,
endedAt: Date,
durationSec: number,
): number {
const floor = Math.max(0, durationSec);
const span = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
return Number.isFinite(span) && span > floor ? span : floor;
}
/**
* 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.
* session window `[0, spanSec)` 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,
spanSec: number,
bucketSec: number,
): { points: HrSeriesPoint[]; bucketCount: number; medianDensity: number } {
const bucketCount = Math.max(1, Math.ceil(durationSec / bucketSec));
const bucketCount = Math.max(1, Math.ceil(spanSec / 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;
if (rel < 0 || rel >= spanSec) continue;
const idx = Math.floor(rel / bucketSec);
const bucket = acc.get(idx);
if (bucket) {
@@ -171,7 +199,11 @@ export async function buildWorkoutHrSeries(
const { userId, startedAt, endedAt, durationSec, storedSamples } = input;
const now = input.now ?? new Date();
const startMs = startedAt.getTime();
const bucketSec = adaptiveBucketSec(durationSec);
// Fold over the session's span, not its duration — see `sessionSpanSec`.
// The bucket width tracks the span too, so a stop-heavy ride keeps the
// same on-screen point density as a continuous one of the same length.
const spanSec = sessionSpanSec(startedAt, endedAt, durationSec);
const bucketSec = adaptiveBucketSec(spanSec);
// 1. Stored series — the native, highest-fidelity path.
const stored = parseStoredSamples(storedSamples);
@@ -179,7 +211,7 @@ export async function buildWorkoutHrSeries(
const { points, medianDensity } = foldHrBuckets(
stored,
startMs,
durationSec,
spanSec,
bucketSec,
);
if (points.length >= 2) {
@@ -222,7 +254,7 @@ export async function buildWorkoutHrSeries(
const { points, bucketCount, medianDensity } = foldHrBuckets(
raw,
startMs,
durationSec,
spanSec,
bucketSec,
);
@@ -0,0 +1,270 @@
/**
* Integration suite for the heart-rate curve on `GET /api/workouts/{id}`.
*
* The curve is the one part of the workout-detail envelope that is
* assembled from rows the workout does not own: when a session carries no
* stored sensor stream, the server reconstructs it from the account's raw
* `PULSE` measurements around the session. Two boundaries decide whether
* that reconstruction is honest, and neither is provable from a unit test
* with a mocked Prisma:
*
* - the WINDOW. A reading taken after the session ended is not part of
* the session. The query pads by five minutes so a boundary reading
* still lands in the first / last bucket, and the fold then clamps to
* the session itself — a pad reading must not extend the curve past
* the finish.
* - the OWNER. The measurement read is scoped to the calling account.
* Another account's pulse at the same wall-clock minute must not
* enter this curve.
*
* Both run through the real route against a real Postgres, and the
* fixture places a reading on the far side of each boundary with a bpm
* value no in-window reading has, so a leak is visible in the numbers
* rather than only in a count.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
process.env.ENCRYPTION_KEY =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_USER_ID = "user-workout-hr-series";
const OTHER_USER_ID = "user-workout-hr-series-other";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const { GET } = await import("@/app/api/workouts/[id]/route");
/** Session window: 07:00 → 07:30 on a fixed day. */
const STARTED_AT = new Date("2026-05-15T07:00:00.000Z");
const ENDED_AT = new Date("2026-05-15T07:30:00.000Z");
const SPAN_SEC = 1800;
/**
* In-window readings sit in this band; every out-of-window reading is
* placed outside it, so an assertion on the curve's bpm bounds names any
* leak directly.
*/
const IN_WINDOW_LOW = 120;
const IN_WINDOW_HIGH = 168;
/** After the finish but inside the query's five-minute pad. */
const PAD_TAIL_BPM = 44;
/** Well outside the padded window on either side. */
const FAR_BEFORE_BPM = 201;
const FAR_AFTER_BPM = 51;
/** Another account's reading in the middle of this session. */
const FOREIGN_BPM = 199;
interface HrSeriesPoint {
tSec: number;
mean: number;
min: number;
max: number;
}
interface WorkoutDetailBody {
data: {
id: string;
hrSeries: {
source: string;
bucketSec: number;
envelope: boolean;
points: HrSeriesPoint[];
} | null;
} | null;
error: string | null;
}
async function createUser(id: string, username: string) {
await getPrismaClient().user.create({
data: { id, username, email: `${username}@example.test` },
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await createUser(TEST_USER_ID, "workout-hr-series");
await createUser(OTHER_USER_ID, "workout-hr-series-other");
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
/**
* The session under test, with no stored sample stream — so the route has
* to reconstruct the curve from `PULSE` rows and the boundaries above are
* the ones that decide what it sees.
*/
async function createWorkout(): Promise<string> {
const row = await getPrismaClient().workout.create({
data: {
userId: TEST_USER_ID,
sportType: "running",
startedAt: STARTED_AT,
endedAt: ENDED_AT,
durationSec: SPAN_SEC,
avgHeartRate: 144,
maxHeartRate: IN_WINDOW_HIGH,
source: "MANUAL",
},
select: { id: true },
});
return row.id;
}
function pulseRow(userId: string, offsetSec: number, value: number) {
return {
userId,
type: "PULSE" as const,
value,
unit: "bpm",
measuredAt: new Date(STARTED_AT.getTime() + offsetSec * 1000),
};
}
/**
* A dense in-window sweep plus one reading on the far side of each
* boundary. The sweep alternates across the in-window band so the fold
* has a real shape to carry.
*/
async function seedPulse() {
const rows = [] as ReturnType<typeof pulseRow>[];
for (let offset = 0; offset < SPAN_SEC; offset += 5) {
const swing = Math.round(
((IN_WINDOW_HIGH - IN_WINDOW_LOW) / 2) *
(1 + Math.sin((offset / SPAN_SEC) * Math.PI * 4)),
);
rows.push(pulseRow(TEST_USER_ID, offset, IN_WINDOW_LOW + swing));
}
// Two minutes after the finish: inside the query's pad, outside the
// session. The query returns it; the curve must not.
rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 120, PAD_TAIL_BPM));
// Far outside the padded window on both sides.
rows.push(pulseRow(TEST_USER_ID, -20 * 60, FAR_BEFORE_BPM));
rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 15 * 60, FAR_AFTER_BPM));
// Another account, mid-session.
rows.push(pulseRow(OTHER_USER_ID, Math.floor(SPAN_SEC / 2), FOREIGN_BPM));
await getPrismaClient().measurement.createMany({ data: rows });
}
async function fetchDetail(id: string): Promise<WorkoutDetailBody> {
const request = new NextRequest(
`http://localhost/api/workouts/${id}?compact=1`,
);
const response = await GET(request, { params: Promise.resolve({ id }) });
return (await response.json()) as WorkoutDetailBody;
}
describe("GET /api/workouts/{id} — reconstructed heart-rate curve", () => {
it("reconstructs the curve from the account's pulse readings", async () => {
const id = await createWorkout();
await seedPulse();
const body = await fetchDetail(id);
expect(body.error).toBeNull();
const series = body.data?.hrSeries;
expect(series).not.toBeNull();
expect(series!.source).toBe("pulse_window");
expect(series!.points.length).toBeGreaterThan(2);
});
it("stops the curve at the finish — a reading inside the query pad is not part of the session", async () => {
const id = await createWorkout();
await seedPulse();
const series = (await fetchDetail(id)).data!.hrSeries!;
const lastTSec = series.points[series.points.length - 1].tSec;
expect(lastTSec).toBeLessThan(SPAN_SEC);
// The pad reading is the only source of a sub-100 bpm value in the
// fixture, so naming the value proves it stayed out rather than
// merely proving a count.
expect(series.points.map((p) => p.min)).not.toContain(PAD_TAIL_BPM);
expect(Math.min(...series.points.map((p) => p.min))).toBeGreaterThanOrEqual(
IN_WINDOW_LOW,
);
});
it("keeps readings from outside the window and from other accounts out of the curve", async () => {
const id = await createWorkout();
await seedPulse();
const series = (await fetchDetail(id)).data!.hrSeries!;
const maxima = series.points.map((p) => p.max);
expect(maxima).not.toContain(FAR_BEFORE_BPM);
expect(maxima).not.toContain(FOREIGN_BPM);
expect(series.points.map((p) => p.min)).not.toContain(FAR_AFTER_BPM);
expect(Math.max(...maxima)).toBeLessThanOrEqual(IN_WINDOW_HIGH);
});
it("serves no curve when the account's readings do not cover the session", async () => {
const id = await createWorkout();
// A handful of opportunistic readings in the first minute. Enough
// rows to clear the sample floor, nowhere near enough to describe a
// half-hour session — the route says nothing rather than drawing a
// curve out of a corner of it.
await getPrismaClient().measurement.createMany({
data: Array.from({ length: 12 }, (_, i) =>
pulseRow(TEST_USER_ID, i * 4, 130 + i),
),
});
const body = await fetchDetail(id);
expect(body.error).toBeNull();
expect(body.data?.hrSeries).toBeNull();
});
it("does not reach the reconstruction at all for another account's workout", async () => {
const foreign = await getPrismaClient().workout.create({
data: {
userId: OTHER_USER_ID,
sportType: "running",
startedAt: STARTED_AT,
endedAt: ENDED_AT,
durationSec: SPAN_SEC,
source: "MANUAL",
},
select: { id: true },
});
await seedPulse();
const request = new NextRequest(
`http://localhost/api/workouts/${foreign.id}?compact=1`,
);
const response = await GET(request, {
params: Promise.resolve({ id: foreign.id }),
});
expect(response.status).toBe(404);
});
});