mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user