diff --git a/messages/de.json b/messages/de.json index 6e756d71e..7136e471a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index f8452cf4d..978148004 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 708fa2729..29ea0677d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 28c551f9e..7beb6c029 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 6ef3ef883..cd9ba3c3b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index a90b85329..223c4ff1a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -2669,7 +2669,8 @@ "bmiEmptyAction": "계정 설정 열기", "personalRecord": { "badge": "신기록", - "tooltip": "개인 최고 기록: {date} {value}{unit}" + "tooltip": "개인 최고 기록: {date} {value}{unit}", + "benchmark": "그 나이의 일반적인 범위: {low}~{high} {unit} ({source})" }, "loadError": "인사이트를 불러오지 못했어요", "heroPersonalBaseline": "최근 90일 기준", diff --git a/messages/pl.json b/messages/pl.json index 6b417cc1f..ae3cf4116 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/personal-records/__tests__/route.test.ts b/src/app/api/personal-records/__tests__/route.test.ts index 30ce136d4..df54373ba 100644 --- a/src/app/api/personal-records/__tests__/route.test.ts +++ b/src/app/api/personal-records/__tests__/route.test.ts @@ -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> }; + 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(); + }); }); diff --git a/src/app/api/personal-records/route.ts b/src/app/api/personal-records/route.ts index 6b7793b1b..ab3ba4623 100644 --- a/src/app/api/personal-records/route.ts +++ b/src/app/api/personal-records/route.ts @@ -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, + })), + ); }); diff --git a/src/components/insights/personal-record-badge.tsx b/src/components/insights/personal-record-badge.tsx index b6d163a81..086433a70 100644 --- a/src/components/insights/personal-record-badge.tsx +++ b/src/components/insights/personal-record-badge.tsx @@ -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" > {tooltipText} + {fresh.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, + })} + + ) : null} ); diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 5385ac359..d145a24bf 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -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 = { }, }, }, - "/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, - }, - }, - }, }; diff --git a/src/lib/personal-records/__tests__/record-benchmark.test.ts b/src/lib/personal-records/__tests__/record-benchmark.test.ts new file mode 100644 index 000000000..2b5058c09 --- /dev/null +++ b/src/lib/personal-records/__tests__/record-benchmark.test.ts @@ -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[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(); + }); +}); diff --git a/src/lib/personal-records/record-benchmark.ts b/src/lib/personal-records/record-benchmark.ts new file mode 100644 index 000000000..6d1310240 --- /dev/null +++ b/src/lib/personal-records/record-benchmark.ts @@ -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> = { + 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 = { + "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; +}