feat(records): publish an age- and sex-aware benchmark beside each personal record

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 23:18:58 +02:00
1 parent b562587c45
commit b91328d67e
13 files changed
+455 -113

No files matched your search

@@ -6,6 +6,8 @@ vi.mock("@/lib/db", () => ({
personalRecord: {
findMany: vi.fn(),
},
user: { findUnique: vi.fn() },
measurement: { findFirst: vi.fn() },
},
}));
@@ -186,4 +188,77 @@ describe("GET /api/personal-records", () => {
expect(body.data[0].value).toBe(47.2);
expect(body.data[0].metricType).toBe("VO2_MAX");
});
// v1.43 (ios #116 item 5) — every stored field stays, and the band for the
// age the record was set at rides beside it.
it("projects every stored field and adds the benchmark", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const createdAt = new Date("2026-05-02T00:00:00.000Z");
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([
{
id: "pr-1",
userId: "user-1",
metricType: "VO2_MAX",
metricSlot: null,
direction: "MAX",
value: 47.2,
unit: "mL/(kg·min)",
achievedAt: new Date("2026-05-01T07:00:00.000Z"),
sourceMeasurementId: "m-1",
source: "APPLE_HEALTH",
externalId: "x-1",
createdAt,
},
{
id: "pr-2",
userId: "user-1",
metricType: "ACTIVITY_STEPS",
metricSlot: null,
direction: "MAX",
value: 21000,
unit: "steps",
achievedAt: new Date("2026-04-01T07:00:00.000Z"),
sourceMeasurementId: null,
source: "APPLE_HEALTH",
externalId: null,
createdAt,
},
] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1981-03-10T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
} as never);
const res = await GET(req("http://localhost/api/personal-records"));
const body = (await res.json()) as { data: Array<Record<string, unknown>> };
expect(Object.keys(body.data[0]).sort()).toEqual(
[
"achievedAt",
"benchmark",
"createdAt",
"direction",
"externalId",
"id",
"metricSlot",
"metricType",
"source",
"sourceMeasurementId",
"unit",
"userId",
"value",
].sort(),
);
// 45 when the record was set: between the 40s and 50s male FRIEND bands.
expect(body.data[0].benchmark).toMatchObject({
unit: "mL/(kg·min)",
source: "friend_2015",
});
const band = body.data[0].benchmark as { low: number; high: number };
expect(band.low).toBeGreaterThan(31);
expect(band.high).toBeLessThan(49);
// A step record has no cited norm.
expect(body.data[1].benchmark).toBeNull();
// No six-minute walk on the page, so no weight read.
expect(prisma.measurement.findFirst).not.toHaveBeenCalled();
});
});
+50 -1
View File
@@ -21,6 +21,11 @@
*
* Response envelope (matches the project-wide `apiSuccess` contract):
* { data: PersonalRecord[], error: null }
*
* v1.43 (ios #116 item 5) — the route projects each row instead of passing the
* Prisma result through. Every stored field stays on the wire, and each row
* gains `benchmark`: the cited age- and sex-aware band for the age the record
* was set at (`resolveRecordBenchmark`), or null.
*/
import { prisma } from "@/lib/db";
import type { NextRequest } from "next/server";
@@ -28,6 +33,11 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { listPersonalRecordsSchema } from "@/lib/validations/measurement";
import {
mayCarryBenchmark,
resolveRecordBenchmark,
} from "@/lib/personal-records/record-benchmark";
import { toProfileSex } from "@/lib/profile/sex";
export const dynamic = "force-dynamic";
@@ -61,5 +71,44 @@ export const GET = apiHandler(async (request: NextRequest) => {
take: limit,
});
return apiSuccess(records);
const wantsBand = records.some((r) => mayCarryBenchmark(r.metricType));
const profile = wantsBand
? await prisma.user.findUnique({
where: { id: user.id },
select: { dateOfBirth: true, gender: true, heightCm: true },
})
: null;
// Only the six-minute-walk equation needs a weight.
const weight =
profile && records.some((r) => r.metricType === "SIX_MINUTE_WALK_DISTANCE")
? await prisma.measurement.findFirst({
where: { userId: user.id, type: "WEIGHT", deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { value: true },
})
: null;
const benchmarkProfile = {
dateOfBirth: profile?.dateOfBirth ?? null,
sex: toProfileSex(profile?.gender) ?? undefined,
heightCm: profile?.heightCm ?? null,
weightKg: weight?.value ?? null,
};
return apiSuccess(
records.map((r) => ({
id: r.id,
userId: r.userId,
metricType: r.metricType,
metricSlot: r.metricSlot,
direction: r.direction,
value: r.value,
unit: r.unit,
achievedAt: r.achievedAt,
sourceMeasurementId: r.sourceMeasurementId,
source: r.source,
externalId: r.externalId,
createdAt: r.createdAt,
benchmark: profile ? resolveRecordBenchmark(r, benchmarkProfile) : null,
})),
);
});