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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 22:40:43 +02:00
1 parent 5f563623d7
commit e482f76508
16 files changed
+165 -30

No files matched your search

+10 -3
View File
@@ -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:
+3 -2
View File
@@ -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}."
}
+3 -2
View File
@@ -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}."
}
+3 -2
View File
@@ -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}."
}
+3 -2
View File
@@ -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}."
}
+3 -2
View File
@@ -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}."
}
+3 -2
View File
@@ -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}."
}
+3 -2
View File
@@ -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}."
}
+3 -2
View File
@@ -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}."
}
+3 -1
View File
@@ -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 },
}),
@@ -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("<LabTrendCard>", () => {
).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");
});
});
+9 -1
View File
@@ -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 (
<Card data-slot="lab-trend">
+3 -1
View File
@@ -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;
}
+46
View File
@@ -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" });
});
});
+36 -6
View File
@@ -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,
+12 -2
View File
@@ -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