Merge branch 'feat/v144-review2' into release/v1.44.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-22 00:46:09 +02:00
commit ae479c86cf
34 files changed
+767 -240

No files matched your search

+27 -20
View File
@@ -1274,9 +1274,9 @@ paths:
- in: query
name: _offset
schema:
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
type: number
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
responses:
"200":
description: searchset Bundle (application/fhir+json).
@@ -1312,9 +1312,9 @@ paths:
- in: query
name: _offset
schema:
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
type: number
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
- in: query
name: date
schema:
@@ -1349,9 +1349,9 @@ paths:
- in: query
name: _offset
schema:
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
type: number
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
responses:
"200":
description: searchset Bundle (application/fhir+json).
@@ -1378,9 +1378,9 @@ paths:
- in: query
name: _offset
schema:
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
type: number
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
responses:
"200":
description: searchset Bundle (application/fhir+json).
@@ -1408,9 +1408,9 @@ paths:
- in: query
name: _offset
schema:
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
type: number
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
responses:
"200":
description: searchset Bundle (application/fhir+json).
@@ -1605,9 +1605,9 @@ paths:
- in: query
name: _offset
schema:
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
type: number
description: At most 100000; a larger offset is a 400 OperationOutcome.
description: Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.
responses:
"200":
description: searchset Bundle (application/fhir+json).
@@ -49472,8 +49472,8 @@ components:
const: ok
asOf:
type: string
description: "The last UTC day with a stored impulse, at most yesterday: a nightly run that has not happened yet never
reads as a rest day."
description: The last rated UTC day, at most yesterday; the figures are those of this day. The series runs on to
yesterday, so a later stretch without data shows as unrated days.
ctl:
type: number
description: "Fitness: 42-day exponentially weighted TRIMP."
@@ -49495,7 +49495,8 @@ components:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse.
description: "Rated days of the trailing `recentDays` (42 up to yesterday, or fewer when covered less): a stored
impulse, or activity data without a workout."
recentDays:
type: integer
minimum: -9007199254740991
@@ -49518,8 +49519,9 @@ components:
type: number
rated:
type: boolean
description: "False for a day without a stored impulse (never scored): the averages held through it rather than counting
it as rest."
description: "False for a day that could not be rated: no stored impulse and no activity data (steps, heart rate, active
energy), or a workout that was never scored. The averages held through it rather than counting it
as rest. A day with activity data and no workout is rest (impulse 0)."
required:
- day
- ctl
@@ -49565,9 +49567,10 @@ components:
- daysCovered
- trainingDays
additionalProperties: false
description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a
scored day without training is 0, a day without a stored impulse is not rated and the averages hold through it).
Needs 28 days and three training days. TRIMP units: comparable with the person's own past only."
description: "v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day
with activity data but no training is 0, a day with no data or an unscored workout is not rated and the averages
hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past
only."
GetWorkoutResponse:
type: object
properties:
@@ -74824,6 +74827,9 @@ components:
type: number
description: "How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when
shorter."
horizonCapped:
type: boolean
description: True when `horizonYears` is the three-year cap, false when the readings' shorter span set it.
projection:
type: string
enum:
@@ -74858,6 +74864,7 @@ components:
- fittedNow
- asOf
- horizonYears
- horizonCapped
- projection
- exitDate
- exitBound
+3 -1
View File
@@ -3903,7 +3903,8 @@
"form": "Form",
"explainer": "Fitness ist dein Trainingsimpuls aus der Herzfrequenz, gemittelt über etwa sechs Wochen, Ermüdung über etwa eine Woche, und Form ist der Unterschied. Die Zahlen lassen sich mit deinen eigenen früheren vergleichen, nicht mit anderen Menschen oder Apps.",
"warmingUp": "Auf Basis von bisher {days} Tagen: Bis sechs Wochen abgedeckt sind, fällt die Fitness niedriger aus, als sie ist.",
"coverage": "{rated} der letzten {days} Tage haben Herzfrequenzdaten. Tage ohne sie bleiben außen vor, statt als Ruhetag zu zählen."
"coverage": "{rated} der letzten {days} Tage ließen sich bewerten. Ein Tag ohne Aktivitätsdaten oder mit einem Training, das sich nicht auswerten ließ, bleibt außen vor, statt als Ruhetag zu zählen.",
"asOf": "Stand {date}, der letzte Tag, der sich bewerten ließ."
}
},
"bloodGlucose": {
@@ -10198,6 +10199,7 @@
"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 länger als {years} Jahre in deinem Referenzbereich, so weit reichen deine Werte zurück.",
"beyond_horizon_capped": "In diesem Tempo bleibt die Gerade länger als {years} Jahre in deinem Referenzbereich, so weit schaut die Fortschreibung voraus.",
"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.",
+3 -1
View File
@@ -3903,7 +3903,8 @@
"form": "Form",
"explainer": "Fitness is your heart-rate training impulse averaged over about six weeks, fatigue over about one week, and form is the difference. The numbers compare with your own past, not with other people or other apps.",
"warmingUp": "Based on {days} days so far: until six weeks are covered, fitness reads lower than it is.",
"coverage": "{rated} of the last {days} days have heart-rate data. Days without it are left out rather than counted as rest."
"coverage": "{rated} of the last {days} days could be rated. A day with no activity data, or with a workout that could not be scored, is left out rather than counted as rest.",
"asOf": "As of {date}, the last day that could be rated."
}
},
"bloodGlucose": {
@@ -10198,6 +10199,7 @@
"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 more than {years} years, the span your readings cover.",
"beyond_horizon_capped": "At this pace the line stays inside your reference range for more than {years} years, as far ahead as the projection looks.",
"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.",
+3 -1
View File
@@ -3903,7 +3903,8 @@
"form": "Estado",
"explainer": "La forma de base es tu impulso de entrenamiento por frecuencia cardiaca promediado en unas seis semanas, la fatiga en una semana, y el estado es la diferencia. Las cifras se comparan con tu propio pasado, no con otras personas ni otras apps.",
"warmingUp": "Con {days} días hasta ahora: hasta cubrir seis semanas, la forma de base aparece más baja de lo que es.",
"coverage": "{rated} de los últimos {days} días tienen datos de frecuencia cardiaca. Los días sin ellos se omiten en lugar de contar como descanso."
"coverage": "Se pudieron valorar {rated} de los últimos {days} días. Un día sin datos de actividad, o con un entrenamiento que no se pudo puntuar, se omite en lugar de contar como descanso.",
"asOf": "A fecha de {date}, el último día que se pudo valorar."
}
},
"bloodGlucose": {
@@ -10198,6 +10199,7 @@
"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 más de {years} años, el periodo que cubren tus valores.",
"beyond_horizon_capped": "A este ritmo, la línea se mantiene dentro de tu rango de referencia más de {years} años, hasta donde llega la proyección.",
"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.",
+3 -1
View File
@@ -3903,7 +3903,8 @@
"form": "Fraîcheur",
"explainer": "La condition est ton impulsion d'entraînement par fréquence cardiaque moyennée sur environ six semaines, la fatigue sur environ une semaine, et la fraîcheur est la différence. Les chiffres se comparent à ton propre passé, pas à d'autres personnes ni à d'autres apps.",
"warmingUp": "Sur {days} jours pour l'instant : tant que six semaines ne sont pas couvertes, la condition paraît plus basse qu'elle ne l'est.",
"coverage": "{rated} des {days} derniers jours ont des données de fréquence cardiaque. Les jours sans elles sont écartés au lieu de compter comme repos."
"coverage": "{rated} des {days} derniers jours ont pu être évalués. Un jour sans données d'activité, ou avec un entraînement qui n'a pas pu être évalué, est écarté au lieu de compter comme repos.",
"asOf": "Au {date}, le dernier jour qui a pu être évalué."
}
},
"bloodGlucose": {
@@ -10198,6 +10199,7 @@
"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 plus de {years} ans, la durée couverte par tes valeurs.",
"beyond_horizon_capped": "À ce rythme, la droite reste dans ta plage de référence plus de {years} ans, aussi loin que porte la projection.",
"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.",
+3 -1
View File
@@ -3903,7 +3903,8 @@
"form": "Freschezza",
"explainer": "La forma di base è il tuo impulso di allenamento dalla frequenza cardiaca mediato su circa sei settimane, l'affaticamento su circa una settimana, e la freschezza è la differenza. I numeri si confrontano con il tuo passato, non con altre persone o altre app.",
"warmingUp": "Su {days} giorni finora: finché non sono coperte sei settimane, la forma di base risulta più bassa di quanto sia.",
"coverage": "{rated} degli ultimi {days} giorni hanno dati di frequenza cardiaca. I giorni senza vengono esclusi invece di contare come riposo."
"coverage": "{rated} degli ultimi {days} giorni sono stati valutati. Un giorno senza dati di attività, o con un allenamento che non è stato possibile valutare, viene escluso invece di contare come riposo.",
"asOf": "Al {date}, l'ultimo giorno che è stato possibile valutare."
}
},
"bloodGlucose": {
@@ -10198,6 +10199,7 @@
"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 più di {years} anni, il periodo coperto dai tuoi valori.",
"beyond_horizon_capped": "A questo ritmo la retta resta nel tuo intervallo di riferimento per più di {years} anni, fin dove arriva la proiezione.",
"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.",
+3 -1
View File
@@ -3903,7 +3903,8 @@
"form": "컨디션",
"explainer": "체력은 심박 기반 훈련 자극을 약 6주 동안, 피로는 약 1주 동안 평균한 값이고, 컨디션은 그 차이입니다. 이 숫자는 다른 사람이나 다른 앱이 아니라 내 과거와 비교하는 값입니다.",
"warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다.",
"coverage": "최근 {days}일 중 {rated}일에 심박수 데이터가 있습니다. 데이터가 없는 날은 휴식일로 세지 않고 제외합니다."
"coverage": "최근 {days}일 중 {rated}일을 평가할 수 있었습니다. 활동 데이터가 없는 날이나 평가할 수 없는 운동이 있는 날은 휴식일로 세지 않고 제외합니다.",
"asOf": "{date} 기준, 평가할 수 있었던 마지막 날입니다."
}
},
"bloodGlucose": {
@@ -10198,6 +10199,7 @@
"low": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 하한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다."
},
"beyond_horizon": "이 속도라면 {years}년 넘게 참고 범위 안에 머뭅니다. 측정값이 다루는 기간입니다.",
"beyond_horizon_capped": "이 속도라면 {years}년 넘게 참고 범위 안에 머뭅니다. 연장선이 내다보는 최대 기간입니다.",
"no_exit": "직선이 참고 범위의 경계에서 멀어지고 있습니다.",
"already_outside": "직선이 이미 참고 범위 밖에 있습니다.",
"no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다.",
+3 -1
View File
@@ -3903,7 +3903,8 @@
"form": "Vorm",
"explainer": "Fitheid is je trainingsprikkel uit de hartslag, gemiddeld over ongeveer zes weken, vermoeidheid over ongeveer een week, en vorm is het verschil. De getallen zijn te vergelijken met je eigen verleden, niet met andere mensen of andere apps.",
"warmingUp": "Op basis van {days} dagen tot nu toe: tot zes weken gedekt zijn, valt de fitheid lager uit dan ze is.",
"coverage": "{rated} van de laatste {days} dagen hebben hartslaggegevens. Dagen zonder worden weggelaten in plaats van als rustdag te tellen."
"coverage": "{rated} van de laatste {days} dagen konden worden beoordeeld. Een dag zonder activiteitsgegevens, of met een training die niet kon worden beoordeeld, wordt weggelaten in plaats van als rustdag te tellen.",
"asOf": "Stand van {date}, de laatste dag die kon worden beoordeeld."
}
},
"bloodGlucose": {
@@ -10198,6 +10199,7 @@
"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 langer dan {years} jaar binnen je referentiebereik, de periode die je waarden beslaan.",
"beyond_horizon_capped": "In dit tempo blijft de lijn langer dan {years} jaar binnen je referentiebereik, zo ver kijkt de doortrekking vooruit.",
"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.",
+3 -1
View File
@@ -3903,7 +3903,8 @@
"form": "Forma",
"explainer": "Wydolność to twój bodziec treningowy z tętna uśredniony z około sześciu tygodni, zmęczenie z około tygodnia, a forma to różnica. Liczby porównuje się z twoją własną przeszłością, nie z innymi osobami ani aplikacjami.",
"warmingUp": "Na podstawie dotychczasowych {days} dni: dopóki nie minie sześć tygodni, wydolność wychodzi niższa, niż jest.",
"coverage": "{rated} z ostatnich {days} dni ma dane tętna. Dni bez nich są pomijane, a nie liczone jako odpoczynek."
"coverage": "Udało się ocenić {rated} z ostatnich {days} dni. Dzień bez danych o aktywności lub z treningiem, którego nie dało się ocenić, jest pomijany, a nie liczony jako odpoczynek.",
"asOf": "Stan na {date}, ostatni dzień, który dało się ocenić."
}
},
"bloodGlucose": {
@@ -10198,6 +10199,7 @@
"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 dłużej niż {years} lat, czyli okres objęty twoimi wynikami.",
"beyond_horizon_capped": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym dłużej niż {years} lat, czyli tak daleko, jak sięga ekstrapolacja.",
"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.",
@@ -160,10 +160,6 @@ const TS_READERS: Record<string, Entry> = {
"lib/insights/narrative/period-narrative.ts": {
exempt: "means of day-keyed series, already one value per day",
},
"lib/mcp/rich-reads.ts": {
exempt:
"changepoints over rollup bucket means, which weigh pulse days once (readCanonicalRollupBuckets)",
},
"lib/medications/efficacy/build-efficacy.ts": {
exempt: "means of the day-mean series (readDayMeanSeries)",
},
+37 -6
View File
@@ -39,7 +39,12 @@ vi.mock("next/headers", () => ({
import { GET as observationGet } from "../Observation/route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { loadFhirContext, MAX_COUNT } from "@/lib/fhir/rest";
import {
loadFhirContext,
MAX_COUNT,
MAX_OFFSET,
searchsetResponse,
} from "@/lib/fhir/rest";
import { requireModuleEnabled, isModuleEnabled } from "@/lib/modules/gate";
import { observationsFromReportData } from "@/lib/fhir/resources";
@@ -147,9 +152,9 @@ describe("GET /api/fhir/Observation — searchset", () => {
expect(bundle.entry[0].resource.id).toBe("obs-3");
});
it("refuses an offset past the ceiling with a 400 OperationOutcome", async () => {
it("refuses only an offset a number cannot carry exactly", async () => {
vi.mocked(observationsFromReportData).mockReturnValue([obs(1)] as never);
for (const offset of ["100001", "1e20", "Infinity"]) {
for (const offset of ["9007199254740992", "1e20", "Infinity"]) {
const res = await observationGet(req(`?_offset=${offset}`));
expect(res.status).toBe(400);
const outcome = (await res.json()) as {
@@ -159,9 +164,35 @@ describe("GET /api/fhir/Observation — searchset", () => {
expect(outcome.resourceType).toBe("OperationOutcome");
expect(outcome.issue[0].code).toBe("invalid");
}
// The ceiling itself still pages (an empty page past the end).
const atCeiling = await observationGet(req("?_offset=100000"));
expect(atCeiling.status).toBe(200);
// A long series pages past a hundred thousand rows, and the ceiling
// itself still pages (an empty page past the end).
for (const offset of ["100001", "250000", String(MAX_OFFSET)]) {
const res = await observationGet(req(`?_offset=${offset}`));
expect(res.status).toBe(200);
}
});
it("keeps emitting next links deep into a long series, never past the ceiling", () => {
const url = new URL("http://localhost/api/fhir/Observation");
const deep = searchsetResponse(url, [], 300_000, 50, 150_000);
const nextOf = async (res: Response) =>
(
(await res.json()) as { link: Array<{ relation: string; url: string }> }
).link.find((l) => l.relation === "next");
return Promise.all([
nextOf(deep).then((next) => {
expect(next?.url).toContain("_offset=150050");
}),
nextOf(
searchsetResponse(
url,
[],
Number.POSITIVE_INFINITY,
50,
MAX_OFFSET - 10,
),
).then((next) => expect(next).toBeUndefined()),
]);
});
it("enforces the fhir:read scope (Bearer narrow token without it 403s)", async () => {
@@ -28,6 +28,8 @@ vi.mock("@/lib/db", () => ({
},
// v1.44 — the first page carries the training load.
strainTrimpCache: { findMany: vi.fn(async () => []) },
measurementRollup: { findMany: vi.fn(async () => []) },
$queryRaw: vi.fn(async () => []),
},
}));
@@ -21,6 +21,7 @@ const OK: BiomarkerTrendDto = {
fittedNow: 130,
asOf: "2026-10-21",
horizonYears: 3,
horizonCapped: true,
projection: "exits",
exitDate: "2027-11-02",
exitBound: "high",
@@ -87,10 +88,38 @@ describe("<LabTrendCard>", () => {
const beyond = render({
...OK,
horizonYears: 1.5,
horizonCapped: false,
projection: "beyond_horizon",
exitDate: null,
exitBound: null,
});
expect(beyond).toContain("for more than 1.5 years");
expect(beyond).toContain("the span your readings cover");
});
it("does not call a capped three-year horizon the span of the readings", () => {
for (const locale of ["en", "de"] as const) {
const capped = render(
{
...OK,
horizonYears: 3,
horizonCapped: true,
projection: "beyond_horizon",
exitDate: null,
exitBound: null,
},
locale,
);
expect(capped).toContain(
locale === "en" ? "more than 3 years" : "länger als 3 Jahre",
);
expect(capped).not.toContain("the span your readings cover");
expect(capped).not.toContain("so weit reichen deine Werte zurück");
expect(capped).toContain(
locale === "en"
? "as far ahead as the projection looks"
: "so weit schaut die Fortschreibung voraus",
);
}
});
});
+11 -3
View File
@@ -53,9 +53,17 @@ export function LabTrendCard({
date: fmt.dateShortSmartCalendar(trend.lastAt),
})
: trend.projection === "beyond_horizon"
? t("labs.trend.projection.beyond_horizon", {
years: fmt.number(trend.horizonYears, 1),
})
? t(
trend.horizonCapped
? "labs.trend.projection.beyond_horizon_capped"
: "labs.trend.projection.beyond_horizon",
{
years: fmt.number(
trend.horizonYears,
trend.horizonCapped ? 0 : 1,
),
},
)
: t(`labs.trend.projection.${trend.projection}`);
return (
+1
View File
@@ -126,6 +126,7 @@ export type BiomarkerTrendDto =
fittedNow: number;
asOf: string;
horizonYears: number;
horizonCapped: boolean;
projection:
| "exits"
| "beyond_horizon"
@@ -44,7 +44,19 @@ describe("<TrainingLoadCard>", () => {
it("says how many recent days were rated when some were not", () => {
const html = render({ ...OK, recentRatedDays: 25, recentDays: 42 });
expect(html).toContain('data-slot="training-load-coverage"');
expect(html).toContain("25 of the last 42 days have heart-rate data");
expect(html).toContain("25 of the last 42 days could be rated");
});
it("names the last rated day when it lies behind the series end", () => {
const series = [
{ day: "2026-10-19", ctl: 54, atl: 61, tsb: -7, rated: false },
{ day: "2026-10-20", ctl: 54, atl: 61, tsb: -7, rated: false },
];
const stale = render({ ...OK, asOf: "2026-09-02", series });
expect(stale).toContain('data-slot="training-load-as-of"');
expect(stale).toMatch(/As of 0?9\/0?2|As of 02\/09|As of Sep/);
const fresh = render({ ...OK, asOf: "2026-10-19", series });
expect(fresh).not.toContain('data-slot="training-load-as-of"');
});
it("signs a positive form and drops the warm-up note once covered", () => {
+18 -1
View File
@@ -6,11 +6,14 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { TrainingLoad } from "@/lib/insights/training-load";
import { shiftDateKey } from "@/lib/tz/format";
/**
* v1.44 — fitness, fatigue and form from the stored training impulse, the
* server's figures as given. Renders nothing until the model has 28 days and
* three training days to stand on.
* three training days to stand on. The series ends on the day the strain run
* scores; when the last rated day lies more than a day before that, the card
* names it, so figures held through weeks without data never pass as fresh.
*/
export function TrainingLoadCard({
trainingLoad,
@@ -21,6 +24,10 @@ export function TrainingLoadCard({
const fmt = useFormatters();
if (!trainingLoad || trainingLoad.status !== "ok") return null;
const seriesEnd = trainingLoad.series.at(-1)?.day;
const stale =
seriesEnd !== undefined && trainingLoad.asOf < shiftDateKey(seriesEnd, -1);
const stats = [
{ key: "fitness", value: trainingLoad.ctl },
{ key: "fatigue", value: trainingLoad.atl },
@@ -47,6 +54,16 @@ export function TrainingLoadCard({
))}
</dl>
<p className="text-sm">{t("insights.workouts.load.explainer")}</p>
{stale ? (
<p
className="text-muted-foreground text-xs"
data-slot="training-load-as-of"
>
{t("insights.workouts.load.asOf", {
date: fmt.dateShortSmartCalendar(trainingLoad.asOf),
})}
</p>
) : null}
{trainingLoad.recentRatedDays < trainingLoad.recentDays ? (
<p
className="text-muted-foreground text-xs"
@@ -28,9 +28,13 @@
* call or a variable derived from one.
* 3. Assert the result is a subset of the rotation registry.
*
* Known limit, stated so it is not mistaken for coverage: a payload assembled
* somewhere other than the call site (`data: updates` where `updates` is built
* field by field) resolves to nothing here. Those columns are covered by the
* A payload passed as an array the same file fills with `.push({…})`
* (`createMany({ data: toWrite })`, the shape a chunked writer takes) is
* followed into those pushed literals.
*
* Known limit, stated so it is not mistaken for coverage: any other payload
* assembled somewhere other than the call site (`data: updates` where
* `updates` is built field by field) resolves to nothing here. Those columns are covered by the
* name scan in the sibling guard; the two signals are deliberately
* independent, and a column has to escape BOTH to go unregistered. The scan
* asserts a non-zero, anchored match count, so a matcher that quietly stops
@@ -398,6 +402,19 @@ function scanCiphertextWrites(): Map<string, Set<string>> {
depth = 0,
): void => {
if (depth > 4) return;
// `data: rows` where the file fills `rows` with `rows.push({…})`: walk
// each pushed literal as part of the payload.
const ident = /^\s*(\w+)\s*$/.exec(value)?.[1];
if (ident) {
for (const push of src.matchAll(
new RegExp(`(?<![.\\w])${ident}\\s*\\.\\s*push\\s*\\(`, "g"),
)) {
const open = push.index! + push[0].length - 1;
const inner = src.slice(open + 1, balanced(src, open));
descend(model, inner, open + 1, depth + 1);
}
return;
}
for (const [obj, objOffset] of payloadObjects(value)) {
for (const [field, expr, exprOffset] of objectPairs(obj)) {
const at = base + objOffset + exprOffset;
@@ -495,6 +512,17 @@ describe("ciphertext columns derived from the code", () => {
}
});
it("follows a payload array filled by push into the write that sends it", () => {
// The lab CSV import builds its rows into an array and writes each chunk
// with one `createMany({ data: toWrite })`.
expect(
[...(written.get("LabResult.noteEncrypted") ?? [])].some((at) =>
at.startsWith("src/lib/import/labs-csv.ts:"),
),
"the chunked lab CSV write of LabResult.noteEncrypted was not seen",
).toBe(true);
});
it("registers every column the code writes ciphertext into", () => {
const registered = new Set(ENCRYPTED_COLUMNS.map(encryptedColumnKey));
const unregistered = [...written.keys()]
+11 -8
View File
@@ -92,19 +92,21 @@ export const DEFAULT_COUNT = 50;
export const MAX_COUNT = 200;
/**
* Ceiling on `_offset`. No record pages that far (the largest search, the
* Observation time series, is bounded by the readings in a date range), and
* an unbounded offset reached the database as a skip it cannot represent and
* answered 500.
* Ceiling on `_offset`: the largest integer a JavaScript number holds
* exactly. It is no paging limit (the Observation time series of a heart-rate
* history pages well past a hundred thousand rows, and every page's `next`
* link must keep working); it refuses only an offset that cannot be passed
* on faithfully. Above it `Math.floor` returns a float such as `1e20`, which
* the database cannot read as a `bigint` `OFFSET` and which answered 500.
*/
export const MAX_OFFSET = 100_000;
export const MAX_OFFSET = Number.MAX_SAFE_INTEGER;
/**
* Parsed + clamped paging parameters. `count` is bounded to `[1, MAX_COUNT]`;
* `offset` floors at 0. A non-numeric / negative input collapses to the
* default rather than erroring — FHIR search is forgiving on paging params.
* An offset above {@link MAX_OFFSET} is the one refusal: the caller answers
* it with a 400 `invalid` OperationOutcome.
* An offset above {@link MAX_OFFSET} (including `Infinity`) is the one
* refusal: the caller answers it with a 400 `invalid` OperationOutcome.
*/
export function parsePaging(
searchParams: URLSearchParams,
@@ -127,6 +129,7 @@ export function parsePaging(
* Build the absolute self link, and a next link when more rows remain. The
* URL is rebuilt from the request URL with the paging params normalised, so
* the echoed link always carries the clamped `_count` and concrete `_offset`.
* A next link never carries an offset {@link parsePaging} would refuse.
*/
function pagingLinks(
requestUrl: URL,
@@ -140,7 +143,7 @@ function pagingLinks(
const links: FhirBundleLink[] = [{ relation: "self", url: self.toString() }];
const nextOffset = offset + count;
if (nextOffset < total) {
if (nextOffset < total && nextOffset <= MAX_OFFSET) {
const next = new URL(requestUrl.toString());
next.searchParams.set("_count", String(count));
next.searchParams.set("_offset", String(nextOffset));
@@ -47,7 +47,11 @@ const DONE = {
failed: 0,
} as unknown as RecordImportResult;
beforeEach(() => auditLog.mockReset());
// A block body: a function returned from `beforeEach` runs as its teardown,
// and `mockReset()` returns the mock itself.
beforeEach(() => {
auditLog.mockReset();
});
describe("handleRecordImport audit", () => {
it("audits a finished import as complete", async () => {
@@ -88,6 +92,47 @@ describe("handleRecordImport audit", () => {
});
});
it("never lets a failing audit write replace the answer or the error", async () => {
auditLog.mockImplementation(async () => {
throw new Error("audit table unavailable");
});
const ok = await handleRecordImport(request(), {
kind: "labs",
module: "labs",
schema,
run: async ({ onWritten }) => {
onWritten(400);
return DONE;
},
});
expect(ok.status).toBe(200);
// A cut-off import still surfaces its own error.
await expect(
handleRecordImport(request(), {
kind: "labs",
module: "labs",
schema,
run: async ({ onWritten }) => {
onWritten(200);
throw new Error("client went away");
},
}),
).rejects.toThrow("client went away");
// And the size cap still answers 413.
const { CsvTooLargeError } = await import("@/lib/import/csv-stream");
const tooLarge = await handleRecordImport(request(), {
kind: "labs",
module: "labs",
schema,
run: async ({ onWritten }) => {
onWritten(200);
throw new CsvTooLargeError(16);
},
});
expect(tooLarge.status).toBe(413);
expect(auditLog).toHaveBeenCalledTimes(3);
});
it("writes no audit row for a dry run or an abort that wrote nothing", async () => {
await handleRecordImport(request("1"), {
kind: "labs",
+12
View File
@@ -378,6 +378,18 @@ export async function importLabsCsv(args: {
}
// 2. Duplicates against the record, one query for the chunk.
//
// The day is cut in the zone the file is read in. Only a reading with a
// wall-clock time depends on it: a date alone is stored at the noon-UTC
// anchor and a time with an offset is one instant, and both land on the
// same day in every zone. A wall-clock time read in another zone on a
// retry is a different instant, and near midnight a different day, so it
// can be written twice. Matching on the exact instant would not catch it
// (the instant moved with the zone), and widening the match by the zone
// difference would also swallow a genuine reading of the same value on
// the neighbouring day. The zone the earlier import read the file in is
// not stored, so the record cannot tell those two apart; the day in the
// current zone is the one rule that never drops a real reading.
const days = planned
.map((p) => labReadingDay(p.row.takenAt, timezone))
.sort();
+43 -39
View File
@@ -284,48 +284,52 @@ export async function importMedicationsCsv(args: {
const data = parsed.data;
// Field by field, with the create route's invariants for the shapes an
// import produces: a daily schedule (FREQ=DAILY, times dual-written), an
// opening course for a dated window, no schedule for as-needed.
const medication = await prisma.medication.create({
data: {
userId,
name: data.name,
dose: data.dose,
asNeeded: data.asNeeded ?? false,
notificationsEnabled: data.notificationsEnabled ?? false,
...(course
? {
startsOn: course.startsOn,
endsOn: course.endsOn,
courses: {
create: {
userId,
startsOn: course.startsOn,
endsOn: course.endsOn,
// opening course for a dated window, no schedule for as-needed. The
// category is written in the same transaction: a crash between the two
// left a medication without one, and the retry skips it as a duplicate.
await prisma.$transaction(async (tx) => {
const medication = await tx.medication.create({
data: {
userId,
name: data.name,
dose: data.dose,
asNeeded: data.asNeeded ?? false,
notificationsEnabled: data.notificationsEnabled ?? false,
...(course
? {
startsOn: course.startsOn,
endsOn: course.endsOn,
courses: {
create: {
userId,
startsOn: course.startsOn,
endsOn: course.endsOn,
},
},
},
}
: {}),
schedules: {
create: (data.schedules ?? []).map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: null,
dose: null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: [],
intervalWeeks: 1,
}),
timesOfDay:
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart],
rrule: "FREQ=DAILY",
})),
}
: {}),
schedules: {
create: (data.schedules ?? []).map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: null,
dose: null,
daysOfWeek: serializeScheduleRecurrence({
daysOfWeek: [],
intervalWeeks: 1,
}),
timesOfDay:
s.timesOfDay && s.timesOfDay.length > 0
? s.timesOfDay
: [s.windowStart],
rrule: "FREQ=DAILY",
})),
},
},
},
select: { id: true },
select: { id: true },
});
await setMedicationCategory(medication.id, undefined, tx);
});
await setMedicationCategory(medication.id, undefined);
written += 1;
args.onWritten?.(1);
}
+23 -16
View File
@@ -126,26 +126,33 @@ export async function handleRecordImport<
} finally {
// The audit row is written whatever happened: an import cut off part way
// (the client went away, a proxy timed out, the cap was hit after some
// chunks) has already written rows, and the trail says how many.
// chunks) has already written rows, and the trail says how many. A
// failing audit write is noted on the wide event and never replaces the
// import's own answer or error: a throw from `finally` would discard
// both, and the rows are written either way.
if (
!query.dryRun &&
((result !== null && result.rows > 0) || written > 0)
) {
await auditLog(action, {
userId: user.id,
ipAddress: getClientIp(request),
details:
result !== null
? {
rows: result.rows,
inserted: result.inserted,
duplicates: result.duplicates,
failed: result.failed,
partial: false,
written,
}
: { partial: true, written },
});
try {
await auditLog(action, {
userId: user.id,
ipAddress: getClientIp(request),
details:
result !== null
? {
rows: result.rows,
inserted: result.inserted,
duplicates: result.duplicates,
failed: result.failed,
partial: false,
written,
}
: { partial: true, written },
});
} catch {
annotate({ meta: { import_audit_failed: true } });
}
}
}
if (result.rows === 0) {
@@ -68,17 +68,81 @@ describe("computeTrainingLoad", () => {
expect(load.warmingUp).toBe(true);
});
it("describes the last stored day, so a run not yet made never zeroes it", () => {
it("holds through days not yet scored and names the last rated day", () => {
// Rows through day 39; asked for day 41 (the nightly run for 40 and 41
// has not happened). The figures are those of day 39.
// has not happened, and nothing else is known about them). The figures
// are those of day 39 and the series still runs to day 41.
const rows = Array.from({ length: 40 }, (_, i) => ({
day: day(i),
dayTrimp: 60,
}));
const late = computeTrainingLoad(rows, day(41));
const exact = computeTrainingLoad(rows, day(39));
expect(late).toEqual(exact);
expect(late).toMatchObject({ status: "ok", asOf: day(39) });
if (late.status !== "ok" || exact.status !== "ok") {
throw new Error("expected ok");
}
expect(late.asOf).toBe(day(39));
expect(late.ctl).toBe(exact.ctl);
expect(late.atl).toBe(exact.atl);
expect(late.tsb).toBe(exact.tsb);
expect(late.series.at(-1)).toMatchObject({ day: day(41), rated: false });
expect(late.recentRatedDays).toBe(40);
expect(late.recentDays).toBe(42);
});
it("reads a day with activity data and no workout as rest", () => {
// Training days 0..2, stored rest days 3..9, then no rows at all from
// day 10 on: the strain run writes none on a day without active energy.
// Days 10..59 carry steps or heart rate, except a workout on day 20 that
// could not be scored and an empty day 30.
const rows = [
{ day: day(0), dayTrimp: 84 },
{ day: day(1), dayTrimp: 42 },
{ day: day(2), dayTrimp: 42 },
];
for (let i = 3; i < 10; i += 1) rows.push({ day: day(i), dayTrimp: 0 });
const dataDays = new Set<string>();
for (let i = 10; i < 60; i += 1) if (i !== 30) dataDays.add(day(i));
const load = computeTrainingLoad(rows, day(59), {
dataDays,
workoutDays: new Set([day(20)]),
});
if (load.status !== "ok") throw new Error("expected ok");
let ctl = 0;
let atl = 0;
for (let i = 0; i < 60; i += 1) {
if (i === 20 || i === 30) continue; // held
const l = i === 0 ? 84 : i < 3 ? 42 : 0;
ctl += (l - ctl) / 42;
atl += (l - atl) / 7;
}
expect(load.asOf).toBe(day(59));
expect(load.ctl).toBe(Math.round(ctl * 10) / 10);
expect(load.atl).toBe(Math.round(atl * 10) / 10);
expect(load.series.find((p) => p.day === day(15))?.rated).toBe(true);
expect(load.series.find((p) => p.day === day(20))?.rated).toBe(false);
expect(load.series.find((p) => p.day === day(30))?.rated).toBe(false);
expect(load.recentRatedDays).toBe(40);
expect(load.recentDays).toBe(42);
// Without the activity data the same rows would hold from day 10 on.
const blind = computeTrainingLoad(rows, day(59));
if (blind.status !== "ok") throw new Error("expected ok");
expect(blind.asOf).toBe(day(9));
expect(load.ctl).toBeLessThan(blind.ctl);
});
it("runs the series to the end and counts a trailing gap as unrated", () => {
const rows = Array.from({ length: 40 }, (_, i) => ({
day: day(i),
dayTrimp: 60,
}));
const load = computeTrainingLoad(rows, day(119));
if (load.status !== "ok") throw new Error("expected ok");
expect(load.asOf).toBe(day(39));
expect(load.series.at(-1)?.day).toBe(day(119));
expect(load.recentRatedDays).toBe(0);
expect(load.recentDays).toBe(42);
expect(load.daysCovered).toBe(120);
});
it("answers insufficient without impulse or with too little history", () => {
@@ -113,21 +177,42 @@ describe("computeTrainingLoad", () => {
});
describe("readTrainingLoad", () => {
it("describes the day the strain run scored", async () => {
it("describes the day the strain run scored and reads activity days as rest", async () => {
const { vi } = await import("vitest");
vi.resetModules();
const findMany = vi.fn(async () => []);
const now = new Date("2026-10-21T01:30:00.000Z");
const { scoreDayKey } = await import("@/lib/insights/score-row");
const until = scoreDayKey(now);
const first = shiftDateKey(until, -59);
const cache = Array.from({ length: 10 }, (_, i) => ({
day: shiftDateKey(first, i),
dayTrimp: i < 3 ? 80 : 0,
}));
const strainFindMany = vi.fn(async () => cache);
// Steps on every day after the stored rows; one workout on the last day.
const rollupFindMany = vi.fn(async () =>
Array.from({ length: 50 }, (_, i) => ({
bucketStart: new Date(`${shiftDateKey(first, 10 + i)}T00:00:00.000Z`),
})),
);
const queryRaw = vi.fn(async () => [{ day: until }]);
vi.doMock("@/lib/db", () => ({
prisma: { strainTrimpCache: { findMany } },
prisma: {
strainTrimpCache: { findMany: strainFindMany },
measurementRollup: { findMany: rollupFindMany },
$queryRaw: queryRaw,
},
}));
const { readTrainingLoad } = await import("../training-load");
const { scoreDayKey } = await import("@/lib/insights/score-row");
const now = new Date("2026-10-21T01:30:00.000Z");
await readTrainingLoad("u1", now);
const args = (findMany.mock.calls[0] as unknown[])[0] as {
const load = await readTrainingLoad("u1", now);
const args = (strainFindMany.mock.calls[0] as unknown[])[0] as {
where: { day: { lte: string } };
};
expect(args.where.day.lte).toBe(scoreDayKey(now));
expect(args.where.day.lte).toBe(until);
if (load.status !== "ok") throw new Error("expected ok");
expect(load.series.at(-1)).toMatchObject({ day: until, rated: false });
expect(load.asOf).toBe(shiftDateKey(until, -1));
expect(load.recentRatedDays).toBe(41);
vi.doUnmock("@/lib/db");
});
});
+36 -5
View File
@@ -176,6 +176,30 @@ export function detectLevelShift(
};
}
/**
* Every validated level shift in a series, by binary segmentation: find one
* with {@link detectLevelShift}, then look again on each side of it with the
* same bar, floors and drift rejection. `breakIndex` is into `series`.
* Callers that want one dated shift per metric use `detectLevelShift`; this
* is for a reader that lists several (the MCP changepoint tool).
*/
export function detectLevelShifts(
series: readonly number[],
opts: DetectLevelShiftOptions = {},
): LevelShift[] {
const out: LevelShift[] = [];
const walk = (lo: number, hi: number) => {
const shift = detectLevelShift(series.slice(lo, hi), opts);
if (!shift) return;
const split = lo + shift.breakIndex + 1;
out.push({ ...shift, breakIndex: split - 1 });
walk(lo, split);
walk(split, hi);
};
walk(0, series.length);
return out;
}
/**
* True when a straight line through the whole series fits at least as well as
* the two-level step (same parameter count, so the residual sums compare
@@ -263,6 +287,17 @@ export const CHANGEPOINT_FLOORS: Partial<
VO2_MAX: { minDelta: 1.5, spreadFloor: 0.5 },
};
/** The {@link CHANGEPOINT_FLOORS} of a metric as detector options. */
export function levelShiftFloors(
metric: MeasurementType,
): Pick<DetectLevelShiftOptions, "minAbsDelta" | "spreadFloor"> {
const floors = CHANGEPOINT_FLOORS[metric];
return {
minAbsDelta: floors?.minDelta,
spreadFloor: floors?.spreadFloor,
};
}
/** Window the reader looks back over (days). */
export const CHANGEPOINT_WINDOW_DAYS = 90;
/** At most this many changepoints in one snapshot (strongest first). */
@@ -329,13 +364,9 @@ async function scanChangepoints(
coverage,
);
if (points.length === 0) continue;
const floors = CHANGEPOINT_FLOORS[metric];
const shift = detectLevelShift(
points.map((p) => p.mean),
{
minAbsDelta: floors?.minDelta,
spreadFloor: floors?.spreadFloor,
},
levelShiftFloors(metric),
);
if (!shift) continue;
const breakDate =
+125 -32
View File
@@ -4,14 +4,23 @@
*
* The impulse is the Banister TRIMP the nightly strain run already writes to
* `strain_trimp_cache`: one row per scored UTC day, 0 on a scored day without
* training. A day WITHOUT a row was never scored (no heart-rate data reached
* the server, or the run has not happened yet), so it is not rated rather
* than read as rest: the averages hold through it instead of decaying toward
* zero, and the figures report how many recent days were rated. The figures
* describe the last stored day, so a nightly run that has not happened yet
* never turns yesterday into a rest day. Over it run the two exponentially weighted averages
* of the fitness-fatigue model (Banister, Calvert, Savage & Bach 1975; in the
* time-constant form popularised as chronic and acute training load):
* training. The strain run writes no row on a day with neither a scorable
* workout nor active energy, so a missing row alone does not tell rest from
* absence. A day without a row is therefore classified from the underlying
* data:
*
* - a workout on the day: training that could not be scored, not rated;
* - steps, heart rate or active energy on the day: the person wore or
* carried a device and did not train, rest (impulse 0);
* - none of these: nothing reached the server, not rated.
*
* An unrated day holds the averages instead of pulling them toward zero.
* The series runs to the day the strain run scores (yesterday in UTC), so a
* stretch without data after the last rated day shows as unrated days and
* in `recentRatedDays`; `asOf` names the last rated day. Over the impulse run
* the two exponentially weighted averages of the fitness-fatigue model
* (Banister, Calvert, Savage & Bach 1975; in the time-constant form
* popularised as chronic and acute training load):
*
* fitness CTL_d = CTL_{d−1} + (L_d − CTL_{d−1}) / 42
* fatigue ATL_d = ATL_{d−1} + (L_d − ATL_{d−1}) / 7
@@ -21,8 +30,9 @@
* flagged `warmingUp` until {@link CTL_DAYS} days are covered. The values are
* in TRIMP units: comparable with the person's own past, not with anyone
* else's and not with another app's training-stress score. Descriptive only:
* no performance prediction and no advice. Pure apart from the one read.
* no performance prediction and no advice. Pure apart from the reads.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { shiftDateKey } from "@/lib/tz/format";
@@ -42,14 +52,17 @@ export interface TrainingLoadPoint {
ctl: number;
atl: number;
tsb: number;
/** False for a day without a stored impulse: the averages held through it. */
/**
* False for a day with neither a stored impulse nor any activity data, or
* with a workout that could not be scored: the averages held through it.
*/
rated: boolean;
}
export type TrainingLoad =
| {
status: "ok";
/** The last scored day the figures describe (UTC day key). */
/** The last rated day the figures describe (UTC day key). */
asOf: string;
ctl: number;
atl: number;
@@ -57,7 +70,11 @@ export type TrainingLoad =
tsb: number;
daysCovered: number;
trainingDays: number;
/** Days of the trailing {@link CTL_DAYS} (or fewer, when covered less) with a stored impulse. */
/**
* Rated days (a stored impulse, or activity data without a workout) of
* the trailing {@link CTL_DAYS} days up to yesterday, or fewer when
* covered less.
*/
recentRatedDays: number;
/** The trailing window `recentRatedDays` counts over. */
recentDays: number;
@@ -74,13 +91,46 @@ export type TrainingLoad =
const round1 = (x: number) => Math.round(x * 10) / 10;
/**
* Run the model from the first stored day through the last stored day on or
* before `until`. A day without a row is not rated: the averages hold through
* it. Rows after `until` are ignored.
* What the read knows about the days without a stored impulse: the UTC days
* with any activity data (steps, heart rate, active energy) and the UTC days
* with a workout.
*/
export interface TrainingLoadActivity {
dataDays: ReadonlySet<string>;
workoutDays: ReadonlySet<string>;
}
const NO_ACTIVITY: TrainingLoadActivity = {
dataDays: new Set(),
workoutDays: new Set(),
};
/**
* The impulse of one day, or `undefined` when the day is not rated: the
* stored impulse when there is one; otherwise rest (0) on a day with
* activity data and no workout, and not rated on a day with a workout that
* was never scored or with no data at all.
*/
function dayLoad(
day: string,
byDay: ReadonlyMap<string, number>,
activity: TrainingLoadActivity,
): number | undefined {
const stored = byDay.get(day);
if (stored !== undefined) return stored;
if (activity.workoutDays.has(day)) return undefined;
return activity.dataDays.has(day) ? 0 : undefined;
}
/**
* Run the model from the first stored day through `until`. A day is rated
* as {@link dayLoad} says; an unrated day holds the averages. Rows after
* `until` are ignored.
*/
export function computeTrainingLoad(
rows: ReadonlyArray<{ day: string; dayTrimp: number }>,
until: string,
activity: TrainingLoadActivity = NO_ACTIVITY,
): TrainingLoad {
const byDay = new Map<string, number>();
for (const row of rows) {
@@ -98,17 +148,22 @@ export function computeTrainingLoad(
};
}
const asOf = days[days.length - 1];
const series: TrainingLoadPoint[] = [];
let ctl = 0;
let atl = 0;
let daysCovered = 0;
for (let day = days[0]; day <= asOf; day = shiftDateKey(day, 1)) {
let asOf = days[0];
let asOfCtl = 0;
let asOfAtl = 0;
for (let day = days[0]; day <= until; day = shiftDateKey(day, 1)) {
const tsb = ctl - atl;
const load = byDay.get(day);
const load = dayLoad(day, byDay, activity);
if (load !== undefined) {
ctl += (load - ctl) / CTL_DAYS;
atl += (load - atl) / ATL_DAYS;
asOf = day;
asOfCtl = ctl;
asOfAtl = atl;
}
daysCovered += 1;
series.push({ day, ctl, atl, tsb, rated: load !== undefined });
@@ -131,9 +186,9 @@ export function computeTrainingLoad(
return {
status: "ok",
asOf,
ctl: round1(ctl),
atl: round1(atl),
tsb: round1(ctl - atl),
ctl: round1(asOfCtl),
atl: round1(asOfAtl),
tsb: round1(asOfCtl - asOfAtl),
daysCovered,
trainingDays,
recentRatedDays,
@@ -149,9 +204,17 @@ export function computeTrainingLoad(
};
}
/** The measurement types whose presence on a day says a device was worn or carried. */
export const TRAINING_LOAD_PRESENCE_TYPES: MeasurementType[] = [
"ACTIVITY_STEPS",
"PULSE",
"ACTIVE_ENERGY_BURNED",
];
/**
* Read the stored impulse and run the model up to the last stored day, at
* most the day the strain run scores (yesterday in UTC).
* Read the stored impulse, the days with activity data (from the daily
* rollups) and the days with a workout, and run the model up to the day the
* strain run scores (yesterday in UTC).
*/
export async function readTrainingLoad(
userId: string,
@@ -162,14 +225,44 @@ export async function readTrainingLoad(
// graph, and this read stays off it.
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day
const until = new Date(now.getTime() - 86_400_000).toISOString().slice(0, 10);
const rows = await prisma.strainTrimpCache.findMany({
where: {
userId,
day: { gte: shiftDateKey(until, -TRAINING_LOAD_READ_DAYS), lte: until },
},
orderBy: { day: "asc" },
take: TRAINING_LOAD_READ_DAYS + 1,
select: { day: true, dayTrimp: true },
const from = shiftDateKey(until, -TRAINING_LOAD_READ_DAYS);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day
const fromInstant = new Date(`${from}T00:00:00.000Z`);
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: the strain cache is keyed on the UTC score day
const endInstant = new Date(`${shiftDateKey(until, 1)}T00:00:00.000Z`);
const [rows, presence, workoutDays] = await Promise.all([
prisma.strainTrimpCache.findMany({
where: { userId, day: { gte: from, lte: until } },
orderBy: { day: "asc" },
take: TRAINING_LOAD_READ_DAYS + 1,
select: { day: true, dayTrimp: true },
}),
// Daily rollups bucket on the UTC day, the same day the strain cache
// keys on.
prisma.measurementRollup.findMany({
where: {
userId,
granularity: "DAY",
type: { in: TRAINING_LOAD_PRESENCE_TYPES },
bucketStart: { gte: fromInstant, lt: endInstant },
count: { gt: 0 },
},
distinct: ["bucketStart"],
select: { bucketStart: true },
}),
// One row per UTC day with a workout; `started_at` is stored as UTC.
prisma.$queryRaw<Array<{ day: string }>>`
SELECT DISTINCT to_char("started_at", 'YYYY-MM-DD') AS day
FROM "workouts"
WHERE "user_id" = ${userId}
AND "started_at" >= ${fromInstant} AND "started_at" < ${endInstant}
`,
]);
return computeTrainingLoad(rows, until, {
dataDays: new Set(
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: a DAY rollup bucket starts at UTC midnight
presence.map((r) => r.bucketStart.toISOString().slice(0, 10)),
),
workoutDays: new Set(workoutDays.map((r) => r.day)),
});
return computeTrainingLoad(rows, until);
}
+14 -1
View File
@@ -139,7 +139,20 @@ describe("computeLabTrend", () => {
]);
expect(
computeLabTrend(short, { low: 70, high: 125 }, atYears(1)),
).toMatchObject({ projection: "beyond_horizon", horizonYears: 1 });
).toMatchObject({
projection: "beyond_horizon",
horizonYears: 1,
horizonCapped: false,
});
// Over four years of readings the horizon is the three-year cap.
const long = readings([
[0, 100],
[2, 101],
[4, 102],
]);
expect(
computeLabTrend(long, { low: 70, high: 200 }, atYears(4)),
).toMatchObject({ horizonYears: 3, horizonCapped: true });
// The same line with a top of 118 meets it 0.8 years out: projected.
expect(
computeLabTrend(short, { low: 70, high: 118 }, atYears(1)),
+9 -4
View File
@@ -83,6 +83,11 @@ export interface LabTrendOk {
* or the readings' span when shorter.
*/
horizonYears: number;
/**
* True when `horizonYears` is the three-year cap, false when the readings'
* shorter span set it.
*/
horizonCapped: boolean;
projection: LabTrendProjection;
/** Set only for `exits`: the day the line meets `exitBound`. */
exitDate: string | null;
@@ -162,10 +167,9 @@ export function computeLabTrend(
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,
);
const spanYears = (tLast - t0) / DAY_MS / YEAR_DAYS;
const horizonCapped = spanYears >= LAB_PROJECTION_HORIZON_YEARS;
const horizonYears = Math.min(LAB_PROJECTION_HORIZON_YEARS, spanYears);
let projection: LabTrendProjection;
let exitDate: string | null = null;
@@ -207,6 +211,7 @@ export function computeLabTrend(
fittedNow: round(fittedNow, 4),
asOf: dayOf(tAsOf),
horizonYears: round(horizonYears, 2),
horizonCapped,
projection,
exitDate,
exitBound,
+52
View File
@@ -654,6 +654,58 @@ describe("detect_changepoints", () => {
expect(res.bucketsAnalysed).toBe(12);
});
it("applies the metric floors and rejects a drift, as the insights scan does", async () => {
const days = (n: number) =>
Array.from(
{ length: n },
(_, i) => `2026-03-${String(i + 1).padStart(2, "0")}`,
);
// A clean 0.6 kg step: many deviations of its own noise, but below the
// 1 kg a weight change has to reach.
vi.mocked(readBestGranularityRollups).mockResolvedValueOnce({
granularity: "DAY",
rows: days(12).map((d, i) =>
row(d, (i < 6 ? 80 : 80.6) + (i % 2) * 0.05),
),
});
const small = await detectChangepoints(USER, { metric: "weight" });
expect(small.present).toBe(false);
expect(small.reason).toBe("no_changepoint");
// A steady climb of 1 kg a day: two halves with different means, but a
// straight line explains it, so it is a drift and not a dated shift.
vi.mocked(readBestGranularityRollups).mockResolvedValueOnce({
granularity: "DAY",
rows: days(20).map((d, i) => row(d, 80 + i)),
});
const drift = await detectChangepoints(USER, { metric: "weight" });
expect(drift.present).toBe(false);
expect(drift.reason).toBe("no_changepoint");
});
it("lists more than one shift with the same bar on each side", async () => {
const values = [
...Array.from({ length: 10 }, (_, i) => 70 + (i % 2) * 0.2),
...Array.from({ length: 10 }, (_, i) => 90 + (i % 2) * 0.2),
...Array.from({ length: 10 }, (_, i) => 95 + (i % 2) * 0.2),
];
vi.mocked(readBestGranularityRollups).mockResolvedValueOnce({
granularity: "DAY",
rows: values.map((v, i) =>
row(new Date(Date.UTC(2026, 2, 1 + i)).toISOString(), v),
),
});
const res = await detectChangepoints(USER, { metric: "weight" });
expect(res.present).toBe(true);
expect(res.changepoints?.map((cp) => cp.at)).toEqual([
new Date(Date.UTC(2026, 2, 11)).toISOString(),
new Date(Date.UTC(2026, 2, 21)).toISOString(),
]);
expect(res.changepoints?.map((cp) => cp.direction)).toEqual([
"increase",
"increase",
]);
});
it("honest-null when too few buckets exist", async () => {
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "DAY",
+28 -65
View File
@@ -63,7 +63,11 @@ import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot";
import { scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
import { encodeOffsetCursor } from "@/lib/mcp/pagination";
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
import { standardDeviation } from "@/lib/stats/spread";
import {
detectLevelShifts,
levelShiftFloors,
type DetectLevelShiftOptions,
} from "@/lib/insights/derived/changepoint";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -1151,77 +1155,30 @@ export interface ChangepointsResult {
}
const MIN_SEGMENT = 5;
/** Mean shift must clear this many series standard deviations to fire. */
const SHIFT_SD_FLOOR = 1.5;
const MAX_CHANGEPOINTS = 5;
function mean(xs: number[]): number {
return xs.reduce((s, x) => s + x, 0) / xs.length;
}
/** The CUSUM threshold estimates the series' noise, so it is the sample SD. */
function stdDev(xs: number[]): number {
return standardDeviation(xs, "sample") ?? 0;
}
/**
* Minimal CUSUM binary-segmentation changepoint detector over a 1-D series.
*
* For a segment, the cumulative sum of mean-centred deviations peaks at the most
* likely split. The split fires ONLY when the resulting mean shift clears a
* conservative effect-size floor (≥ SHIFT_SD_FLOOR · series SD) and both halves
* are at least MIN_SEGMENT long — a deliberately high bar so noise does not
* register (honest-null over false positives). Accepted splits recurse on each
* half; the result is capped to the strongest MAX_CHANGEPOINTS shifts by the
* caller.
*
* Indices are into the original series. Pure / dependency-free.
* The bar the tool's segmentation runs at: the insights level-shift detector
* with the metric's floors, sized for buckets rather than days. A bucket may
* be a week or a month, so a segment of five buckets is enough to persist.
*/
function cusumSegment(
values: number[],
lo: number,
hi: number,
out: Array<{ index: number; beforeMean: number; afterMean: number }>,
): void {
const n = hi - lo;
if (n < 2 * MIN_SEGMENT) return;
const segment = values.slice(lo, hi);
const segMean = mean(segment);
const sd = stdDev(segment);
if (sd <= 0) return;
// Cumulative sum of mean-centred deviations; track the extreme position.
let cusum = 0;
let maxAbs = 0;
let splitRel = -1;
for (let i = 0; i < n; i++) {
cusum += segment[i] - segMean;
if (Math.abs(cusum) > maxAbs) {
maxAbs = Math.abs(cusum);
// The new regime starts at the bucket AFTER the accumulation peak.
splitRel = i + 1;
}
}
if (splitRel < MIN_SEGMENT || n - splitRel < MIN_SEGMENT) return;
const before = segment.slice(0, splitRel);
const after = segment.slice(splitRel);
const beforeMean = mean(before);
const afterMean = mean(after);
if (Math.abs(afterMean - beforeMean) < SHIFT_SD_FLOOR * sd) return;
out.push({ index: lo + splitRel, beforeMean, afterMean });
cusumSegment(values, lo, lo + splitRel, out);
cusumSegment(values, lo + splitRel, hi, out);
function changepointOptions(type: MeasurementType): DetectLevelShiftOptions {
return {
minSeriesLength: 2 * MIN_SEGMENT,
minSegmentLength: MIN_SEGMENT,
minPersistDays: MIN_SEGMENT,
...levelShiftFloors(type),
};
}
/**
* Surface level shifts in a metric over the rollup tier. Reads the trailing
* window's rollup buckets (`readBestGranularityRollups` — DAY for ≤90 days,
* coarser for longer windows; the resolved granularity is reported) and runs
* the minimal CUSUM above over the bucket means. High firing bar — returns
* `{ present: false }` when too few buckets exist or no shift clears the floor.
* the insights level-shift detector over the bucket means by binary
* segmentation (`detectLevelShifts`): the same robust bar, per-metric floors
* and drift rejection as the health-status scan. Returns `{ present: false }`
* when too few buckets exist or no shift clears the bar.
* Re-uses the rollup tier's already-computed bucket means; adds no new
* persisted analytics.
*/
@@ -1291,9 +1248,15 @@ export async function detectChangepoints(
const rows = rangeRows;
const values = rows.map((r) => r.mean);
const found: Array<{ index: number; beforeMean: number; afterMean: number }> =
[];
cusumSegment(values, 0, values.length, found);
const found = detectLevelShifts(
values,
changepointOptions(metric.measurementType),
).map((shift) => ({
// The new regime starts at the bucket after the before segment.
index: shift.breakIndex + 1,
beforeMean: shift.beforeMean,
afterMean: shift.afterMean,
}));
if (found.length === 0) {
return {
+5
View File
@@ -103,6 +103,11 @@ const labTrend = z
.describe(
"How far ahead a bound may be met and still count as `exits`: three years, or the readings' span when shorter.",
),
horizonCapped: z
.boolean()
.describe(
"True when `horizonYears` is the three-year cap, false when the readings' shorter span set it.",
),
projection: z.enum([
"exits",
"beyond_horizon",
+6 -6
View File
@@ -380,7 +380,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.number()
.optional()
.describe(
"At most 100000; a larger offset is a 400 OperationOutcome.",
"Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.",
),
}),
},
@@ -410,7 +410,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.number()
.optional()
.describe(
"At most 100000; a larger offset is a 400 OperationOutcome.",
"Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.",
),
date: z.string().optional(),
code: z.string().optional(),
@@ -442,7 +442,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.number()
.optional()
.describe(
"At most 100000; a larger offset is a 400 OperationOutcome.",
"Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.",
),
}),
},
@@ -472,7 +472,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.number()
.optional()
.describe(
"At most 100000; a larger offset is a 400 OperationOutcome.",
"Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.",
),
}),
},
@@ -502,7 +502,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.number()
.optional()
.describe(
"At most 100000; a larger offset is a 400 OperationOutcome.",
"Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.",
),
}),
},
@@ -682,7 +682,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.number()
.optional()
.describe(
"At most 100000; a larger offset is a 400 OperationOutcome.",
"Any integer up to 9007199254740991; a larger offset is a 400 OperationOutcome.",
),
}),
},
+4 -4
View File
@@ -118,7 +118,7 @@ const trainingLoadPoint = z.object({
rated: z
.boolean()
.describe(
"False for a day without a stored impulse (never scored): the averages held through it rather than counting it as rest.",
"False for a day that could not be rated: no stored impulse and no activity data (steps, heart rate, active energy), or a workout that was never scored. The averages held through it rather than counting it as rest. A day with activity data and no workout is rest (impulse 0).",
),
});
@@ -129,7 +129,7 @@ const trainingLoad = z
asOf: z
.string()
.describe(
"The last UTC day with a stored impulse, at most yesterday: a nightly run that has not happened yet never reads as a rest day.",
"The last rated UTC day, at most yesterday; the figures are those of this day. The series runs on to yesterday, so a later stretch without data shows as unrated days.",
),
ctl: z.number().describe("Fitness: 42-day exponentially weighted TRIMP."),
atl: z.number().describe("Fatigue: 7-day exponentially weighted TRIMP."),
@@ -140,7 +140,7 @@ const trainingLoad = z
.number()
.int()
.describe(
"Days of the trailing `recentDays` (42, or fewer when covered less) with a stored impulse.",
"Rated days of the trailing `recentDays` (42 up to yesterday, or fewer when covered less): a stored impulse, or activity data without a workout.",
),
recentDays: z.number().int(),
warmingUp: z
@@ -162,7 +162,7 @@ const trainingLoad = z
.meta({
id: "WorkoutTrainingLoad",
description:
"v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a scored day without training is 0, a day without a stored impulse is not rated and the averages hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.",
"v1.44 — the fitness-fatigue model over the stored daily heart-rate training impulse (Banister TRIMP; a day with activity data but no training is 0, a day with no data or an unscored workout is not rated and the averages hold through it). Needs 28 days and three training days. TRIMP units: comparable with the person's own past only.",
});
const workoutListResponse = z
+63 -1
View File
@@ -8,7 +8,8 @@
* - re-uploading the same file writes nothing: every row is a duplicate;
* - a refused row is reported by line and does not stop the rest;
* - medications take a daily schedule, reminders off, as-needed without
* schedule, and a dated window opens a course.
* schedule, and a dated window opens a course;
* - a medication and its category land together or not at all.
*/
import { beforeAll, describe, expect, it, vi } from "vitest";
@@ -27,6 +28,20 @@ vi.mock("@/lib/db-compat", () => ({
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(() => null),
}));
const categoryFault = vi.hoisted(() => ({ on: false }));
vi.mock("@/lib/medication-category", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/medication-category")>();
return {
...actual,
setMedicationCategory: vi.fn(
async (...args: Parameters<typeof actual.setMedicationCategory>) => {
if (categoryFault.on) throw new Error("category write failed");
return actual.setMedicationCategory(...args);
},
),
};
});
const prisma = getPrismaClient();
const USER = "csv-import-user";
@@ -367,3 +382,50 @@ describe("medication CSV import", () => {
expect(again).toMatchObject({ inserted: 0, duplicates: 4 });
});
});
describe("medication CSV import, a crash between medication and category", () => {
const CSV = ["name,dose,times", "Atorvastatin,20 mg,20:00"].join("\n");
const query = medicationsCsvQuerySchema.parse({
name: "0",
dose: "1",
times: "2",
dryRun: "0",
});
it("leaves no medication without a category, so the retry writes it whole", async () => {
categoryFault.on = true;
await expect(
importMedicationsCsv({
userId: USER,
tz: TZ,
stream: textToStream(CSV),
maxBytes: 100_000,
query,
}),
).rejects.toThrow("category write failed");
categoryFault.on = false;
expect(
await prisma.medication.count({
where: { userId: USER, name: "Atorvastatin" },
}),
).toBe(0);
const retry = await importMedicationsCsv({
userId: USER,
tz: TZ,
stream: textToStream(CSV),
maxBytes: 100_000,
query,
});
expect(retry).toMatchObject({ inserted: 1, duplicates: 0 });
const med = await prisma.medication.findFirstOrThrow({
where: { userId: USER, name: "Atorvastatin" },
select: { id: true },
});
expect(
await prisma.medicationCategoryAssignment.count({
where: { medicationId: med.id },
}),
).toBe(1);
});
});