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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 21:51:36 +02:00
1 parent faa3ef867b
commit 2ba6214a65
20 files changed
+993 -11

No files matched your search

+170 -3
View File
@@ -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:
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
+19
View File
@@ -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": {
@@ -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. */
@@ -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);
+37 -3
View File
@@ -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,
});
},
);
@@ -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(
<I18nProvider initialLocale={locale}>
<LabTrendCard trend={trend} unit="mg/dL" />
</I18nProvider>,
);
}
describe("<LabTrendCard>", () => {
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("");
});
});
@@ -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 }) {
/>
</CardContent>
</Card>
<LabTrendCard
trend={marker?.trend}
unit={marker?.unit ?? latest?.unit ?? ""}
/>
</>
)}
+81
View File
@@ -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 (
<Card data-slot="lab-trend">
<CardHeader>
<TileHeader icon={TrendingUp} title={t("labs.trend.title")} />
</CardHeader>
<CardContent className="space-y-1">
<p className="text-sm" data-slot="lab-trend-slope">
{slopeLine}
</p>
{projectionLine ? (
<p
className="text-sm"
data-slot="lab-trend-projection"
data-projection={trend.projection}
>
{projectionLine}
</p>
) : null}
<p className="text-muted-foreground text-xs">
{t("labs.trend.interval", {
lo: fmt.number(trend.slopeCi95[0], 2),
hi: fmt.number(trend.slopeCi95[1], 2),
unit,
})}
</p>
</CardContent>
</Card>
);
}
+32
View File
@@ -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[];
}
+20 -1
View File
@@ -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 {
+131
View File
@@ -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" });
});
});
+184
View File
@@ -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,
};
}
+54 -2
View File
@@ -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<ZodOpenApiObject["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`.",
"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<ZodOpenApiObject["paths"]> = {
description: "Biomarker detail.",
content: {
"application/json": {
schema: dataEnvelope(biomarkerRow, "GetBiomarkerResponse"),
schema: dataEnvelope(biomarkerDetail, "GetBiomarkerResponse"),
},
},
},