mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
faa3ef867b
commit
2ba6214a65
20 files changed
+993
-11
No files matched your search
+170
-3
@@ -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:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? ""}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user