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 (
+
+
+
+
+
+