mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
+2
-1
@@ -2669,7 +2669,8 @@
|
||||
"assessmentPreparing": "Einschätzung wird vorbereitet…",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}"
|
||||
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}",
|
||||
"benchmark": "Typisch in dem Alter: {low} bis {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "Auswertungen konnten nicht geladen werden",
|
||||
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
|
||||
|
||||
+2
-1
@@ -2669,7 +2669,8 @@
|
||||
"bmiEmptyAction": "Open account settings",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Personal record: {value} {unit} on {date}"
|
||||
"tooltip": "Personal record: {value} {unit} on {date}",
|
||||
"benchmark": "Typical at that age: {low} to {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "Failed to load insights",
|
||||
"heroPersonalBaseline": "Based on your last 90 days",
|
||||
|
||||
+2
-1
@@ -2669,7 +2669,8 @@
|
||||
"bmiEmptyAction": "Abrir ajustes de cuenta",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Récord personal: {value} {unit} el {date}"
|
||||
"tooltip": "Récord personal: {value} {unit} el {date}",
|
||||
"benchmark": "Lo típico a esa edad: de {low} a {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "No se han podido cargar los análisis",
|
||||
"heroPersonalBaseline": "Basado en tus últimos 90 días",
|
||||
|
||||
+2
-1
@@ -2669,7 +2669,8 @@
|
||||
"bmiEmptyAction": "Ouvrir les paramètres du compte",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Record personnel : {value} {unit} le {date}"
|
||||
"tooltip": "Record personnel : {value} {unit} le {date}",
|
||||
"benchmark": "Valeur typique à cet âge : de {low} à {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "Impossible de charger les analyses",
|
||||
"heroPersonalBaseline": "Basé sur tes 90 derniers jours",
|
||||
|
||||
+2
-1
@@ -2669,7 +2669,8 @@
|
||||
"bmiEmptyAction": "Apri impostazioni account",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Record personale: {value} {unit} il {date}"
|
||||
"tooltip": "Record personale: {value} {unit} il {date}",
|
||||
"benchmark": "Tipico a quell’età: da {low} a {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "Impossibile caricare le analisi",
|
||||
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
|
||||
|
||||
+2
-1
@@ -2669,7 +2669,8 @@
|
||||
"bmiEmptyAction": "계정 설정 열기",
|
||||
"personalRecord": {
|
||||
"badge": "신기록",
|
||||
"tooltip": "개인 최고 기록: {date} {value}{unit}"
|
||||
"tooltip": "개인 최고 기록: {date} {value}{unit}",
|
||||
"benchmark": "그 나이의 일반적인 범위: {low}~{high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "인사이트를 불러오지 못했어요",
|
||||
"heroPersonalBaseline": "최근 90일 기준",
|
||||
|
||||
+2
-1
@@ -2669,7 +2669,8 @@
|
||||
"bmiEmptyAction": "Otwórz ustawienia konta",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}"
|
||||
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}",
|
||||
"benchmark": "Typowo w tym wieku: od {low} do {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "Nie udało się załadować analiz",
|
||||
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
});
|
||||
@@ -41,6 +41,17 @@ interface PersonalRecordRow {
|
||||
value: number;
|
||||
unit: string;
|
||||
achievedAt: string;
|
||||
/**
|
||||
* v1.43 — the cited band for the age the record was set at, resolved on
|
||||
* the server; null (or absent on an older server) when none applies.
|
||||
*/
|
||||
benchmark?: {
|
||||
low: number;
|
||||
high: number;
|
||||
unit: string;
|
||||
source: string;
|
||||
sourceLabel: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface PersonalRecordBadgeProps {
|
||||
@@ -146,6 +157,19 @@ export function PersonalRecordBadge({
|
||||
data-slot="insights-pr-badge-tooltip"
|
||||
>
|
||||
<span className="text-xs">{tooltipText}</span>
|
||||
{fresh.benchmark ? (
|
||||
<span
|
||||
className="text-muted-foreground block text-xs"
|
||||
data-slot="insights-pr-badge-benchmark"
|
||||
>
|
||||
{t("insights.personalRecord.benchmark", {
|
||||
low: fmt.number(fresh.benchmark.low, 1),
|
||||
high: fmt.number(fresh.benchmark.high, 1),
|
||||
unit: fresh.benchmark.unit,
|
||||
source: fresh.benchmark.sourceLabel,
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
seriesChange,
|
||||
seriesReferenceLine,
|
||||
trendVerdict,
|
||||
recordBenchmark,
|
||||
} from "./trend-verdict";
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
@@ -595,86 +594,8 @@ export const sleepRhythmResponse = z
|
||||
"Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.",
|
||||
});
|
||||
|
||||
// ── Personal records ─────────────────────────────────────────────────
|
||||
//
|
||||
// `GET /api/personal-records` shipped as a schema-only contract so the native
|
||||
// client could build its query path against a stable shape before the
|
||||
// detection worker landed. It answers the stored rows verbatim — including
|
||||
// `userId` and the provenance foreign key — because the handler passes the
|
||||
// Prisma result straight to `apiSuccess` with no projection.
|
||||
|
||||
const personalRecordQuery = z
|
||||
.object({
|
||||
metricType: measurementTypeEnum
|
||||
.optional()
|
||||
.describe(
|
||||
"Restrict to one measurement type. A value outside the enum is DROPPED rather than refused — the read then covers every metric, which is indistinguishable from omitting the parameter.",
|
||||
),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(500)
|
||||
.optional()
|
||||
.default(100)
|
||||
.describe(
|
||||
"Row cap, 1..500, default 100. A non-integer, zero, negative or non-numeric value falls back to the default and a value above the ceiling clamps to 500 — none of them 422.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "PersonalRecordQuery" });
|
||||
|
||||
const personalRecordResource = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
userId: z
|
||||
.string()
|
||||
.describe(
|
||||
"The RECORD owner's id, which is not the caller's id on a delegated read.",
|
||||
),
|
||||
metricType: measurementTypeEnum,
|
||||
metricSlot: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Per-sport-type bucket for workout-driven records (e.g. `running_5km_time`). Null for a plain measurement-driven record, where the metric type alone is the dimension.",
|
||||
),
|
||||
direction: z
|
||||
.enum(["MAX", "MIN"])
|
||||
.describe(
|
||||
"Which way is better for this metric: `MAX` for steps / VO2 max / HRV / distance / daylight, `MIN` for resting heart rate / body fat / audio exposure.",
|
||||
),
|
||||
value: z.number(),
|
||||
unit: z.string(),
|
||||
achievedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe(
|
||||
"When the record was set. The list orders on this, newest first.",
|
||||
),
|
||||
sourceMeasurementId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The measurement row that achieved the record. Nulled rather than cascaded when that measurement is deleted, so the historical fact survives its source.",
|
||||
),
|
||||
source: measurementSourceEnum,
|
||||
externalId: z.string().nullable(),
|
||||
createdAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe(
|
||||
"When the detection worker wrote the row — distinct from `achievedAt`, which is when the reading happened.",
|
||||
),
|
||||
benchmark: recordBenchmark
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null otherwise.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "PersonalRecord",
|
||||
description:
|
||||
"One personal record: the best value a metric (optionally within a per-sport slot) has reached, in the direction that counts as better for it.",
|
||||
});
|
||||
// `GET /api/personal-records` is documented once, in `./awards.ts`, beside the
|
||||
// other read of earned bests.
|
||||
|
||||
// ── Time-series adapter (iOS chart source) ───────────────────────────
|
||||
//
|
||||
@@ -1255,28 +1176,4 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/personal-records": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
summary: "List the record's personal records",
|
||||
description:
|
||||
"Returns the account's personal-record rows, `achievedAt` descending. Both query parameters are FORGIVING rather than strict: an unrecognised `metricType` is dropped (the read falls back to every metric) and an unusable `limit` clamps to the default instead of 422-ing, so a loosely-typed client filter cannot take the page down. Rows are the stored entities verbatim, including `userId` and the provenance `sourceMeasurementId`. Delegable at READ level over the `measurements` section. Not module-gated: the list spans metrics from several modules and is filtered by the caller's `metricType`, not by the account's module map — a record achieved under a metric whose module is now off is still returned. Cookie or Bearer auth.",
|
||||
requestParams: { query: personalRecordQuery },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The personal-record rows (possibly empty).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.array(personalRecordResource),
|
||||
"PersonalRecordListEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ageAt, resolveRecordBenchmark } from "../record-benchmark";
|
||||
|
||||
const DOB_1981 = new Date("1981-06-15T00:00:00.000Z");
|
||||
const at = (iso: string) => new Date(iso);
|
||||
|
||||
const profile = {
|
||||
dateOfBirth: DOB_1981,
|
||||
sex: "MALE" as const,
|
||||
heightCm: 180,
|
||||
weightKg: 80,
|
||||
};
|
||||
|
||||
function record(
|
||||
metricType: Parameters<typeof resolveRecordBenchmark>[0]["metricType"],
|
||||
unit: string,
|
||||
achievedAt = "2026-07-01T08:00:00.000Z",
|
||||
metricSlot: string | null = null,
|
||||
) {
|
||||
return { metricType, unit, metricSlot, achievedAt: at(achievedAt) };
|
||||
}
|
||||
|
||||
describe("ageAt", () => {
|
||||
it("counts whole years up to the day before the birthday", () => {
|
||||
expect(ageAt(DOB_1981, at("2026-06-14T23:00:00.000Z"))).toBe(44);
|
||||
expect(ageAt(DOB_1981, at("2026-06-15T00:00:00.000Z"))).toBe(45);
|
||||
expect(ageAt(null, at("2026-06-15T00:00:00.000Z"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRecordBenchmark", () => {
|
||||
it("gives a 45-year-old man's VO2 max record the FRIEND band in its unit", () => {
|
||||
const band = resolveRecordBenchmark(
|
||||
record("VO2_MAX", "mL/(kg·min)"),
|
||||
profile,
|
||||
);
|
||||
expect(band).not.toBeNull();
|
||||
expect(band!.unit).toBe("mL/(kg·min)");
|
||||
expect(band!.source).toBe("friend_2015");
|
||||
expect(band!.sourceLabel).toContain("Kaminsky");
|
||||
// Between the 40s (35–45) and 50s (31–41) bands.
|
||||
expect(band!.low).toBeGreaterThanOrEqual(31);
|
||||
expect(band!.low).toBeLessThanOrEqual(35);
|
||||
expect(band!.high).toBeGreaterThanOrEqual(41);
|
||||
expect(band!.high).toBeLessThanOrEqual(45);
|
||||
});
|
||||
|
||||
it("uses the age the record was set at, not today's", () => {
|
||||
const old = resolveRecordBenchmark(
|
||||
record("VO2_MAX", "mL/(kg·min)", "2004-07-01T08:00:00.000Z"),
|
||||
profile,
|
||||
);
|
||||
// 23 then: below the 20s bracket centre, so the 20s male band itself.
|
||||
expect(old).toMatchObject({ low: 42, high: 53 });
|
||||
});
|
||||
|
||||
it("returns null for a metric without a cited norm", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(record("ACTIVITY_STEPS", "steps"), profile),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without a date of birth", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(record("VO2_MAX", "mL/(kg·min)"), {
|
||||
...profile,
|
||||
dateOfBirth: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a sex-split table without a sex", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(record("VO2_MAX", "mL/(kg·min)"), {
|
||||
...profile,
|
||||
sex: undefined,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a record stored in another unit, never converted on a guess", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(record("GRIP_STRENGTH", "lb"), profile),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a spelling of the canonical unit", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(record("RESPIRATORY_RATE", "count/min"), profile),
|
||||
).toMatchObject({ unit: "count/min", source: "who_pals" });
|
||||
});
|
||||
|
||||
it("returns null for a per-sport slot", () => {
|
||||
expect(
|
||||
resolveRecordBenchmark(
|
||||
record("WALKING_RUNNING_DISTANCE", "m", undefined, "running_5km_time"),
|
||||
profile,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("places a six-minute walk between 80 % of the predicted distance and the prediction", () => {
|
||||
const band = resolveRecordBenchmark(
|
||||
record("SIX_MINUTE_WALK_DISTANCE", "m"),
|
||||
profile,
|
||||
);
|
||||
// Enright & Sherrill, man, 45 y, 180 cm, 80 kg.
|
||||
const predicted = 7.57 * 180 - 5.02 * 45 - 1.76 * 80 - 309;
|
||||
expect(band).toEqual({
|
||||
low: Math.round(predicted * 0.8),
|
||||
high: Math.round(predicted),
|
||||
unit: "m",
|
||||
source: "enright_sherrill_1998",
|
||||
sourceLabel: "Enright & Sherrill 1998",
|
||||
});
|
||||
expect(
|
||||
resolveRecordBenchmark(record("SIX_MINUTE_WALK_DISTANCE", "m"), {
|
||||
...profile,
|
||||
weightKg: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* The comparison band published beside a personal record (v1.43, ios #116
|
||||
* item 5).
|
||||
*
|
||||
* The native client used to place a record against hard-coded population
|
||||
* values that knew neither the person's age nor their sex. The server owns the
|
||||
* cited norms (`lookupNormalRange` in `src/lib/insights/derived/norms.ts`, the
|
||||
* Enright & Sherrill equation for the six-minute walk), so it resolves the band
|
||||
* here and the clients show it as delivered, or nothing when it is null.
|
||||
*
|
||||
* The band is for the age the person had when the record was set: a record
|
||||
* from ten years ago is compared with what was typical then, not with today's
|
||||
* bracket. Null whenever no honest band exists: a metric without a cited norm,
|
||||
* no date of birth, a sex the table has no row for, a per-sport workout slot,
|
||||
* or a record stored in a unit other than the norm's (the band is never
|
||||
* converted on a guess).
|
||||
*
|
||||
* Pure: the caller reads the profile and the weight the walk equation needs.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
lookupNormalRange,
|
||||
predictSixMinuteWalkDistance,
|
||||
type NormSex,
|
||||
} from "@/lib/insights/derived/norms";
|
||||
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
|
||||
import { getUnitForType } from "@/lib/measurements/unit-map";
|
||||
|
||||
export interface RecordBenchmark {
|
||||
low: number;
|
||||
high: number;
|
||||
unit: string;
|
||||
/** Stable identifier of the cited norm. */
|
||||
source: string;
|
||||
/** The citation as a reader sees it. */
|
||||
sourceLabel: string;
|
||||
}
|
||||
|
||||
/** The profile facts the norms are keyed on. */
|
||||
export interface RecordBenchmarkProfile {
|
||||
dateOfBirth: Date | null;
|
||||
sex: NormSex;
|
||||
heightCm: number | null;
|
||||
/** Latest weight in kg, for the six-minute-walk equation only. */
|
||||
weightKg: number | null;
|
||||
}
|
||||
|
||||
interface NormCitation {
|
||||
metricId: MetricStatusMetricId;
|
||||
source: string;
|
||||
sourceLabel: string;
|
||||
}
|
||||
|
||||
const CITED_NORMS: Partial<Record<MeasurementType, NormCitation>> = {
|
||||
VO2_MAX: {
|
||||
metricId: "VO2_MAX",
|
||||
source: "friend_2015",
|
||||
sourceLabel: "FRIEND registry (Kaminsky et al. 2015)",
|
||||
},
|
||||
RESTING_HEART_RATE: {
|
||||
metricId: "RESTING_HEART_RATE",
|
||||
source: "fleming_2011",
|
||||
sourceLabel: "Fleming et al. 2011, AHA",
|
||||
},
|
||||
RESPIRATORY_RATE: {
|
||||
metricId: "RESPIRATORY_RATE",
|
||||
source: "who_pals",
|
||||
sourceLabel: "WHO / PALS reference",
|
||||
},
|
||||
GRIP_STRENGTH: {
|
||||
metricId: "GRIP_STRENGTH",
|
||||
source: "dodds_2014",
|
||||
sourceLabel: "Dodds et al. 2014, EWGSOP2",
|
||||
},
|
||||
};
|
||||
|
||||
const SIX_MINUTE_WALK: { source: string; sourceLabel: string } = {
|
||||
source: "enright_sherrill_1998",
|
||||
sourceLabel: "Enright & Sherrill 1998",
|
||||
};
|
||||
|
||||
/**
|
||||
* The share of the predicted distance the six-minute-walk card already calls
|
||||
* "green" (`six-minute-walk.ts`). The band runs from there to the predicted
|
||||
* distance, so the record and the card speak about the same expectation.
|
||||
*/
|
||||
const SIX_MINUTE_WALK_LOW_SHARE = 0.8;
|
||||
|
||||
/** Spellings of one canonical unit that writers have used. */
|
||||
const UNIT_ALIASES: Record<string, readonly string[]> = {
|
||||
"breaths/min": ["count/min", "/min", "breaths/min", "brpm"],
|
||||
};
|
||||
|
||||
function unitKey(unit: string): string {
|
||||
return unit.toLowerCase().replace(/[^a-z0-9%]/g, "");
|
||||
}
|
||||
|
||||
function sameUnit(type: MeasurementType, stored: string): boolean {
|
||||
const canonical = getUnitForType(type);
|
||||
const accepted = [canonical, ...(UNIT_ALIASES[canonical] ?? [])];
|
||||
return accepted.some((u) => unitKey(u) === unitKey(stored));
|
||||
}
|
||||
|
||||
/** Whole years between a date of birth and an instant. */
|
||||
export function ageAt(dateOfBirth: Date | null, at: Date): number | null {
|
||||
if (!dateOfBirth || Number.isNaN(dateOfBirth.getTime())) return null;
|
||||
let age = at.getUTCFullYear() - dateOfBirth.getUTCFullYear();
|
||||
const beforeBirthday =
|
||||
at.getUTCMonth() < dateOfBirth.getUTCMonth() ||
|
||||
(at.getUTCMonth() === dateOfBirth.getUTCMonth() &&
|
||||
at.getUTCDate() < dateOfBirth.getUTCDate());
|
||||
if (beforeBirthday) age -= 1;
|
||||
return age >= 0 ? age : null;
|
||||
}
|
||||
|
||||
const round1 = (n: number) => Math.round(n * 10) / 10;
|
||||
|
||||
export function resolveRecordBenchmark(
|
||||
record: {
|
||||
metricType: MeasurementType;
|
||||
metricSlot: string | null;
|
||||
unit: string;
|
||||
achievedAt: Date;
|
||||
},
|
||||
profile: RecordBenchmarkProfile,
|
||||
): RecordBenchmark | null {
|
||||
// A per-sport slot (a 5 km time) has no population norm here.
|
||||
if (record.metricSlot !== null) return null;
|
||||
if (!sameUnit(record.metricType, record.unit)) return null;
|
||||
const age = ageAt(profile.dateOfBirth, record.achievedAt);
|
||||
// Every band below is age-stratified; without an age none is honest.
|
||||
if (age === null) return null;
|
||||
|
||||
if (record.metricType === "SIX_MINUTE_WALK_DISTANCE") {
|
||||
const predicted = predictSixMinuteWalkDistance(
|
||||
age,
|
||||
profile.heightCm,
|
||||
profile.weightKg,
|
||||
profile.sex,
|
||||
);
|
||||
if (predicted === null) return null;
|
||||
return {
|
||||
low: Math.round(predicted * SIX_MINUTE_WALK_LOW_SHARE),
|
||||
high: Math.round(predicted),
|
||||
unit: record.unit,
|
||||
...SIX_MINUTE_WALK,
|
||||
};
|
||||
}
|
||||
|
||||
const citation = CITED_NORMS[record.metricType];
|
||||
if (!citation) return null;
|
||||
const band = lookupNormalRange(citation.metricId, age, profile.sex);
|
||||
if (!band) return null;
|
||||
return {
|
||||
low: round1(band.low),
|
||||
high: round1(band.high),
|
||||
unit: record.unit,
|
||||
source: citation.source,
|
||||
sourceLabel: citation.sourceLabel,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a record of this type can carry a band at all (query planning). */
|
||||
export function mayCarryBenchmark(type: MeasurementType): boolean {
|
||||
return type === "SIX_MINUTE_WALK_DISTANCE" || type in CITED_NORMS;
|
||||
}
|
||||
Reference in new issue
Block a user