From 2ba6214a651efb15b80c3c8d29875062611b98e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 21:51:36 +0200 Subject: [PATCH] feat(labs): show each marker's slope and where the line would leave the range The biomarker detail read now carries a trend: the least-squares line through every numeric reading of the marker (all stored in its unit), with the slope's 95 % interval and p-value. It needs three readings over at least a year. The line is placed against the marker's band, and an exit date is given only when the interval excludes zero, the line is inside the band today and reaches a bound within three years. The detail page words it as a straight-line projection, not a forecast. --- docs/api/openapi.yaml | 173 +++++++++++++++- messages/de.json | 19 ++ messages/en.json | 19 ++ messages/es.json | 19 ++ messages/fr.json | 19 ++ messages/it.json | 19 ++ messages/ko.json | 19 ++ messages/nl.json | 19 ++ messages/pl.json | 19 ++ .../analysis-depth-no-ai-guard.test.ts | 1 + .../biomarkers/[id]/__tests__/route.test.ts | 54 ++++- src/app/api/biomarkers/[id]/route.ts | 40 +++- .../labs/__tests__/lab-trend-card.test.tsx | 74 +++++++ src/components/labs/lab-biomarker-detail.tsx | 5 + src/components/labs/lab-trend-card.tsx | 81 ++++++++ src/components/labs/types.ts | 32 +++ src/lib/insights/correlations.ts | 21 +- src/lib/labs/__tests__/lab-trend.test.ts | 131 +++++++++++++ src/lib/labs/lab-trend.ts | 184 ++++++++++++++++++ src/lib/openapi/routes/biomarkers.ts | 56 +++++- 20 files changed, 993 insertions(+), 11 deletions(-) create mode 100644 src/components/labs/__tests__/lab-trend-card.test.tsx create mode 100644 src/components/labs/lab-trend-card.tsx create mode 100644 src/lib/labs/__tests__/lab-trend.test.ts create mode 100644 src/lib/labs/lab-trend.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 339c7b7c5..c9b3511d3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -15869,8 +15869,9 @@ paths: tags: - Labs summary: Fetch a single biomarker - description: Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows - surface as 404. A marker merged into another answers 404 with `meta.mergedInto`. + description: Returns the biomarker including its decrypted `context`, since v1.43 its `aliases`, and since v1.44 its + `trend` over every stored reading. Cross-user rows surface as 404. A marker merged into another answers 404 with + `meta.mergedInto`. parameters: - in: path name: id @@ -72267,7 +72268,7 @@ components: type: object properties: data: - $ref: "#/components/schemas/Biomarker" + $ref: "#/components/schemas/BiomarkerDetail" error: type: "null" meta: @@ -72280,6 +72281,172 @@ components: - data - error additionalProperties: false + BiomarkerDetail: + type: object + properties: + id: + type: string + name: + type: string + unit: + type: string + lowerBound: + type: + - number + - "null" + upperBound: + type: + - number + - "null" + panel: + type: + - string + - "null" + analyteKey: + type: + - string + - "null" + hasContext: + type: boolean + context: + type: + - string + - "null" + hidden: + type: boolean + aliases: + description: v1.43 — other spellings that resolve to this marker (folded in by a merge, chosen at an import, or typed on + the marker), sorted, without the marker's own name. Absent on older servers. + type: array + items: + type: string + createdAt: + type: string + updatedAt: + type: string + trend: + description: v1.44 — absent on older servers. + $ref: "#/components/schemas/BiomarkerTrend" + required: + - id + - name + - unit + - lowerBound + - upperBound + - panel + - analyteKey + - hasContext + - context + - hidden + - createdAt + - updatedAt + additionalProperties: false + BiomarkerTrend: + anyOf: + - type: object + properties: + status: + type: string + const: ok + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + firstAt: + type: string + description: YYYY-MM-DD of the first reading. + lastAt: + type: string + description: YYYY-MM-DD of the newest reading. + spanDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + slopePerYear: + type: number + description: Least-squares change per year, in the marker's unit. + slopeCi95: + type: array + prefixItems: + - type: number + - type: number + items: false + minItems: 2 + maxItems: 2 + description: 95 % interval of the slope (Student t at n − 2 df). + pValue: + type: number + fittedNow: + type: number + description: The line's value on `asOf`. + asOf: + type: string + description: YYYY-MM-DD the line was evaluated on. + projection: + type: string + enum: + - exits + - beyond_horizon + - no_exit + - already_outside + - no_clear_trend + - no_band + exitDate: + description: "Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast." + type: + - string + - "null" + exitBound: + anyOf: + - type: string + enum: + - low + - high + - type: "null" + required: + - status + - n + - firstAt + - lastAt + - spanDays + - slopePerYear + - slopeCi95 + - pValue + - fittedNow + - asOf + - projection + - exitDate + - exitBound + additionalProperties: false + - type: object + properties: + status: + type: string + const: insufficient + reason: + type: string + enum: + - too_few_values + - span_too_short + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + spanDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - status + - reason + - n + - spanDays + additionalProperties: false + description: "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's + unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the + line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the + line is inside the band today and reaches a bound within three years. Server-computed; clients render it as + given." UpdateBiomarkerResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index dd2a6c8e4..fc61d1d46 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10115,6 +10115,25 @@ "doneToastFew": "In {name} zusammengeführt, {count} Messungen verschoben", "doneToastOther": "In {name} zusammengeführt, {count} Messungen verschoben", "error": "Die Biomarker konnten nicht zusammengeführt werden. Versuche es erneut." + }, + "trend": { + "title": "Verlauf", + "slope": { + "rising": "Steigt um etwa {amount} {unit} pro Jahr, über {n} Werte seit {since}.", + "falling": "Sinkt um etwa {amount} {unit} pro Jahr, über {n} Werte seit {since}.", + "flat": "Keine Veränderung über {n} Werte seit {since}." + }, + "projection": { + "exits": { + "high": "Liefe diese Gerade so weiter, erreichte sie um den {date} das obere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage.", + "low": "Liefe diese Gerade so weiter, erreichte sie um den {date} das untere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage." + }, + "beyond_horizon": "In diesem Tempo bleibt die Gerade noch mindestens drei Jahre in deinem Referenzbereich.", + "no_exit": "Die Gerade läuft von den Grenzen deines Referenzbereichs weg.", + "already_outside": "Die Gerade liegt bereits außerhalb deines Referenzbereichs.", + "no_clear_trend": "Über diese Werte ist die Veränderung nicht klar von keiner Veränderung zu unterscheiden, deshalb wird nichts fortgeschrieben." + }, + "interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}." } }, "modules": { diff --git a/messages/en.json b/messages/en.json index d9a637e04..be2db6114 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Merged into {name}, {count} readings moved", "doneToastOther": "Merged into {name}, {count} readings moved", "error": "The biomarkers could not be merged. Try again." + }, + "trend": { + "title": "Trend", + "slope": { + "rising": "Rising by about {amount} {unit} a year across {n} readings since {since}.", + "falling": "Falling by about {amount} {unit} a year across {n} readings since {since}.", + "flat": "No change across {n} readings since {since}." + }, + "projection": { + "exits": { + "high": "If this straight line continued, it would reach the upper end of your reference range around {date}. That is a projection, not a forecast.", + "low": "If this straight line continued, it would reach the lower end of your reference range around {date}. That is a projection, not a forecast." + }, + "beyond_horizon": "At this pace the line stays inside your reference range for at least three more years.", + "no_exit": "The line heads away from the edges of your reference range.", + "already_outside": "The line already sits outside your reference range.", + "no_clear_trend": "Across these readings the change is not clearly different from no change, so no projection is drawn." + }, + "interval": "95 % interval of the yearly change: {lo} to {hi} {unit}." } }, "modules": { diff --git a/messages/es.json b/messages/es.json index cf161392a..287b36a61 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Combinado en {name}, {count} mediciones movidas", "doneToastOther": "Combinado en {name}, {count} mediciones movidas", "error": "No se pudieron combinar los biomarcadores. Inténtalo de nuevo." + }, + "trend": { + "title": "Tendencia", + "slope": { + "rising": "Sube unos {amount} {unit} al año en {n} valores desde {since}.", + "falling": "Baja unos {amount} {unit} al año en {n} valores desde {since}.", + "flat": "Sin cambios en {n} valores desde {since}." + }, + "projection": { + "exits": { + "high": "Si esta línea recta continuara, llegaría al límite superior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico.", + "low": "Si esta línea recta continuara, llegaría al límite inferior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico." + }, + "beyond_horizon": "A este ritmo, la línea se mantiene dentro de tu rango de referencia al menos tres años más.", + "no_exit": "La línea se aleja de los límites de tu rango de referencia.", + "already_outside": "La línea ya está fuera de tu rango de referencia.", + "no_clear_trend": "En estos valores, el cambio no se distingue con claridad de la ausencia de cambio, así que no se proyecta nada." + }, + "interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}." } }, "modules": { diff --git a/messages/fr.json b/messages/fr.json index 1edb3089b..d1c564694 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Fusionné dans {name}, {count} mesures déplacées", "doneToastOther": "Fusionné dans {name}, {count} mesures déplacées", "error": "Les biomarqueurs n'ont pas pu être fusionnés. Réessaie." + }, + "trend": { + "title": "Tendance", + "slope": { + "rising": "Augmente d'environ {amount} {unit} par an sur {n} valeurs depuis le {since}.", + "falling": "Diminue d'environ {amount} {unit} par an sur {n} valeurs depuis le {since}.", + "flat": "Aucune variation sur {n} valeurs depuis le {since}." + }, + "projection": { + "exits": { + "high": "Si cette droite se prolongeait, elle atteindrait la limite haute de ta plage de référence vers le {date}. C'est une projection, pas une prévision.", + "low": "Si cette droite se prolongeait, elle atteindrait la limite basse de ta plage de référence vers le {date}. C'est une projection, pas une prévision." + }, + "beyond_horizon": "À ce rythme, la droite reste dans ta plage de référence pendant au moins trois ans encore.", + "no_exit": "La droite s'éloigne des limites de ta plage de référence.", + "already_outside": "La droite est déjà hors de ta plage de référence.", + "no_clear_trend": "Sur ces valeurs, la variation ne se distingue pas clairement d'une absence de variation, donc aucune projection n'est tracée." + }, + "interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}." } }, "modules": { diff --git a/messages/it.json b/messages/it.json index 52ef2d137..dd0c39841 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Unito in {name}, {count} misurazioni spostate", "doneToastOther": "Unito in {name}, {count} misurazioni spostate", "error": "Non è stato possibile unire i biomarcatori. Riprova." + }, + "trend": { + "title": "Andamento", + "slope": { + "rising": "Sale di circa {amount} {unit} all'anno su {n} valori dal {since}.", + "falling": "Scende di circa {amount} {unit} all'anno su {n} valori dal {since}.", + "flat": "Nessuna variazione su {n} valori dal {since}." + }, + "projection": { + "exits": { + "high": "Se questa retta proseguisse, raggiungerebbe il limite superiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione.", + "low": "Se questa retta proseguisse, raggiungerebbe il limite inferiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione." + }, + "beyond_horizon": "A questo ritmo la retta resta nel tuo intervallo di riferimento per almeno altri tre anni.", + "no_exit": "La retta si allontana dai limiti del tuo intervallo di riferimento.", + "already_outside": "La retta è già fuori dal tuo intervallo di riferimento.", + "no_clear_trend": "Su questi valori la variazione non si distingue chiaramente dall'assenza di variazione, quindi non si proietta nulla." + }, + "interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}." } }, "modules": { diff --git a/messages/ko.json b/messages/ko.json index 26c14f484..f8ac7397c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10115,6 +10115,25 @@ "doneToastFew": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", "doneToastOther": "{name}(으)로 합쳤어요, 측정값 {count}개 이동", "error": "바이오마커를 합치지 못했어요. 다시 시도해 주세요." + }, + "trend": { + "title": "추세", + "slope": { + "rising": "{since} 이후 {n}개 값에서 1년에 약 {amount} {unit}씩 오르고 있습니다.", + "falling": "{since} 이후 {n}개 값에서 1년에 약 {amount} {unit}씩 내려가고 있습니다.", + "flat": "{since} 이후 {n}개 값에서 변화가 없습니다." + }, + "projection": { + "exits": { + "high": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 상한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다.", + "low": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 하한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다." + }, + "beyond_horizon": "이 속도라면 적어도 3년 동안은 참고 범위 안에 머뭅니다.", + "no_exit": "직선이 참고 범위의 경계에서 멀어지고 있습니다.", + "already_outside": "직선이 이미 참고 범위 밖에 있습니다.", + "no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다." + }, + "interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}." } }, "modules": { diff --git a/messages/nl.json b/messages/nl.json index 8fd2353e6..bc81ba2ae 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Samengevoegd in {name}, {count} metingen verplaatst", "doneToastOther": "Samengevoegd in {name}, {count} metingen verplaatst", "error": "De biomarkers konden niet worden samengevoegd. Probeer het opnieuw." + }, + "trend": { + "title": "Verloop", + "slope": { + "rising": "Stijgt met ongeveer {amount} {unit} per jaar over {n} waarden sinds {since}.", + "falling": "Daalt met ongeveer {amount} {unit} per jaar over {n} waarden sinds {since}.", + "flat": "Geen verandering over {n} waarden sinds {since}." + }, + "projection": { + "exits": { + "high": "Als deze rechte lijn zo doorliep, zou ze rond {date} de bovengrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling.", + "low": "Als deze rechte lijn zo doorliep, zou ze rond {date} de ondergrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling." + }, + "beyond_horizon": "In dit tempo blijft de lijn nog minstens drie jaar binnen je referentiebereik.", + "no_exit": "De lijn loopt weg van de grenzen van je referentiebereik.", + "already_outside": "De lijn ligt al buiten je referentiebereik.", + "no_clear_trend": "Over deze waarden is de verandering niet duidelijk te onderscheiden van geen verandering, dus er wordt niets doorgetrokken." + }, + "interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}." } }, "modules": { diff --git a/messages/pl.json b/messages/pl.json index 304d35ac3..3939cf2eb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10115,6 +10115,25 @@ "doneToastFew": "Scalono w {name}, przeniesiono {count} pomiary", "doneToastOther": "Scalono w {name}, przeniesiono {count} pomiarów", "error": "Nie udało się scalić biomarkerów. Spróbuj ponownie." + }, + "trend": { + "title": "Trend", + "slope": { + "rising": "Rośnie o około {amount} {unit} rocznie na podstawie {n} wyników od {since}.", + "falling": "Spada o około {amount} {unit} rocznie na podstawie {n} wyników od {since}.", + "flat": "Brak zmian na podstawie {n} wyników od {since}." + }, + "projection": { + "exits": { + "high": "Gdyby ta prosta biegła dalej, osiągnęłaby górną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza.", + "low": "Gdyby ta prosta biegła dalej, osiągnęłaby dolną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza." + }, + "beyond_horizon": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym jeszcze co najmniej trzy lata.", + "no_exit": "Prosta oddala się od granic twojego zakresu referencyjnego.", + "already_outside": "Prosta jest już poza twoim zakresem referencyjnym.", + "no_clear_trend": "Na podstawie tych wyników zmiana nie różni się wyraźnie od braku zmiany, więc nie jest wyznaczana ekstrapolacja." + }, + "interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}." } }, "modules": { diff --git a/src/__tests__/analysis-depth-no-ai-guard.test.ts b/src/__tests__/analysis-depth-no-ai-guard.test.ts index 0eb5df59f..f5aa84195 100644 --- a/src/__tests__/analysis-depth-no-ai-guard.test.ts +++ b/src/__tests__/analysis-depth-no-ai-guard.test.ts @@ -29,6 +29,7 @@ const ANALYSIS_MODULES = [ "lib/insights/correlation-channel-series.ts", "lib/insights/discovery-matrix.ts", "lib/insights/derived/changepoint.ts", + "lib/labs/lab-trend.ts", ] as const; /** Reachable under `lib/ai/` without being a model: the at-rest cipher. */ diff --git a/src/app/api/biomarkers/[id]/__tests__/route.test.ts b/src/app/api/biomarkers/[id]/__tests__/route.test.ts index 9cf66d9dd..46a840f1f 100644 --- a/src/app/api/biomarkers/[id]/__tests__/route.test.ts +++ b/src/app/api/biomarkers/[id]/__tests__/route.test.ts @@ -16,7 +16,7 @@ vi.mock("@/lib/db", () => { createMany: vi.fn(), }, biomarkerMerge: { findFirst: vi.fn(async () => null) }, - labResult: { deleteMany: vi.fn() }, + labResult: { deleteMany: vi.fn(), findMany: vi.fn(async () => []) }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; // `$transaction` is set in `beforeEach`: the batch form for the delete, the @@ -50,7 +50,7 @@ vi.mock("next/headers", () => ({ })), })); -import { PUT, DELETE } from "../route"; +import { GET, PUT, DELETE } from "../route"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -105,6 +105,56 @@ beforeEach(() => { vi.mocked(prisma.biomarkerMerge.findFirst).mockResolvedValue(null as never); }); +describe("GET /api/biomarkers/[id] — trend (v1.44)", () => { + const YEAR_MS = 365.25 * 86_400_000; + const getReq = () => + new NextRequest("http://localhost/api/biomarkers/bm-1", { method: "GET" }); + + it("carries the marker's slope over every stored reading", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + const t0 = Date.now() - 2 * YEAR_MS; + vi.mocked(prisma.labResult.findMany).mockResolvedValue([ + { takenAt: new Date(t0), value: 60 }, + { takenAt: new Date(t0 + YEAR_MS), value: 70 }, + { takenAt: new Date(t0 + 2 * YEAR_MS), value: 80 }, + ] as never); + const res = await GET(getReq(), params("bm-1")); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.name).toBe("LDL"); + expect(body.data.trend).toMatchObject({ + status: "ok", + n: 3, + slopePerYear: 10, + projection: "exits", + exitBound: "high", + }); + expect(prisma.labResult.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + userId: "user-1", + biomarkerId: "bm-1", + deletedAt: null, + }), + }), + ); + }); + + it("answers insufficient below three readings", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.biomarker.findFirst).mockResolvedValue(EXISTING as never); + vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never); + const body = await (await GET(getReq(), params("bm-1"))).json(); + expect(body.data.trend).toEqual({ + status: "insufficient", + reason: "too_few_values", + n: 0, + spanDays: 0, + }); + }); +}); + describe("DELETE /api/biomarkers/[id]", () => { it("drops the marker and its readings in one userId-narrowed transaction", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index 0cc1601b9..e47454e51 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -26,6 +26,10 @@ import { mergedInto } from "@/lib/labs/biomarker-merge"; import { encryptContextToBytes } from "@/lib/labs/biomarker-store"; import { matchAnalyteKey } from "@/lib/labs/analyte-match"; import { annotate } from "@/lib/logging/context"; +import { computeLabTrend } from "@/lib/labs/lab-trend"; + +/** Upper bound on readings the trend line reads. */ +const LAB_TREND_READ_CAP = 2000; import { updateBiomarkerSchema } from "@/lib/validations/biomarkers"; import { effectiveBound, isInvertedRange } from "@/lib/validations/labs"; @@ -58,14 +62,44 @@ export const GET = apiHandler( }); } - const aliases = await aliasNamesByMarker(prisma, user.id, [row]); + const [aliases, readings] = await Promise.all([ + aliasNamesByMarker(prisma, user.id, [row]), + // v1.44 — the marker's numeric readings for the trend line. Every + // reading is stored in the marker's unit, so the series needs no + // conversion; the cap is far above any real panel history. + prisma.labResult.findMany({ + where: { + userId: user.id, + biomarkerId: id, + deletedAt: null, + value: { not: null }, + }, + orderBy: { takenAt: "asc" }, + take: LAB_TREND_READ_CAP, + select: { takenAt: true, value: true }, + }), + ]); + const trend = computeLabTrend( + readings.flatMap((r) => + r.value === null ? [] : [{ takenAt: r.takenAt, value: r.value }], + ), + { low: row.lowerBound, high: row.upperBound }, + new Date(), + ); annotate({ action: { name: "labs.biomarker.get" }, - meta: { biomarkerId: id }, + meta: { + biomarkerId: id, + trend: trend.status, + ...(trend.status === "ok" ? { projection: trend.projection } : {}), + }, }); - return apiSuccess(serialiseBiomarker(row, aliases.get(row.id) ?? [])); + return apiSuccess({ + ...serialiseBiomarker(row, aliases.get(row.id) ?? []), + trend, + }); }, ); diff --git a/src/components/labs/__tests__/lab-trend-card.test.tsx b/src/components/labs/__tests__/lab-trend-card.test.tsx new file mode 100644 index 000000000..d5c3ae554 --- /dev/null +++ b/src/components/labs/__tests__/lab-trend-card.test.tsx @@ -0,0 +1,74 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { primeLocales } from "@/__tests__/helpers/prime-locales"; + +import { LabTrendCard } from "../lab-trend-card"; +import type { BiomarkerTrendDto } from "../types"; + +primeLocales("de"); + +const OK: BiomarkerTrendDto = { + status: "ok", + n: 4, + firstAt: "2023-01-10", + lastAt: "2026-01-10", + spanDays: 1096, + slopePerYear: 12.5, + slopeCi95: [4.25, 20.75], + pValue: 0.02, + fittedNow: 130, + asOf: "2026-10-21", + projection: "exits", + exitDate: "2027-11-02", + exitBound: "high", +}; + +function render( + trend: BiomarkerTrendDto | undefined, + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("words the server's slope and labels the exit as a projection", () => { + const html = render(OK); + expect(html).toContain("Rising by about 13 mg/dL a year across 4 readings"); + expect(html).toContain('data-projection="exits"'); + expect(html).toContain("upper end of your reference range"); + expect(html).toContain("projection, not a forecast"); + expect(html).toContain("4.25 to 20.75 mg/dL"); + }); + + it("speaks German with du", () => { + const html = render(OK, "de"); + expect(html).toContain("deines Referenzbereichs"); + expect(html).toContain("Fortschreibung, keine Vorhersage"); + }); + + it("draws no projection sentence without a clear trend, and nothing when insufficient", () => { + const html = render({ + ...OK, + projection: "no_clear_trend", + exitDate: null, + exitBound: null, + }); + expect(html).toContain('data-projection="no_clear_trend"'); + expect(html).not.toContain("forecast"); + expect( + render({ + status: "insufficient", + reason: "too_few_values", + n: 2, + spanDays: 40, + }), + ).toBe(""); + expect(render(undefined)).toBe(""); + }); +}); diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index f83352055..67978895b 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -55,6 +55,7 @@ import { importWithRetry } from "@/lib/retry-import"; import { BiomarkerAliases } from "./biomarker-aliases"; import { BiomarkerForm } from "./biomarker-form"; import { LabForm } from "./lab-form"; +import { LabTrendCard } from "./lab-trend-card"; import type { BiomarkerDto, LabResultDto, @@ -505,6 +506,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { /> + )} diff --git a/src/components/labs/lab-trend-card.tsx b/src/components/labs/lab-trend-card.tsx new file mode 100644 index 000000000..94b81f164 --- /dev/null +++ b/src/components/labs/lab-trend-card.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import type { BiomarkerTrendDto } from "./types"; + +/** + * v1.44 — the marker's slope over all its readings, and where that straight + * line would leave the reference band. + * + * Server-authoritative: every number is the route's (`computeLabTrend`); this + * only words it. Renders nothing below three readings over a year. The exit + * sentence names itself a projection, never a forecast. + */ +export function LabTrendCard({ + trend, + unit, +}: { + trend: BiomarkerTrendDto | undefined; + unit: string; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + if (!trend || trend.status !== "ok") return null; + + const digits = Math.abs(trend.slopePerYear) >= 10 ? 0 : 2; + const per = (v: number) => fmt.number(Math.abs(v), digits); + const direction = + trend.slopePerYear > 0 + ? "rising" + : trend.slopePerYear < 0 + ? "falling" + : "flat"; + const slopeLine = t(`labs.trend.slope.${direction}`, { + amount: per(trend.slopePerYear), + unit, + n: trend.n, + since: fmt.dateShortSmartCalendar(trend.firstAt), + }); + const projectionLine = + trend.projection === "exits" && trend.exitDate + ? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, { + date: fmt.dateShortSmartCalendar(trend.exitDate), + }) + : trend.projection === "no_band" + ? null + : t(`labs.trend.projection.${trend.projection}`); + + return ( + + + + + +

+ {slopeLine} +

+ {projectionLine ? ( +

+ {projectionLine} +

+ ) : null} +

+ {t("labs.trend.interval", { + lo: fmt.number(trend.slopeCi95[0], 2), + hi: fmt.number(trend.slopeCi95[1], 2), + unit, + })} +

+
+
+ ); +} diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index d7a0e2816..f4c976a74 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -108,8 +108,40 @@ export interface BiomarkerDto { hidden: boolean; createdAt: string; updatedAt: string; + /** v1.44 — the detail read's trend line; absent on the list and older servers. */ + trend?: BiomarkerTrendDto; } +/** v1.44 — `GET /api/biomarkers/{id}` `trend` (`computeLabTrend`). */ +export type BiomarkerTrendDto = + | { + status: "ok"; + n: number; + firstAt: string; + lastAt: string; + spanDays: number; + slopePerYear: number; + slopeCi95: [number, number]; + pValue: number; + fittedNow: number; + asOf: string; + projection: + | "exits" + | "beyond_horizon" + | "no_exit" + | "already_outside" + | "no_clear_trend" + | "no_band"; + exitDate: string | null; + exitBound: "low" | "high" | null; + } + | { + status: "insufficient"; + reason: "too_few_values" | "span_too_short"; + n: number; + spanDays: number; + }; + export interface BiomarkerListResponse { biomarkers: BiomarkerDto[]; } diff --git a/src/lib/insights/correlations.ts b/src/lib/insights/correlations.ts index 86ba3e7a9..892658646 100644 --- a/src/lib/insights/correlations.ts +++ b/src/lib/insights/correlations.ts @@ -388,7 +388,7 @@ function roundTo(value: number, digits: number): number { * gate is no longer load-bearing for correctness — only for sample * adequacy. */ -function twoSidedPFromT(absT: number, df: number): number { +export function twoSidedPFromT(absT: number, df: number): number { if (!Number.isFinite(absT)) return 0; if (df <= 0) return 1; if (absT === 0) return 1; @@ -396,6 +396,25 @@ function twoSidedPFromT(absT: number, df: number): number { return regularizedIncompleteBeta(x, df / 2, 0.5); } +/** + * The two-sided critical t at `alpha` with `df` degrees of freedom: the t + * whose two-sided p equals `alpha` (12.706 at df 1, 4.303 at df 2, 1.960 as + * df grows, for alpha 0.05). Bisection over {@link twoSidedPFromT}, which is + * monotone in t; 200 halvings of [0, 1e6] reach double precision. Null for a + * df below 1, where no interval exists. + */ +export function criticalTTwoSided(df: number, alpha = 0.05): number | null { + if (!(df >= 1) || !(alpha > 0 && alpha < 1)) return null; + let lo = 0; + let hi = 1e6; + for (let i = 0; i < 200; i += 1) { + const mid = (lo + hi) / 2; + if (twoSidedPFromT(mid, df) > alpha) lo = mid; + else hi = mid; + } + return (lo + hi) / 2; +} + // ── Welch two-sample t-test (unequal variances) ──────────────────── export interface WelchOk { diff --git a/src/lib/labs/__tests__/lab-trend.test.ts b/src/lib/labs/__tests__/lab-trend.test.ts new file mode 100644 index 000000000..b4aeb3dfa --- /dev/null +++ b/src/lib/labs/__tests__/lab-trend.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; + +import { criticalTTwoSided } from "@/lib/insights/correlations"; +import { computeLabTrend } from "../lab-trend"; + +const YEAR_MS = 365.25 * 86_400_000; +const T0 = Date.UTC(2023, 0, 1); +const atYears = (y: number) => new Date(T0 + y * YEAR_MS); +const readings = (values: Array<[number, number]>) => + values.map(([y, value]) => ({ takenAt: atYears(y), value })); + +describe("criticalTTwoSided", () => { + it("matches the tabled Student t at alpha 0.05", () => { + // df 1: tan(0.95·π/2) exactly. + expect(criticalTTwoSided(1)).toBeCloseTo(Math.tan(0.475 * Math.PI), 6); + expect(criticalTTwoSided(2)).toBeCloseTo(4.302653, 5); + expect(criticalTTwoSided(10)).toBeCloseTo(2.228139, 5); + expect(criticalTTwoSided(1000)).toBeCloseTo(1.962339, 4); + expect(criticalTTwoSided(0)).toBeNull(); + }); +}); + +describe("computeLabTrend", () => { + it("needs three readings over at least a year", () => { + expect( + computeLabTrend( + readings([ + [0, 1], + [2, 2], + ]), + { low: 0, high: 5 }, + atYears(2), + ), + ).toEqual({ + status: "insufficient", + reason: "too_few_values", + n: 2, + spanDays: 731, + }); + expect( + computeLabTrend( + readings([ + [0, 1], + [0.3, 2], + [0.6, 3], + ]), + { low: 0, high: 5 }, + atYears(1), + ), + ).toMatchObject({ status: "insufficient", reason: "span_too_short", n: 3 }); + }); + + it("projects the exit date of an exact line", () => { + // 100, 110, 120 a year apart: 10 per year, no residual, band top 140. + const trend = computeLabTrend( + readings([ + [0, 100], + [1, 110], + [2, 120], + ]), + { low: 70, high: 140 }, + atYears(2), + ); + expect(trend).toMatchObject({ + status: "ok", + n: 3, + slopePerYear: 10, + slopeCi95: [10, 10], + pValue: 0, + fittedNow: 120, + projection: "exits", + exitBound: "high", + // The line meets 140 four years after the first reading. + exitDate: new Date(T0 + 4 * YEAR_MS).toISOString().slice(0, 10), + }); + }); + + it("draws no projection when the slope's interval includes zero", () => { + // 100, 112, 118: slope 9, SSR 6, df 1, SE √3, t = 9/√3. + const trend = computeLabTrend( + readings([ + [0, 100], + [1, 112], + [2, 118], + ]), + { low: 70, high: 140 }, + atYears(2), + ); + if (trend.status !== "ok") throw new Error("expected ok"); + const half = Math.tan(0.475 * Math.PI) * Math.sqrt(3); + expect(trend.slopePerYear).toBe(9); + expect(trend.slopeCi95[0]).toBeCloseTo(9 - half, 3); + expect(trend.slopeCi95[1]).toBeCloseTo(9 + half, 3); + // df 1 is the Cauchy: p = (2/π)·atan(√3 / 9). + expect(trend.pValue).toBeCloseTo( + (2 / Math.PI) * Math.atan(Math.sqrt(3) / 9), + 5, + ); + expect(trend.projection).toBe("no_clear_trend"); + expect(trend.exitDate).toBeNull(); + }); + + it("says when the line is already outside, has no band, or exits too late", () => { + const line = readings([ + [0, 100], + [1, 110], + [2, 120], + ]); + expect( + computeLabTrend(line, { low: 70, high: 115 }, atYears(2)), + ).toMatchObject({ projection: "already_outside" }); + expect( + computeLabTrend(line, { low: null, high: null }, atYears(2)), + ).toMatchObject({ projection: "no_band" }); + expect( + computeLabTrend(line, { low: 70, high: 200 }, atYears(2)), + ).toMatchObject({ projection: "beyond_horizon", exitDate: null }); + // Falling toward a band with no lower bound. + const falling = readings([ + [0, 120], + [1, 110], + [2, 100], + ]); + expect( + computeLabTrend(falling, { low: null, high: 140 }, atYears(2)), + ).toMatchObject({ projection: "no_exit" }); + expect( + computeLabTrend(falling, { low: 90, high: 140 }, atYears(2)), + ).toMatchObject({ projection: "exits", exitBound: "low" }); + }); +}); diff --git a/src/lib/labs/lab-trend.ts b/src/lib/labs/lab-trend.ts new file mode 100644 index 000000000..f577c1f3d --- /dev/null +++ b/src/lib/labs/lab-trend.ts @@ -0,0 +1,184 @@ +/** + * v1.44 — the slope of one lab marker over all its readings, and where that + * line would leave the reference band. + * + * A single panel says where a value is; the trend says where it is going. + * The statistic is an ordinary least-squares line through every numeric + * reading of the marker (stored in the marker's unit since v1.40, so the + * series is unit-normalised by construction), x in years. It is reported + * only with at least {@link LAB_TREND_MIN_VALUES} readings spanning at least + * {@link LAB_TREND_MIN_SPAN_DAYS} days: three draws in one month are a + * measurement-noise question, not a trend. + * + * The slope carries its 95 % confidence interval (Student t at n − 2 degrees + * of freedom). A projection is offered only when that interval excludes zero, + * the line is inside the band today and heads toward a bound it reaches + * within {@link LAB_PROJECTION_HORIZON_YEARS} years. It is a straight-line + * extrapolation, labelled as one wherever it is shown, never a forecast of + * the body and never a risk statement: lab values plateau, respond to + * treatment and carry assay noise a line does not know about. With three + * readings the interval is wide (t ≈ 12.7 at one degree of freedom), so the + * projection is rare exactly where the evidence is thin. + * + * Pure. The read lives in the biomarker route. + */ +import { criticalTTwoSided, twoSidedPFromT } from "@/lib/insights/correlations"; +import { linearFit } from "@/lib/stats/linear-fit"; +import { dateOnlyKey } from "@/lib/tz/date-only"; + +export const LAB_TREND_MIN_VALUES = 3; +export const LAB_TREND_MIN_SPAN_DAYS = 365; +export const LAB_PROJECTION_HORIZON_YEARS = 3; + +const DAY_MS = 86_400_000; +const YEAR_DAYS = 365.25; + +export type LabTrendProjection = + /** The line meets a bound within the horizon: `exitDate` is set. */ + | "exits" + /** The line heads to a bound but meets it after the horizon. */ + | "beyond_horizon" + /** The line heads away from the band edges, or to a side with no bound. */ + | "no_exit" + /** The line already sits outside the band today. */ + | "already_outside" + /** The slope's interval includes zero: no projection is drawn. */ + | "no_clear_trend" + /** The marker has no reference band to leave. */ + | "no_band"; + +export interface LabTrendOk { + status: "ok"; + /** Readings in the fit. */ + n: number; + firstAt: string; + lastAt: string; + spanDays: number; + /** Change per year in the marker's unit. */ + slopePerYear: number; + /** 95 % interval of the slope per year. */ + slopeCi95: [number, number]; + /** Two-sided p of the slope against zero (t at n − 2 df). */ + pValue: number; + /** The line's value on `asOf`. */ + fittedNow: number; + asOf: string; + projection: LabTrendProjection; + /** Set only for `exits`: the day the line meets `exitBound`. */ + exitDate: string | null; + exitBound: "low" | "high" | null; +} + +export interface LabTrendInsufficient { + status: "insufficient"; + reason: "too_few_values" | "span_too_short"; + n: number; + spanDays: number; +} + +export type LabTrend = LabTrendOk | LabTrendInsufficient; + +const round = (x: number, digits: number) => { + const f = 10 ** digits; + return Math.round(x * f) / f; +}; + +/** + * The calendar label of a draw instant. Lab readings carry the day of the + * draw as their meaning (`reading-day.ts`); the exit day is a point on the + * same axis, so both read through the same label. + */ +const dayOf = (ms: number) => dateOnlyKey(new Date(ms)); + +/** + * Fit the marker's readings and place the line against its band. + * `now` anchors "today" for the fitted value and the horizon. + */ +export function computeLabTrend( + readings: ReadonlyArray<{ takenAt: Date; value: number }>, + band: { low: number | null; high: number | null }, + now: Date, +): LabTrend { + const points = readings + .filter((r) => Number.isFinite(r.value)) + .map((r) => ({ t: r.takenAt.getTime(), v: r.value })) + .sort((a, b) => a.t - b.t); + const n = points.length; + const spanDays = + n > 0 ? Math.round((points[n - 1].t - points[0].t) / DAY_MS) : 0; + if (n < LAB_TREND_MIN_VALUES) { + return { status: "insufficient", reason: "too_few_values", n, spanDays }; + } + if (spanDays < LAB_TREND_MIN_SPAN_DAYS) { + return { status: "insufficient", reason: "span_too_short", n, spanDays }; + } + + const t0 = points[0].t; + const xs = points.map((p) => (p.t - t0) / DAY_MS / YEAR_DAYS); + const ys = points.map((p) => p.v); + const fit = linearFit(xs, ys); + if (!fit) { + return { status: "insufficient", reason: "span_too_short", n, spanDays }; + } + + const df = n - 2; + // Residual variance from the centred sums: SSR = Syy − Sxy² / Sxx. + const ssr = Math.max(0, fit.syy - (fit.sxy * fit.sxy) / fit.sxx); + const se = df > 0 ? Math.sqrt(ssr / df / fit.sxx) : Infinity; + const tCrit = criticalTTwoSided(df) ?? Infinity; + const half = tCrit * se; + const lo = fit.slope - half; + const hi = fit.slope + half; + const pValue = + se === 0 + ? fit.slope === 0 + ? 1 + : 0 + : twoSidedPFromT(Math.abs(fit.slope / se), df); + + const xNow = (now.getTime() - t0) / DAY_MS / YEAR_DAYS; + const fittedNow = fit.intercept + fit.slope * xNow; + + let projection: LabTrendProjection; + let exitDate: string | null = null; + let exitBound: "low" | "high" | null = null; + const hasBand = band.low != null || band.high != null; + if (!hasBand) projection = "no_band"; + else if (!(lo > 0 || hi < 0)) projection = "no_clear_trend"; + else if ( + (band.low != null && fittedNow < band.low) || + (band.high != null && fittedNow > band.high) + ) { + projection = "already_outside"; + } else { + const bound = fit.slope > 0 ? band.high : band.low; + if (bound == null) projection = "no_exit"; + else { + const xCross = (bound - fit.intercept) / fit.slope; + const yearsAhead = xCross - xNow; + if (yearsAhead > LAB_PROJECTION_HORIZON_YEARS) { + projection = "beyond_horizon"; + } else { + projection = "exits"; + exitBound = fit.slope > 0 ? "high" : "low"; + exitDate = dayOf(t0 + xCross * YEAR_DAYS * DAY_MS); + } + } + } + + return { + status: "ok", + n, + firstAt: dayOf(points[0].t), + lastAt: dayOf(points[n - 1].t), + spanDays, + slopePerYear: round(fit.slope, 4), + slopeCi95: [round(lo, 4), round(hi, 4)], + pValue: round(pValue, 6), + fittedNow: round(fittedNow, 4), + asOf: dayOf(now.getTime()), + projection, + exitDate, + exitBound, + }; +} diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index b467b9ba3..8de2be86d 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -77,6 +77,58 @@ const biomarkerRow = z "A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as: the catalog slug its name belongs to (e.g. `fasting-glucose`, `ldl`, `hdl`), or a key of the unit-conversion registry for an analyte with no catalog seed (e.g. `urea`, `uric-acid`), or null when its name matches none. It is derived from `name` and `unit`, never chosen: it is set on create and re-derived (or cleared) when an update changes either. A reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.", }); +const labTrend = z + .union([ + z.object({ + status: z.literal("ok"), + n: z.number().int(), + firstAt: z.string().describe("YYYY-MM-DD of the first reading."), + lastAt: z.string().describe("YYYY-MM-DD of the newest reading."), + spanDays: z.number().int(), + slopePerYear: z + .number() + .describe("Least-squares change per year, in the marker's unit."), + slopeCi95: z + .tuple([z.number(), z.number()]) + .describe("95 % interval of the slope (Student t at n − 2 df)."), + pValue: z.number(), + fittedNow: z.number().describe("The line's value on `asOf`."), + asOf: z.string().describe("YYYY-MM-DD the line was evaluated on."), + projection: z.enum([ + "exits", + "beyond_horizon", + "no_exit", + "already_outside", + "no_clear_trend", + "no_band", + ]), + exitDate: z + .string() + .nullable() + .describe( + "Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast.", + ), + exitBound: z.enum(["low", "high"]).nullable(), + }), + z.object({ + status: z.literal("insufficient"), + reason: z.enum(["too_few_values", "span_too_short"]), + n: z.number().int(), + spanDays: z.number().int(), + }), + ]) + .meta({ + id: "BiomarkerTrend", + description: + "v1.44 — the least-squares line through every numeric reading of the marker (all stored in the marker's unit). Needs at least three readings spanning at least 365 days, else `insufficient`. `projection` places the line against the marker's band: `exits` (with `exitDate`) only when the slope's 95 % interval excludes zero, the line is inside the band today and reaches a bound within three years. Server-computed; clients render it as given.", + }); + +const biomarkerDetail = biomarkerRow + .extend({ + trend: labTrend.optional().describe("v1.44 — absent on older servers."), + }) + .meta({ id: "BiomarkerDetail" }); + const listResponse = z .object({ biomarkers: z.array(biomarkerRow) }) .meta({ id: "ListBiomarkersResponse" }); @@ -191,7 +243,7 @@ export const biomarkerPaths: NonNullable = { tags: ["Labs"], summary: "Fetch a single biomarker", description: - "Returns the biomarker including its decrypted `context` and, since v1.43, its `aliases`. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`.", + "Returns the biomarker including its decrypted `context`, since v1.43 its `aliases`, and since v1.44 its `trend` over every stored reading. Cross-user rows surface as 404. A marker merged into another answers 404 with `meta.mergedInto`.", requestParams: { path: z.object({ id: z.string() }) }, responses: { ...recordRefusal(), @@ -199,7 +251,7 @@ export const biomarkerPaths: NonNullable = { description: "Biomarker detail.", content: { "application/json": { - schema: dataEnvelope(biomarkerRow, "GetBiomarkerResponse"), + schema: dataEnvelope(biomarkerDetail, "GetBiomarkerResponse"), }, }, },