mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
5f563623d7
commit
e482f76508
16 files changed
+165
-30
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user