From d00e106ce1f2e1d16e8d2a04ea21b555cab1e811 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:46:39 +0200 Subject: [PATCH 01/29] fix(clinician): print every shared statistic with its unit The clinician view printed bare numbers for every measurement group and the per-context glucose rows in raw mg/dL, so a recipient read glucose without a unit and in the wrong one for an account on mmol/L. The PDF's measurement table had the same gap for the raw glucose series. Both artefacts now take the unit and the displayed number from one helper (doctor-report/stat-display.ts): SI for mass, length and temperature as before, hours for sleep, and the record owner's unit for glucose. Refs #1067 --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../__tests__/clinician-view.test.tsx | 43 ++++++++++ src/components/clinician/report-sections.tsx | 49 +++++++++--- .../__tests__/doctor-report-pdf-core.test.ts | 24 ++++++ src/lib/doctor-report-pdf-core.ts | 36 +++------ .../measurements-charts-section.ts | 10 +-- src/lib/doctor-report/stat-display.ts | 79 +++++++++++++++++++ 13 files changed, 207 insertions(+), 41 deletions(-) create mode 100644 src/lib/doctor-report/stat-display.ts diff --git a/messages/de.json b/messages/de.json index cecd50085..fc3b54979 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8167,6 +8167,7 @@ "medications": "Medikamente & Therapietreue", "bmi": "Body-Mass-Index (BMI)", "statSummary": "zuletzt {latest} (Ø {avg}, Bereich {min}–{max})", + "statSummaryWithUnit": "zuletzt {latest} {unit} (Ø {avg} {unit}, Bereich {min}–{max} {unit})", "adherence": "{rate} Therapietreue", "noAdherence": "—", "glucose": { diff --git a/messages/en.json b/messages/en.json index 322af6ad7..5fcf26297 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8167,6 +8167,7 @@ "medications": "Medications & adherence", "bmi": "Body mass index (BMI)", "statSummary": "latest {latest} (avg {avg}, range {min}–{max})", + "statSummaryWithUnit": "latest {latest} {unit} (avg {avg} {unit}, range {min}–{max} {unit})", "adherence": "{rate} adherence", "noAdherence": "—", "glucose": { diff --git a/messages/es.json b/messages/es.json index 06010c86e..4bdfb5534 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8167,6 +8167,7 @@ "medications": "Medicamentos y adherencia", "bmi": "Índice de masa corporal (IMC)", "statSummary": "último {latest} (prom. {avg}, rango {min}–{max})", + "statSummaryWithUnit": "último {latest} {unit} (prom. {avg} {unit}, rango {min}–{max} {unit})", "adherence": "{rate} de adherencia", "noAdherence": "—", "glucose": { diff --git a/messages/fr.json b/messages/fr.json index 5135074b2..f1c57d412 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8167,6 +8167,7 @@ "medications": "Médicaments et observance", "bmi": "Indice de masse corporelle (IMC)", "statSummary": "dernier {latest} (moy. {avg}, plage {min}–{max})", + "statSummaryWithUnit": "dernier {latest} {unit} (moy. {avg} {unit}, plage {min}–{max} {unit})", "adherence": "{rate} d'observance", "noAdherence": "—", "glucose": { diff --git a/messages/it.json b/messages/it.json index 7ffad325e..79b439e50 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8167,6 +8167,7 @@ "medications": "Farmaci e aderenza", "bmi": "Indice di massa corporea (IMC)", "statSummary": "ultimo {latest} (media {avg}, intervallo {min}–{max})", + "statSummaryWithUnit": "ultimo {latest} {unit} (media {avg} {unit}, intervallo {min}–{max} {unit})", "adherence": "{rate} di aderenza", "noAdherence": "—", "glucose": { diff --git a/messages/ko.json b/messages/ko.json index 28038e56f..26052e28f 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8167,6 +8167,7 @@ "medications": "복약 및 복약 순응도", "bmi": "체질량지수(BMI)", "statSummary": "최근 {latest} (평균 {avg}, 범위 {min}–{max})", + "statSummaryWithUnit": "최근 {latest} {unit} (평균 {avg} {unit}, 범위 {min}–{max} {unit})", "adherence": "복약 순응도 {rate}", "noAdherence": "—", "glucose": { diff --git a/messages/pl.json b/messages/pl.json index 295afc34f..396f758be 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8167,6 +8167,7 @@ "medications": "Leki i przestrzeganie zaleceń", "bmi": "Wskaźnik masy ciała (BMI)", "statSummary": "ostatnio {latest} (śr. {avg}, zakres {min}–{max})", + "statSummaryWithUnit": "ostatnio {latest} {unit} (śr. {avg} {unit}, zakres {min}–{max} {unit})", "adherence": "{rate} przestrzegania", "noAdherence": "—", "glucose": { diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index cb62d4f82..22fb9d4e0 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -225,6 +225,49 @@ describe("", () => { expect(html).not.toContain(">WEIGHT<"); }); + it("prints every measurement group with its unit", () => { + const html = render( + makeReport({ + stats: { + WEIGHT: { avg: 80, min: 78, max: 82, count: 12, latest: 79 }, + // Per-night asleep minutes; the report reads them in hours. + SLEEP_DURATION: { + avg: 450, + min: 420, + max: 480, + count: 7, + latest: 450, + }, + }, + }), + ); + expect(html).toContain("latest 79 kg (avg 80 kg, range 78–82 kg)"); + expect(html).toContain("latest 7.5 h (avg 7.5 h, range 7–8 h)"); + }); + + it("shows glucose in the record owner's unit, never raw mg/dL", () => { + const html = render( + makeReport({ + glucoseUnit: "mmol/L", + stats: { + BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 }, + }, + glucoseStats: { + FASTING: { avg: 99, min: 90, max: 108, count: 5, latest: 95 }, + }, + }), + ); + // 99 mg/dL = 5.5 mmol/L, 108 = 6.0, 90 = 5.0, 126 = 7.0, 95 = 5.3. + expect(html).toContain( + "latest 5.5 mmol/L (avg 6 mmol/L, range 5–7 mmol/L)", + ); + expect(html).toContain( + "latest 5.3 mmol/L (avg 5.5 mmol/L, range 5–6 mmol/L)", + ); + expect(html).not.toContain("mg/dL"); + expect(html).not.toContain("latest 99"); + }); + it("omits the wellness card when there are no scores", () => { const html = render(makeReport({ wellnessScores: [] })); expect(html).not.toContain("Wellness scores"); diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index bf41f8535..db44a8de2 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -15,6 +15,11 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { + reportStatUnit, + reportStatValue, +} from "@/lib/doctor-report/stat-display"; +import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"; import { isReportLeafId, @@ -212,6 +217,13 @@ export function MeasurementGroups({ if (groups.length === 0) return null; + // The same unit and the same number the PDF prints for the row: SI for + // mass, length and temperature, the owner's unit for glucose, hours for + // sleep. A clinician must never read a figure without knowing its unit. + const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null); + const show = (type: string, value: number) => + fmtNum(reportStatValue(type, value, glucoseUnit)); + return ( <> {groups.map((group) => ( @@ -224,11 +236,11 @@ export function MeasurementGroups({ ))} @@ -238,6 +250,20 @@ export function MeasurementGroups({ ); } +/** + * "latest 79 kg (avg 80 kg, range 78–82 kg)". A type with no unit (a score, a + * count) keeps the unit-less sentence rather than printing a dangling space. + */ +function statSummary( + t: Translate, + unit: string, + values: { latest: number; avg: number; min: number; max: number }, +): string { + return unit + ? t("clinicianView.statSummaryWithUnit", { ...values, unit }) + : t("clinicianView.statSummary", values); +} + /** * Per-context glucose stats. The heading used to read "Lab values", which was * the wrong name for it even before the record's actual lab results reached @@ -258,6 +284,9 @@ export function GlucoseSection({ const entries = Object.entries(report.glucoseStats).filter( ([, s]) => s.count > 0, ); + // Stored in mg/dL; shown in the record owner's unit, as the PDF shows it. + const glucoseUnit = resolveGlucoseUnit(report.glucoseUnit ?? null); + const show = (value: number) => fmtNum(convertGlucose(value, glucoseUnit)); return ( ))} diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index b525b4857..58f8688af 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -227,6 +227,30 @@ describe("renderDoctorReportPdfBytes", () => { expect(text).not.toContain("450 h"); }); + it("prints the raw glucose row in the owner's unit, like the glucose panel", async () => { + // The measurement table used to take BLOOD_GLUCOSE's canonical unit and + // number, so an mmol/L account read "99.0 mg/dL" two lines above its own + // context rows in mmol/L. + const data = makeData({ + glucoseUnit: "mmol/L", + stats: { + ...makeData().stats, + BLOOD_GLUCOSE: { avg: 108, min: 90, max: 126, count: 9, latest: 99 }, + }, + }); + const bytes = renderDoctorReportPdfBytes(data, { + timeFormat: "AUTO", + dateFormat: "AUTO", + t: getServerTranslator("en").t, + locale: "en", + now: FIXED_NOW, + }); + const text = await extractText(bytes); + expect(text).toContain("5.5 mmol/L"); + expect(text).toContain("6.0 mmol/L"); + expect(text).not.toContain("mg/dL"); + }); + it("omits the sleep vitals row when there are no sleep stats", async () => { const bytes = renderDoctorReportPdfBytes(makeData(), { timeFormat: "AUTO", diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index bdb689fea..3da0ac540 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -19,7 +19,11 @@ import { REPORT_GROUPS, } from "./report-selection/catalogue"; -import { getUnitForType } from "./validations/measurement"; +import { + DOCTOR_REPORT_TYPE_UNIT_KEYS, + reportStatUnit, +} from "./doctor-report/stat-display"; +import { resolveGlucoseUnit } from "./glucose"; import { makeFormatters, type DateFormatPreference, @@ -175,18 +179,9 @@ export interface DoctorReportRenderOptions { includeCharts?: boolean; } -export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { - WEIGHT: "kg", - BLOOD_PRESSURE_SYS: "mmHg", - BLOOD_PRESSURE_DIA: "mmHg", - PULSE: "bpm", - BODY_FAT: "%", - SLEEP_DURATION: "h", - ACTIVITY_STEPS: null, // translated unit - TOTAL_BODY_WATER: "kg", - BONE_MASS: "kg", - OXYGEN_SATURATION: "%", -}; +// The unit map moved beside the clinician view's reader of it; re-exported so +// the enum-coverage test keeps one import. +export { DOCTOR_REPORT_TYPE_UNIT_KEYS }; /** * The measurement table is driven by the selection, not by a whitelist. @@ -238,18 +233,9 @@ export function buildDoctorReportPdfDocument( const footerTz = userTz && isValidTimezone(userTz) ? userTz : DEFAULT_TIMEZONE; - const unitFor = (type: string): string => { - const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type]; - if (staticUnit === null && type === "ACTIVITY_STEPS") { - return t("doctorReport.unitSteps"); - } - if (staticUnit !== undefined && staticUnit !== null) return staticUnit; - // Every other type falls back to the canonical unit the measurement - // validation layer already records. A type with no recorded unit prints - // none — an absent unit is absent, not a guessed symbol. - const canonical = getUnitForType(type); - return canonical === "unknown" ? "" : canonical; - }; + const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null); + const unitFor = (type: string): string => + reportStatUnit(type, glucoseUnit, t); const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); patchPdfTextSanitiser(doc); diff --git a/src/lib/doctor-report-pdf/measurements-charts-section.ts b/src/lib/doctor-report-pdf/measurements-charts-section.ts index fef72fd68..32fdd4e1a 100644 --- a/src/lib/doctor-report-pdf/measurements-charts-section.ts +++ b/src/lib/doctor-report-pdf/measurements-charts-section.ts @@ -2,6 +2,7 @@ import type { jsPDF } from "jspdf"; import { GLUCOSE_CONTEXT_BUCKETS } from "@/lib/glucose"; import autoTable from "jspdf-autotable"; import { convertGlucose, resolveGlucoseUnit } from "../glucose"; +import { reportStatValue } from "../doctor-report/stat-display"; import type { MeasurementType } from "@/generated/prisma/client"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "../measurements/type-label-keys"; import { @@ -235,12 +236,9 @@ export function buildMeasurementsChartsSection( const s = data.stats[type]; if (!s) continue; const unit = unitFor(type); - // SLEEP_DURATION stats are per-night asleep totals in MINUTES (the data - // layer reconstructs them for exactly this row); the vitals unit is - // hours (`h`), so convert to keep value and unit consistent — the same - // hours value the FHIR sleep Observation emits. - const conv = - type === "SLEEP_DURATION" ? (v: number) => v / 60 : (v: number) => v; + // Sleep minutes print as hours and glucose in the owner's unit; the + // clinician view reads the same helper, so the two cannot drift. + const conv = (v: number) => reportStatValue(type, v, glucoseUnit); groupRows.push([ t(MEASUREMENT_TYPE_LABEL_KEYS[type as MeasurementType] ?? ""), `${num(conv(s.latest))} ${unit}`.trim(), diff --git a/src/lib/doctor-report/stat-display.ts b/src/lib/doctor-report/stat-display.ts new file mode 100644 index 000000000..a2ede71f9 --- /dev/null +++ b/src/lib/doctor-report/stat-display.ts @@ -0,0 +1,79 @@ +/** + * How a measurement statistic reads in the clinician-facing artefacts: the + * PDF report and the shared clinician view. + * + * Both describe the same record to the same reader, so they take the unit and + * the displayed number from here and nowhere else. Before this module the PDF + * carried its own unit map and the clinician view printed bare numbers, so a + * doctor opening a share link read "latest 5.8 (avg 6.1, range 4.9–7.4)" with + * no unit at all, and for an account on mmol/L the glucose rows of the PDF's + * measurement table still printed raw mg/dL beside a mmol/L glucose panel. + * + * The policy, which `display-transform.ts` states for the metric/imperial + * preference: a document handed to a clinician stays SI for mass, length and + * temperature regardless of the owner's imperial preference. Glucose is the + * exception, because mmol/L versus mg/dL is not an imperial question — it is + * the clinical convention of the country the record lives in — so glucose + * follows the owner's `glucoseUnit` here as it does everywhere a person reads + * it. + */ +import { convertGlucose, type GlucoseUnit } from "@/lib/glucose"; +import { getUnitForType } from "@/lib/validations/measurement"; + +/** + * Report-specific unit symbols, where the report's own reading differs from + * the validation layer's canonical unit. `null` marks a unit that has to be + * translated (steps). Everything absent falls back to `getUnitForType`. + */ +export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record = { + WEIGHT: "kg", + BLOOD_PRESSURE_SYS: "mmHg", + BLOOD_PRESSURE_DIA: "mmHg", + PULSE: "bpm", + BODY_FAT: "%", + SLEEP_DURATION: "h", + ACTIVITY_STEPS: null, // translated unit + TOTAL_BODY_WATER: "kg", + BONE_MASS: "kg", + OXYGEN_SATURATION: "%", +}; + +/** + * The unit symbol a report statistic of `type` is printed with. + * + * `translate` resolves the one translated unit (steps). A type with no + * recorded unit prints none — an absent unit is absent, not a guessed symbol. + */ +export function reportStatUnit( + type: string, + glucoseUnit: GlucoseUnit, + translate: (key: string) => string, +): string { + if (type === "BLOOD_GLUCOSE") return glucoseUnit; + const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type]; + if (staticUnit === null && type === "ACTIVITY_STEPS") { + return translate("doctorReport.unitSteps"); + } + if (staticUnit !== undefined && staticUnit !== null) return staticUnit; + const canonical = getUnitForType(type); + return canonical === "unknown" ? "" : canonical; +} + +/** + * The number a report statistic of `type` is printed as, in the unit + * {@link reportStatUnit} names. + * + * SLEEP_DURATION statistics are per-night asleep totals in minutes (the data + * layer reconstructs them for exactly this row) while the unit is hours, so + * they are divided — the same hours value the FHIR sleep Observation emits. + * Glucose is stored in mg/dL and converted to the owner's unit. + */ +export function reportStatValue( + type: string, + value: number, + glucoseUnit: GlucoseUnit, +): number { + if (type === "SLEEP_DURATION") return value / 60; + if (type === "BLOOD_GLUCOSE") return convertGlucose(value, glucoseUnit); + return value; +} From 7f2349e56f944174394213ae8d676cc3352ec68d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:47:18 +0200 Subject: [PATCH 02/29] fix(targets): fold the glucose year and the heart-rate month in SQL The targets page read a year of raw glucose readings to keep the newest one per meal context, and thirty days of raw heart rate to derive one resting figure per day. A sensor reporting every five minutes puts 105 000 glucose readings in that year and a watch recording once a minute puts 43 000 heart-rate readings in the month, and the page is read on every visit from the web and the app. Glucose now reads one row per context (DISTINCT ON) for the newest value and one row per context and local day for the thirty-day mean and the consistency strip; the strip takes day sums directly through rollupConsistencyFromDays. Heart rate reaches the resting-pulse card through readRestingPulseProxy, the SQL fold the insights page already uses, merged per day with the native resting rows exactly as before. The cards are unchanged: the integration test rebuilds them from the raw readings through the in-memory folds and compares. --- src/lib/analytics/resting-pulse.ts | 29 +- src/lib/targets/__tests__/builders.test.ts | 32 +- src/lib/targets/build-response.ts | 40 ++- src/lib/targets/consistency.ts | 62 ++-- src/lib/targets/glucose-builder.ts | 56 ++-- src/lib/targets/glucose-read.ts | 148 +++++++++ src/lib/targets/vitals-builder.ts | 23 +- tests/integration/dense-targets-reads.test.ts | 283 ++++++++++++++++++ 8 files changed, 600 insertions(+), 73 deletions(-) create mode 100644 src/lib/targets/glucose-read.ts create mode 100644 tests/integration/dense-targets-reads.test.ts diff --git a/src/lib/analytics/resting-pulse.ts b/src/lib/analytics/resting-pulse.ts index 8f8deaacc..ba26c914c 100644 --- a/src/lib/analytics/resting-pulse.ts +++ b/src/lib/analytics/resting-pulse.ts @@ -139,6 +139,26 @@ export function resolveRestingPulseSeries(input: { pulseSamples: ReadonlyArray; /** Day key for the proxy buckets: the user's own day. */ dayKeyOf: (d: Date) => string; +}): { series: PulseSample[]; which: "resting" | "proxy" | "none" } { + return mergeRestingWithProxy({ + restingSamples: input.restingSamples, + proxySamples: deriveRestingProxyFromPulse( + input.pulseSamples, + input.dayKeyOf, + ), + dayKeyOf: input.dayKeyOf, + }); +} + +/** + * {@link resolveRestingPulseSeries} for a caller that already holds the + * proxy, one point per day, as `readRestingPulseProxy` folds it in Postgres. + * The per-day merge and the label are the same. + */ +export function mergeRestingWithProxy(input: { + restingSamples: ReadonlyArray; + proxySamples: ReadonlyArray; + dayKeyOf: (d: Date) => string; }): { series: PulseSample[]; which: "resting" | "proxy" | "none" } { const dayKeyOf = input.dayKeyOf; const byTime = (a: PulseSample, b: PulseSample) => @@ -148,14 +168,13 @@ export function resolveRestingPulseSeries(input: { // Days already covered by a real resting row — the proxy must not // second-guess them. const restingDays = new Set(resting.map((s) => dayKeyOf(s.measuredAt))); - const proxy = deriveRestingProxyFromPulse( - input.pulseSamples, - dayKeyOf, - ).filter((p) => !restingDays.has(dayKeyOf(p.measuredAt))); + const proxy = input.proxySamples.filter( + (p) => !restingDays.has(dayKeyOf(p.measuredAt)), + ); if (resting.length === 0) { return proxy.length > 0 - ? { series: proxy, which: "proxy" } + ? { series: [...proxy], which: "proxy" } : { series: [], which: "none" }; } if (proxy.length === 0) return { series: resting, which: "resting" }; diff --git a/src/lib/targets/__tests__/builders.test.ts b/src/lib/targets/__tests__/builders.test.ts index 8362cda40..9a765e531 100644 --- a/src/lib/targets/__tests__/builders.test.ts +++ b/src/lib/targets/__tests__/builders.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { buildGlucoseTargets } from "../glucose-builder"; +import { foldGlucoseTargetRows } from "../glucose-read"; import { buildMedicationTarget } from "../medication-builder"; import { buildMoodTargets } from "../mood-builder"; import { buildSleepTarget } from "../sleep-builder"; @@ -8,11 +9,13 @@ import { buildVitalTargets } from "../vitals-builder"; const NOW = new Date("2026-07-21T12:00:00.000Z"); const TZ = "UTC"; +const RECENT = new Date(NOW.getTime() - 30 * 86_400_000); describe("target section builders", () => { it("keeps the vital target order and profile-gated cards on an empty profile", () => { const result = buildVitalTargets({ recentMeasurements: [], + restingPulseProxy: [], latestByType: {}, average30ByType: {}, heightCm: null, @@ -108,7 +111,7 @@ describe("target section builders", () => { }, ]; const targets = buildGlucoseTargets({ - rows, + summaries: foldGlucoseTargetRows(rows, RECENT, TZ), profile: { heightCm: 180, dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), @@ -140,10 +143,14 @@ describe("target section builders", () => { // metadata, so every row arrives with a null context. Iterating the four // named contexts alone returned no glucose target at all. const targets = buildGlucoseTargets({ - rows: [ - { value: 112, measuredAt: NOW, glucoseContext: null }, - { value: 104, measuredAt: NOW, glucoseContext: null }, - ], + summaries: foldGlucoseTargetRows( + [ + { value: 112, measuredAt: NOW, glucoseContext: null }, + { value: 104, measuredAt: NOW, glucoseContext: null }, + ], + RECENT, + TZ, + ), profile: { heightCm: 180, dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), @@ -173,11 +180,15 @@ describe("target section builders", () => { it("keeps the tagged breakdown and puts the untagged bucket last (#943)", () => { const targets = buildGlucoseTargets({ - rows: [ - { value: 130, measuredAt: NOW, glucoseContext: "RANDOM" as const }, - { value: 90, measuredAt: NOW, glucoseContext: "FASTING" as const }, - { value: 111, measuredAt: NOW, glucoseContext: null }, - ], + summaries: foldGlucoseTargetRows( + [ + { value: 130, measuredAt: NOW, glucoseContext: "RANDOM" as const }, + { value: 90, measuredAt: NOW, glucoseContext: "FASTING" as const }, + { value: 111, measuredAt: NOW, glucoseContext: null }, + ], + RECENT, + TZ, + ), profile: { heightCm: 180, dateOfBirth: new Date("1985-01-01T00:00:00.000Z"), @@ -235,6 +246,7 @@ describe("target section builders", () => { describe("buildVitalTargets — user weight target", () => { const base = { recentMeasurements: [], + restingPulseProxy: [], latestByType: {}, average30ByType: {}, heightCm: 178, diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 6addbc725..74903024b 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -26,7 +26,9 @@ import { ensureUserMoodRollupsFresh, readMoodDayRollups, } from "@/lib/rollups/mood-rollups"; +import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; import { buildGlucoseTargets } from "./glucose-builder"; +import { readGlucoseTargetSummaries } from "./glucose-read"; import { buildMedicationTarget } from "./medication-builder"; import { buildMoodTargets } from "./mood-builder"; import { buildSleepTarget } from "./sleep-builder"; @@ -75,6 +77,10 @@ export async function buildTargetsResponse(user: AuthedUser) { "BODY_FAT", "ACTIVITY_STEPS", ]; + // Heart rate is the one vital a device streams. Its thirty days reach the + // resting-pulse card as one proxy point per day, folded in SQL below; every + // other vital is written a few times a day and is read as rows. + const recentTypes = types.filter((type) => type !== "PULSE"); const thirtyDaysAgo = new Date(queryNow.getTime() - 30 * 24 * 60 * 60 * 1000); const oneYearAgo = new Date(queryNow.getTime() - 365 * 24 * 60 * 60 * 1000); const moodSince = new Date( @@ -95,7 +101,8 @@ export async function buildTargetsResponse(user: AuthedUser) { medicationBundle, moodRollups, latestMoodEntry, - glucoseRows, + glucoseSummaries, + restingPulseProxy, ] = await Promise.all([ limit(() => prisma.user.findUnique({ @@ -115,7 +122,7 @@ export async function buildTargetsResponse(user: AuthedUser) { prisma.measurement.findMany({ where: { userId, - type: { in: types }, + type: { in: recentTypes }, measuredAt: { gte: thirtyDaysAgo }, deletedAt: null, }, @@ -211,16 +218,22 @@ export async function buildTargetsResponse(user: AuthedUser) { select: { score: true, moodLoggedAt: true }, }), ), + // A sensor reporting every five minutes writes 105 000 glucose readings + // a year. The cards need the newest per meal context and the last thirty + // days per local day, so both fold in SQL. limit(() => - prisma.measurement.findMany({ - where: { - userId, - type: "BLOOD_GLUCOSE", - measuredAt: { gte: oneYearAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - select: { value: true, measuredAt: true, glucoseContext: true }, + readGlucoseTargetSummaries({ + userId, + since: oneYearAgo, + recentSince: thirtyDaysAgo, + timeZone: timezone, + }), + ), + limit(() => + readRestingPulseProxy({ + userId, + since: thirtyDaysAgo, + timeZone: timezone, }), ), ]); @@ -251,6 +264,8 @@ export async function buildTargetsResponse(user: AuthedUser) { for (const type of types) { latestByType[type] = latestEverByType.find((row) => row.type === type)?.value ?? null; + } + for (const type of recentTypes) { const recent = recentMeasurements.filter( (measurement) => measurement.type === type, ); @@ -266,6 +281,7 @@ export async function buildTargetsResponse(user: AuthedUser) { const vitalSection = buildVitalTargets({ recentMeasurements, + restingPulseProxy, latestByType, average30ByType, heightCm, @@ -314,7 +330,7 @@ export async function buildTargetsResponse(user: AuthedUser) { const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null); targets.push( ...buildGlucoseTargets({ - rows: glucoseRows, + summaries: glucoseSummaries, profile: { heightCm, dateOfBirth: dbUser?.dateOfBirth ?? null, diff --git a/src/lib/targets/consistency.ts b/src/lib/targets/consistency.ts index 66de155d2..a7c994d41 100644 --- a/src/lib/targets/consistency.ts +++ b/src/lib/targets/consistency.ts @@ -43,12 +43,13 @@ function last7DayKeys(now: Date, timezone: string): string[] { return keys; } -function computeStrip( +/** A day's readings folded to their sum and count, keyed by local day. */ +export type DaySums = ReadonlyMap; + +function sumByDay( events: ReadonlyArray<{ measuredAt: Date; value: number }>, - classify: (mean: number) => DayBand | null, timezone: string, - now: Date, -) { +): Map { const byDay = new Map(); for (const event of events) { const key = userDayKey(event.measuredAt, timezone); @@ -57,7 +58,15 @@ function computeStrip( bucket.count += 1; byDay.set(key, bucket); } + return byDay; +} +function computeStrip( + byDay: DaySums, + classify: (mean: number) => DayBand | null, + timezone: string, + now: Date, +) { const consistency7d: Array = []; let daysInRange7d = 0; let daysLogged7d = 0; @@ -77,13 +86,40 @@ function computeStrip( export function rollupConsistency({ events, + ...rest +}: RollupConsistencyInput): TargetConsistency { + return rollupConsistencyFromDays({ + ...rest, + byDay: sumByDay(events, rest.timezone), + readingCount: events.length, + }); +} + +interface RollupDaySumsInput extends ConsistencyClock { + /** Each local day's readings as sum and count. */ + byDay: DaySums; + /** Readings behind `byDay`, for the thin-data threshold. */ + readingCount: number; + classify: (mean: number) => DayBand | null; + totalReadingsThreshold?: number; +} + +/** + * The same consistency as {@link rollupConsistency}, from readings already + * folded per local day. Every figure here is a function of each day's mean + * and of the reading count, so a reader that folds in SQL hands over one row + * per day instead of every reading and gets the same answer. + */ +export function rollupConsistencyFromDays({ + byDay, + readingCount, classify, timezone, now, totalReadingsThreshold = 3, -}: RollupConsistencyInput): TargetConsistency { - if (events.length < totalReadingsThreshold) { - const partial = computeStrip(events, classify, timezone, now); +}: RollupDaySumsInput): TargetConsistency { + if (readingCount < totalReadingsThreshold) { + const partial = computeStrip(byDay, classify, timezone, now); return { ...EMPTY_CONSISTENCY, ...partial, @@ -91,15 +127,7 @@ export function rollupConsistency({ }; } - const byDay = new Map(); - for (const event of events) { - const key = userDayKey(event.measuredAt, timezone); - const bucket = byDay.get(key) ?? { sum: 0, count: 0 }; - bucket.sum += event.value; - bucket.count += 1; - byDay.set(key, bucket); - } - const strip = computeStrip(events, classify, timezone, now); + const strip = computeStrip(byDay, classify, timezone, now); let daysInRange30d = 0; let daysLogged30d = 0; @@ -136,7 +164,7 @@ export function rollupConsistency({ lastMetGoalAt, streakDays, insufficientData: - events.length < totalReadingsThreshold || strip.daysLogged7d < 1, + readingCount < totalReadingsThreshold || strip.daysLogged7d < 1, consistency7d: strip.consistency7d, }; } diff --git a/src/lib/targets/glucose-builder.ts b/src/lib/targets/glucose-builder.ts index cea599c92..7d3dad20f 100644 --- a/src/lib/targets/glucose-builder.ts +++ b/src/lib/targets/glucose-builder.ts @@ -4,8 +4,9 @@ import { type GlucoseContextBucket, } from "@/lib/glucose"; import { resolveGlucoseTarget } from "./glucose-targets"; -import { makeRangeClassifier, rollupConsistency } from "./consistency"; -import type { TargetGlucoseRow, TargetItem, TargetProfile } from "./types"; +import { makeRangeClassifier, rollupConsistencyFromDays } from "./consistency"; +import type { TargetGlucoseContextSummary } from "./glucose-read"; +import type { TargetItem, TargetProfile } from "./types"; const LABEL_BY_CONTEXT: Record = { FASTING: "targets.glucoseFasting", @@ -16,38 +17,53 @@ const LABEL_BY_CONTEXT: Record = { }; interface GlucoseTargetsInput { - rows: TargetGlucoseRow[]; + /** + * One summary per stored meal context: the newest reading of the year and + * the last thirty days per local day (`readGlucoseTargetSummaries`). + */ + summaries: TargetGlucoseContextSummary[]; profile: TargetProfile; timezone: string; now: Date; } export function buildGlucoseTargets({ - rows, + summaries, profile, timezone, now, }: GlucoseTargetsInput): TargetItem[] { const targets: TargetItem[] = []; const unit = resolveGlucoseUnit(profile.glucoseUnit); - const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // #943 — the untagged bucket rides the same loop. Iterating the four named // contexts alone left an account whose source writes no meal-time tag with // no glucose target at all, the same gap the dashboard tile had. - for (const [context, contextRows] of groupByGlucoseContext( - rows, - (row) => row.glucoseContext, + for (const [context, contextSummaries] of groupByGlucoseContext( + summaries, + (summary) => summary.glucoseContext, )) { - const latest = contextRows[0].value; - const recent = contextRows.filter((row) => row.measuredAt >= thirtyDaysAgo); + // A bucket is one stored context today; folding several keeps the newest + // reading and adds the days up, should two ever share a bucket. + const newest = contextSummaries.reduce((a, b) => + b.latestAt > a.latestAt ? b : a, + ); + const latest = newest.latest; + const recentByDay = new Map(); + let recentCount = 0; + let recentSum = 0; + for (const summary of contextSummaries) { + recentCount += summary.recentCount; + for (const [day, bucket] of summary.recentByDay) { + const current = recentByDay.get(day) ?? { sum: 0, count: 0 }; + current.sum += bucket.sum; + current.count += bucket.count; + recentByDay.set(day, current); + recentSum += bucket.sum; + } + } const average30 = - recent.length > 0 - ? Math.round( - (recent.reduce((sum, row) => sum + row.value, 0) / recent.length) * - 10, - ) / 10 - : null; + recentCount > 0 ? Math.round((recentSum / recentCount) * 10) / 10 : null; const resolved = resolveGlucoseTarget({ context, hasDiabetes: profile.hasDiabetes, @@ -97,11 +113,9 @@ export function buildGlucoseTargets({ : resolved.source === "ADA goal (diabetes)" ? "ADA goal (diabetes)" : "ADA 2024 / DDG", - ...rollupConsistency({ - events: recent.map((row) => ({ - measuredAt: row.measuredAt, - value: row.value, - })), + ...rollupConsistencyFromDays({ + byDay: recentByDay, + readingCount: recentCount, classify: makeRangeClassifier( range, effectiveRange diff --git a/src/lib/targets/glucose-read.ts b/src/lib/targets/glucose-read.ts new file mode 100644 index 000000000..0496f8cef --- /dev/null +++ b/src/lib/targets/glucose-read.ts @@ -0,0 +1,148 @@ +/** + * The glucose rows the targets page needs, folded in Postgres. + * + * The glucose targets used to read every glucose reading of the last year and + * keep three things from them per meal context: the newest value, and for the + * last thirty days each local day's mean and the number of readings. A sensor + * that reports every five minutes puts 105 000 readings in that year, and the + * targets page is read on every visit, on the web and in the app. So the year + * is reduced to one row per context (`DISTINCT ON`) and the thirty days to one + * row per context and local day, and only those rows cross the wire. + * + * Output matches {@link foldGlucoseTargetRows} over the raw readings: the same + * newest value per context (ties broken by id, newest first), and the same + * per-day sums and counts, with days cut in the zone the caller passes by the + * expression every other day fold uses. + */ +import { Prisma, type PrismaClient } from "@/generated/prisma/client"; +import type { GlucoseContext } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { isValidTimezone, userDayKey } from "@/lib/tz/format"; +import type { TargetGlucoseRow } from "./types"; + +/** One meal context's newest reading and its last thirty days, per day. */ +export interface TargetGlucoseContextSummary { + glucoseContext: GlucoseContext | null; + latest: number; + latestAt: Date; + /** Local day → the day's readings as sum and count, since `recentSince`. */ + recentByDay: Map; + /** Readings behind `recentByDay`. */ + recentCount: number; +} + +/** + * The in-memory fold over raw readings, newest first. Pure; the reference the + * SQL reader is tested against. + */ +export function foldGlucoseTargetRows( + rows: readonly TargetGlucoseRow[], + recentSince: Date, + timeZone: string, +): TargetGlucoseContextSummary[] { + const byContext = new Map(); + for (const row of rows) { + const key = row.glucoseContext ?? ""; + let summary = byContext.get(key); + if (!summary) { + summary = { + glucoseContext: row.glucoseContext, + latest: row.value, + latestAt: row.measuredAt, + recentByDay: new Map(), + recentCount: 0, + }; + byContext.set(key, summary); + } + if (row.measuredAt < recentSince) continue; + const day = userDayKey(row.measuredAt, timeZone); + const bucket = summary.recentByDay.get(day) ?? { sum: 0, count: 0 }; + bucket.sum += row.value; + bucket.count += 1; + summary.recentByDay.set(day, bucket); + summary.recentCount += 1; + } + return [...byContext.values()]; +} + +export async function readGlucoseTargetSummaries(opts: { + userId: string; + /** Inclusive lower bound for the newest reading per context. */ + since: Date; + /** Inclusive lower bound for the per-day fold. */ + recentSince: Date; + /** IANA zone the day boundary is cut in. */ + timeZone: string; + db?: Pick; +}): Promise { + const db = opts.db ?? prisma; + const timeZone = isValidTimezone(opts.timeZone) ? opts.timeZone : "UTC"; + const [latestRows, dayRows] = await Promise.all([ + db.$queryRaw< + Array<{ + glucose_context: GlucoseContext | null; + value: number; + measured_at: Date; + }> + >(Prisma.sql` + SELECT DISTINCT ON (m."glucose_context") + m."glucose_context" AS glucose_context, + m."value" AS value, + m."measured_at" AS measured_at + FROM measurements m + WHERE m."user_id" = ${opts.userId} + AND m."type" = 'BLOOD_GLUCOSE'::measurement_type + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${opts.since} + ORDER BY m."glucose_context", m."measured_at" DESC, m."id" DESC + `), + db.$queryRaw< + Array<{ + glucose_context: GlucoseContext | null; + day: string; + n: number; + sum: number; + }> + >(Prisma.sql` + WITH src AS ( + SELECT + m."glucose_context" AS glucose_context, + to_char((m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE ${timeZone}, 'YYYY-MM-DD') AS day, + m."value" AS value + FROM measurements m + WHERE m."user_id" = ${opts.userId} + AND m."type" = 'BLOOD_GLUCOSE'::measurement_type + AND m."deleted_at" IS NULL + AND m."measured_at" >= ${opts.recentSince} + ) + SELECT + glucose_context, + day, + COUNT(*)::int AS n, + SUM(value)::double precision AS sum + FROM src + GROUP BY glucose_context, day + `), + ]); + + const byContext = new Map(); + for (const row of latestRows) { + byContext.set(row.glucose_context ?? "", { + glucoseContext: row.glucose_context, + latest: Number(row.value), + latestAt: row.measured_at, + recentByDay: new Map(), + recentCount: 0, + }); + } + for (const row of dayRows) { + // Every recent reading is also inside the year, so its context has a + // newest row unless `recentSince` predates `since`, which no caller does. + const summary = byContext.get(row.glucose_context ?? ""); + if (!summary) continue; + const n = Number(row.n); + summary.recentByDay.set(row.day, { sum: Number(row.sum), count: n }); + summary.recentCount += n; + } + return [...byContext.values()]; +} diff --git a/src/lib/targets/vitals-builder.ts b/src/lib/targets/vitals-builder.ts index 05c6eccaf..69b8b589d 100644 --- a/src/lib/targets/vitals-builder.ts +++ b/src/lib/targets/vitals-builder.ts @@ -13,7 +13,10 @@ import { classifyPulseByTarget, getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; -import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; +import { + mergeRestingWithProxy, + type PulseSample, +} from "@/lib/analytics/resting-pulse"; import { getBodyFatTargetRange } from "@/lib/analytics/value-bands"; import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex"; import { userDayKey } from "@/lib/tz/resolver"; @@ -38,7 +41,15 @@ export interface BloodPressureTargetRange { } interface VitalTargetsInput { + /** The last thirty days of every vital type except `PULSE`. */ recentMeasurements: TargetMeasurement[]; + /** + * The resting-pulse proxy over the same thirty days, one point per local + * day, folded from raw `PULSE` in Postgres (`readRestingPulseProxy`). Raw + * heart rate is never read here: a watch that records it once a minute puts + * 43 000 readings in thirty days, and the card needs one figure per day. + */ + restingPulseProxy: PulseSample[]; latestByType: TargetValueByType; average30ByType: TargetValueByType; heightCm: number | null; @@ -140,6 +151,7 @@ function buildBpPairsByDay( export function buildVitalTargets({ recentMeasurements, + restingPulseProxy, latestByType, average30ByType, heightCm, @@ -273,19 +285,14 @@ export function buildVitalTargets({ } const pulseTarget = getPersonalizedPulseTarget(age, referenceSex); - const restingResolved = resolveRestingPulseSeries({ + const restingResolved = mergeRestingWithProxy({ restingSamples: recentMeasurements .filter((measurement) => measurement.type === "RESTING_HEART_RATE") .map((measurement) => ({ measuredAt: measurement.measuredAt, value: measurement.value, })), - pulseSamples: recentMeasurements - .filter((measurement) => measurement.type === "PULSE") - .map((measurement) => ({ - measuredAt: measurement.measuredAt, - value: measurement.value, - })), + proxySamples: restingPulseProxy, dayKeyOf: (date) => userDayKey(date, timezone), }); const restingEvents = restingResolved.series; diff --git a/tests/integration/dense-targets-reads.test.ts b/tests/integration/dense-targets-reads.test.ts new file mode 100644 index 000000000..e3a4a2aae --- /dev/null +++ b/tests/integration/dense-targets-reads.test.ts @@ -0,0 +1,283 @@ +/** + * The targets page on a dense account: a glucose sensor reporting every five + * minutes and a watch recording heart rate once a minute. + * + * `buildTargetsResponse` read a year of raw glucose readings to keep the + * newest one per meal context, and thirty days of raw heart rate to derive one + * resting figure per day. Both now fold in SQL. These cases pin that neither + * stream is read as rows any more, and that the cards come out exactly as the + * in-memory fold over the raw readings makes them, on a dense account and on + * a sparse one with readings next to the local midnight. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; +import { deriveRestingProxyFromPulse } from "@/lib/analytics/resting-pulse"; +import { buildTargetsResponse } from "@/lib/targets/build-response"; +import { buildGlucoseTargets } from "@/lib/targets/glucose-builder"; +import { + foldGlucoseTargetRows, + readGlucoseTargetSummaries, +} from "@/lib/targets/glucose-read"; +import { buildVitalTargets } from "@/lib/targets/vitals-builder"; +import { userDayKey } from "@/lib/tz/format"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const TZ = "America/New_York"; +const DENSE = "dense-targets-owner"; +const SPARSE = "sparse-targets-owner"; +const NOW = new Date(Math.floor(Date.now() / 60_000) * 60_000 - 60_000); +const DAY = 86_400_000; + +beforeAll(async () => { + await truncateAllTables(prisma); + for (const id of [DENSE, SPARSE]) { + await prisma.user.create({ + data: { + id, + username: id, + timezone: TZ, + dateOfBirth: new Date("1980-05-01T00:00:00Z"), + gender: "FEMALE", + heightCm: 170, + }, + }); + invalidateUserTimezone(id); + } + // 200 days of a sensor every five minutes, untagged, with every 37th + // reading tagged fasting so two contexts are present. + await prisma.$executeRawUnsafe( + `INSERT INTO measurements ( + id, user_id, type, value, unit, source, glucose_context, measured_at, + created_at, updated_at, sync_version) + SELECT 'dg' || lpad(g::text, 7, '0'), $1, 'BLOOD_GLUCOSE'::measurement_type, + 70 + (g % 110) + (g % 3) * 0.25, 'mg/dL', 'NIGHTSCOUT'::measurement_source, + CASE WHEN g % 37 = 0 THEN 'FASTING'::glucose_context ELSE NULL END, + ($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '5 minutes'), + now(), now(), 1 + FROM generate_series(0, 200 * 288 - 1) g`, + DENSE, + NOW.toISOString(), + ); + // Ten days of heart rate once a minute. + await prisma.$executeRawUnsafe( + `INSERT INTO measurements ( + id, user_id, type, value, unit, source, measured_at, + created_at, updated_at, sync_version) + SELECT 'dp' || lpad(g::text, 7, '0'), $1, 'PULSE'::measurement_type, + 55 + (g % 50), 'bpm', 'GOOGLE_HEALTH'::measurement_source, + ($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'), + now(), now(), 1 + FROM generate_series(0, 10 * 24 * 60 - 1) g`, + DENSE, + NOW.toISOString(), + ); + + // Sparse: fingerstick readings in three contexts, a few a day, some older + // than thirty days, some within minutes of the New York midnight; a cuff a + // few times a day; a resting heart rate on alternate days. + const contexts = ["FASTING", "POSTPRANDIAL", null] as const; + const sparse: Array<{ + type: "BLOOD_GLUCOSE" | "PULSE" | "RESTING_HEART_RATE"; + value: number; + at: Date; + ctx?: (typeof contexts)[number]; + }> = []; + for (let day = 0; day < 60; day++) { + for (let i = 0; i < 1 + (day % 3); i++) { + sparse.push({ + type: "BLOOD_GLUCOSE", + value: 85 + ((day * 13 + i * 29) % 90) + (i % 2 ? 0.5 : 0), + at: new Date(NOW.getTime() - day * DAY - (i * 311 + 7) * 60_000), + ctx: contexts[(day + i) % 3], + }); + } + if (day < 25) { + for (let i = 0; i < 2 + (day % 4); i++) { + sparse.push({ + type: "PULSE", + value: 54 + ((day * 7 + i * 11) % 40), + at: new Date(NOW.getTime() - day * DAY - (i * 173 + 3) * 60_000), + }); + } + } + if (day % 2 === 0 && day < 30) { + sparse.push({ + type: "RESTING_HEART_RATE", + value: 58 + (day % 6), + at: new Date(NOW.getTime() - day * DAY - 4 * 3_600_000), + }); + } + } + await prisma.measurement.createMany({ + data: sparse.map((r, i) => ({ + id: `st${String(i).padStart(5, "0")}`, + userId: SPARSE, + type: r.type, + value: r.value, + unit: r.type === "BLOOD_GLUCOSE" ? "mg/dL" : "bpm", + source: "MANUAL" as const, + glucoseContext: r.ctx ?? null, + measuredAt: r.at, + })), + }); + await prisma.$executeRawUnsafe(`ANALYZE measurements`); +}, 180_000); + +function readsDenseRows(args: unknown): boolean { + const type = (args as { where?: { type?: unknown } } | undefined)?.where + ?.type; + if (type === "BLOOD_GLUCOSE" || type === "PULSE") return true; + const list = (type as { in?: unknown } | undefined)?.in; + return ( + Array.isArray(list) && + (list.includes("PULSE") || list.includes("BLOOD_GLUCOSE")) + ); +} + +describe("targets on a dense account", () => { + it("never reads the glucose or heart-rate stream as rows", async () => { + const spy = vi.spyOn(prisma.measurement, "findMany"); + try { + const started = performance.now(); + const res = await buildTargetsResponse({ id: DENSE, timezone: TZ }); + const elapsed = performance.now() - started; + process.stderr.write( + `[dense-targets] buildTargetsResponse ${elapsed.toFixed(0)} ms\n`, + ); + expect(res.targets.length).toBeGreaterThan(0); + const denseReads = spy.mock.calls.filter(([args]) => + readsDenseRows(args), + ); + expect(denseReads).toEqual([]); + } finally { + spy.mockRestore(); + } + }); +}); + +describe("targets fold equals the raw-reading fold", () => { + for (const userId of [SPARSE, DENSE]) { + it(`glucose summaries match the in-memory fold (${userId})`, async () => { + const since = new Date(NOW.getTime() - 365 * DAY); + const recentSince = new Date(NOW.getTime() - 30 * DAY); + const raw = await prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE", + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: [{ measuredAt: "desc" }, { id: "desc" }], + select: { value: true, measuredAt: true, glucoseContext: true }, + }); + const expected = foldGlucoseTargetRows(raw, recentSince, TZ); + const actual = await readGlucoseTargetSummaries({ + userId, + since, + recentSince, + timeZone: TZ, + }); + const byCtx = (xs: T[]) => + [...xs].sort((a, b) => + (a.glucoseContext ?? "").localeCompare(b.glucoseContext ?? ""), + ); + expect(actual.length).toBeGreaterThanOrEqual(2); + const exp = byCtx(expected); + const act = byCtx(actual); + expect(act.map((s) => s.glucoseContext)).toEqual( + exp.map((s) => s.glucoseContext), + ); + for (let i = 0; i < exp.length; i++) { + expect(act[i].latest).toBe(exp[i].latest); + expect(act[i].latestAt).toEqual(exp[i].latestAt); + expect(act[i].recentCount).toBe(exp[i].recentCount); + expect([...act[i].recentByDay.keys()].sort()).toEqual( + [...exp[i].recentByDay.keys()].sort(), + ); + for (const [day, bucket] of exp[i].recentByDay) { + const got = act[i].recentByDay.get(day)!; + expect(got.count, day).toBe(bucket.count); + expect(got.sum, day).toBeCloseTo(bucket.sum, 6); + } + } + }); + + it(`glucose and resting-pulse cards equal the raw path (${userId})`, async () => { + const res = await buildTargetsResponse({ id: userId, timezone: TZ }); + // The builder takes its own clock after the reads; rebuild the raw + // expectation against the same thirty-day cut. + const now = new Date(); + const recentSince = new Date(now.getTime() - 30 * DAY); + const raw = await prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE", + deletedAt: null, + measuredAt: { gte: new Date(now.getTime() - 365 * DAY) }, + }, + orderBy: [{ measuredAt: "desc" }, { id: "desc" }], + select: { value: true, measuredAt: true, glucoseContext: true }, + }); + const expectedGlucose = buildGlucoseTargets({ + summaries: foldGlucoseTargetRows(raw, recentSince, TZ), + profile: { + heightCm: 170, + dateOfBirth: new Date("1980-05-01T00:00:00Z"), + gender: "FEMALE", + glucoseUnit: null, + hasDiabetes: false, + thresholdsJson: null, + }, + timezone: TZ, + now, + }); + const glucose = res.targets.filter((t) => + t.type.startsWith("BLOOD_GLUCOSE_"), + ); + expect(glucose.length).toBe(expectedGlucose.length); + for (let i = 0; i < glucose.length; i++) { + const { average30, ...rest } = glucose[i]; + const { average30: expAvg, ...expRest } = expectedGlucose[i]; + expect(rest).toEqual(expRest); + if (expAvg === null) expect(average30).toBeNull(); + else expect(average30).toBeCloseTo(expAvg, 6); + } + + const recent = await prisma.measurement.findMany({ + where: { + userId, + type: { in: ["PULSE", "RESTING_HEART_RATE"] }, + deletedAt: null, + measuredAt: { gte: recentSince }, + }, + select: { type: true, value: true, measuredAt: true }, + }); + const expectedPulse = buildVitalTargets({ + recentMeasurements: recent.filter( + (r) => r.type === "RESTING_HEART_RATE", + ), + restingPulseProxy: deriveRestingProxyFromPulse( + recent.filter((r) => r.type === "PULSE"), + (d) => userDayKey(d, TZ), + ), + latestByType: {}, + average30ByType: {}, + heightCm: 170, + age: res.profile.age, + gender: "FEMALE", + timezone: TZ, + now, + weightTargetOverride: null, + }).targets.find((t) => t.type === "PULSE"); + const pulse = res.targets.find((t) => t.type === "PULSE"); + expect(pulse?.current).not.toBeNull(); + expect(pulse).toEqual(expectedPulse); + }); + } +}); From 70e6e07a899dc15e394d6399401d27e3ed0d1432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:48:39 +0200 Subject: [PATCH 03/29] refactor(units): resolve both unit preferences through one registry Metric/imperial and the glucose unit were two separate lookups, so every server-side text producer had to remember both and most remembered neither. display-transform.ts now carries a glucose branch keyed by the glucose preference (getReadingTransform), the metre-valued workout quantities (distance, elevation, pace), and a resolver for the two stored columns together. loadUnitPreferences reads them in one query and useUnitDisplay hands the glucose unit out beside the metric/imperial branch. Refs #1067 --- src/hooks/use-unit-display.ts | 20 ++- .../__tests__/display-transform.test.ts | 89 ++++++++++++ src/lib/measurements/display-transform.ts | 131 ++++++++++++++++++ src/lib/measurements/unit-preferences.ts | 26 ++++ 4 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 src/lib/measurements/unit-preferences.ts diff --git a/src/hooks/use-unit-display.ts b/src/hooks/use-unit-display.ts index f74b311da..212823877 100644 --- a/src/hooks/use-unit-display.ts +++ b/src/hooks/use-unit-display.ts @@ -23,13 +23,21 @@ import { getDisplayTransform, invertDisplayTransform, hasDisplayTransform, + resolveUnitPreference, type DisplayTransform, type UnitPreference, } from "@/lib/measurements/display-transform"; +import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; export interface UnitDisplay { /** Resolved preference ("metric" by default / on a stale payload). */ preference: UnitPreference; + /** + * Resolved glucose unit ("mg/dL" by default). Carried beside the + * metric/imperial branch so a surface that edits or renders glucose reads + * both preferences from the one hook. + */ + glucoseUnit: GlucoseUnit; /** The full transform for a type under the current preference. */ transformFor: (type: string) => DisplayTransform; /** Convert a raw canonical ABSOLUTE value to the display value. */ @@ -48,7 +56,7 @@ export interface UnitDisplay { export function useUnitDisplay(): UnitDisplay { const { user } = useAuth(); - return useDisplayForPreference(user?.unitPreference); + return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit); } /** @@ -69,20 +77,22 @@ export function useUnitDisplay(): UnitDisplay { */ export function useUnitDisplayOnceMounted(): UnitDisplay { const user = useAccountOnceMounted(); - return useDisplayForPreference(user?.unitPreference); + return useDisplayForPreference(user?.unitPreference, user?.glucoseUnit); } function useDisplayForPreference( rawPreference: string | null | undefined, + rawGlucoseUnit: string | null | undefined, ): UnitDisplay { - const preference: UnitPreference = - rawPreference === "imperial" ? "imperial" : "metric"; + const preference = resolveUnitPreference(rawPreference); + const glucoseUnit = resolveGlucoseUnit(rawGlucoseUnit); return useMemo(() => { const transformFor = (type: string) => getDisplayTransform(type, preference); return { preference, + glucoseUnit, transformFor, toDisplay: (type, rawValue) => applyDisplayTransform(rawValue, transformFor(type)), @@ -94,5 +104,5 @@ function useDisplayForPreference( decimalsFor: (type) => transformFor(type).decimals, isTransformed: (type) => hasDisplayTransform(type), }; - }, [preference]); + }, [preference, glucoseUnit]); } diff --git a/src/lib/measurements/__tests__/display-transform.test.ts b/src/lib/measurements/__tests__/display-transform.test.ts index ccb7c5d79..91f23e674 100644 --- a/src/lib/measurements/__tests__/display-transform.test.ts +++ b/src/lib/measurements/__tests__/display-transform.test.ts @@ -10,7 +10,14 @@ import { transformRescales, TRANSFORMED_TYPES, DEFAULT_UNIT_PREFERENCE, + DEFAULT_UNIT_PREFERENCES, + getQuantityTransform, + getReadingTransform, + isUnitSensitiveType, + paceSecondsPerDistanceUnit, + resolveUnitPreferences, } from "../display-transform"; +import { convertGlucose } from "@/lib/glucose"; describe("display-transform", () => { it("converts WALKING_SPEED m/s → km/h (factor 3.6)", () => { @@ -308,3 +315,85 @@ describe("display-transform — converted values carry the declared precision", expect(invertDisplayTransform(185.2, imperial)).not.toBe(84); }); }); + +describe("display-transform — both unit preferences, one resolver", () => { + it("reads glucose in the preferred clinical unit, matching convertGlucose", () => { + const mmol = getReadingTransform("BLOOD_GLUCOSE", { + system: "metric", + glucoseUnit: "mmol/L", + }); + expect(mmol.displayUnit).toBe("mmol/L"); + for (const mgdl of [54, 70, 99, 126, 140, 250]) { + expect(applyDisplayTransform(mgdl, mmol)).toBe( + convertGlucose(mgdl, "mmol/L"), + ); + } + const mgdl = getReadingTransform("BLOOD_GLUCOSE", DEFAULT_UNIT_PREFERENCES); + expect(mgdl.displayUnit).toBe("mg/dL"); + expect(applyDisplayTransform(99, mgdl)).toBe(99); + }); + + it("leaves glucose alone under the imperial preference", () => { + const t = getReadingTransform("BLOOD_GLUCOSE", { + system: "imperial", + glucoseUnit: "mg/dL", + }); + expect(t.displayUnit).toBe("mg/dL"); + }); + + it("delegates every other type to the metric/imperial registry", () => { + expect( + getReadingTransform("WEIGHT", { + system: "imperial", + glucoseUnit: "mmol/L", + }).displayUnit, + ).toBe("lb"); + expect( + getReadingTransform("PULSE", { + system: "imperial", + glucoseUnit: "mmol/L", + }).displayUnit, + ).toBe("bpm"); + }); + + it("marks glucose and the transformed set as unit-sensitive", () => { + expect(isUnitSensitiveType("BLOOD_GLUCOSE")).toBe(true); + expect(isUnitSensitiveType("WEIGHT")).toBe(true); + expect(isUnitSensitiveType("HEART_RATE_VARIABILITY")).toBe(false); + }); + + it("resolves the stored columns with their defaults", () => { + expect(resolveUnitPreferences({})).toEqual(DEFAULT_UNIT_PREFERENCES); + expect( + resolveUnitPreferences({ + unitPreference: "imperial", + glucoseUnit: "mmol/L", + }), + ).toEqual({ system: "imperial", glucoseUnit: "mmol/L" }); + expect(resolveUnitPreferences({ unitPreference: "bogus" }).system).toBe( + "metric", + ); + }); + + it("converts workout distance, elevation and pace per preference", () => { + const km = getQuantityTransform("distance", "metric"); + const mi = getQuantityTransform("distance", "imperial"); + expect(applyDisplayTransform(5000, km)).toBe(5); + expect(km.displayUnit).toBe("km"); + expect(applyDisplayTransform(5000, mi)).toBe(3.11); + expect(mi.displayUnit).toBe("mi"); + expect( + applyDisplayTransform(100, getQuantityTransform("elevation", "imperial")), + ).toBe(328); + expect(getQuantityTransform("elevation", "metric").displayUnit).toBe("m"); + // 30 min over 5 km: 360 s/km, 579.4 s/mi. + expect(paceSecondsPerDistanceUnit(1800, 5000, "metric")).toBeCloseTo( + 360, + 6, + ); + expect(paceSecondsPerDistanceUnit(1800, 5000, "imperial")).toBeCloseTo( + 579.36, + 2, + ); + }); +}); diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index ec61fe1f2..5e1cba548 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -61,12 +61,54 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; +import { + MGDL_PER_MMOL, + resolveGlucoseUnit, + type GlucoseUnit, +} from "@/lib/glucose"; import { getUnitForType } from "@/lib/validations/measurement"; export type UnitPreference = "metric" | "imperial"; export const DEFAULT_UNIT_PREFERENCE: UnitPreference = "metric"; +/** The stored preference column resolved to a branch; anything unknown is metric. */ +export function resolveUnitPreference( + raw: string | null | undefined, +): UnitPreference { + return raw === "imperial" ? "imperial" : DEFAULT_UNIT_PREFERENCE; +} + +/** + * Both unit choices a person makes, together. They are separate columns + * because they are separate questions — metric versus imperial is about mass, + * length and temperature, mg/dL versus mmol/L is the clinical convention of + * the country the record lives in — but a surface that renders a reading + * needs both, and taking them as one value is what keeps a server-side text + * producer from honouring one and forgetting the other. + */ +export interface UnitPreferences { + system: UnitPreference; + glucoseUnit: GlucoseUnit; +} + +/** Resolve both stored columns at once (each falls back to its default). */ +export function resolveUnitPreferences(row: { + unitPreference?: string | null; + glucoseUnit?: string | null; +}): UnitPreferences { + return { + system: resolveUnitPreference(row.unitPreference), + glucoseUnit: resolveGlucoseUnit(row.glucoseUnit), + }; +} + +/** The defaults: metric, mg/dL. */ +export const DEFAULT_UNIT_PREFERENCES: UnitPreferences = { + system: DEFAULT_UNIT_PREFERENCE, + glucoseUnit: "mg/dL", +}; + export interface DisplayTransform { /** Multiplier applied to the raw canonical value for display. */ factor: number; @@ -156,6 +198,95 @@ const TRANSFORMS: Partial< }, }; +/** + * Blood glucose, stored in mg/dL, read in either clinical unit. Keyed by the + * glucose preference rather than metric/imperial. The mmol/L branch rounds to + * one decimal through `applyDisplayTransform`, which is exactly what + * `mgdlToMmol` does, so a server-side text and a screen built on + * `convertGlucose` print the same figure. + */ +const GLUCOSE_TRANSFORM: Record = { + "mg/dL": { factor: 1, displayUnit: "mg/dL", decimals: 0 }, + "mmol/L": { factor: 1 / MGDL_PER_MMOL, displayUnit: "mmol/L", decimals: 1 }, +}; + +/** + * The transform for a reading of `type` under BOTH unit preferences — the + * one resolver a server-side text producer (a status note, a narrative, a + * prompt) takes its numbers and unit symbols from. + * + * The client surfaces keep `getDisplayTransform` through `useUnitDisplay` + * and convert glucose through `convertGlucose` at the few places that render + * it; folding glucose into that path would convert it a second time on the + * screens that already do. Text built on the server has no such history, so + * it starts from the complete answer. + */ +export function getReadingTransform( + type: string, + preferences: UnitPreferences, +): DisplayTransform { + if (type === "BLOOD_GLUCOSE") + return GLUCOSE_TRANSFORM[preferences.glucoseUnit]; + return getDisplayTransform(type, preferences.system); +} + +/** + * Every measurement type whose displayed number or unit depends on a unit + * preference: the metric/imperial set plus glucose. Text that quoted one of + * these was written in a unit, and goes stale when the preference changes. + */ +export function isUnitSensitiveType(type: string): boolean { + return type === "BLOOD_GLUCOSE" || TRANSFORMED_TYPES.has(type); +} + +/** + * Quantities that are not a stored measurement type but are read in a unit + * system all the same: a workout's distance and its elevation gain, both + * stored in metres. Same rules as the type registry — the metric distance + * branch reads kilometres, which is a rescale, so it rounds; the metric + * elevation branch is the identity. + */ +export type QuantityKind = "distance" | "elevation"; + +// 1 m = 3.28083989501 ft. +const M_TO_FT = 3.28083989501; + +const QUANTITY_TRANSFORMS: Record< + QuantityKind, + Record +> = { + distance: { + metric: { factor: 0.001, displayUnit: "km", decimals: 2 }, + imperial: { factor: 0.000621371192237, displayUnit: "mi", decimals: 2 }, + }, + elevation: { + metric: { factor: 1, displayUnit: "m", decimals: 0 }, + imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 0 }, + }, +}; + +/** The transform for a metre-valued quantity under a metric/imperial preference. */ +export function getQuantityTransform( + kind: QuantityKind, + preference: UnitPreference, +): DisplayTransform { + return QUANTITY_TRANSFORMS[kind][preference]; +} + +/** + * Seconds per displayed distance unit (per km, or per mile) from a duration + * and a distance in metres. A pace is a rate in the inverse direction of a + * distance, so it divides by the factor instead of multiplying. + */ +export function paceSecondsPerDistanceUnit( + durationSec: number, + meters: number, + preference: UnitPreference, +): number { + const { factor } = QUANTITY_TRANSFORMS.distance[preference]; + return durationSec / (meters * factor); +} + /** * Every type with a registered transform. The structural guard test uses * this set to assert no display surface hardcodes a unit string / scale diff --git a/src/lib/measurements/unit-preferences.ts b/src/lib/measurements/unit-preferences.ts new file mode 100644 index 000000000..5dadc9b7b --- /dev/null +++ b/src/lib/measurements/unit-preferences.ts @@ -0,0 +1,26 @@ +/** + * Server-side read of a record's two unit preferences. + * + * Every text the server writes for a person to read — a status note, a period + * narrative, a score label — quotes numbers, and a number is only right in the + * unit its reader chose. This is the one read those producers share, so none + * of them can load `unitPreference` and forget `glucoseUnit` or the reverse. + */ +import { prisma } from "@/lib/db"; + +import { + DEFAULT_UNIT_PREFERENCES, + resolveUnitPreferences, + type UnitPreferences, +} from "./display-transform"; + +/** Both preferences of one record; the defaults when the row is gone. */ +export async function loadUnitPreferences( + userId: string, +): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { unitPreference: true, glucoseUnit: true }, + }); + return row ? resolveUnitPreferences(row) : DEFAULT_UNIT_PREFERENCES; +} From 8949606de9ef71e7b618e9c0067e5510a4afedb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:50:31 +0200 Subject: [PATCH 04/29] fix(cycle): read the insights crosstab as per-source day aggregates GET /api/cycle/insights read a year of raw readings for its outcome channels, glucose and heart-rate variability included. On a sensor account that is over 100 000 objects per request. The crosstab uses each local day's sum and count after picking one source and device per day, so the route now reads readSourceDayAggregates like the coach's cycle snapshot does, and each row carries its reading count into metricDayMap. The integration test runs the route over the fold and over every raw reading handed in as a one-reading group, and compares the answers. --- .../insights/__tests__/route-locale.test.ts | 3 + src/app/api/cycle/insights/route.ts | 40 ++- .../cycle-insights-dense-reads.test.ts | 264 ++++++++++++++++++ 3 files changed, 285 insertions(+), 22 deletions(-) create mode 100644 tests/integration/cycle-insights-dense-reads.test.ts diff --git a/src/app/api/cycle/insights/__tests__/route-locale.test.ts b/src/app/api/cycle/insights/__tests__/route-locale.test.ts index a55be82e6..0a516735c 100644 --- a/src/app/api/cycle/insights/__tests__/route-locale.test.ts +++ b/src/app/api/cycle/insights/__tests__/route-locale.test.ts @@ -37,6 +37,9 @@ vi.mock("@/lib/cycle/phase-crosstab", async (importOriginal) => ({ discoverPhaseCorrelations(args), })); +vi.mock("@/lib/measurements/day-aggregates", () => ({ + readSourceDayAggregates: vi.fn().mockResolvedValue([]), +})); vi.mock("@/lib/db", () => ({ prisma: { auditLog: { create: vi.fn() }, diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index 4fc89c5b3..fb9d6c1c8 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -43,6 +43,7 @@ import { type SymptomDay, } from "@/lib/cycle/symptom-phase"; import { addDays } from "@/lib/cycle/day-math"; +import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates"; import { moodDateKey } from "@/lib/mood/date-key"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; @@ -130,27 +131,19 @@ export const GET = apiHandler(async () => { orderBy: { measuredAt: "asc" }, select: { measuredAt: true, value: true }, }), - // The outcome metrics the phase contrast compares — soft-delete-scoped, - // canonical-source deduped per day inside `metricDayMap`. Skipped whole - // for a caller outside `measurements`. + // The outcome metrics the phase contrast compares, soft-delete-scoped. + // A year of steps, heart-rate variability and sensor glucose is far too + // many readings to hold as objects (#1023), and the crosstab uses only + // each day's sum and count after picking one source and device per day. + // So the fold runs in SQL, one row per type, local day, source and + // device, the read the coach's cycle snapshot already makes. Skipped + // whole for a caller outside `measurements`. seesMeasurements - ? prisma.measurement.findMany({ - where: { - userId: user.id, - deletedAt: null, - type: { in: PHASE_CROSSTAB_METRIC_TYPES }, - measuredAt: { - gte: new Date(Date.parse(`${from}T00:00:00Z`)), - }, - }, - orderBy: { measuredAt: "asc" }, - select: { - type: true, - value: true, - measuredAt: true, - source: true, - deviceType: true, - }, + ? readSourceDayAggregates({ + userId: user.id, + types: PHASE_CROSSTAB_METRIC_TYPES, + since: new Date(Date.parse(`${from}T00:00:00Z`)), + timeZone: tz, }) : [], prisma.user.findUnique({ @@ -200,10 +193,13 @@ export const GET = apiHandler(async () => { today, ); + // Each row stands for its day's readings: `value` is their sum and + // `count` their number, so `metricDayMap` gets the raw readings' day mean. const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({ type: m.type, - value: m.value, - measuredAt: m.measuredAt, + value: m.sum, + count: m.n, + measuredAt: m.firstAt, source: m.source, deviceType: m.deviceType, })); diff --git a/tests/integration/cycle-insights-dense-reads.test.ts b/tests/integration/cycle-insights-dense-reads.test.ts new file mode 100644 index 000000000..545e1d24b --- /dev/null +++ b/tests/integration/cycle-insights-dense-reads.test.ts @@ -0,0 +1,264 @@ +/** + * `GET /api/cycle/insights` reads its outcome channels as per-source day + * aggregates, not as a year of readings. + * + * The route compared a year of resting heart rate, heart-rate variability, + * sleep, steps, weight, temperature and glucose across the cycle phases, and + * read every reading of it. A sensor reporting glucose every five minutes is + * over 100 000 readings in that year. The contrast only uses each local day's + * sum and count after one source and device is picked per day, which is what + * `readSourceDayAggregates` returns. + * + * Two properties on real Postgres: no outcome channel is read as rows, and the + * response is the one the raw readings give. The second runs the route twice, + * once over the SQL fold and once over every raw reading handed in as its own + * one-reading aggregate (exactly the old input), and compares the bodies. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import type { SourceDayAggregateRow } from "@/lib/measurements/day-aggregates"; +import { userDayKey } from "@/lib/tz/format"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +/** When set, the fold is replaced by every raw reading as a one-row group. */ +const mode = vi.hoisted(() => ({ raw: false })); + +vi.mock("@/lib/measurements/day-aggregates", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readSourceDayAggregates: async ( + opts: Parameters[0], + ): Promise => { + if (!mode.raw) return actual.readSourceDayAggregates(opts); + const { getPrismaClient } = await import("./setup"); + const rows = await getPrismaClient().measurement.findMany({ + where: { + userId: opts.userId, + deletedAt: null, + type: { in: [...opts.types] }, + measuredAt: { gte: opts.since }, + }, + orderBy: { measuredAt: "asc" }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + }, + }); + return rows.map((r) => ({ + type: r.type, + day: userDayKey(r.measuredAt, opts.timeZone), + source: r.source, + deviceType: r.deviceType, + n: 1, + sum: r.value, + firstAt: r.measuredAt, + })); + }, + }; +}); + +const prisma = getPrismaClient(); +const OWNER = "cycle-dense-owner"; +const TZ = "Europe/Berlin"; +const CYCLE_LENGTH = 28; +const OBSERVED_CYCLES = 5; +const DAY = 86_400_000; + +function isoDay(offsetDays: number): string { + return new Date(Date.now() + offsetDays * DAY).toISOString().slice(0, 10); +} + +beforeAll(async () => { + await truncateAllTables(prisma); + await prisma.user.create({ + data: { id: OWNER, username: OWNER, gender: "FEMALE", timezone: TZ }, + }); + invalidateUserTimezone(OWNER); + await prisma.cycleProfile.create({ + data: { + userId: OWNER, + cycleTrackingEnabled: true, + predictionEnabled: false, + lutealPhaseLength: 14, + }, + }); + for (let i = OBSERVED_CYCLES; i >= 1; i--) { + await prisma.menstrualCycle.create({ + data: { + userId: OWNER, + startDate: isoDay(-i * CYCLE_LENGTH), + endDate: i > 1 ? isoDay(-(i - 1) * CYCLE_LENGTH - 1) : null, + periodEndDate: isoDay(-i * CYCLE_LENGTH + 4), + tz: "UTC", + }, + }); + } + const start = `${isoDay(-OBSERVED_CYCLES * CYCLE_LENGTH)} 00:00:00`; + // Sensor glucose every five minutes from two sources, higher in the second + // half of each cycle; heart-rate variability every twenty minutes from a + // watch, lower in the second half; steps every fifteen minutes from a watch + // and a phone; weight once a day next to the Berlin midnight. + await prisma.$executeRawUnsafe( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version) + SELECT 'cg' || s || '-' || g, $1, 'BLOOD_GLUCOSE'::measurement_type, + (CASE WHEN (g / 288) % 28 >= 14 THEN 120 ELSE 95 END) + (g % 23) + s * 0.5, 'mg/dL', + (CASE WHEN s = 0 THEN 'APPLE_HEALTH' ELSE 'NIGHTSCOUT' END)::measurement_source, + $2::timestamp + (g * interval '5 minutes') + (s * interval '1 second'), + now(), now(), 1 + FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 288 - 1) g, generate_series(0, 1) s`, + OWNER, + start, + ); + await prisma.$executeRawUnsafe( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version) + SELECT 'hv' || g, $1, 'HEART_RATE_VARIABILITY'::measurement_type, + (CASE WHEN (g / 72) % 28 >= 14 THEN 38 ELSE 52 END) + (g % 11) * 0.7, 'ms', + 'APPLE_HEALTH'::measurement_source, + $2::timestamp + (g * interval '20 minutes'), now(), now(), 1 + FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 72 - 1) g`, + OWNER, + start, + ); + await prisma.$executeRawUnsafe( + `INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at, sync_version) + SELECT 'st' || d || '-' || g, $1, 'ACTIVITY_STEPS'::measurement_type, 10 + (g % 7), 'count', + 'APPLE_HEALTH'::measurement_source, CASE WHEN d = 0 THEN 'watch' ELSE 'phone' END, + $2::timestamp + (g * interval '15 minutes') + (d * interval '2 seconds'), + now(), now(), 1 + FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} * 96 - 1) g, generate_series(0, 1) d + WHERE d = 0 OR g % 3 = 0`, + OWNER, + start, + ); + await prisma.$executeRawUnsafe( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version) + SELECT 'wt' || g, $1, 'WEIGHT'::measurement_type, + (CASE WHEN g % 28 >= 14 THEN 71.2 ELSE 70.1 END) + (g % 5) * 0.1, 'kg', + 'WITHINGS'::measurement_source, + $2::timestamp + (g * interval '1 day') + interval '22 hours 50 minutes', now(), now(), 1 + FROM generate_series(0, ${OBSERVED_CYCLES * CYCLE_LENGTH} - 1) g`, + OWNER, + start, + ); + await prisma.$executeRawUnsafe(`ANALYZE measurements`); + const session = await prisma.session.create({ + data: { userId: OWNER, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", session.id); +}, 180_000); + +async function readInsights(): Promise<{ status: number; body: unknown }> { + const { GET } = await import("@/app/api/cycle/insights/route"); + const handler = GET as unknown as (request: Request) => Promise; + const res = await handler(new Request("http://localhost/api/cycle/insights")); + return { status: res.status, body: await res.json() }; +} + +function readsOutcomeRows(args: unknown): boolean { + const type = (args as { where?: { type?: unknown } } | undefined)?.where + ?.type; + const list = (type as { in?: unknown } | undefined)?.in; + return ( + type === "BLOOD_GLUCOSE" || + type === "HEART_RATE_VARIABILITY" || + (Array.isArray(list) && + (list.includes("BLOOD_GLUCOSE") || + list.includes("HEART_RATE_VARIABILITY"))) + ); +} + +function expectSameUpToRounding( + actual: unknown, + expected: unknown, + path: string, +): void { + if (typeof expected === "number" && typeof actual === "number") { + const scale = Math.max(Math.abs(expected), Math.abs(actual)); + expect( + Math.abs(actual - expected) <= scale * 1e-9, + `${path}: ${actual} vs ${expected}`, + ).toBe(true); + return; + } + if (Array.isArray(expected)) { + expect(Array.isArray(actual), path).toBe(true); + expect((actual as unknown[]).length, path).toBe(expected.length); + expected.forEach((e, i) => + expectSameUpToRounding((actual as unknown[])[i], e, `${path}[${i}]`), + ); + return; + } + if (expected !== null && typeof expected === "object") { + expect(actual !== null && typeof actual === "object", path).toBe(true); + const a = actual as Record; + const e = expected as Record; + expect(Object.keys(a).sort(), path).toEqual(Object.keys(e).sort()); + for (const key of Object.keys(e)) { + expectSameUpToRounding(a[key], e[key], `${path}.${key}`); + } + return; + } + expect(actual, path).toEqual(expected); +} + +describe("cycle insights on a dense account", () => { + it("reads no outcome channel as rows", async () => { + mode.raw = false; + const spy = vi.spyOn(prisma.measurement, "findMany"); + try { + const started = performance.now(); + const { status } = await readInsights(); + process.stderr.write( + `[cycle-insights-dense] ${(performance.now() - started).toFixed(0)} ms\n`, + ); + expect(status).toBe(200); + expect(spy.mock.calls.filter(([a]) => readsOutcomeRows(a))).toEqual([]); + } finally { + spy.mockRestore(); + } + }, 60_000); + + it("answers exactly what the raw readings give", async () => { + mode.raw = false; + const folded = await readInsights(); + mode.raw = true; + const raw = await readInsights(); + mode.raw = false; + expect(folded.status).toBe(200); + const rows = (folded.body as { data: { rows: unknown[] } }).data.rows; + // The fixture is built so the contrast has something to say. + expect(rows.length).toBeGreaterThan(0); + // A day's sum is added in another order in SQL than in JavaScript, so a + // p-value can differ in its last bits; every figure is otherwise equal. + expectSameUpToRounding(folded.body, raw.body, "body"); + }, 120_000); +}); From b2c484e25e7b6ee2d0c826119838c3b00ff129de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:52:10 +0200 Subject: [PATCH 05/29] fix(series): bucket heart-rate variability and blood oxygen like pulse The dense rule in /api/measurements/series covered pulse and glucose only. Heart-rate variability and blood oxygen read every raw row for any window up to 3650 days, and a watch or ring that writes them every minute makes that the same unbounded read pulse had. Both kinds now fold per local day beyond 90 days and per local hour inside 90 days once the window holds more than 10 000 readings. A bucket keeps the point shape of its kind: only pulse carries valueMin / valueMax. Statistics stay computed over every reading, and a sparse series inside 90 days is unchanged. The OpenAPI description names the four kinds. --- docs/api/openapi.yaml | 19 +- src/app/api/measurements/series/route.ts | 32 +++- src/lib/openapi/routes/measurements.ts | 4 +- .../integration/dense-series-hrv-spo2.test.ts | 175 ++++++++++++++++++ 4 files changed, 210 insertions(+), 20 deletions(-) create mode 100644 tests/integration/dense-series-hrv-spo2.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20072acf6..ad690c61f 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -41066,9 +41066,9 @@ components: properties: id: type: string - description: Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse` / - `glucose` beyond 90 days); `hour:` for an hour bucket (`pulse` / `glucose` within 90 days - when the window holds more than 10 000 readings). + description: Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse`, + `glucose`, `heartRateVariability`, `oxygenSaturation` beyond 90 days); `hour:` for an hour + bucket (the same kinds within 90 days when the window holds more than 10 000 readings). at: type: string format: date-time @@ -41141,12 +41141,13 @@ components: - stats additionalProperties: false description: "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an - explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and - `glucose` beyond 90 days per local day, and `pulse` / `glucose` within 90 days per local hour when the window - holds more than 10 000 readings (a continuous glucose sensor, a watch recording heart rate every minute; a cuff - or fingerstick meter stays raw). A bucket carries the mean as `value`; a `pulse` bucket also carries its range - as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` - is always computed over the underlying readings, never over the buckets." + explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, + `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within + 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or + ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries + the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its + range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. + `stats` is always computed over the underlying readings, never over the buckets." ListMeasurementsResponse: type: object properties: diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 06d46e07a..f42afb47c 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -49,11 +49,23 @@ const SLEEP_SERIES_MAX_DAYS = 365; * byte-identical. Sparse kinds (weight, BP, …) are untouched. */ const DENSE_SERIES_RAW_WINDOW_DAYS = 90; -const DENSE_SERIES_KINDS: ReadonlySet = new Set(["glucose", "pulse"]); +/** + * The kinds a device can write at sampling rate. Heart-rate variability and + * blood oxygen joined pulse and glucose once watches and rings began writing + * them every few minutes overnight: their 3650-day "Alle" range read every + * raw row, which is the read this rule exists to stop. + */ +const DENSE_SERIES_KINDS: ReadonlySet = new Set([ + "glucose", + "pulse", + "heartRateVariability", + "oxygenSaturation", +]); /** - * #1023 — inside the raw window, a pulse or glucose series with more rows - * than this is bucketed per local hour in SQL instead of sent raw. + * #1023 — inside the raw window, a dense-kind series (pulse, glucose, HRV, + * blood oxygen) with more rows than this is bucketed per local hour in SQL + * instead of sent raw. * * A watch that records heart rate once a minute puts 43 000 rows in the * default 30-day window and 130 000 in 90 days: a 15 MB response the client @@ -381,7 +393,7 @@ export const GET = apiHandler(async (request: NextRequest) => { }, })) > DENSE_SERIES_RAW_ROW_CAP) ) { - // Day buckets past the raw window; hour buckets for a pulse or CGM + // Day buckets past the raw window; hour buckets for a dense-kind // stream too dense to send raw inside it. const grain = days > DENSE_SERIES_RAW_WINDOW_DAYS ? "day" : "hour"; // v1.28.25 — long-window read of a sample-dense kind (CGM glucose, @@ -489,17 +501,19 @@ export const GET = apiHandler(async (request: NextRequest) => { }; } } else { - // pulse — `value` is the bucket's average; valueMin/valueMax carry the - // bucket's low/high band (folding each hourly import bucket's own + // `value` is the bucket's average. For pulse, valueMin/valueMax carry + // the bucket's low/high band (folding each hourly import bucket's own // spread via the COALESCE above), same band semantics as the per-hour - // shape. + // shape. The other dense kinds keep the bare point shape their raw rows + // have, so a client reads a bucket the way it reads a reading. points = bucketRows.map((r) => ({ id: dayId(r.bucket_start), at: r.bucket_start.toISOString(), value: round2(r.mean), secondary: null, - valueMin: r.min_value, - valueMax: r.max_value, + ...(kind === "pulse" + ? { valueMin: r.min_value, valueMax: r.max_value } + : {}), })); if ( rawAgg !== undefined && diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 0795e88b1..864baeb4a 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -696,7 +696,7 @@ const seriesPointSchema = z.object({ id: z .string() .describe( - "Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse` / `glucose` beyond 90 days); `hour:` for an hour bucket (`pulse` / `glucose` within 90 days when the window holds more than 10 000 readings).", + "Measurement row id; `sleep:` for a sleep night; `day:` for a day bucket (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation` beyond 90 days); `hour:` for an hour bucket (the same kinds within 90 days when the window holds more than 10 000 readings).", ), at: z.iso.datetime({ offset: true }).describe("Point timestamp (ISO-8601)."), value: z.number().describe("Primary value in the top-level `unit`."), @@ -751,7 +751,7 @@ const seriesResponse = z .meta({ id: "MeasurementsSeriesResponse", description: - "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds are bucketed server-side: `pulse` and `glucose` beyond 90 days per local day, and `pulse` / `glucose` within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch recording heart rate every minute; a cuff or fingerstick meter stays raw). A bucket carries the mean as `value`; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.", + "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block. Sample-dense kinds (`pulse`, `glucose`, `heartRateVariability`, `oxygenSaturation`) are bucketed server-side: beyond 90 days per local day, and within 90 days per local hour when the window holds more than 10 000 readings (a continuous glucose sensor, a watch or ring recording every minute; a cuff, a fingerstick meter or a few readings a night stay raw). A bucket carries the mean as `value` and otherwise has the shape of a reading of its kind; a `pulse` bucket also carries its range as `valueMin` / `valueMax`, and a `glucose` bucket is converted to the user's unit like a raw reading. `stats` is always computed over the underlying readings, never over the buckets.", }); // ── Batched daily series (v1.18.6 dashboard fetch coalescing) ──────── diff --git a/tests/integration/dense-series-hrv-spo2.test.ts b/tests/integration/dense-series-hrv-spo2.test.ts new file mode 100644 index 000000000..e60c70647 --- /dev/null +++ b/tests/integration/dense-series-hrv-spo2.test.ts @@ -0,0 +1,175 @@ +/** + * The per-kind series for heart-rate variability and blood oxygen follows the + * same dense rule as pulse and glucose. + * + * Both used to read every raw row for any window up to 3650 days, and a ring + * or a watch that writes them every minute overnight, or all day, makes that + * the same unbounded read pulse had before #1023. Beyond 90 days they now fold + * per local day; within 90 days a window of more than 10 000 readings folds + * per local hour. A sparse account inside 90 days keeps its raw readings, + * byte for byte, and a bucket has the shape of a reading of its kind (no + * `valueMin` / `valueMax`, which only pulse carries). + */ +import { NextRequest } from "next/server"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const prisma = getPrismaClient(); +const TZ = "Europe/Berlin"; +const DENSE = "dense-hrv-spo2-owner"; +const SPARSE = "sparse-hrv-spo2-owner"; +const NOW = new Date(Math.floor(Date.now() / 60_000) * 60_000 - 60_000); +/** Twelve days of one reading a minute: over the 10 000-row cap. */ +const DENSE_ROWS = 12 * 24 * 60; + +const KINDS = [ + { kind: "heartRateVariability", type: "HEART_RATE_VARIABILITY", unit: "ms" }, + { kind: "oxygenSaturation", type: "OXYGEN_SATURATION", unit: "%" }, +] as const; + +beforeAll(async () => { + await truncateAllTables(prisma); + for (const id of [DENSE, SPARSE]) { + await prisma.user.create({ data: { id, username: id, timezone: TZ } }); + invalidateUserTimezone(id); + } + for (const { type, unit } of KINDS) { + await prisma.$executeRawUnsafe( + `INSERT INTO measurements ( + id, user_id, type, value, unit, source, measured_at, + created_at, updated_at, sync_version) + SELECT $4 || lpad(g::text, 7, '0'), $1, $5::measurement_type, + CASE WHEN $5 = 'OXYGEN_SATURATION' THEN 92 + (g % 8) ELSE 30 + (g % 40) * 0.5 END, + $6, 'GOOGLE_HEALTH'::measurement_source, + ($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'), + now(), now(), 1 + FROM generate_series(0, $3::int - 1) g`, + DENSE, + NOW.toISOString(), + DENSE_ROWS, + type === "OXYGEN_SATURATION" ? "ds" : "dh", + type, + unit, + ); + // A few readings a night for a month and a half. + await prisma.measurement.createMany({ + data: Array.from({ length: 45 * 3 }, (_, i) => ({ + id: `sp-${type}-${i}`, + userId: SPARSE, + type, + value: type === "OXYGEN_SATURATION" ? 94 + (i % 5) : 35 + (i % 17), + unit, + source: "MANUAL" as const, + measuredAt: new Date( + NOW.getTime() - Math.floor(i / 3) * 86_400_000 - (i % 3) * 7_200_000, + ), + })), + }); + } + await prisma.$executeRawUnsafe(`ANALYZE measurements`); +}, 120_000); + +async function series(userId: string, kind: string, days: number) { + const s = await prisma.session.create({ + data: { userId, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.clear(); + cookieJar.set("healthlog_session", s.id); + const { GET } = await import("@/app/api/measurements/series/route"); + const res = await GET( + new NextRequest( + `http://localhost/api/measurements/series?kind=${kind}&days=${days}`, + ), + ); + expect(res.status).toBe(200); + return ( + (await res.json()) as { + data: { + unit: string; + points: Array & { id: string; value: number }>; + stats: { count: number; mean: number; min: number; max: number }; + }; + } + ).data; +} + +describe.each(KINDS)("$kind series", ({ kind, type, unit }) => { + it("sends a dense stream inside 90 days as hour buckets", async () => { + const data = await series(DENSE, kind, 30); + expect(data.unit).toBe(unit); + expect(data.points.length).toBeLessThanOrEqual(12 * 24 + 2); + expect(data.points.length).toBeGreaterThanOrEqual(12 * 24 - 2); + expect(data.points.every((p) => p.id.startsWith("hour:"))).toBe(true); + // A bucket has the shape of a reading of its kind. + expect( + data.points.every((p) => !("valueMin" in p) && !("valueMax" in p)), + ).toBe(true); + // Statistics stay over every reading. + const agg = await prisma.measurement.aggregate({ + where: { + userId: DENSE, + type, + measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) }, + }, + _count: true, + _min: { value: true }, + _max: { value: true }, + }); + expect(data.stats.count).toBe(agg._count); + expect(data.stats.min).toBe(agg._min.value); + expect(data.stats.max).toBe(agg._max.value); + }); + + it("folds the long ranges per local day", async () => { + const data = await series(SPARSE, kind, 3650); + expect(data.points.length).toBeGreaterThan(40); + expect(data.points.length).toBeLessThanOrEqual(46); + expect(data.points.every((p) => p.id.startsWith("day:"))).toBe(true); + expect(data.stats.count).toBe(45 * 3); + }); + + it("keeps a sparse series inside 90 days raw", async () => { + const data = await series(SPARSE, kind, 30); + const raw = await prisma.measurement.findMany({ + where: { + userId: SPARSE, + type, + measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) }, + }, + orderBy: { measuredAt: "asc" }, + select: { id: true, value: true, measuredAt: true }, + }); + expect(data.points).toEqual( + raw.map((r) => ({ + id: r.id, + at: r.measuredAt.toISOString(), + value: r.value, + secondary: null, + })), + ); + }); +}); From 6c6d363bba0c41503b8f8cbc850fbaccec98ebd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:52:44 +0200 Subject: [PATCH 06/29] fix(insights): write metric status notes in the reader's units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-metric status note quoted glucose in mg/dL, body composition in kg, temperatures in °C and walking distance in metres, whatever the reader had chosen; the deterministic floor without a provider did the same. The generator now converts the series, the reference band and the signal into the reader's units before they reach the prompt or the floor, and prints the guideline band table in that unit while still classifying on the canonical value. A changed glucose unit or metric/imperial preference (own account or a managed record) now re-warms the stored notes and narratives through the hash-gated refill, so today's text does not keep the old unit. Refs #1067 --- .../me/glucose-unit/__tests__/route.test.ts | 35 +++++ src/app/api/auth/me/glucose-unit/route.ts | 7 + .../unit-preference/__tests__/route.test.ts | 35 +++++ src/app/api/auth/me/unit-preference/route.ts | 7 + src/app/api/record-settings/[family]/route.ts | 4 + .../__tests__/interpretation-block.test.ts | 23 +++ src/lib/ai/prompts/interpretation-block.ts | 32 ++-- .../insights/__tests__/metric-status.test.ts | 131 ++++++++++++++++ .../__tests__/unit-change-refresh.test.ts | 60 ++++++++ src/lib/insights/graded-series.ts | 29 ++++ src/lib/insights/metric-status.ts | 145 ++++++++++++++++-- src/lib/insights/no-key-fallbacks.ts | 8 +- src/lib/insights/unit-change-refresh.ts | 63 ++++++++ 13 files changed, 551 insertions(+), 28 deletions(-) create mode 100644 src/lib/insights/__tests__/unit-change-refresh.test.ts create mode 100644 src/lib/insights/unit-change-refresh.ts diff --git a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts index a699059cb..1e19944a9 100644 --- a/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts +++ b/src/app/api/auth/me/glucose-unit/__tests__/route.test.ts @@ -23,6 +23,10 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/insights/unit-change-refresh", () => ({ + refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); @@ -56,6 +60,7 @@ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; import { checkRateLimit } from "@/lib/rate-limit"; const SESSION_OK = { @@ -210,3 +215,33 @@ describe("PATCH /api/auth/me/glucose-unit", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); }); + +describe("PATCH re-warms the texts that quote the unit", () => { + it("re-warms the stored notes when the unit actually changes", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ glucoseUnit: "mmol/L" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1"); + }); + + it("leaves them alone when the same unit is saved again", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + glucoseUnit: "mmol/L", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ glucoseUnit: "mmol/L" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/glucose-unit/route.ts b/src/app/api/auth/me/glucose-unit/route.ts index 144fa8df6..b11373224 100644 --- a/src/app/api/auth/me/glucose-unit/route.ts +++ b/src/app/api/auth/me/glucose-unit/route.ts @@ -38,6 +38,7 @@ import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; import { glucoseUnitPatchSchema } from "@/lib/validations/user-prefs"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; export const dynamic = "force-dynamic"; @@ -112,6 +113,12 @@ export const PATCH = apiHandler(async (req: Request) => { }, }); + // The status notes and narratives quote glucose in this unit; re-warm + // them so today's stored text does not keep the old one. + if (resolveGlucoseUnit(previous?.glucoseUnit) !== next) { + void refreshTextsAfterUnitChange(user.id); + } + annotate({ action: { name: "auth.me.glucose-unit.patch" }, meta: { glucoseUnit: next }, diff --git a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts index 17f9e74a3..430a05879 100644 --- a/src/app/api/auth/me/unit-preference/__tests__/route.test.ts +++ b/src/app/api/auth/me/unit-preference/__tests__/route.test.ts @@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/insights/unit-change-refresh", () => ({ + refreshTextsAfterUnitChange: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); @@ -43,6 +47,7 @@ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -197,3 +202,33 @@ describe("PATCH /api/auth/me/unit-preference", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); }); + +describe("PATCH re-warms the texts that quote the unit", () => { + it("re-warms the stored notes when the unit actually changes", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + unitPreference: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ unitPreference: "imperial" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).toHaveBeenCalledWith("user-1"); + }); + + it("leaves them alone when the same unit is saved again", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + unitPreference: "imperial", + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await (PATCH as (r: Request) => Promise)( + mkPatch({ unitPreference: "imperial" }), + ); + expect(res.status).toBe(200); + expect(refreshTextsAfterUnitChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/auth/me/unit-preference/route.ts b/src/app/api/auth/me/unit-preference/route.ts index ae7b459d7..fe17915fa 100644 --- a/src/app/api/auth/me/unit-preference/route.ts +++ b/src/app/api/auth/me/unit-preference/route.ts @@ -38,6 +38,7 @@ import { prisma } from "@/lib/db"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { DEFAULT_UNIT_PREFERENCE } from "@/lib/measurements/display-transform"; import { unitPreferencePatchSchema } from "@/lib/validations/user-prefs"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; export const dynamic = "force-dynamic"; @@ -118,6 +119,12 @@ export const PATCH = apiHandler(async (req: Request) => { }, }); + // The status notes and narratives quote mass, length and temperature in + // this branch; re-warm them so today's stored text follows the switch. + if (resolve(previous?.unitPreference) !== next) { + void refreshTextsAfterUnitChange(user.id); + } + annotate({ action: { name: "auth.me.unit-preference.patch" }, meta: { unitPreference: next }, diff --git a/src/app/api/record-settings/[family]/route.ts b/src/app/api/record-settings/[family]/route.ts index 5b91fa3de..f72f62af9 100644 --- a/src/app/api/record-settings/[family]/route.ts +++ b/src/app/api/record-settings/[family]/route.ts @@ -41,6 +41,7 @@ import { parseNotificationPrefs, resolveNotificationPrefs, } from "@/lib/validations/notification-prefs"; +import { refreshTextsAfterUnitChange } from "@/lib/insights/unit-change-refresh"; export const dynamic = "force-dynamic"; @@ -230,6 +231,9 @@ export const PATCH = apiHandler( }, }); invalidateUserProfile(access.recordId); + if (patch.unitPreference !== undefined) { + void refreshTextsAfterUnitChange(access.recordId); + } if (patch.timezone !== undefined) { invalidateUserMedications(access.recordId, { evict: true }); } diff --git a/src/lib/ai/prompts/__tests__/interpretation-block.test.ts b/src/lib/ai/prompts/__tests__/interpretation-block.test.ts index 31a04abca..010100fb2 100644 --- a/src/lib/ai/prompts/__tests__/interpretation-block.test.ts +++ b/src/lib/ai/prompts/__tests__/interpretation-block.test.ts @@ -9,6 +9,29 @@ import { describe, expect, it } from "vitest"; import { buildInterpretationBlock } from "../interpretation-block"; describe("buildInterpretationBlock", () => { + it("classifies on the canonical value but prints in the reader's unit", () => { + const toF = (c: number) => Math.round((c * 1.8 + 32) * 10) / 10; + const block = buildInterpretationBlock({ + metricKey: "BODY_TEMPERATURE", + value: 36.8, + sex: null, + locale: "en", + display: { unit: "°F", convert: toF }, + }); + expect(block).toBeDefined(); + expect(block).toContain("Current value 98.2 °F"); + expect(block).not.toContain("°C"); + // Same band as the canonical block names. + const canonical = buildInterpretationBlock({ + metricKey: "BODY_TEMPERATURE", + value: 36.8, + sex: null, + locale: "en", + })!; + const band = (text: string) => text.match(/is in the "([^"]+)" band/)![1]; + expect(band(block!)).toBe(band(canonical)); + }); + it("renders the band position for visceral fat 2.7 (the headline case)", () => { const en = buildInterpretationBlock({ metricKey: "VISCERAL_FAT", diff --git a/src/lib/ai/prompts/interpretation-block.ts b/src/lib/ai/prompts/interpretation-block.ts index 84bad478e..7e42185a6 100644 --- a/src/lib/ai/prompts/interpretation-block.ts +++ b/src/lib/ai/prompts/interpretation-block.ts @@ -130,9 +130,17 @@ function proximityPhrase( */ export function buildInterpretationBlock(args: { metricKey: string; + /** The value in the guideline's own (canonical) unit — what is classified. */ value: number; sex: "MALE" | "FEMALE" | null | undefined; locale: Locale; + /** + * Print the block in the reader's unit. Classification always runs on the + * canonical value against the canonical bands; only the printed numbers and + * the unit symbol are converted, so a prompt whose snapshot reads °F does + * not carry a band table in °C beside it. + */ + display?: { unit: string; convert: (canonical: number) => number }; }): string | undefined { const resolved = resolveInterpretation(args.metricKey, args.sex ?? null); if (!resolved) return undefined; @@ -140,22 +148,24 @@ export function buildInterpretationBlock(args: { const loc: "en" | "de" = instructionLocale(args.locale); const pos = classifyBandPosition(args.value, resolved.bands); - const rows = resolved.bands + const show = (n: number) => (args.display ? args.display.convert(n) : n); + const unit = args.display ? args.display.unit : resolved.unit; + const shownBands = resolved.bands.map((band) => ({ + ...band, + upTo: band.upTo === null ? null : show(band.upTo), + })); + + const rows = shownBands .map((band, i) => - bandRow( - band, - i > 0 ? resolved.bands[i - 1].upTo : null, - resolved.unit, - loc, - ), + bandRow(band, i > 0 ? shownBands[i - 1].upTo : null, unit, loc), ) .map((r) => ` - ${r}`) .join("\n"); const proximity = proximityPhrase( pos.proximity, - pos.nearestEdge, - resolved.unit, + pos.nearestEdge === null ? null : show(pos.nearestEdge), + unit, loc, ); const valence = valencePhrase(pos.band.valence, loc); @@ -164,14 +174,14 @@ export function buildInterpretationBlock(args: { if (loc === "en") { return `INTERPRETATION CONTEXT (guideline bands — computed server-side; state, do NOT recompute): -- Current value ${args.value} ${resolved.unit} is in the "${pos.band.label}" band, and ${proximity}. +- Current value ${show(args.value)} ${unit} is in the "${pos.band.label}" band, and ${proximity}. - Guideline bands (${resolved.source}; ${direction}): ${rows} - Values in the "${pos.band.label}" band are considered ${valence} per these guidelines.${caveat} - Name where the value sits and what that band means in plain words. Judge any trend BY this position: a shift deep inside a favourable band is a footnote; the same shift approaching or crossing a boundary is the headline. Frame consequence without diagnosis ("values in this range are considered …", never "you have / are at risk of …").`; } return `EINORDNUNGS-KONTEXT (Leitlinien-Bänder — serverseitig berechnet; nennen, NICHT neu rechnen): -- Der aktuelle Wert ${args.value} ${resolved.unit} liegt im Band "${pos.band.label}" und ${proximity}. +- Der aktuelle Wert ${show(args.value)} ${unit} liegt im Band "${pos.band.label}" und ${proximity}. - Leitlinien-Bänder (${resolved.source}; ${direction}): ${rows} - Werte im Band "${pos.band.label}" gelten laut diesen Leitlinien als ${valence}.${caveat} diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 0f7fe5be4..74903b025 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -507,3 +507,134 @@ describe("generateMetricStatus — generation path", () => { expect(result.text).toContain("Steady."); }); }); + +describe("generateMetricStatus — the note is written in the reader's units", () => { + function seed(values: (day: number) => number, days = 120) { + const now = new Date(); + const records: Array<{ value: number; measuredAt: Date }> = []; + for (let day = 0; day < days; day++) { + records.push({ + value: values(day), + measuredAt: new Date(now.getTime() - day * dayMs), + }); + } + vi.mocked(prisma.measurement.count).mockResolvedValue(days as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never); + } + + function snapshotOf(userPrompt: string) { + return JSON.parse(userPrompt.match(/\{[\s\S]*\}/)![0]); + } + + it("states glucose, its band and its series in mmol/L for an mmol/L reader", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "metric", + glucoseUnit: "mmol/L", + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("unit: mmol/L"); + // 70–140 mg/dL is 3.9–7.8 mmol/L. + expect(captured.systemPrompt).toContain("3.9–7.8 mmol/L"); + // The shared safety contract quotes its own fixed thresholds; the metric + // block itself must not carry the canonical band. + expect(captured.systemPrompt).not.toContain("70–140 mg/dL"); + const snapshot = snapshotOf(captured.userPrompt!); + expect(snapshot.metric.unit).toBe("mmol/L"); + expect(snapshot.metric.normalRange).toMatchObject({ low: 3.9, high: 7.8 }); + const recentMeans = snapshot.BLOOD_GLUCOSE.series.recent.map( + (b: { mean: number }) => b.mean, + ); + expect(recentMeans.length).toBeGreaterThan(0); + for (const mean of recentMeans) { + expect(mean).toBeGreaterThan(5); + expect(mean).toBeLessThan(6.5); + } + expect(captured.userPrompt).not.toContain("mg/dL"); + }); + + it("states body temperature in °F for an imperial reader, guideline band included", async () => { + seed((day) => 36.6 + (day % 3) * 0.1); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "imperial", + glucoseUnit: null, + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BODY_TEMPERATURE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("unit: °F"); + expect(captured.systemPrompt).not.toContain("36.1–37.2 °C"); + // The interpretation block classifies in °C but prints in °F. + expect(captured.userPrompt).toContain("INTERPRETATION CONTEXT"); + expect(captured.userPrompt).not.toContain("°C"); + expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("°F"); + }); + + it("writes the no-provider floor in the reader's glucose unit", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: "metric", + glucoseUnit: "mmol/L", + } as never); + vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never); + + const result = await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(result.text).toMatch(/\d\.\d mmol\/L/); + expect(result.text).not.toContain("mg/dL"); + }); + + it("leaves a metric reader on the default units byte-identical", async () => { + seed((day) => 99 + (day % 4) * 3); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + unitPreference: null, + glucoseUnit: null, + } as never); + const captured = { systemPrompt: null, userPrompt: null } as { + systemPrompt: string | null; + userPrompt: string | null; + }; + stubCompletion('{"summary":"Steady."}', captured); + + await generateMetricStatus({ + metric: "BLOOD_GLUCOSE", + userId: "user-1", + locale: "en", + }); + + expect(captured.systemPrompt).toContain("70–140 mg/dL"); + expect(snapshotOf(captured.userPrompt!).metric.unit).toBe("mg/dL"); + }); +}); diff --git a/src/lib/insights/__tests__/unit-change-refresh.test.ts b/src/lib/insights/__tests__/unit-change-refresh.test.ts new file mode 100644 index 000000000..15a0eac27 --- /dev/null +++ b/src/lib/insights/__tests__/unit-change-refresh.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + insightNarrative: { findMany: vi.fn() }, + }, +})); +vi.mock("@/lib/insights/status-invalidation", () => ({ + enqueueStatusRefillForUser: vi.fn(), +})); +vi.mock("@/lib/jobs/period-narrative-shared", () => ({ + enqueueNarrativeWarm: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation"; +import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; +import { refreshTextsAfterUnitChange } from "../unit-change-refresh"; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("refreshTextsAfterUnitChange", () => { + it("refills the status cards in the reader's locale and re-warms each stored narrative", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + locale: "de", + } as never); + vi.mocked(enqueueStatusRefillForUser).mockResolvedValue(12); + vi.mocked(prisma.insightNarrative.findMany).mockResolvedValue([ + { period: "week", locale: "de" }, + { period: "month", locale: "en" }, + { period: "year", locale: "de" }, + { period: "week", locale: "xx" }, + ] as never); + + await refreshTextsAfterUnitChange("user-1"); + + expect(enqueueStatusRefillForUser).toHaveBeenCalledWith("user-1", "de"); + expect(enqueueNarrativeWarm).toHaveBeenCalledTimes(2); + expect(enqueueNarrativeWarm).toHaveBeenCalledWith({ + userId: "user-1", + period: "week", + locale: "de", + }); + expect(enqueueNarrativeWarm).toHaveBeenCalledWith({ + userId: "user-1", + period: "month", + locale: "en", + }); + }); + + it("never throws into the preference write", async () => { + vi.mocked(prisma.user.findUnique).mockRejectedValue(new Error("db down")); + await expect(refreshTextsAfterUnitChange("user-1")).resolves.toBe( + undefined, + ); + }); +}); diff --git a/src/lib/insights/graded-series.ts b/src/lib/insights/graded-series.ts index b67298080..73e1938f8 100644 --- a/src/lib/insights/graded-series.ts +++ b/src/lib/insights/graded-series.ts @@ -598,6 +598,35 @@ export function scaleGradedSeries( }; } +/** + * Re-express a canonical graded series in a display unit: absolute bucket + * values (`min / max / mean`) take the full affine conversion, the yearly + * slope — a rate, not a level — takes the factor alone, exactly as + * `applyDisplayTransformDelta` treats a delta. Bucket keys and counts are + * untouched. Used where a series is narrated in the reader's unit; the + * series it is built from stays canonical. + */ +export function convertGradedSeries( + series: GradedSeries, + conversion: { factor: number; offset?: number }, + digits = 2, +): GradedSeries { + const offset = conversion.offset ?? 0; + const abs = (v: number) => round(v * conversion.factor + offset, digits); + const bucket = ( + b: T, + ): T => ({ ...b, min: abs(b.min), max: abs(b.max), mean: abs(b.mean) }); + return { + recent: series.recent.map(bucket), + weekly: series.weekly.map(bucket), + monthly: series.monthly.map(bucket), + yearly: series.yearly.map((b) => ({ + ...bucket(b), + slope: b.slope === null ? null : round(b.slope * conversion.factor, 4), + })), + }; +} + /** * Assembled-snapshot soft char cap, mirroring the Coach's * `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index a7d922cf3..b937b689c 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -58,7 +58,9 @@ import { applyPayloadBudget } from "@/lib/insights/bucket-series"; import { buildGradedSeriesFromPoints, buildGradedSeriesWithRollups, + convertGradedSeries, degradeStatusSnapshotToBudget, + type GradedSeries, } from "@/lib/insights/graded-series"; import { type SupportedLocale, @@ -84,6 +86,16 @@ import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback"; import { annotate } from "@/lib/logging/context"; import { binaryReferenceSex } from "@/lib/profile/sex"; +import { + applyDisplayTransform, + getReadingTransform, + invertDisplayTransform, + isUnitSensitiveType, + resolveUnitPreferences, + transformRescales, + type DisplayTransform, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; export interface MetricStatusResult { hasProvider: boolean; @@ -222,9 +234,40 @@ export async function generateMetricStatus(args: { const user = await prisma.user.findUnique({ where: { id: args.userId }, - select: { dateOfBirth: true, gender: true }, + select: { + dateOfBirth: true, + gender: true, + unitPreference: true, + glucoseUnit: true, + }, }); + // The note is written in the reader's units. The series, the reference band + // and the signal are converted once, here, before any of them reaches the + // prompt or the deterministic floor; the registry entry, the rollups and + // the guideline classification stay canonical. An untransformed metric (and + // every metric on the default preferences that does not rescale) passes + // through unchanged, so its snapshot hash does not move. + const reading = readingDisplay( + meta, + resolveUnitPreferences({ + unitPreference: user?.unitPreference, + glucoseUnit: user?.glucoseUnit, + }), + ); + const shownMeta: MetricStatusMeta = { + ...meta, + unit: reading.unit, + ...(meta.normalRange + ? { + normalRange: { + low: reading.toDisplay(meta.normalRange.low), + high: reading.toDisplay(meta.normalRange.high), + }, + } + : {}), + }; + const now = new Date(); // Bounded raw read for the derived recent stats; the monthly/yearly @@ -295,7 +338,10 @@ export async function generateMetricStatus(args: { }) .then((r) => r.reverse()); for (const m of rows) { - points.push({ measuredAt: m.measuredAt, value: m.value }); + points.push({ + measuredAt: m.measuredAt, + value: reading.toDisplay(m.value), + }); } measurements = rows.map((m) => ({ measuredAt: m.measuredAt })); } @@ -309,11 +355,13 @@ export async function generateMetricStatus(args: { // recent/weekly/monthly/yearly bucket is a night total, not a stage sum. const graded = isSleep ? buildGradedSeriesFromPoints(points, now, userTz) - : await buildGradedSeriesWithRollups( - args.userId, - meta.measurementType, - now, - userTz, + : reading.convertGraded( + await buildGradedSeriesWithRollups( + args.userId, + meta.measurementType, + now, + userTz, + ), ); const summary = summarizeSeries( series.daily.map((bucket) => ({ value: bucket.value })), @@ -344,7 +392,14 @@ export async function generateMetricStatus(args: { // metric + profile; otherwise keep the existing flat anchor (strictly // additive, never a regression). Age + sex only — no ancestry/region. const sharpenedRange = lookupNormalRange(meta.id, ageYears, sex); - const normalRange = sharpenedRange ?? meta.normalRange; + const canonicalRange = sharpenedRange ?? meta.normalRange; + const normalRange = canonicalRange + ? { + ...canonicalRange, + low: reading.toDisplay(canonicalRange.low), + high: reading.toDisplay(canonicalRange.high), + } + : canonicalRange; // v1.13.x — the SIGNAL block: the recent-vs-baseline comparison + the // normal-swing verdict, computed server-side so the model states it rather @@ -353,7 +408,7 @@ export async function generateMetricStatus(args: { // when the recent window is empty, in which case it is omitted. const signal = buildMetricSignal({ metric: meta.displayName, - unit: meta.unit, + unit: shownMeta.unit, direction: meta.direction, graded, normalRange: normalRange ?? null, @@ -369,7 +424,7 @@ export async function generateMetricStatus(args: { metric: { id: meta.id, displayName: meta.displayName, - unit: meta.unit, + unit: shownMeta.unit, direction: meta.direction, ...(normalRange ? { normalRange } : {}), ...(sharpenedRange ? { normalRangeSource: "age-sex-adjusted" } : {}), @@ -477,14 +532,24 @@ export async function generateMetricStatus(args: { // window exists); absent for uncovered metrics (fail-soft to personal- // relative). Sex-split bands (waist) resolve only when the profile carries a // sex. + // The guideline bands are canonical, so the band position is classified on + // the canonical value; the block then prints in the reader's unit. const interpretationValue = signal?.current ?? latest?.value ?? null; const interpretationBlock = interpretationValue !== null ? buildInterpretationBlock({ metricKey: meta.id, - value: interpretationValue, + value: reading.toCanonical(interpretationValue), sex, locale, + ...(reading.converts + ? { + display: { + unit: reading.unit, + convert: reading.toDisplay, + }, + } + : {}), }) : undefined; @@ -502,9 +567,9 @@ export async function generateMetricStatus(args: { userId: args.userId, cacheAction, capability: "statusText", - systemPrompt: getMetricArchetypeSystemPrompt(meta, locale), + systemPrompt: getMetricArchetypeSystemPrompt(shownMeta, locale), userPrompt: getMetricArchetypeUserPrompt( - meta, + shownMeta, snapshotJson, todayKey, locale, @@ -526,7 +591,7 @@ export async function generateMetricStatus(args: { if (outcome.kind === "none") { return { hasProvider: false, - text: getNoKeyMetricStatusText(locale, signal), + text: getNoKeyMetricStatusText(locale, signal, reading.decimals), cached: true, updatedAt: null, }; @@ -537,7 +602,7 @@ export async function generateMetricStatus(args: { reason: outcome.kind, userId: args.userId, todayKey, - stubText: getNoKeyMetricStatusText(locale, signal), + stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals), }); } @@ -553,7 +618,7 @@ export async function generateMetricStatus(args: { reason: "screened", userId: args.userId, todayKey, - stubText: getNoKeyMetricStatusText(locale, signal), + stubText: getNoKeyMetricStatusText(locale, signal, reading.decimals), }); } const text = screened.text; @@ -577,3 +642,51 @@ export function resolveMetricStatusLocale( ): SupportedLocale { return normalizeLocale(locale); } + +/** + * How one metric's numbers are written for this reader: the unit symbol, the + * conversions in both directions, and the decimals a value is read at. + * + * Only a unit-sensitive type (`isUnitSensitiveType`: glucose and the + * metric/imperial set) converts; every other metric keeps its registry unit + * and its stored numbers. A transform that does not rescale (`converts` + * false) is the identity, so the default preferences leave a snapshot + * byte-identical for the metrics whose registry unit already is the display + * unit. + */ +function readingDisplay( + meta: MetricStatusMeta, + preferences: UnitPreferences, +): { + unit: string; + decimals: number | undefined; + converts: boolean; + toDisplay: (canonical: number) => number; + toCanonical: (display: number) => number; + convertGraded: (series: GradedSeries) => GradedSeries; +} { + if (!isUnitSensitiveType(meta.measurementType)) { + return { + unit: meta.unit, + decimals: undefined, + converts: false, + toDisplay: (v) => v, + toCanonical: (v) => v, + convertGraded: (series) => series, + }; + } + const transform: DisplayTransform = getReadingTransform( + meta.measurementType, + preferences, + ); + const converts = transform.factor !== 1 || (transform.offset ?? 0) !== 0; + return { + unit: transform.displayUnit, + decimals: transform.decimals, + converts, + toDisplay: (v) => applyDisplayTransform(v, transform), + toCanonical: (v) => invertDisplayTransform(v, transform), + convertGraded: (series) => + converts ? convertGradedSeries(series, transform) : series, + }; +} diff --git a/src/lib/insights/no-key-fallbacks.ts b/src/lib/insights/no-key-fallbacks.ts index 7be0863dd..fd54f0cf3 100644 --- a/src/lib/insights/no-key-fallbacks.ts +++ b/src/lib/insights/no-key-fallbacks.ts @@ -439,6 +439,12 @@ const GENERIC_METRIC_POINTER: Localized = { export function getNoKeyMetricStatusText( locale: InsightLocale, signal?: MetricSignal | null, + /** + * Decimals the signal's unit is read at (0 for mg/dL, 1 for mmol/L or + * pounds). Defaults to one, which was the only precision before the + * signal could arrive in the reader's unit. + */ + digits = 1, ): string { const grounded = signal && Number.isFinite(signal.current) @@ -449,7 +455,7 @@ export function getNoKeyMetricStatusText( // localised upstream, so it stands for every locale. label: { en: signal.metric }, ...(signal.unit ? { unit: ` ${signal.unit}` } : {}), - digits: 1, + digits, pointer: GENERIC_METRIC_POINTER, }, locale, diff --git a/src/lib/insights/unit-change-refresh.ts b/src/lib/insights/unit-change-refresh.ts new file mode 100644 index 000000000..de820ceed --- /dev/null +++ b/src/lib/insights/unit-change-refresh.ts @@ -0,0 +1,63 @@ +/** + * Re-warm the stored texts that quote numbers after a unit preference changes. + * + * A status note and a period narrative are written once and served all day + * (the narrative for up to twenty hours). Both now state their figures in the + * reader's units, so a note written before a switch from mg/dL to mmol/L — or + * from metric to imperial — keeps saying the old unit until something + * regenerates it. Nothing did: neither preference write touched the stored + * text. + * + * The refill goes through the same hash-gated path as the manual regenerate: + * every card the record has is enqueued, the worker forces a fresh snapshot, + * and a card whose snapshot did not change (a metric that does not depend on + * the preference) gets a timestamp refresh and no model call. Narratives are + * re-warmed only where one already exists. Best-effort and fire-and-forget: + * the preference write never waits on it and never fails because of it. + */ +import { prisma } from "@/lib/db"; +import { enqueueNarrativeWarm } from "@/lib/jobs/period-narrative-shared"; +import { enqueueStatusRefillForUser } from "@/lib/insights/status-invalidation"; +import { normalizeLocale } from "@/lib/insights/status-shared"; +import { locales, type Locale } from "@/lib/i18n/config"; +import type { NarrativePeriod } from "@/lib/insights/narrative/period-narrative"; +import { annotate } from "@/lib/logging/context"; + +export async function refreshTextsAfterUnitChange( + userId: string, +): Promise { + try { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const statusScopes = await enqueueStatusRefillForUser( + userId, + normalizeLocale(user?.locale), + ); + + const narratives = await prisma.insightNarrative.findMany({ + where: { userId }, + select: { period: true, locale: true }, + }); + let narrativeCount = 0; + for (const row of narratives) { + if (row.period !== "week" && row.period !== "month") continue; + if (!(locales as readonly string[]).includes(row.locale)) continue; + void enqueueNarrativeWarm({ + userId, + period: row.period as NarrativePeriod, + locale: row.locale as Locale, + }); + narrativeCount++; + } + + annotate({ + action: { name: "insights.unit-change.refresh" }, + meta: { status_scopes: statusScopes, narratives: narrativeCount }, + }); + } catch { + // Best-effort: the nightly warm and the next ingest still converge the + // texts; a failed enqueue must not surface on the preference write. + } +} From e7fdcc208ea233adbf43dbd5d01f258901f70470 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:54:20 +0200 Subject: [PATCH 07/29] test(targets): pin the route over the folded glucose and heart-rate reads The route test mocked Prisma and asserted a raw glucose findMany. The glucose and resting-pulse inputs now come from their SQL readers, so the test mocks those, feeds the pinned response through the in-memory fold, and asserts that neither stream is read as rows. --- .../insights/targets/__tests__/route.test.ts | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index 19b0ca3e4..3fe45c806 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -30,6 +30,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +// The glucose and heart-rate streams fold in SQL; this file pins the route's +// composition over Prisma mocks, so both readers are mocked at the module. +vi.mock("@/lib/targets/glucose-read", async (importOriginal) => ({ + ...(await importOriginal()), + readGlucoseTargetSummaries: vi.fn(), +})); +vi.mock("@/lib/analytics/resting-pulse-read", () => ({ + readRestingPulseProxy: vi.fn(), +})); + vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique: vi.fn() }, @@ -84,6 +94,11 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { + foldGlucoseTargetRows, + readGlucoseTargetSummaries, +} from "@/lib/targets/glucose-read"; +import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; const SESSION_USER = { id: "user-targets-1", @@ -153,6 +168,8 @@ beforeEach(() => { // v1.28.25 — the latest-ever-per-type read is a raw `DISTINCT ON` // (Prisma's `distinct` dedups client-side after pulling every row). (prisma.$queryRawUnsafe as ReturnType).mockResolvedValue([]); + vi.mocked(readGlucoseTargetSummaries).mockResolvedValue([]); + vi.mocked(readRestingPulseProxy).mockResolvedValue([]); }); describe("GET /api/insights/targets — mood-rollup tier swap", () => { @@ -338,16 +355,26 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { it("bounds the glucose read with the same one-year floor (v1.16.8)", async () => { // The glucose section used to scan the user's entire BLOOD_GLUCOSE // history on every cold build (no `measuredAt` filter at all) just - // to read the latest value per context. The read now carries the - // same 365-day floor as the latest-ever-per-type query. + // to read the latest value per context. It now folds in SQL, newest + // per context over a year and per day over thirty days, and no glucose + // or heart-rate reading is read as a row. await callGet(makeReq()); const calls = ( prisma.measurement.findMany as ReturnType ).mock.calls.map((c) => c[0]); - const glucoseCall = calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE"); - expect(glucoseCall).toBeDefined(); - expect(glucoseCall?.where?.measuredAt?.gte).toBeInstanceOf(Date); + expect( + calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE"), + ).toBeUndefined(); + expect( + calls.filter((c) => c?.where?.type?.in?.includes?.("PULSE")), + ).toEqual([]); + const [args] = vi.mocked(readGlucoseTargetSummaries).mock.calls[0]; + const day = 86_400_000; + expect(Date.now() - args.since.getTime()).toBeGreaterThan(364 * day); + expect(Date.now() - args.since.getTime()).toBeLessThan(366 * day); + expect(Date.now() - args.recentSince.getTime()).toBeGreaterThan(29 * day); + expect(Date.now() - args.recentSince.getTime()).toBeLessThan(31 * day); }); it("pins the complete ordered public response for representative populated data", async () => { @@ -408,10 +435,20 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => { ).mockImplementation( async (args: { where?: { type?: string | { in: string[] } } }) => { if (args.where?.type === "SLEEP_DURATION") return sleepRows; - if (args.where?.type === "BLOOD_GLUCOSE") return glucoseRows; return genericRows; }, ); + vi.mocked(readGlucoseTargetSummaries).mockImplementation( + async ({ recentSince, timeZone }) => + foldGlucoseTargetRows( + glucoseRows.map((row) => ({ + ...row, + glucoseContext: row.glucoseContext as "FASTING", + })), + recentSince, + timeZone, + ), + ); (prisma.$queryRawUnsafe as ReturnType).mockResolvedValue([ { type: "WEIGHT", value: 70 }, { type: "BLOOD_PRESSURE_SYS", value: 125 }, From 10588597301ecca47d0473c8586bb5f44be15cc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:55:19 +0200 Subject: [PATCH 08/29] fix(narrative): narrate period changes in the reader's units The period narrative pinned kilograms and mg/dL on its change list, and its personal-range lines carried no unit at all. Every delta and band edge is now stated in the reader's units (computed canonically, converted at the end, percent left unit-free), and each band transition carries its unit into the prompt. The sleep channel, a per-night total in minutes, was labelled hours and is now converted to them. Refs #1067 --- .../__tests__/period-narrative-db.test.ts | 8 +- .../period-narrative-deterministic.test.ts | 1 + .../__tests__/period-narrative.test.ts | 117 +++++++++++++++ .../narrative/period-narrative-generate.ts | 3 +- .../insights/narrative/period-narrative.ts | 134 ++++++++++++++---- 5 files changed, 234 insertions(+), 29 deletions(-) diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index b742c12ac..29462480f 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -225,9 +225,11 @@ describe("buildPeriodNarrativeContext — grain (QA F3)", () => { expect(sleepDelta).toBeDefined(); // The pre-fix local `toDailyMeans` twin would have averaged the three // segment durations: (240 + 90 + 80) / 3 ≈ 137 — a number with no - // clinical meaning. The correct grain is the night's TOTAL (410). - expect(sleepDelta!.current).toBe(410); - expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3, 0); + // clinical meaning. The correct grain is the night's TOTAL (410 min), + // narrated in the hours its unit names: this used to read "410 h". + expect(sleepDelta!.unit).toBe("h"); + expect(sleepDelta!.current).toBe(6.83); + expect(sleepDelta!.current).not.toBeCloseTo((240 + 90 + 80) / 3 / 60, 1); }); it("orders the measurement read DESC + cap so the CURRENT period survives a capped window (QA F2)", async () => { diff --git a/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts index 3eae6875d..af39d5e72 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-deterministic.test.ts @@ -113,6 +113,7 @@ describe("buildDeterministicNarrative", () => { bandTransitions: [ { type: "BLOOD_PRESSURE_SYS", + unit: "mmHg", center: 140, bandLow: 110, bandHigh: 130, diff --git a/src/lib/insights/narrative/__tests__/period-narrative.test.ts b/src/lib/insights/narrative/__tests__/period-narrative.test.ts index 292aa268c..99e3c7e68 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative.test.ts @@ -9,6 +9,9 @@ import type { DailySeriesPoint, NamedSeries, } from "@/lib/insights/correlation-discovery"; +import { DEFAULT_UNIT_PREFERENCES } from "@/lib/measurements/display-transform"; +import { buildDeterministicNarrative } from "../period-narrative-deterministic"; +import { buildNarrativeUserPrompt } from "../period-narrative-generate"; /** * Build a contiguous daily series ending at `endDay` (YYYY-MM-DD), one point @@ -42,6 +45,7 @@ function monthInput( discoverySeries, locale: "en", computedAt: "2026-04-30T12:00:00.000Z", + units: DEFAULT_UNIT_PREFERENCES, }; } @@ -122,6 +126,119 @@ describe("assemblePeriodNarrativeContext — metric deltas", () => { }); }); +describe("assemblePeriodNarrativeContext — the reader's units", () => { + function glucoseAndWeight() { + const m = new Map(); + // Prior 30 days at 90 mg/dL / 80 kg, current 30 at 108 mg/dL / 82 kg. + m.set( + "BLOOD_GLUCOSE", + seriesEndingAt( + [...Array(30).fill(90), ...Array(30).fill(108)], + "2026-04-30", + ), + ); + m.set( + "WEIGHT", + seriesEndingAt( + [...Array(30).fill(80), ...Array(30).fill(82)], + "2026-04-30", + ), + ); + return m; + } + + it("narrates glucose in mmol/L and weight in pounds when the reader chose them", () => { + const ctx = assertReady( + assemblePeriodNarrativeContext({ + ...monthInput(glucoseAndWeight()), + units: { system: "imperial", glucoseUnit: "mmol/L" }, + }), + ); + const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!; + expect(g.unit).toBe("mmol/L"); + expect(g.current).toBe(5.99); + expect(g.prior).toBe(4.99); + expect(g.delta).toBe(1); + // Percent is unit-free. + expect(g.deltaPercent).toBe(20); + const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT")!; + expect(w.unit).toBe("lb"); + expect(w.current).toBe(180.78); + expect(w.delta).toBe(4.41); + + const prose = buildDeterministicNarrative(ctx, "en"); + expect(prose).toContain("mmol/L"); + expect(prose).toContain("lb"); + expect(prose).not.toContain("mg/dL"); + expect(prose).not.toContain("kg"); + const prompt = buildNarrativeUserPrompt(ctx, "en"); + expect(prompt).toContain("BLOOD_GLUCOSE: 5.99 mmol/L"); + expect(prompt).not.toContain("mg/dL"); + }); + + it("keeps mg/dL and kilograms on the default preferences", () => { + const ctx = assertReady( + assemblePeriodNarrativeContext(monthInput(glucoseAndWeight())), + ); + const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!; + expect(g.unit).toBe("mg/dL"); + expect(g.current).toBe(108); + expect(ctx.metricDeltas.find((d) => d.type === "WEIGHT")!.unit).toBe("kg"); + }); + + it("narrates sleep minutes as hours, the unit it always claimed", () => { + const m = new Map(); + m.set( + "SLEEP_DURATION", + seriesEndingAt( + [...Array(30).fill(420), ...Array(30).fill(450)], + "2026-04-30", + ), + ); + m.set( + "WEIGHT", + seriesEndingAt( + [...Array(30).fill(80), ...Array(30).fill(82)], + "2026-04-30", + ), + ); + const ctx = assertReady(assemblePeriodNarrativeContext(monthInput(m))); + const s = ctx.metricDeltas.find((d) => d.type === "SLEEP_DURATION")!; + expect(s.unit).toBe("h"); + expect(s.current).toBe(7.5); + expect(s.delta).toBe(0.5); + }); + + it("states a band transition with its unit, converted", () => { + const m = new Map(); + const prior = Array.from({ length: 30 }, (_, i) => 90 + (i % 3)); + m.set( + "BLOOD_GLUCOSE", + seriesEndingAt([...prior, ...Array(30).fill(140)], "2026-04-30"), + ); + m.set( + "WEIGHT", + seriesEndingAt( + [...Array(30).fill(80), ...Array(30).fill(82)], + "2026-04-30", + ), + ); + const ctx = assertReady( + assemblePeriodNarrativeContext({ + ...monthInput(m), + units: { system: "metric", glucoseUnit: "mmol/L" }, + }), + ); + const t = ctx.bandTransitions.find((b) => b.type === "BLOOD_GLUCOSE")!; + expect(t.unit).toBe("mmol/L"); + expect(t.center).toBe(7.77); + expect(t.movedOut).toBe(true); + expect(buildNarrativeUserPrompt(ctx, "en")).toContain( + "BLOOD_GLUCOSE: 7.77 mmol/L", + ); + }); +}); + describe("assemblePeriodNarrativeContext — band transitions", () => { it("flags a vital whose current center moved above its prior band", () => { const m = new Map(); diff --git a/src/lib/insights/narrative/period-narrative-generate.ts b/src/lib/insights/narrative/period-narrative-generate.ts index 0d50e9dc3..22a33b2fc 100644 --- a/src/lib/insights/narrative/period-narrative-generate.ts +++ b/src/lib/insights/narrative/period-narrative-generate.ts @@ -259,8 +259,9 @@ export function buildNarrativeUserPrompt( : locale === "de" ? "im Bereich" : "in range"; + const unit = b.unit ? ` ${b.unit}` : ""; lines.push( - `- ${b.type}: ${b.center} (${b.bandLow}–${b.bandHigh}) → ${where}`, + `- ${b.type}: ${b.center}${unit} (${b.bandLow}–${b.bandHigh}${unit}) → ${where}`, ); } } diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index c5cab2167..c03deab01 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -56,6 +56,13 @@ import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry"; import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; import { resolveModuleMap } from "@/lib/modules/gate"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { + applyDisplayTransformUnrounded, + getReadingTransform, + isUnitSensitiveType, + resolveUnitPreferences, + type UnitPreferences, +} from "@/lib/measurements/display-transform"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -74,20 +81,78 @@ export const MIN_COVERED_DAYS_PER_METRIC = 3; /** A band must rest on at least this many prior-period days to be trusted. */ export const MIN_BASELINE_DAYS = 7; -/** The metrics the period-delta beat scans, with their display unit. */ -const DELTA_METRICS: Array<{ type: MeasurementType; unit: string }> = [ - { type: "WEIGHT", unit: "kg" }, - { type: "BLOOD_PRESSURE_SYS", unit: "mmHg" }, - { type: "BLOOD_PRESSURE_DIA", unit: "mmHg" }, - { type: "PULSE", unit: "bpm" }, - { type: "RESTING_HEART_RATE", unit: "bpm" }, - { type: "HEART_RATE_VARIABILITY", unit: "ms" }, - { type: "SLEEP_DURATION", unit: "h" }, - { type: "ACTIVITY_STEPS", unit: "" }, - { type: "BODY_FAT", unit: "%" }, - { type: "BLOOD_GLUCOSE", unit: "mg/dL" }, +/** + * The metrics the period-delta beat scans. Their unit is not listed here: a + * reading is narrated in the reader's unit (see `narratedUnit`), and the list + * used to pin kilograms and mg/dL for everyone. + */ +const DELTA_METRICS: MeasurementType[] = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + "BODY_FAT", + "BLOOD_GLUCOSE", ]; +/** + * Unit symbols of the narrated types whose unit no preference changes. The + * sleep channel is a per-night total in minutes and is narrated in hours. + */ +const FIXED_NARRATED_UNITS: Partial> = { + BLOOD_PRESSURE_SYS: "mmHg", + BLOOD_PRESSURE_DIA: "mmHg", + PULSE: "bpm", + RESTING_HEART_RATE: "bpm", + HEART_RATE_VARIABILITY: "ms", + RESPIRATORY_RATE: "breaths/min", + OXYGEN_SATURATION: "%", + SLEEP_DURATION: "h", + ACTIVITY_STEPS: "", + BODY_FAT: "%", +}; + +/** + * How one narrated type is written for this reader: its unit symbol, the + * conversion of an absolute value (a mean, a band edge) and of a difference + * (a delta — factor only, never an affine offset). The computation itself + * stays canonical, so the personal-band floors and the percent change do not + * depend on the unit the reader chose. + */ +function narratedUnit( + type: MeasurementType, + units: UnitPreferences, +): { + unit: string; + abs: (canonical: number) => number; + delta: (canonical: number) => number; +} { + if (isUnitSensitiveType(type)) { + const transform = getReadingTransform(type, units); + return { + unit: transform.displayUnit, + abs: (v) => round2(applyDisplayTransformUnrounded(v, transform)), + delta: (v) => round2(v * transform.factor), + }; + } + if (type === "SLEEP_DURATION") { + return { + unit: "h", + abs: (v) => round2(v / 60), + delta: (v) => round2(v / 60), + }; + } + return { + unit: FIXED_NARRATED_UNITS[type] ?? "", + abs: round2, + delta: round2, + }; +} + // ── context shape ─────────────────────────────────────────────────────── /** One metric's current-period mean vs the prior period of equal length. */ @@ -114,6 +179,8 @@ export interface MetricDelta { */ export interface BandTransition { type: MeasurementType; + /** Unit of `center` / `bandLow` / `bandHigh`, in the reader's preference. */ + unit: string; /** Current-period robust center (median of per-day means). */ center: number; /** Prior-period band edges. */ @@ -243,6 +310,12 @@ export interface AssembleInput { locale: Locale; /** Compute time (ISO). */ computedAt: string; + /** + * The reader's unit preferences. Required for the same reason the locale + * is: the context carries numbers a page and a prompt print verbatim, and a + * caller that stays quiet must not get kilograms and mg/dL by default. + */ + units: UnitPreferences; } /** @@ -262,12 +335,14 @@ export function assemblePeriodNarrativeContext( discoverySeries, locale, computedAt, + units, } = input; // ── metric deltas (current period vs prior period of equal length) ────── const metricDeltas: MetricDelta[] = []; const metricsWithCoverage: string[] = []; - for (const { type, unit } of DELTA_METRICS) { + for (const type of DELTA_METRICS) { + const shown = narratedUnit(type, units); const points = seriesByMetric.get(type) ?? []; const { current, prior } = splitByPeriod(points, currentFrom, priorFrom); const currentAvg = meanOrNull(current); @@ -280,16 +355,21 @@ export function assemblePeriodNarrativeContext( currentAvg !== null && priorAvg !== null ? round2(currentAvg - priorAvg) : null; + // Percent on the canonical values: it is unit-free for a pure rescale, + // and the one affine unit (°F) would otherwise move it. const deltaPercent = delta !== null && priorAvg !== null && priorAvg !== 0 ? Math.round((delta / Math.abs(priorAvg)) * 1000) / 10 : null; metricDeltas.push({ type, - unit, - current: currentAvg === null ? null : round2(currentAvg), - prior: priorAvg === null ? null : round2(priorAvg), - delta, + unit: shown.unit, + current: currentAvg === null ? null : shown.abs(currentAvg), + prior: priorAvg === null ? null : shown.abs(priorAvg), + delta: + currentAvg !== null && priorAvg !== null + ? shown.delta(currentAvg - priorAvg) + : null, deltaPercent, currentDays: current.length, priorDays: prior.length, @@ -324,11 +404,13 @@ export function assemblePeriodNarrativeContext( const center = median(current); const above = center > band.high; const below = center < band.low; + const shown = narratedUnit(type, units); bandTransitions.push({ type, - center: round2(center), - bandLow: round2(band.low), - bandHigh: round2(band.high), + unit: shown.unit, + center: shown.abs(center), + bandLow: shown.abs(band.low), + bandHigh: shown.abs(band.high), direction: above ? "above" : below ? "below" : "in", movedOut: above || below, baselineDays: prior.length, @@ -479,9 +561,13 @@ export async function buildPeriodNarrativeContext( const profile = await prisma.user.findUnique({ where: { id: userId }, - select: { timezone: true }, + select: { timezone: true, unitPreference: true, glucoseUnit: true }, }); const tz = profile?.timezone ?? DEFAULT_TIMEZONE; + const units = resolveUnitPreferences({ + unitPreference: profile?.unitPreference, + glucoseUnit: profile?.glucoseUnit, + }); const currentFrom = tzDayKey( new Date(now.getTime() - periodDays * MS_PER_DAY), @@ -523,10 +609,7 @@ export async function buildPeriodNarrativeContext( // comment for why it is one and not four. includeMoodFactors: true, extraMeasurementTypes: Array.from( - new Set([ - ...DELTA_METRICS.map((m) => m.type), - ...VITALS_BASELINE_TYPES, - ]), + new Set([...DELTA_METRICS, ...VITALS_BASELINE_TYPES]), ), }); @@ -553,5 +636,6 @@ export async function buildPeriodNarrativeContext( discoverySeries, locale: opts.locale, computedAt: now.toISOString(), + units, }); } From 730e9e701580f2d2ca73675e5326218e80da2ecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:56:19 +0200 Subject: [PATCH 09/29] fix(doctor-report): take the day path for dense heart-rate variability and blood oxygen The report's dense exception covered pulse and glucose. Heart-rate variability and blood oxygen were read raw for any window, and a watch or ring writing them every minute puts six figures of readings into a long report that draws them as one sparkline. Their report sections are a statistics row and a series, the same as pulse, so they take the same rule: beyond 90 days per local day, and inside 90 days per local day once the window holds more than 10 000 of their readings. One grouped count decides each type on its own. Count, minimum, maximum and the latest reading stay exact; the mean is the readings' mean. Glucose keeps its own rule because its clinical panel needs the per-reading counts. The bucket query now takes a list of types instead of two flags. --- .../health-record/__tests__/route.test.ts | 8 ++- .../__tests__/leaf-gating-sweep.test.ts | 1 + src/lib/doctor-report/collect.ts | 64 +++++++++++++----- src/lib/doctor-report/dense-buckets.ts | 31 +++++---- .../integration/dense-series-hrv-spo2.test.ts | 65 +++++++++++++++++++ 5 files changed, 133 insertions(+), 36 deletions(-) diff --git a/src/app/api/export/health-record/__tests__/route.test.ts b/src/app/api/export/health-record/__tests__/route.test.ts index 29d5fe949..87cae0237 100644 --- a/src/app/api/export/health-record/__tests__/route.test.ts +++ b/src/app/api/export/health-record/__tests__/route.test.ts @@ -19,8 +19,12 @@ import { unzipSync } from "fflate"; vi.mock("@/lib/db", () => ({ prisma: { - // `count` sizes the report's pulse window (#1023): a sparse account. - measurement: { findMany: vi.fn(), count: vi.fn(async () => 0) }, + // `groupBy` sizes the report's dense windows (#1023): a sparse account. + measurement: { + findMany: vi.fn(), + count: vi.fn(async () => 0), + groupBy: vi.fn(async () => []), + }, medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, diff --git a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts index c279ed081..3d94b9e17 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -49,6 +49,7 @@ const { calls, rows, prismaMock } = vi.hoisted(() => { return Promise.resolve(rows[name] ?? []); }, count: () => Promise.resolve(0), + groupBy: () => Promise.resolve([]), findUnique: () => { calls.push(name); return Promise.resolve(rows[`${name}Unique`]?.[0] ?? null); diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index fe2154b0a..d991edbac 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -77,8 +77,21 @@ import { const DENSE_REPORT_RAW_WINDOW_DAYS = 90; /** - * #1023 — inside the raw window, pulse takes the per-day path anyway once the - * window holds more readings than this. + * The types a device writes at sampling rate whose report sections are + * statistics and a series and nothing else: the figures the day path keeps + * exactly (count, mean, minimum, maximum, latest) plus one point per local + * day. Glucose is dense too but has its own rule below, because its clinical + * panel needs the per-reading counts the bucket query carries. + */ +const DENSE_STAT_TYPES = [ + "PULSE", + "HEART_RATE_VARIABILITY", + "OXYGEN_SATURATION", +] as const satisfies readonly MeasurementType[]; + +/** + * #1023 — inside the raw window, a dense statistics type takes the per-day + * path anyway once the window holds more of its readings than this. * * A watch that records heart rate once a minute puts 130 000 readings in a * 90-day report. Read raw, every one of them became an object, went through @@ -170,22 +183,38 @@ export async function collectDoctorReportData( const lastDay = new Date(`${userDayKey(end, reportTz)}T00:00:00.000Z`); const aggregateDenseTypes = days > DENSE_REPORT_RAW_WINDOW_DAYS; - const densePulse = - !excluded.includes("PULSE") && - (aggregateDenseTypes || - (await prisma.measurement.count({ - where: { - userId, - type: "PULSE", - measuredAt: { gte: start, lte: end }, - deletedAt: null, - }, - })) > DENSE_REPORT_RAW_ROW_CAP); + const includedDenseStatTypes = DENSE_STAT_TYPES.filter( + (type) => !excluded.includes(type), + ); + // Inside the raw window, one grouped count decides each type on its own: + // a watch's minute-by-minute heart rate folds while a few blood-oxygen + // readings a night beside it stay raw. + const denseStatCounts = + aggregateDenseTypes || includedDenseStatTypes.length === 0 + ? null + : new Map( + ( + await prisma.measurement.groupBy({ + by: ["type"], + where: { + userId, + type: { in: [...includedDenseStatTypes] }, + measuredAt: { gte: start, lte: end }, + deletedAt: null, + }, + _count: { _all: true }, + }) + ).map((row) => [row.type, row._count._all]), + ); + const denseTypes: MeasurementType[] = includedDenseStatTypes.filter( + (type) => + denseStatCounts === null || + (denseStatCounts.get(type) ?? 0) > DENSE_REPORT_RAW_ROW_CAP, + ); const denseGlucose = aggregateDenseTypes && !excluded.includes("BLOOD_GLUCOSE"); - const rawExcluded = [...excluded]; - if (densePulse) rawExcluded.push("PULSE"); - if (denseGlucose) rawExcluded.push("BLOOD_GLUCOSE"); + if (denseGlucose) denseTypes.push("BLOOD_GLUCOSE"); + const rawExcluded = [...excluded, ...denseTypes]; const wantsMedications = MEDICATION_LEAVES.some((leaf) => gate.admits(leaf)); const wantsIntakeEvents = @@ -318,8 +347,7 @@ export async function collectDoctorReportData( start, end, reportTz, - densePulse, - denseGlucose, + denseTypes, }), ]); diff --git a/src/lib/doctor-report/dense-buckets.ts b/src/lib/doctor-report/dense-buckets.ts index 597b55349..bd89d4dc1 100644 --- a/src/lib/doctor-report/dense-buckets.ts +++ b/src/lib/doctor-report/dense-buckets.ts @@ -1,16 +1,17 @@ /** - * The per-local-day pre-aggregation for the two sample-dense measurement - * types. + * The per-local-day pre-aggregation for the sample-dense measurement types. * * On a long window a CGM or a per-sample heart-rate account produces six - * figures of rows, so pulse and glucose collapse to one row per local - * day/source/device/context in SQL and the canonical-source picker then works - * over that bounded intermediate set. Every other type keeps the raw-row path. + * figures of rows, so the dense types (glucose, pulse, heart-rate variability, + * blood oxygen) collapse to one row per local day/source/device/context in + * SQL and the canonical-source picker then works over that bounded + * intermediate set. Every other type keeps the raw-row path. * * Extracted from the aggregator with the selection rework: the query is a job * of its own, it is the one raw-SQL surface on this path, and keeping it in a * thousand-line function is how a parameter stops being reviewed. */ +import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import type { GlucoseContext, @@ -46,8 +47,8 @@ export interface DenseMeasurementBucket { } /** - * Load the dense buckets for whichever of the two types the caller wants. Both - * flags false is not a call: the caller skips it entirely. + * Load the dense buckets for the types the caller names. An empty list is not + * a call: nothing is read. * * Every value is bound as a tagged-template parameter — no splice, no * interpolation of anything that came off the wire. @@ -57,11 +58,13 @@ export async function loadDenseMeasurementBuckets(params: { start: Date; end: Date; reportTz: string; - densePulse: boolean; - denseGlucose: boolean; + denseTypes: readonly MeasurementType[]; }): Promise { - const { userId, start, end, reportTz, densePulse, denseGlucose } = params; - if (!densePulse && !denseGlucose) return []; + const { userId, start, end, reportTz, denseTypes } = params; + if (denseTypes.length === 0) return []; + const typeList = Prisma.join( + denseTypes.map((type) => Prisma.sql`${type}::"measurement_type"`), + ); return prisma.$queryRaw` WITH localized AS ( SELECT @@ -81,11 +84,7 @@ export async function loadDenseMeasurementBuckets(params: { AND m."measured_at" >= ${start} AND m."measured_at" <= ${end} AND m."deleted_at" IS NULL - AND ( - (${densePulse} AND m."type" = 'PULSE'::"measurement_type") - OR - (${denseGlucose} AND m."type" = 'BLOOD_GLUCOSE'::"measurement_type") - ) + AND m."type" IN (${typeList}) ), scored AS ( SELECT diff --git a/tests/integration/dense-series-hrv-spo2.test.ts b/tests/integration/dense-series-hrv-spo2.test.ts index e60c70647..0145a4ae2 100644 --- a/tests/integration/dense-series-hrv-spo2.test.ts +++ b/tests/integration/dense-series-hrv-spo2.test.ts @@ -9,12 +9,19 @@ * per local hour. A sparse account inside 90 days keeps its raw readings, * byte for byte, and a bucket has the shape of a reading of its kind (no * `valueMin` / `valueMax`, which only pulse carries). + * + * The doctor report applies the same rule to both: their sections are a + * statistics row and a series, so a dense window takes the per-day path pulse + * already takes, with exact count, minimum, maximum and latest reading. */ import { NextRequest } from "next/server"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +import { collectDoctorReportData } from "@/lib/doctor-report-data"; +import { latestReading } from "@/lib/fhir/resources/common"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; import { invalidateUserTimezone } from "@/lib/tz/resolver"; vi.mock("next/headers", async () => { @@ -173,3 +180,61 @@ describe.each(KINDS)("$kind series", ({ kind, type, unit }) => { ); }); }); + +describe.each(KINDS)("doctor report $type", ({ type }) => { + async function report(userId: string, days: number) { + const end = new Date(); + const start = new Date(end.getTime() - days * 86_400_000); + return collectDoctorReportData( + userId, + { start, end, days }, + selectionFromLeaves([type]), + ); + } + + it("folds a dense window to one point per day, statistics exact", async () => { + const data = await report(DENSE, 30); + const points = data.measurements[type] ?? []; + expect(points.length).toBeLessThanOrEqual(14); + expect(points.length).toBeGreaterThanOrEqual(12); + const agg = await prisma.measurement.aggregate({ + where: { userId: DENSE, type }, + _count: true, + _avg: { value: true }, + _min: { value: true }, + _max: { value: true }, + }); + expect(data.stats[type]?.count).toBe(agg._count); + expect(data.stats[type]?.min).toBe(agg._min.value); + expect(data.stats[type]?.max).toBe(agg._max.value); + expect(data.stats[type]?.avg).toBeCloseTo(agg._avg.value ?? 0, 9); + const newest = await prisma.measurement.findFirstOrThrow({ + where: { userId: DENSE, type }, + orderBy: { measuredAt: "desc" }, + select: { value: true, measuredAt: true }, + }); + expect(latestReading(data, type)).toEqual({ + value: newest.value, + measuredAt: newest.measuredAt.toISOString(), + }); + }); + + it("keeps a sparse window raw", async () => { + const data = await report(SPARSE, 30); + const count = await prisma.measurement.count({ + where: { + userId: SPARSE, + type, + measuredAt: { gte: new Date(Date.now() - 30 * 86_400_000) }, + }, + }); + expect(data.measurements[type]).toHaveLength(count); + expect(data.stats[type]?.count).toBe(count); + }); + + it("folds a window longer than 90 days per day", async () => { + const data = await report(SPARSE, 120); + expect(data.stats[type]?.count).toBe(45 * 3); + expect((data.measurements[type] ?? []).length).toBeLessThanOrEqual(46); + }); +}); From d0a9086538884540fe06360423b45c76113a70d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:58:24 +0200 Subject: [PATCH 10/29] fix(targets): edit glucose thresholds in the reader's glucose unit The settings threshold editor took the identity path for the glucose thresholds, so an account on mmol/L was shown a mg/dL default and asked for mg/dL figures against 40-400 bounds, while the insights sheet for the same threshold already spoke mmol/L. The shared target adapter now owns the glucose unit as it owns pounds: fields, inward-rounded bounds, the default hint and the canonical write all go through it, and the insights panel and its sheet use the same branch instead of their own conversion. Refs #1067 --- .../insights/metric-target-summary.tsx | 49 ++++----- .../thresholds-editor-glucose-unit.test.tsx | 76 +++++++++++++ .../settings/thresholds-editor-section.tsx | 8 +- src/components/targets/target-edit-sheet.tsx | 65 +++++------ .../__tests__/target-unit-display.test.ts | 101 ++++++++++++++++-- src/lib/targets/target-unit-display.ts | 47 +++++++- 6 files changed, 270 insertions(+), 76 deletions(-) create mode 100644 src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index cfdaa0be4..845b1b8d6 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -8,7 +8,6 @@ import { useAuth } from "@/hooks/use-auth"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; -import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { RangeBar } from "@/components/targets/range-bar"; @@ -18,6 +17,7 @@ import { TileHeader } from "@/components/insights/tile-header"; import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; import { getTargetSourceLink } from "@/lib/targets/source-link"; import { apiGet } from "@/lib/api/api-fetch"; +import { resolveGlucoseUnit, type GlucoseUnit } from "@/lib/glucose"; /** * v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each @@ -152,28 +152,17 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { // mg/dL values to the user's display unit. The route's per-context // labels are i18n keys, so resolve them. if (isGlucose) { - const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null); - const convert = (v: number | null) => - v == null ? null : convertGlucose(v, displayUnit); + // The payload stays canonical mg/dL here; each panel converts through + // the shared target adapter, which owns the glucose unit as it owns + // pounds, so the panel and its edit sheet cannot convert twice. + const glucoseUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null); const panels = GLUCOSE_TARGET_TYPES.map((type) => data.targets.find((entry) => entry.type === type), ) .filter( (entry): entry is TargetItem => entry != null && entry.range != null, ) - .map((entry) => ({ - ...entry, - label: t(entry.label), - unit: displayUnit, - current: convert(entry.current), - average30: convert(entry.average30), - range: entry.range - ? { - min: convertGlucose(entry.range.min, displayUnit), - max: convertGlucose(entry.range.max, displayUnit), - } - : null, - })); + .map((entry) => ({ ...entry, label: t(entry.label) })); if (panels.length === 0) return null; @@ -188,6 +177,7 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { key={panel.type} target={panel} heading={panel.label} + glucoseUnit={glucoseUnit} /> ))} @@ -212,6 +202,12 @@ interface TargetReferencePanelProps { bpDiastolic?: TargetsResponse["bpDiastolic"]; /** Optional sub-heading shown above the panel (used for glucose contexts). */ heading?: string; + /** + * The record's glucose unit as the targets payload resolved it. The + * glucose panels pass it so the band reads in the unit the server + * resolved for this record; every other panel leaves it to the hook. + */ + glucoseUnit?: GlucoseUnit; } /** @@ -232,21 +228,26 @@ function TargetReferencePanel({ target, bpDiastolic, heading, + glucoseUnit: payloadGlucoseUnit, }: TargetReferencePanelProps) { const { t } = useTranslations(); const adjust = useTargetAdjust(); - const { preference } = useUnitDisplay(); + const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay(); + const glucoseUnit = payloadGlucoseUnit ?? accountGlucoseUnit; // v1.32.27 — the panel renders in the user's preferred unit. The // `/api/insights/targets` payload is canonical (kg for weight); the // adapter converts it once, here, and everything downstream — the // range bar, the status pill, the 30-day average, and the edit - // sheet's seed — consumes the converted pair. Glucose contexts are - // pre-converted by the caller above and carry no display transform, - // so they take the adapter's identity path and are never scaled - // twice. For a metric account the adapter is the exact identity, so - // this panel is byte-identical to the previous release. - const units = resolveTargetUnitAdapter(target.type, target.unit, preference); + // sheet's seed — consumes the converted pair. The glucose contexts + // arrive canonical too and convert here to the reader's glucose unit. + // On the default preferences the adapter is the exact identity. + const units = resolveTargetUnitAdapter( + target.type, + target.unit, + preference, + glucoseUnit, + ); const unit = units.unit; const range = target.range ? { diff --git a/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx b/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx new file mode 100644 index 000000000..362d029b6 --- /dev/null +++ b/src/components/settings/__tests__/thresholds-editor-glucose-unit.test.tsx @@ -0,0 +1,76 @@ +/** + * The settings threshold editor reads glucose in the reader's unit. + * + * It used to pass every glucose threshold through the identity path, so an + * mmol/L account was shown "Default: 70–99 mg/dL" and mg/dL input fields + * bounded at 40–400, while the insights sheet for the same threshold already + * spoke mmol/L. The stored value stays canonical mg/dL either way. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const authUser = vi.hoisted(() => ({ + current: { glucoseUnit: "mmol/L", unitPreference: "metric" } as Record< + string, + unknown + >, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: authUser.current, isAuthenticated: true }), + useAccountOnceMounted: () => authUser.current, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + isLoading: false, + data: { + effective: { + BLOOD_GLUCOSE_FASTING: { + range: { greenMin: 72, greenMax: 108 }, + isOverride: true, + default: { greenMin: 70, greenMax: 99 }, + bounds: { min: 40, max: 400, unit: "mg/dL" }, + }, + }, + overrides: { BLOOD_GLUCOSE_FASTING: { min: 72, max: 108 } }, + }, + }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ThresholdsEditorSection } from "../thresholds-editor-section"; + +function render(): string { + return renderToStaticMarkup( + + + , + ); +} + +describe(" glucose unit", () => { + it("states the default, the fields and the bounds in mmol/L", () => { + authUser.current = { glucoseUnit: "mmol/L", unitPreference: "metric" }; + const html = render(); + // 70–99 mg/dL is 3.9–5.5 mmol/L. + expect(html).toMatch(/3\.9–5\.5 mmol\/L/); + expect(html).not.toMatch(/70(\.0)?–99(\.0)? mg\/dL/); + // The stored 72–108 mg/dL override seeds 4–6 mmol/L. + expect(html).toContain('value="4"'); + expect(html).toContain('value="6"'); + // Inward-rounded guardrail of 40–400 mg/dL. + expect(html).toContain('min="2.3"'); + expect(html).toContain('max="22.1"'); + }); + + it("keeps mg/dL for an mg/dL reader", () => { + authUser.current = { glucoseUnit: "mg/dL", unitPreference: "metric" }; + const html = render(); + expect(html).toMatch(/70(\.0)?–99(\.0)? mg\/dL/); + expect(html).toContain('value="72"'); + expect(html).toContain('min="40"'); + }); +}); diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx index 0d638024b..8a50c0fb8 100644 --- a/src/components/settings/thresholds-editor-section.tsx +++ b/src/components/settings/thresholds-editor-section.tsx @@ -237,7 +237,7 @@ function MetricRow({ }: MetricRowProps) { const { t } = useTranslations(); const fmt = useFormatters(); - const { preference } = useUnitDisplay(); + const { preference, glucoseUnit } = useUnitDisplay(); const canonicalBounds = METRIC_BOUNDS[metric]; // v1.32.27 — the row reads and writes in the user's preferred unit @@ -246,12 +246,14 @@ function MetricRow({ // canonical, so it converts on the way into the fields and inverts on // the way back out in `onSave`. Guardrails round inward so a value // typed at the displayed limit still passes the server's canonical - // check. Metrics without a transform — and every metric for a metric - // user — take the adapter's identity path untouched. + // check. The glucose thresholds read in the reader's glucose unit the + // same way. Metrics without a transform — and every metric on the default + // preferences — take the adapter's identity path untouched. const units = resolveTargetUnitAdapter( metric, canonicalBounds.unit, preference, + glucoseUnit, ); const bounds = { ...units.bounds(canonicalBounds), unit: units.unit }; diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx index d3a788d42..bddd162e0 100644 --- a/src/components/targets/target-edit-sheet.tsx +++ b/src/components/targets/target-edit-sheet.tsx @@ -17,8 +17,11 @@ import { type ThresholdMetric, type EffectiveRange, } from "@/lib/analytics/effective-range"; -import { convertGlucose, toCanonicalMgdl } from "@/lib/glucose"; -import { resolveTargetUnitAdapter } from "@/lib/targets/target-unit-display"; +import { + isGlucoseTargetMetric, + resolveTargetUnitAdapter, +} from "@/lib/targets/target-unit-display"; +import { resolveGlucoseUnit } from "@/lib/glucose"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch"; @@ -129,49 +132,33 @@ function TargetEditSheetBody({ const metric = TARGET_TYPE_TO_METRIC[targetType] ?? null; const isDerivedMetric = !isBp && metric == null; - // Glucose is the one metric whose display unit can differ from the - // canonical storage unit: HealthLog stores mg/dL, but a user on the - // mmol/L preference sees — and types — mmol/L. The parent already - // hands us `unit="mmol/L"` and a `initialRange` pre-converted to that - // unit. We must therefore (a) seed the mg/dL persisted override into - // the display unit, (b) validate the typed value against bounds - // expressed in the display unit, and (c) convert the typed value back - // to canonical mg/dL before the PUT — otherwise a `5.5 mmol/L` target - // is rejected by the 40–400 mg/dL bounds or stored verbatim as 5.5. - const isGlucoseMmol = - metric != null && metric.startsWith("BLOOD_GLUCOSE") && unit === "mmol/L"; - - // v1.32.27 — the same coupled treatment now covers the metric/imperial - // preference. Weight-class thresholds are stored in kg; an imperial - // user seeds, validates, and types in lb, and the adapter inverts the - // typed number back to kg before the PUT. Glucose keeps its own - // mg/dL ↔ mmol/L dialect (it carries no display transform, so the two - // never compose); every other metric takes the adapter's identity - // path and is numerically untouched. - const { preference } = useUnitDisplay(); + // One adapter owns every direction of the unit conversion: an imperial + // user seeds, validates and types weight-class thresholds in pounds, an + // mmol/L user types glucose thresholds in mmol/L, and the typed number is + // inverted back to the canonical kg / mg/dL before the PUT. The parent + // hands `unit` and `initialRange` already in the display unit; the + // persisted override and `METRIC_BOUNDS` are canonical and go through the + // adapter. Every other metric takes the identity path. + // A glucose sheet speaks the unit its panel registered, so the sheet and + // the band it edits can never disagree. + const { preference, glucoseUnit: accountGlucoseUnit } = useUnitDisplay(); + const adapterKey = isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? ""); const units = resolveTargetUnitAdapter( - isBp ? "BLOOD_PRESSURE_SYS" : (metric ?? ""), + adapterKey, unit, preference, + isGlucoseTargetMetric(adapterKey) + ? resolveGlucoseUnit(unit) + : accountGlucoseUnit, ); - const toDisplay = (canonical: number) => - isGlucoseMmol - ? convertGlucose(canonical, "mmol/L") - : units.toDisplay(canonical); - const toCanonical = (displayValue: number) => - isGlucoseMmol - ? toCanonicalMgdl(displayValue, "mmol/L") - : units.toCanonical(displayValue); - // Guardrails the typed value is checked against, expressed in the - // display unit. Glucose projects its mg/dL window through the same - // conversion the fields use; every other metric rounds the window - // INWARD so a value typed at the display bound never inverts to a - // canonical value the server rejects. + const toDisplay = (canonical: number) => units.toDisplay(canonical); + const toCanonical = (displayValue: number) => units.toCanonical(displayValue); + // Guardrails the typed value is checked against, expressed in the display + // unit and rounded INWARD so a value typed at the display bound never + // inverts to a canonical value the server rejects. const toDisplayBounds = (canonical: { min: number; max: number }) => - isGlucoseMmol - ? { min: toDisplay(canonical.min), max: toDisplay(canonical.max) } - : units.bounds(canonical); + units.bounds(canonical); // Lazy-load the thresholds payload so the dialog also catches any // already-persisted override even when the seeded `initialRange` diff --git a/src/lib/targets/__tests__/target-unit-display.test.ts b/src/lib/targets/__tests__/target-unit-display.test.ts index da8702179..3d2fa1522 100644 --- a/src/lib/targets/__tests__/target-unit-display.test.ts +++ b/src/lib/targets/__tests__/target-unit-display.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { resolveTargetUnitAdapter } from "../target-unit-display"; +import { toCanonicalMgdl } from "@/lib/glucose"; /** * v1.32.27 — the target/threshold unit adapter. @@ -18,7 +19,7 @@ const KG_PER_LB = 0.45359237; describe("resolveTargetUnitAdapter — metric account (identity)", () => { it("leaves a weight threshold bit-for-bit untouched", () => { - const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric"); + const units = resolveTargetUnitAdapter("WEIGHT", "kg", "metric", "mg/dL"); expect(units.rescales).toBe(false); expect(units.unit).toBe("kg"); // Not "close to" — exactly the same double, in both directions. @@ -37,6 +38,7 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => { "BLOOD_PRESSURE_SYS", "mmHg", preference, + "mg/dL", ); expect(units.rescales).toBe(false); expect(units.unit).toBe("mmHg"); @@ -51,11 +53,14 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => { it("is inert for the keys the edit sheet passes on its non-metric paths", () => { // A derived card (BMI, mood, medication compliance) has no editable - // threshold and the sheet resolves the adapter with an empty key; - // glucose keeps its own mg/dL ↔ mmol/L dialect and must never pick - // up a second conversion on top. - for (const key of ["", "BMI", "BLOOD_GLUCOSE_FASTING"]) { - const units = resolveTargetUnitAdapter(key, "mmol/L", "imperial"); + // threshold and the sheet resolves the adapter with an empty key. + for (const key of ["", "BMI"]) { + const units = resolveTargetUnitAdapter( + key, + "mmol/L", + "imperial", + "mg/dL", + ); expect(units.rescales).toBe(false); expect(units.unit).toBe("mmol/L"); expect(units.toDisplay(5.5)).toBe(5.5); @@ -66,14 +71,15 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => { it("keeps the whole-hundred step for a step-count target", () => { for (const preference of ["metric", "imperial"] as const) { expect( - resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference).step, + resolveTargetUnitAdapter("ACTIVITY_STEPS", "steps", preference, "mg/dL") + .step, ).toBe(100); } }); }); describe("resolveTargetUnitAdapter — imperial weight class", () => { - const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial"); + const units = resolveTargetUnitAdapter("WEIGHT", "kg", "imperial", "mg/dL"); it("announces pounds and a one-decimal step", () => { expect(units.rescales).toBe(true); @@ -145,7 +151,12 @@ describe("resolveTargetUnitAdapter — imperial weight class", () => { it("carries the same treatment across the whole weight class", () => { for (const metric of ["TOTAL_BODY_WATER", "BONE_MASS"] as const) { - const adapter = resolveTargetUnitAdapter(metric, "kg", "imperial"); + const adapter = resolveTargetUnitAdapter( + metric, + "kg", + "imperial", + "mg/dL", + ); expect(adapter.unit).toBe("lb"); const bounds = adapter.bounds(METRIC_BOUNDS[metric]); expect(adapter.toCanonical(bounds.min)).toBeGreaterThanOrEqual( @@ -169,6 +180,7 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => { "BODY_TEMPERATURE", "celsius", "imperial", + "mg/dL", ); it("shifts absolute readings but never a difference", () => { @@ -208,6 +220,7 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => { "BODY_TEMPERATURE", "celsius", "metric", + "mg/dL", ); expect(metricUnits.rescales).toBe(false); expect(metricUnits.toDisplay(37)).toBe(37); @@ -224,3 +237,73 @@ describe("resolveTargetUnitAdapter — temperature (the affine one)", () => { expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual(45); }); }); + +describe("resolveTargetUnitAdapter — glucose thresholds follow the glucose unit", () => { + const contexts = [ + "BLOOD_GLUCOSE_FASTING", + "BLOOD_GLUCOSE_POSTPRANDIAL", + "BLOOD_GLUCOSE_RANDOM", + "BLOOD_GLUCOSE_BEDTIME", + ] as const; + + it("reads mmol/L for an mmol/L reader, whatever the metric/imperial branch", () => { + for (const metric of contexts) { + for (const preference of ["metric", "imperial"] as const) { + const units = resolveTargetUnitAdapter( + metric, + "mg/dL", + preference, + "mmol/L", + ); + expect(units.unit).toBe("mmol/L"); + expect(units.rescales).toBe(true); + expect(units.step).toBe(0.1); + // The 70–99 mg/dL fasting default reads 3.9–5.5. + expect(units.toDisplay(70)).toBe(3.9); + expect(units.toDisplay(99)).toBe(5.5); + } + } + }); + + it("round-trips a typed mmol/L value to whole mg/dL and back", () => { + const units = resolveTargetUnitAdapter( + "BLOOD_GLUCOSE_FASTING", + "mg/dL", + "metric", + "mmol/L", + ); + expect(units.toCanonical(5.5)).toBe(99); + expect(units.toDisplay(units.toCanonical(5.5))).toBe(5.5); + expect(units.toCanonical(3.9)).toBe(toCanonicalMgdl(3.9, "mmol/L")); + }); + + it("rounds the canonical 40–400 mg/dL guardrail inward", () => { + const units = resolveTargetUnitAdapter( + "BLOOD_GLUCOSE_FASTING", + "mg/dL", + "metric", + "mmol/L", + ); + const bounds = units.bounds(METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING); + expect(bounds).toEqual({ min: 2.3, max: 22.1 }); + expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual( + METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING.min, + ); + expect(units.toCanonical(bounds.max)).toBeLessThanOrEqual( + METRIC_BOUNDS.BLOOD_GLUCOSE_FASTING.max, + ); + }); + + it("stays the exact identity for an mg/dL reader", () => { + const units = resolveTargetUnitAdapter( + "BLOOD_GLUCOSE_FASTING", + "mg/dL", + "imperial", + "mg/dL", + ); + expect(units.unit).toBe("mg/dL"); + expect(units.rescales).toBe(false); + expect(units.toDisplay(99)).toBe(99); + expect(units.toCanonical(99)).toBe(99); + }); +}); diff --git a/src/lib/targets/target-unit-display.ts b/src/lib/targets/target-unit-display.ts index a92218e4e..0afb78a7d 100644 --- a/src/lib/targets/target-unit-display.ts +++ b/src/lib/targets/target-unit-display.ts @@ -48,6 +48,12 @@ import { transformRescales, type UnitPreference, } from "@/lib/measurements/display-transform"; +import { + MGDL_PER_MMOL, + convertGlucose, + toCanonicalMgdl, + type GlucoseUnit, +} from "@/lib/glucose"; /** Canonical decimals a converted threshold value persists with. */ const CANONICAL_DECIMALS = 2; @@ -119,6 +125,41 @@ function identityAdapter(metric: string, unit: string): TargetUnitAdapter { }; } +/** + * The glucose thresholds (`BLOOD_GLUCOSE_FASTING` and its siblings) are + * stored in mg/dL and read in the reader's glucose unit. The settings editor + * used to take the identity path for them, so an mmol/L account was asked + * for mg/dL figures under a label that said nothing, against 40–400 bounds + * and a "default: 70–99" hint — while the insights sheet for the same + * threshold already spoke mmol/L. + * + * mmol/L reads at one decimal; the canonical write rounds to whole mg/dL + * (`toCanonicalMgdl`), the precision every glucose path stores. Guardrails + * round inward exactly like the imperial branch: 40 mg/dL is 2.22 mmol/L, + * shown as 2.3 so that the typed bound inverts to 41 mg/dL and passes. + */ +function glucoseAdapter(glucoseUnit: GlucoseUnit): TargetUnitAdapter { + if (glucoseUnit === "mg/dL") return identityAdapter("BLOOD_GLUCOSE", "mg/dL"); + const toMmol = (mgdl: number) => mgdl / MGDL_PER_MMOL; + return { + unit: "mmol/L", + step: 0.1, + rescales: true, + toDisplay: (canonical) => convertGlucose(canonical, "mmol/L"), + toCanonical: (display) => toCanonicalMgdl(display, "mmol/L"), + toDisplayDelta: (canonicalDelta) => roundTo(toMmol(canonicalDelta), 1), + bounds: (canonical) => ({ + min: ceilTo(toMmol(canonical.min), 1), + max: floorTo(toMmol(canonical.max), 1), + }), + }; +} + +/** True for a glucose threshold metric or target-card type. */ +export function isGlucoseTargetMetric(metric: string): boolean { + return metric === "BLOOD_GLUCOSE" || metric.startsWith("BLOOD_GLUCOSE_"); +} + /** * Resolve the unit adapter for one threshold metric / target-card type. * @@ -128,13 +169,17 @@ function identityAdapter(metric: string, unit: string): TargetUnitAdapter { * on, so a metric participates exactly when it has a registered * transform. `canonicalUnit` is the unit the surface announced before * this release (`METRIC_BOUNDS[metric].unit` or the targets payload's - * `unit`) and is what an untransformed metric keeps. + * `unit`) and is what an untransformed metric keeps. `glucoseUnit` is + * the reader's glucose preference; it decides the glucose thresholds and + * nothing else. */ export function resolveTargetUnitAdapter( metric: string, canonicalUnit: string, preference: UnitPreference, + glucoseUnit: GlucoseUnit, ): TargetUnitAdapter { + if (isGlucoseTargetMetric(metric)) return glucoseAdapter(glucoseUnit); if (!hasDisplayTransform(metric)) return identityAdapter(metric, canonicalUnit); From 5f1eb054457036c608b64cceef1fa967b2dbbfcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 08:58:42 +0200 Subject: [PATCH 11/29] fix(measurements): bound the per-night sleep list to a year ending at to The sleep list capped its read to the last year only when no `from` was sent. An explicit early `from` walked every per-stage row back to that date, on a multi-source account several rows per night for years. The read window is now at most a year ending at `to` (or now). `from` narrows it but never widens it, older nights are reached by moving `to` back, and `meta.windowFrom` names the instant a cut window started, so a client can tell a cut from an empty history. --- docs/api/openapi.yaml | 6 ++ src/app/api/measurements/route.ts | 23 ++++++-- src/lib/openapi/routes/measurements.ts | 6 ++ .../measurements-sleep-list.test.ts | 59 ++++++++++++++++++- 4 files changed, 89 insertions(+), 5 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ad690c61f..046d46b73 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -41340,6 +41340,12 @@ components: type: integer minimum: 0 maximum: 9007199254740991 + windowFrom: + description: "Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant + the read started from. The window is a year ending at `to` (or now); page further back by moving `to`." + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - total - limit diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index ffcaafece..278ddb899 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -543,9 +543,18 @@ async function sleepListResponse( loadUserSourcePriority(user.id), ]); - // Bound the read window: honour an explicit from/to, otherwise cap to - // the last year so the per-stage scan stays small. - const since = from ?? new Date(Date.now() - SLEEP_LIST_MAX_DAYS * 86_400_000); + // Bound the read window to a year ending at `to` (or now). An explicit + // `from` narrows it but never widens it: an early `from` used to walk every + // per-stage row back to that date, which on a multi-year, multi-source + // account is the unbounded read this cap exists to stop. Older nights are + // reached by moving `to` back, and `meta.windowFrom` says where a cut + // window started so a client can tell a cut from an empty history. + const windowEnd = to ?? new Date(); + const floor = new Date( + windowEnd.getTime() - SLEEP_LIST_MAX_DAYS * 86_400_000, + ); + const since = from && from > floor ? from : floor; + const windowCut = from !== undefined && from < floor; const rows = await prisma.measurement.findMany({ where: { userId: user.id, @@ -690,7 +699,13 @@ async function sleepListResponse( }); return apiSuccess({ measurements, - meta: { total: totalNights, limit, offset, groupBy: "night" }, + meta: { + total: totalNights, + limit, + offset, + groupBy: "night", + ...(windowCut ? { windowFrom: since.toISOString() } : {}), + }, }); } diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 864baeb4a..dea56ced2 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -389,6 +389,12 @@ const listMeasurementsMeta = z .describe( "Rows discarded by the cross-source canonical picker on the day-collapse mode.", ), + windowFrom: z.iso + .datetime({ offset: true }) + .optional() + .describe( + "Set on the per-night SLEEP_DURATION mode when the requested window was longer than 365 days: the instant the read started from. The window is a year ending at `to` (or now); page further back by moving `to`.", + ), }) .meta({ id: "ListMeasurementsMeta" }); diff --git a/tests/integration/measurements-sleep-list.test.ts b/tests/integration/measurements-sleep-list.test.ts index 9e952c4dc..9524bd854 100644 --- a/tests/integration/measurements-sleep-list.test.ts +++ b/tests/integration/measurements-sleep-list.test.ts @@ -56,7 +56,7 @@ interface ListEnvelope { napCount?: number; awakenings?: number; }>; - meta: { total: number; groupBy?: string }; + meta: { total: number; groupBy?: string; windowFrom?: string }; }; } @@ -160,4 +160,61 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => { const stages = body.data.measurements.map((m) => m.sleepStage).sort(); expect(stages).toEqual(["CORE", "DEEP", "REM"]); }); + + it("reads at most a year ending at `to`, however early `from` is", async () => { + // An explicit early `from` used to walk every per-stage row back to it. + const prisma = getPrismaClient(); + const user = await seedSession("sleep-window-user"); + const day = 86_400_000; + const morning = new Date(); + morning.setUTCHours(4, 0, 0, 0); + // One night of a single CORE stage every day for two years. + await prisma.measurement.createMany({ + data: Array.from({ length: 730 }, (_, i) => ({ + userId: user.id, + type: "SLEEP_DURATION" as const, + value: 420, + unit: "minutes", + source: "APPLE_HEALTH" as const, + measuredAt: new Date(morning.getTime() - i * day), + externalId: `uuid-night-${i}`, + sleepStage: "CORE" as const, + })), + }); + const spy = vi.spyOn(prisma.measurement, "findMany"); + const from = new Date(morning.getTime() - 800 * day).toISOString(); + const res = await callList( + `type=SLEEP_DURATION&from=${encodeURIComponent(from)}&limit=1000`, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as ListEnvelope; + expect(body.data.meta.total).toBeLessThanOrEqual(366); + expect(body.data.meta.total).toBeGreaterThanOrEqual(364); + expect(body.data.meta.windowFrom).toBeDefined(); + const windowFrom = Date.parse(body.data.meta.windowFrom!); + expect(Date.now() - windowFrom).toBeLessThan(366 * day); + expect(Date.now() - windowFrom).toBeGreaterThan(364 * day); + // The read itself was bounded, not just the answer. + const read = spy.mock.calls + .map(([args]) => args as { where?: { measuredAt?: { gte?: Date } } }) + .find((args) => args?.where?.measuredAt?.gte); + expect(read?.where?.measuredAt?.gte?.getTime()).toBe(windowFrom); + + // Older nights are reached by moving `to` back. + const to = new Date(morning.getTime() - 400 * day).toISOString(); + const older = await callList( + `type=SLEEP_DURATION&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}&limit=1000`, + ); + const olderBody = (await older.json()) as ListEnvelope; + expect(olderBody.data.meta.total).toBeGreaterThanOrEqual(329); + expect(olderBody.data.meta.total).toBeLessThanOrEqual(331); + + // A window inside the year is untouched and carries no marker. + const recent = await callList( + `type=SLEEP_DURATION&from=${encodeURIComponent(new Date(morning.getTime() - 10.5 * day).toISOString())}`, + ); + const recentBody = (await recent.json()) as ListEnvelope; + expect(recentBody.data.meta.total).toBe(11); + expect(recentBody.data.meta.windowFrom).toBeUndefined(); + }); }); From 5486014360038369308bc17a94b729dc139179c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:00:53 +0200 Subject: [PATCH 12/29] fix(score): write the glycaemia labels in the reader's glucose unit The fasting-glucose pillar served its observed and reference labels as "Fasting median 99 mg/dL" and "70-99 mg/dL fasting" whatever the account's unit, and clients print those labels as they come. They are now written in the record's glucose unit. The scored value, its unit and the band edges stay canonical, so the score does not move and the web, which converts from the value, cannot convert twice. The schema now says which fields are canonical and which are display text. Refs #1067 --- docs/api/openapi.yaml | 8 ++++- src/app/api/analytics/route.ts | 3 +- .../__tests__/composition-source.test.ts | 1 + ...onfig-change-is-not-a-health-event.test.ts | 1 + .../analytics/score/__tests__/pillars.test.ts | 36 +++++++++++++++++++ .../analytics/score/__tests__/reader.test.ts | 1 + .../reference-citation-guard.test.ts | 1 + src/lib/analytics/score/derived.ts | 3 ++ src/lib/analytics/score/glycaemia.ts | 8 +++-- src/lib/analytics/score/reader.ts | 8 +++++ src/lib/analytics/score/types.ts | 7 ++++ src/lib/dashboard/snapshot.ts | 3 +- src/lib/openapi/routes/analytics.ts | 20 +++++++---- 13 files changed, 89 insertions(+), 11 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 20072acf6..8ca9ebec5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -55784,11 +55784,14 @@ components: properties: value: type: number + description: The scored value in its canonical unit (fasting glucose in mg/dL). unit: type: string + description: The canonical unit of `value`. label: type: string - description: The complete display value, paired or panel values included. + description: The complete display value, paired or panel values included, written in the reader's units (fasting glucose + in the account's glucose unit). asOf: type: string format: date-time @@ -55817,12 +55820,15 @@ components: anyOf: - type: number - type: "null" + description: Lower band edge, canonical unit. high: anyOf: - type: number - type: "null" + description: Upper band edge, canonical unit. label: type: string + description: The band as display text, in the reader's units. source: type: string required: diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 02056525c..de8e909bd 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -34,7 +34,7 @@ import { GLUCOSE_PANEL_WINDOW_DAYS, type GlucoseClinicalMetrics, } from "@/lib/analytics/glucose-metrics"; -import { groupByGlucoseContext } from "@/lib/glucose"; +import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose"; import { decisionForEvidence, PATTERN_FAMILIES, @@ -619,6 +619,7 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) { timezone: userTz, sourcePriorityJson: scoreSourcePriority, thresholdsJson: user.thresholdsJson ?? null, + glucoseUnit: resolveGlucoseUnit(user.glucoseUnit), }, modules: { glucose: glucoseEnabled, diff --git a/src/lib/analytics/score/__tests__/composition-source.test.ts b/src/lib/analytics/score/__tests__/composition-source.test.ts index e28140842..89af33f19 100644 --- a/src/lib/analytics/score/__tests__/composition-source.test.ts +++ b/src/lib/analytics/score/__tests__/composition-source.test.ts @@ -240,6 +240,7 @@ function score(args: { timezone: "UTC", sourcePriorityJson: null, thresholdsJson: null, + glucoseUnit: "mg/dL" as const, }, modules: args.modules, healthScoreConfigJson: args.healthScoreConfigJson, diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 171e54fec..d031de698 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -318,6 +318,7 @@ function scoreInput(healthScoreConfigJson: unknown) { timezone: "UTC", sourcePriorityJson: null, thresholdsJson: null, + glucoseUnit: "mg/dL" as const, }, modules: { glucose: true, diff --git a/src/lib/analytics/score/__tests__/pillars.test.ts b/src/lib/analytics/score/__tests__/pillars.test.ts index 67f173c85..939558c0d 100644 --- a/src/lib/analytics/score/__tests__/pillars.test.ts +++ b/src/lib/analytics/score/__tests__/pillars.test.ts @@ -14,6 +14,7 @@ const live = { source: "live" as const, readFailed: false, timezone: "Europe/Berlin", + glucoseUnit: "mg/dL" as const, }; function days(count: number, value: number) { @@ -160,6 +161,41 @@ describe("reference-score pillars", () => { expect(eligible.status).toBe("ok"); }); + it("writes the fasting labels in the reader's glucose unit, value and band canonical", () => { + const fasting = days(8, 99).map((point) => ({ + value: point.value, + at: new Date(`${point.day}T08:00:00Z`), + source: "APPLE_HEALTH", + })); + const mmol = computeGlycaemiaPillar({ + ...live, + glucoseUnit: "mmol/L", + asOf: NOW, + hba1c: [], + fastingGlucose: fasting, + }); + const mgdl = computeGlycaemiaPillar({ + ...live, + asOf: NOW, + hba1c: [], + fastingGlucose: fasting, + }); + expect(mmol.status).toBe("ok"); + expect(mgdl.status).toBe("ok"); + if (mmol.status !== "ok" || mgdl.status !== "ok") return; + // These labels reach the app as finished text. + expect(mmol.value.observed.label).toBe("Fasting median 5.5 mmol/L"); + expect(mmol.value.reference.label).toBe("3.9–5.5 mmol/L fasting"); + expect(mgdl.value.observed.label).toBe("Fasting median 99 mg/dL"); + expect(mgdl.value.reference.label).toBe("70–99 mg/dL fasting"); + // The score and the numbers the web converts from do not move. + expect(mmol.value.observed.value).toBe(99); + expect(mmol.value.observed.unit).toBe("mg/dL"); + expect(mmol.value.reference.low).toBe(70); + expect(mmol.value.reference.high).toBe(99); + expect(mmol.value.score).toBe(mgdl.value.score); + }); + it("does not use fasting fallback when the preferred lab read failed", () => { const result = computeGlycaemiaPillar({ ...live, diff --git a/src/lib/analytics/score/__tests__/reader.test.ts b/src/lib/analytics/score/__tests__/reader.test.ts index 972fd9190..af86b62af 100644 --- a/src/lib/analytics/score/__tests__/reader.test.ts +++ b/src/lib/analytics/score/__tests__/reader.test.ts @@ -137,6 +137,7 @@ describe("score reader bounds and source parity", () => { timezone: "Europe/Berlin", sourcePriorityJson: null, thresholdsJson: null, + glucoseUnit: "mg/dL" as const, }, modules: { glucose: true, diff --git a/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts b/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts index 75a459e86..a88abdb95 100644 --- a/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts +++ b/src/lib/analytics/score/__tests__/reference-citation-guard.test.ts @@ -233,6 +233,7 @@ const live = { source: "live" as const, readFailed: false, timezone: "Europe/Berlin", + glucoseUnit: "mg/dL" as const, }; function days(count: number, value: number) { diff --git a/src/lib/analytics/score/derived.ts b/src/lib/analytics/score/derived.ts index 4065c65a1..5a70969f7 100644 --- a/src/lib/analytics/score/derived.ts +++ b/src/lib/analytics/score/derived.ts @@ -5,6 +5,7 @@ import { getBpTargets } from "@/lib/analytics/bp-targets"; import { resolveModuleMap } from "@/lib/modules/gate"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { resolveGlucoseUnit } from "@/lib/glucose"; import { computeAndRecordUserHealthScore } from "./record"; import type { CompositeValue } from "./types"; @@ -23,6 +24,7 @@ export async function computeHealthScoreDerived( timezone: true, thresholdsJson: true, healthScoreConfigJson: true, + glucoseUnit: true, }, }); const now = new Date(); @@ -59,6 +61,7 @@ export async function computeHealthScoreDerived( timezone, sourcePriorityJson, thresholdsJson: user.thresholdsJson, + glucoseUnit: resolveGlucoseUnit(user.glucoseUnit), }, modules: { glucose: modules.glucose !== false, diff --git a/src/lib/analytics/score/glycaemia.ts b/src/lib/analytics/score/glycaemia.ts index 08cb64555..b62462a02 100644 --- a/src/lib/analytics/score/glycaemia.ts +++ b/src/lib/analytics/score/glycaemia.ts @@ -1,4 +1,5 @@ import type { Derived } from "@/lib/insights/derived/types"; +import { convertGlucose } from "@/lib/glucose"; import type { GlycaemiaPillarInput, PillarValue } from "./types"; import { @@ -153,10 +154,13 @@ export function computeGlycaemiaPillar( FASTING_GLUCOSE_REFERENCE.low, FASTING_GLUCOSE_REFERENCE.high, ), + // `value`, `unit` and the band stay canonical: the score is computed on + // them and the web converts from them. The labels are finished text a + // client prints as-is, so they are written in the reader's unit. observed: { value: observed, unit: "mg/dL", - label: `Fasting median ${Math.round(observed)} mg/dL`, + label: `Fasting median ${convertGlucose(observed, input.glucoseUnit)} ${input.glucoseUnit}`, asOf: fasting.at(-1)!.at.toISOString(), sources: uniqueSources(fasting.map((row) => row.source)), }, @@ -164,7 +168,7 @@ export function computeGlycaemiaPillar( kind: "clinical-threshold", low: FASTING_GLUCOSE_REFERENCE.low, high: FASTING_GLUCOSE_REFERENCE.high, - label: `${FASTING_GLUCOSE_REFERENCE.low}–${FASTING_GLUCOSE_REFERENCE.high} mg/dL fasting`, + label: `${convertGlucose(FASTING_GLUCOSE_REFERENCE.low, input.glucoseUnit)}–${convertGlucose(FASTING_GLUCOSE_REFERENCE.high, input.glucoseUnit)} ${input.glucoseUnit} fasting`, source: "ADA 2025; Selvin 2010", }, noiseFloor: Math.max(1, Math.abs(ceilingScore - floorScore)), diff --git a/src/lib/analytics/score/reader.ts b/src/lib/analytics/score/reader.ts index 7f9e80559..eae8b85a8 100644 --- a/src/lib/analytics/score/reader.ts +++ b/src/lib/analytics/score/reader.ts @@ -42,6 +42,7 @@ import type { HealthScoreReport, PillarInputs, ScorePillarId } from "./types"; import { computeWeightGoal } from "./weight-goal"; import { DAY_MS, uniqueSources } from "./shared"; import type { DerivedProvenanceSource } from "@/lib/insights/derived/types"; +import type { GlucoseUnit } from "@/lib/glucose"; const HISTORY_OFFSET_DAYS = 14; const SCORE_READ_DAYS = 365 + HISTORY_OFFSET_DAYS; @@ -52,6 +53,12 @@ export interface ScoreReaderProfile { timezone: string; sourcePriorityJson: unknown; thresholdsJson: unknown; + /** + * The record's glucose unit, for the glycaemia pillar's display labels. + * Required: those labels are served as finished text (the app prints them + * verbatim), and a caller that forgets the unit must not get mg/dL. + */ + glucoseUnit: GlucoseUnit; } export interface UserHealthScoreInput { @@ -513,6 +520,7 @@ function scoreInputsFor(args: { }, GLYCAEMIA: { source: "live", + glucoseUnit: input.profile.glucoseUnit, readFailed: (!input.modules.glucose || !args.glucose.ok) && (!input.modules.labs || !args.labs.ok), diff --git a/src/lib/analytics/score/types.ts b/src/lib/analytics/score/types.ts index 44dc7ed1b..67c25081a 100644 --- a/src/lib/analytics/score/types.ts +++ b/src/lib/analytics/score/types.ts @@ -8,6 +8,7 @@ import type { // runtime cycle. The tier's definition belongs beside the rule that // produces it; its NAME belongs here, where the wire shape is declared. import type { ScoreBreadthTier } from "./breadth"; +import type { GlucoseUnit } from "@/lib/glucose"; /** * The scoring method's identity. @@ -346,6 +347,12 @@ export interface GlucoseReading { export interface GlycaemiaPillarInput extends DomainReadState { asOf: Date; + /** + * The unit the fasting-glucose labels are written in. The scored value, + * its `unit` and the reference band stay canonical mg/dL; only the two + * human-readable labels follow the reader. + */ + glucoseUnit: GlucoseUnit; hba1cReadFailed?: boolean; fastingReadFailed?: boolean; hba1c: HbA1cReading[]; diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 6d0f074a7..83dd06f32 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -49,7 +49,7 @@ import { GLUCOSE_PANEL_WINDOW_DAYS, type GlucoseClinicalMetrics, } from "@/lib/analytics/glucose-metrics"; -import { groupByGlucoseContext } from "@/lib/glucose"; +import { groupByGlucoseContext, resolveGlucoseUnit } from "@/lib/glucose"; import { computeBpInTargetFastPath, type BpInTargetEnvelope, @@ -788,6 +788,7 @@ async function buildExtras( timezone: userTz, sourcePriorityJson: scoreSourcePriority, thresholdsJson: user.thresholdsJson, + glucoseUnit: resolveGlucoseUnit(user.glucoseUnit), }, modules: { glucose: modules.glucose !== false, diff --git a/src/lib/openapi/routes/analytics.ts b/src/lib/openapi/routes/analytics.ts index f51360fee..41dda0037 100644 --- a/src/lib/openapi/routes/analytics.ts +++ b/src/lib/openapi/routes/analytics.ts @@ -239,11 +239,17 @@ const scorePillarIdEnum = z.enum(SCORE_PILLAR_IDS); const pillarValue = z.object({ score: z.number(), observed: z.object({ - value: z.number(), - unit: z.string(), + value: z + .number() + .describe( + "The scored value in its canonical unit (fasting glucose in mg/dL).", + ), + unit: z.string().describe("The canonical unit of `value`."), label: z .string() - .describe("The complete display value, paired or panel values included."), + .describe( + "The complete display value, paired or panel values included, written in the reader's units (fasting glucose in the account's glucose unit).", + ), asOf: z.iso.datetime({ offset: true }), sources: z.array(z.string()), }), @@ -253,9 +259,11 @@ const pillarValue = z.object({ "population-percentile", "guideline-band", ]), - low: z.number().nullable(), - high: z.number().nullable(), - label: z.string(), + low: z.number().nullable().describe("Lower band edge, canonical unit."), + high: z.number().nullable().describe("Upper band edge, canonical unit."), + label: z + .string() + .describe("The band as display text, in the reader's units."), source: z.string(), }), personalReference: z From 0f31fd199b749165413ffa620ed0134c3366f0f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:02:23 +0200 Subject: [PATCH 13/29] test(guard): police dense measurement reads in request handlers and raw SQL The dense-read guard walked the worker and insight directories only, and the same class kept turning up beside them: the targets page, the cycle insights, the per-kind series and the doctor report all lived in app/api or a helper next to it. The findMany check now also covers app/api, import, export, the targets and the doctor report. It reads the type from the where object rather than the first type key in the call, accepts a list of sparse literals, and counts a key-bounded where (an id, externalId or measuredAt list, or an exact instant) as bounded. Every remaining unbounded read is listed with its reason. A second check covers every raw statement in the tree that reads from measurements: it must GROUP BY, use DISTINCT or LIMIT, or select aggregates only, or be listed. Both checks carry floors and run their matchers over a planted offender. Restoring the old targets builder fails the findMany check on the year of glucose and the heart-rate month; dropping the GROUP BY from the resting-pulse fold fails the SQL check. --- .../dense-measurement-read-guard.test.ts | 356 +++++++++++++++--- 1 file changed, 299 insertions(+), 57 deletions(-) diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index 8414051a4..35060ade2 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -1,6 +1,5 @@ /** - * Structural guard: background and insight readers must not materialise a - * dense measurement stream. + * Structural guard: no reader materialises a dense measurement stream. * * #1023: a single account syncing heart rate from a watch took the worker * down within half an hour. The status-card read was bounded in time @@ -10,17 +9,34 @@ * SQL (`readDayAggregates`, `readLiveBuckets`), so the reader's size tracks * the number of days, not the sampling rate. * - * What this guard pins, in the directories that run on the worker or feed an - * insight: every `measurement.findMany` without a `take` reads a type that is - * named as a literal and is not one a device streams. A read whose type is a - * variable could be handed a dense type by the next caller, so it must be - * listed below with the reason it is safe. The list is frozen both ways: a new - * unlisted read fails, and an entry whose read is gone fails too. + * The same class kept turning up outside the directories this guard first + * walked: the targets page read a year of glucose and a month of heart rate, + * the cycle insights read a year of every outcome channel, the per-kind series + * read heart-rate variability and blood oxygen raw for ten years, all from + * `app/api` or a helper beside it. So the guard now covers the request + * handlers, import, export, the targets and the doctor report as well, and + * the raw SQL reads of `measurements` anywhere in the tree. * - * It is a tripwire, not a proof: the matcher reads source text, and a read - * written some other way (a raw SELECT of rows, a helper in another - * directory) passes it. The floor below fails the guard if the matcher stops - * finding reads at all. + * Two checks. + * + * 1. `measurement.findMany` without a `take`, in the scoped directories, + * must be bounded by its `where`: a type named as a literal (or a list of + * literals) none of which a device streams, or a key list (`id`, + * `externalId` or `measuredAt` `in: [...]`, or an exact `measuredAt`). + * Anything else is listed below with the reason it stays bounded. + * + * 2. A raw `$queryRaw` / `$queryRawUnsafe` whose statement reads from + * `measurements` must fold: `GROUP BY`, `DISTINCT`, `LIMIT`, or a select + * list of aggregates only. A statement that returns rows is listed below + * with its reason. A statement held in a variable is invisible to the + * matcher; that is the check's known blind spot. + * + * Both lists are frozen both ways: a new unlisted read fails, and an entry + * whose read is gone fails too. The floors below fail the guard if a matcher + * stops finding reads at all, and the "plants" cases run each matcher over a + * planted offender, which is the mutation proof kept in the suite: the + * planted findMany of `BLOOD_GLUCOSE` over a year and the planted raw SELECT of + * every pulse row are both caught. It is a tripwire, not a proof. */ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; @@ -42,6 +58,12 @@ const SCOPED_DIRS = [ "lib/analytics/", "lib/dashboard/", "lib/daily/", + // Widened in v1.39.7: the request handlers and the helpers they lean on. + "app/api/", + "lib/import", + "lib/export", + "lib/targets/", + "lib/doctor-report", ]; /** Types a device writes at sampling rate rather than a few times a day. */ @@ -55,8 +77,8 @@ const DENSE_TYPES: ReadonlySet = new Set([ ]); /** - * Untaken reads whose type is not a sparse literal, keyed - * `::`, with why each stays bounded. + * Untaken reads whose `where` neither names sparse literal types nor a key + * list, keyed `::`, with why each stays bounded. */ const ALLOWED: Record = { "lib/insights/comprehensive-generate.ts::(shorthand)": @@ -69,8 +91,6 @@ const ALLOWED: Record = { "A constant for six-minute-walk distance, written at most daily.", "lib/insights/derived/wellness-scores.ts::measurementType": "Daily computed / provider scores (recovery, strain, readiness), one row per day.", - "lib/jobs/reaction-line.ts::{ in: [...types]": - "Exact-instant match (`measuredAt: row.occurredAt`), not a window.", "lib/jobs/step-consolidation-repair.ts::STEP_TYPE": "Repair job over one user's step rows for a bounded day range; runs once per repair, not per status refresh.", 'lib/ai/coach/snapshot.ts::"BLOOD_GLUCOSE" as never': @@ -79,6 +99,34 @@ const ALLOWED: Record = { "Same 30-day clinical glucose panel as the coach snapshot (per-reading metrics), grouped by meal context.", "lib/analytics/score/reader.ts::{ in: types": "Health-score inputs: steps (drained to one row per day nightly), sleep, waist, weight, blood pressure, fasting glucose only.", + 'app/api/analytics/route.ts::"BLOOD_GLUCOSE"': + "The 30-day per-context glucose summary, the same window and bound as the dashboard and coach glucose panels: at a sensor's fixed rate, below 45 000 rows.", + "app/api/measurements/series/route.ts::(shorthand)": + "The raw branch of the per-kind series. Every dense kind reaches it only inside 90 days and under 10 000 rows (counted first); the sparse kinds are a few readings a day.", + "app/api/measurements/batch/route.ts::p.row.type as MeasurementType": + "Cross-source merge probe: one ±tolerance window around each posted reading's instant, so the rows found are bounded by the batch size.", + "app/api/measurements/route.ts::(shorthand)": + "Cross-source merge probe for one posted sleep segment: SLEEP_DURATION in a ±tolerance window around its end.", + "lib/targets/build-response.ts::{ in: recentTypes": + "Thirty days of weight, blood pressure, resting heart rate, body fat and steps (drained to one row per day). PULSE is filtered out of `recentTypes` and folds in SQL.", + "lib/doctor-report/collect.ts::{ notIn: rawExcluded": + "The report window's sparse types. Pulse, heart-rate variability and blood oxygen over 10 000 readings or any window over 90 days, and glucose over 90 days, are in `rawExcluded` and read as day buckets; glucose inside 90 days stays raw for the clinical panel (per-reading metrics), below 26 000 rows at a sensor's rate.", +}; + +/** + * Raw statements that read rows of `measurements` without folding them, keyed + * `::`, with why each + * stays bounded. + */ +const ALLOWED_SQL: Record = { + "lib/analytics/bp-in-target-fast-path.ts::1": + "Canonical-source blood pressure, one type over 365 days: a cuff, a few readings a day. The in-target pairing needs the individual readings.", + "lib/insights/comprehensive-aggregator.ts::1": + "Canonical-source blood pressure and weight over 90 days: sparse types, a few rows a day.", + "lib/measurements/reconcile-external-measurement.ts::1": + "Row locks (`FOR UPDATE`) on an id list the caller already read; returns those ids only.", + "lib/rollups/measurement-coverage.ts::1": + "Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.", }; interface UntakenRead { @@ -87,63 +135,199 @@ interface UntakenRead { typeExpr: string; } -function scopedFiles(): string[] { +function lineOf(src: string, index: number): number { + return src.slice(0, index).split("\n").length; +} + +/** Index just past the bracket that closes the one at `open`. */ +function closeOf(src: string, open: number): number { + const pair: Record = { "(": ")", "{": "}", "[": "]" }; + const want = pair[src[open]]; + let depth = 1; + let i = open + 1; + while (depth > 0 && i < src.length) { + if (src[i] === src[open]) depth += 1; + else if (src[i] === want) depth -= 1; + i += 1; + } + return i; +} + +/** The `where: { … }` object of a call, or null when `where` is a variable. */ +function whereObject(call: string): string | null { + const m = /\bwhere\s*:\s*\{/.exec(call); + if (!m) return null; + const open = m.index + m[0].length - 1; + return call.slice(open, closeOf(call, open)); +} + +/** The type expression a `where` object constrains on. */ +function typeExprOf(call: string): string { + const where = whereObject(call); + if (where === null) { + return /\bwhere\s*[,}]/.test(call) ? "(where variable)" : "(none)"; + } + const typed = /\btype\s*:\s*([^,\n}]+)/.exec(where); + if (typed) return typed[1].trim(); + return /\btype\s*[,}\n]/.test(where) ? "(shorthand)" : "(none)"; +} + +/** A `where` that pins rows by key rather than by a window. */ +function keyBounded(call: string): boolean { + const where = whereObject(call) ?? ""; + return ( + /\b(?:id|externalId|measuredAt)\s*:\s*\{\s*in\s*:/.test(where) || + /\bmeasuredAt\s*:\s*(?![\s{])/.test(where) + ); +} + +function sparseLiteral(typeExpr: string): boolean { + const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr); + if (lit) return !DENSE_TYPES.has(lit[1]); + const list = /^\{\s*in\s*:\s*\[([^\]]*)/.exec(typeExpr); + if (!list) return false; + const items = list[1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + return ( + items.length > 0 && + items.every((item) => { + const m = /^"([A-Z_0-9]+)"$/.exec(item); + return m !== null && !DENSE_TYPES.has(m[1]); + }) + ); +} + +/** Every untaken `measurement.findMany` in `src`, with its type expression. */ +function untakenFindManyReads( + file: string, + src: string, +): { all: number; untaken: UntakenRead[] } { + let all = 0; + const untaken: UntakenRead[] = []; + // Whitespace-tolerant: a call split across lines still matches. + const re = /\.measurement\s*\.\s*findMany\s*\(/g; + for (let m = re.exec(src); m; m = re.exec(src)) { + all += 1; + const call = src.slice(m.index, closeOf(src, m.index + m[0].length - 1)); + if (/\btake\b/.test(call)) continue; + if (keyBounded(call)) continue; + // A type expression can itself contain a full bracket (`{ in: [...] }`); + // the regex above stops at the first `,` / `}` / newline, which is the + // stable key the allowlist uses. + untaken.push({ + file, + line: lineOf(src, m.index), + typeExpr: typeExprOf(call), + }); + } + return { all, untaken }; +} + +/** The statement text of the raw call starting at `at`, or null if opaque. */ +function statementAt(src: string, at: number): string | null { + let i = at; + // Skip a type argument: `$queryRaw>`. + if (src[i] === "<") { + let depth = 1; + i += 1; + while (depth > 0 && i < src.length) { + if (src[i] === "<") depth += 1; + else if (src[i] === ">") depth -= 1; + i += 1; + } + } + while (/\s/.test(src[i] ?? "")) i += 1; + if (src[i] === "`") { + // Tagged template: up to the matching backtick, skipping `${ … }`. + let j = i + 1; + while (j < src.length && src[j] !== "`") { + if (src[j] === "$" && src[j + 1] === "{") j = closeOf(src, j + 1); + else j += 1; + } + return src.slice(i, j + 1); + } + if (src[i] === "(") return src.slice(i, closeOf(src, i)); + return null; +} + +const READS_MEASUREMENTS = + /\bFROM\s+"?measurements"?(?![_\w])|canonicalMeasurementsFrom\s*\(/i; +const FOLDS = + /GROUP\s+BY|\bDISTINCT\b|\bLIMIT\b|^[\s`(]*(?:Prisma\.sql`)?\s*SELECT\s+(?:COUNT|SUM|AVG|MIN|MAX|STDDEV_POP)\s*\(/i; + +interface RawRead { + file: string; + line: number; + folds: boolean; +} + +/** Every raw statement in `src` that reads from `measurements`. */ +function rawMeasurementReads(file: string, src: string): RawRead[] { + const out: RawRead[] = []; + const re = /\.\$queryRaw(?:Unsafe)?\b/g; + for (let m = re.exec(src); m; m = re.exec(src)) { + const statement = statementAt(src, m.index + m[0].length); + if (statement === null || !READS_MEASUREMENTS.test(statement)) continue; + out.push({ + file, + line: lineOf(src, m.index), + folds: FOLDS.test(statement), + }); + } + return out; +} + +function sourceFiles(): string[] { return walkSourceFiles(SRC, { floor: 3000 }) - .filter((p) => SCOPED_DIRS.some((d) => p.startsWith(d))) + .filter((p) => !p.startsWith("generated/")) .filter((p) => !p.includes("__tests__")) .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) .sort(); } -function untakenReads(): { all: number; untaken: UntakenRead[] } { +function scan() { let all = 0; const untaken: UntakenRead[] = []; - for (const file of scopedFiles()) { + const raw: RawRead[] = []; + for (const file of sourceFiles()) { const src = readFileSync(join(SRC, file), "utf8"); - // Whitespace-tolerant: a call split across lines still matches. - const re = /\.measurement\s*\.\s*findMany\s*\(/g; - for (let m = re.exec(src); m; m = re.exec(src)) { - all += 1; - let i = m.index + m[0].length; - let depth = 1; - while (depth > 0 && i < src.length) { - if (src[i] === "(") depth += 1; - else if (src[i] === ")") depth -= 1; - i += 1; - } - const call = src.slice(m.index, i); - if (/\btake\b/.test(call)) continue; - const typed = /\btype\s*:\s*([^,\n}]+)/.exec(call); - const typeExpr = typed - ? typed[1].trim() - : /\btype\s*,/.test(call) - ? "(shorthand)" - : "(none)"; - untaken.push({ - file, - line: src.slice(0, m.index).split("\n").length, - typeExpr, - }); - } + raw.push(...rawMeasurementReads(file, src)); + if (!SCOPED_DIRS.some((d) => file.startsWith(d))) continue; + const reads = untakenFindManyReads(file, src); + all += reads.all; + untaken.push(...reads.untaken); } - return { all, untaken }; + return { all, untaken, raw }; } -function sparseLiteral(typeExpr: string): boolean { - const lit = /^"([A-Z_0-9]+)"/.exec(typeExpr); - return lit !== null && !DENSE_TYPES.has(lit[1]); +/** `::` for the n-th unfolded statement of each file. */ +function unfoldedKeys(raw: readonly RawRead[]): Map { + const keys = new Map(); + const perFile = new Map(); + for (const read of raw) { + if (read.folds) continue; + const n = (perFile.get(read.file) ?? 0) + 1; + perFile.set(read.file, n); + keys.set(`${read.file}::${n}`, read); + } + return keys; } describe("dense measurement reads stay bounded (#1023)", () => { - const { all, untaken } = untakenReads(); + const { all, untaken, raw } = scan(); it("finds the reads it is meant to police", () => { // Guard against a matcher that silently matches nothing. - expect(all).toBeGreaterThanOrEqual(40); - expect(untaken.length).toBeGreaterThanOrEqual(15); + // Pinned below the counts on 2026-10-01 (82 / 45 / 37 / 33). + expect(all).toBeGreaterThanOrEqual(75); + expect(untaken.length).toBeGreaterThanOrEqual(40); + expect(raw.length).toBeGreaterThanOrEqual(33); + expect(raw.filter((r) => r.folds).length).toBeGreaterThanOrEqual(30); }); - it("every untaken read names a sparse literal type or is listed with its reason", () => { + it("every untaken read names sparse literal types, a key list, or is listed with its reason", () => { const offenders = untaken .filter((r) => !sparseLiteral(r.typeExpr)) .filter((r) => !(`${r.file}::${r.typeExpr}` in ALLOWED)) @@ -156,20 +340,78 @@ describe("dense measurement reads stay bounded (#1023)", () => { expect(Object.keys(ALLOWED).filter((k) => !present.has(k))).toEqual([]); }); + it("every raw read of measurements folds or is listed with its reason", () => { + const keys = unfoldedKeys(raw); + const offenders = [...keys] + .filter(([key]) => !(key in ALLOWED_SQL)) + .map(([key, read]) => `${key} (line ${read.line})`); + expect(offenders).toEqual([]); + expect(Object.keys(ALLOWED_SQL).filter((k) => !keys.has(k))).toEqual([]); + }); + it("the readers fixed for #1023 fold in SQL rather than reading rows", () => { for (const file of [ "lib/insights/graded-series.ts", "lib/rollups/tiered-context.ts", "lib/jobs/coach-plan-review.ts", "lib/measurements/daily-series-read.ts", + "lib/insights/derived/baseline.ts", + "lib/targets/glucose-read.ts", ]) { const src = readFileSync(join(SRC, file), "utf8"); expect(src, file).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/); } - const baseline = readFileSync( - join(SRC, "lib/insights/derived/baseline.ts"), - "utf8", + }); + + it("plants: an unbounded findMany of a dense type is caught", () => { + const planted = ` + const rows = await prisma.measurement.findMany({ + where: { userId, type: "BLOOD_GLUCOSE", measuredAt: { gte: oneYearAgo } }, + select: { value: true, measuredAt: true }, + });`; + const { all: n, untaken: found } = untakenFindManyReads( + "app/api/planted/route.ts", + planted, ); - expect(baseline).not.toMatch(/\.measurement\s*\.\s*findMany\s*\(/); + expect(n).toBe(1); + expect(found.map((r) => r.typeExpr)).toEqual(['"BLOOD_GLUCOSE"']); + expect(sparseLiteral(found[0].typeExpr)).toBe(false); + // The same read with a key list or a take is bounded. + expect( + untakenFindManyReads( + "x.ts", + planted.replace("measuredAt: { gte", "id: { in: ids }, x: { gte"), + ).untaken, + ).toEqual([]); + expect( + untakenFindManyReads( + "x.ts", + planted.replace("select:", "take: 10, select:"), + ).untaken, + ).toEqual([]); + // A list of sparse literals passes; one dense member does not. + expect(sparseLiteral('{ in: ["WEIGHT", "BODY_FAT"]')).toBe(true); + expect(sparseLiteral('{ in: ["WEIGHT", "PULSE"]')).toBe(false); + }); + + it("plants: a raw SELECT of rows from measurements is caught", () => { + const planted = + "const rows = await prisma.$queryRaw>`\n" + + ' SELECT m."value" FROM measurements m\n' + + ' WHERE m."user_id" = ${userId} AND m."type" = \'PULSE\'\n' + + "`;"; + const reads = rawMeasurementReads("lib/planted.ts", planted); + expect(reads).toHaveLength(1); + expect(reads[0].folds).toBe(false); + const folded = rawMeasurementReads( + "lib/planted.ts", + planted.replace("WHERE", "WHERE true GROUP BY 1 HAVING true AND"), + ); + expect(folded[0].folds).toBe(true); + const aggregate = rawMeasurementReads( + "lib/planted.ts", + planted.replace('SELECT m."value"', 'SELECT COUNT(*), AVG(m."value")'), + ); + expect(aggregate[0].folds).toBe(true); }); }); From d6f79c8beffb38ed932726edeb2925938477ab7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:03:51 +0200 Subject: [PATCH 14/29] fix(workouts): show distance, pace and elevation in the reader's units The workout list, the detail header, the stat tiles, the sport-average line and the elevation chart printed kilometres, minutes per kilometre and metres for every reader. They now read distance, pace and elevation through the unit registry (km or mi, /km or /mi, m or ft). The splits table keeps its kilometre rows, which are cut server-side, and says so. A pace that rounded to sixty seconds now carries into the minute. Refs #1067 --- .../charts/workout-elevation-chart.tsx | 23 +++++- .../__tests__/workout-detail.test.tsx | 53 ++++++++++++- .../insights/__tests__/workout-list.test.tsx | 27 ++++++- .../insights/workout-detail/format.ts | 78 +++++++++++++++---- .../insights/workout-detail/header.tsx | 6 +- .../insights/workout-detail/splits.tsx | 2 +- .../insights/workout-detail/stats.tsx | 19 +++-- src/components/insights/workout-list.tsx | 13 +--- .../__tests__/display-transform.test.ts | 2 +- src/lib/measurements/display-transform.ts | 4 +- 10 files changed, 184 insertions(+), 43 deletions(-) diff --git a/src/components/charts/workout-elevation-chart.tsx b/src/components/charts/workout-elevation-chart.tsx index 8c565bd11..3fd0cfa93 100644 --- a/src/components/charts/workout-elevation-chart.tsx +++ b/src/components/charts/workout-elevation-chart.tsx @@ -11,6 +11,11 @@ import { } from "recharts"; import { useTranslations } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { + applyDisplayTransformUnrounded, + getQuantityTransform, +} from "@/lib/measurements/display-transform"; /** * Workout elevation profile — a small cumulative-distance / altitude @@ -30,7 +35,15 @@ export function WorkoutElevationChart({ points: WorkoutElevationPoint[]; }) { const { t } = useTranslations(); - const data = points.map((p) => ({ km: p.distanceM / 1000, alt: p.altitude })); + const { preference } = useUnitDisplay(); + // Distance along the route in km or mi, altitude in m or ft — the same + // registry the stat tiles read, so the axis and the tiles agree. + const distance = getQuantityTransform("distance", preference); + const elevation = getQuantityTransform("elevation", preference); + const data = points.map((p) => ({ + dist: applyDisplayTransformUnrounded(p.distanceM, distance), + alt: applyDisplayTransformUnrounded(p.altitude, elevation), + })); return (
@@ -57,7 +70,7 @@ export function WorkoutElevationChart({ opacity={0.4} /> `${Number(km).toFixed(2)} km`} + labelFormatter={(d) => + `${Number(d).toFixed(2)} ${distance.displayUnit}` + } formatter={(value) => [ - `${Math.round(Number(value))} m`, + `${Math.round(Number(value))} ${elevation.displayUnit}`, t("insights.workouts.detail.elevationTitle"), ]} /> diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx index a7916eaaa..242331f7a 100644 --- a/src/components/insights/__tests__/workout-detail.test.tsx +++ b/src/components/insights/__tests__/workout-detail.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -16,6 +16,19 @@ import { WorkoutInsightCard } from "../workout-detail/insight-slot"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; import type { RouteCoordinate } from "@/lib/workouts/route-svg"; +// The unit preference comes from the account payload; a mutable stub lets a +// test switch the reader to imperial. +const account = vi.hoisted(() => ({ + user: { unitPreference: "metric", glucoseUnit: null } as Record< + string, + unknown + >, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: account.user, isAuthenticated: true }), + useAccountOnceMounted: () => account.user, +})); + /** * #67 — `` unit tests over the split `workout-detail/` * directory. Each primitive runs through SSR with a canonical fixture; @@ -149,6 +162,44 @@ describe("", () => { expect(html).toContain("148 bpm"); }); + it("states distance, pace, elevation and the average line in imperial units", () => { + account.user = { unitPreference: "imperial", glucoseUnit: null }; + try { + const html = render( + , + ); + // 5200 m = 3.23 mi; 1800 s over it = 9:17 per mile; 12.5 m = 41.0 ft. + expect(html).toContain("3.23 mi"); + expect(html).toContain("9:17 /mi"); + expect(html).toContain("41.0 ft"); + // The sport-average line: 5800 m = 3.60 mi. + expect(html).toContain("3.60 mi"); + expect(html).not.toContain(" km"); + expect(html).not.toContain("/km"); + const header = render(); + expect(header).toContain("3.23 mi"); + } finally { + account.user = { unitPreference: "metric", glucoseUnit: null }; + } + }); + + it("keeps kilometres, minutes per km and metres for a metric reader", () => { + const html = render(); + expect(html).toContain("5.20 km"); + expect(html).toContain("5:46 /km"); + expect(html).toContain("12.5 m"); + }); + it("hides the average line for a lone session", () => { const lone: WorkoutDetailPayload = { ...FIXTURE, diff --git a/src/components/insights/__tests__/workout-list.test.tsx b/src/components/insights/__tests__/workout-list.test.tsx index d6cb72ec5..a3dad821a 100644 --- a/src/components/insights/__tests__/workout-list.test.tsx +++ b/src/components/insights/__tests__/workout-list.test.tsx @@ -1,10 +1,23 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { WorkoutList } from "../workout-list"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; +// The unit preference comes from the account payload; a mutable stub lets a +// test switch the reader to imperial. +const account = vi.hoisted(() => ({ + user: { unitPreference: "metric", glucoseUnit: null } as Record< + string, + unknown + >, +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: account.user, isAuthenticated: true }), + useAccountOnceMounted: () => account.user, +})); + /** * v1.4.32 — `` unit tests. * @@ -47,6 +60,18 @@ describe("", () => { expect(html).toContain("320 kcal"); }); + it("states the distance in miles for an imperial reader", () => { + account.user = { unitPreference: "imperial", glucoseUnit: null }; + try { + const html = render(); + // 5200 m = 3.23 mi. + expect(html).toContain("3.23 mi"); + expect(html).not.toContain(" km"); + } finally { + account.user = { unitPreference: "metric", glucoseUnit: null }; + } + }); + it("renders aggregate average HR without claiming an HR series", () => { const html = render( , diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index 50fc6f775..03e559ea1 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -1,4 +1,11 @@ import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { + applyDisplayTransform, + applyDisplayTransformUnrounded, + getQuantityTransform, + paceSecondsPerDistanceUnit, + type UnitPreference, +} from "@/lib/measurements/display-transform"; import { hourCycleOptions, type TimeFormatPreference, @@ -27,12 +34,38 @@ export function formatDurationMinutes(seconds: number, locale: string): string { return formatNumber(minutes, locale); } -export function formatDistanceKm(meters: number, locale: string): string { - const km = meters / 1000; - return getNumberFormat(locale, { +/** + * A workout distance in the reader's unit, with its symbol: "5.80 km" or + * "3.60 mi". The workout list, the detail header, the stat tile and the + * sport-average line all print through this, so none of them can keep + * kilometres for an imperial reader. + */ +export function formatDistance( + meters: number, + locale: string, + preference: UnitPreference, +): string { + const transform = getQuantityTransform("distance", preference); + const value = applyDisplayTransformUnrounded(meters, transform); + const formatted = getNumberFormat(locale, { maximumFractionDigits: 2, - minimumFractionDigits: km < 10 ? 2 : 1, - }).format(km); + minimumFractionDigits: value < 10 ? 2 : 1, + }).format(value); + return `${formatted} ${transform.displayUnit}`; +} + +/** Elevation gain in the reader's unit with its symbol: "123.4 m" / "404.9 ft". */ +export function formatElevation( + meters: number, + locale: string, + preference: UnitPreference, +): string { + const transform = getQuantityTransform("elevation", preference); + return `${formatNumber( + applyDisplayTransform(meters, transform), + locale, + transform.decimals, + )} ${transform.displayUnit}`; } export function formatNumber( @@ -46,21 +79,36 @@ export function formatNumber( }).format(value); } -export function formatPaceMinPerKm( +/** + * Average pace per kilometre or per mile, by the reader's preference. Only + * meaningful for run / walk / hike / ride; the caller gates on distance + + * sport. + */ +export function formatPace( durationSec: number, meters: number, + preference: UnitPreference, ): string { - // Seconds-per-kilometre. Only meaningful for run / walk / hike / ride; - // the caller gates on distance + sport. - const secPerKm = (durationSec / meters) * 1000; - return formatPaceSeconds(secPerKm); + return formatPaceSeconds( + paceSecondsPerDistanceUnit(durationSec, meters, preference), + getQuantityTransform("distance", preference).displayUnit, + ); } -/** "m:ss /km" from a seconds-per-km value (used by pace + splits). */ -export function formatPaceSeconds(secPerKm: number): string { - const m = Math.floor(secPerKm / 60); - const s = Math.round(secPerKm % 60); - return `${m}:${s.toString().padStart(2, "0")} /km`; +/** + * "m:ss /km" from a seconds-per-unit value. The splits table passes "km" + * explicitly: its rows are kilometre segments cut server-side, so their + * pace is per kilometre whatever the preference. + */ +export function formatPaceSeconds(secPerUnit: number, unit: string): string { + let m = Math.floor(secPerUnit / 60); + let s = Math.round(secPerUnit % 60); + // 359.6 s is 6:00, not 5:60. + if (s === 60) { + m += 1; + s = 0; + } + return `${m}:${s.toString().padStart(2, "0")} /${unit}`; } export function formatDateRange( diff --git a/src/components/insights/workout-detail/header.tsx b/src/components/insights/workout-detail/header.tsx index 172316af1..4a54a9bea 100644 --- a/src/components/insights/workout-detail/header.tsx +++ b/src/components/insights/workout-detail/header.tsx @@ -3,8 +3,9 @@ import { useTranslations, useTimeFormatPreference } from "@/lib/i18n/context"; import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; -import { formatDuration, formatDistanceKm, formatDateRange } from "./format"; +import { formatDuration, formatDistance, formatDateRange } from "./format"; /** * Hero header — the at-a-glance verdict row. Sport icon badge, source @@ -30,6 +31,7 @@ function renderSportIconBadge(sportType: string) { export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { const { t, locale } = useTranslations(); const timeFormat = useTimeFormatPreference(); + const { preference } = useUnitDisplay(); const sportLabelKey = `insights.workouts.sport.${workout.sportType}`; const sportLabel = t(sportLabelKey); const sportName = @@ -67,7 +69,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { {workout.distanceM != null ? ( - {formatDistanceKm(workout.distanceM, locale)} km + {formatDistance(workout.distanceM, locale, preference)} ) : null}
diff --git a/src/components/insights/workout-detail/splits.tsx b/src/components/insights/workout-detail/splits.tsx index 42fafe68e..16d228a8e 100644 --- a/src/components/insights/workout-detail/splits.tsx +++ b/src/components/insights/workout-detail/splits.tsx @@ -70,7 +70,7 @@ export function WorkoutDetailSplits({ workout }: WorkoutDetailSplitsProps) { : "py-1.5 text-right tabular-nums" } > - {formatPaceSeconds(s.paceSecPerKm)} + {formatPaceSeconds(s.paceSecPerKm, "km")} ))} diff --git a/src/components/insights/workout-detail/stats.tsx b/src/components/insights/workout-detail/stats.tsx index 72767d043..8d2ab065a 100644 --- a/src/components/insights/workout-detail/stats.tsx +++ b/src/components/insights/workout-detail/stats.tsx @@ -17,12 +17,15 @@ import { TileHeader } from "@/components/insights/tile-header"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; +import type { UnitPreference } from "@/lib/measurements/display-transform"; import { formatDuration, - formatDistanceKm, + formatDistance, + formatElevation, formatNumber, - formatPaceMinPerKm, + formatPace, formatDurationMinutes, } from "./format"; @@ -69,12 +72,13 @@ const PACE_SPORTS = new Set(["walking", "running", "hiking", "cycling"]); function sportAverageLine( workout: WorkoutDetailPayload, locale: string, + preference: UnitPreference, ): string | null { const ctx = workout.sportContext; if (!ctx || ctx.count < 2) return null; const parts: string[] = []; if (ctx.avgDistanceM != null && ctx.avgDistanceM > 0) { - parts.push(`${formatDistanceKm(ctx.avgDistanceM, locale)} km`); + parts.push(formatDistance(ctx.avgDistanceM, locale, preference)); } parts.push(`${formatDurationMinutes(ctx.avgDurationSec, locale)} min`); if (ctx.avgAvgHr != null) parts.push(`${ctx.avgAvgHr} bpm`); @@ -83,6 +87,7 @@ function sportAverageLine( export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { const { t, locale } = useTranslations(); + const { preference } = useUnitDisplay(); const tiles: StatTileProps[] = []; tiles.push({ @@ -95,7 +100,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { tiles.push({ icon: , label: t("insights.workouts.detail.statsDistance"), - value: `${formatDistanceKm(workout.distanceM, locale)} km`, + value: formatDistance(workout.distanceM, locale, preference), }); } @@ -139,7 +144,7 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { tiles.push({ icon: , label: t("insights.workouts.detail.statsElevation"), - value: `${formatNumber(workout.elevationM, locale, 1)} m`, + value: formatElevation(workout.elevationM, locale, preference), }); } @@ -151,11 +156,11 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) { tiles.push({ icon: , label: t("insights.workouts.detail.statsPace"), - value: formatPaceMinPerKm(workout.durationSec, workout.distanceM), + value: formatPace(workout.durationSec, workout.distanceM, preference), }); } - const averageLine = sportAverageLine(workout, locale); + const averageLine = sportAverageLine(workout, locale, preference); return ( diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index 150dcb106..ab9130316 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -13,6 +13,8 @@ import { import { iconForSport } from "@/lib/workouts/sport-icons"; import type { WorkoutListEntry } from "@/hooks/use-workouts"; import { Button } from "@/components/ui/button"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { formatDistance } from "@/components/insights/workout-detail/format"; /** * v1.4.32 — workout list row primitive. @@ -33,14 +35,6 @@ function formatDuration(seconds: number): string { return `${m}m`; } -function formatDistanceKm(meters: number, locale: string): string { - const km = meters / 1000; - return getNumberFormat(locale, { - maximumFractionDigits: 2, - minimumFractionDigits: km < 10 ? 2 : 1, - }).format(km); -} - function formatEnergy(kcal: number, locale: string): string { return getNumberFormat(locale, { maximumFractionDigits: 0, @@ -86,6 +80,7 @@ export function WorkoutList({ }: WorkoutListProps) { const { t, locale } = useTranslations(); const timeFormat = useTimeFormatPreference(); + const { preference } = useUnitDisplay(); return (
    {workout.distanceM != null ? ( - {formatDistanceKm(workout.distanceM, locale)} km + {formatDistance(workout.distanceM, locale, preference)} ) : null} {workout.activeEnergyKcal != null ? ( diff --git a/src/lib/measurements/__tests__/display-transform.test.ts b/src/lib/measurements/__tests__/display-transform.test.ts index 91f23e674..62fa98d33 100644 --- a/src/lib/measurements/__tests__/display-transform.test.ts +++ b/src/lib/measurements/__tests__/display-transform.test.ts @@ -384,7 +384,7 @@ describe("display-transform — both unit preferences, one resolver", () => { expect(mi.displayUnit).toBe("mi"); expect( applyDisplayTransform(100, getQuantityTransform("elevation", "imperial")), - ).toBe(328); + ).toBe(328.1); expect(getQuantityTransform("elevation", "metric").displayUnit).toBe("m"); // 30 min over 5 km: 360 s/km, 579.4 s/mi. expect(paceSecondsPerDistanceUnit(1800, 5000, "metric")).toBeCloseTo( diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 5e1cba548..72b7426d1 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -260,8 +260,8 @@ const QUANTITY_TRANSFORMS: Record< imperial: { factor: 0.000621371192237, displayUnit: "mi", decimals: 2 }, }, elevation: { - metric: { factor: 1, displayUnit: "m", decimals: 0 }, - imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 0 }, + metric: { factor: 1, displayUnit: "m", decimals: 1 }, + imperial: { factor: M_TO_FT, displayUnit: "ft", decimals: 1 }, }, }; From bc2d60cce8286d4e7e593949abc723d372c9987a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:08:11 +0200 Subject: [PATCH 15/29] fix(insights): plot the trends row and log fevers in the reader's units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more surfaces carried a fixed unit. The insights trends row labelled weight in kg and body temperature in °C for every reader, and plotted walking distance as raw metres under a km label; its trend caption said the same. A single transformed series there now takes unit, scale and offset from the preference, and the caption describes the converted series against a converted noise floor. The illness day sheet and timeline showed and took a fever in °C. The fever is now seeded, typed and shown in the reader's temperature unit and stored in °C as before. Refs #1067 --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/pl.json | 2 +- .../illness-day-timeline-units.test.tsx | 70 +++++++++++++++++++ .../illness/illness-day-timeline.tsx | 7 +- src/components/illness/log-day-sheet.tsx | 20 ++++-- .../insights/__tests__/trends-row.test.tsx | 53 +++++++++++++- src/components/insights/trend-annotation.tsx | 22 ++++-- src/components/insights/trends-row.tsx | 17 ++++- .../__tests__/trend-descriptor.test.ts | 28 ++++++-- src/lib/insights/trend-chart-select.ts | 12 ++-- src/lib/insights/trend-descriptor.ts | 24 +++++-- 16 files changed, 229 insertions(+), 38 deletions(-) create mode 100644 src/components/illness/__tests__/illness-day-timeline-units.test.tsx diff --git a/messages/de.json b/messages/de.json index fc3b54979..606ecda35 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9405,7 +9405,7 @@ "severity": "Stärke {severity}", "noSymptoms": "Keine Symptome erfasst.", "impact": "Befinden: {impact}", - "fever": "Temperatur: {value} °C", + "fever": "Temperatur: {value} {unit}", "empty": "Noch keine Tage erfasst." }, "loadError": "Diese Episode konnte nicht geladen werden.", diff --git a/messages/en.json b/messages/en.json index 5fcf26297..72344cb64 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9405,7 +9405,7 @@ "severity": "Severity {severity}", "noSymptoms": "No symptoms logged.", "impact": "How you functioned: {impact}", - "fever": "Temperature: {value} °C", + "fever": "Temperature: {value} {unit}", "empty": "No days logged yet." }, "loadError": "Could not load this episode.", diff --git a/messages/es.json b/messages/es.json index 4bdfb5534..d2e383e31 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9405,7 +9405,7 @@ "severity": "Intensidad {severity}", "noSymptoms": "Sin síntomas registrados.", "impact": "Cómo funcionaste: {impact}", - "fever": "Temperatura: {value} °C", + "fever": "Temperatura: {value} {unit}", "empty": "Todavía no hay días registrados." }, "loadError": "No se pudo cargar este episodio.", diff --git a/messages/fr.json b/messages/fr.json index f1c57d412..bada54749 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9405,7 +9405,7 @@ "severity": "Intensité {severity}", "noSymptoms": "Aucun symptôme enregistré.", "impact": "Votre forme : {impact}", - "fever": "Température : {value} °C", + "fever": "Température : {value} {unit}", "empty": "Aucun jour enregistré pour l’instant." }, "loadError": "Impossible de charger cet épisode.", diff --git a/messages/it.json b/messages/it.json index 79b439e50..2ba28ca6d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9405,7 +9405,7 @@ "severity": "Intensità {severity}", "noSymptoms": "Nessun sintomo registrato.", "impact": "Come ti sei sentito: {impact}", - "fever": "Temperatura: {value} °C", + "fever": "Temperatura: {value} {unit}", "empty": "Nessun giorno registrato finora." }, "loadError": "Impossibile caricare questo episodio.", diff --git a/messages/ko.json b/messages/ko.json index 26052e28f..b1fb0504d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9405,7 +9405,7 @@ "severity": "심각도 {severity}", "noSymptoms": "기록한 증상이 없어요.", "impact": "활동 정도: {impact}", - "fever": "체온: {value}°C", + "fever": "체온: {value}{unit}", "empty": "아직 기록한 날이 없어요." }, "loadError": "이 에피소드를 불러오지 못했어요.", diff --git a/messages/pl.json b/messages/pl.json index 396f758be..f3c2c7b3b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9405,7 +9405,7 @@ "severity": "Nasilenie {severity}", "noSymptoms": "Nie zapisano objawów.", "impact": "Jak funkcjonowałeś: {impact}", - "fever": "Temperatura: {value} °C", + "fever": "Temperatura: {value} {unit}", "empty": "Nie zapisano jeszcze żadnych dni." }, "loadError": "Nie udało się wczytać tego epizodu.", diff --git a/src/components/illness/__tests__/illness-day-timeline-units.test.tsx b/src/components/illness/__tests__/illness-day-timeline-units.test.tsx new file mode 100644 index 000000000..a01dda594 --- /dev/null +++ b/src/components/illness/__tests__/illness-day-timeline-units.test.tsx @@ -0,0 +1,70 @@ +/** + * A logged fever reads in the reader's temperature unit. The timeline and the + * day sheet printed a hardcoded °C, so an imperial account read its own + * fever in Celsius. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const account = vi.hoisted(() => ({ + user: { unitPreference: "imperial", glucoseUnit: null } as Record< + string, + unknown + >, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ user: account.user, isAuthenticated: true }), + useAccountOnceMounted: () => account.user, +})); + +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ canManageDomain: () => true }), +})); + +vi.mock("../use-illness", () => ({ + useIllnessDayLogList: () => ({ + isLoading: false, + isError: false, + data: { + dayLogs: [ + { + id: "d1", + episodeId: "e1", + date: "2026-09-30", + functionalImpact: null, + feverC: 38.5, + symptoms: [], + note: null, + updatedAt: "2026-09-30T08:00:00.000Z", + }, + ], + }, + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { IllnessDayTimeline } from "../illness-day-timeline"; + +function render(): string { + return renderToStaticMarkup( + + {}} /> + , + ); +} + +describe(" fever unit", () => { + it("shows the fever in °F for an imperial reader", () => { + account.user = { unitPreference: "imperial", glucoseUnit: null }; + const html = render(); + // 38.5 °C = 101.3 °F. + expect(html).toContain("Temperature: 101.3 °F"); + expect(html).not.toContain("°C"); + }); + + it("keeps °C for a metric reader", () => { + account.user = { unitPreference: "metric", glucoseUnit: null }; + expect(render()).toContain("Temperature: 38.5 °C"); + }); +}); diff --git a/src/components/illness/illness-day-timeline.tsx b/src/components/illness/illness-day-timeline.tsx index fa99bea56..3dd029e55 100644 --- a/src/components/illness/illness-day-timeline.tsx +++ b/src/components/illness/illness-day-timeline.tsx @@ -19,6 +19,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; import { useIllnessDayLogList } from "./use-illness"; @@ -32,6 +33,7 @@ function symptomLabelKey(key: string): string | null { function DayLogRow({ log }: { log: IllnessDayLogDTO }) { const { t } = useTranslations(); const fmt = useFormatters(); + const { toDisplay, unitFor } = useUnitDisplay(); return (
    @@ -72,7 +74,10 @@ function DayLogRow({ log }: { log: IllnessDayLogDTO }) { {log.feverC !== null ? (

    - {t("illness.timeline.fever", { value: fmt.number(log.feverC, 1) })} + {t("illness.timeline.fever", { + value: fmt.number(toDisplay("BODY_TEMPERATURE", log.feverC), 1), + unit: unitFor("BODY_TEMPERATURE"), + })}

    ) : null} diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx index 0afacddc7..b5d7e69da 100644 --- a/src/components/illness/log-day-sheet.tsx +++ b/src/components/illness/log-day-sheet.tsx @@ -25,6 +25,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog"; import { useIllnessDayLog, useUpsertDayLog } from "./use-illness"; @@ -140,6 +141,10 @@ export function LogDaySheet({ onsetDate, }: LogDaySheetProps) { const { t } = useTranslations(); + // The fever is stored in °C and typed in the reader's temperature unit: + // seeded through the transform, inverted on save. + const { toDisplay, fromDisplay, unitFor } = useUnitDisplay(); + const feverUnit = unitFor("BODY_TEMPERATURE"); // The day being logged — seeded from `date` (today), but the picker lets the // user backdate within [onset, today] for the retrospective journal. @@ -178,7 +183,11 @@ export function LogDaySheet({ new Map((dto?.symptoms ?? []).map((s) => [s.key, s.severity ?? null])), ); setFunctionalImpact(dto?.functionalImpact ?? null); - setFever(dto?.feverC != null ? String(dto.feverC) : ""); + setFever( + dto?.feverC != null + ? String(toDisplay("BODY_TEMPERATURE", dto.feverC)) + : "", + ); setNote(dto?.note ?? ""); } else if (!open && lastFormKey !== null) { setLastFormKey(null); @@ -213,7 +222,10 @@ export function LogDaySheet({ const input: IllnessDayLogInput = { date: selectedDate, functionalImpact, - feverC: feverValue, + feverC: + feverValue == null + ? null + : Math.round(fromDisplay("BODY_TEMPERATURE", feverValue) * 100) / 100, symptoms: Array.from(symptoms.entries()).map(([key, severity]) => ({ key, severity: severity ?? undefined, @@ -333,7 +345,7 @@ export function LogDaySheet({ summary={ feverValue != null ? ( - {feverValue.toFixed(1)} °C + {feverValue.toFixed(1)} {feverUnit} ) : null } @@ -345,7 +357,7 @@ export function LogDaySheet({ setFever(e.target.value)} className="max-w-32" diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx index c8b18b288..8b85cfd45 100644 --- a/src/components/insights/__tests__/trends-row.test.tsx +++ b/src/components/insights/__tests__/trends-row.test.tsx @@ -21,14 +21,40 @@ import { I18nProvider } from "@/lib/i18n/context"; vi.mock("next/dynamic", () => ({ default: () => { - const Stub = ({ title }: { title?: string }) => ( -
    {title ?? "chart"}
    + const Stub = ({ + title, + unit, + valueScale, + valueOffset, + }: { + title?: string; + unit?: string; + valueScale?: number; + valueOffset?: number; + }) => ( +
    + {title ?? "chart"} +
    ); Stub.displayName = "TrendsRowChartStub"; return Stub; }, })); +const account = vi.hoisted(() => ({ + user: null as Record | null, +})); +vi.mock("@/hooks/use-auth", async (importOriginal) => ({ + ...(await importOriginal()), + useAuth: () => ({ user: account.user, isAuthenticated: true }), + useAccountOnceMounted: () => account.user, +})); + import { TrendsRow } from "../trends-row"; import type { DailyBriefing } from "@/lib/ai/schema"; @@ -306,4 +332,27 @@ describe("", () => { expect(html).toMatch(/data-metric="steps"/); expect(html).toContain('href="/insights/steps"'); }); + + it("plots a transformed slot in the reader's unit, scale and offset included", () => { + account.user = { unitPreference: "imperial", glucoseUnit: null }; + try { + const html = render( + , + ); + expect(html).toContain('data-unit="lb"'); + expect(html).toContain('data-unit="mi"'); + expect(html).toContain('data-unit="°F"'); + expect(html).toContain('data-offset="32"'); + expect(html).not.toContain('data-unit="kg"'); + expect(html).not.toContain('data-unit="km"'); + } finally { + account.user = null; + } + }); + + it("plots distance in kilometres, scaled from metres, for a metric reader", () => { + const html = render(); + expect(html).toContain('data-unit="km"'); + expect(html).toContain('data-scale="0.001"'); + }); }); diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx index 5ce48d013..5b72e9ba9 100644 --- a/src/components/insights/trend-annotation.tsx +++ b/src/components/insights/trend-annotation.tsx @@ -20,6 +20,7 @@ import { import { cn } from "@/lib/utils"; import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge"; import { apiGet } from "@/lib/api/api-fetch"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; /** * v1.4.20 phase B3 — single-sentence AI annotation rendered directly @@ -264,6 +265,7 @@ export function TrendDescriptorCaption({ }) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); + const { toDisplay, transformFor, unitFor } = useUnitDisplay(); const isMood = kind === "mood"; const primaryType = types[0] ?? ""; @@ -323,18 +325,28 @@ export function TrendDescriptorCaption({ } // The numeric series is already server-bounded to the trailing // 30-day window by the fetch, so every returned row is in-window. + // Described in the reader's unit, the same one the chart above plots. return (numericQuery.data ?? []).map((row) => ({ timestamp: new Date(row.measuredAt).getTime(), - value: row.value, + value: toDisplay(primaryType, row.value), })); - }, [isMood, moodQuery.data, numericQuery.data]); + }, [isMood, moodQuery.data, numericQuery.data, toDisplay, primaryType]); const descriptor = useMemo(() => { + const meta = TREND_SLOT_DESCRIPTOR_META[metric]; + // A slot that converts its series converts its noise floor with it (a + // floor is a difference: factor only). const config = isMood ? MOOD_DESCRIPTOR_CONFIG - : TREND_SLOT_DESCRIPTOR_META[metric]?.config; + : meta?.unitType + ? { + ...meta.config, + absoluteFloor: + meta.config.absoluteFloor * transformFor(meta.unitType).factor, + } + : meta?.config; return computeTrendDescriptor(points, config); - }, [points, isMood, metric]); + }, [points, isMood, metric, transformFor]); // Tier 4 — genuinely too few points (also covers the in-flight window // before the small series lands). Surface the real empty hint so the @@ -361,7 +373,7 @@ export function TrendDescriptorCaption({ // copy defensively (should not happen for the legacy triple). const copy = isMood ? moodDescriptorCopy(descriptor) - : (numericDescriptorCopy(metric, descriptor) ?? + : (numericDescriptorCopy(metric, descriptor, unitFor) ?? moodDescriptorCopy(descriptor)); return ( diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index a7056fd80..9e7b730fa 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -22,6 +22,7 @@ import { type TrendAnnotationConfidenceBand, type TrendAnnotationStatus, } from "./trend-annotation"; +import { useUnitDisplay } from "@/hooks/use-unit-display"; /** * Trends row — a small chart per metric above a one-sentence @@ -116,17 +117,29 @@ function MetricChart({ title: string; }) { const { user } = useAuth(); + const { transformFor, isTransformed } = useUnitDisplay(); const userTimezone = user?.timezone; if (config.kind === "mood") { return ; } + // A single transformed series (weight, distance, temperature) plots in + // the reader's unit: the chart folds scale and offset in at its read + // boundary, exactly as the metric's own insights page does. Distance + // used to plot raw metres under a "km" label for every reader. + const primary = config.types[0] ?? ""; + const transform = + config.types.length === 1 && isTransformed(primary) + ? transformFor(primary) + : null; return ( diff --git a/src/lib/insights/__tests__/trend-descriptor.test.ts b/src/lib/insights/__tests__/trend-descriptor.test.ts index 9b46547e2..cf7258f7b 100644 --- a/src/lib/insights/__tests__/trend-descriptor.test.ts +++ b/src/lib/insights/__tests__/trend-descriptor.test.ts @@ -106,13 +106,31 @@ describe("computeTrendDescriptor", () => { }); describe("numericDescriptorCopy", () => { + it("states a preference-bound slot in the unit the caller resolves", () => { + const d = computeTrendDescriptor(series([181.6, 178.5]), { + absoluteFloor: 0.66, + relativeFloor: 0.01, + decimals: 1, + })!; + const copy = numericDescriptorCopy("weight", d, (type) => + type === "WEIGHT" ? "lb" : "?", + ); + expect(copy?.params.unit).toBe(" lb"); + expect(TREND_SLOT_DESCRIPTOR_META.distance.unitType).toBe( + "WALKING_RUNNING_DISTANCE", + ); + expect(TREND_SLOT_DESCRIPTOR_META.body_temp.unitType).toBe( + "BODY_TEMPERATURE", + ); + }); + it("resolves the rising template with a signed delta and spaced unit", () => { const d = computeTrendDescriptor(series([120, 128]), { absoluteFloor: 2, relativeFloor: 0.02, decimals: 0, })!; - const copy = numericDescriptorCopy("bp", d); + const copy = numericDescriptorCopy("bp", d, () => "unused"); expect(copy?.key).toBe("insights.trendDescriptor.rising"); expect(copy?.params.delta).toBe("+8"); expect(copy?.params.unit).toBe(" mmHg"); @@ -124,7 +142,7 @@ describe("numericDescriptorCopy", () => { relativeFloor: 0.05, decimals: 0, })!; - const copy = numericDescriptorCopy("steps", d); + const copy = numericDescriptorCopy("steps", d, () => "unused"); expect(copy?.key).toBe("insights.trendDescriptor.rising"); expect(copy?.params.unit).toBe(""); }); @@ -135,14 +153,14 @@ describe("numericDescriptorCopy", () => { relativeFloor: 0.01, decimals: 1, })!; - const copy = numericDescriptorCopy("weight", d); + const copy = numericDescriptorCopy("weight", d, () => "kg"); expect(copy?.key).toBe("insights.trendDescriptor.falling"); expect(copy?.params.delta).toBe("−1.4"); }); it("returns null for a slot with no numeric meta (mood)", () => { const d = computeTrendDescriptor(series([3, 4]), MOOD_DESCRIPTOR_CONFIG)!; - expect(numericDescriptorCopy("mood", d)).toBeNull(); + expect(numericDescriptorCopy("mood", d, () => "")).toBeNull(); }); it("covers every numeric slot in the descriptor meta", () => { @@ -154,7 +172,7 @@ describe("numericDescriptorCopy", () => { relativeFloor: 0, decimals: 1, })!; - expect(numericDescriptorCopy(metric, d)).not.toBeNull(); + expect(numericDescriptorCopy(metric, d, () => "u")).not.toBeNull(); } }); }); diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts index fc87a27c2..1ed5b8bc9 100644 --- a/src/lib/insights/trend-chart-select.ts +++ b/src/lib/insights/trend-chart-select.ts @@ -51,7 +51,12 @@ export interface TrendChartConfig { types: string[]; /** Recharts stroke colours, positionally matched to `types`. */ colors: string[]; - /** Display unit (chart legend / tooltip). */ + /** + * Display unit (chart legend / tooltip) for a metric no preference + * changes. A single-series slot whose type has a display transform + * (weight, distance, body temperature) carries none: the renderer takes + * the unit, scale and offset from the reader's preference instead. + */ unit?: string; /** Y-axis unit label when it differs from the value unit. */ yAxisUnit?: string; @@ -114,7 +119,6 @@ export const TREND_CHART_CONFIG: Record< kind: "health-chart", types: ["WEIGHT"], colors: ["var(--chart-1)"], - unit: "kg", titleKey: "charts.weight", captionKey: "insights.trendsRow.caption.weight", annotationKey: "weight", @@ -212,8 +216,6 @@ export const TREND_CHART_CONFIG: Record< kind: "health-chart", types: ["WALKING_RUNNING_DISTANCE"], colors: ["var(--chart-4)"], - unit: "km", - yAxisUnit: "km", titleKey: "charts.distance", captionKey: "insights.trendsRow.caption.distance", detailHref: "/insights/walking-distance", @@ -234,8 +236,6 @@ export const TREND_CHART_CONFIG: Record< kind: "health-chart", types: ["BODY_TEMPERATURE"], colors: ["var(--destructive)"], - unit: "°C", - yAxisUnit: "°C", titleKey: "charts.bodyTemperature", captionKey: "insights.trendsRow.caption.body_temp", detailHref: "/insights/body-temperature", diff --git a/src/lib/insights/trend-descriptor.ts b/src/lib/insights/trend-descriptor.ts index b01ee8ffb..f42dd31aa 100644 --- a/src/lib/insights/trend-descriptor.ts +++ b/src/lib/insights/trend-descriptor.ts @@ -140,8 +140,17 @@ export function computeTrendDescriptor( * dedicated copy rather than the numeric `{delta} {unit}` template. */ export interface TrendSlotDescriptorMeta { - /** Display unit interpolated into the numeric descriptor template. */ - unit: string; + /** + * Display unit interpolated into the numeric descriptor template, for a + * metric no preference changes. + */ + unit?: string; + /** + * For a metric whose unit follows the reader's preference: the + * measurement type the caller resolves the unit of (and converts the + * series and the noise floor through) before describing it. + */ + unitType?: string; config: TrendDescriptorConfig; } @@ -154,7 +163,7 @@ export const TREND_SLOT_DESCRIPTOR_META: Record< config: { absoluteFloor: 2, relativeFloor: 0.02, decimals: 0 }, }, weight: { - unit: "kg", + unitType: "WEIGHT", config: { absoluteFloor: 0.3, relativeFloor: 0.01, decimals: 1 }, }, pulse: { @@ -186,7 +195,7 @@ export const TREND_SLOT_DESCRIPTOR_META: Record< config: { absoluteFloor: 1, relativeFloor: 0.1, decimals: 0 }, }, distance: { - unit: "km", + unitType: "WALKING_RUNNING_DISTANCE", config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 }, }, vo2_max: { @@ -194,7 +203,7 @@ export const TREND_SLOT_DESCRIPTOR_META: Record< config: { absoluteFloor: 0.5, relativeFloor: 0.02, decimals: 1 }, }, body_temp: { - unit: "°C", + unitType: "BODY_TEMPERATURE", config: { absoluteFloor: 0.2, relativeFloor: 0.005, decimals: 1 }, }, }; @@ -231,9 +240,12 @@ export interface TrendDescriptorCopy { export function numericDescriptorCopy( metric: string, descriptor: TrendDescriptor, + /** Resolves a `unitType` slot's unit under the reader's preference. */ + unitFor: (type: string) => string, ): TrendDescriptorCopy | null { const meta = TREND_SLOT_DESCRIPTOR_META[metric]; if (!meta) return null; + const unit = meta.unitType ? unitFor(meta.unitType) : (meta.unit ?? ""); // Signed, formatted magnitude (e.g. "+8", "-1.4"). `stable` still // carries its small delta so the caption can read "stable (±0 …)" if a @@ -255,7 +267,7 @@ export function numericDescriptorCopy( // reads "({delta}{unit})" → "(+8 mmHg)" with a unit, "(+1200)" // without one — no dangling space inside the parens for unit-less // metrics (steps, flights). - unit: meta.unit ? ` ${meta.unit}` : "", + unit: unit ? ` ${unit}` : "", }, }; } From 8a11e60bb30aeab5f38a81f81bcdc678b02cb287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:10:16 +0200 Subject: [PATCH 16/29] fix(cycle): state the cycle-phase glucose row in the reader's unit The cycle-phase table printed the bundle's mg/dL label beside raw mg/dL values for every reader. Weight, temperature and now glucose rows take their unit and conversion from the one reading resolver; every other row keeps its bundle label. Refs #1067 --- .../__tests__/cycle-phase-crosstab.test.tsx | 27 +++++++ src/components/cycle/cycle-phase-crosstab.tsx | 73 +++++++++++++------ 2 files changed, 79 insertions(+), 21 deletions(-) diff --git a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx index 82aec2b28..1e16d70e6 100644 --- a/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx +++ b/src/components/cycle/__tests__/cycle-phase-crosstab.test.tsx @@ -6,12 +6,17 @@ import { renderToStaticMarkup } from "react-dom/server"; // QueryClient, so mock the hook to a real metric-preference implementation — // metric is the identity on values and yields the canonical display symbols, // so the assertions below (kg, °C, unchanged numbers) hold. +const reader = vi.hoisted(() => ({ + glucoseUnit: "mg/dL" as "mg/dL" | "mmol/L", +})); + vi.mock("@/hooks/use-unit-display", async () => { const dt = await import("@/lib/measurements/display-transform"); const forType = (t: string) => dt.getDisplayTransform(t, "metric"); return { useUnitDisplay: () => ({ preference: "metric" as const, + glucoseUnit: reader.glucoseUnit, transformFor: forType, toDisplay: (t: string, v: number) => dt.applyDisplayTransform(v, forType(t)), @@ -107,4 +112,26 @@ describe("", () => { const html = render(); expect(html).not.toContain("undefined"); }); + + it("states a glucose row in mmol/L for an mmol/L reader", () => { + reader.glucoseUnit = "mmol/L"; + try { + const glucose: CyclePhaseCrosstabRow = { + ...row("bloodGlucose", "glucose"), + lutealAvg: 108, + follicularAvg: 90, + delta: 18, + }; + const html = render(); + // 18 mg/dL is 1.0 mmol/L; 108 / 90 mg/dL are 6.0 / 5.0 mmol/L. + expect(html).toContain("+1.0 mmol/L"); + expect(html).toContain("6.0"); + expect(html).not.toContain("mg/dL"); + expect(render()).toContain( + "mmol/L", + ); + } finally { + reader.glucoseUnit = "mg/dL"; + } + }); }); diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx index 8303c18d3..0a9fe400d 100644 --- a/src/components/cycle/cycle-phase-crosstab.tsx +++ b/src/components/cycle/cycle-phase-crosstab.tsx @@ -1,7 +1,12 @@ "use client"; import { useTranslations } from "@/lib/i18n/context"; -import { useUnitDisplay } from "@/hooks/use-unit-display"; +import { useUnitDisplay, type UnitDisplay } from "@/hooks/use-unit-display"; +import { + applyDisplayTransform, + applyDisplayTransformDelta, + getReadingTransform, +} from "@/lib/measurements/display-transform"; import { cn } from "@/lib/utils"; /** @@ -88,8 +93,47 @@ function fmt(value: number, display: CyclePhaseCrosstabDisplay): string { const DISPLAY_TO_TYPE: Partial> = { kg: "WEIGHT", celsius: "BODY_TEMPERATURE", + glucose: "BLOOD_GLUCOSE", }; +/** + * Unit symbol and conversions for one row, from the reader's preferences. + * Glucose follows the glucose unit (it used to print the mg/dL label from + * the bundle beside raw mg/dL values for every reader); weight and + * temperature follow metric/imperial; every other display keeps its bundle + * label and its raw value. + */ +function rowUnits( + display: CyclePhaseCrosstabDisplay, + unitDisplay: UnitDisplay, + t: (key: string) => string, +): { + unit: string; + abs: (v: number) => number; + delta: (v: number) => number; +} { + const type = DISPLAY_TO_TYPE[display]; + if (!type) { + // Defensive: an unmapped display must never pass undefined to `t` + // (whose resolver does `key.split(".")`). Empty unit on a miss. + const unitKey = UNIT_KEY[display]; + return { + unit: unitKey ? t(unitKey) : "", + abs: (v) => v, + delta: (v) => v, + }; + } + const transform = getReadingTransform(type, { + system: unitDisplay.preference, + glucoseUnit: unitDisplay.glucoseUnit, + }); + return { + unit: transform.displayUnit, + abs: (v) => applyDisplayTransform(v, transform), + delta: (v) => applyDisplayTransformDelta(v, transform), + }; +} + const HEADLINE_KEY: Record = { restingHeartRate: "cycle.insights.headline.restingHeartRate", heartRateVariability: "cycle.insights.headline.heartRateVariability", @@ -120,11 +164,7 @@ export function CyclePhaseHeadline({ const metricLabel = t( METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey, ); - // Defensive: an unmapped display must never pass undefined to `t` (whose - // resolver does `key.split(".")`). An empty unit is the honest degrade. - const mt = DISPLAY_TO_TYPE[headline.display]; - const unitKey = UNIT_KEY[headline.display]; - const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : ""; + const { unit, delta: deltaConv } = rowUnits(headline.display, unitDisplay, t); const up = headline.delta >= 0; const dir = t( up @@ -134,12 +174,7 @@ export function CyclePhaseHeadline({ const params = { metric: metricLabel, // A phase delta → factor-only conversion (no affine offset). - delta: fmt( - Math.abs( - mt ? unitDisplay.toDisplayDelta(mt, headline.delta) : headline.delta, - ), - headline.display, - ), + delta: fmt(Math.abs(deltaConv(headline.delta)), headline.display), unit, dir, }; @@ -175,15 +210,11 @@ export function CyclePhaseCrosstab({ const metricLabel = t( METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey, ); - // Defensive: an unmapped display must never pass undefined to `t` - // (whose resolver does `key.split(".")`). Empty unit on a miss. - const mt = DISPLAY_TO_TYPE[row.display]; - const unitKey = UNIT_KEY[row.display]; - const unit = mt ? unitDisplay.unitFor(mt) : unitKey ? t(unitKey) : ""; - const absConv = (v: number) => - mt ? unitDisplay.toDisplay(mt, v) : v; - const deltaConv = (v: number) => - mt ? unitDisplay.toDisplayDelta(mt, v) : v; + const { + unit, + abs: absConv, + delta: deltaConv, + } = rowUnits(row.display, unitDisplay, t); // `delta` = lutealAvg − follicularAvg: positive = the vital runs // higher in the luteal phase. The number stays NEUTRAL — higher is // good for steps but bad for resting HR, and this board is From b788b5b8b2b981df6fe3c17bdc8e5de8adbdf08c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:10:17 +0200 Subject: [PATCH 17/29] test(units): sweep every display tree for unit literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit-preference guard covered the dashboard, the insights pages and the target surfaces, and the class kept turning up beside them. It now also walks src/components, src/app, src/lib/insights and the doctor report and export modules for the glucose units anywhere in code and for kg, km and °C in display strings, with comments stripped. Each file that legitimately carries one is listed with its exact line budget and the reason: the conversion and the pickers, input parsing, display-kind tags, canonical wire and registry units, the SI policy for clinician artefacts, and four prompt texts still to move. The sweep asserts a match floor, so a broken walk cannot pass as a clean tree. Refs #1067 --- .../unit-preference-display-guard.test.ts | 219 +++++++++++++++++- 1 file changed, 218 insertions(+), 1 deletion(-) diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index a90ff2dbb..693048d76 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -13,7 +13,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { walkSourceFiles } from "./helpers/source-files"; +import { stripComments, walkSourceFiles } from "./helpers/source-files"; import { describe, it, expect } from "vitest"; @@ -207,3 +207,220 @@ describe("unit-preference display guard", () => { } }); }); + +/** + * The wide sweep (#1067). The block above guards the surfaces that were + * wired for metric/imperial; the class kept reappearing everywhere else — a + * clinician view without units, status notes and narratives pinned to mg/dL + * and kilograms, a glucose threshold editor in mg/dL for an mmol/L account, + * workout distances in kilometres for an imperial reader. Each one was a + * unit literal in a display string. + * + * So every display-bearing tree is swept for the literals a preference + * changes: the two glucose units anywhere in code, and kilograms, + * kilometres and degrees Celsius where they sit in a display string (a bare + * quoted unit, a unit after an interpolation or a number, a unit closing a + * string, a per-kilometre pace). Comments are stripped first. A file that + * legitimately carries one is listed below with the exact number of matching + * lines and the reason; any other file, or one more line in a listed file, + * fails. A listed file that drops a line fails too, so the budget follows + * the code down instead of leaving headroom for the next literal. + * + * The limit, stated: this is a line scanner. A unit spelled through a + * variable named after it, or a literal split across lines, passes. The + * message bundles are not swept either (the fever and cycle-glucose labels + * lived there). + */ +const WIDE_SWEEP_ROOTS = [ + "components/", + "app/", + "lib/insights/", + "lib/doctor-report", + "lib/export", +]; + +const UNIT_LITERAL_PATTERNS: readonly RegExp[] = [ + // Glucose, anywhere in code. + /mg\/dL|mmol\/L/, + // A bare quoted unit: "kg", 'km', `°C`. + /(["'`])\s*(?:kg|km|°C)\s*\1/, + // A unit after an interpolation: `${x} kg`, {value} km in JSX. + /\}\s?(?:kg|km|°C)(?![\w/²])/, + // A unit after a number: "75 kg", "37.0 °C". + /\d\s?(?:kg|km|°C)(?![\w/²])/, + // A unit closing a string or a JSX text run: " km<", " kg\"". + /\s(?:kg|km|°C)(?=["'`<])/, + // A per-kilometre pace. + /\/km\b/, +]; + +/** + * Files allowed to carry a unit literal, with the exact number of matching + * lines and why. `kg/m²` (BMI) never matches; every other hit is listed. + */ +const UNIT_LITERAL_ALLOWLIST: Record< + string, + { lines: number; reason: string } +> = { + // ── The conversion and the choice themselves ── + "components/settings/glucose-unit-select.tsx": { + lines: 2, + reason: "the glucose unit picker: its options are the two units", + }, + "components/onboarding/units-screen.tsx": { + lines: 3, + reason: "the onboarding unit picker: its options are the two units", + }, + "components/measurements/measurement-form.tsx": { + lines: 11, + reason: + "input parsing: canonical units of the entry table, replaced by the transform's unit at render and inverted on save", + }, + "components/measurements/measurement-list.tsx": { + lines: 2, + reason: "input parsing: the inline edit inverts an mmol/L entry to mg/dL", + }, + "components/settings/import-panel/import-examples.ts": { + lines: 3, + reason: "input parsing: CSV examples showing the units an import accepts", + }, + "components/onboarding/first-result-screen.tsx": { + lines: 1, + reason: "decimals chosen by the resolved glucose unit, not a label", + }, + "app/insights/blood-glucose/page.tsx": { + lines: 1, + reason: "branches on the resolved glucose unit to scale the chart", + }, + "components/insights/glucose/glucose-clinical-panel.tsx": { + lines: 1, + reason: "branches on the resolved glucose unit for its decimals", + }, + "components/insights/health-score-pillar-detail.ts": { + lines: 2, + reason: + "branches on the canonical observed unit before converting to the reader's", + }, + // ── Display-kind tags, converted through the preference at render ── + "components/cycle/cycle-phase-crosstab.tsx": { + lines: 1, + reason: "a display-kind tag; rows convert through getReadingTransform", + }, + "lib/insights/mood-crosstab.ts": { + lines: 2, + reason: "a display-kind tag; the weight row converts through the hook", + }, + "components/insights/mood/mood-factor-metric-crosstab.tsx": { + lines: 2, + reason: "a display-kind tag; the weight row converts through the hook", + }, + // ── Canonical by contract ── + "lib/insights/metric-status-registry.ts": { + lines: 11, + reason: + "canonical registry units; metric-status.ts converts to the reader's before writing", + }, + "lib/export.ts": { + lines: 1, + reason: "canonical default when an export caller passes no glucose unit", + }, + "app/api/export/route.ts": { + lines: 1, + reason: "the legacy export endpoint's canonical storage-unit contract", + }, + "app/api/measurements/series/route.ts": { + lines: 4, + reason: + "canonical wire units; glucose is overridden to the reader's unit per request", + }, + "lib/doctor-report/stat-display.ts": { + lines: 3, + reason: + "SI by policy: the clinician artefacts keep mass in kg (glucose follows the owner)", + }, + "components/insights/workout-detail/splits.tsx": { + lines: 1, + reason: "kilometre splits are cut server-side; their pace is per km", + }, + "app/privacy/page.tsx": { + lines: 2, + reason: "prose naming both glucose units the app offers", + }, + // ── Still canonical; the prompt-unit pass for these is open (#1067) ── + "lib/insights/comprehensive-generate.ts": { + lines: 1, + reason: "comprehensive prompt still states weight in kg", + }, + "lib/insights/signals-of-day.ts": { + lines: 1, + reason: "briefing signal still states weight in kg", + }, + "lib/insights/weight-status.ts": { + lines: 1, + reason: "weight status snapshot still states kg", + }, + "lib/insights/glp1-plateau.ts": { + lines: 2, + reason: "GLP-1 plateau prompt context still states kg", + }, +}; + +function unitLiteralLines(): Map { + const files = walkSourceFiles(SRC, { floor: 1400 }).filter( + (rel) => + WIDE_SWEEP_ROOTS.some((root) => rel.startsWith(root)) && + !rel.includes("__tests__") && + !/\.test\.tsx?$/.test(rel), + ); + if (files.length < 1200) { + throw new Error( + `unit sweep narrowed to ${files.length} files; a sweep this small is a broken filter`, + ); + } + const hits = new Map(); + for (const rel of files) { + const lines = stripComments(readFileSync(join(SRC, rel), "utf8")) + .split("\n") + .filter((line) => UNIT_LITERAL_PATTERNS.some((p) => p.test(line))) + .map((line) => line.trim()); + if (lines.length > 0) hits.set(rel, lines); + } + return hits; +} + +describe("unit-preference display guard — the wide sweep", () => { + const hits = unitLiteralLines(); + + it("finds the literals it is meant to find (a sweep that matches nothing proves nothing)", () => { + const total = [...hits.values()].reduce((n, l) => n + l.length, 0); + expect(total).toBeGreaterThanOrEqual(40); + expect(hits.size).toBeGreaterThanOrEqual(15); + }); + + it("no display surface carries a unit literal outside the allowlist", () => { + const offenders: string[] = []; + for (const [rel, lines] of hits) { + const allowed = UNIT_LITERAL_ALLOWLIST[rel]; + if (!allowed) { + offenders.push(`${rel}: ${lines.join(" | ")}`); + } else if (lines.length > allowed.lines) { + offenders.push( + `${rel}: ${lines.length} unit-literal lines, allowlisted ${allowed.lines}: ${lines.join(" | ")}`, + ); + } + } + expect(offenders).toEqual([]); + }); + + it("every allowlist entry still matches exactly its budget, with a reason", () => { + const stale: string[] = []; + for (const [rel, allowed] of Object.entries(UNIT_LITERAL_ALLOWLIST)) { + expect(allowed.reason.length).toBeGreaterThan(10); + const found = hits.get(rel)?.length ?? 0; + if (found !== allowed.lines) { + stale.push(`${rel}: allowlisted ${allowed.lines}, found ${found}`); + } + } + expect(stale).toEqual([]); + }); +}); From 0039bd1d1f8e1f95f5610fef02ff44ff928c9d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:11:47 +0200 Subject: [PATCH 18/29] refactor(insights): use the shared rescale predicate in the status note Refs #1067 --- src/lib/insights/metric-status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/insights/metric-status.ts b/src/lib/insights/metric-status.ts index b937b689c..8ee1c927c 100644 --- a/src/lib/insights/metric-status.ts +++ b/src/lib/insights/metric-status.ts @@ -679,7 +679,7 @@ function readingDisplay( meta.measurementType, preferences, ); - const converts = transform.factor !== 1 || (transform.offset ?? 0) !== 0; + const converts = transformRescales(transform); return { unit: transform.displayUnit, decimals: transform.decimals, From 9a282ed698ebd7661e079b8ec3094b4a901c2c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:17:44 +0200 Subject: [PATCH 19/29] fix(units): keep converted values on the rounding helpers The narrative, the workout distance and the elevation chart converted through the unrounded escape hatch, and the trend caption scaled its noise floor off the raw factor. They now take the rounded helpers, so a narrated pound or mmol/L figure reads at the grain every screen shows it, and the caption's floors are declared canonically (the distance floor in metres). The trends row joins the chart-feeding surfaces that may hand a raw scale to a chart. Refs #1067 --- .../display-transform-rounding-guard.test.ts | 5 ++++- src/components/charts/workout-elevation-chart.tsx | 6 +++--- src/components/insights/trend-annotation.tsx | 14 ++++++++------ src/components/insights/workout-detail/format.ts | 3 +-- .../narrative/__tests__/period-narrative.test.ts | 14 +++++++------- src/lib/insights/narrative/period-narrative.ts | 9 ++++++--- src/lib/insights/trend-descriptor.ts | 6 ++++-- 7 files changed, 33 insertions(+), 24 deletions(-) diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts index 7db775374..73b928296 100644 --- a/src/__tests__/display-transform-rounding-guard.test.ts +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -52,7 +52,7 @@ const REGISTRY = "lib/measurements/display-transform.ts"; const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"]; /** - * The surfaces allowed to read a transform's raw `factor` / `offset`. All four + * The surfaces allowed to read a transform's raw `factor` / `offset`. All five * do it for the same reason: the chart takes the multiplier as a prop, folds * it into its own series at the single read boundary, and formats the result * itself. Everything those files RENDER directly goes through the rounding @@ -61,6 +61,9 @@ const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"]; */ const RAW_SCALE_SURFACES = [ "app/page-client.tsx", + // The insights trends row: a single transformed series hands its scale + // and offset to the mini chart, which formats every value itself. + "components/insights/trends-row.tsx", "app/insights/weight/page.tsx", "components/insights/healthkit-metric-page.tsx", "components/insights/device-score-tile.tsx", diff --git a/src/components/charts/workout-elevation-chart.tsx b/src/components/charts/workout-elevation-chart.tsx index 3fd0cfa93..dbf0382fc 100644 --- a/src/components/charts/workout-elevation-chart.tsx +++ b/src/components/charts/workout-elevation-chart.tsx @@ -13,7 +13,7 @@ import { import { useTranslations } from "@/lib/i18n/context"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { - applyDisplayTransformUnrounded, + applyDisplayTransform, getQuantityTransform, } from "@/lib/measurements/display-transform"; @@ -41,8 +41,8 @@ export function WorkoutElevationChart({ const distance = getQuantityTransform("distance", preference); const elevation = getQuantityTransform("elevation", preference); const data = points.map((p) => ({ - dist: applyDisplayTransformUnrounded(p.distanceM, distance), - alt: applyDisplayTransformUnrounded(p.altitude, elevation), + dist: applyDisplayTransform(p.distanceM, distance), + alt: applyDisplayTransform(p.altitude, elevation), })); return ( diff --git a/src/components/insights/trend-annotation.tsx b/src/components/insights/trend-annotation.tsx index 5b72e9ba9..7551a88cc 100644 --- a/src/components/insights/trend-annotation.tsx +++ b/src/components/insights/trend-annotation.tsx @@ -265,7 +265,7 @@ export function TrendDescriptorCaption({ }) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); - const { toDisplay, transformFor, unitFor } = useUnitDisplay(); + const { toDisplay, toDisplayDelta, unitFor } = useUnitDisplay(); const isMood = kind === "mood"; const primaryType = types[0] ?? ""; @@ -334,19 +334,21 @@ export function TrendDescriptorCaption({ const descriptor = useMemo(() => { const meta = TREND_SLOT_DESCRIPTOR_META[metric]; - // A slot that converts its series converts its noise floor with it (a - // floor is a difference: factor only). + // A slot that converts its series converts its noise floor with it: the + // floor is declared canonically and is a difference (factor only). const config = isMood ? MOOD_DESCRIPTOR_CONFIG : meta?.unitType ? { ...meta.config, - absoluteFloor: - meta.config.absoluteFloor * transformFor(meta.unitType).factor, + absoluteFloor: toDisplayDelta( + meta.unitType, + meta.config.absoluteFloor, + ), } : meta?.config; return computeTrendDescriptor(points, config); - }, [points, isMood, metric, transformFor]); + }, [points, isMood, metric, toDisplayDelta]); // Tier 4 — genuinely too few points (also covers the in-flight window // before the small series lands). Surface the real empty hint so the diff --git a/src/components/insights/workout-detail/format.ts b/src/components/insights/workout-detail/format.ts index 03e559ea1..9dfb6eadd 100644 --- a/src/components/insights/workout-detail/format.ts +++ b/src/components/insights/workout-detail/format.ts @@ -1,7 +1,6 @@ import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache"; import { applyDisplayTransform, - applyDisplayTransformUnrounded, getQuantityTransform, paceSecondsPerDistanceUnit, type UnitPreference, @@ -46,7 +45,7 @@ export function formatDistance( preference: UnitPreference, ): string { const transform = getQuantityTransform("distance", preference); - const value = applyDisplayTransformUnrounded(meters, transform); + const value = applyDisplayTransform(meters, transform); const formatted = getNumberFormat(locale, { maximumFractionDigits: 2, minimumFractionDigits: value < 10 ? 2 : 1, diff --git a/src/lib/insights/narrative/__tests__/period-narrative.test.ts b/src/lib/insights/narrative/__tests__/period-narrative.test.ts index 99e3c7e68..83abb0ed5 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative.test.ts @@ -156,15 +156,15 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => { ); const g = ctx.metricDeltas.find((d) => d.type === "BLOOD_GLUCOSE")!; expect(g.unit).toBe("mmol/L"); - expect(g.current).toBe(5.99); - expect(g.prior).toBe(4.99); + expect(g.current).toBe(6); + expect(g.prior).toBe(5); expect(g.delta).toBe(1); // Percent is unit-free. expect(g.deltaPercent).toBe(20); const w = ctx.metricDeltas.find((d) => d.type === "WEIGHT")!; expect(w.unit).toBe("lb"); - expect(w.current).toBe(180.78); - expect(w.delta).toBe(4.41); + expect(w.current).toBe(180.8); + expect(w.delta).toBe(4.4); const prose = buildDeterministicNarrative(ctx, "en"); expect(prose).toContain("mmol/L"); @@ -172,7 +172,7 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => { expect(prose).not.toContain("mg/dL"); expect(prose).not.toContain("kg"); const prompt = buildNarrativeUserPrompt(ctx, "en"); - expect(prompt).toContain("BLOOD_GLUCOSE: 5.99 mmol/L"); + expect(prompt).toContain("BLOOD_GLUCOSE: 6 mmol/L"); expect(prompt).not.toContain("mg/dL"); }); @@ -231,10 +231,10 @@ describe("assemblePeriodNarrativeContext — the reader's units", () => { ); const t = ctx.bandTransitions.find((b) => b.type === "BLOOD_GLUCOSE")!; expect(t.unit).toBe("mmol/L"); - expect(t.center).toBe(7.77); + expect(t.center).toBe(7.8); expect(t.movedOut).toBe(true); expect(buildNarrativeUserPrompt(ctx, "en")).toContain( - "BLOOD_GLUCOSE: 7.77 mmol/L", + "BLOOD_GLUCOSE: 7.8 mmol/L", ); }); }); diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index c03deab01..807ade7f6 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -57,7 +57,8 @@ import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; import { resolveModuleMap } from "@/lib/modules/gate"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import { - applyDisplayTransformUnrounded, + applyDisplayTransform, + applyDisplayTransformDelta, getReadingTransform, isUnitSensitiveType, resolveUnitPreferences, @@ -135,8 +136,10 @@ function narratedUnit( const transform = getReadingTransform(type, units); return { unit: transform.displayUnit, - abs: (v) => round2(applyDisplayTransformUnrounded(v, transform)), - delta: (v) => round2(v * transform.factor), + // Rounded to the unit's own grain (one decimal for lb and mmol/L), the + // grain every screen shows the same reading at. + abs: (v) => round2(applyDisplayTransform(v, transform)), + delta: (v) => round2(applyDisplayTransformDelta(v, transform)), }; } if (type === "SLEEP_DURATION") { diff --git a/src/lib/insights/trend-descriptor.ts b/src/lib/insights/trend-descriptor.ts index f42dd31aa..fea9ec7a7 100644 --- a/src/lib/insights/trend-descriptor.ts +++ b/src/lib/insights/trend-descriptor.ts @@ -148,7 +148,8 @@ export interface TrendSlotDescriptorMeta { /** * For a metric whose unit follows the reader's preference: the * measurement type the caller resolves the unit of (and converts the - * series and the noise floor through) before describing it. + * series and the noise floor through) before describing it. Such a slot + * declares its `absoluteFloor` in the CANONICAL unit (kg, m, °C). */ unitType?: string; config: TrendDescriptorConfig; @@ -196,7 +197,8 @@ export const TREND_SLOT_DESCRIPTOR_META: Record< }, distance: { unitType: "WALKING_RUNNING_DISTANCE", - config: { absoluteFloor: 0.3, relativeFloor: 0.05, decimals: 1 }, + // 300 m — canonical metres, converted to the reader's km or mi. + config: { absoluteFloor: 300, relativeFloor: 0.05, decimals: 1 }, }, vo2_max: { unit: "", From ac3fdfccc3002afc2d4e4713af2cfdb501abae88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:23:45 +0200 Subject: [PATCH 20/29] fix(jobs): keep long passes inside their expiry and alone pg-boss gives a job fifteen minutes unless the send says otherwise, then fails it and retries it while the handler keeps running. Several passes can legitimately need longer and had no protection at all: the seven nightly status passes and the pre-generation (a provider call per account), the period-narrative warm, the record-detection cron (every account's history, every thirty minutes), the key rotation (every encrypted column), the content-index backfill (up to 200 transcriptions) and the on-host backup. Each of them now carries an explicit expiry on every send and schedule, stops between units once three quarters of it is spent (jobBudget), and runs under a session advisory lock keyed on queue and identity (lockedPass), so the retry pg-boss starts after the expiry does nothing beside a run that is still going. The lock is the one the full-history imports already held, moved into job-lock.ts. Where a pass stops early it says so (stopped_early) and the next run continues: the status passes and the narrative warm leave the rest to the next night, the pre-generation and the narratives walk the stalest accounts first, the on-host backup now backs up the oldest copies first, the key rotation reports a partial run in its audit row and skips rotated rows when run again, and the dose-history import fails the delivery so its retry resumes from the committed chunk. The off-host backup, which already stopped on its budget, gets a four-hour expiry and the lock. --- src/app/api/admin/encryption/rotate/route.ts | 2 + src/lib/crypto/encryption-corpus.ts | 96 +++++++---- .../__tests__/insight-pregenerate.test.ts | 27 ++- src/lib/jobs/__tests__/long-pass.test.ts | 74 ++++++++ .../__tests__/period-narrative-warm.test.ts | 2 +- .../__tests__/status-cron-candidates.test.ts | 4 +- src/lib/jobs/backup-restore.ts | 7 +- .../jobs/document-content-index-backfill.ts | 42 ++++- src/lib/jobs/encryption-key-rotate.ts | 23 ++- src/lib/jobs/insight-pregenerate-shared.ts | 11 ++ src/lib/jobs/insight-pregenerate.ts | 14 ++ src/lib/jobs/integration-backfill-lock.ts | 40 ++--- src/lib/jobs/job-lock.ts | 58 +++++++ src/lib/jobs/long-pass.ts | 58 +++++++ src/lib/jobs/medication-intake-import.ts | 43 ++++- src/lib/jobs/offhost-backup.ts | 9 + src/lib/jobs/period-narrative-shared.ts | 10 ++ src/lib/jobs/period-narrative-warm.ts | 14 ++ src/lib/jobs/pr-detection.ts | 11 ++ .../__tests__/backup-handlers.test.ts | 42 +++++ src/lib/jobs/reminder/backup-handlers.ts | 36 +++- src/lib/jobs/reminder/insights-handlers.ts | 88 ++++++++-- src/lib/jobs/reminder/ops-handlers.ts | 11 ++ src/lib/jobs/reminder/register-maintenance.ts | 144 ++++++++++------ src/lib/jobs/reminder/register-status.ts | 159 ++++++++++++------ src/lib/jobs/reminder/registrar-shared.ts | 16 ++ 26 files changed, 837 insertions(+), 204 deletions(-) create mode 100644 src/lib/jobs/__tests__/long-pass.test.ts create mode 100644 src/lib/jobs/job-lock.ts create mode 100644 src/lib/jobs/long-pass.ts diff --git a/src/app/api/admin/encryption/rotate/route.ts b/src/app/api/admin/encryption/rotate/route.ts index 0beacfe7f..e3f8dc063 100644 --- a/src/app/api/admin/encryption/rotate/route.ts +++ b/src/app/api/admin/encryption/rotate/route.ts @@ -33,6 +33,7 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { ENCRYPTION_KEY_ROTATE_QUEUE, ENCRYPTION_KEY_ROTATE_SINGLETON, + ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS, } from "@/lib/jobs/encryption-key-rotate"; import { getActiveKeyId } from "@/lib/crypto"; import { NextRequest } from "next/server"; @@ -57,6 +58,7 @@ export const POST = apiHandler(async (request: NextRequest) => { singletonKey: ENCRYPTION_KEY_ROTATE_SINGLETON, retryLimit: 2, retryDelay: 60, + expireInSeconds: ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS, }, ); diff --git a/src/lib/crypto/encryption-corpus.ts b/src/lib/crypto/encryption-corpus.ts index d470a163b..c55240731 100644 --- a/src/lib/crypto/encryption-corpus.ts +++ b/src/lib/crypto/encryption-corpus.ts @@ -390,19 +390,29 @@ export interface RotationResult { dropped: number; } -/** Re-encrypt one column's stale rows to the active key. */ +/** Thrown out of a column walk when the caller's time budget is spent. */ +class RotationStopped extends Error {} + +/** + * Re-encrypt one column's stale rows to the active key. `shouldStop` is the + * caller's time budget, checked between rows; a stopped column reports + * `stopped` and the rows it did not reach stay on their old key for the next + * run, which skips every row already rotated. + */ export async function rotateColumn( client: CorpusClient, col: EncryptedColumn, -): Promise { + shouldStop: () => boolean = () => false, +): Promise { const delegate = getDelegate(client, col.model); - const result: RotationResult = { + const result: RotationResult & { stopped: boolean } = { model: col.model, field: col.field, scanned: 0, rotated: 0, errors: 0, dropped: 0, + stopped: false, }; /** @@ -432,6 +442,10 @@ export async function rotateColumn( // (`convertSingleValueBackup`). A row converted, replaced or deleted since // it was listed is gone, not an error. for await (const id of singleValueIds(delegate)) { + if (shouldStop()) { + result.stopped = true; + break; + } result.scanned += 1; try { const outcome = await convertSingleValueBackup( @@ -452,31 +466,37 @@ export async function rotateColumn( // Idempotent — rows already on the active key are skipped, so an // interrupted run resumes cleanly on the next invocation. `scanned` counts // the rows that hold ciphertext, whatever the column. - await walkColumn(delegate, col, async ({ id, value, codec }) => { - result.scanned += 1; - if (walkedKeyId(value, codec, col.kind) === getActiveKeyId()) return; - try { - // The plaintext is never inspected, only re-sealed, so every envelope - // a stored value can legitimately carry survives rotation untouched. - const next = - codec !== null - ? reencryptBlob(value as Uint8Array, codec, col.aad) - : fromCiphertext( - encrypt(decrypt(toCiphertext(value, col.kind)!)), - col.kind, - ); - await delegate.update({ - where: { [pkField(col)]: id }, - data: { [col.field]: next }, - }); - result.rotated += 1; - } catch (err) { - // Deleted between the read and the write (a backup replacing its - // pieces, an account going): nothing is left to rotate. - if (isRowGone(err)) return; - await onUnreadable(id); - } - }); + try { + await walkColumn(delegate, col, async ({ id, value, codec }) => { + if (shouldStop()) throw new RotationStopped(); + result.scanned += 1; + if (walkedKeyId(value, codec, col.kind) === getActiveKeyId()) return; + try { + // The plaintext is never inspected, only re-sealed, so every envelope + // a stored value can legitimately carry survives rotation untouched. + const next = + codec !== null + ? reencryptBlob(value as Uint8Array, codec, col.aad) + : fromCiphertext( + encrypt(decrypt(toCiphertext(value, col.kind)!)), + col.kind, + ); + await delegate.update({ + where: { [pkField(col)]: id }, + data: { [col.field]: next }, + }); + result.rotated += 1; + } catch (err) { + // Deleted between the read and the write (a backup replacing its + // pieces, an account going): nothing is left to rotate. + if (isRowGone(err)) return; + await onUnreadable(id); + } + }); + } catch (err) { + if (!(err instanceof RotationStopped)) throw err; + result.stopped = true; + } return result; } @@ -488,16 +508,31 @@ export interface CorpusRotation { totalErrors: number; /** Unreadable rows deleted from `disposable` columns. */ totalDropped: number; + /** + * The time budget ran out before every column was walked. Running again + * finishes the job: rows already on the active key are skipped. + */ + stoppedEarly: boolean; } -/** Re-encrypt the whole corpus to the active key. Idempotent + active-key-only. */ +/** + * Re-encrypt the whole corpus to the active key. Idempotent + active-key-only. + * `shouldStop` is the caller's time budget (`jobBudget` in the worker). + */ export async function rotateCorpus( client: CorpusClient, + shouldStop: () => boolean = () => false, ): Promise { const activeKeyId = getActiveKeyId(); const results: RotationResult[] = []; + let stoppedEarly = false; for (const col of ENCRYPTED_COLUMNS) { - results.push(await rotateColumn(client, col)); + const { stopped, ...result } = await rotateColumn(client, col, shouldStop); + results.push(result); + if (stopped) { + stoppedEarly = true; + break; + } } let totalScanned = 0; let totalRotated = 0; @@ -516,5 +551,6 @@ export async function rotateCorpus( totalRotated, totalErrors, totalDropped, + stoppedEarly, }; } diff --git a/src/lib/jobs/__tests__/insight-pregenerate.test.ts b/src/lib/jobs/__tests__/insight-pregenerate.test.ts index 0d0026a0a..163d0d209 100644 --- a/src/lib/jobs/__tests__/insight-pregenerate.test.ts +++ b/src/lib/jobs/__tests__/insight-pregenerate.test.ts @@ -207,6 +207,31 @@ describe("findPregenerateCandidates", () => { }); }); +describe("runInsightPregenerate — the job's time budget", () => { + it("stops between candidates once the budget is spent and says so", async () => { + capabilities = { + briefing: unavailable("user_disabled"), + statusText: unavailable("user_disabled"), + }; + const { prisma } = makePrisma([ + { id: "u1", locale: "de" }, + { id: "u2", locale: "de" }, + { id: "u3", locale: "de" }, + ]); + let checks = 0; + const result = await runInsightPregenerate(prisma as never, { + generate: vi.fn(), + statusGenerators: [], + warmGenericMetrics: vi.fn().mockResolvedValue(0), + // Spent after the first candidate. + shouldStop: () => checks++ >= 1, + }); + + expect(result).toMatchObject({ total: 3, skipped: 1, stoppedEarly: true }); + expect(aiCapabilityForJob).toHaveBeenCalledTimes(2); + }); +}); + describe("runInsightPregenerate — per-user capabilities", () => { it("skips a user with neither capability before any budget write or generation", async () => { capabilities = { @@ -1337,7 +1362,7 @@ describe("queue registration", () => { it("schedules the cron in the schedules table (with retry policy)", () => { expect(workerSrc).toMatch( - /\[\s*INSIGHT_PREGENERATE_QUEUE\s*,\s*INSIGHT_PREGENERATE_CRON\s*,\s*insightRetryOptions\s*\]/, + /\[\s*INSIGHT_PREGENERATE_QUEUE\s*,\s*INSIGHT_PREGENERATE_CRON\s*,\s*\{\s*\.\.\.insightRetryOptions\s*,\s*expireInSeconds:\s*INSIGHT_PREGENERATE_EXPIRE_SECONDS\s*,?\s*\}\s*,?\s*\]/, ); }); diff --git a/src/lib/jobs/__tests__/long-pass.test.ts b/src/lib/jobs/__tests__/long-pass.test.ts new file mode 100644 index 000000000..31a750fc3 --- /dev/null +++ b/src/lib/jobs/__tests__/long-pass.test.ts @@ -0,0 +1,74 @@ +/** + * `lockedPass`: each job runs under the lock `job::`, and a + * delivery that finds the lock held does no work and succeeds. + * + * The lock itself (a session advisory lock on a connection of its own) is + * exercised against Postgres in + * `tests/integration/integration-backfill-lane-expiry.test.ts`; this pins the + * wrapper around it. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Job } from "pg-boss"; + +const held = vi.hoisted(() => new Set()); +const keys = vi.hoisted(() => [] as string[]); + +vi.mock("@/lib/jobs/job-lock", () => ({ + withJobLock: async (key: string, run: () => Promise) => { + keys.push(key); + if (held.has(key)) return { ran: false }; + held.add(key); + try { + return { ran: true, result: await run() }; + } finally { + held.delete(key); + } + }, +})); + +import { jobDone, jobFailed } from "@/lib/jobs/job-outcome"; +import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass"; + +type Payload = { userId?: string }; +const job = (data: Payload) => ({ id: "j", data }) as Job; + +beforeEach(() => { + held.clear(); + keys.length = 0; +}); + +describe("lockedPass", () => { + it("runs each job under its queue and identity", async () => { + const handler = vi.fn(async () => jobDone({ generated: 1 })); + const bound = lockedPass( + "q", + (j: Job) => + j.data.userId ? `user:${j.data.userId}` : WHOLE_PASS, + handler, + ); + const outcome = await bound([job({ userId: "a" })]); + expect(outcome).toEqual(jobDone({ generated: 1 })); + await bound([job({})]); + expect(keys).toEqual(["job:q:user:a", "job:q:pass"]); + }); + + it("does nothing beside a run that still holds the lock", async () => { + held.add("job:q:pass"); + const handler = vi.fn(async () => jobDone()); + const outcome = await lockedPass("q", () => WHOLE_PASS, handler)([job({})]); + expect(handler).not.toHaveBeenCalled(); + expect(outcome).toEqual(jobDone({ already_running: 1 })); + }); + + it("passes a failed outcome through", async () => { + const failed = jobFailed("stopped early", undefined, { + stopped_early: true, + }); + const outcome = await lockedPass( + "q", + () => WHOLE_PASS, + async () => failed, + )([job({})]); + expect(outcome).toBe(failed); + }); +}); diff --git a/src/lib/jobs/__tests__/period-narrative-warm.test.ts b/src/lib/jobs/__tests__/period-narrative-warm.test.ts index 58e270c6d..a41071ad5 100644 --- a/src/lib/jobs/__tests__/period-narrative-warm.test.ts +++ b/src/lib/jobs/__tests__/period-narrative-warm.test.ts @@ -250,7 +250,7 @@ describe("queue registration", () => { it("schedules the cron in the schedules table (with retry policy)", () => { expect(workerSrc).toMatch( - /\[\s*PERIOD_NARRATIVE_QUEUE\s*,\s*PERIOD_NARRATIVE_CRON\s*,\s*insightRetryOptions\s*\]/, + /\[\s*PERIOD_NARRATIVE_QUEUE\s*,\s*PERIOD_NARRATIVE_CRON\s*,\s*\{\s*\.\.\.insightRetryOptions\s*,\s*expireInSeconds:\s*PERIOD_NARRATIVE_EXPIRE_SECONDS\s*,?\s*\}\s*,?\s*\]/, ); }); diff --git a/src/lib/jobs/__tests__/status-cron-candidates.test.ts b/src/lib/jobs/__tests__/status-cron-candidates.test.ts index fcd97978a..075c531ab 100644 --- a/src/lib/jobs/__tests__/status-cron-candidates.test.ts +++ b/src/lib/jobs/__tests__/status-cron-candidates.test.ts @@ -190,9 +190,9 @@ describe("mood-status queue registration (dead-queue guard)", () => { expect(match![1]).toMatch(/\bMOOD_STATUS_QUEUE\b/); }); - it("schedules the 02:30 cron with the insight retry policy", () => { + it("schedules the 02:30 cron with the nightly pass options (retries and expiry)", () => { expect(workerSrc).toMatch( - /\[\s*MOOD_STATUS_QUEUE\s*,\s*MOOD_STATUS_CRON\s*,\s*insightRetryOptions\s*\]/, + /\[\s*MOOD_STATUS_QUEUE\s*,\s*MOOD_STATUS_CRON\s*,\s*nightlyInsightPassOptions\s*\]/, ); expect(workerSrc).toMatch(/MOOD_STATUS_CRON\s*=\s*"30 2 \* \* \*"/); }); diff --git a/src/lib/jobs/backup-restore.ts b/src/lib/jobs/backup-restore.ts index 0e76db75e..e2af9e0dd 100644 --- a/src/lib/jobs/backup-restore.ts +++ b/src/lib/jobs/backup-restore.ts @@ -811,7 +811,12 @@ export async function enqueueBackupRestoreSweepAtBoot( await boss.send( BACKUP_RESTORE_QUEUE, { sweep: true } satisfies BackupRestorePayload, - { startAfter: Math.ceil(BACKUP_RESTORE_STALE_AFTER_MS / 1000) + 15 }, + { + startAfter: Math.ceil(BACKUP_RESTORE_STALE_AFTER_MS / 1000) + 15, + // The sweep is short, but it rides a queue whose every send carries + // the restore's expiry, so no job on it is cut at the default. + expireInSeconds: BACKUP_RESTORE_EXPIRE_SECONDS, + }, ); } catch { // The admission check fails an abandoned job the next time the account diff --git a/src/lib/jobs/document-content-index-backfill.ts b/src/lib/jobs/document-content-index-backfill.ts index 2dc1bceb9..357619313 100644 --- a/src/lib/jobs/document-content-index-backfill.ts +++ b/src/lib/jobs/document-content-index-backfill.ts @@ -64,6 +64,14 @@ export const CONTENT_INDEX_BACKFILL_CONCURRENCY = 1; /** Discovery page size (id-only; blobs are loaded one at a time). */ const PAGE_SIZE = 50; +/** + * The job's expiry: three hours. One job transcribes up to 200 documents with + * a provider call each, which outlasted pg-boss's fifteen-minute default and + * was retried beside itself. The run stops at three quarters of this + * (`jobBudget`) and holds a lock per account (`lockedPass`). + */ +export const CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS = 3 * 60 * 60; + /** Max documents one job indexes before yielding (bounds spend + runtime). */ const MAX_DOCS_PER_RUN = 200; @@ -97,6 +105,11 @@ export interface ContentIndexBackfillSummary { failed: number; reason: "ok" | "no-provider" | "no-consent" | "budget-reached" | "unavailable"; + /** + * The job's time budget ran out before the walk did. The documents not + * reached stay un-indexed for the next run. + */ + stoppedEarly?: boolean; } /** @@ -158,6 +171,11 @@ async function retokeniseStaleRows( */ export async function runContentIndexBackfillForUser( userId: string, + /** + * The job's time budget (`jobBudget`), checked before each document: up to + * 200 transcriptions, one provider call each, can outlast the job. + */ + shouldStop: () => boolean = () => false, ): Promise { // Stale-tokeniser rows first: local work that owes nothing to the // provider, consent, or budget gates below, so a user without a usable @@ -222,6 +240,7 @@ export async function runContentIndexBackfillForUser( let failed = stale.failed; let reason: ContentIndexBackfillSummary["reason"] = "ok"; let cursor: string | null = null; + let stoppedEarly = false; outer: for (;;) { if (indexed >= MAX_DOCS_PER_RUN) break; @@ -244,6 +263,10 @@ export async function runContentIndexBackfillForUser( for (const { id } of rows) { if (indexed >= MAX_DOCS_PER_RUN) break outer; + if (shouldStop()) { + stoppedEarly = true; + break outer; + } const dateKey = buildDateKey(); const reservation = await reserveBudget( @@ -325,9 +348,23 @@ export async function runContentIndexBackfillForUser( annotate({ action: { name: "documents.contentIndex.backfill" }, - meta: { indexed, retokenised: stale.retokenised, skipped, failed, reason }, + meta: { + indexed, + retokenised: stale.retokenised, + skipped, + failed, + reason, + stopped_early: stoppedEarly, + }, }); - return { indexed, retokenised: stale.retokenised, skipped, failed, reason }; + return { + indexed, + retokenised: stale.retokenised, + skipped, + failed, + reason, + ...(stoppedEarly ? { stoppedEarly } : {}), + }; } /** @@ -344,6 +381,7 @@ export async function enqueueContentIndexBackfill( enqueuedAt: new Date().toISOString(), }; const jobId = await boss.send(CONTENT_INDEX_BACKFILL_QUEUE, payload, { + expireInSeconds: CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS, retryLimit: 3, retryDelay: 60, retryBackoff: true, diff --git a/src/lib/jobs/encryption-key-rotate.ts b/src/lib/jobs/encryption-key-rotate.ts index 86eb671d9..cd9c93eb8 100644 --- a/src/lib/jobs/encryption-key-rotate.ts +++ b/src/lib/jobs/encryption-key-rotate.ts @@ -25,6 +25,7 @@ import { type Job } from "pg-boss"; import { withBackgroundEvent } from "@/lib/logging/background"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { jobBudget } from "@/lib/jobs/job-budget"; import { auditLog } from "@/lib/auth/audit"; import { rotateCorpus, @@ -37,27 +38,40 @@ export const ENCRYPTION_KEY_ROTATE_CONCURRENCY = 1; /** Fixed key so duplicate admin triggers coalesce into one queued run. */ export const ENCRYPTION_KEY_ROTATE_SINGLETON = "encryption-key-rotate"; +/** + * The job's expiry: twelve hours. The pass re-encrypts every encrypted column + * on the instance and outlasted pg-boss's fifteen-minute default, which + * retried it beside itself. The pass stops at three quarters of this + * (`jobBudget`) and holds a lock (`lockedPass`); the admin route's send + * carries it. + */ +export const ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS = 12 * 60 * 60; + export interface EncryptionKeyRotatePayload { /** The admin who triggered the run (for the audit trail). */ requestedByUserId?: string; enqueuedAt?: string; } -export async function runEncryptionKeyRotation(): Promise<{ +export async function runEncryptionKeyRotation( + shouldStop: () => boolean = () => false, +): Promise<{ activeKeyId: string; totalScanned: number; totalRotated: number; totalErrors: number; totalDropped: number; + stoppedEarly: boolean; }> { const prisma = getWorkerPrisma(); - const out = await rotateCorpus(prisma as unknown as CorpusClient); + const out = await rotateCorpus(prisma as unknown as CorpusClient, shouldStop); return { activeKeyId: out.activeKeyId, totalScanned: out.totalScanned, totalRotated: out.totalRotated, totalErrors: out.totalErrors, totalDropped: out.totalDropped, + stoppedEarly: out.stoppedEarly, }; } @@ -67,7 +81,10 @@ export async function handleEncryptionKeyRotate( return withBackgroundEvent("job.encryption_key_rotate", async (evt) => { const requestedBy = jobs[0]?.data?.requestedByUserId ?? null; try { - const result = await runEncryptionKeyRotation(); + // A whole-corpus pass: it stops between rows once the job's budget is + // spent, and says so in the audit row, so the operator runs it again + // to finish (rows already rotated are skipped). + const result = await runEncryptionKeyRotation(jobBudget(jobs)); evt.addMeta("rotate_active_key_id", result.activeKeyId); evt.addMeta("rotate_scanned", result.totalScanned); evt.addMeta("rotate_rotated", result.totalRotated); diff --git a/src/lib/jobs/insight-pregenerate-shared.ts b/src/lib/jobs/insight-pregenerate-shared.ts index 22aca36c8..e6353b37a 100644 --- a/src/lib/jobs/insight-pregenerate-shared.ts +++ b/src/lib/jobs/insight-pregenerate-shared.ts @@ -19,6 +19,15 @@ import { annotate } from "@/lib/logging/context"; export const INSIGHT_PREGENERATE_QUEUE = "insight-pregenerate"; +/** + * The job's expiry: three hours. The scheduled pass walks up to 200 accounts + * with a comprehensive generation each, which can outlast pg-boss's fifteen + * minute default; past it pg-boss retried the job beside the pass still + * running. The pass now stops itself at three quarters of this (`jobBudget`) + * and holds a lock (`lockedPass`), and every send and the schedule carry it. + */ +export const INSIGHT_PREGENERATE_EXPIRE_SECONDS = 3 * 60 * 60; + export interface InsightPregeneratePayload { /** Set on the nightly scheduled tick (informational; the handler keys * its batch behaviour off the ABSENCE of `userId`/`force`). */ @@ -73,6 +82,7 @@ export async function enqueueForceWarm(payload: { } satisfies InsightPregeneratePayload, { singletonKey: `force:${payload.userId}`, + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, // De-dupe within a 6-minute window. v1.16.8 — widened from 120 s: // the client's revalidation poll runs up to ~250 s after a stale // page-open, so a 2-minute singleton let the tail of one poll @@ -148,6 +158,7 @@ export async function enqueuePregenerateFailureRetry(payload: { // warm can never swallow the scheduled retry (and vice versa); // one retry per user per window. singletonKey: `retry:${payload.userId}`, + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, singletonSeconds: 3600, // A worker crash mid-warm re-runs the job; a warm that completes // with a failed comprehensive does NOT throw, so this cannot loop. diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index 37cac27f8..cdf911576 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -277,6 +277,8 @@ export interface PregenerateRunResult { skipped: number; failed: number; budgetBlocked: number; + /** The job's time budget ran out before every candidate was reached. */ + stoppedEarly: boolean; /** * Count of per-metric assessment caches written warm across the whole * run. The comprehensive generator evicts the seven `*-status` @@ -575,6 +577,12 @@ export async function runInsightPregenerate( userId: string; locale: SupportedLocale; }) => Promise; + /** + * The job's time budget (`jobBudget`). Checked between candidates: a pass + * of 200 provider calls can outlast its job, and the candidates it does + * not reach are the stalest again tomorrow, so they lead the next pass. + */ + shouldStop?: () => boolean; } = {}, ): Promise { const now = options.now ?? new Date(); @@ -606,6 +614,7 @@ export async function runInsightPregenerate( skipped: 0, failed: 0, budgetBlocked: 0, + stoppedEarly: false, assessmentsWarmed: 0, metricAssessmentsWarmed: 0, }; @@ -619,6 +628,10 @@ export async function runInsightPregenerate( result.total = candidates.length; for (const candidate of candidates) { + if (options.shouldStop?.()) { + result.stoppedEarly = true; + break; + } // v1.18.11 P3 — one feature-cache scope per candidate, so the // comprehensive briefing's bounded feature read is computed once and any // sibling consumer in this candidate's warm reuses it. Scoped per user so @@ -819,6 +832,7 @@ export async function runInsightPregenerate( skipped: result.skipped, failed: result.failed, budget_blocked: result.budgetBlocked, + stopped_early: result.stoppedEarly, assessments_warmed: result.assessmentsWarmed, metric_assessments_warmed: result.metricAssessmentsWarmed, }, diff --git a/src/lib/jobs/integration-backfill-lock.ts b/src/lib/jobs/integration-backfill-lock.ts index 1e6a90905..c42403720 100644 --- a/src/lib/jobs/integration-backfill-lock.ts +++ b/src/lib/jobs/integration-backfill-lock.ts @@ -11,8 +11,9 @@ * started beside it in the same worker, so the lane's "one at a time" did not * hold for the one case it exists for, an import too long for its expiry. * - * So each run holds a session-level advisory lock keyed on the job's identity - * (kind, provider where there is one, account) for as long as it runs. A + * So each run holds a session-level advisory lock (`withJobLock`) keyed on the + * job's identity (kind, provider where there is one, account) for as long as + * it runs. A * delivery that finds the lock taken does no work: the run that holds it is * still importing, and it either stamps its completion marker or leaves the * account for the next boot's discovery. The lock lives on a connection of its @@ -24,47 +25,26 @@ * One extra connection per running import; the lane admits one import per * process at a time, so that is one connection. */ -import { Client } from "pg"; +import { withJobLock, type GuardedRun } from "./job-lock"; /** The lock key for one admission job's identity. */ function integrationBackfillLockKey(identity: string): string { return `integration-backfill:${identity}`; } -/** What a guarded run came to. */ -type GuardedRun = { ran: true; result: T } | { ran: false }; - /** * Run `run` while holding the import lock for `identity`. Resolves * `{ ran: false }` at once, without calling `run`, when another run holds it. * Rejects exactly as `run` does otherwise. */ -export async function withIntegrationBackfillLock( +export function withIntegrationBackfillLock( identity: string, run: () => Promise, connectionString: string | undefined = process.env.DATABASE_URL, ): Promise> { - const client = new Client({ connectionString, keepAlive: true }); - // An idle client whose server goes away emits `error`; unhandled, that - // takes the whole worker down. The run itself notices the database is - // gone through its own queries. - client.on("error", () => {}); - await client.connect(); - try { - const key = integrationBackfillLockKey(identity); - const { rows } = await client.query<{ held: boolean }>( - "SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS held", - [key], - ); - if (!rows[0]?.held) return { ran: false }; - try { - return { ran: true, result: await run() }; - } finally { - await client - .query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [key]) - .catch(() => {}); - } - } finally { - await client.end().catch(() => {}); - } + return withJobLock( + integrationBackfillLockKey(identity), + run, + connectionString, + ); } diff --git a/src/lib/jobs/job-lock.ts b/src/lib/jobs/job-lock.ts new file mode 100644 index 000000000..5db0d75ba --- /dev/null +++ b/src/lib/jobs/job-lock.ts @@ -0,0 +1,58 @@ +/** + * A session-level advisory lock held for the length of one run, across + * processes. + * + * pg-boss holds a job's "one at a time" only while the job is inside its + * expiry. Once the expiry passes it fails the job, fires its abort signal and + * retries it, and it does not stop the handler: measured against pg-boss 12.34 + * (`tests/integration/integration-backfill-lane-expiry.test.ts`), the retry of + * the same job started beside the run that was still going. No queue policy + * helps, because the policies constrain job rows and the expired row is no + * longer active. Only something the running handler holds can, so each long + * run holds an advisory lock keyed on its identity, and a delivery that finds + * the lock taken does no work. + * + * The lock lives on a connection of its own, outside the Prisma pool, because + * a session lock has to stay on one connection for the whole run and a pooled + * one is handed back between statements. Ending the connection releases the + * lock, and so does the process dying, so a crash never leaves a key locked. + * One extra connection per running locked job. + */ +import { Client } from "pg"; + +/** What a guarded run came to. */ +export type GuardedRun = { ran: true; result: T } | { ran: false }; + +/** + * Run `run` while holding the advisory lock for `key`. Resolves + * `{ ran: false }` at once, without calling `run`, when another run holds it. + * Rejects exactly as `run` does otherwise. + */ +export async function withJobLock( + key: string, + run: () => Promise, + connectionString: string | undefined = process.env.DATABASE_URL, +): Promise> { + const client = new Client({ connectionString, keepAlive: true }); + // An idle client whose server goes away emits `error`; unhandled, that + // takes the whole worker down. The run itself notices the database is + // gone through its own queries. + client.on("error", () => {}); + await client.connect(); + try { + const { rows } = await client.query<{ held: boolean }>( + "SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS held", + [key], + ); + if (!rows[0]?.held) return { ran: false }; + try { + return { ran: true, result: await run() }; + } finally { + await client + .query("SELECT pg_advisory_unlock(hashtextextended($1, 0))", [key]) + .catch(() => {}); + } + } finally { + await client.end().catch(() => {}); + } +} diff --git a/src/lib/jobs/long-pass.ts b/src/lib/jobs/long-pass.ts new file mode 100644 index 000000000..0d4ca0da4 --- /dev/null +++ b/src/lib/jobs/long-pass.ts @@ -0,0 +1,58 @@ +/** + * The binding for a queue whose job can outlast pg-boss's default expiry. + * + * Three things together make a long job safe, and `queue-runtime.ts` names + * every queue that needs them: + * + * 1. An explicit `expireInSeconds` on every send and schedule, so pg-boss + * does not declare the job dead at fifteen minutes while it is working. + * 2. A time budget (`jobBudget` / `jobDeadline` in `job-budget.ts`) the + * handler checks between units of work, so the pass stops on its own + * before that expiry and the next run picks up what is left. + * 3. A lock the running handler holds (`withJobLock`), so the retry pg-boss + * starts once the expiry has passed does nothing beside a run that is + * still going. Queue policies cannot do this: they constrain job rows, + * and an expired job's row is no longer active. + * + * `lockedPass` is the third. It wraps a handler so each job runs under the + * lock `:`, where the identity is what two runs must not + * share: the account for a per-user job, a constant for a pass over everyone. + * A delivery that finds the lock held reports `already_running` and succeeds, + * because the run that holds it is doing the work. + */ +import type { Job } from "pg-boss"; + +import { withJobLock } from "@/lib/jobs/job-lock"; +import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { annotate } from "@/lib/logging/context"; + +/** The identity of a pass that walks every account: one run at a time. */ +export const WHOLE_PASS = "pass"; + +export function lockedPass>( + queue: string, + identityOf: (job: J) => string, + handler: (jobs: J[]) => Promise, +): (jobs: J[]) => Promise { + return async (jobs) => { + let outcome: JobOutcome = jobDone({ jobs: 0 }); + for (const job of jobs) { + const run = await withJobLock(`job:${queue}:${identityOf(job)}`, () => + handler([job]), + ); + if (!run.ran) { + annotate({ + action: { name: "job.long_pass.already_running" }, + meta: { queue }, + }); + outcome = jobDone({ already_running: 1 }); + continue; + } + // The first failed job fails the batch; pg-boss hands one job per + // batch here, so this is that job's own outcome. + if (!run.result.ok) return run.result; + outcome = run.result; + } + return outcome; + }; +} diff --git a/src/lib/jobs/medication-intake-import.ts b/src/lib/jobs/medication-intake-import.ts index b7c92dfd0..a79a71ed4 100644 --- a/src/lib/jobs/medication-intake-import.ts +++ b/src/lib/jobs/medication-intake-import.ts @@ -4,7 +4,8 @@ import { Prisma, type PrismaClient } from "@/generated/prisma/client"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { auditLog } from "@/lib/auth/audit"; import { prisma, toJson } from "@/lib/db"; -import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { jobBudget } from "@/lib/jobs/job-budget"; import { consumeImportedIntakesBatch } from "@/lib/medications/inventory/consumption"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { @@ -825,13 +826,25 @@ async function processNextChunk( ); } -export async function processMedicationIntakeImportJob( +/** + * Work the import chunk by chunk until it is finished. `shouldStop` is the + * job's time budget: between chunks, once it is spent, the run returns + * `"stopped"` and leaves the job row as it is. Every chunk commits its own + * progress under the row lock, so the retry that follows picks up at the next + * chunk, and a second delivery running beside this one waits on that lock and + * continues from the progress this one wrote. + */ +async function workMedicationIntakeImport( jobId: string, -): Promise { + shouldStop: () => boolean, +): Promise { const client = getWorkerPrisma(); for (;;) { const outcome = await processNextChunk(client, jobId); - if (!outcome.terminal) continue; + if (!outcome.terminal) { + if (shouldStop()) return "stopped"; + continue; + } if (outcome.finalized && outcome.userId && outcome.result) { if (outcome.result.imported > 0) { invalidateUserMedications(outcome.userId, { evict: true }); @@ -843,6 +856,14 @@ export async function processMedicationIntakeImportJob( } } +/** The whole import in one go, with no time budget. */ +export async function processMedicationIntakeImportJob( + jobId: string, +): Promise { + const result = await workMedicationIntakeImport(jobId, () => false); + return result === "stopped" ? null : result; +} + async function recordWorkerFailure( job: Job, error: unknown, @@ -879,7 +900,19 @@ export async function handleMedicationIntakeImport( job: Job, ): Promise { try { - const result = await processMedicationIntakeImportJob(job.data.jobId); + const result = await workMedicationIntakeImport( + job.data.jobId, + jobBudget([job]), + ); + // Out of time before the last chunk: fail the delivery so pg-boss + // retries it, and the retry resumes from the committed progress. + if (result === "stopped") { + return jobFailed( + "stopped before the last chunk; the retry resumes", + undefined, + { stopped_early: true }, + ); + } // A null result is a delivery that found the row already terminal or gone. // There was nothing left to import, which is an absence of work rather // than a failure to do it. diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index a2f2e34fa..e1c89d864 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -42,6 +42,15 @@ const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; const TAG_LENGTH = 16; +/** + * The off-host pass's expiry: four hours. It uploads every account and + * already stopped between accounts at three quarters of the default fifteen + * minutes, which on an instance of any size meant a retry per night; one + * large account past the default was retried beside itself. Four hours with + * the same budget and a lock (`lockedPass`) leaves room for the night's pass. + */ +export const OFFHOST_BACKUP_EXPIRE_SECONDS = 4 * 60 * 60; + export interface OffhostBackupConfig { endpoint: string; bucket: string; diff --git a/src/lib/jobs/period-narrative-shared.ts b/src/lib/jobs/period-narrative-shared.ts index 52dad1371..56742c1ea 100644 --- a/src/lib/jobs/period-narrative-shared.ts +++ b/src/lib/jobs/period-narrative-shared.ts @@ -23,6 +23,15 @@ import type { Locale } from "@/lib/i18n/config"; export const PERIOD_NARRATIVE_QUEUE = "period-narrative-warm"; +/** + * The job's expiry: three hours. On a boundary night the scheduled pass + * writes up to two narratives for each of 200 accounts, which can outlast + * pg-boss's fifteen-minute default. The pass stops itself at three quarters + * of this (`jobBudget`) and holds a lock (`lockedPass`); every send and the + * schedule carry it. + */ +export const PERIOD_NARRATIVE_EXPIRE_SECONDS = 3 * 60 * 60; + /** * Nightly at 05:05 Europe/Berlin — inside the existing maintenance window, * after the comprehensive insight pre-generation (04:30) and the computed @@ -79,6 +88,7 @@ export async function enqueueNarrativeWarm(payload: { { singletonKey: `warm:${payload.userId}:${payload.period}:${payload.locale}`, singletonSeconds: 120, + expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS, // Transient provider / pool failures retry with backoff instead // of leaving the narrative cold until the next boundary night. retryLimit: 3, diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index 6ade00411..15eb409c9 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -63,6 +63,8 @@ export interface NarrativeWarmRunResult { insufficient: number; failed: number; budgetBlocked: number; + /** The job's time budget ran out before every candidate was reached. */ + stoppedEarly: boolean; } /** @@ -152,6 +154,12 @@ export async function runPeriodNarrativeWarm( cap?: number; /** Injected for the test — defaults to the real generator. */ generate?: typeof generatePeriodNarrative; + /** + * The job's time budget (`jobBudget`), checked between candidates. The + * candidates are ordered least recently narrated first, so the ones a + * stopped pass did not reach lead the next one. + */ + shouldStop?: () => boolean; } = {}, ): Promise { const now = options.now ?? new Date(); @@ -167,6 +175,7 @@ export async function runPeriodNarrativeWarm( insufficient: 0, failed: 0, budgetBlocked: 0, + stoppedEarly: false, }; const flags = await getAssistantFlags(); @@ -180,6 +189,10 @@ export async function runPeriodNarrativeWarm( result.total = candidates.length; for (const candidate of candidates) { + if (options.shouldStop?.()) { + result.stoppedEarly = true; + break; + } // The nightly pass exists for the model-written narrative. Unavailable, // it skips the user before the context build and the budget write; the // deterministic narrative is written on the next read instead (the @@ -233,6 +246,7 @@ export async function runPeriodNarrativeWarm( insufficient: result.insufficient, failed: result.failed, budget_blocked: result.budgetBlocked, + stopped_early: result.stoppedEarly, }, }); diff --git a/src/lib/jobs/pr-detection.ts b/src/lib/jobs/pr-detection.ts index 8062dc74e..94f933fd3 100644 --- a/src/lib/jobs/pr-detection.ts +++ b/src/lib/jobs/pr-detection.ts @@ -15,6 +15,16 @@ import { getGlobalBoss } from "@/lib/jobs/boss-instance"; export const PR_DETECTION_QUEUE = "pr-detection"; +/** + * The job's expiry: twenty-five minutes. The fallback cron walks every + * account's whole history every thirty minutes, which on a large instance + * outlasts pg-boss's fifteen-minute default; past it pg-boss retried the pass + * beside itself. The pass stops at three quarters of this (`jobBudget`) and + * holds a lock (`lockedPass`), so it ends before the next tick and a retry or + * a late tick never runs beside it. The ingest sends carry the same expiry. + */ +export const PR_DETECTION_EXPIRE_SECONDS = 25 * 60; + /** Concurrency budget for the worker process — five jobs in flight * is enough to drain a multi-user backfill spike without crowding * the reminder check or the daily insights workload that runs on the @@ -62,6 +72,7 @@ export async function enqueuePrDetection( triggeredAt: new Date().toISOString(), }; await boss.send(PR_DETECTION_QUEUE, payload, { + expireInSeconds: PR_DETECTION_EXPIRE_SECONDS, // Stagger retries gently — the detector is purely read-heavy on // the user's own data, but a stampede of failed jobs against a // briefly-flaky DB would compound the problem. diff --git a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts index fda4afc67..30b92dca3 100644 --- a/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts +++ b/src/lib/jobs/reminder/__tests__/backup-handlers.test.ts @@ -70,6 +70,7 @@ function buildPrismaMock( ) { return { user: { findMany: vi.fn().mockResolvedValue(users) }, + dataBackup: { findMany: vi.fn().mockResolvedValue([]) }, }; } @@ -236,3 +237,44 @@ describe("handleDataBackup outcome", () => { expect(outcome).toMatchObject({ ok: true, did: { backed: 0, total: 0 } }); }); }); + +describe("handleDataBackup on a large instance", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.store.mockResolvedValue(undefined); + }); + + it("backs up the accounts with the oldest copies first and stops on the budget", async () => { + const prisma = buildPrismaMock([ + { id: "fresh", username: "fresh" }, + { id: "never", username: "never" }, + { id: "stale", username: "stale" }, + ]); + prisma.dataBackup.findMany.mockResolvedValue([ + { userId: "fresh", createdAt: new Date("2026-09-30T00:00:00Z") }, + { userId: "stale", createdAt: new Date("2026-09-01T00:00:00Z") }, + ]); + mocks.getWorkerPrisma.mockReturnValue(prisma); + const controller = new AbortController(); + // Two accounts in, pg-boss gives up on the job. + mocks.store.mockImplementation(async () => { + if (mocks.store.mock.calls.length === 2) controller.abort(); + }); + + const outcome = await handleDataBackup([ + { + expireInSeconds: 7200, + signal: controller.signal, + } as unknown as Parameters[0][number], + ]); + + const order = mocks.store.mock.calls.map( + ([input]) => (input as { userId: string }).userId, + ); + expect(order).toEqual(["never", "stale"]); + expect(outcome).toMatchObject({ + ok: true, + did: { backed: 2, stopped_early: true }, + }); + }); +}); diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index 5d57c2e27..f02714de6 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -121,16 +121,39 @@ export async function handleOffhostBackup( }); } +/** + * The weekly on-host copy of every account. + * + * The pass serialises each account's whole record, so on a large instance it + * can outlast its job. It walks the accounts whose weekly copy is oldest + * first and stops between accounts once the job's budget is spent + * (`jobBudget`), reporting `stopped_early`. The accounts it did not reach have + * the oldest copies, so they lead the next pass (the weekly cron or the + * admin's "run now"), and every pass makes progress where it matters most. + * The binding holds a lock (`lockedPass`), so a retry never runs beside it. + */ export async function handleDataBackup( jobs: Job[], ): Promise { - void jobs; + const shouldStop = jobBudget(jobs); return withBackgroundEvent("job.data_backup", async (evt) => { const prisma = getWorkerPrisma(); try { - const users = await prisma.user.findMany({ - select: { id: true, username: true }, - }); + const [accounts, copies] = await Promise.all([ + prisma.user.findMany({ select: { id: true, username: true } }), + prisma.dataBackup.findMany({ + where: { type: "WEEKLY_AUTO" }, + select: { userId: true, createdAt: true }, + }), + ]); + const copiedAt = new Map( + copies.map((copy) => [copy.userId, copy.createdAt.getTime()]), + ); + // No copy yet sorts first, then the oldest copy. + const users = [...accounts].sort( + (a, b) => (copiedAt.get(a.id) ?? -1) - (copiedAt.get(b.id) ?? -1), + ); + let stoppedEarly = false; let backed = 0; let usersFailed = 0; @@ -142,6 +165,10 @@ export async function handleDataBackup( // six weeks in. let lastError: unknown; for (const user of users) { + if (shouldStop()) { + stoppedEarly = true; + break; + } reportJobProgress({ backup_user: user.id, backup_users_done: backed + usersFailed + skippedRestoring, @@ -207,6 +234,7 @@ export async function handleDataBackup( users_failed: usersFailed, records_oversized: oversized, users_skipped_restoring: skippedRestoring, + stopped_early: stoppedEarly, }; // A pass that wrote nothing for anybody protected nobody, and saying diff --git a/src/lib/jobs/reminder/insights-handlers.ts b/src/lib/jobs/reminder/insights-handlers.ts index 4fd293222..4f529f204 100644 --- a/src/lib/jobs/reminder/insights-handlers.ts +++ b/src/lib/jobs/reminder/insights-handlers.ts @@ -21,6 +21,7 @@ import { runInsightStatusGenerate, } from "@/lib/jobs/insight-status-generate"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { jobBudget } from "@/lib/jobs/job-budget"; import { withBackgroundEvent } from "@/lib/logging/background"; import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; import { generateWeightStatusForUser } from "@/lib/insights/weight-status"; @@ -77,6 +78,12 @@ export interface MedicationComplianceStatusPayload { * The pass is what succeeds or fails. One user whose generation threw is that * user's cold card, reported as the `failed` count; failing the job would * re-run the whole cohort over it. + * + * The pass walks every candidate with a provider call each, so on a large + * instance it can outlast its job. `shouldStop` is the job's time budget: the + * pass stops between users once it is spent and reports `stopped_early`. The + * users it did not reach keep yesterday's note until the next night or their + * next visit; nothing is retried beside a pass that is still running. */ export async function runStatusCronGenerate( taskName: string, @@ -84,6 +91,7 @@ export async function runStatusCronGenerate( userId: string, options: { locale: string | null; force: boolean }, ) => Promise, + shouldStop: () => boolean = () => false, ): Promise { return withBackgroundEvent(taskName, async (evt) => { const prisma = getWorkerPrisma(); @@ -96,8 +104,13 @@ export async function runStatusCronGenerate( let generated = 0; let failed = 0; + let stoppedEarly = false; for (const user of users) { + if (shouldStop()) { + stoppedEarly = true; + break; + } // The capability before the generator builds a snapshot. The batch // entry below checks for itself. const capability = await aiCapabilityForJob(user.id, "statusText"); @@ -124,9 +137,14 @@ export async function runStatusCronGenerate( evt.setBackground({ task_name: taskName, - result: { generated, failed, total: users.length }, + result: { generated, failed, total: users.length, stoppedEarly }, + }); + return jobDone({ + total: users.length, + generated, + failed, + stopped_early: stoppedEarly, }); - return jobDone({ total: users.length, generated, failed }); } catch (err) { evt.setError(err); recordError(); @@ -153,7 +171,10 @@ export async function runStatusCronGenerate( * inside the batch itself). Net effect: the nightly ladder pays one call per * user instead of seven, with no queue/registry churn. */ -async function runStatusBatchCron(taskName: string): Promise { +async function runStatusBatchCron( + taskName: string, + shouldStop: () => boolean, +): Promise { return withBackgroundEvent(taskName, async (evt) => { const prisma = getWorkerPrisma(); try { @@ -166,7 +187,14 @@ async function runStatusBatchCron(taskName: string): Promise { let generated = 0; let served = 0; let failed = 0; + let stoppedEarly = false; for (const user of users) { + // Same budget as the per-metric passes: stop between users, and + // leave the rest to the later crons and the next night. + if (shouldStop()) { + stoppedEarly = true; + break; + } try { const result = await generateStatusBatchForUser(user.id, { // The stored locale, then the operator default — the prompt names @@ -186,16 +214,34 @@ async function runStatusBatchCron(taskName: string): Promise { annotate({ action: { name: "insights.status.batch.cron" }, - meta: { generated, served, failed, total: users.length }, + meta: { + generated, + served, + failed, + total: users.length, + stopped_early: stoppedEarly, + }, }); evt.setBackground({ task_name: taskName, - result: { generated, served, failed, total: users.length }, + result: { + generated, + served, + failed, + total: users.length, + stoppedEarly, + }, }); // A user whose batch call threw already fell back to the single-card // path inside the batch, and the six later per-metric crons cover what // is still cold. The batch pass ran, so it reports the count. - return jobDone({ total: users.length, generated, served, failed }); + return jobDone({ + total: users.length, + generated, + served, + failed, + stopped_early: stoppedEarly, + }); } catch (err) { evt.setError(err); recordError(); @@ -208,61 +254,66 @@ async function runStatusBatchCron(taskName: string): Promise { export function handleGeneralStatusGenerate( jobs: Job[], ): Promise { - void jobs; - return runStatusBatchCron("job.insights.batch"); + return runStatusBatchCron("job.insights.batch", jobBudget(jobs)); } export function handleBloodPressureStatusGenerate( jobs: Job[], ): Promise { - void jobs; return runStatusCronGenerate( "job.insights.blood_pressure", generateBloodPressureStatusForUser, + jobBudget(jobs), ); } export function handleWeightStatusGenerate( jobs: Job[], ): Promise { - void jobs; return runStatusCronGenerate( "job.insights.weight", generateWeightStatusForUser, + jobBudget(jobs), ); } export function handlePulseStatusGenerate( jobs: Job[], ): Promise { - void jobs; return runStatusCronGenerate( "job.insights.pulse", generatePulseStatusForUser, + jobBudget(jobs), ); } export function handleBmiStatusGenerate( jobs: Job[], ): Promise { - void jobs; - return runStatusCronGenerate("job.insights.bmi", generateBmiStatusForUser); + return runStatusCronGenerate( + "job.insights.bmi", + generateBmiStatusForUser, + jobBudget(jobs), + ); } export function handleMoodStatusGenerate( jobs: Job[], ): Promise { - void jobs; - return runStatusCronGenerate("job.insights.mood", generateMoodStatusForUser); + return runStatusCronGenerate( + "job.insights.mood", + generateMoodStatusForUser, + jobBudget(jobs), + ); } export function handleMedicationComplianceStatusGenerate( jobs: Job[], ): Promise { - void jobs; return runStatusCronGenerate( "job.insights.medication_compliance", generateMedicationComplianceStatusForUser, + jobBudget(jobs), ); } @@ -310,7 +361,9 @@ export async function handleInsightPregenerateJob( return jobDone({ forced: forced.length, scheduled: 0 }); } try { - const summary = await runInsightPregenerate(getWorkerPrisma()); + const summary = await runInsightPregenerate(getWorkerPrisma(), { + shouldStop: jobBudget(jobs), + }); evt.setBackground({ task_name: "job.insight_pregenerate", result: { ...summary }, @@ -325,6 +378,7 @@ export async function handleInsightPregenerateJob( skipped: summary.skipped, failed: summary.failed, budget_blocked: summary.budgetBlocked, + stopped_early: summary.stoppedEarly, assessments_warmed: summary.assessmentsWarmed, metric_assessments_warmed: summary.metricAssessmentsWarmed, }); diff --git a/src/lib/jobs/reminder/ops-handlers.ts b/src/lib/jobs/reminder/ops-handlers.ts index 7560e70f5..7f944c1b8 100644 --- a/src/lib/jobs/reminder/ops-handlers.ts +++ b/src/lib/jobs/reminder/ops-handlers.ts @@ -4,6 +4,7 @@ * Extracted from reminder-worker.ts, which owns the queue names, cron * schedules, and boss.work registrations. */ +import { jobBudget } from "@/lib/jobs/job-budget"; import { type Job } from "pg-boss"; import { runGeoBackfill } from "@/lib/jobs/geo-backfill"; import { fetchGeoLite2Databases } from "@/lib/geo/geolite2-fetch"; @@ -223,6 +224,11 @@ export async function handlePrDetection( let insertedAcrossJobs = 0; let tiesAcrossJobs = 0; let usersFailed = 0; + let stoppedEarly = false; + // The cron pass walks every account's all-time history and ticks every + // thirty minutes. It stops between accounts once the job's budget is spent; + // the ingest jobs and the next tick carry the rest. + const shouldStop = jobBudget(jobs); for (const job of jobs) { await withBackgroundEvent("job.pr_detection", async (evt) => { const p = getWorkerPrisma(); @@ -241,6 +247,10 @@ export async function handlePrDetection( let insertedTotal = 0; let tiesTotal = 0; for (const userId of userIds) { + if (shouldStop()) { + stoppedEarly = true; + break; + } try { const result = await detectPersonalRecordsForUser(userId, { silent, @@ -274,5 +284,6 @@ export async function handlePrDetection( pr_detection_inserted: insertedAcrossJobs, pr_detection_ties: tiesAcrossJobs, pr_detection_users_failed: usersFailed, + stopped_early: stoppedEarly, }); } diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index 460ac70ec..e9fdc896d 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -31,8 +31,12 @@ import { PR_DETECTION_QUEUE, PR_DETECTION_CONCURRENCY, PR_DETECTION_FALLBACK_CRON, + PR_DETECTION_EXPIRE_SECONDS, type PrDetectionPayload, } from "@/lib/jobs/pr-detection"; +import { jobBudget } from "@/lib/jobs/job-budget"; +import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass"; +import { OFFHOST_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/offhost-backup"; import { MEDICATION_INVENTORY_EXPIRE_QUEUE, MEDICATION_INVENTORY_EXPIRE_CRON, @@ -531,7 +535,11 @@ const schedules: ScheduleEntry[] = [ STEP_UP_ELEVATION_CLEANUP_CRON, cronIsTheRetry, ], - [OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON], + [ + OFFHOST_BACKUP_QUEUE, + OFFHOST_BACKUP_CRON, + { expireInSeconds: OFFHOST_BACKUP_EXPIRE_SECONDS }, + ], [RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON], // Nightly backstop for the purge the deletion kicks straight away: a // request the bucket refused, or one written while the queue was down. @@ -557,7 +565,11 @@ const schedules: ScheduleEntry[] = [ // cron payload deliberately omits a `userId` so the handler iterates // every user; per-user push-suppression cannot apply on the cron // path (the silent flag is set by the ingest hooks). - [PR_DETECTION_QUEUE, PR_DETECTION_FALLBACK_CRON], + [ + PR_DETECTION_QUEUE, + PR_DETECTION_FALLBACK_CRON, + { expireInSeconds: PR_DETECTION_EXPIRE_SECONDS }, + ], // v1.4.25 W19b — daily expire-stale pass for the per-pen inventory // entities. Flips IN_USE rows whose 30-day clock has blown to // EXPIRED at 03:30 Europe/Berlin (in the existing 02:xx–03:xx @@ -747,7 +759,7 @@ export async function registerMaintenanceQueues( boss, DATA_BACKUP_QUEUE, { localConcurrency: 1 }, - handleDataBackup, + lockedPass(DATA_BACKUP_QUEUE, () => WHOLE_PASS, handleDataBackup), ); await createAndWork( boss, @@ -778,7 +790,7 @@ export async function registerMaintenanceQueues( boss, OFFHOST_BACKUP_QUEUE, { localConcurrency: 1, includeMetadata: true }, - handleOffhostBackup, + lockedPass(OFFHOST_BACKUP_QUEUE, () => WHOLE_PASS, handleOffhostBackup), ); await createAndWork( boss, @@ -935,7 +947,16 @@ export async function registerMaintenanceQueues( boss, PR_DETECTION_QUEUE, { localConcurrency: PR_DETECTION_CONCURRENCY }, - handlePrDetection, + // An ingest job is one account; the cron pass is every account, one run + // at a time. + lockedPass( + PR_DETECTION_QUEUE, + (job) => { + const userId = (job.data as PrDetectionPayload | undefined)?.userId; + return userId ? `user:${userId}` : WHOLE_PASS; + }, + handlePrDetection, + ), ); await createAndWork( boss, @@ -1159,7 +1180,11 @@ export async function registerMaintenanceQueues( boss, ENCRYPTION_KEY_ROTATE_QUEUE, { localConcurrency: ENCRYPTION_KEY_ROTATE_CONCURRENCY }, - handleEncryptionKeyRotate, + lockedPass( + ENCRYPTION_KEY_ROTATE_QUEUE, + () => WHOLE_PASS, + handleEncryptionKeyRotate, + ), ); // v1.25 — medication-note encryption backfill worker. The boot enqueue @@ -1318,56 +1343,65 @@ export async function registerMaintenanceQueues( boss, CONTENT_INDEX_BACKFILL_QUEUE, { localConcurrency: CONTENT_INDEX_BACKFILL_CONCURRENCY }, - async (jobs) => { - let users = 0; - let skipped = 0; - let documentsIndexed = 0; - let documentsRetokenised = 0; - let documentsSkipped = 0; - let documentsFailed = 0; - for (const job of jobs) { - const { userId } = job.data; - if (!userId) { - skipped++; - continue; + lockedPass( + CONTENT_INDEX_BACKFILL_QUEUE, + (job) => `user:${job.data.userId ?? ""}`, + async (jobs) => { + let users = 0; + let stoppedEarly = false; + const shouldStop = jobBudget(jobs); + let skipped = 0; + let documentsIndexed = 0; + let documentsRetokenised = 0; + let documentsSkipped = 0; + let documentsFailed = 0; + for (const job of jobs) { + const { userId } = job.data; + if (!userId) { + skipped++; + continue; + } + try { + const { + indexed, + retokenised, + skipped: docsSkipped, + failed: docsFailed, + reason, + stoppedEarly: stopped, + } = await runContentIndexBackfillForUser(userId, shouldStop); + users++; + stoppedEarly ||= stopped === true; + documentsIndexed += indexed; + documentsRetokenised += retokenised; + documentsSkipped += docsSkipped; + documentsFailed += docsFailed; + workerLog( + "info", + `[document-content-index-backfill] user=${userId} indexed=${indexed} retokenised=${retokenised} skipped=${docsSkipped} failed=${docsFailed} reason=${reason}`, + ); + } catch (err) { + recordError(); + workerLog( + "error", + `[document-content-index-backfill] user=${userId} failed`, + err, + ); + throw err; + } } - try { - const { - indexed, - retokenised, - skipped: docsSkipped, - failed: docsFailed, - reason, - } = await runContentIndexBackfillForUser(userId); - users++; - documentsIndexed += indexed; - documentsRetokenised += retokenised; - documentsSkipped += docsSkipped; - documentsFailed += docsFailed; - workerLog( - "info", - `[document-content-index-backfill] user=${userId} indexed=${indexed} retokenised=${retokenised} skipped=${docsSkipped} failed=${docsFailed} reason=${reason}`, - ); - } catch (err) { - recordError(); - workerLog( - "error", - `[document-content-index-backfill] user=${userId} failed`, - err, - ); - throw err; - } - } - return jobDone({ - jobs: jobs.length, - users, - skipped, - documents_indexed: documentsIndexed, - documents_retokenised: documentsRetokenised, - documents_skipped: documentsSkipped, - documents_failed: documentsFailed, - }); - }, + return jobDone({ + jobs: jobs.length, + users, + skipped, + documents_indexed: documentsIndexed, + documents_retokenised: documentsRetokenised, + documents_skipped: documentsSkipped, + documents_failed: documentsFailed, + stopped_early: stoppedEarly, + }); + }, + ), ); // Document AI — automatic per-document content indexing. Enqueued on upload diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts index 70ece8c3d..bd8d3c09a 100644 --- a/src/lib/jobs/reminder/register-status.ts +++ b/src/lib/jobs/reminder/register-status.ts @@ -102,11 +102,16 @@ import { } from "@/lib/jobs/insight-status-generate"; import { withBackgroundEvent } from "@/lib/logging/background"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { jobBudget } from "@/lib/jobs/job-budget"; +import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass"; +import { INSIGHT_PREGENERATE_EXPIRE_SECONDS } from "@/lib/jobs/insight-pregenerate-shared"; +import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "@/lib/jobs/period-narrative-shared"; import { getWorkerPrisma, workerLog } from "./shared"; import { createAndSchedule, createAndWork, insightRetryOptions, + nightlyInsightPassOptions, type QueuePolicyTable, type ScheduleEntry, } from "./registrar-shared"; @@ -232,25 +237,32 @@ const allQueues = [ ]; const schedules: ScheduleEntry[] = [ - [GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON, insightRetryOptions], + [GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON, nightlyInsightPassOptions], [ BLOOD_PRESSURE_STATUS_QUEUE, BLOOD_PRESSURE_STATUS_CRON, - insightRetryOptions, + nightlyInsightPassOptions, ], - [WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON, insightRetryOptions], - [PULSE_STATUS_QUEUE, PULSE_STATUS_CRON, insightRetryOptions], - [BMI_STATUS_QUEUE, BMI_STATUS_CRON, insightRetryOptions], + [WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON, nightlyInsightPassOptions], + [PULSE_STATUS_QUEUE, PULSE_STATUS_CRON, nightlyInsightPassOptions], + [BMI_STATUS_QUEUE, BMI_STATUS_CRON, nightlyInsightPassOptions], // v1.15.20 — mood status nightly, continuing the 02:xx ladder. - [MOOD_STATUS_QUEUE, MOOD_STATUS_CRON, insightRetryOptions], + [MOOD_STATUS_QUEUE, MOOD_STATUS_CRON, nightlyInsightPassOptions], [ MEDICATION_COMPLIANCE_STATUS_QUEUE, MEDICATION_COMPLIANCE_STATUS_CRON, - insightRetryOptions, + nightlyInsightPassOptions, ], // v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight // pre-generation. Budget-gated per user inside the handler. - [INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON, insightRetryOptions], + [ + INSIGHT_PREGENERATE_QUEUE, + INSIGHT_PREGENERATE_CRON, + { + ...insightRetryOptions, + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, + }, + ], // v1.10.0 — computed scores (WX-C). Nightly 04:45 Europe/Berlin // Recovery-score compute + store, after the rollup-feeding consolidation // + drain so the signals it reads are already folded. @@ -266,7 +278,14 @@ const schedules: ScheduleEntry[] = [ // v1.11.0 — nightly 05:05 Europe/Berlin period-narrative warm. The // handler only fans out on a week (Mon) / month (1st) boundary; every // other night is a cheap no-op. Budget-gated per user inside the runner. - [PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON, insightRetryOptions], + [ + PERIOD_NARRATIVE_QUEUE, + PERIOD_NARRATIVE_CRON, + { + ...insightRetryOptions, + expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS, + }, + ], // v1.15.20 — daily 05:15 Europe/Berlin proactive Coach nudge, after // the 04:45–04:55 score crons so the recovery-score trigger reads // settled rows. Deterministic triggers only — no AI call on this path. @@ -369,43 +388,59 @@ export async function registerStatusQueues( boss, GENERAL_STATUS_QUEUE, { localConcurrency: 1 }, - handleGeneralStatusGenerate, + lockedPass( + GENERAL_STATUS_QUEUE, + () => WHOLE_PASS, + handleGeneralStatusGenerate, + ), ); await createAndWork( boss, BLOOD_PRESSURE_STATUS_QUEUE, { localConcurrency: 1 }, - handleBloodPressureStatusGenerate, + lockedPass( + BLOOD_PRESSURE_STATUS_QUEUE, + () => WHOLE_PASS, + handleBloodPressureStatusGenerate, + ), ); await createAndWork( boss, WEIGHT_STATUS_QUEUE, { localConcurrency: 1 }, - handleWeightStatusGenerate, + lockedPass( + WEIGHT_STATUS_QUEUE, + () => WHOLE_PASS, + handleWeightStatusGenerate, + ), ); await createAndWork( boss, PULSE_STATUS_QUEUE, { localConcurrency: 1 }, - handlePulseStatusGenerate, + lockedPass(PULSE_STATUS_QUEUE, () => WHOLE_PASS, handlePulseStatusGenerate), ); await createAndWork( boss, BMI_STATUS_QUEUE, { localConcurrency: 1 }, - handleBmiStatusGenerate, + lockedPass(BMI_STATUS_QUEUE, () => WHOLE_PASS, handleBmiStatusGenerate), ); await createAndWork( boss, MOOD_STATUS_QUEUE, { localConcurrency: 1 }, - handleMoodStatusGenerate, + lockedPass(MOOD_STATUS_QUEUE, () => WHOLE_PASS, handleMoodStatusGenerate), ); await createAndWork( boss, MEDICATION_COMPLIANCE_STATUS_QUEUE, { localConcurrency: 1 }, - handleMedicationComplianceStatusGenerate, + lockedPass( + MEDICATION_COMPLIANCE_STATUS_QUEUE, + () => WHOLE_PASS, + handleMedicationComplianceStatusGenerate, + ), ); // v1.7.0 — nightly comprehensive-insight pre-generation. // @@ -422,7 +457,16 @@ export async function registerStatusQueues( boss, INSIGHT_PREGENERATE_QUEUE, { localConcurrency: 2 }, - handleInsightPregenerateJob, + // The cohort walk is one pass at a time; a forced warm is one per + // account, so it never waits behind the walk. + lockedPass( + INSIGHT_PREGENERATE_QUEUE, + (job) => + job.data?.force && job.data.userId + ? `user:${job.data.userId}` + : WHOLE_PASS, + handleInsightPregenerateJob, + ), ); // S4 — event-driven morning digest refresh. One forced comprehensive // regeneration per user per local morning, enqueued by the sleep-arrival @@ -749,44 +793,53 @@ export async function registerStatusQueues( boss, PERIOD_NARRATIVE_QUEUE, { localConcurrency: 1 }, - async (jobs): Promise => { - // Two modes on one queue, so the outcome counts them separately: a - // non-boundary night is a scheduled pass that generated nothing, which - // is a success with zero counts, not an idle queue. - let singleUser = 0; - let scheduled = 0; - let generated = 0; - let failed = 0; - for (const job of jobs) { - try { - if (job.data?.userId) { - await warmOneNarrative(job.data); - singleUser++; - } else { - const summary = await runPeriodNarrativeWarm(getWorkerPrisma()); - scheduled++; - generated += summary.generated; - failed += summary.failed; - workerLog( - "info", - `[period-narrative] periods=${summary.periods.join(",") || "none"} total=${summary.total} generated=${summary.generated} cached=${summary.cached} skipped=${summary.skipped} insufficient=${summary.insufficient} failed=${summary.failed} budget=${summary.budgetBlocked}`, - ); + lockedPass( + PERIOD_NARRATIVE_QUEUE, + (job) => (job.data?.userId ? `user:${job.data.userId}` : WHOLE_PASS), + async (jobs): Promise => { + // Two modes on one queue, so the outcome counts them separately: a + // non-boundary night is a scheduled pass that generated nothing, which + // is a success with zero counts, not an idle queue. + let singleUser = 0; + let scheduled = 0; + let generated = 0; + let failed = 0; + let stoppedEarly = false; + for (const job of jobs) { + try { + if (job.data?.userId) { + await warmOneNarrative(job.data); + singleUser++; + } else { + const summary = await runPeriodNarrativeWarm(getWorkerPrisma(), { + shouldStop: jobBudget(jobs), + }); + scheduled++; + generated += summary.generated; + failed += summary.failed; + stoppedEarly ||= summary.stoppedEarly; + workerLog( + "info", + `[period-narrative] periods=${summary.periods.join(",") || "none"} total=${summary.total} generated=${summary.generated} cached=${summary.cached} skipped=${summary.skipped} insufficient=${summary.insufficient} failed=${summary.failed} budget=${summary.budgetBlocked}`, + ); + } + } catch (err) { + recordError(); + await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, { + mode: job.data?.userId ? "single-user" : "scheduled", + }); + throw err; } - } catch (err) { - recordError(); - await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, { - mode: job.data?.userId ? "single-user" : "scheduled", - }); - throw err; } - } - return jobDone({ - single_user: singleUser, - scheduled, - generated, - failed, - }); - }, + return jobDone({ + single_user: singleUser, + scheduled, + generated, + failed, + stopped_early: stoppedEarly, + }); + }, + ), ); // v1.11.1 — combined Coach memory refresh: rolling conversation summary + // durable fact extraction for one long conversation. localConcurrency 1 so a diff --git a/src/lib/jobs/reminder/registrar-shared.ts b/src/lib/jobs/reminder/registrar-shared.ts index c46497fb8..6841b0111 100644 --- a/src/lib/jobs/reminder/registrar-shared.ts +++ b/src/lib/jobs/reminder/registrar-shared.ts @@ -168,6 +168,22 @@ export const insightRetryOptions = { retryBackoff: true, } as const; +/** + * Send options for a nightly AI pass over every account (the status crons). + * + * The retries of `insightRetryOptions`, plus a two-hour expiry. Each pass + * makes a provider call per account, and on a large instance that outlasted + * pg-boss's fifteen-minute default: the job was declared dead and retried + * beside the pass still running. The pass now stops itself at three quarters + * of this (`jobBudget`) and holds a lock (`lockedPass`). The options ride on + * the schedule because the cron is the only sender. + */ +export const NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS = 2 * 60 * 60; +export const nightlyInsightPassOptions = { + ...insightRetryOptions, + expireInSeconds: NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS, +} as const; + /** * Retry policy for a cron-driven pass whose failure mode is deterministic. * From 1934b97663915c1c0f9d82ac8b86b2584a72354c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:24:15 +0200 Subject: [PATCH 21/29] test(guard): declare every queue's runtime and hold long ones to it Every queue bound with createAndWork is now declared in src/lib/jobs/queue-runtime.ts as short (cannot come near the default fifteen minutes, with the reason), budgeted (stops itself at three quarters of the default and resumes) or long (an explicit expiry, a stop on the job's budget, and a lock or claim per identity; or a declared one-shot with no retries). The guard reads every binding, every boss.send and every schedule tuple in the tree: each names a queue in the table, every entry is bound, a long queue's sends and schedules all carry its expiry, its handler stops on jobBudget / jobDeadline where declared and holds lockedPass or the named marker, and a short or budgeted queue sets no expiry of its own. Floors pin the counts (112 bindings, 64 sends, 74 schedules), and planted long queues missing each protection in turn are caught. Run against the tree before the previous commit it reports 59 findings; dropping lockedPass and the cron expiry from pr-detection reports both. --- src/__tests__/job-queue-runtime-guard.test.ts | 371 ++++++++++++++++ src/lib/jobs/queue-runtime.ts | 398 ++++++++++++++++++ 2 files changed, 769 insertions(+) create mode 100644 src/__tests__/job-queue-runtime-guard.test.ts create mode 100644 src/lib/jobs/queue-runtime.ts diff --git a/src/__tests__/job-queue-runtime-guard.test.ts b/src/__tests__/job-queue-runtime-guard.test.ts new file mode 100644 index 000000000..5110a5c93 --- /dev/null +++ b/src/__tests__/job-queue-runtime-guard.test.ts @@ -0,0 +1,371 @@ +/** + * Structural guard: every pg-boss queue declares how long its job can run, and + * a long one is safe against pg-boss's expiry. + * + * pg-boss gives a job fifteen minutes unless the send says otherwise, then + * fails it, fires its abort signal and retries it while the handler keeps + * going. The full-history imports ran beside themselves that way (#1066), and + * so could every other job that legitimately needs longer: the nightly status + * passes, the pre-generation, the record scan, the key rotation, the backups. + * + * `src/lib/jobs/queue-runtime.ts` declares every queue `short`, `budgeted` or + * `long`. This guard holds the tree to it: + * + * - every queue bound with `createAndWork` is in the table, and every table + * entry is bound; + * - every `boss.send` and every schedule tuple names a queue in the table; + * - a `long` queue has an expiry between the default and pg-boss's 24-hour + * ceiling, every send and schedule for it carries `expireInSeconds` (or an + * identifier the table names as its carrier), it stops on the job's budget + * where the table says (`jobBudget` / `jobDeadline`, or a declared + * one-shot), and it is exclusive per identity where the table says + * (`lockedPass` at the binding, or the named marker); + * - a `budgeted` queue stops on the budget and relies on the default expiry; + * - a `short` queue's sends set no expiry of their own, which would mean the + * job is longer than the table claims. + * + * Floors fail the guard if a matcher stops matching, and the "plants" cases + * run the checks over a planted long queue that lacks each protection in turn + * (the mutation proof kept in the suite). Removing `lockedPass` from the + * pr-detection binding, or the expiry from its cron tuple, fails the real + * checks below. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; +import { + DEFAULT_JOB_EXPIRE_SECONDS, + QUEUE_RUNTIME, + type QueueRuntime, + type SourceSite, +} from "@/lib/jobs/queue-runtime"; + +const SRC = join(process.cwd(), "src"); +const REGISTRARS = [ + "lib/jobs/reminder/register-integration-sync.ts", + "lib/jobs/reminder/register-maintenance.ts", + "lib/jobs/reminder/register-reminders.ts", + "lib/jobs/reminder/register-rollup.ts", + "lib/jobs/reminder/register-status.ts", +]; +/** pg-boss refuses an expiry of 24 hours or more. */ +const MAX_EXPIRE_SECONDS = 24 * 60 * 60; + +const files = walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); +const source = new Map( + files.map((f) => [f, readFileSync(join(SRC, f), "utf8")]), +); + +/** Strip comments so documentation that quotes code never matches. */ +function code(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:])\/\/.*$/gm, "$1"); +} + +/** Index just past the bracket that closes the one at `open`. */ +function closeOf(text: string, open: number): number { + const pair: Record = { "(": ")", "[": "]", "{": "}" }; + const want = pair[text[open]]; + let depth = 1; + let i = open + 1; + while (depth > 0 && i < text.length) { + if (text[i] === text[open]) depth += 1; + else if (text[i] === want) depth -= 1; + i += 1; + } + return i; +} + +type Definitions = Map>; + +/** `const NAME = "queue-name"` definitions, per file. */ +function definitionsOf(texts: ReadonlyMap): Definitions { + const definitions: Definitions = new Map(); + for (const [file, text] of texts) { + for (const m of text.matchAll( + /\bconst\s+([A-Z][A-Z0-9_]*)\s*(?::\s*string\s*)?=\s*"([a-z0-9-]+)"/g, + )) { + const list = definitions.get(m[1]) ?? []; + list.push({ file, name: m[2] }); + definitions.set(m[1], list); + } + } + return definitions; +} + +/** Resolve a queue identifier as seen from `file`, preferring its own. */ +function resolve( + definitions: Definitions, + file: string, + ident: string, +): string | null { + const defs = definitions.get(ident) ?? []; + const own = defs.find((d) => d.file === file); + if (own) return own.name; + const names = new Set(defs.map((d) => d.name)); + return names.size === 1 ? [...names][0] : null; +} + +interface Site { + file: string; + queue: string; + text: string; +} + +/** Every `createAndWork(boss, QUEUE, …)` in the registrars. */ +function bindings( + texts: ReadonlyMap, + definitions: Definitions, +): Site[] { + const out: Site[] = []; + for (const file of REGISTRARS) { + const text = code(texts.get(file) ?? ""); + for (const m of text.matchAll( + /createAndWork\s*(?:<[^(]*?>)?\(\s*boss\s*,\s*([A-Z][A-Z0-9_]*)/g, + )) { + const open = text.indexOf("(", m.index); + const queue = resolve(definitions, file, m[1]); + if (queue) { + out.push({ file, queue, text: text.slice(open, closeOf(text, open)) }); + } + } + } + return out; +} + +/** Every `boss.send(QUEUE, …)` / `getGlobalBoss()?.send(QUEUE, …)`. */ +function sends( + texts: ReadonlyMap, + definitions: Definitions, +): Site[] { + const out: Site[] = []; + for (const [file, raw] of texts) { + const text = code(raw); + for (const m of text.matchAll( + /\b(?:boss|getGlobalBoss\(\)\??)\s*\.\s*send\s*(?:<[^(]*?>)?\(\s*([A-Z][A-Z0-9_]*)/g, + )) { + const open = text.indexOf("(", text.indexOf("send", m.index)); + const queue = resolve(definitions, file, m[1]); + if (queue) { + out.push({ file, queue, text: text.slice(open, closeOf(text, open)) }); + } + } + } + return out; +} + +/** Every `[QUEUE, CRON, options?]` tuple in a registrar's `schedules`. */ +function schedules( + texts: ReadonlyMap, + definitions: Definitions, +): Site[] { + const out: Site[] = []; + for (const file of REGISTRARS) { + const text = code(texts.get(file) ?? ""); + const m = /const\s+schedules\s*:\s*ScheduleEntry\[\]\s*=\s*\[/.exec(text); + if (!m) continue; + const open = m.index + m[0].length - 1; + const body = text.slice(open + 1, closeOf(text, open) - 1); + for (let i = 0; i < body.length; i += 1) { + if (body[i] !== "[") continue; + const end = closeOf(body, i); + const tuple = body.slice(i, end); + const ident = /^\[\s*([A-Z][A-Z0-9_]*)/.exec(tuple)?.[1]; + const queue = ident ? resolve(definitions, file, ident) : null; + if (queue) out.push({ file, queue, text: tuple }); + i = end - 1; + } + } + return out; +} + +/** The text of `fn` (a function or a const) in `file`. */ +function bodyOf( + texts: ReadonlyMap, + site: { file: string; fn: string }, +): string { + const text = code(texts.get(site.file) ?? ""); + const m = new RegExp( + `(?:function\\s+${site.fn}\\b|const\\s+${site.fn}\\b)`, + ).exec(text); + if (!m) return ""; + const end = text.indexOf("\n}", m.index); + return text.slice(m.index, end < 0 ? undefined : end + 2); +} + +function siteText( + texts: ReadonlyMap, + site: SourceSite, + binding: Site | undefined, +): string { + return site === "binding" ? (binding?.text ?? "") : bodyOf(texts, site); +} + +const STOPS = /\bjob(?:Budget|Deadline)\s*\(/; + +/** Every finding for one queue's declaration against the tree. */ +function check( + queue: string, + entry: QueueRuntime, + texts: ReadonlyMap, + all: { bindings: Site[]; sends: Site[]; schedules: Site[] }, +): string[] { + const problems: string[] = []; + const binding = all.bindings.find((b) => b.queue === queue); + const carriers = [...all.sends, ...all.schedules].filter( + (s) => s.queue === queue, + ); + if (entry.runtime === "long") { + if ( + !(entry.expireInSeconds > DEFAULT_JOB_EXPIRE_SECONDS) || + !(entry.expireInSeconds < MAX_EXPIRE_SECONDS) + ) { + problems.push(`${queue}: expiry ${entry.expireInSeconds}s out of range`); + } + for (const site of carriers) { + const carried = + /\bexpireInSeconds\b/.test(site.text) || + entry.expiryVia.some((v) => new RegExp(`\\b${v}\\b`).test(site.text)); + if (!carried) { + problems.push( + `${queue}: ${site.file} sends or schedules it without an expiry`, + ); + } + } + if (typeof entry.stop === "object" && "oneShot" in entry.stop) { + if (entry.stop.oneShot.length < 20) { + problems.push(`${queue}: a one-shot needs its reason`); + } + } else if (!STOPS.test(siteText(texts, entry.stop, binding))) { + problems.push(`${queue}: does not stop on the job's budget`); + } + if (entry.exclusive === "lockedPass") { + if (!/\blockedPass\s*\(/.test(binding?.text ?? "")) { + problems.push(`${queue}: binding holds no lockedPass`); + } + } else if ( + !siteText(texts, entry.exclusive.at, binding).includes( + entry.exclusive.marker, + ) + ) { + problems.push(`${queue}: no "${entry.exclusive.marker}" where declared`); + } + } else { + if (entry.runtime === "budgeted") { + if (!STOPS.test(siteText(texts, entry.budget, binding))) { + problems.push(`${queue}: does not stop on the job's budget`); + } + } + for (const site of carriers) { + if (/\bexpireInSeconds\b/.test(site.text)) { + problems.push( + `${queue}: ${site.file} sets an expiry on a queue declared ${entry.runtime}`, + ); + } + } + } + return problems; +} + +function scan(texts: ReadonlyMap) { + const definitions = definitionsOf(texts); + return { + bindings: bindings(texts, definitions), + sends: sends(texts, definitions), + schedules: schedules(texts, definitions), + }; +} + +describe("every queue declares its runtime", () => { + const all = scan(source); + const bound = new Set(all.bindings.map((b) => b.queue)); + + it("finds the bindings, sends and schedules it is meant to police", () => { + // Pinned below the counts on 2026-10-01 (112 / 64 / 74). + expect(all.bindings.length).toBeGreaterThanOrEqual(105); + expect(all.sends.length).toBeGreaterThanOrEqual(50); + expect(all.schedules.length).toBeGreaterThanOrEqual(70); + expect( + Object.values(QUEUE_RUNTIME).filter((e) => e.runtime === "long").length, + ).toBeGreaterThanOrEqual(15); + }); + + it("every bound queue is in the table, and every entry is bound", () => { + expect([...bound].filter((q) => !(q in QUEUE_RUNTIME)).sort()).toEqual([]); + expect( + Object.keys(QUEUE_RUNTIME) + .filter((q) => !bound.has(q)) + .sort(), + ).toEqual([]); + }); + + it("every send and schedule names a queue in the table", () => { + const unknown = [...all.sends, ...all.schedules] + .filter((s) => !(s.queue in QUEUE_RUNTIME)) + .map((s) => `${s.file} → ${s.queue}`); + expect(unknown).toEqual([]); + }); + + it("every queue meets its declaration", () => { + const problems = Object.entries(QUEUE_RUNTIME).flatMap(([queue, entry]) => + check(queue, entry, source, all), + ); + expect(problems).toEqual([]); + }); + + it("plants: a long queue missing any protection is caught", () => { + const registrar = REGISTRARS[1]; + const planted = (binding: string, schedule: string) => + new Map([ + ...source, + [ + registrar, + `const PLANT_QUEUE = "planted-long";\n` + + `const schedules: ScheduleEntry[] = [\n ${schedule},\n];\n` + + `async function register(boss) {\n await createAndWork(boss, PLANT_QUEUE, { localConcurrency: 1 }, ${binding});\n}\n`, + ], + ]); + const entry: QueueRuntime = { + runtime: "long", + expireInSeconds: 3600, + expiryVia: [], + stop: "binding", + exclusive: "lockedPass", + why: "planted", + }; + const findings = (binding: string, schedule: string) => { + const texts = planted(binding, schedule); + return check("planted-long", entry, texts, scan(texts)); + }; + const safe = [ + "lockedPass(PLANT_QUEUE, () => WHOLE_PASS, async (jobs) => run(jobBudget(jobs)))", + "[PLANT_QUEUE, CRON, { expireInSeconds: 3600 }]", + ] as const; + expect(findings(...safe)).toEqual([]); + expect(findings(safe[0], "[PLANT_QUEUE, CRON]")).toEqual([ + `planted-long: ${registrar} sends or schedules it without an expiry`, + ]); + expect( + findings("lockedPass(PLANT_QUEUE, () => WHOLE_PASS, run)", safe[1]), + ).toEqual(["planted-long: does not stop on the job's budget"]); + expect(findings("async (jobs) => run(jobBudget(jobs))", safe[1])).toEqual([ + "planted-long: binding holds no lockedPass", + ]); + expect( + check( + "planted-long", + { ...entry, expireInSeconds: 900 }, + ...((): [ReadonlyMap, ReturnType] => { + const texts = planted(...safe); + return [texts, scan(texts)]; + })(), + ), + ).toEqual(["planted-long: expiry 900s out of range"]); + }); +}); diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts new file mode 100644 index 000000000..caf376c3d --- /dev/null +++ b/src/lib/jobs/queue-runtime.ts @@ -0,0 +1,398 @@ +/** + * How long one job of each queue can run, and what keeps a long one safe. + * + * pg-boss gives every job an expiry, fifteen minutes unless the send says + * otherwise. Past it pg-boss fails the job, fires its abort signal and retries + * it, and it does not stop the handler, so a job that legitimately needs longer + * ends up running twice beside itself. No queue policy prevents that: the + * policies constrain job rows, and an expired row is no longer active. Every + * queue bound with `createAndWork` is therefore declared here as one of: + * + * - `short`: a job cannot come near fifteen minutes (one account's bounded + * window, one document, a forwarder, a bounded delete). It relies on the + * default expiry, and the reason says why that is enough. + * - `budgeted`: a pass over a long history that stops itself at three + * quarters of the default expiry (`jobBudget`) and hands the rest to its + * next run, so it ends before fifteen minutes by construction. + * - `long`: a job that may run for hours. It carries an explicit + * `expireInSeconds` on every send and schedule, stops itself before that + * expiry (`jobBudget` / `jobDeadline`), and holds something per identity + * that keeps a second run from working beside it: the lock `lockedPass` + * takes, a lock of its own, or a row it claims. The one exception is a + * one-shot job with `retryLimit: 0`, which pg-boss never retries and which + * cannot stop half-way without losing its input. + * + * `src/__tests__/job-queue-runtime-guard.test.ts` holds every binding, send + * and schedule in the tree to this table. + */ +import { INTEGRATION_BACKFILL_EXPIRE_SECONDS } from "@/lib/jobs/integration-backfill-admission"; +import { NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS } from "@/lib/jobs/reminder/registrar-shared"; +import { INSIGHT_PREGENERATE_EXPIRE_SECONDS } from "@/lib/jobs/insight-pregenerate-shared"; +import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "@/lib/jobs/period-narrative-shared"; +import { PR_DETECTION_EXPIRE_SECONDS } from "@/lib/jobs/pr-detection"; +import { ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS } from "@/lib/jobs/encryption-key-rotate"; +import { CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS } from "@/lib/jobs/document-content-index-backfill"; +import { DATA_BACKUP_SEND_OPTIONS } from "@/lib/jobs/data-backup-policy"; +import { OFFHOST_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/offhost-backup"; +import { BACKUP_RESTORE_EXPIRE_SECONDS } from "@/lib/jobs/backup-restore"; +import { APPLE_HEALTH_IMPORT_SEND_OPTIONS } from "@/lib/jobs/apple-health-import-worker"; +import { MEDICATION_INTAKE_IMPORT_SEND_OPTIONS } from "@/lib/jobs/medication-intake-import"; + +/** pg-boss's default job expiry, in seconds. */ +export const DEFAULT_JOB_EXPIRE_SECONDS = 15 * 60; + +/** Where a check lives: `binding` is the `createAndWork` call itself. */ +export type SourceSite = "binding" | { file: string; fn: string }; + +export type QueueRuntime = + | { runtime: "short"; why: string } + | { runtime: "budgeted"; budget: SourceSite; why: string } + | { + runtime: "long"; + expireInSeconds: number; + /** + * Identifiers that carry the expiry into a send or a schedule. A site + * that names none of them must set `expireInSeconds` itself. + */ + expiryVia: readonly string[]; + /** Where the handler stops on the job's budget, or `oneShot`. */ + stop: SourceSite | { oneShot: string }; + /** + * What keeps a second run from working beside this one: `lockedPass` + * at the binding, or a marker in the named function. + */ + exclusive: "lockedPass" | { marker: string; at: SourceSite }; + why: string; + }; + +const short = (why: string): QueueRuntime => ({ runtime: "short", why }); + +const nightlyStatusPass = (fn: string): QueueRuntime => ({ + runtime: "long", + expireInSeconds: NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS, + expiryVia: ["nightlyInsightPassOptions"], + stop: { file: "lib/jobs/reminder/insights-handlers.ts", fn }, + exclusive: "lockedPass", + why: "Nightly status pass: one provider call per candidate account, serially, over the whole instance.", +}); + +const cohortSync = (provider: string): QueueRuntime => + short( + `${provider} sync: a recent window per connection (incremental since the last sync); a webhook job is one connection.`, + ); + +const oauthStateCleanup = short("One DELETE of expired OAuth state rows."); + +const forwarder = short( + "Forwards each job to integration-backfill-admission; the import itself runs there.", +); + +export const QUEUE_RUNTIME: Readonly> = { + // ── Integration sync ─────────────────────────────────────────────── + "integration-backfill-admission": { + runtime: "long", + expireInSeconds: INTEGRATION_BACKFILL_EXPIRE_SECONDS, + expiryVia: ["integrationBackfillAdmissionSendOptions"], + stop: { + file: "lib/jobs/integration-backfill-drain.ts", + fn: "drainIntegrationBackfillAdmission", + }, + exclusive: { + marker: "withIntegrationBackfillLock", + at: { + file: "lib/jobs/integration-backfill-drain.ts", + fn: "drainIntegrationBackfillAdmission", + }, + }, + why: "Full-history provider imports: thousands of pages on a dense account.", + }, + "withings-fallback-sync": cohortSync("Withings"), + "withings-activity-sync": cohortSync("Withings activity"), + "withings-sleep-sync": cohortSync("Withings sleep"), + "withings-ecg-sync": cohortSync("Withings ECG"), + "withings-oauth-state-cleanup": oauthStateCleanup, + "whoop-recovery-sync": cohortSync("WHOOP recovery"), + "whoop-sleep-sync": cohortSync("WHOOP sleep"), + "whoop-workout-sync": cohortSync("WHOOP workout"), + "whoop-cycle-sync": cohortSync("WHOOP cycle"), + "whoop-backfill": forwarder, + "whoop-oauth-state-cleanup": oauthStateCleanup, + "oidc-native-handoff-cleanup": short("One DELETE of expired handoff rows."), + "fitbit-sync": cohortSync("Fitbit"), + "fitbit-backfill": forwarder, + "fitbit-sleep-repair": forwarder, + "fitbit-oauth-state-cleanup": oauthStateCleanup, + "google-health-sync": cohortSync("Google Health"), + "google-health-backfill": forwarder, + "google-health-sleep-repair": forwarder, + "google-health-oauth-state-cleanup": oauthStateCleanup, + "sleep-timeline-backfill": forwarder, + "lab-biomarker-backfill": forwarder, + "nightscout-sync": cohortSync("Nightscout"), + "polar-sync": cohortSync("Polar"), + "oura-sync": cohortSync("Oura"), + "strava-sync": cohortSync("Strava"), + "strava-backfill": forwarder, + + // ── Status and insights ──────────────────────────────────────────── + "insights-general-status": nightlyStatusPass("handleGeneralStatusGenerate"), + "insights-blood-pressure-status": nightlyStatusPass( + "handleBloodPressureStatusGenerate", + ), + "insights-weight-status": nightlyStatusPass("handleWeightStatusGenerate"), + "insights-pulse-status": nightlyStatusPass("handlePulseStatusGenerate"), + "insights-bmi-status": nightlyStatusPass("handleBmiStatusGenerate"), + "insights-mood-status": nightlyStatusPass("handleMoodStatusGenerate"), + "insights-medication-compliance-status": nightlyStatusPass( + "handleMedicationComplianceStatusGenerate", + ), + "insight-pregenerate": { + runtime: "long", + expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS, + expiryVia: ["INSIGHT_PREGENERATE_EXPIRE_SECONDS"], + stop: { + file: "lib/jobs/reminder/insights-handlers.ts", + fn: "handleInsightPregenerateJob", + }, + exclusive: "lockedPass", + why: "Up to 200 accounts, a comprehensive generation each (a provider call bounded by the account's timeout, up to ten minutes) plus the status warm.", + }, + "morning-digest-refresh": short( + "One account, one comprehensive generation, bounded by the provider timeout.", + ), + "insight-status-generate": short( + "One account, one metric, one provider call.", + ), + "data-arrival": short("One arrival's reactions."), + "reaction-line-generate": short("One provider call for one reaction line."), + "workout-insight-generate": short("One workout's insight."), + "recovery-score-compute": short( + "Arithmetic over a recent window, capped at 500 accounts; no provider call.", + ), + "stress-score-compute": short( + "Arithmetic over a recent window, capped at 500 accounts; no provider call.", + ), + "strain-score-compute": short( + "Arithmetic over a recent window, capped at 500 accounts; no provider call.", + ), + "period-narrative-warm": { + runtime: "long", + expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS, + expiryVia: ["PERIOD_NARRATIVE_EXPIRE_SECONDS"], + stop: "binding", + exclusive: "lockedPass", + why: "On a boundary night up to 200 accounts with a provider call per period each; a single-account warm is short.", + }, + "coach-memory-refresh": short("One conversation's summary and facts."), + "coach-nudge": short( + "Light deterministic checks per account; the provider part is capped at 25 calls and 90 seconds a tick.", + ), + "coach-reminder-sweep": short("Batches of 200 to 500 rows, no provider."), + "coach-plan-review": short("A batch of 100 plans, folded in SQL."), + "medication-low-stock": short("A light inventory check per account."), + "daily-briefing": short("A light cohort scan every fifteen minutes."), + + // ── Rollups ──────────────────────────────────────────────────────── + "rollup-recompute": short("One bucket."), + "rollup-full-backfill": short( + "One account's rollup tiers: four aggregate statements, each held to the 60-second statement timeout.", + ), + "mood-rollup-recompute": short("One bucket."), + "mood-rollup-full-backfill": short( + "One account's mood entries, a few a day.", + ), + "medication-compliance-full-backfill": short( + "One account's 90-day compliance window.", + ), + "step-consolidation": short( + "One account's legacy step rows, one transaction per day.", + ), + "step-consolidation-repair": short( + "One account's tombstoned step rows inside the retention window.", + ), + "cumulative-pr-rederive": short( + "One account's record detection; measured at 20 seconds for 1.25 million rows.", + ), + "mean-consolidation": { + runtime: "budgeted", + budget: "binding", + why: "One account's whole history, a day at a time; stops at three quarters of the default expiry and resumes.", + }, + "dense-intraday-retention": { + runtime: "budgeted", + budget: "binding", + why: "A dense account's history, a day at a time; stops at three quarters of the default expiry and resumes.", + }, + "dense-intraday-hourly-rebuild": { + runtime: "budgeted", + budget: "binding", + why: "A dense account's hourly rows, a day at a time; stops at three quarters of the default expiry and resumes.", + }, + "drain-per-sample-cumulative": { + runtime: "budgeted", + budget: "binding", + why: "Every account's per-sample drains; stops at three quarters of the default expiry and hands the rest to a continuation job.", + }, + + // ── Reminders ────────────────────────────────────────────────────── + "medication-reminder-check": short("The due slots of the current minute."), + "mood-reminder-check": short("Accounts whose local reminder hour is now."), + "cycle-reminder-check": short("Accounts whose local reminder hour is now."), + "measurement-reminder-check": short( + "Accounts whose local reminder hour is now.", + ), + "reminder-satisfy": short("One account's open reminders."), + + // ── Maintenance ──────────────────────────────────────────────────── + "data-backup": { + runtime: "long", + expireInSeconds: DATA_BACKUP_SEND_OPTIONS.expireInSeconds, + expiryVia: ["DATA_BACKUP_SEND_OPTIONS"], + stop: { + file: "lib/jobs/reminder/backup-handlers.ts", + fn: "handleDataBackup", + }, + exclusive: "lockedPass", + why: "Serialises every account's whole record.", + }, + "data-backup-offhost": { + runtime: "long", + expireInSeconds: OFFHOST_BACKUP_EXPIRE_SECONDS, + expiryVia: ["OFFHOST_BACKUP_EXPIRE_SECONDS"], + stop: { + file: "lib/jobs/reminder/backup-handlers.ts", + fn: "handleOffhostBackup", + }, + exclusive: "lockedPass", + why: "Uploads every account's record off-host.", + }, + "data-restore-drill": short("Fetches and stream-parses one off-host object."), + "offhost-backup-purge": short("At most 200 delete requests per run."), + "backup-restore": { + runtime: "long", + expireInSeconds: BACKUP_RESTORE_EXPIRE_SECONDS, + expiryVia: ["BACKUP_RESTORE_SEND_OPTIONS", "BACKUP_RESTORE_EXPIRE_SECONDS"], + stop: { file: "lib/jobs/backup-restore.ts", fn: "handleBackupRestore" }, + exclusive: { + marker: "restore_claimed", + at: { file: "lib/jobs/backup-restore.ts", fn: "runBackupRestoreJob" }, + }, + why: "Restores a whole account in one transaction.", + }, + "host-metric-sample": short("One host sample."), + "feedback-aggregator": short("One aggregate over the feedback table."), + "geo-backfill": short("At most 500 audit rows per pass."), + "geolite2-fetch": short("One database download."), + "tls-pin-monitor": short("One TLS handshake."), + "mood-reminder-cleanup": short("One retention DELETE."), + "push-attempt-cleanup": short("One retention DELETE."), + "arrival-reaction-cleanup": short("One retention DELETE."), + "rate-limit-cleanup": short("One retention DELETE."), + "idempotency-cleanup": short("One retention DELETE."), + "step-up-elevation-cleanup": short("One retention DELETE."), + "coach-message-cleanup": short("One retention DELETE."), + "mcp-token-cleanup": short("One retention DELETE."), + "audit-log-cleanup": short("At most 40 batches of 5 000 rows per leg."), + "measurement-tombstone-cleanup": short( + "At most 40 batches of 5 000 rows per leg.", + ), + "document-tombstone-purge": short("One deleteMany."), + "document-summary-reaper": short("One updateMany."), + "cycle-prediction-refresh": short( + "Arithmetic per eligible account, no provider call.", + ), + "mood-prognosis-refresh": short( + "Arithmetic per eligible account, no provider call.", + ), + "achievement-unlock-sweep": short( + "A bounded achievement read per account, no provider call.", + ), + "pr-detection": { + runtime: "long", + expireInSeconds: PR_DETECTION_EXPIRE_SECONDS, + expiryVia: ["PR_DETECTION_EXPIRE_SECONDS"], + stop: { + file: "lib/jobs/reminder/ops-handlers.ts", + fn: "handlePrDetection", + }, + exclusive: "lockedPass", + why: "The fallback cron scans every account's all-time history every thirty minutes; an ingest job is one account.", + }, + "medication-inventory-expire": short("One updateMany."), + "intake-auto-skip": short("One updateMany."), + "apple-health-import-v2": { + runtime: "long", + expireInSeconds: APPLE_HEALTH_IMPORT_SEND_OPTIONS.expireInSeconds, + expiryVia: ["APPLE_HEALTH_IMPORT_SEND_OPTIONS"], + stop: { + oneShot: + "The run consumes and unlinks its staged upload, so it can neither stop half-way nor be retried; the send sets retryLimit 0.", + }, + exclusive: { + marker: "retryLimit: 0", + at: { + file: "lib/jobs/apple-health-import-worker.ts", + fn: "APPLE_HEALTH_IMPORT_SEND_OPTIONS", + }, + }, + why: "Parses an export that can be several gigabytes.", + }, + "apple-health-import": short( + "The legacy queue: moves each job onto apple-health-import-v2.", + ), + "apple-health-import-reconcile": short("One updateMany."), + "medication-intake-import": { + runtime: "long", + expireInSeconds: MEDICATION_INTAKE_IMPORT_SEND_OPTIONS.expireInSeconds, + expiryVia: ["MEDICATION_INTAKE_IMPORT_SEND_OPTIONS"], + stop: { + file: "lib/jobs/medication-intake-import.ts", + fn: "handleMedicationIntakeImport", + }, + exclusive: { + marker: "FOR UPDATE", + at: { + file: "lib/jobs/medication-intake-import.ts", + fn: "processNextChunk", + }, + }, + why: "Imports a dose-history file chunk by chunk; its length follows the file.", + }, + "intake-slot-dedup": short("One account's duplicate dose slots."), + "note-encryption-backfill": short( + "Pages of 200 rows of one account's plaintext notes, light per row.", + ), + "med-notes-encryption-backfill": short( + "Pages of 200 rows of one account's plaintext notes, light per row.", + ), + "free-text-encryption-backfill": short( + "Pages of 200 rows of one account's plaintext fields, light per row.", + ), + "encryption-key-rotate": { + runtime: "long", + expireInSeconds: ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS, + expiryVia: ["ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS"], + stop: { + file: "lib/jobs/encryption-key-rotate.ts", + fn: "handleEncryptionKeyRotate", + }, + exclusive: "lockedPass", + why: "Re-encrypts every encrypted column on the instance.", + }, + "document-content-index-backfill": { + runtime: "long", + expireInSeconds: CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS, + expiryVia: ["CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS"], + stop: "binding", + exclusive: "lockedPass", + why: "Up to 200 documents of one account, a provider transcription each.", + }, + "document-index": short("One document."), + "document-thumbnail": short("One document."), + "document-summary": short("One document."), + "document-summary-catchup": short("Enqueues at most 200 documents."), + "document-thumbnail-backfill": short("Enqueues at most 1 000 documents."), + "environment-fetch": short( + "A fan-out of sends, or one account's bounded archive range.", + ), +}; From 4a13004917b38828ef8ff59a961b3e7140f652ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:44:29 +0200 Subject: [PATCH 22/29] fix(labs): fit the lab list, values list and add sheet to a phone The compact list was a five-column table at every width. Below the fixed columns (badge, 12rem range bar, 72px trend, chevron) the name column collapsed to nothing, the badge sat over the value and the row panned sideways off the card. Below lg the same cells now fold into two lines per row; the desktop subgrid is unchanged. The values table needed ~510px, so on a phone its edit and delete buttons sat in a sideways-scrolling strip and the range bar labels were clipped. Below lg each reading is a stacked row with its actions beside it. The add sheet's three footer buttons did not fit a phone row in German and pushed Cancel off the left edge; the secondary save takes a row of its own there, and the footer wraps instead of overflowing on desktop. The scan review's 'links to existing marker' chip truncates instead of running past its row. --- src/app/labs/page.tsx | 5 +- src/components/labs/lab-form.tsx | 4 + src/components/labs/lab-history-list.tsx | 233 ++++++++++++++--------- src/components/labs/lab-list.tsx | 72 +++++-- src/components/labs/ocr-row-editor.tsx | 12 +- 5 files changed, 214 insertions(+), 112 deletions(-) diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 1899669fd..69ca8f41e 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -192,7 +192,10 @@ export default function LabsPage() { title={t("labs.addResult")} description={t("labs.addDescription")} footer={ -
    +
    } > {pendingAction === "saveAndAddAnother" ? ( diff --git a/src/components/labs/lab-history-list.tsx b/src/components/labs/lab-history-list.tsx index 55b397519..3dce4fa67 100644 --- a/src/components/labs/lab-history-list.tsx +++ b/src/components/labs/lab-history-list.tsx @@ -311,103 +311,148 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) { }); }, [readings, sortBy, sortDir]); + // One action pair per reading, shared by the phone list and the table. + const renderActions = (r: LabResultDto) => ( +
    + {canManageLabs && ( + + )} + deleteMutation.mutate(r.id)} + title={t("labs.deleteConfirmTitle")} + description={t("labs.deleteConfirmDescription")} + confirmLabel={t("labs.deleteReading")} + className="size-11 sm:size-9" + iconClassName="h-4 w-4" + /> +
    + ); + return ( <> - - - - - - - - - {t("labs.editReading")} - - - - - {ordered.map((r) => ( - - -
    - {formatLabReading(r)} - -
    -
    - - {formatDateShort(r.takenAt, true)} - - -
    - - -
    -
    - - {r.hasNote ? t("labs.hasNote") : ""} - - -
    - {canManageLabs && ( - - )} - deleteMutation.mutate(r.id)} - title={t("labs.deleteConfirmTitle")} - description={t("labs.deleteConfirmDescription")} - confirmLabel={t("labs.deleteReading")} - className="size-11 sm:size-9" - iconClassName="h-4 w-4" - /> -
    -
    + {/* Phones get one stacked row per reading. The table needs ~510 px for + its five columns, so on a phone it panned sideways inside its + container and parked the edit / delete buttons off-screen, while the + range bar's end labels were clipped by the same container. */} +
      + {ordered.map((r) => ( +
    • +
      +
      + {formatLabReading(r)} +
      + +
      + + {formatDateShort(r.takenAt, true)} + + + {r.hasNote ? ( + {t("labs.hasNote")} + ) : null} +
      + +
      + {renderActions(r)} +
    • + ))} +
    +
    +
    + + + + + + + + {t("labs.editReading")} + - ))} - -
    + + + {ordered.map((r) => ( + + +
    + {formatLabReading(r)} + +
    +
    + + {formatDateShort(r.takenAt, true)} + + +
    + + +
    +
    + + {r.hasNote ? t("labs.hasNote") : ""} + + + {renderActions(r)} + +
    + ))} +
    + +
    +
    void } = {}) { const { t } = useTranslations(); const { canWriteDomain } = useRecordCapabilities(); @@ -212,12 +241,15 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {

    ) : null} - + {groups.map((group) => { const inner = ( -
    +
    - + {group.analyte}
    @@ -234,47 +266,57 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { {inner} -
    +
    reading.value)} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} />
    - + ) : ( // Un-linked group (not backfilled to a catalog biomarker): // render inert but say so, so it doesn't read as a broken // link next to its clickable neighbours. -
    +
    {inner} -
    +
    reading.value)} referenceLow={group.latest.referenceLow} referenceHigh={group.latest.referenceHigh} />
    - + {t("labs.notLinkedYet")}
    diff --git a/src/components/labs/ocr-row-editor.tsx b/src/components/labs/ocr-row-editor.tsx index ea1a06281..e63388a55 100644 --- a/src/components/labs/ocr-row-editor.tsx +++ b/src/components/labs/ocr-row-editor.tsx @@ -129,9 +129,17 @@ export function OcrRowEditor({ {fieldError("analyte", "labs.ocr.errorAnalyte")}
    {row.biomarkerMatch === "existing" ? ( - + // The chip repeats the analyte, so a long panel name ran past + // the row on a phone; it truncates inside the row instead. + - {t("labs.ocr.linksExisting", { name: row.analyte })} + + {t("labs.ocr.linksExisting", { name: row.analyte })} + ) : ( From 24e6972f95cbc031ffcfc66028a64ea30ce3e100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:44:30 +0200 Subject: [PATCH 23/29] fix(labs): keep the biomarker chart axis at zero for a range starting at zero A reference window of 0-130 padded the axis to -13, a tick that reads like a value for a concentration that cannot be negative. The pad only goes below zero when the data already does. --- src/components/labs/lab-biomarker-chart.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx index db6612d54..999d90e15 100644 --- a/src/components/labs/lab-biomarker-chart.tsx +++ b/src/components/labs/lab-biomarker-chart.tsx @@ -163,7 +163,10 @@ export function LabBiomarkerChart({ const min = Math.min(...candidates); const max = Math.max(...candidates); const pad = (max - min || Math.abs(max) || 1) * 0.1; - return [min - pad, max + pad]; + // A reference window that starts at 0 (LDL "0–130") must not drag the + // axis below zero: a concentration cannot be negative, and a "-13" tick + // reads as a value. The pad only stays below zero when the data already is. + return [min >= 0 ? Math.max(0, min - pad) : min - pad, max + pad]; }, [points, lowerBound, upperBound, sourceBand]); const animate = !prefersReducedMotion(); From dc385405731daa2c3a2f615115cef96bb0c3b680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:44:43 +0200 Subject: [PATCH 24/29] fix(ui): give the page-header description the full row on a phone With three or four icon actions beside the title, the description was left a ~130px column that wrapped five or six times. Below sm it now spans the row under the title and actions; from sm up the actions span both rows exactly as before. A one-word German title wider than the column beside the actions ran underneath them; the heading hyphenates by the page language and breaks as a last resort. Notifications, whose two actions are text links, moves them under the description on a phone (stackActionsOnPhone). --- src/app/notifications/page.tsx | 2 + src/components/ui/page-header.tsx | 68 ++++++++++++++++++++++++------- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index ba17b08c5..f84bc2fbd 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -201,6 +201,7 @@ export default function NotificationsPage() { title={t("notifications.title")} description={t("notifications.subtitle")} actions={headerActions} + stackActionsOnPhone /> {/* Desktop: table layout */} diff --git a/src/components/ui/page-header.tsx b/src/components/ui/page-header.tsx index e806e5294..c6e8ef28e 100644 --- a/src/components/ui/page-header.tsx +++ b/src/components/ui/page-header.tsx @@ -26,6 +26,15 @@ import { cn } from "@/lib/utils"; * shells hand-rolled the whole block to get the second copy, which is how * they drifted apart from every module page in the first place. */ +/** + * A one-word German title ("Krankheitstagebuch") is wider than the column a + * phone leaves beside the actions, and without a break opportunity it ran + * underneath them. `hyphens-auto` breaks it by the page's `lang`; the + * `wrap-break-word` floor covers a word the hyphenation dictionary does not. + */ +const HEADING_CLASS = + "text-2xl font-bold tracking-tight hyphens-auto wrap-break-word"; + export function PageHeader({ title, titleId, @@ -34,6 +43,7 @@ export function PageHeader({ backLink, topSlot, actions, + stackActionsOnPhone = false, className, }: { title: ReactNode; @@ -44,37 +54,65 @@ export function PageHeader({ /** Optional block above the title (e.g. a hub back-link the shell owns). */ topSlot?: ReactNode; actions?: ReactNode; + /** + * Below `sm`, move the actions onto their own row under the description. + * For text-labelled actions (two outline links, say): beside the title they + * left the H1 a column narrower than its own word. Icon actions fit beside + * the title and leave this off. + */ + stackActionsOnPhone?: boolean; className?: string; }) { const heading = headingAs === "div" ? ( -
    +
    {title}
    ) : ( -

    +

    {title}

    ); + // Title, description and actions share one grid. On a phone the + // description spans the full row under the title + actions: a header with + // three or four icon actions otherwise left the sentence a ~130 px column + // that wrapped five or six times. From `sm` up the actions span both rows, + // so the description sits beside them exactly as before. return (
    {backLink ? : null} {topSlot} -
    -
    - {heading} - {description ? ( -

    {description}

    - ) : null} -
    +
    +
    {heading}
    {actions ? ( -
    {actions}
    +
    + {actions} +
    + ) : null} + {description ? ( +

    + {description} +

    ) : null}
    From 537ac3c7a147c262208abea9137017c7437426ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 09:44:43 +0200 Subject: [PATCH 25/29] fix(settings): stop settings and admin cards widening the page on a phone Three cards put a full sentence into the SettingsCardHeader status slot (mood reminder, low-stock warning, the app-log refresh button), which does not wrap: the row grew to 1000px and /settings/notifications panned sideways at every width. The sentence moves into the card body, and the slot is capped at the row so a long status wraps instead. Also on a phone: the provider-chain add control wraps, the report picker's standard-template action drops under its sentence instead of running past the card, and the dashboard layout rows put their switches and arrows on a second line so the tile name is no longer cut to five characters. --- src/components/admin/app-log-preview-section.tsx | 7 +++---- src/components/settings/_card-header.tsx | 7 ++++++- src/components/settings/ai/fallback-chain-card.tsx | 2 +- src/components/settings/dashboard-layout-section.tsx | 9 +++++++-- src/components/settings/low-stock-card.tsx | 3 +-- src/components/settings/mood-reminder-card.tsx | 3 +-- .../settings/report-selection/report-scope-picker.tsx | 7 +++++-- 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/components/admin/app-log-preview-section.tsx b/src/components/admin/app-log-preview-section.tsx index 1cc49e2c1..f9b92a01d 100644 --- a/src/components/admin/app-log-preview-section.tsx +++ b/src/components/admin/app-log-preview-section.tsx @@ -125,7 +125,7 @@ export function AppLogPreviewSection() { } /> +

    {t("admin.section.app-logs.processDetail")}

    +
    {status ? ( -
    {status}
    + // `max-w-full` caps the slot at the row once it wraps under the + // title, so a status line longer than a phone row wraps inside + // the card instead of widening the page. +
    + {status} +
    ) : null}
    {/* One sentence, one text run. This slot deliberately carries no diff --git a/src/components/settings/ai/fallback-chain-card.tsx b/src/components/settings/ai/fallback-chain-card.tsx index 388a04b1a..80977ecfb 100644 --- a/src/components/settings/ai/fallback-chain-card.tsx +++ b/src/components/settings/ai/fallback-chain-card.tsx @@ -294,7 +294,7 @@ function AddProviderControl({ const [picked, setPicked] = useState(addable[0] ?? ""); return ( -
    +
    @@ -898,7 +898,12 @@ function SortableWidgetRow({ {labels.widgetLabel} -
    + {/* On a phone the two switches and two arrows (~250 px) left the name + five characters ("Gewic…"). Below `sm` the controls wrap onto a + second line, right-aligned under the column heads, and the name + keeps the first line to itself. */} +