From e482f76508e964de4dfbb4596efd4af59839bcd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 22:40:43 +0200 Subject: [PATCH] fix(labs): stop projecting lab trends from stale or short series A marker whose newest reading is more than a year old is now reported as of that reading (projection "stale"), with no exit date and no already-outside verdict. The projection horizon is capped at the span the readings cover, so a line is never carried further ahead than the data reaches back. The detail read keeps the newest 2000 readings rather than the oldest. --- docs/api/openapi.yaml | 13 ++++-- messages/de.json | 5 +- messages/en.json | 5 +- messages/es.json | 5 +- messages/fr.json | 5 +- messages/it.json | 5 +- messages/ko.json | 5 +- messages/nl.json | 5 +- messages/pl.json | 5 +- src/app/api/biomarkers/[id]/route.ts | 4 +- .../labs/__tests__/lab-trend-card.test.tsx | 22 +++++++++ src/components/labs/lab-trend-card.tsx | 10 +++- src/components/labs/types.ts | 4 +- src/lib/labs/__tests__/lab-trend.test.ts | 46 +++++++++++++++++++ src/lib/labs/lab-trend.ts | 42 ++++++++++++++--- src/lib/openapi/routes/biomarkers.ts | 14 +++++- 16 files changed, 165 insertions(+), 30 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a0f523a40..0452e9bfc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -74758,7 +74758,11 @@ components: description: The line's value on `asOf`. asOf: type: string - description: YYYY-MM-DD the line was evaluated on. + description: "YYYY-MM-DD the line was evaluated on: today, or `lastAt` when `projection` is `stale`." + horizonYears: + type: number + description: "How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when + shorter." projection: type: string enum: @@ -74768,6 +74772,7 @@ components: - already_outside - no_clear_trend - no_band + - stale exitDate: description: "Only for `exits`: the day the straight line meets `exitBound`. A projection, never a forecast." type: @@ -74791,6 +74796,7 @@ components: - pValue - fittedNow - asOf + - horizonYears - projection - exitDate - exitBound @@ -74822,8 +74828,9 @@ components: 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." + line is inside the band today and reaches a bound within `horizonYears` (three years, or the readings' span when + shorter). `stale` when the newest reading is more than 365 days old: the line is then reported as of `lastAt`, + with no projection. Server-computed; clients render it as given." UpdateBiomarkerResponse: type: object properties: diff --git a/messages/de.json b/messages/de.json index 255887f9c..0bea7d105 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10207,10 +10207,11 @@ "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.", + "beyond_horizon": "In diesem Tempo bleibt die Gerade länger als {years} Jahre in deinem Referenzbereich, so weit reichen deine Werte zurück.", "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." + "no_clear_trend": "Über diese Werte ist die Veränderung nicht klar von keiner Veränderung zu unterscheiden, deshalb wird nichts fortgeschrieben.", + "stale": "Der neueste Wert ist vom {date}, also älter als ein Jahr. Die Gerade gilt für diesen Stand, ohne Projektion." }, "interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}." } diff --git a/messages/en.json b/messages/en.json index d5caa0a3c..6647f6d01 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10207,10 +10207,11 @@ "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.", + "beyond_horizon": "At this pace the line stays inside your reference range for more than {years} years, the span your readings cover.", "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." + "no_clear_trend": "Across these readings the change is not clearly different from no change, so no projection is drawn.", + "stale": "The newest reading is from {date}, more than a year ago. The line is shown as of then, without a projection." }, "interval": "95 % interval of the yearly change: {lo} to {hi} {unit}." } diff --git a/messages/es.json b/messages/es.json index 067c6cc1d..dd7b71ab8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10207,10 +10207,11 @@ "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.", + "beyond_horizon": "A este ritmo, la línea se mantiene dentro de tu rango de referencia más de {years} años, el periodo que cubren tus valores.", "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." + "no_clear_trend": "En estos valores, el cambio no se distingue con claridad de la ausencia de cambio, así que no se proyecta nada.", + "stale": "El valor más reciente es del {date}, hace más de un año. La línea se muestra a esa fecha, sin proyección." }, "interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}." } diff --git a/messages/fr.json b/messages/fr.json index 72bbb6ea3..9f408bbe0 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10207,10 +10207,11 @@ "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.", + "beyond_horizon": "À ce rythme, la droite reste dans ta plage de référence plus de {years} ans, la durée couverte par tes valeurs.", "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." + "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.", + "stale": "La valeur la plus récente date du {date}, il y a plus d’un an. La droite est montrée à cette date, sans projection." }, "interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}." } diff --git a/messages/it.json b/messages/it.json index f18c92e05..140376896 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10207,10 +10207,11 @@ "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.", + "beyond_horizon": "A questo ritmo la retta resta nel tuo intervallo di riferimento per più di {years} anni, il periodo coperto dai tuoi valori.", "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." + "no_clear_trend": "Su questi valori la variazione non si distingue chiaramente dall'assenza di variazione, quindi non si proietta nulla.", + "stale": "Il valore più recente è del {date}, più di un anno fa. La retta è mostrata a quella data, senza proiezione." }, "interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}." } diff --git a/messages/ko.json b/messages/ko.json index afddfe2e2..b29dd3b6c 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10207,10 +10207,11 @@ "high": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 상한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다.", "low": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 하한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다." }, - "beyond_horizon": "이 속도라면 적어도 3년 동안은 참고 범위 안에 머뭅니다.", + "beyond_horizon": "이 속도라면 {years}년 넘게 참고 범위 안에 머뭅니다. 측정값이 다루는 기간입니다.", "no_exit": "직선이 참고 범위의 경계에서 멀어지고 있습니다.", "already_outside": "직선이 이미 참고 범위 밖에 있습니다.", - "no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다." + "no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다.", + "stale": "가장 최근 값은 {date}로 1년이 넘었습니다. 직선은 그 시점 기준으로 표시하며 예측은 하지 않습니다." }, "interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}." } diff --git a/messages/nl.json b/messages/nl.json index 80005fc1f..ebef8b7d0 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10207,10 +10207,11 @@ "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.", + "beyond_horizon": "In dit tempo blijft de lijn langer dan {years} jaar binnen je referentiebereik, de periode die je waarden beslaan.", "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." + "no_clear_trend": "Over deze waarden is de verandering niet duidelijk te onderscheiden van geen verandering, dus er wordt niets doorgetrokken.", + "stale": "De nieuwste waarde is van {date}, meer dan een jaar geleden. De lijn staat op die datum, zonder projectie." }, "interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}." } diff --git a/messages/pl.json b/messages/pl.json index ce08e9fbd..6a641519c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10207,10 +10207,11 @@ "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.", + "beyond_horizon": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym dłużej niż {years} lat, czyli okres objęty twoimi wynikami.", "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." + "no_clear_trend": "Na podstawie tych wyników zmiana nie różni się wyraźnie od braku zmiany, więc nie jest wyznaczana ekstrapolacja.", + "stale": "Najnowszy wynik pochodzi z {date}, sprzed ponad roku. Prosta jest pokazana na ten dzień, bez projekcji." }, "interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}." } diff --git a/src/app/api/biomarkers/[id]/route.ts b/src/app/api/biomarkers/[id]/route.ts index e47454e51..17f4b07ca 100644 --- a/src/app/api/biomarkers/[id]/route.ts +++ b/src/app/api/biomarkers/[id]/route.ts @@ -74,7 +74,9 @@ export const GET = apiHandler( deletedAt: null, value: { not: null }, }, - orderBy: { takenAt: "asc" }, + // Newest first, so a capped read keeps the recent end of the series + // (the staleness check reads the newest reading); the fit sorts. + orderBy: { takenAt: "desc" }, take: LAB_TREND_READ_CAP, select: { takenAt: true, value: true }, }), diff --git a/src/components/labs/__tests__/lab-trend-card.test.tsx b/src/components/labs/__tests__/lab-trend-card.test.tsx index d5c3ae554..7757520f1 100644 --- a/src/components/labs/__tests__/lab-trend-card.test.tsx +++ b/src/components/labs/__tests__/lab-trend-card.test.tsx @@ -20,6 +20,7 @@ const OK: BiomarkerTrendDto = { pValue: 0.02, fittedNow: 130, asOf: "2026-10-21", + horizonYears: 3, projection: "exits", exitDate: "2027-11-02", exitBound: "high", @@ -71,4 +72,25 @@ describe("", () => { ).toBe(""); expect(render(undefined)).toBe(""); }); + + it("words a stale trend as of its newest reading, and a span-bound horizon", () => { + const stale = render({ + ...OK, + asOf: "2026-01-10", + projection: "stale", + exitDate: null, + exitBound: null, + }); + expect(stale).toContain('data-projection="stale"'); + expect(stale).toContain("more than a year ago"); + expect(stale).not.toContain("forecast"); + const beyond = render({ + ...OK, + horizonYears: 1.5, + projection: "beyond_horizon", + exitDate: null, + exitBound: null, + }); + expect(beyond).toContain("for more than 1.5 years"); + }); }); diff --git a/src/components/labs/lab-trend-card.tsx b/src/components/labs/lab-trend-card.tsx index 94b81f164..975518443 100644 --- a/src/components/labs/lab-trend-card.tsx +++ b/src/components/labs/lab-trend-card.tsx @@ -48,7 +48,15 @@ export function LabTrendCard({ }) : trend.projection === "no_band" ? null - : t(`labs.trend.projection.${trend.projection}`); + : trend.projection === "stale" + ? t("labs.trend.projection.stale", { + date: fmt.dateShortSmartCalendar(trend.lastAt), + }) + : trend.projection === "beyond_horizon" + ? t("labs.trend.projection.beyond_horizon", { + years: fmt.number(trend.horizonYears, 1), + }) + : t(`labs.trend.projection.${trend.projection}`); return ( diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts index f4c976a74..783c3d3c1 100644 --- a/src/components/labs/types.ts +++ b/src/components/labs/types.ts @@ -125,13 +125,15 @@ export type BiomarkerTrendDto = pValue: number; fittedNow: number; asOf: string; + horizonYears: number; projection: | "exits" | "beyond_horizon" | "no_exit" | "already_outside" | "no_clear_trend" - | "no_band"; + | "no_band" + | "stale"; exitDate: string | null; exitBound: "low" | "high" | null; } diff --git a/src/lib/labs/__tests__/lab-trend.test.ts b/src/lib/labs/__tests__/lab-trend.test.ts index b4aeb3dfa..9a7591b63 100644 --- a/src/lib/labs/__tests__/lab-trend.test.ts +++ b/src/lib/labs/__tests__/lab-trend.test.ts @@ -128,4 +128,50 @@ describe("computeLabTrend", () => { computeLabTrend(falling, { low: 90, high: 140 }, atYears(2)), ).toMatchObject({ projection: "exits", exitBound: "low" }); }); + + it("never carries the line further ahead than the readings reach back", () => { + // 10 a year over one year (span 1.0); band top 125 sits 1.5 years past + // the last reading. Inside the three-year cap, outside the one-year span. + const short = readings([ + [0, 100], + [0.5, 105], + [1, 110], + ]); + expect( + computeLabTrend(short, { low: 70, high: 125 }, atYears(1)), + ).toMatchObject({ projection: "beyond_horizon", horizonYears: 1 }); + // The same line with a top of 118 meets it 0.8 years out: projected. + expect( + computeLabTrend(short, { low: 70, high: 118 }, atYears(1)), + ).toMatchObject({ projection: "exits", exitBound: "high" }); + }); + + it("reports a stale series as of its last reading, without a projection", () => { + const line = readings([ + [0, 100], + [1, 110], + [2, 120], + ]); + // Last reading two years before "now": evaluated on that day. + const stale = computeLabTrend(line, { low: 70, high: 125 }, atYears(4)); + expect(stale).toMatchObject({ + status: "ok", + projection: "stale", + exitDate: null, + exitBound: null, + fittedNow: 120, + slopePerYear: 10, + }); + if (stale.status !== "ok") throw new Error("expected ok"); + expect(stale.asOf).toBe(stale.lastAt); + // Carried to today the line would read 140, above the band: a stale + // series never says "already outside". + expect( + computeLabTrend(line, { low: 70, high: 115 }, atYears(4)), + ).toMatchObject({ projection: "stale" }); + // Eleven months after the last reading it is still current. + expect( + computeLabTrend(line, { low: 70, high: 200 }, atYears(2.9)), + ).toMatchObject({ projection: "beyond_horizon" }); + }); }); diff --git a/src/lib/labs/lab-trend.ts b/src/lib/labs/lab-trend.ts index f577c1f3d..c5781b80e 100644 --- a/src/lib/labs/lab-trend.ts +++ b/src/lib/labs/lab-trend.ts @@ -13,7 +13,14 @@ * 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 + * within the horizon: {@link LAB_PROJECTION_HORIZON_YEARS} years, or the span + * the readings cover when that is shorter, so the line is never carried + * further ahead than the data reaches back. + * + * A marker whose newest reading is older than {@link LAB_TREND_STALE_DAYS} + * days gets no projection at all (`stale`): the line is evaluated on the day + * of that reading and reported as of then, with no exit date and no + * "already outside" verdict about a today the readings say nothing about. 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 @@ -29,6 +36,8 @@ 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; +/** Newest reading older than this: the trend is reported as of that reading. */ +export const LAB_TREND_STALE_DAYS = 365; const DAY_MS = 86_400_000; const YEAR_DAYS = 365.25; @@ -45,7 +54,12 @@ export type LabTrendProjection = /** The slope's interval includes zero: no projection is drawn. */ | "no_clear_trend" /** The marker has no reference band to leave. */ - | "no_band"; + | "no_band" + /** + * The newest reading is older than {@link LAB_TREND_STALE_DAYS} days: the + * line is reported as of that reading (`asOf` = `lastAt`), not projected. + */ + | "stale"; export interface LabTrendOk { status: "ok"; @@ -62,7 +76,13 @@ export interface LabTrendOk { pValue: number; /** The line's value on `asOf`. */ fittedNow: number; + /** Today, or the newest reading's day when the trend is `stale`. */ asOf: string; + /** + * How far ahead a bound may be met and still count as `exits`: three years, + * or the readings' span when shorter. + */ + horizonYears: number; projection: LabTrendProjection; /** Set only for `exits`: the day the line meets `exitBound`. */ exitDate: string | null; @@ -136,14 +156,23 @@ export function computeLabTrend( : 0 : twoSidedPFromT(Math.abs(fit.slope / se), df); - const xNow = (now.getTime() - t0) / DAY_MS / YEAR_DAYS; + const tLast = points[n - 1].t; + const stale = now.getTime() - tLast > LAB_TREND_STALE_DAYS * DAY_MS; + // A stale series is read on its own last day, never carried to today. + const tAsOf = stale ? tLast : now.getTime(); + const xNow = (tAsOf - t0) / DAY_MS / YEAR_DAYS; const fittedNow = fit.intercept + fit.slope * xNow; + const horizonYears = Math.min( + LAB_PROJECTION_HORIZON_YEARS, + (tLast - t0) / DAY_MS / YEAR_DAYS, + ); 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"; + if (stale) projection = "stale"; + else if (!hasBand) projection = "no_band"; else if (!(lo > 0 || hi < 0)) projection = "no_clear_trend"; else if ( (band.low != null && fittedNow < band.low) || @@ -156,7 +185,7 @@ export function computeLabTrend( else { const xCross = (bound - fit.intercept) / fit.slope; const yearsAhead = xCross - xNow; - if (yearsAhead > LAB_PROJECTION_HORIZON_YEARS) { + if (yearsAhead > horizonYears) { projection = "beyond_horizon"; } else { projection = "exits"; @@ -176,7 +205,8 @@ export function computeLabTrend( slopeCi95: [round(lo, 4), round(hi, 4)], pValue: round(pValue, 6), fittedNow: round(fittedNow, 4), - asOf: dayOf(now.getTime()), + asOf: dayOf(tAsOf), + horizonYears: round(horizonYears, 2), projection, exitDate, exitBound, diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 8de2be86d..b4568f1fe 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -93,7 +93,16 @@ const labTrend = z .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."), + asOf: z + .string() + .describe( + "YYYY-MM-DD the line was evaluated on: today, or `lastAt` when `projection` is `stale`.", + ), + horizonYears: z + .number() + .describe( + "How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when shorter.", + ), projection: z.enum([ "exits", "beyond_horizon", @@ -101,6 +110,7 @@ const labTrend = z "already_outside", "no_clear_trend", "no_band", + "stale", ]), exitDate: z .string() @@ -120,7 +130,7 @@ const labTrend = z .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.", + "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 `horizonYears` (three years, or the readings' span when shorter). `stale` when the newest reading is more than 365 days old: the line is then reported as of `lastAt`, with no projection. Server-computed; clients render it as given.", }); const biomarkerDetail = biomarkerRow