mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
feat(records): publish an age- and sex-aware benchmark beside each personal record
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user