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

+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
});
});
+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,
})),
);
});
@@ -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>
);
+2 -105
View File
@@ -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;
}