From 5cf4a7d2139e81f40aded59884b459bb0335ca41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:41:46 +0100 Subject: [PATCH 01/12] fix(doctor-report): name a managed record by its display name The report cover, the PDF author field, the FHIR Patient and the share page printed `fullName ?? username`, and the collector never read the display name. A managed record has no way to get a full name, so its generated `managed-` login landed on every clinical document made for it; an own account printed the login instead of its display name. The collector now resolves the name once (`patientName()`: full name, display name, login, never a managed record's generated handle) and the five readers print that one field. `patient.username` and `patient.fullName` leave the report payload; nothing else read them. --- .../baselines/version-stamp-comments.json | 6 +-- .../share-view-leaf-render-guard.test.ts | 5 +- .../[token]/__tests__/report-download.test.ts | 2 +- .../__tests__/clinician-view-locales.test.tsx | 3 +- .../__tests__/clinician-view.test.tsx | 2 +- .../clinician/identity-sections.tsx | 2 +- src/lib/__tests__/account-name.test.ts | 40 +++++++++++++++- .../__tests__/doctor-report-pdf-core.test.ts | 8 ++-- src/lib/account-name.ts | 28 +++++++++++ src/lib/doctor-report-pdf-core.ts | 2 +- .../activity-effect-sections.test.ts | 2 +- .../__tests__/header-profile-gender.test.ts | 3 +- .../header-profile-section.ts | 9 ++-- src/lib/doctor-report-types.ts | 18 +++---- .../__tests__/leaf-gating-sweep.test.ts | 47 ++++++++++++++++++- src/lib/doctor-report/collect.ts | 6 ++- src/lib/fhir/__tests__/build-bundle.test.ts | 21 +++------ src/lib/fhir/__tests__/conformance.test.ts | 7 ++- .../fhir/__tests__/cycle-observations.test.ts | 2 +- .../glucose-clinical-observations.test.ts | 2 +- src/lib/fhir/__tests__/illness-snomed.test.ts | 2 +- .../fhir/__tests__/lab-observations.test.ts | 2 +- .../__tests__/terminology-displays.test.ts | 4 +- src/lib/fhir/build-bundle.ts | 2 +- src/lib/fhir/resources/patient.ts | 3 +- .../doctor-report-module-gate.test.ts | 2 +- .../__tests__/doctor-visit-summary.test.ts | 2 +- src/lib/mcp/__tests__/prompts.test.ts | 6 +-- src/lib/mcp/__tests__/resources.test.ts | 6 +-- .../doctor-report-sections.test.ts | 2 +- 30 files changed, 169 insertions(+), 77 deletions(-) diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index fc64b4a07..278f68243 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2892, - "total": 9220, + "total": 9216, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1963,10 +1963,10 @@ "lib/doctor-report-helpers.ts": 1, "lib/doctor-report-pdf-core.ts": 4, "lib/doctor-report-pdf/clinical-records-notes-section.ts": 6, - "lib/doctor-report-pdf/header-profile-section.ts": 3, + "lib/doctor-report-pdf/header-profile-section.ts": 2, "lib/doctor-report-pdf/measurements-charts-section.ts": 3, "lib/doctor-report-pdf/medication-mood-wellness-section.ts": 7, - "lib/doctor-report-types.ts": 21, + "lib/doctor-report-types.ts": 20, "lib/doctor-report/__tests__/leaf-gating-sweep.test.ts": 1, "lib/doctor-report/clinical-records.ts": 2, "lib/doctor-report/collect.ts": 4, diff --git a/src/__tests__/share-view-leaf-render-guard.test.ts b/src/__tests__/share-view-leaf-render-guard.test.ts index bd50f93f0..6dba245a7 100644 --- a/src/__tests__/share-view-leaf-render-guard.test.ts +++ b/src/__tests__/share-view-leaf-render-guard.test.ts @@ -59,7 +59,7 @@ function emptyReport(): DoctorReportData { end: "2026-01-31T00:00:00.000Z", }, patient: { - username: null, + name: null, dateOfBirth: null, gender: null, heightCm: null, @@ -156,11 +156,10 @@ const DISPOSITIONS: Record = { kind: "rendered", data: { patient: { - username: "shared-account", + name: "A. Patient", dateOfBirth: "1979-04-02T00:00:00.000Z", gender: "FEMALE", heightCm: 171, - fullName: "A. Patient", }, }, marker: "A. Patient", diff --git a/src/app/c/[token]/__tests__/report-download.test.ts b/src/app/c/[token]/__tests__/report-download.test.ts index b52bbf05c..228857a33 100644 --- a/src/app/c/[token]/__tests__/report-download.test.ts +++ b/src/app/c/[token]/__tests__/report-download.test.ts @@ -97,7 +97,7 @@ function report(): DoctorReportData { end: "2026-01-31T00:00:00.000Z", }, patient: { - username: null, + name: null, dateOfBirth: null, gender: null, heightCm: null, diff --git a/src/components/clinician/__tests__/clinician-view-locales.test.tsx b/src/components/clinician/__tests__/clinician-view-locales.test.tsx index 4fa3094b9..75980ba2f 100644 --- a/src/components/clinician/__tests__/clinician-view-locales.test.tsx +++ b/src/components/clinician/__tests__/clinician-view-locales.test.tsx @@ -42,11 +42,10 @@ const FULL_RECORD = { end: "2026-01-31T00:00:00.000Z", }, patient: { - username: "shared-account", + name: "A. Patient", dateOfBirth: "1980-01-01T00:00:00.000Z", gender: "FEMALE", heightCm: 170, - fullName: "A. Patient", }, practiceName: null, measurements: {}, diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index da8a4a3f3..7b16bb750 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -27,7 +27,7 @@ function makeReport( end: "2026-01-31T00:00:00.000Z", }, patient: { - username: "tester", + name: "tester", dateOfBirth: null, gender: null, heightCm: null, diff --git a/src/components/clinician/identity-sections.tsx b/src/components/clinician/identity-sections.tsx index 9dfef4aef..3eacd8e81 100644 --- a/src/components/clinician/identity-sections.tsx +++ b/src/components/clinician/identity-sections.tsx @@ -56,7 +56,7 @@ export function PatientIdentitySection({ fmtDate: (iso: string) => string; }) { const patient = report.patient; - const name = patient.fullName ?? patient.username ?? null; + const name = patient.name; const genderKey = patient.gender ? GENDER_LABEL_KEYS[patient.gender] : undefined; diff --git a/src/lib/__tests__/account-name.test.ts b/src/lib/__tests__/account-name.test.ts index 3d080ebb1..99e0ff3cc 100644 --- a/src/lib/__tests__/account-name.test.ts +++ b/src/lib/__tests__/account-name.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { accountName } from "../account-name"; +import { accountName, nameInitials, patientName } from "../account-name"; describe("accountName", () => { it("names the account by its display name when one is set", () => { @@ -19,3 +19,41 @@ describe("accountName", () => { expect(accountName({ username: "jdoe" })).toBe("jdoe"); }); }); + +describe("patientName", () => { + const own = { + username: "jdoe", + displayName: null, + fullName: null, + managedProfileAt: null, + }; + const managed = { + username: "managed-3f9a0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f", + displayName: null, + fullName: null, + managedProfileAt: new Date("2026-01-01T00:00:00.000Z"), + }; + + it("prefers the full name, then the display name, then the login", () => { + expect( + patientName({ ...own, fullName: "Jane Doe", displayName: "Jane" }), + ).toBe("Jane Doe"); + expect(patientName({ ...own, fullName: " ", displayName: "Jane" })).toBe( + "Jane", + ); + expect(patientName(own)).toBe("jdoe"); + }); + + it("never prints a managed record's generated login", () => { + expect(patientName({ ...managed, displayName: "Robin" })).toBe("Robin"); + expect(patientName(managed)).toBeNull(); + }); +}); + +describe("nameInitials", () => { + it("takes the first letter of the first two words", () => { + expect(nameInitials("Jane Doe")).toBe("JD"); + expect(nameInitials("max.mustermann")).toBe("MM"); + expect(nameInitials("Robin")).toBe("R"); + }); +}); diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 081dd0a57..4547d92e5 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -27,7 +27,7 @@ function makeData(overrides?: Partial): DoctorReportData { end: "2026-05-03T12:00:00.000Z", }, patient: { - username: "testuser", + name: "testuser", dateOfBirth: "1985-06-15T00:00:00.000Z", gender: "MALE", heightCm: 182, @@ -1003,12 +1003,11 @@ describe("doctor-report emergency first page", () => { }); describe("extracted doctor-report section boundaries", () => { - it("header/profile prefers the legal name and renders insurance identity", async () => { + it("header/profile prints the resolved name and renders insurance identity", async () => { const data = makeData({ patient: { ...makeData().patient, - fullName: "Ada Lovelace", - username: "ignored-handle", + name: "Ada Lovelace", insurerName: "Example Health", }, }); @@ -1022,7 +1021,6 @@ describe("extracted doctor-report section boundaries", () => { }); const text = await extractText(bytes); expect(text).toContain("Ada Lovelace"); - expect(text).not.toContain("ignored-handle"); expect(text).toContain("Example Health"); expect(text).toContain("A123456789"); }); diff --git a/src/lib/account-name.ts b/src/lib/account-name.ts index 2340acaa6..7e39c7319 100644 --- a/src/lib/account-name.ts +++ b/src/lib/account-name.ts @@ -11,3 +11,31 @@ export function accountName(user: { const displayName = user.displayName?.trim(); return displayName ? displayName : user.username; } + +/** + * The name a clinical document gives a person: the full name when one is + * set, the display name next, the login last. A managed record's login is a + * handle generated when the record was created (`managed-`), not a name + * anybody chose, so it never prints; with no other name the line is left out. + */ +export function patientName(user: { + username: string; + displayName: string | null; + fullName: string | null; + managedProfileAt: Date | null; +}): string | null { + const fullName = user.fullName?.trim(); + if (fullName) return fullName; + const displayName = user.displayName?.trim(); + if (displayName) return displayName; + return user.managedProfileAt ? null : user.username; +} + +/** Up to two letters for an avatar, from a name such as `accountName()`. */ +export function nameInitials(name: string): string { + return name + .split(/[\s._-]+/) + .slice(0, 2) + .map((s) => s[0]?.toUpperCase() ?? "") + .join(""); +} diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index ad1d366a7..3a511be97 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -164,7 +164,7 @@ export function buildDoctorReportPdfDocument( title: t("doctorReport.title"), subject: t("doctorReport.subtitle"), creator: "HealthLog", - author: data.patient.fullName ?? data.patient.username ?? "HealthLog", + author: data.patient.name ?? "HealthLog", }); const pageWidth = doc.internal.pageSize.getWidth(); const pageHeight = doc.internal.pageSize.getHeight(); diff --git a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts index 85587fa4d..9ccd51995 100644 --- a/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts +++ b/src/lib/doctor-report-pdf/__tests__/activity-effect-sections.test.ts @@ -97,7 +97,7 @@ function data(overrides: Partial = {}): DoctorReportData { end: "2026-05-03T12:00:00.000Z", }, patient: { - username: null, + name: null, dateOfBirth: null, gender: null, heightCm: null, diff --git a/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts b/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts index ce126d2b5..db5846db6 100644 --- a/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts +++ b/src/lib/doctor-report-pdf/__tests__/header-profile-gender.test.ts @@ -32,8 +32,7 @@ function renderPatientLines(gender: string | null): string[] { doc, data: { patient: { - username: "testuser", - fullName: null, + name: "testuser", dateOfBirth: null, gender, heightCm: null, diff --git a/src/lib/doctor-report-pdf/header-profile-section.ts b/src/lib/doctor-report-pdf/header-profile-section.ts index 27975a81b..f4a3ca0d4 100644 --- a/src/lib/doctor-report-pdf/header-profile-section.ts +++ b/src/lib/doctor-report-pdf/header-profile-section.ts @@ -62,12 +62,9 @@ export function buildHeaderProfileSection( doc.setFontSize(9); doc.setTextColor(80, 80, 80); const patientInfo: string[] = []; - // v1.7.0 — prefer the legal full name on the cover; fall back to the - // username when no full name is set (collapses exactly like the - // practice name when neither is present). - const patientName = data.patient.fullName ?? data.patient.username ?? null; - if (patientName) { - patientInfo.push(`${t("doctorReport.patient")}: ${patientName}`); + // Collapses like the practice name when the record has no name to print. + if (data.patient.name) { + patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.name}`); } if (data.patient.dateOfBirth) { patientInfo.push( diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts index b82c4fffe..2b7a73697 100644 --- a/src/lib/doctor-report-types.ts +++ b/src/lib/doctor-report-types.ts @@ -80,17 +80,19 @@ export interface DoctorReportData { */ period: { days: number; since: string; start: string; end: string }; patient: { - username: string | null; + /** + * The name the cover, the PDF metadata, the FHIR Patient and the share + * page print, resolved once by `patientName()`: full name, display name, + * login, never a managed record's generated handle. Null prints no line. + */ + name: string | null; dateOfBirth: string | null; gender: string | null; heightCm: number | null; - // v1.7.0 — optional patient-identity fields for the export cover + - // FHIR Patient. `fullName` + `insurerName` are plaintext columns; the - // KVNR is NOT carried here (it's encrypted at rest and decrypted by - // the route, then handed to the builders separately). Optional in the - // type so pre-v1.7.0 fixtures that omit them still typecheck — the - // renderer treats absent and null identically. - fullName?: string | null; + // `insurerName` is a plaintext column; the KVNR is NOT carried here (it's + // encrypted at rest and decrypted by the route, then handed to the + // builders separately). Optional so fixtures that omit it still + // typecheck; the renderer treats absent and null identically. insurerName?: string | null; // v1.8.6 — German insurer institution number (IKNR), plaintext. Carried // here for the FHIR `Coverage` payor Organization identifier. Optional 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 da880ef5d..6650e83a6 100644 --- a/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts +++ b/src/lib/doctor-report/__tests__/leaf-gating-sweep.test.ts @@ -130,6 +130,8 @@ function seed() { rows.userUnique = [ { username: "owner", + displayName: null, + managedProfileAt: null, dateOfBirth: new Date("1980-05-05T00:00:00.000Z"), gender: "OTHER", heightCm: 180, @@ -328,7 +330,7 @@ describe("per-leaf gating sweep", () => { 0, ); expect(Object.keys(allPayload.compliance).length).toBeGreaterThan(0); - expect(allPayload.patient.username).toBe("owner"); + expect(allPayload.patient.name).toBe("Owner Name"); expect(allPayload.patient.insurerName).toBe("Sample Insurer"); expect(allPayload.bmi).not.toBeNull(); expect(Object.keys(allPayload.glucoseStats).length).toBeGreaterThan(0); @@ -457,6 +459,47 @@ describe("the derived figures ride their own leaf", () => { }); }); +describe("the name the report prints", () => { + const identity = selectionFromLeaves(["PATIENT_IDENTITY"]); + const asUser = (fields: Record) => { + rows.userUnique = [{ ...(rows.userUnique[0] as object), ...fields }]; + }; + + it("names a managed record by its display name, never its generated login", async () => { + asUser({ + username: "managed-3f9a0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f", + displayName: "Robin", + fullName: null, + managedProfileAt: new Date("2026-01-01T00:00:00.000Z"), + }); + const payload = await collectDoctorReportData("u1", RANGE, identity, { + moduleMap: moduleMap(), + }); + expect(payload.patient.name).toBe("Robin"); + }); + + it("prints no name for a managed record that has none", async () => { + asUser({ + username: "managed-3f9a0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f", + displayName: null, + fullName: null, + managedProfileAt: new Date("2026-01-01T00:00:00.000Z"), + }); + const payload = await collectDoctorReportData("u1", RANGE, identity, { + moduleMap: moduleMap(), + }); + expect(payload.patient.name).toBeNull(); + }); + + it("prefers the display name over the login on an own account", async () => { + asUser({ username: "owner", displayName: "Jane Doe", fullName: null }); + const payload = await collectDoctorReportData("u1", RANGE, identity, { + moduleMap: moduleMap(), + }); + expect(payload.patient.name).toBe("Jane Doe"); + }); +}); + describe("fail-closed exclusion", () => { it("excludes a measurement type the catalogue does not know", () => { const gate = resolveReportGate( @@ -571,7 +614,7 @@ describe("the empty selection serves nothing", () => { selectionFromLeaves([]), { moduleMap: moduleMap() }, ); - expect(payload.patient.username).toBeNull(); + expect(payload.patient.name).toBeNull(); expect(payload.patient.insurerName).toBeNull(); expect(payload.labResults).toBeNull(); expect(payload.allergies).toBeNull(); diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index e40039f56..5d76aafd3 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -84,6 +84,7 @@ import { loadLabResults, } from "./clinical-records"; import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { patientName } from "@/lib/account-name"; import { readingsMean } from "@/lib/measurements/day-mean"; import { loadScreenerUnvalidatedTranslations } from "./screener-translations"; @@ -179,6 +180,8 @@ export async function collectDoctorReportData( where: { id: userId }, select: { username: true, + displayName: true, + managedProfileAt: true, dateOfBirth: true, gender: true, heightCm: true, @@ -720,14 +723,13 @@ export async function collectDoctorReportData( end: end.toISOString(), }, patient: { - username: identityOn ? (userProfile?.username ?? null) : null, + name: identityOn && userProfile ? patientName(userProfile) : null, dateOfBirth: identityOn && userProfile?.dateOfBirth ? userProfile.dateOfBirth.toISOString() : null, gender: identityOn ? (userProfile?.gender ?? null) : null, heightCm: identityOn ? (userProfile?.heightCm ?? null) : null, - fullName: identityOn ? (userProfile?.fullName ?? null) : null, insurerName: insuranceOn ? (userProfile?.insurerName ?? null) : null, insurerIkNumber: insuranceOn ? (userProfile?.insurerIkNumber ?? null) diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index 473758eae..0bdc2e550 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -69,11 +69,10 @@ function makeData(overrides?: Partial): DoctorReportData { end: "2026-05-03T12:00:00.000Z", }, patient: { - username: "sample-user", + name: "Sample Patient", dateOfBirth: "1985-06-15T00:00:00.000Z", gender: "MALE", heightCm: 182, - fullName: "Sample Patient", insurerName: "Example Insurer", }, practiceName: null, @@ -181,11 +180,10 @@ describe("buildFhirDocumentBundle", () => { const bundle = buildFhirDocumentBundle( makeData({ patient: { - username: "sample-user", + name: "Sample Patient", dateOfBirth: "1985-06-15T00:00:00.000Z", gender: "MALE", heightCm: 182, - fullName: "Sample Patient", insurerName: "Example Insurer", insurerIkNumber: "101234567", }, @@ -215,11 +213,10 @@ describe("buildFhirDocumentBundle", () => { const bundle = buildFhirDocumentBundle( makeData({ patient: { - username: "sample-user", + name: "Sample Patient", dateOfBirth: null, gender: null, heightCm: null, - fullName: "Sample Patient", insurerName: "Example Insurer", insurerIkNumber: "101234567", }, @@ -238,11 +235,10 @@ describe("buildFhirDocumentBundle", () => { const bundle = buildFhirDocumentBundle( makeData({ patient: { - username: "sample-user", + name: "Sample Patient", dateOfBirth: null, gender: null, heightCm: null, - fullName: "Sample Patient", insurerName: "Example Insurer", // no insurerIkNumber }, @@ -262,11 +258,10 @@ describe("buildFhirDocumentBundle", () => { const bundle = buildFhirDocumentBundle( makeData({ patient: { - username: "sample-user", + name: "Sample Patient", dateOfBirth: null, gender: null, heightCm: null, - fullName: "Sample Patient", insurerName: "Example Insurer", insurerIkNumber: "101234567", }, @@ -284,11 +279,10 @@ describe("buildFhirDocumentBundle", () => { const bundle = buildFhirDocumentBundle( makeData({ patient: { - username: "sample-user", + name: "Sample Patient", dateOfBirth: null, gender: null, heightCm: null, - fullName: "Sample Patient", // neither insurerName nor insurerIkNumber }, }), @@ -303,11 +297,10 @@ describe("buildFhirDocumentBundle", () => { const bundle = buildFhirDocumentBundle( makeData({ patient: { - username: "sample-user", + name: "Sample Patient", dateOfBirth: null, gender: null, heightCm: null, - fullName: "Sample Patient", // neither insurerName nor insurerIkNumber — just a bare KVNR. }, }), diff --git a/src/lib/fhir/__tests__/conformance.test.ts b/src/lib/fhir/__tests__/conformance.test.ts index 693da6fdf..6e924c033 100644 --- a/src/lib/fhir/__tests__/conformance.test.ts +++ b/src/lib/fhir/__tests__/conformance.test.ts @@ -36,11 +36,10 @@ function makeData(overrides?: Partial): DoctorReportData { end: "2026-05-03T12:00:00.000Z", }, patient: { - username: "sample-user", + name: "Sample Patient", dateOfBirth: "1985-06-15T00:00:00.000Z", gender: "MALE", heightCm: 182, - fullName: "Sample Patient", insurerName: "Example Insurer", insurerIkNumber: "101234567", }, @@ -286,7 +285,7 @@ describe("document Bundle — identity and resolvability", () => { fullBundle(), fullBundle({ patient: { - username: "u", + name: "u", dateOfBirth: null, gender: null, heightCm: null, @@ -375,7 +374,7 @@ describe("Patient.gender", () => { for (const [stored, expected] of cases) { const bundle = fullBundle({ patient: { - username: "u", + name: "u", dateOfBirth: null, gender: stored, heightCm: null, diff --git a/src/lib/fhir/__tests__/cycle-observations.test.ts b/src/lib/fhir/__tests__/cycle-observations.test.ts index 330a83d63..0bed3d981 100644 --- a/src/lib/fhir/__tests__/cycle-observations.test.ts +++ b/src/lib/fhir/__tests__/cycle-observations.test.ts @@ -15,7 +15,7 @@ function baseData(overrides?: Partial): DoctorReportData { end: "2026-05-03T12:00:00.000Z", }, patient: { - username: "sample-user", + name: "sample-user", dateOfBirth: "1990-06-15T00:00:00.000Z", gender: "FEMALE", heightCm: 168, diff --git a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts index 8562be554..c773239fc 100644 --- a/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts +++ b/src/lib/fhir/__tests__/glucose-clinical-observations.test.ts @@ -22,7 +22,7 @@ function makeData(overrides?: Partial): DoctorReportData { end: "2026-05-03T12:00:00.000Z", }, patient: { - username: "sample-user", + name: "sample-user", dateOfBirth: "1985-06-15T00:00:00.000Z", gender: "MALE", heightCm: 182, diff --git a/src/lib/fhir/__tests__/illness-snomed.test.ts b/src/lib/fhir/__tests__/illness-snomed.test.ts index ce4b162c7..3377b24c8 100644 --- a/src/lib/fhir/__tests__/illness-snomed.test.ts +++ b/src/lib/fhir/__tests__/illness-snomed.test.ts @@ -18,7 +18,7 @@ function makeData(overrides?: Partial): DoctorReportData { end: "2026-05-03T12:00:00.000Z", }, patient: { - username: "sample-user", + name: "sample-user", dateOfBirth: "1985-06-15T00:00:00.000Z", gender: "MALE", heightCm: 182, diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts index 3cb005613..02ff11d73 100644 --- a/src/lib/fhir/__tests__/lab-observations.test.ts +++ b/src/lib/fhir/__tests__/lab-observations.test.ts @@ -17,7 +17,7 @@ function makeData(overrides?: Partial): DoctorReportData { end: "2026-05-03T12:00:00.000Z", }, patient: { - username: "sample-user", + name: "sample-user", dateOfBirth: "1985-06-15T00:00:00.000Z", gender: "MALE", heightCm: 182, diff --git a/src/lib/fhir/__tests__/terminology-displays.test.ts b/src/lib/fhir/__tests__/terminology-displays.test.ts index 70ede4b70..a4c766e85 100644 --- a/src/lib/fhir/__tests__/terminology-displays.test.ts +++ b/src/lib/fhir/__tests__/terminology-displays.test.ts @@ -22,7 +22,7 @@ function codings(overrides: Partial): FhirCoding[] { start: "2026-02-02T00:00:00.000Z", end: "2026-05-03T12:00:00.000Z", }, - patient: { username: "sample-user", dateOfBirth: null, gender: null }, + patient: { name: "sample-user", dateOfBirth: null, gender: null }, practiceName: null, measurements: {}, stats: {}, @@ -118,7 +118,7 @@ describe("medication codings", () => { start: "2026-02-02T00:00:00.000Z", end: "2026-05-03T12:00:00.000Z", }, - patient: { username: "sample-user", dateOfBirth: null, gender: null }, + patient: { name: "sample-user", dateOfBirth: null, gender: null }, practiceName: null, measurements: {}, stats: {}, diff --git a/src/lib/fhir/build-bundle.ts b/src/lib/fhir/build-bundle.ts index db9225600..0f2ddf71b 100644 --- a/src/lib/fhir/build-bundle.ts +++ b/src/lib/fhir/build-bundle.ts @@ -315,7 +315,7 @@ export function buildFhirDocumentBundle( // v1.9.0 — when the aggregator capped the administration set, disclose // it in the narrative so the export is honest: it carries the // most-recent N of M acted intakes, the oldest having been omitted. - const displayName = data.patient.fullName ?? data.patient.username ?? null; + const displayName = data.patient.name; const truncation = data.medicationAdministrationsTruncation; const narrativeText = [ `Health record for ${escapeXml(displayName ?? "patient")}.`, diff --git a/src/lib/fhir/resources/patient.ts b/src/lib/fhir/resources/patient.ts index 47f14bc93..dfca26ac4 100644 --- a/src/lib/fhir/resources/patient.ts +++ b/src/lib/fhir/resources/patient.ts @@ -49,8 +49,7 @@ export function patientResource( resourceType: "Patient", id: PATIENT_RESOURCE_ID, }; - const displayName = data.patient.fullName ?? data.patient.username ?? null; - if (displayName) patient.name = [{ text: displayName }]; + if (data.patient.name) patient.name = [{ text: data.patient.name }]; const gender = administrativeGender(data.patient.gender); if (gender) patient.gender = gender; if (data.patient.dateOfBirth) { diff --git a/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts b/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts index c85a7f8a7..db607e77d 100644 --- a/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts +++ b/src/lib/mcp/__tests__/doctor-report-module-gate.test.ts @@ -74,7 +74,7 @@ const REPORT_FIXTURE = { start: "2026-03-29T00:00:00.000Z", end: "2026-06-27T00:00:00.000Z", }, - patient: { username: "tester" }, + patient: { name: "tester" }, stats: { WEIGHT: { avg: 80, min: 79, max: 82, count: 30, latest: 80 } }, compliance: {}, medications: [], diff --git a/src/lib/mcp/__tests__/doctor-visit-summary.test.ts b/src/lib/mcp/__tests__/doctor-visit-summary.test.ts index 01e81b3b4..5b82a4b5c 100644 --- a/src/lib/mcp/__tests__/doctor-visit-summary.test.ts +++ b/src/lib/mcp/__tests__/doctor-visit-summary.test.ts @@ -29,7 +29,7 @@ function payload(overrides: Partial = {}): DoctorReportData { end: "2026-04-01T00:00:00.000Z", }, patient: { - username: null, + name: null, dateOfBirth: null, gender: null, heightCm: null, diff --git a/src/lib/mcp/__tests__/prompts.test.ts b/src/lib/mcp/__tests__/prompts.test.ts index d321aa8e9..ffd37d7ad 100644 --- a/src/lib/mcp/__tests__/prompts.test.ts +++ b/src/lib/mcp/__tests__/prompts.test.ts @@ -73,8 +73,7 @@ function reportFixture( end: "2026-06-27T00:00:00.000Z", }, patient: { - username: "tester", - fullName: "A Person", + name: "A Person", dateOfBirth: "1990-01-01", gender: "female", heightCm: 170, @@ -213,8 +212,7 @@ describe("doctor_visit_summary — grounding + structure", () => { .text, ); const patient = data.patient as Record; - expect(patient).not.toHaveProperty("fullName"); - expect(patient).not.toHaveProperty("username"); + expect(patient).not.toHaveProperty("name"); expect(patient.dateOfBirth).toBe("1990-01-01"); }); }); diff --git a/src/lib/mcp/__tests__/resources.test.ts b/src/lib/mcp/__tests__/resources.test.ts index c402eaf6f..945023533 100644 --- a/src/lib/mcp/__tests__/resources.test.ts +++ b/src/lib/mcp/__tests__/resources.test.ts @@ -224,8 +224,7 @@ describe("healthlog://report/doctor-visit", () => { end: "2026-06-27T00:00:00.000Z", }, patient: { - username: "tester", - fullName: "A Person", + name: "A Person", dateOfBirth: "1990-01-01", gender: "female", heightCm: 170, @@ -267,8 +266,7 @@ describe("healthlog://report/doctor-visit", () => { expect(result).toHaveProperty("illnessEpisodes"); // Patient name never leaks. const patient = result.patient as Record; - expect(patient).not.toHaveProperty("fullName"); - expect(patient).not.toHaveProperty("username"); + expect(patient).not.toHaveProperty("name"); }); it("omits absent sections honestly (no zero-fill)", async () => { diff --git a/tests/integration/doctor-report-sections.test.ts b/tests/integration/doctor-report-sections.test.ts index 8b857cf4d..759dd957a 100644 --- a/tests/integration/doctor-report-sections.test.ts +++ b/tests/integration/doctor-report-sections.test.ts @@ -314,7 +314,7 @@ describe("doctor-report — per-section toggles", () => { expect(data.labResults).toBeNull(); expect(data.allergies).toBeNull(); expect(data.familyHistory).toBeNull(); - expect(data.patient.username).toBeNull(); + expect(data.patient.name).toBeNull(); expect(data.patient.insurerName).toBeNull(); }); }); From 9a02dfff2e81cf631e16fe159fa9b8a08c74cdfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:43:21 +0100 Subject: [PATCH 02/12] fix(settings): build the avatar initials from the display name The settings avatar took its fallback letters and its alt text from the login, while the top bar and the sidebar spell the display name, so the same account showed different initials on two screens. The three copies of the initials function become one `nameInitials()` beside `accountName()`, and the settings avatar reads the same name. --- src/components/layout/sidebar-nav.tsx | 16 ++------ src/components/layout/top-bar.tsx | 12 +----- .../__tests__/avatar-initials.test.tsx | 39 +++++++++++++++++++ .../account-section/avatar-section.tsx | 13 ++----- 4 files changed, 48 insertions(+), 32 deletions(-) create mode 100644 src/components/settings/account-section/__tests__/avatar-initials.test.tsx diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index 28062cbb8..cae4fb514 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -40,7 +40,7 @@ import { import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; import { useAuth, useLogout } from "@/hooks/use-auth"; -import { accountName } from "@/lib/account-name"; +import { accountName, nameInitials } from "@/lib/account-name"; import { useIsMobile } from "@/hooks/use-is-mobile"; import { useNavModules } from "@/hooks/use-nav-modules"; import { useMounted } from "@/hooks/use-mounted"; @@ -98,14 +98,6 @@ export function writeSidebarCollapsedPref(collapsed: boolean): void { } } -function getInitials(name: string): string { - return name - .split(/[\s._-]+/) - .slice(0, 2) - .map((s) => s[0]?.toUpperCase() ?? "") - .join(""); -} - /** * The sidebar footer's name-and-avatar block. * @@ -170,7 +162,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { {avatarUrl && } - {getInitials(name)} + {nameInitials(name)}
@@ -286,7 +278,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) { @@ -313,7 +305,7 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
diff --git a/src/components/layout/top-bar.tsx b/src/components/layout/top-bar.tsx index 4d499d1a2..53c220210 100644 --- a/src/components/layout/top-bar.tsx +++ b/src/components/layout/top-bar.tsx @@ -26,7 +26,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Skeleton } from "@/components/ui/skeleton"; import Link from "next/link"; import { useAuth, useLogout } from "@/hooks/use-auth"; -import { accountName } from "@/lib/account-name"; +import { accountName, nameInitials } from "@/lib/account-name"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTheme } from "@/components/providers"; import { useTranslations } from "@/lib/i18n/context"; @@ -41,14 +41,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -function getInitials(name: string): string { - return name - .split(/[\s._-]+/) - .slice(0, 2) - .map((s) => s[0]?.toUpperCase() ?? "") - .join(""); -} - export function TopBar() { const { user, isLoading } = useAuth(); // v1.36.0 — acting on somebody else's record; see the menu below. Read from @@ -147,7 +139,7 @@ export function TopBar() { )} - {getInitials(accountName(user))} + {nameInitials(accountName(user))} {accountName(user)} diff --git a/src/components/settings/account-section/__tests__/avatar-initials.test.tsx b/src/components/settings/account-section/__tests__/avatar-initials.test.tsx new file mode 100644 index 000000000..0e9bd00a3 --- /dev/null +++ b/src/components/settings/account-section/__tests__/avatar-initials.test.tsx @@ -0,0 +1,39 @@ +/** + * The avatar fallback on the settings card spells the name the rest of the + * app shows. It used to take its letters from the login, so an account named + * "Robin Example" with the login "rex" read "RE" in the top bar and the + * sidebar and "R" here. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "rex", + displayName: "Robin Example", + avatarUrl: null, + }, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AvatarSection } from "../avatar-section"; + +describe("the settings avatar fallback", () => { + it("builds its initials from the display name, not the login", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain(">RE<"); + expect(html).not.toContain(">R<"); + }); +}); diff --git a/src/components/settings/account-section/avatar-section.tsx b/src/components/settings/account-section/avatar-section.tsx index bff3eb9e5..6297cdb61 100644 --- a/src/components/settings/account-section/avatar-section.tsx +++ b/src/components/settings/account-section/avatar-section.tsx @@ -13,6 +13,7 @@ import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { apiFetchRaw } from "@/lib/api/api-fetch"; import { statusText, type StatusMessage } from "./account-section-utils"; +import { accountName, nameInitials } from "@/lib/account-name"; // Accepted upload types + byte cap mirror the server contract in // `src/app/api/user/avatar/route.ts` so the client rejects an obvious @@ -21,14 +22,6 @@ import { statusText, type StatusMessage } from "./account-section-utils"; const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"]; const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024; -function getAvatarInitials(name: string): string { - return name - .split(/[\s._-]+/) - .slice(0, 2) - .map((s) => s[0]?.toUpperCase() ?? "") - .join(""); -} - export function AvatarSection() { const { t } = useTranslations(); const { user, refetch } = useAuth(); @@ -123,9 +116,9 @@ export function AvatarSection() {

- {avatarUrl && } + {avatarUrl && } - {getAvatarInitials(user.username)} + {nameInitials(accountName(user))}
From 1d0a2a812845eb556ae27ee40aa5c4b0824b9e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:45:12 +0100 Subject: [PATCH 03/12] fix(doctor-report): print the date of birth on its own day west of UTC The date of birth is a calendar date stored at UTC midnight. The PDF cover and the share page formatted it as an instant in the owner's zone, so everyone in the Americas read the day before; settings and the FHIR export, which take the date part, already showed the right day. Both now print it through a calendar-date formatter (read in UTC, the owner's date order), which is right in every zone, unlike a noon anchor that moves the day east of UTC+12. --- .../__tests__/clinician-view.test.tsx | 18 +++++++++++++ src/components/clinician/clinician-view.tsx | 7 +++++- .../clinician/identity-sections.tsx | 6 ++--- .../__tests__/doctor-report-pdf-core.test.ts | 25 +++++++++++++++++++ src/lib/doctor-report-pdf-core.ts | 4 +++ .../header-profile-section.ts | 3 ++- src/lib/doctor-report-pdf/render-context.ts | 6 +++++ 7 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index 7b16bb750..c6ef44d2f 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -72,6 +72,7 @@ function render( | "selection" | "timeFormat" | "dateFormat" + | "timezone" > >, ) { @@ -501,6 +502,23 @@ describe("", () => { expect(html).not.toContain(' { + const html = render( + makeReport({ + patient: { + name: "A. Patient", + // Stored at UTC midnight: 13 May, 20:00 in New York. + dateOfBirth: "1960-05-14T00:00:00.000Z", + gender: null, + heightCm: null, + }, + }), + { timezone: "America/New_York", locale: "en" }, + ); + expect(html).toContain("05/14/1960"); + expect(html).not.toContain("05/13/1960"); + }); }); /** diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 2be46e88b..141456606 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -30,6 +30,7 @@ * document list in `./documents-list`, the downloads in `./download-actions`. */ import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { formatDate } from "@/lib/date-format"; import { formatLabValue, type LabNumberFormat } from "@/lib/labs/format-value"; import { makeFormatters, @@ -153,6 +154,10 @@ export function ClinicianView({ // Owner hour cycle + date order ride along (issue #922). const fmt = makeFormatters(locale, timezone, timeFormat, dateFormat); const fmtDate = (iso: string) => fmt.date(new Date(iso)); + // A date-only value (a date of birth, a dose date) is its calendar date in + // every zone; read as an instant it fell on the previous day west of UTC. + const fmtCalendarDate = (iso: string) => + formatDate(iso.slice(0, 10), dateFormat, locale); const fmtDateTime = (iso: string) => fmt.dateTime(new Date(iso)); // Two decimals at most, in the owner's locale: a German reader gets // "80,5", not the "80.5" a bare number prints. @@ -199,7 +204,7 @@ export function ClinicianView({ t={t} report={report} scope={scope} - fmtDate={fmtDate} + fmtCalendarDate={fmtCalendarDate} /> {/* ── measurements ─────────────────────────────────────── */} diff --git a/src/components/clinician/identity-sections.tsx b/src/components/clinician/identity-sections.tsx index 3eacd8e81..01debc6e0 100644 --- a/src/components/clinician/identity-sections.tsx +++ b/src/components/clinician/identity-sections.tsx @@ -48,12 +48,12 @@ export function PatientIdentitySection({ t, report, scope, - fmtDate, + fmtCalendarDate, }: { t: Translate; report: DoctorReportData; scope: LeafScope; - fmtDate: (iso: string) => string; + fmtCalendarDate: (iso: string) => string; }) { const patient = report.patient; const name = patient.name; @@ -67,7 +67,7 @@ export function PatientIdentitySection({ if (patient.dateOfBirth) { rows.push({ label: t("doctorReport.dateOfBirth"), - value: fmtDate(patient.dateOfBirth), + value: fmtCalendarDate(patient.dateOfBirth), }); } if (genderKey) { diff --git a/src/lib/__tests__/doctor-report-pdf-core.test.ts b/src/lib/__tests__/doctor-report-pdf-core.test.ts index 4547d92e5..f237ec2c0 100644 --- a/src/lib/__tests__/doctor-report-pdf-core.test.ts +++ b/src/lib/__tests__/doctor-report-pdf-core.test.ts @@ -1176,6 +1176,31 @@ describe("doctor report — the user's date order", () => { ); expect(text).toContain("05/03/2026"); }); + + it("prints the date of birth on its own day west of UTC", async () => { + const { t } = getServerTranslator("en"); + const text = await extractText( + renderDoctorReportPdfBytes( + makeData({ + patient: { + ...makeData().patient, + // Stored at UTC midnight: 13 May, 20:00 in New York. + dateOfBirth: "1960-05-14T00:00:00.000Z", + }, + }), + { + t, + locale: "en", + now: FIXED_NOW, + userTz: "America/New_York", + timeFormat: "AUTO", + dateFormat: "AUTO", + }, + ), + ); + expect(text).toContain("05/14/1960"); + expect(text).not.toContain("05/13/1960"); + }); }); // ── script coverage ── the report is read in every shipped locale and carries diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index 3a511be97..825f9d2b2 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -31,6 +31,7 @@ import { } from "./format-locale"; import type { Locale } from "./i18n/config"; import { isValidTimezone } from "./tz/format"; +import { formatDate } from "./date-format"; import type { DoctorReportData } from "./doctor-report-data"; import { buildEmergencyFirstPageSection } from "./doctor-report-pdf/emergency-first-page-section"; import { buildHeaderProfileSection } from "./doctor-report-pdf/header-profile-section"; @@ -146,6 +147,8 @@ export function buildDoctorReportPdfDocument( const num = (value: number, decimals = 1) => formatters.number(value, decimals); const fmtDate = (iso: string) => formatters.date(iso); + const fmtCalendarDate = (iso: string) => + formatDate(iso.slice(0, 10), dateFormat, locale); const footerTz = userTz && isValidTimezone(userTz) ? userTz : DEFAULT_TIMEZONE; @@ -187,6 +190,7 @@ export function buildDoctorReportPdfDocument( t, num, fmtDate, + fmtCalendarDate, dateShort: formatters.dateShort, dateTime: formatters.dateTime, now, diff --git a/src/lib/doctor-report-pdf/header-profile-section.ts b/src/lib/doctor-report-pdf/header-profile-section.ts index f4a3ca0d4..93328d8a6 100644 --- a/src/lib/doctor-report-pdf/header-profile-section.ts +++ b/src/lib/doctor-report-pdf/header-profile-section.ts @@ -16,6 +16,7 @@ export function buildHeaderProfileSection( t, num, fmtDate, + fmtCalendarDate, now, insuranceNumber, margin, @@ -68,7 +69,7 @@ export function buildHeaderProfileSection( } if (data.patient.dateOfBirth) { patientInfo.push( - `${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`, + `${t("doctorReport.dateOfBirth")}: ${fmtCalendarDate(data.patient.dateOfBirth)}`, ); } if (data.patient.gender) { diff --git a/src/lib/doctor-report-pdf/render-context.ts b/src/lib/doctor-report-pdf/render-context.ts index f5f1c2d85..213972cc7 100644 --- a/src/lib/doctor-report-pdf/render-context.ts +++ b/src/lib/doctor-report-pdf/render-context.ts @@ -22,6 +22,12 @@ export interface DoctorReportPdfRenderContext { t: DoctorReportTranslator; num: DoctorReportNumberFormatter; fmtDate: (iso: string) => string; + /** + * A date-only value (a date of birth, a dose date): its calendar date, the + * same in every zone. `fmtDate` reads an instant in the report zone, which + * put a UTC-midnight date on the previous day west of UTC. + */ + fmtCalendarDate: (iso: string) => string; dateShort: (iso: string) => string; dateTime: (date: Date) => string; now: Date; From 9afcfd43514f90da31fe58224ab226a91f2c569b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:47:53 +0100 Subject: [PATCH 04/12] fix(day): read dose and allergy dates as calendar dates A dose date is stored at UTC midnight and an allergy onset at noon UTC; neither has a time of day. The day view, the day index, the visit preparation and the timeline treated both as instants in the person's zone: west of UTC a dose landed on the previous day, and the day view printed a clock time nobody entered (01:00 for a dose in Berlin, 13:00 for an onset). These readers now take the date part, as the vaccination list and the allergy form already do: the day and the index query the UTC calendar day (the window documents already use), the events carry no instant, and the timeline dates both kinds by their key. Correct for midnight and noon rows in every zone, without touching stored data. --- .../day/__tests__/date-only-sections.test.ts | 177 ++++++++++++++++++ src/lib/day/day-index.ts | 48 +++-- src/lib/day/records.ts | 37 ++-- src/lib/day/visit-preparation.ts | 9 +- src/lib/timeline/lanes.ts | 4 +- src/lib/timeline/readiness.ts | 6 +- 6 files changed, 244 insertions(+), 37 deletions(-) create mode 100644 src/lib/day/__tests__/date-only-sections.test.ts diff --git a/src/lib/day/__tests__/date-only-sections.test.ts b/src/lib/day/__tests__/date-only-sections.test.ts new file mode 100644 index 000000000..90b22fb76 --- /dev/null +++ b/src/lib/day/__tests__/date-only-sections.test.ts @@ -0,0 +1,177 @@ +/** + * A vaccination and an allergy onset carry a date, not a time of day: a dose + * is stored at UTC midnight on its date, an onset typed in the form at noon + * UTC. The day view, the day index and the timeline used to read both as + * instants in the person's zone, so west of UTC a dose sat on the previous + * day, and the day view printed a clock time nobody entered ("01:00" for a + * dose in Berlin). + * + * The rows below are filtered by the window each reader asks for, so the + * assertions read which day a row lands on, not how the query is spelled. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Row = Record; + +const { tables } = vi.hoisted(() => ({ + tables: {} as Record, +})); + +function inWindow(value: unknown, window: unknown): boolean { + if (window === undefined) return true; + if (!(value instanceof Date)) return false; + const { gte, lt } = window as { gte?: Date; lt?: Date }; + return (!gte || value >= gte) && (!lt || value < lt); +} + +vi.mock("@/lib/db", () => { + const model = (name: string) => ({ + findMany: async (args?: { where?: Record }) => { + const where = args?.where ?? {}; + return (tables[name] ?? []).filter( + (row) => + inWindow(row.occurredAt, where.occurredAt) && + inWindow(row.onsetAt, where.onsetAt), + ); + }, + }); + return { + prisma: new Proxy({} as Record, { + get: (_target, name: string) => model(name), + }), + }; +}); + +vi.mock("@/lib/day/daily-stats", () => ({ + readLocalDaysWithReadings: async () => [], +})); +vi.mock("@/lib/day/notable", () => ({ loadNotableRange: async () => [] })); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: async () => null, +})); + +import { RECORD_SECTION_READERS, type DayFrame } from "@/lib/day/records"; +import { loadDayIndex } from "@/lib/day/day-index"; +import { loadSinceLastVisit } from "@/lib/day/visit-preparation"; +import type { DaySectionKey } from "@/lib/day/contract"; +import type { DayAccess } from "@/lib/day/sections"; +import { LANE_READERS } from "@/lib/timeline/lanes"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; +import { shiftDateKey } from "@/lib/tz/format"; + +const TZ = "America/New_York"; + +function frame(day: string): DayFrame { + return { + userId: "u1", + day, + tz: TZ, + dayStart: startOfLocalDayKey(day, TZ), + dayEnd: startOfLocalDayKey(shiftDateKey(day, 1), TZ), + documentsReadable: false, + }; +} + +beforeEach(() => { + tables.vaccinationRecord = [ + { + id: "dose-1", + occurredAt: new Date("2026-11-11T00:00:00.000Z"), + vaccineName: "Influenza", + antigenSlug: null, + doseNumber: null, + noteEncrypted: null, + customVaccine: null, + }, + ]; + tables.allergy = [ + { + id: "allergy-1", + substance: "Penicillin", + onsetAt: new Date("2026-11-11T12:00:00.000Z"), + severity: null, + status: "ACTIVE", + }, + ]; +}); + +describe("the day view", () => { + it("files a dose and an allergy onset under their own date west of UTC", async () => { + const dose = await RECORD_SECTION_READERS.vaccinations!( + frame("2026-11-11"), + ); + const onset = await RECORD_SECTION_READERS.allergies!(frame("2026-11-11")); + expect(dose.events.map((e) => e.id)).toEqual(["dose-1"]); + expect(onset.events.map((e) => e.id)).toEqual(["allergy-1"]); + + const before = await RECORD_SECTION_READERS.vaccinations!( + frame("2026-11-10"), + ); + expect(before.events).toEqual([]); + }); + + it("gives neither a clock time", async () => { + const dose = await RECORD_SECTION_READERS.vaccinations!( + frame("2026-11-11"), + ); + const onset = await RECORD_SECTION_READERS.allergies!(frame("2026-11-11")); + expect(dose.events[0].at).toBeNull(); + expect(onset.events[0].at).toBeNull(); + }); +}); + +describe("the day index", () => { + it("marks the date itself, not the evening before", async () => { + const access = { + readable: new Set(["vaccinations", "allergies"]), + notShared: [], + modules: {}, + moduleOff: [], + } as unknown as DayAccess; + const index = await loadDayIndex({ + recordId: "u1", + from: "2026-11-01", + to: "2026-11-30", + access, + tz: TZ, + }); + expect(index.days).toEqual({ + "2026-11-11": ["allergies", "vaccinations"], + }); + }); +}); + +describe("the visit preparation", () => { + it("lists a dose under its own date", async () => { + const window = await loadSinceLastVisit({ + recordId: "u1", + access: { + readable: new Set(["vaccinations"]), + notShared: [], + modules: {}, + moduleOff: [], + } as unknown as DayAccess, + tz: TZ, + today: "2026-11-30", + from: "2026-11-11", + to: "2026-11-30", + }); + expect(window.changes.map((c) => [c.date, c.kind])).toEqual([ + ["2026-11-11", "vaccination"], + ]); + }); +}); + +describe("the timeline", () => { + it("starts a dose and an allergy on their own date", async () => { + const laneFrame = { + userId: "u1", + tz: TZ, + access: {} as never, + }; + const doses = await LANE_READERS.vaccinations(laneFrame); + const allergies = await LANE_READERS.allergies(laneFrame); + expect(doses.items[0].start).toBe("2026-11-11"); + expect(allergies.items[0].start).toBe("2026-11-11"); + }); +}); diff --git a/src/lib/day/day-index.ts b/src/lib/day/day-index.ts index 70b7f7504..28ae9d84d 100644 --- a/src/lib/day/day-index.ts +++ b/src/lib/day/day-index.ts @@ -102,6 +102,28 @@ const instantWindow = (frame: IndexFrame) => ({ lt: frame.end, }); +/** + * The window as UTC calendar days, for a column that holds a date rather + * than an instant (a dose date at UTC midnight, an allergy onset or a + * document date at noon UTC). Its date part is its day in every zone. + */ +const calendarWindow = (frame: IndexFrame) => ({ + gte: dayKeyAsUtcMidnight(frame.from), + lt: dayKeyAsUtcMidnight(shiftDateKey(frame.to, 1)), +}); + +/** Mark the calendar date of every date-only value. */ +function markDates( + frame: IndexFrame, + section: DaySectionKey, + dates: ReadonlyArray, +): void { + for (const date of dates) { + if (date) + mark(frame.marks, dateOnlyKey(date), section, frame.from, frame.to); + } +} + /** The per-section readers of the index. */ const INDEX_READERS: Partial< Record Promise> @@ -232,11 +254,11 @@ const INDEX_READERS: Partial< where: { userId: frame.userId, deletedAt: null, - onsetAt: instantWindow(frame), + onsetAt: calendarWindow(frame), }, select: { onsetAt: true }, }); - markInstants( + markDates( frame, "allergies", rows.map((r) => r.onsetAt), @@ -278,11 +300,11 @@ const INDEX_READERS: Partial< where: { userId: frame.userId, deletedAt: null, - occurredAt: instantWindow(frame), + occurredAt: calendarWindow(frame), }, select: { occurredAt: true }, }); - markInstants( + markDates( frame, "vaccinations", rows.map((r) => r.occurredAt), @@ -305,24 +327,20 @@ const INDEX_READERS: Partial< ); }, async documents(frame) { - const start = dayKeyAsUtcMidnight(frame.from); - const end = dayKeyAsUtcMidnight(shiftDateKey(frame.to, 1)); + const days = calendarWindow(frame); const rows = await prisma.inboundDocument.findMany({ where: { userId: frame.userId, deletedAt: null, - OR: [ - { reportDate: { gte: start, lt: end } }, - { reportDate: null, documentDate: { gte: start, lt: end } }, - ], + OR: [{ reportDate: days }, { reportDate: null, documentDate: days }], }, select: { reportDate: true, documentDate: true }, }); - for (const row of rows) { - const date = row.reportDate ?? row.documentDate; - if (date) - mark(frame.marks, dateOnlyKey(date), "documents", frame.from, frame.to); - } + markDates( + frame, + "documents", + rows.map((r) => r.reportDate ?? r.documentDate), + ); }, async mood(frame) { const rows = await prisma.moodEntry.findMany({ diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index ec2073c8e..139cdd416 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -130,6 +130,19 @@ function dateKeyOrNull(value: Date | null): string | null { return value ? dateOnlyKey(value) : null; } +/** + * The day as a window over a column that holds a date, not an instant (a + * dose date at UTC midnight, an allergy onset or a document date at noon + * UTC). Its date part decides the day, whatever the person's zone; the + * local day window put a UTC-midnight dose on the previous day west of UTC. + */ +function calendarDayWindow(day: string): { gte: Date; lt: Date } { + return { + gte: dayKeyAsUtcMidnight(day), + lt: dayKeyAsUtcMidnight(shiftDateKey(day, 1)), + }; +} + /* ─── medications ─────────────────────────────────────────────────────────── */ async function medicationsPart(frame: DayFrame): Promise { @@ -578,15 +591,16 @@ async function allergiesPart(frame: DayFrame): Promise { where: { userId: frame.userId, deletedAt: null, - onsetAt: { gte: frame.dayStart, lt: frame.dayEnd }, + onsetAt: calendarDayWindow(frame.day), }, - select: { id: true, substance: true, onsetAt: true, severity: true }, + select: { id: true, substance: true, severity: true }, }); return { running: [], events: rows.map((row) => event({ - at: row.onsetAt ? row.onsetAt.toISOString() : null, + // A date without a time of day: no clock time to print. + at: null, kind: "allergyOnset", section: "allergies", id: row.id, @@ -648,11 +662,10 @@ async function vaccinationsPart(frame: DayFrame): Promise { where: { userId: frame.userId, deletedAt: null, - occurredAt: { gte: frame.dayStart, lt: frame.dayEnd }, + occurredAt: calendarDayWindow(frame.day), }, select: { id: true, - occurredAt: true, vaccineName: true, antigenSlug: true, doseNumber: true, @@ -670,7 +683,8 @@ async function vaccinationsPart(frame: DayFrame): Promise { running: [], events: rows.map((row) => event({ - at: row.occurredAt.toISOString(), + // A dose carries a date, never a time of day. + at: null, kind: "vaccination", section: "vaccinations", id: row.id, @@ -761,18 +775,13 @@ async function checkupsPart(frame: DayFrame): Promise { async function documentsPart(frame: DayFrame): Promise { // A document's date is a calendar date stored at noon UTC (older rows at - // UTC midnight): both anchors fall inside this UTC calendar day, and the - // date key decides. The stated report date wins over the filing date. - const start = dayKeyAsUtcMidnight(frame.day); - const end = dayKeyAsUtcMidnight(shiftDateKey(frame.day, 1)); + // UTC midnight). The stated report date wins over the filing date. + const day = calendarDayWindow(frame.day); const rows = await prisma.inboundDocument.findMany({ where: { userId: frame.userId, deletedAt: null, - OR: [ - { reportDate: { gte: start, lt: end } }, - { reportDate: null, documentDate: { gte: start, lt: end } }, - ], + OR: [{ reportDate: day }, { reportDate: null, documentDate: day }], }, select: { id: true, diff --git a/src/lib/day/visit-preparation.ts b/src/lib/day/visit-preparation.ts index f3ab26c74..533f530d9 100644 --- a/src/lib/day/visit-preparation.ts +++ b/src/lib/day/visit-preparation.ts @@ -240,7 +240,12 @@ async function vaccinationChanges(w: Window): Promise { where: { userId: w.userId, deletedAt: null, - occurredAt: { gte: w.start, lt: w.end }, + // A dose date is stored at UTC midnight: its date part is its day in + // every zone, so the window is UTC calendar days, not local instants. + occurredAt: { + gte: dayKeyAsUtcMidnight(w.from), + lt: dayKeyAsUtcMidnight(shiftDateKey(w.to, 1)), + }, }, select: { id: true, @@ -251,7 +256,7 @@ async function vaccinationChanges(w: Window): Promise { }, }); return rows.map((row) => - change(userDayKey(row.occurredAt, w.tz), "vaccinations", { + change(dateOnlyKey(row.occurredAt), "vaccinations", { kind: "vaccination", id: row.id, title: diff --git a/src/lib/timeline/lanes.ts b/src/lib/timeline/lanes.ts index eeb0f9053..3d440a628 100644 --- a/src/lib/timeline/lanes.ts +++ b/src/lib/timeline/lanes.ts @@ -204,7 +204,7 @@ async function allergiesLane(frame: LaneFrame): Promise { item({ id: r.id, kind: "allergy", - start: userDayKey(r.onsetAt, frame.tz), + start: dateOnlyKey(r.onsetAt), end: null, open: r.status === "ACTIVE", label: r.substance, @@ -388,7 +388,7 @@ async function vaccinationsLane(frame: LaneFrame): Promise { item({ id: r.id, kind: "vaccination", - start: userDayKey(r.occurredAt, frame.tz), + start: dateOnlyKey(r.occurredAt), end: null, open: false, label: r.vaccineName ?? r.customVaccine?.name ?? r.antigenSlug ?? "", diff --git a/src/lib/timeline/readiness.ts b/src/lib/timeline/readiness.ts index a65e313d4..db122b9a5 100644 --- a/src/lib/timeline/readiness.ts +++ b/src/lib/timeline/readiness.ts @@ -271,9 +271,7 @@ async function allergiesLane(frame: Frame) { const detail = { key: "allergies", params: { count: rows.length } }; const dated = rows.filter((r) => r.onsetAt !== null); if (dated.length > 0) { - const since = dated - .map((r) => userDayKey(r.onsetAt as Date, frame.tz)) - .sort()[0]; + const since = dated.map((r) => dateOnlyKey(r.onsetAt as Date)).sort()[0]; return lane("carries", rows.length, since, { detail }); } // Undated allergies are standing items, not a shortcoming: no gap. @@ -351,7 +349,7 @@ async function vaccinationsLane(frame: Frame) { gaps: [gap("vaccinationsEmpty", 0, "/vaccinations")], }); } - return lane("carries", count, userDayKey(first.occurredAt, frame.tz), { + return lane("carries", count, dateOnlyKey(first.occurredAt), { detail: { key: "vaccinations", params: { count } }, }); } From 9d7c99e60c2b4e20cfa538569eac15a49ec4a9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:50:34 +0100 Subject: [PATCH 05/12] fix(vaccinations): date a dose by its calendar day on every surface A dose date is stored at UTC midnight. The health record PDF, the share page, the related-records list, the document link chips and both dose pickers formatted it as an instant in the reader's zone, so across the Americas each of them showed the day before the vaccination list did. The booster anchor added the interval to the stored instant, which put the first due day a day early there too. The report and share page print dose dates through the calendar-date formatter, the link surfaces through `useCalendarDate()`, and the booster counts its interval from the dose's date to the start of that day in the person's zone. --- .../__tests__/clinician-view.test.tsx | 22 ++++++++ src/components/clinician/clinician-view.tsx | 2 +- src/components/clinician/history-sections.tsx | 7 +-- .../documents/document-record-links.tsx | 7 ++- src/components/links/related-records.tsx | 16 +++--- .../__tests__/doctor-report-pdf-core.test.ts | 32 ++++++++++++ .../clinical-records-notes-section.ts | 16 ++++-- .../__tests__/booster-mint.test.ts | 50 +++++++++++++++++++ src/lib/vaccinations/booster-mint.ts | 14 ++++-- 9 files changed, 147 insertions(+), 19 deletions(-) create mode 100644 src/lib/vaccinations/__tests__/booster-mint.test.ts diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index c6ef44d2f..21666b4eb 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -519,6 +519,28 @@ describe("", () => { expect(html).toContain("05/14/1960"); expect(html).not.toContain("05/13/1960"); }); + + it("dates a vaccination on its own day west of UTC", () => { + const html = render( + makeReport({ + immunizations: [ + { + // A dose date at UTC midnight: the evening before in New York. + occurredAt: "2026-04-11T00:00:00.000Z", + antigenSlug: null, + vaccineName: "Influenza", + lotNumber: null, + site: null, + practitionerName: null, + series: [], + }, + ], + }), + { timezone: "America/New_York", locale: "en" }, + ); + expect(html).toContain("04/11/2026"); + expect(html).not.toContain("04/10/2026"); + }); }); /** diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 141456606..5480c050d 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -303,7 +303,7 @@ export function ClinicianView({ t={t} report={report} scope={scope} - fmtDate={fmtDate} + fmtCalendarDate={fmtCalendarDate} /> diff --git a/src/components/clinician/history-sections.tsx b/src/components/clinician/history-sections.tsx index dd6a25138..8b66da8b4 100644 --- a/src/components/clinician/history-sections.tsx +++ b/src/components/clinician/history-sections.tsx @@ -302,12 +302,13 @@ export function ImmunizationsSection({ t, report, scope, - fmtDate, + fmtCalendarDate, }: { t: Translate; report: DoctorReportData; scope: LeafScope; - fmtDate: (iso: string) => string; + /** A dose date is a calendar date at UTC midnight, never an instant. */ + fmtCalendarDate: (iso: string) => string; }) { const doses = report.immunizations ?? []; @@ -342,7 +343,7 @@ export function ImmunizationsSection({ : (dose.vaccineName ?? "—") } value={compose([ - fmtDate(dose.occurredAt), + fmtCalendarDate(dose.occurredAt), doseDisplay(dose.series), dose.lotNumber ? `${t("doctorReport.immunizationsColLot")} ${dose.lotNumber}` diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index c3cbd6827..fcfce50e6 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -46,6 +46,7 @@ import { useVaccinations } from "@/components/vaccinations/use-vaccinations"; import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { queryKeys } from "@/lib/query-keys"; import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto"; import type { EncounterKind } from "@/generated/prisma/client"; @@ -161,6 +162,7 @@ export function DocumentRecordLinks({ }) { const { t } = useTranslations(); const format = useFormatters(); + const calendarDate = useCalendarDate(); const { user } = useAuth(); const vaccinationsOn = user?.modules?.vaccinations !== false; const anchor = doc.documentDate ?? doc.reportDate; @@ -205,6 +207,7 @@ export function DocumentRecordLinks({ anchor, labels, ); + // A dose date is a calendar date at UTC midnight, never an instant. const doseOptions = recordOptions( (vaccinations.data?.vaccinations ?? []).map((dose) => ({ id: dose.id, @@ -213,7 +216,7 @@ export function DocumentRecordLinks({ href: doseHref(dose.id), })), anchor, - labels, + { ...labels, date: calendarDate }, ); const showDoses = vaccinationsOn && doc.vaccinationLinks !== null; @@ -324,7 +327,7 @@ export function DocumentRecordLinks({ {link.occurredAt ? ( - {format.date(link.occurredAt)} + {calendarDate(link.occurredAt)} ) : null} diff --git a/src/components/links/related-records.tsx b/src/components/links/related-records.tsx index 85ff071f4..682aa3b1a 100644 --- a/src/components/links/related-records.tsx +++ b/src/components/links/related-records.tsx @@ -37,6 +37,7 @@ import { type EntityLinkOption, } from "@/components/links/entity-link-picker"; import { useVaultDocumentOptions } from "@/components/links/vault-document-options"; +import { useCalendarDate } from "@/hooks/use-calendar-date"; import { pickerPivot, useLinkPickerPages, @@ -133,6 +134,7 @@ export function RelatedRecordsList({ }) { const { t } = useTranslations(); const format = useFormatters(); + const calendarDate = useCalendarDate(); const rows = (related ?? []).filter((row) => !omitKinds.includes(row.kind)); if (rows.length === 0) return null; return ( @@ -142,7 +144,12 @@ export function RelatedRecordsList({ const via = viaText(t, row.via); const meta = [ kindText(t, row.kind), - row.date ? format.date(row.date) : null, + row.date + ? // A dose date is a calendar date at UTC midnight. + row.kind === "vaccination" + ? calendarDate(row.date) + : format.date(row.date) + : null, via, ] .filter(Boolean) @@ -381,7 +388,7 @@ function MedicationPicker({ selected, onChange, known }: PickerProps) { function DosePicker({ selected, onChange, anchor, known }: PickerProps) { const { t } = useTranslations(); - const format = useFormatters(); + const calendarDate = useCalendarDate(); const doses = useVaccinations(null, true); const options = recordOptions( (doses.data?.vaccinations ?? []).map((dose) => ({ @@ -393,10 +400,7 @@ function DosePicker({ selected, onChange, anchor, known }: PickerProps) { href: `/vaccinations?dose=${encodeURIComponent(dose.id)}`, })), anchor, - { - suggested: t("links.picker.suggested"), - date: (iso: string) => format.date(iso), - }, + { suggested: t("links.picker.suggested"), date: calendarDate }, ); return ( { expect(text).toContain("05/14/1960"); expect(text).not.toContain("05/13/1960"); }); + + it("dates a vaccination on its own day west of UTC", async () => { + const { t } = getServerTranslator("en"); + const text = await extractText( + renderDoctorReportPdfBytes( + makeData({ + immunizations: [ + { + // A dose date at UTC midnight: 10 April, 20:00 in New York. + occurredAt: "2026-04-11T00:00:00.000Z", + antigenSlug: null, + vaccineName: "Influenza", + lotNumber: null, + site: null, + practitionerName: null, + series: [], + }, + ], + }), + { + t, + locale: "en", + now: FIXED_NOW, + userTz: "America/New_York", + timeFormat: "AUTO", + dateFormat: "AUTO", + }, + ), + ); + expect(text).toContain("04/11/2026"); + expect(text).not.toContain("04/10/2026"); + }); }); // ── script coverage ── the report is read in every shipped locale and carries diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index 59b2c265e..40c314fd4 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -23,8 +23,17 @@ export function buildClinicalRecordsNotesSection( context: DoctorReportPdfRenderContext, state: DoctorReportPdfCursorState, ): DoctorReportPdfCursorState { - const { doc, data, t, num, fmtDate, margin, tableBottomMargin, ensureSpace } = - context; + const { + doc, + data, + t, + num, + fmtDate, + fmtCalendarDate, + margin, + tableBottomMargin, + ensureSpace, + } = context; let y = state.y; // v1.17.1 — structured lab-results section. Populated when the `labs` @@ -422,7 +431,8 @@ export function buildClinicalRecordsNotesSection( }; const immunizationRows = data.immunizations.map((row) => [ - fmtDate(row.occurredAt), + // A dose date is a calendar date at UTC midnight, never an instant. + fmtCalendarDate(row.occurredAt), row.antigenSlug ? t(`vaccinations.catalog.${row.antigenSlug}`) : (row.vaccineName ?? "—"), diff --git a/src/lib/vaccinations/__tests__/booster-mint.test.ts b/src/lib/vaccinations/__tests__/booster-mint.test.ts new file mode 100644 index 000000000..48c2f4364 --- /dev/null +++ b/src/lib/vaccinations/__tests__/booster-mint.test.ts @@ -0,0 +1,50 @@ +/** + * The first booster is due one interval after the dose. The dose date is a + * calendar date stored at UTC midnight; adding the interval to that instant + * put the anchor on the evening before for anyone west of UTC, so the + * reminder came due a day early there. + */ +import { describe, expect, it, vi } from "vitest"; + +import type { Prisma } from "@/generated/prisma/client"; +import { mintOrReanchorBooster } from "@/lib/vaccinations/booster-mint"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; + +function fakeTx() { + const created: Array> = []; + const tx = { + vaccinationRecord: { + findFirst: vi.fn(async () => ({ + id: "dose-1", + antigenSlug: "tetanus", + customVaccineId: null, + occurredAt: new Date("2026-11-11T00:00:00.000Z"), + })), + update: vi.fn(async () => ({})), + }, + measurementReminder: { + findFirst: vi.fn(async () => null), + create: vi.fn(async ({ data }: { data: Record }) => { + created.push(data); + return { id: "reminder-1" }; + }), + }, + }; + return { tx: tx as unknown as Prisma.TransactionClient, created }; +} + +describe("mintOrReanchorBooster", () => { + it("counts the interval from the dose's own date west of UTC", async () => { + const { tx, created } = fakeTx(); + // 12 months → round(12 × 30.4375) = 365 days after 11 November 2026. + await mintOrReanchorBooster( + tx, + "u1", + { vaccinationId: "dose-1", intervalMonths: 12, label: "Tetanus" }, + "America/New_York", + ); + expect(created[0].anchorDate).toEqual( + startOfLocalDayKey("2027-11-11", "America/New_York"), + ); + }); +}); diff --git a/src/lib/vaccinations/booster-mint.ts b/src/lib/vaccinations/booster-mint.ts index 871fc68f1..8faeeb47a 100644 --- a/src/lib/vaccinations/booster-mint.ts +++ b/src/lib/vaccinations/booster-mint.ts @@ -28,6 +28,9 @@ import type { Prisma } from "@/generated/prisma/client"; import { computeReminderNextDueAt } from "@/lib/measurement-reminders/scheduling"; +import { dateOnlyKey } from "@/lib/tz/date-only"; +import { shiftDateKey } from "@/lib/tz/format"; +import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { componentsForDose, loadCustomVaccineLookup, @@ -111,10 +114,13 @@ export async function mintOrReanchorBooster( // dose date plus the interval — a never-satisfied rolling reminder is due at // its anchor, and the dose date itself is not when the next shot is due. Once // the person logs that next dose, `satisfyReminder` re-anchors on the satisfy - // instant and this anchor no longer matters. - const DAY_MS = 24 * 60 * 60 * 1000; - const anchorDate = new Date( - record.occurredAt.getTime() + intervalDays * DAY_MS, + // instant and this anchor no longer matters. The dose date is a calendar + // date at UTC midnight, so the interval counts from its date part to the + // start of that day in the person's zone, where adding days to the stored + // instant fell on the evening before west of UTC. + const anchorDate = startOfLocalDayKey( + shiftDateKey(dateOnlyKey(record.occurredAt), intervalDays), + timezone, ); const nextDueAt = computeReminderNextDueAt( { From 2c7a0dc3c22791bae11ae9cdbd5d53cce4d679c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:52:13 +0100 Subject: [PATCH 06/12] fix(insights): show small lab changes and print them like the labs page "What changed since your last panel" rounded every delta to two decimals, so TSH 0.004 to 0.009 read "no change", and it printed values and deltas through `String()`: a full stop in every language and every digit of a unit conversion (41.999198717948715 ng/mL). The summary now reads each reading at the precision it was recorded at (`recordedPrecision`, with the report's printed figure for a converted value, which the route now selects) and rounds the delta to the finer of the two, so its direction agrees with the printed figures. The card prints both through `useLabNumber()`. 5.9 - 5.6 still comes out as 0.3, so the existing marker-pairing test stands as it was. --- src/app/api/insights/labs-changes/route.ts | 2 + .../__tests__/labs-changes-card.test.tsx | 60 +++++++++++++++++++ src/components/insights/labs-changes-card.tsx | 9 ++- .../insights/__tests__/labs-changes.test.ts | 49 +++++++++++++++ src/lib/insights/labs-changes.ts | 36 +++++++++-- 5 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 src/components/insights/__tests__/labs-changes-card.test.tsx diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts index 3436b15b6..18099e912 100644 --- a/src/app/api/insights/labs-changes/route.ts +++ b/src/app/api/insights/labs-changes/route.ts @@ -48,6 +48,7 @@ export const GET = apiHandler(async () => { biomarkerId: true, unit: true, value: true, + sourceValue: true, referenceLow: true, referenceHigh: true, sourceReferenceLow: true, @@ -65,6 +66,7 @@ export const GET = apiHandler(async () => { biomarkerId: r.biomarkerId, unit: r.unit, value: r.value as number, + sourceValue: r.sourceValue, referenceLow: r.referenceLow, referenceHigh: r.referenceHigh, sourceReferenceLow: r.sourceReferenceLow, diff --git a/src/components/insights/__tests__/labs-changes-card.test.tsx b/src/components/insights/__tests__/labs-changes-card.test.tsx new file mode 100644 index 000000000..ac78f10e4 --- /dev/null +++ b/src/components/insights/__tests__/labs-changes-card.test.tsx @@ -0,0 +1,60 @@ +/** + * The card prints a lab figure the way the labs page does: in the reader's + * notation and with the digits the reading holds. It used to print + * `String(value)`, so a German reader saw "4.6" beside a labs page that says + * "4,6", and a converted reading showed every digit of the conversion. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: { + present: true, + latestDate: "2026-06-01", + previousDate: "2026-03-01", + generatedAt: "2026-06-02T00:00:00.000Z", + changes: [ + { + analyte: "TSH", + unit: "mIU/L", + latest: 0.009, + previous: 0.004, + delta: 0.005, + direction: "up", + status: "unknown", + }, + { + analyte: "Potassium", + unit: "mmol/L", + latest: 4.6, + previous: 4.9, + delta: -0.3, + direction: "down", + status: "unknown", + }, + ], + }, + }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { LabsChangesCard } from "../labs-changes-card"; + +describe("", () => { + it("prints values and deltas in the reader's notation", () => { + const html = renderToStaticMarkup( + + + , + ); + expect(html).toContain("0,009 mIU/L"); + expect(html).toContain("+0,005 mIU/L"); + expect(html).toContain("4,6 mmol/L"); + expect(html).toContain("-0,3 mmol/L"); + }); +}); diff --git a/src/components/insights/labs-changes-card.tsx b/src/components/insights/labs-changes-card.tsx index 30a8e146a..f912b27c4 100644 --- a/src/components/insights/labs-changes-card.tsx +++ b/src/components/insights/labs-changes-card.tsx @@ -12,6 +12,7 @@ import { SectionHeading } from "@/components/ui/section-heading"; import type { LabChange } from "@/lib/insights/labs-changes"; import { InsightSectionCard } from "./insight-section-card"; import { withLabUnit } from "@/lib/labs/dimensionless"; +import { useLabNumber } from "@/components/labs/use-lab-format"; /** * v1.25 — "what changed since your last panel" card. @@ -55,6 +56,7 @@ export function LabsChangesCard({ }: LabsChangesCardProps) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); + const labNumber = useLabNumber(); const { data } = useQuery({ queryKey: queryKeys.insightsLabsChanges(), @@ -82,7 +84,10 @@ export function LabsChangesCard({ const signedDelta = c.direction === "flat" ? t("insights.labsChanges.directionFlat") - : withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit); + : withLabUnit( + `${c.delta > 0 ? "+" : ""}${labNumber(c.delta)}`, + c.unit, + ); return (

- {withLabUnit(String(c.latest), c.unit)} + {withLabUnit(labNumber(c.latest), c.unit)} {labelKey ? ` · ${t(labelKey)}` : ""}

diff --git a/src/lib/insights/__tests__/labs-changes.test.ts b/src/lib/insights/__tests__/labs-changes.test.ts index f1f003b72..d5bf7744a 100644 --- a/src/lib/insights/__tests__/labs-changes.test.ts +++ b/src/lib/insights/__tests__/labs-changes.test.ts @@ -27,6 +27,7 @@ function row( sourceHigh?: number | null; sourceText?: string | null; biomarkerId?: string | null; + sourceValue?: number | null; } = {}, ): LabChangeRow { return { @@ -34,6 +35,7 @@ function row( biomarkerId: opts.biomarkerId ?? null, unit: opts.unit ?? "mg/dL", value, + sourceValue: opts.sourceValue ?? null, referenceLow: opts.low ?? null, referenceHigh: opts.high ?? null, sourceReferenceLow: opts.sourceLow ?? null, @@ -179,6 +181,53 @@ describe("summariseLabChanges", () => { expect(out.present).toBe(false); }); + it("calls a small change in a small value a change", () => { + // Two decimals for every analyte rounded this delta to 0: "no change" + // for a TSH that more than doubled. + const out = summariseLabChanges( + [ + row("TSH", 0.009, "2026-06-01", { unit: "mIU/L" }), + row("TSH", 0.004, "2026-03-01", { unit: "mIU/L" }), + ], + NOW, + ); + expect(out.changes).toEqual([ + expect.objectContaining({ delta: 0.005, direction: "up" }), + ]); + }); + + it("reads a converted value at the precision its report printed", () => { + // 104.83 nmol/L converted to ng/mL is 41.999198717948715. + const out = summariseLabChanges( + [ + row("Vitamin D", 41.999198717948715, "2026-06-01", { + unit: "ng/mL", + sourceValue: 104.83, + }), + row("Vitamin D", 30, "2026-03-01", { unit: "ng/mL" }), + ], + NOW, + ); + expect(out.changes).toEqual([ + expect.objectContaining({ + latest: 41.999, + previous: 30, + delta: 11.999, + direction: "up", + }), + ]); + }); + + it("still calls an unchanged reading flat", () => { + const out = summariseLabChanges( + [row("LDL", 130, "2026-06-01"), row("LDL", 130, "2026-03-01")], + NOW, + ); + expect(out.changes).toEqual([ + expect.objectContaining({ delta: 0, direction: "flat" }), + ]); + }); + it("reports no delta across two units", () => { const out = summariseLabChanges( [ diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts index 455e02c6f..f3f732515 100644 --- a/src/lib/insights/labs-changes.ts +++ b/src/lib/insights/labs-changes.ts @@ -15,6 +15,7 @@ * the newest panel is older than `LAB_CHANGE_RECENCY_DAYS`. Neutral framing * only — a delta is not a diagnosis. */ +import { recordedPrecision } from "@/lib/format/recorded-number"; import { classifyAgainstEffectiveRange, resolveEffectiveReferenceRange, @@ -33,6 +34,11 @@ export interface LabChangeRow extends SourceReferenceRange { biomarkerId?: string | null; unit: string; value: number; + /** + * The figure the source report printed when `value` was converted from + * another unit; it sets the precision the converted value is read at. + */ + sourceValue?: number | null; referenceLow: number | null; referenceHigh: number | null; takenAt: Date; @@ -41,9 +47,13 @@ export interface LabChangeRow extends SourceReferenceRange { export interface LabChange { analyte: string; unit: string; + /** The readings at the precision they were recorded at (`recordedPrecision`). */ latest: number; previous: number; - /** Signed latest − previous, rounded to 2dp. */ + /** + * Signed latest − previous, at the finer of the two readings' precisions, + * so TSH 0.004 → 0.009 is +0.005 and not a rounded-away 0. + */ delta: number; direction: "up" | "down" | "flat"; status: ReferenceRangeStatus; @@ -108,8 +118,6 @@ function sameUnit(a: LabChangeRow, b: LabChangeRow): boolean { return a.unit.trim().toLowerCase() === b.unit.trim().toLowerCase(); } -const round2 = (n: number): number => Math.round(n * 100) / 100; - /** * Summarise the change between the two most-recent numeric lab panels. `rows` * should already be the user's live (non-deleted) numeric results; this helper @@ -161,14 +169,30 @@ export function summariseLabChanges( const previousRow = previous.get(key); // A delta across two units is no change, it is a conversion; skip it. if (!previousRow || !sameUnit(latestRow, previousRow)) continue; - const delta = round2(latestRow.value - previousRow.value); + // Each reading at the digits it was recorded with; the delta at the + // finer of the two, so it says what the printed figures say. + const latestReading = recordedPrecision( + latestRow.value, + latestRow.sourceValue, + ); + const previousReading = recordedPrecision( + previousRow.value, + previousRow.sourceValue, + ); + const digits = Math.max( + latestReading.fractionDigits, + previousReading.fractionDigits, + ); + const delta = Number( + (latestReading.value - previousReading.value).toFixed(digits), + ); const direction: LabChange["direction"] = delta > 0 ? "up" : delta < 0 ? "down" : "flat"; changes.push({ analyte: latestRow.analyte, unit: latestRow.unit, - latest: latestRow.value, - previous: previousRow.value, + latest: latestReading.value, + previous: previousReading.value, delta, direction, status: classifyAgainstEffectiveRange( From 4fa5e6381e5d833ad033c60aa13514f745d38b73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 19:56:49 +0100 Subject: [PATCH 07/12] fix(insights): take weight and BMI bands from the record on screen Inside a shared or managed record the weight page shaded the band for the viewer's own height, and the record's own target never arrived because `/api/user/thresholds` refuses under a switch: a parent saw their own healthy range behind a child's weights. The BMI page decided from the viewer's height whether there was anything to show, and the target gear opened an editor that could neither load nor save there. The weight page now reads the record's target and height from `/api/insights/targets`, the payload the reference panel under the chart already uses, and paints the same wings as before. The BMI page reads the record's height there inside somebody else's record. The gear stays hidden in another person's record, as the BMI chart already draws nothing there rather than a wrong figure. --- .../baselines/version-stamp-comments.json | 4 +- src/app/insights/bmi/__tests__/page.test.tsx | 80 +++++++++++++ src/app/insights/bmi/page.tsx | 23 +++- .../insights/weight/__tests__/page.test.tsx | 105 ++++++++++++++++++ .../weight/__tests__/weight-band.test.ts | 46 ++++++++ src/app/insights/weight/page.tsx | 54 +++------ src/app/insights/weight/weight-band.ts | 43 +++++++ .../__tests__/sub-page-shell.test.tsx | 6 + .../__tests__/target-adjust-button.test.tsx | 21 ++++ .../insights/target-adjust-button.tsx | 11 +- 10 files changed, 350 insertions(+), 43 deletions(-) create mode 100644 src/app/insights/bmi/__tests__/page.test.tsx create mode 100644 src/app/insights/weight/__tests__/page.test.tsx create mode 100644 src/app/insights/weight/__tests__/weight-band.test.ts create mode 100644 src/app/insights/weight/weight-band.ts diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 278f68243..45d5e3f4a 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2892, - "total": 9216, + "total": 9213, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -643,7 +643,7 @@ "app/insights/walking-heart-rate/page.tsx": 1, "app/insights/walking-speed/page.tsx": 1, "app/insights/walking-steadiness/page.tsx": 1, - "app/insights/weight/page.tsx": 8, + "app/insights/weight/page.tsx": 5, "app/insights/workouts/page-client.tsx": 2, "app/insights/wrist-temperature/page.tsx": 1, "app/invite/[token]/__tests__/page.test.tsx": 1, diff --git a/src/app/insights/bmi/__tests__/page.test.tsx b/src/app/insights/bmi/__tests__/page.test.tsx new file mode 100644 index 000000000..b0f342bc4 --- /dev/null +++ b/src/app/insights/bmi/__tests__/page.test.tsx @@ -0,0 +1,80 @@ +/** + * `/insights/bmi` decides from a height whether it can show anything. It used + * the height of the person at the keyboard, so inside somebody else's record + * a viewer without a height of their own was asked to set it, and a viewer + * with one hid that the record has none. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const shellProps = vi.fn(); +vi.mock("@/components/insights/sub-page-shell", () => ({ + SubPageShell: (props: Record) => { + shellProps(props); + return null; + }, +})); + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key, locale: "en" }), +})); + +const state = { + viewerHeight: null as number | null, + inSharedRecord: true, + recordHeight: 118 as number | null, +}; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: { heightCm: state.viewerHeight, timezone: "UTC" }, + }), +})); +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: state.inSharedRecord }), +})); +vi.mock("@/hooks/use-insights-analytics", () => ({ + useInsightsAnalytics: () => ({ isEmpty: false }), +})); +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ enabled }: { enabled?: boolean }) => ({ + data: + enabled === false + ? undefined + : { profile: { heightCm: state.recordHeight } }, + }), +})); + +import InsightsBmiPage from "../page"; + +/** True when the page rendered its "set your height" state. */ +function asksForHeight(): boolean { + renderToStaticMarkup(); + const props = shellProps.mock.calls.at(-1)![0] as { coachLaunch?: boolean }; + // The chart branch launches the Coach from the shell; the empty states do not. + return props.coachLaunch !== true; +} + +describe("/insights/bmi height gate", () => { + beforeEach(() => { + shellProps.mockClear(); + state.viewerHeight = null; + state.inSharedRecord = true; + state.recordHeight = 118; + }); + + it("reads the record's height inside somebody else's record", () => { + expect(asksForHeight()).toBe(false); + state.viewerHeight = 180; + state.recordHeight = null; + expect(asksForHeight()).toBe(true); + }); + + it("reads the account's own height in one's own record", () => { + state.inSharedRecord = false; + state.viewerHeight = 180; + expect(asksForHeight()).toBe(false); + state.viewerHeight = null; + expect(asksForHeight()).toBe(true); + }); +}); diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index 735c4d7ea..cc6db7390 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -1,9 +1,13 @@ "use client"; import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; import { Ruler } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useTranslations } from "@/lib/i18n/context"; import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; @@ -45,6 +49,23 @@ export default function InsightsBmiPage() { const { isEmpty } = useInsightsAnalytics("BMI"); + // `useAuth()` is the person at the keyboard. Inside somebody else's record + // the height that decides is the record's, from the route that knows which + // record is on screen (the reference panel below reads the same cell). + const { inSharedRecord } = useRecordCapabilities(); + const { data: targets } = useQuery({ + queryKey: queryKeys.insightsTargets(), + queryFn: () => + apiGet<{ profile?: { heightCm: number | null } }>( + "/api/insights/targets", + ), + enabled: isAuthenticated && inSharedRecord, + }); + const heightCm = inSharedRecord + ? (targets?.profile?.heightCm ?? null) + : (user?.heightCm ?? null); + const heightKnown = !inSharedRecord || targets !== undefined; + // v1.4.27 — BMI is derived from WEIGHT. When no weight readings // exist yet, the existing "set your height" branch can never compute // anything useful either; surface the empty-state CTA pointing at @@ -75,7 +96,7 @@ export default function InsightsBmiPage() { ); } - if (!user?.heightCm) { + if (heightKnown && !heightCm) { return ( ({ + SubPageShell: (props: Record) => { + shellProps(props); + return null; + }, +})); + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key, locale: "en" }), +})); + +// The viewer: 180 cm, signed in, looking at somebody else's record. +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: { heightCm: 180, timezone: "UTC", unitPreference: "metric" }, + }), + useAccountOnceMounted: () => null, +})); + +vi.mock("@/hooks/use-insights-analytics", () => ({ + useInsightsAnalytics: () => ({ + data: { summaries: { WEIGHT: { count: 10 } } }, + isLoading: false, + isEmpty: false, + }), +})); + +// The record on screen: 118 cm, no target of its own. +const targets = { + current: { + targets: [ + { type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" }, + ], + profile: { heightCm: 118 }, + } as unknown, +}; +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => ({ + data: JSON.stringify(queryKey).includes("targets") + ? targets.current + : undefined, + isLoading: false, + error: null, + }), +})); + +import InsightsGewichtPage from "../page"; +import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands"; + +function chartBands(): Array<{ min: number; max: number }> | undefined { + renderToStaticMarkup(); + const props = shellProps.mock.calls.at(-1)![0] as { children: unknown }; + const children = Array.isArray(props.children) + ? props.children + : [props.children]; + const chart = children.find( + (el) => + el && + typeof el === "object" && + Array.isArray((el as { props?: { types?: unknown } }).props?.types), + ) as { props: { valueBands?: Array<{ min: number; max: number }> } }; + return chart.props.valueBands; +} + +describe("/insights/weight band", () => { + beforeEach(() => shellProps.mockClear()); + + it("shades the WHO band for the record's height, not the viewer's", () => { + const bands = chartBands(); + const record = buildWeightRangeFromHeight(118); + const viewer = buildWeightRangeFromHeight(180); + expect(bands).toContainEqual( + expect.objectContaining({ min: record.greenMin, max: record.greenMax }), + ); + expect(bands).not.toContainEqual( + expect.objectContaining({ min: viewer.greenMin, max: viewer.greenMax }), + ); + }); + + it("shades no band when the record's targets are not readable", () => { + targets.current = undefined; + try { + expect(chartBands()).toBeUndefined(); + } finally { + targets.current = { + targets: [], + profile: { heightCm: 118 }, + }; + } + }); +}); diff --git a/src/app/insights/weight/__tests__/weight-band.test.ts b/src/app/insights/weight/__tests__/weight-band.test.ts new file mode 100644 index 000000000..0a4da57a5 --- /dev/null +++ b/src/app/insights/weight/__tests__/weight-band.test.ts @@ -0,0 +1,46 @@ +/** + * The weight band belongs to the record on screen. It used to be built from + * the signed-in person's height, so a parent of 180 cm looking at a child's + * record saw a healthy band of about 60 to 81 kg behind the child's weights. + */ +import { describe, expect, it } from "vitest"; + +import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands"; +import { selectWeightBandRange } from "../weight-band"; + +describe("selectWeightBandRange", () => { + it("builds the WHO band from the record's height", () => { + const range = selectWeightBandRange({ + targets: [ + { type: "WEIGHT", range: { min: 25.8, max: 34.7 }, source: "WHO BMI" }, + ], + profile: { heightCm: 118 }, + }); + expect(range).toEqual(buildWeightRangeFromHeight(118)); + }); + + it("paints the record's own target with the override wings", () => { + const range = selectWeightBandRange({ + targets: [ + { type: "WEIGHT", range: { min: 70, max: 80 }, source: "Custom" }, + ], + profile: { heightCm: 180 }, + }); + expect(range).toEqual({ + greenMin: 70, + greenMax: 80, + orangeMin: 68.5, + orangeMax: 81.5, + }); + }); + + it("paints nothing without a target or a height, or before the read lands", () => { + expect( + selectWeightBandRange({ + targets: [{ type: "WEIGHT", range: null, source: "WHO BMI" }], + profile: { heightCm: null }, + }), + ).toBeNull(); + expect(selectWeightBandRange(null)).toBeNull(); + }); +}); diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index c5ac87fff..b6685072a 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -23,11 +23,8 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation- import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { - buildBandsFromTrafficRange, - buildWeightBandsFromHeight, - type TrafficRange, -} from "@/lib/analytics/value-bands"; +import { buildBandsFromTrafficRange } from "@/lib/analytics/value-bands"; +import { selectWeightBandRange, type WeightTargetsSlice } from "./weight-band"; /** * v1.4.25 — `/insights/weight`. @@ -37,8 +34,8 @@ import { * (`chartKey="weight"`) lets the user toggle trend lines + comparison * overlay independently from the dashboard weight card. * - * v1.34 — the bands follow the user's own weight target when one is set, - * and fall back to the height-derived WHO band only when it is not. + * The bands follow the record's own weight target when one is set, and fall + * back to the WHO band for the record's height only when it is not. * * v1.4.28 — analytics fetch + empty-state * branch now consume `useInsightsAnalytics()` + ``. @@ -56,19 +53,13 @@ export default function InsightsGewichtPage() { } = useInsightsAnalytics("WEIGHT"); const weightSummary = analytics?.summaries?.WEIGHT ?? null; - // v1.34 — the user's own weight target, read through the shared - // `queryKeys.userThresholds()` cell the targets editor also subscribes to, so - // editing the target repaints this chart's band without a reload. - const { data: thresholds } = useQuery({ - queryKey: queryKeys.userThresholds(), - queryFn: async () => { - return apiGet<{ - effective: Record< - string, - { range: TrafficRange | null; isOverride: boolean } - >; - }>("/api/user/thresholds"); - }, + // The record's own target and height, from the route that knows which + // record is on screen (see `selectWeightBandRange`). Shares the + // `insightsTargets()` cell with the reference panel below; the targets + // editor invalidates it on save, so the band repaints without a reload. + const { data: targets } = useQuery({ + queryKey: queryKeys.insightsTargets(), + queryFn: () => apiGet("/api/insights/targets"), enabled: isAuthenticated, }); @@ -136,27 +127,14 @@ export default function InsightsGewichtPage() { ); } - // v1.34 — the shaded zone follows the user's OWN target when they set one. - // The page used to shade the height-derived WHO band unconditionally, so - // someone who had entered a target on `/targets` kept reading a chart that - // ignored the answer. `/api/user/thresholds` already resolves the override - // (with its orange wings) through `getEffectiveRange`, so the page reads the - // resolved band rather than re-deriving one. No target and no height → no - // bands, unchanged. - const overrideRange = thresholds?.effective?.WEIGHT?.isOverride - ? (thresholds.effective.WEIGHT.range ?? null) - : null; - const weightBands = overrideRange - ? buildBandsFromTrafficRange(overrideRange, { + // No target and no height → no bands. + const weightRange = selectWeightBandRange(targets ?? null); + const weightBands = weightRange + ? buildBandsFromTrafficRange(weightRange, { lowerBound: 30, upperBound: 250, }) - : user?.heightCm - ? buildWeightBandsFromHeight(user.heightCm, { - lowerBound: 30, - upperBound: 250, - }) - : undefined; + : undefined; // The bands are kg bounds → scale into the display unit so the shaded zone // tracks the converted line (factor-only; weight carries no offset). const displayBands = diff --git a/src/app/insights/weight/weight-band.ts b/src/app/insights/weight/weight-band.ts new file mode 100644 index 000000000..0c645f8c6 --- /dev/null +++ b/src/app/insights/weight/weight-band.ts @@ -0,0 +1,43 @@ +import { getEffectiveRange } from "@/lib/analytics/effective-range"; +import { + buildWeightRangeFromHeight, + type TrafficRange, +} from "@/lib/analytics/value-bands"; + +/** The slice of `GET /api/insights/targets` the weight band needs. */ +export interface WeightTargetsSlice { + targets: ReadonlyArray<{ + type: string; + range: { min: number; max: number } | null; + source: string; + }>; + profile?: { heightCm: number | null }; +} + +/** + * The weight band for the record on screen, as the server resolved it. + * + * The page used to read the person at the keyboard: their height from + * `useAuth()` and their own target from `/api/user/thresholds`, which + * refuses inside somebody else's record. A parent on a child's record saw a + * band built from the parent's height. `/api/insights/targets` resolves the + * record's own target and height, and the reference panel under the chart + * reads the same payload. The record's own target (`source: "Custom"`) gets + * the wings `getEffectiveRange` gives every override; otherwise the band is + * the height-derived WHO one, and with neither there is none. + */ +export function selectWeightBandRange( + data: WeightTargetsSlice | null, +): TrafficRange | null { + if (!data) return null; + const weight = data.targets.find((entry) => entry.type === "WEIGHT"); + if (weight?.source === "Custom" && weight.range) { + return getEffectiveRange( + "WEIGHT", + { heightCm: null, dateOfBirth: null, gender: null }, + { WEIGHT: weight.range }, + ).range; + } + const heightCm = data.profile?.heightCm ?? null; + return heightCm ? buildWeightRangeFromHeight(heightCm) : null; +} diff --git a/src/components/insights/__tests__/sub-page-shell.test.tsx b/src/components/insights/__tests__/sub-page-shell.test.tsx index 78ad13403..db226e1d0 100644 --- a/src/components/insights/__tests__/sub-page-shell.test.tsx +++ b/src/components/insights/__tests__/sub-page-shell.test.tsx @@ -10,6 +10,12 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/insights/weight", })); +// The target gear asks whether this is somebody else's record; the shell +// renders here without the session it would read that from. +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => ({ inSharedRecord: false }), +})); + /** * v1.4.25 — `` unit tests. * diff --git a/src/components/insights/__tests__/target-adjust-button.test.tsx b/src/components/insights/__tests__/target-adjust-button.test.tsx index 758eb1cce..8a3a3387d 100644 --- a/src/components/insights/__tests__/target-adjust-button.test.tsx +++ b/src/components/insights/__tests__/target-adjust-button.test.tsx @@ -28,6 +28,11 @@ vi.mock("@/lib/insights/target-adjust-context", () => ({ useTargetAdjust: () => useTargetAdjustMock(), })); +const capabilities = { inSharedRecord: false }; +vi.mock("@/hooks/use-record-capabilities", () => ({ + useRecordCapabilities: () => capabilities, +})); + const { TargetAdjustButton } = await import("../target-adjust-button"); function render(node: React.ReactNode, locale: "en" | "de" = "en") { @@ -53,6 +58,22 @@ describe("", () => { expect(html).toBe(""); }); + it("renders nothing inside somebody else's record", () => { + // The editor reads and writes the signed-in account's own targets, which + // the server refuses under a switch: the gear would open a dead sheet. + useTargetAdjustMock.mockReturnValue({ + canAdjust: true, + requestAdjust: vi.fn(), + register: vi.fn(), + }); + capabilities.inSharedRecord = true; + try { + expect(render()).toBe(""); + } finally { + capabilities.inSharedRecord = false; + } + }); + it("renders the gear once an editable target is registered", () => { useTargetAdjustMock.mockReturnValue({ canAdjust: true, diff --git a/src/components/insights/target-adjust-button.tsx b/src/components/insights/target-adjust-button.tsx index bcb8df982..c8bff7a46 100644 --- a/src/components/insights/target-adjust-button.tsx +++ b/src/components/insights/target-adjust-button.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; /** * Header gear that opens the per-metric target editor. @@ -20,6 +21,10 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; * has registered for the page (e.g. a metric without a numeric * target band, where the card paints nothing), so the header never * shows a dead control; + * - renders nothing inside somebody else's record: the editor loads and + * saves the signed-in account's own targets (`/api/user/thresholds`), + * which refuses under a switch, so the gear would open a sheet that + * can do neither; * - opens the primary registered target's `` on click. * * Sized to match `` so the gear + the @@ -29,9 +34,11 @@ import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; export function TargetAdjustButton({ className }: { className?: string }) { const { t } = useTranslations(); const adjust = useTargetAdjust(); + const { inSharedRecord } = useRecordCapabilities(); - // No provider, or no editable target registered for this page. - if (!adjust || !adjust.canAdjust) return null; + // No provider, no editable target registered for this page, or a record + // whose targets this editor cannot reach. + if (!adjust || !adjust.canAdjust || inSharedRecord) return null; // Reuse the action's existing copy ("Adjust target range" / // "Zielbereich anpassen") — the gear performs exactly what the old From 46b1cd6f3444eebbde3d23eb0996630c460916ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:00:38 +0100 Subject: [PATCH 08/12] refactor(insights): classify the derived BMI through classifyBMI The derived BMI card kept its own copy of the WHO cut-offs beside `classifyBMI`, which the targets panel, the status text and the overview use. It now maps that one classifier onto its four-word vocabulary, so a later change to the cut-offs has one place to land. --- .../insights/derived/__tests__/bmi.test.ts | 6 ++++ src/lib/insights/derived/bmi.ts | 31 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/lib/insights/derived/__tests__/bmi.test.ts b/src/lib/insights/derived/__tests__/bmi.test.ts index bc329d599..3e304f452 100644 --- a/src/lib/insights/derived/__tests__/bmi.test.ts +++ b/src/lib/insights/derived/__tests__/bmi.test.ts @@ -27,6 +27,12 @@ describe("classifyBmi (WHO)", () => { it("obese ≥ 30 (red)", () => { expect(classifyBmi(31)).toEqual({ category: "obese", band: "red" }); }); + it("puts each WHO cut-off in the upper category, every obesity grade obese", () => { + expect(classifyBmi(18.5).category).toBe("normal"); + expect(classifyBmi(25).category).toBe("overweight"); + expect(classifyBmi(30).category).toBe("obese"); + expect(classifyBmi(45)).toEqual({ category: "obese", band: "red" }); + }); }); describe("computeBmi", () => { diff --git a/src/lib/insights/derived/bmi.ts b/src/lib/insights/derived/bmi.ts index 0766f01e8..51ce69881 100644 --- a/src/lib/insights/derived/bmi.ts +++ b/src/lib/insights/derived/bmi.ts @@ -25,6 +25,10 @@ */ import type { MeasurementType } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +import { + classifyBMI, + type BmiClassification, +} from "@/lib/analytics/classifications"; import { buildInsufficient, buildOk, @@ -63,15 +67,32 @@ export interface BmiValue { series: number[]; } -/** WHO category + band for a BMI value. Pure. */ +const CATEGORY_OF: Readonly> = { + Underweight: "underweight", + Normal: "normal", + Overweight: "overweight", +}; + +const BAND_OF: Readonly> = { + normal: "green", + warning: "yellow", + danger: "red", +}; + +/** + * WHO category + band for a BMI value, in this payload's vocabulary. The + * cut-offs are `classifyBMI`'s, so the derived card and the targets panel + * cannot disagree about where a reading falls. Pure. + */ export function classifyBmi(bmi: number): { category: BmiCategory; band: BmiBand; } { - if (bmi < 18.5) return { category: "underweight", band: "yellow" }; - if (bmi < 25) return { category: "normal", band: "green" }; - if (bmi < 30) return { category: "overweight", band: "yellow" }; - return { category: "obese", band: "red" }; + const { category, severity } = classifyBMI(bmi); + return { + category: CATEGORY_OF[category] ?? "obese", + band: BAND_OF[severity], + }; } /** From 02856e7f37585fd05f60117ab7edd2db19ef3576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:00:39 +0100 Subject: [PATCH 09/12] fix(targets): accept a child's weight target below 30 kg A weight target was refused below 30 kg on every save path (the own targets editor and a managed record's settings parse the same schema), while a weight reading is accepted from 1 kg. A guardian setting 20 to 23 kg for a child got only "please check your entries". The target floor is now the measurement floor. The weight bands on the dashboard and the weight page started their low red zone at a hard-coded 30 kg, so a target below it lost that zone; the edge now follows the same floor. Bands are clipped to the chart's own domain, so an adult's chart looks the same. `buildWeightBandsFromHeight` had no caller left and goes; the band builder's 30/250 defaults, which every caller overrode, go with it. The unit-adapter test keeps its inward-rounding check at the new floor (1 kg is 2.2046 lb, offered as 2.3). --- .../display-transform-rounding-guard.test.ts | 5 +-- .../user/thresholds/__tests__/route.test.ts | 2 +- src/app/insights/weight/page.tsx | 3 +- .../__tests__/effective-range.test.ts | 36 +++++++++++++++++++ src/lib/analytics/effective-range.ts | 6 +++- src/lib/analytics/value-bands.ts | 15 ++------ .../dashboard/__tests__/target-bands.test.ts | 7 ++-- src/lib/dashboard/bands.ts | 3 +- .../__tests__/target-unit-display.test.ts | 10 +++--- src/lib/targets/target-unit-display.ts | 6 ++-- 10 files changed, 63 insertions(+), 30 deletions(-) diff --git a/src/__tests__/display-transform-rounding-guard.test.ts b/src/__tests__/display-transform-rounding-guard.test.ts index f6d32f37a..8d0a4a5fd 100644 --- a/src/__tests__/display-transform-rounding-guard.test.ts +++ b/src/__tests__/display-transform-rounding-guard.test.ts @@ -45,8 +45,9 @@ const REGISTRY = "lib/measurements/display-transform.ts"; /** * The one module allowed to import the unrounded escape hatch. The target * adapter needs the unrounded pound edge of a canonical guardrail so it can - * round it INWARD — rounding to the nearest tenth first gives 66.1 lb for the - * 30 kg floor, which inverts to 29.98 kg and the server rejects it. + * round it INWARD — rounding to the nearest tenth first can put the pound + * edge outside the kilogram window, and the server rejects what converts + * back from it. */ const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"]; diff --git a/src/app/api/user/thresholds/__tests__/route.test.ts b/src/app/api/user/thresholds/__tests__/route.test.ts index c7865e32f..6a3468a1e 100644 --- a/src/app/api/user/thresholds/__tests__/route.test.ts +++ b/src/app/api/user/thresholds/__tests__/route.test.ts @@ -96,7 +96,7 @@ beforeEach(() => { describe("PUT /api/user/thresholds — 422 multi-issue (v1.4.43 W6)", () => { it("surfaces TWO simultaneous validation errors", async () => { - // WEIGHT bounds are min=30, max=300. Sending min > max + a value + // WEIGHT bounds are min=1, max=300. Sending min > max + a value // out of bounds on PULSE forces two range errors. const res = await PUT( putReq({ diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index b6685072a..75101098f 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -23,6 +23,7 @@ import { MetricCorrelationCard } from "@/components/insights/metric-correlation- import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { buildBandsFromTrafficRange } from "@/lib/analytics/value-bands"; import { selectWeightBandRange, type WeightTargetsSlice } from "./weight-band"; @@ -131,7 +132,7 @@ export default function InsightsGewichtPage() { const weightRange = selectWeightBandRange(targets ?? null); const weightBands = weightRange ? buildBandsFromTrafficRange(weightRange, { - lowerBound: 30, + lowerBound: METRIC_BOUNDS.WEIGHT.min, upperBound: 250, }) : undefined; diff --git a/src/lib/analytics/__tests__/effective-range.test.ts b/src/lib/analytics/__tests__/effective-range.test.ts index 863f00cd0..2650d4f93 100644 --- a/src/lib/analytics/__tests__/effective-range.test.ts +++ b/src/lib/analytics/__tests__/effective-range.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { thresholdsUpdateSchema } from "@/lib/validations/thresholds"; +import { buildBandsFromTrafficRange } from "@/lib/analytics/value-bands"; import { getEffectiveRange, getAllEffectiveRanges, @@ -313,3 +315,37 @@ describe("resolveWeightTargetOverride", () => { expect(resolveWeightTargetOverride(undefined)).toBeNull(); }); }); + +describe("a child's weight target", () => { + // A guardian keeping a record for a six-year-old sets 20 to 23 kg. The old + // 30 kg floor refused it with nothing but "please check your entries", and + // the chart's hard-coded 30 kg lower edge dropped the low red zone. + const child = { WEIGHT: { min: 20, max: 23 } }; + + it("is accepted by the same schema both save paths parse", () => { + expect(thresholdsUpdateSchema.safeParse(child).success).toBe(true); + expect( + thresholdsUpdateSchema.safeParse({ WEIGHT: { min: 0.5, max: 3 } }) + .success, + ).toBe(false); + }); + + it("paints every band below and above the target", () => { + const range = getEffectiveRange( + "WEIGHT", + { heightCm: 118, dateOfBirth: null, gender: null }, + child, + ).range!; + const bands = buildBandsFromTrafficRange(range, { + lowerBound: METRIC_BOUNDS.WEIGHT.min, + upperBound: 250, + }); + expect(bands.map((band) => band.color)).toEqual([ + "var(--destructive)", + "var(--warning)", + "var(--success)", + "var(--warning)", + "var(--destructive)", + ]); + }); +}); diff --git a/src/lib/analytics/effective-range.ts b/src/lib/analytics/effective-range.ts index 38f02113b..0e00c6a87 100644 --- a/src/lib/analytics/effective-range.ts +++ b/src/lib/analytics/effective-range.ts @@ -15,6 +15,7 @@ * consistently. */ +import { VALUE_RANGES } from "@/lib/measurements/value-ranges"; import { binaryReferenceSex } from "@/lib/profile/sex"; import { getBpTargets, type BpTargets } from "./bp-targets"; import { @@ -65,7 +66,10 @@ export const METRIC_BOUNDS: Record< ThresholdMetric, { min: number; max: number; unit: string } > = { - WEIGHT: { min: 30, max: 300, unit: "kg" }, + // The floor is the measurement floor: a target refused for a weight the + // record can hold (a child's 20 kg under a 30 kg floor) left a guardian + // with nothing but "please check your entries". + WEIGHT: { min: VALUE_RANGES.WEIGHT.min, max: 300, unit: "kg" }, BLOOD_PRESSURE_SYS: { min: 80, max: 220, unit: "mmHg" }, BLOOD_PRESSURE_DIA: { min: 40, max: 140, unit: "mmHg" }, PULSE: { min: 30, max: 220, unit: "bpm" }, diff --git a/src/lib/analytics/value-bands.ts b/src/lib/analytics/value-bands.ts index 4f7cf68db..2d34febd8 100644 --- a/src/lib/analytics/value-bands.ts +++ b/src/lib/analytics/value-bands.ts @@ -100,10 +100,9 @@ export function buildWeightRangeFromHeight(heightCm: number): TrafficRange { */ export function buildBandsFromTrafficRange( range: TrafficRange, - options: { lowerBound?: number; upperBound?: number } = {}, + options: { lowerBound: number; upperBound: number }, ): ValueBand[] { - const lowerBound = options.lowerBound ?? 30; - const upperBound = options.upperBound ?? 250; + const { lowerBound, upperBound } = options; return [ { @@ -139,16 +138,6 @@ export function buildBandsFromTrafficRange( ].filter((band) => band.max > band.min); } -export function buildWeightBandsFromHeight( - heightCm: number, - options: { lowerBound?: number; upperBound?: number } = {}, -): ValueBand[] { - return buildBandsFromTrafficRange( - buildWeightRangeFromHeight(heightCm), - options, - ); -} - /** * Body-fat target range — the "green" band shown on the dashboard / * targets page / chart value-bands. diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts index a60b3eb36..1883e175d 100644 --- a/src/lib/dashboard/__tests__/target-bands.test.ts +++ b/src/lib/dashboard/__tests__/target-bands.test.ts @@ -10,10 +10,11 @@ import { describe, it, expect } from "vitest"; import { buildTargetBands } from "../snapshot"; import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { buildTrafficLightBands, buildTrafficRange, - buildWeightBandsFromHeight, + buildBandsFromTrafficRange, buildWeightRangeFromHeight, getBodyFatTargetRange, } from "@/lib/analytics/value-bands"; @@ -38,8 +39,8 @@ function clientBands(profile: { ? buildWeightRangeFromHeight(profile.heightCm) : null; const weightBands = profile.heightCm - ? buildWeightBandsFromHeight(profile.heightCm, { - lowerBound: 30, + ? buildBandsFromTrafficRange(buildWeightRangeFromHeight(profile.heightCm), { + lowerBound: METRIC_BOUNDS.WEIGHT.min, upperBound: 250, }) : null; diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts index 1bfe1649d..5f03dacab 100644 --- a/src/lib/dashboard/bands.ts +++ b/src/lib/dashboard/bands.ts @@ -32,6 +32,7 @@ import { import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex"; import { getEffectiveRange, + METRIC_BOUNDS, resolveEffectiveBpTargets, resolveThresholdOverride, type ThresholdMetric, @@ -136,7 +137,7 @@ export function buildDashboardBands(profile: { (profile.heightCm ? buildWeightRangeFromHeight(profile.heightCm) : null); const weightBands = weightRange ? buildBandsFromTrafficRange(weightRange, { - lowerBound: 30, + lowerBound: METRIC_BOUNDS.WEIGHT.min, upperBound: 250, }) : null; diff --git a/src/lib/targets/__tests__/target-unit-display.test.ts b/src/lib/targets/__tests__/target-unit-display.test.ts index 3d2fa1522..590ce8063 100644 --- a/src/lib/targets/__tests__/target-unit-display.test.ts +++ b/src/lib/targets/__tests__/target-unit-display.test.ts @@ -28,7 +28,7 @@ describe("resolveTargetUnitAdapter — metric account (identity)", () => { expect(units.toCanonical(value)).toBe(value); expect(units.toDisplayDelta(value)).toBe(value); } - expect(units.bounds(METRIC_BOUNDS.WEIGHT)).toEqual({ min: 30, max: 300 }); + expect(units.bounds(METRIC_BOUNDS.WEIGHT)).toEqual({ min: 1, max: 300 }); expect(units.step).toBe(0.1); }); @@ -115,9 +115,9 @@ describe("resolveTargetUnitAdapter — imperial weight class", () => { it("rounds guardrails inward so a value typed at the limit still saves", () => { const bounds = units.bounds(METRIC_BOUNDS.WEIGHT); - // A naive round of the 30 kg floor gives 66.1 lb, which inverts to - // 29.98 kg and the server rejects it. The ceiling keeps it legal. - expect(bounds.min).toBe(66.2); + // The 1 kg floor is 2.2046 lb: the floor rounds up to 2.3, never down, + // so a value typed at the limit converts back inside the window. + expect(bounds.min).toBe(2.3); expect(bounds.max).toBe(661.3); expect(units.toCanonical(bounds.min)).toBeGreaterThanOrEqual( METRIC_BOUNDS.WEIGHT.min, @@ -144,7 +144,7 @@ describe("resolveTargetUnitAdapter — imperial weight class", () => { it("still rejects a genuinely out-of-range value", () => { const bounds = units.bounds(METRIC_BOUNDS.WEIGHT); - for (const typed of [12, 66.1, 661.4, 900]) { + for (const typed of [0.5, 2.2, 661.4, 900]) { expect(typed < bounds.min || typed > bounds.max).toBe(true); } }); diff --git a/src/lib/targets/target-unit-display.ts b/src/lib/targets/target-unit-display.ts index 0afb78a7d..e1d3fd6c6 100644 --- a/src/lib/targets/target-unit-display.ts +++ b/src/lib/targets/target-unit-display.ts @@ -27,7 +27,7 @@ * 68.04 kg, not 68.03885550000001. * 3. Rules 1 + 2 compose back to the typed number: 150 lb → 68.04 kg * → 150 lb. Save-then-reopen is stable. - * 4. Guardrails round INWARD (min up, max down). A naive round of the + * 4. Guardrails round INWARD (min up, max down). A naive round of a * 30 kg floor gives 66.1 lb, which inverts to 29.98 kg and the * server rejects it. Ceiling it to 66.2 lb inverts to 30.03 kg and * passes. @@ -205,8 +205,8 @@ export function resolveTargetUnitAdapter( applyDisplayTransformDelta(canonicalDelta, transform), // A guardrail is not a rendered value — it is the window a typed // number is checked against, and it has to round INWARD (rule 4). - // That needs the UNROUNDED edge: the shared helper would round 30 kg - // to 66.1 lb first and the ceiling below would then be a no-op on an + // That needs the UNROUNDED edge: the shared helper would round a 30 kg + // floor to 66.1 lb first and the ceiling below would then be a no-op on an // edge that already inverts to 29.98 kg. bounds: (canonical) => ({ min: ceilTo( From 3fcfe1169467dbfbd7849aac74d80603808bd513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:02:03 +0100 Subject: [PATCH 10/12] fix(cycle): date today in the profile zone The cycle page, its ring and the summary card took "today" and their read window from the device clock, while the server keys every cycle day, today included, in the profile zone. With a phone in another zone the highlighted day and the day "log today" writes to sat one apart from the rest of the app around midnight. One `useCycleWindow()` now dates today in the display zone and derives the shared window from it; it replaces `localYmd` and its two copies. --- .../__tests__/cycle-view-error-state.test.tsx | 6 ++- .../cycle-view-sheet-context.test.tsx | 2 +- .../use-cycle-ring-has-dial.test.tsx | 12 +++--- .../cycle/__tests__/use-cycle-window.test.tsx | 33 ++++++++++++++ .../cycle/cycle-insight-summary-card.tsx | 17 +++----- src/components/cycle/cycle-view.tsx | 13 +----- src/components/cycle/use-cycle.ts | 43 +++++++++---------- 7 files changed, 73 insertions(+), 53 deletions(-) create mode 100644 src/components/cycle/__tests__/use-cycle-window.test.tsx diff --git a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx index cedad4149..63602a290 100644 --- a/src/components/cycle/__tests__/cycle-view-error-state.test.tsx +++ b/src/components/cycle/__tests__/cycle-view-error-state.test.tsx @@ -42,7 +42,11 @@ const idleQuery = { const mutationStub = { mutate: vi.fn(), isPending: false, isError: false }; vi.mock("../use-cycle", () => ({ - localYmd: () => "2026-07-01", + useCycleWindow: () => ({ + today: "2026-07-01", + from: "2026-04-02", + to: "2026-12-28", + }), useCycleCalendar: () => errorQuery, useCycleHistory: () => idleQuery, useCycleInsights: () => idleQuery, diff --git a/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx index f727d776e..89c58407a 100644 --- a/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx +++ b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx @@ -95,7 +95,7 @@ const idleQuery = { const mutationStub = { mutate: vi.fn(), isPending: false, isError: false }; vi.mock("../use-cycle", () => ({ - localYmd: () => TODAY, + useCycleWindow: () => ({ today: TODAY, from: TODAY, to: TODAY }), useCycleCalendar: () => calendarQuery, useCycleHistory: () => idleQuery, useCycleInsights: () => idleQuery, diff --git a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx index 4b872315e..0ff861570 100644 --- a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx +++ b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx @@ -16,14 +16,14 @@ import { describe, expect, it } from "vitest"; import { ApiError } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; -import { localYmd, useCycleRingDial } from "../use-cycle"; +import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format"; +import { useCycleRingDial } from "../use-cycle"; + +/** The ring's window, around today in the display zone (Berlin when unset). */ function window(): [string, string] { - const from = new Date(); - from.setDate(from.getDate() - 90); - const to = new Date(); - to.setDate(to.getDate() + 180); - return [localYmd(from), localYmd(to)]; + const today = userDayKey(new Date(), DEFAULT_TIMEZONE); + return [shiftDateKey(today, -90), shiftDateKey(today, 180)]; } function Probe({ enabled }: { enabled: boolean }) { diff --git a/src/components/cycle/__tests__/use-cycle-window.test.tsx b/src/components/cycle/__tests__/use-cycle-window.test.tsx new file mode 100644 index 000000000..225a5ea4e --- /dev/null +++ b/src/components/cycle/__tests__/use-cycle-window.test.tsx @@ -0,0 +1,33 @@ +/** + * The cycle surfaces take "today" from the record's display zone, the zone + * the server keys cycle days in. They used to read the device clock, so with + * a phone in another zone than the profile the highlighted day and the day a + * log lands on sat one apart from the server's around midnight. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@/lib/i18n/context", async (importOriginal) => ({ + ...(await importOriginal()), + // UTC+14: already the next calendar day for most of the world's afternoon. + useDisplayTimezone: () => "Pacific/Kiritimati", +})); + +import { useCycleWindow } from "../use-cycle"; + +function Probe() { + const { today, from, to } = useCycleWindow(); + return {`${from}|${today}|${to}`}; +} + +afterEach(() => vi.useRealTimers()); + +describe("useCycleWindow", () => { + it("dates today in the display zone, not on the device clock", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-07-01T12:00:00.000Z")); + expect(renderToStaticMarkup()).toContain( + "2026-04-03|2026-07-02|2026-12-29", + ); + }); +}); diff --git a/src/components/cycle/cycle-insight-summary-card.tsx b/src/components/cycle/cycle-insight-summary-card.tsx index ee1b2e0c1..8d620d7f4 100644 --- a/src/components/cycle/cycle-insight-summary-card.tsx +++ b/src/components/cycle/cycle-insight-summary-card.tsx @@ -1,6 +1,5 @@ "use client"; -import { useMemo } from "react"; import Link from "next/link"; import { ArrowRight, Droplets } from "lucide-react"; @@ -8,7 +7,11 @@ import { useTranslations } from "@/lib/i18n/context"; import { SectionHeading } from "@/components/ui/section-heading"; import { CyclePhaseHeadline } from "./cycle-phase-crosstab"; import { PHASE_HUE } from "./phase-tokens"; -import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle"; +import { + useCycleCalendar, + useCycleInsights, + useCycleWindow, +} from "./use-cycle"; import { QueryErrorRow } from "@/components/ui/query-error-row"; /** @@ -37,20 +40,12 @@ import { QueryErrorRow } from "@/components/ui/query-error-row"; * a broken or empty-framed state on the overview. */ -/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */ -function shiftToday(days: number): string { - const d = new Date(); - d.setDate(d.getDate() + days); - return localYmd(d); -} - export function CycleInsightSummaryCard() { const { t } = useTranslations(); // Mirror the cycle-view window so the cache key is shared (no extra request // when the user later opens the cycle page in the same session). - const from = useMemo(() => shiftToday(-90), []); - const to = useMemo(() => shiftToday(180), []); + const { from, to } = useCycleWindow(); const calendar = useCycleCalendar(from, to); const insights = useCycleInsights(); diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 586fe4b14..b748a1a8b 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -27,7 +27,7 @@ import { CycleSettingsTab } from "./cycle-settings-tab"; import { PHASE_HUE } from "./phase-tokens"; import { monthOf, monthWindowOutside } from "./calendar-window"; import { - localYmd, + useCycleWindow, useCycleCalendar, useCycleHistory, useCycleInsights, @@ -50,13 +50,6 @@ import { ListCapNotice } from "@/components/ui/list-cap-notice"; * re-triggers the moment. */ -/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */ -function shiftToday(days: number): string { - const d = new Date(); - d.setDate(d.getDate() + days); - return localYmd(d); -} - /** The four cycle tabs, in render order. The deep-link value is validated * against this set so a junk `?tab=` falls back to the calendar tab. */ // The BBT chart is the only recharts consumer on /cycle — load it through @@ -114,9 +107,7 @@ export function CycleView() { // a later param change does not force-switch a tab the user is reading). const [tab, setTab] = useState(initialTab); - const today = useMemo(() => shiftToday(0), []); - const from = useMemo(() => shiftToday(-90), []); - const to = useMemo(() => shiftToday(180), []); + const { today, from, to } = useCycleWindow(); const calendar = useCycleCalendar(from, to); const history = useCycleHistory(); diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index cbe9afe92..0707607ca 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -21,7 +21,8 @@ import { apiPatch, apiPost, } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context"; +import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { randomId } from "@/lib/random-id"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { useCalendarDate } from "@/hooks/use-calendar-date"; @@ -72,16 +73,23 @@ export interface CycleInsightsResponse { } /** - * The local-timezone `YYYY-MM-DD` for a Date (default: now). Cycle day-keys - * are tz-anchored on the server; the client must derive the key from the - * user's wall-clock day, never from `toISOString()` (which is UTC and rolls - * the date over near midnight). + * Today, and the window the cycle page, its ring and its summary card read + * (90 days back, 180 ahead), in the record's display zone. The server keys + * cycle days, "today" included, in the profile zone; the device clock put + * the highlighted day and the day a log lands on one apart from it when a + * phone sat in another zone near midnight. One window, so the three readers + * share one cache entry. */ -export function localYmd(d: Date = new Date()): string { - const y = d.getFullYear(); - const m = String(d.getMonth() + 1).padStart(2, "0"); - const day = String(d.getDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; +export function useCycleWindow(): { today: string; from: string; to: string } { + const timeZone = useDisplayTimezone(); + return useMemo(() => { + const today = userDayKey(new Date(), timeZone); + return { + today, + from: shiftDateKey(today, -90), + to: shiftDateKey(today, 180), + }; + }, [timeZone]); } /** @@ -115,20 +123,9 @@ export function useCycleCalendar(from: string, to: string, enabled = true) { }); } -/** YYYY-MM-DD for `days` from today in the local zone. */ -function localYmdFromToday(days: number): string { - const d = new Date(); - d.setDate(d.getDate() + days); - return localYmd(d); -} - -/** - * The calendar window the cycle ring reads: the cycle page's own window, so - * the ring, the page and the summary card share one cache entry. - */ +/** The calendar window the cycle ring reads: the cycle page's own. */ export function useCycleRingCalendar(enabled = true) { - const from = useMemo(() => localYmdFromToday(-90), []); - const to = useMemo(() => localYmdFromToday(180), []); + const { from, to } = useCycleWindow(); return useCycleCalendar(from, to, enabled); } From bdfecfa70e2885ac52f90623e5f7effb2b8fc47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:03:46 +0100 Subject: [PATCH 11/12] test(e2e): build the mock weight bands with the server's helpers The dashboard snapshot mock still called `buildWeightBandsFromHeight`, which went with the weight floor change, and kept the old 30 kg lower edge. It now builds the bands the way `buildTargetBands` does. --- e2e/utils/mock-dashboard-snapshot.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index c8df7de9c..8e3ae7574 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -13,10 +13,11 @@ import type { DashboardTargetBands, } from "@/lib/dashboard/snapshot"; import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { buildTrafficLightBands, buildTrafficRange, - buildWeightBandsFromHeight, + buildBandsFromTrafficRange, buildWeightRangeFromHeight, getBodyFatTargetRange, } from "@/lib/analytics/value-bands"; @@ -77,8 +78,8 @@ function mockTargetBands(): DashboardTargetBands { upperBound: 55, }), weightRange: buildWeightRangeFromHeight(180), - weightBands: buildWeightBandsFromHeight(180, { - lowerBound: 30, + weightBands: buildBandsFromTrafficRange(buildWeightRangeFromHeight(180), { + lowerBound: METRIC_BOUNDS.WEIGHT.min, upperBound: 250, }), }; From da671f74aa40ddf9773af498724aa15f9b9d0a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:12:44 +0100 Subject: [PATCH 12/12] refactor(day): share one window for date-only columns The day view, the day index and the visit preparation each built the UTC calendar window for a date-only column by hand (documents had a fourth copy). `dateOnlyWindow()` beside the other date-only helpers replaces them. The report name select becomes one spread, the share page builds its calendar formatter outside the view, and the PDF section reads it off the context, so the functions these fixes touched stay within their size budget; `DocumentRecordLinks` drops under it. --- .../baselines/source-function-lines.json | 5 ++- src/components/clinician/clinician-view.tsx | 17 ++++++---- .../documents/document-record-links.tsx | 10 ++---- src/lib/account-name.ts | 8 +++++ .../day/__tests__/date-only-sections.test.ts | 11 +++++-- src/lib/day/day-index.ts | 17 +++++----- src/lib/day/records.ts | 31 ++++++------------- src/lib/day/visit-preparation.ts | 13 ++++---- .../clinical-records-notes-section.ts | 16 ++-------- src/lib/doctor-report/collect.ts | 7 ++--- src/lib/tz/__tests__/date-only.test.ts | 13 ++++++++ src/lib/tz/date-only.ts | 16 ++++++++++ 12 files changed, 90 insertions(+), 74 deletions(-) diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index dd83b4bdc..7861139a9 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 344, - "total": 111520, + "count": 343, + "total": 111318, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -94,7 +94,6 @@ "components/documents/document-facts-review.tsx :: DocumentFactsSection": 242, "components/documents/document-facts-review.tsx :: FactRow": 261, "components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348, - "components/documents/document-record-links.tsx :: DocumentRecordLinks": 202, "components/documents/document-timeline.tsx :: DocumentTimeline": 314, "components/documents/documents-view.tsx :: DocumentsView": 303, "components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563, diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 5480c050d..43be0b2a6 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -134,6 +134,15 @@ interface ClinicianViewProps { dateFormat: DateFormatPreference; } +/** + * A date-only value (a date of birth, a dose date) as its calendar date in + * the owner's date order, the same in every zone. Read as an instant in the + * owner's zone it fell on the previous day west of UTC. + */ +function calendarDateIn(dateFormat: DateFormatPreference, locale: Locale) { + return (iso: string) => formatDate(iso.slice(0, 10), dateFormat, locale); +} + export function ClinicianView({ t, label, @@ -154,10 +163,6 @@ export function ClinicianView({ // Owner hour cycle + date order ride along (issue #922). const fmt = makeFormatters(locale, timezone, timeFormat, dateFormat); const fmtDate = (iso: string) => fmt.date(new Date(iso)); - // A date-only value (a date of birth, a dose date) is its calendar date in - // every zone; read as an instant it fell on the previous day west of UTC. - const fmtCalendarDate = (iso: string) => - formatDate(iso.slice(0, 10), dateFormat, locale); const fmtDateTime = (iso: string) => fmt.dateTime(new Date(iso)); // Two decimals at most, in the owner's locale: a German reader gets // "80,5", not the "80.5" a bare number prints. @@ -204,7 +209,7 @@ export function ClinicianView({ t={t} report={report} scope={scope} - fmtCalendarDate={fmtCalendarDate} + fmtCalendarDate={calendarDateIn(dateFormat, locale)} /> {/* ── measurements ─────────────────────────────────────── */} @@ -303,7 +308,7 @@ export function ClinicianView({ t={t} report={report} scope={scope} - fmtCalendarDate={fmtCalendarDate} + fmtCalendarDate={calendarDateIn(dateFormat, locale)} /> diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index fcfce50e6..c4de521b8 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -188,10 +188,7 @@ export function DocumentRecordLinks({ const [visitIds, setVisitIds] = useState(null); const [doseIds, setDoseIds] = useState(null); - const labels = { - suggested: t("links.picker.suggested"), - date: (iso: string) => format.date(iso), - }; + const suggested = t("links.picker.suggested"); const visitOptions = recordOptions( [...encounters.items] @@ -205,9 +202,8 @@ export function DocumentRecordLinks({ href: visitHref(visit.id), })), anchor, - labels, + { suggested, date: format.date }, ); - // A dose date is a calendar date at UTC midnight, never an instant. const doseOptions = recordOptions( (vaccinations.data?.vaccinations ?? []).map((dose) => ({ id: dose.id, @@ -216,7 +212,7 @@ export function DocumentRecordLinks({ href: doseHref(dose.id), })), anchor, - { ...labels, date: calendarDate }, + { suggested, date: calendarDate }, // a dose date is a calendar date ); const showDoses = vaccinationsOn && doc.vaccinationLinks !== null; diff --git a/src/lib/account-name.ts b/src/lib/account-name.ts index 7e39c7319..b02c371ab 100644 --- a/src/lib/account-name.ts +++ b/src/lib/account-name.ts @@ -12,6 +12,14 @@ export function accountName(user: { return displayName ? displayName : user.username; } +/** The columns `patientName()` reads, for a Prisma `select`. */ +export const PATIENT_NAME_SELECT = { + username: true, + displayName: true, + fullName: true, + managedProfileAt: true, +} as const; + /** * The name a clinical document gives a person: the full name when one is * set, the display name next, the login last. A managed record's login is a diff --git a/src/lib/day/__tests__/date-only-sections.test.ts b/src/lib/day/__tests__/date-only-sections.test.ts index 90b22fb76..1c36c036e 100644 --- a/src/lib/day/__tests__/date-only-sections.test.ts +++ b/src/lib/day/__tests__/date-only-sections.test.ts @@ -55,7 +55,8 @@ import { loadDayIndex } from "@/lib/day/day-index"; import { loadSinceLastVisit } from "@/lib/day/visit-preparation"; import type { DaySectionKey } from "@/lib/day/contract"; import type { DayAccess } from "@/lib/day/sections"; -import { LANE_READERS } from "@/lib/timeline/lanes"; +import { LANE_READERS, type TimelineAccess } from "@/lib/timeline/lanes"; +import { MODULE_KEYS } from "@/lib/modules/gate"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { shiftDateKey } from "@/lib/tz/format"; @@ -167,7 +168,13 @@ describe("the timeline", () => { const laneFrame = { userId: "u1", tz: TZ, - access: {} as never, + access: { + modules: Object.fromEntries( + MODULE_KEYS.map((key) => [key, true]), + ) as TimelineAccess["modules"], + domainVisible: () => true, + owner: true, + }, }; const doses = await LANE_READERS.vaccinations(laneFrame); const allergies = await LANE_READERS.allergies(laneFrame); diff --git a/src/lib/day/day-index.ts b/src/lib/day/day-index.ts index 28ae9d84d..7773c2379 100644 --- a/src/lib/day/day-index.ts +++ b/src/lib/day/day-index.ts @@ -33,7 +33,11 @@ import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health"; import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; import { effectiveMoodTz } from "@/lib/mood/date-key"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; -import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { + dateOnlyKey, + dateOnlyWindow, + dayKeyAsUtcMidnight, +} from "@/lib/tz/date-only"; import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; @@ -102,15 +106,8 @@ const instantWindow = (frame: IndexFrame) => ({ lt: frame.end, }); -/** - * The window as UTC calendar days, for a column that holds a date rather - * than an instant (a dose date at UTC midnight, an allergy onset or a - * document date at noon UTC). Its date part is its day in every zone. - */ -const calendarWindow = (frame: IndexFrame) => ({ - gte: dayKeyAsUtcMidnight(frame.from), - lt: dayKeyAsUtcMidnight(shiftDateKey(frame.to, 1)), -}); +const calendarWindow = (frame: IndexFrame) => + dateOnlyWindow(frame.from, frame.to); /** Mark the calendar date of every date-only value. */ function markDates( diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 139cdd416..a41d23bac 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -39,7 +39,11 @@ import { phaseForDate } from "@/lib/cycle/engine-adapter"; import { LUTEAL_DEFAULT } from "@/lib/cycle/types"; import { resolveCycleDay } from "@/lib/cycle/verdict"; import { effectiveMoodTz } from "@/lib/mood/date-key"; -import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { + dateOnlyKey, + dateOnlyWindow, + dayKeyAsUtcMidnight, +} from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { visitPath } from "@/lib/encounters/visit-links"; import { @@ -130,19 +134,6 @@ function dateKeyOrNull(value: Date | null): string | null { return value ? dateOnlyKey(value) : null; } -/** - * The day as a window over a column that holds a date, not an instant (a - * dose date at UTC midnight, an allergy onset or a document date at noon - * UTC). Its date part decides the day, whatever the person's zone; the - * local day window put a UTC-midnight dose on the previous day west of UTC. - */ -function calendarDayWindow(day: string): { gte: Date; lt: Date } { - return { - gte: dayKeyAsUtcMidnight(day), - lt: dayKeyAsUtcMidnight(shiftDateKey(day, 1)), - }; -} - /* ─── medications ─────────────────────────────────────────────────────────── */ async function medicationsPart(frame: DayFrame): Promise { @@ -591,7 +582,7 @@ async function allergiesPart(frame: DayFrame): Promise { where: { userId: frame.userId, deletedAt: null, - onsetAt: calendarDayWindow(frame.day), + onsetAt: dateOnlyWindow(frame.day), }, select: { id: true, substance: true, severity: true }, }); @@ -599,8 +590,7 @@ async function allergiesPart(frame: DayFrame): Promise { running: [], events: rows.map((row) => event({ - // A date without a time of day: no clock time to print. - at: null, + at: null, // a date without a time of day kind: "allergyOnset", section: "allergies", id: row.id, @@ -662,7 +652,7 @@ async function vaccinationsPart(frame: DayFrame): Promise { where: { userId: frame.userId, deletedAt: null, - occurredAt: calendarDayWindow(frame.day), + occurredAt: dateOnlyWindow(frame.day), }, select: { id: true, @@ -683,8 +673,7 @@ async function vaccinationsPart(frame: DayFrame): Promise { running: [], events: rows.map((row) => event({ - // A dose carries a date, never a time of day. - at: null, + at: null, // a date without a time of day kind: "vaccination", section: "vaccinations", id: row.id, @@ -776,7 +765,7 @@ async function checkupsPart(frame: DayFrame): Promise { async function documentsPart(frame: DayFrame): Promise { // A document's date is a calendar date stored at noon UTC (older rows at // UTC midnight). The stated report date wins over the filing date. - const day = calendarDayWindow(frame.day); + const day = dateOnlyWindow(frame.day); const rows = await prisma.inboundDocument.findMany({ where: { userId: frame.userId, diff --git a/src/lib/day/visit-preparation.ts b/src/lib/day/visit-preparation.ts index 533f530d9..7c83d7a0f 100644 --- a/src/lib/day/visit-preparation.ts +++ b/src/lib/day/visit-preparation.ts @@ -23,7 +23,11 @@ import { openText } from "@/lib/day/records"; import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections"; import { prisma } from "@/lib/db"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; -import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { + dateOnlyKey, + dateOnlyWindow, + dayKeyAsUtcMidnight, +} from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { visitPath } from "@/lib/encounters/visit-links"; @@ -240,12 +244,7 @@ async function vaccinationChanges(w: Window): Promise { where: { userId: w.userId, deletedAt: null, - // A dose date is stored at UTC midnight: its date part is its day in - // every zone, so the window is UTC calendar days, not local instants. - occurredAt: { - gte: dayKeyAsUtcMidnight(w.from), - lt: dayKeyAsUtcMidnight(shiftDateKey(w.to, 1)), - }, + occurredAt: dateOnlyWindow(w.from, w.to), // a date, not an instant }, select: { id: true, diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index 40c314fd4..32a8d02ab 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -23,17 +23,8 @@ export function buildClinicalRecordsNotesSection( context: DoctorReportPdfRenderContext, state: DoctorReportPdfCursorState, ): DoctorReportPdfCursorState { - const { - doc, - data, - t, - num, - fmtDate, - fmtCalendarDate, - margin, - tableBottomMargin, - ensureSpace, - } = context; + const { doc, data, t, num, fmtDate, margin, tableBottomMargin, ensureSpace } = + context; let y = state.y; // v1.17.1 — structured lab-results section. Populated when the `labs` @@ -431,8 +422,7 @@ export function buildClinicalRecordsNotesSection( }; const immunizationRows = data.immunizations.map((row) => [ - // A dose date is a calendar date at UTC midnight, never an instant. - fmtCalendarDate(row.occurredAt), + context.fmtCalendarDate(row.occurredAt), row.antigenSlug ? t(`vaccinations.catalog.${row.antigenSlug}`) : (row.vaccineName ?? "—"), diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 5d76aafd3..f55d2f66c 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -84,7 +84,7 @@ import { loadLabResults, } from "./clinical-records"; import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; -import { patientName } from "@/lib/account-name"; +import { PATIENT_NAME_SELECT, patientName } from "@/lib/account-name"; import { readingsMean } from "@/lib/measurements/day-mean"; import { loadScreenerUnvalidatedTranslations } from "./screener-translations"; @@ -179,9 +179,7 @@ export async function collectDoctorReportData( const userProfile = await prisma.user.findUnique({ where: { id: userId }, select: { - username: true, - displayName: true, - managedProfileAt: true, + ...PATIENT_NAME_SELECT, dateOfBirth: true, gender: true, heightCm: true, @@ -189,7 +187,6 @@ export async function collectDoctorReportData( thresholdsJson: true, timezone: true, sourcePriorityJson: true, - fullName: true, insurerName: true, insurerIkNumber: true, }, diff --git a/src/lib/tz/__tests__/date-only.test.ts b/src/lib/tz/__tests__/date-only.test.ts index 541fddbce..3aca0df8c 100644 --- a/src/lib/tz/__tests__/date-only.test.ts +++ b/src/lib/tz/__tests__/date-only.test.ts @@ -3,6 +3,7 @@ import { isNoonUtcAnchor, dateOnlyAtNoonUtc, dateOnlyKey, + dateOnlyWindow, dayKeyAsUtcMidnight, isCalendarDateKey, isDateOnlyKey, @@ -60,3 +61,15 @@ describe("isNoonUtcAnchor", () => { expect(isNoonUtcAnchor(new Date("2025-10-04T20:30:00.000Z"))).toBe(false); }); }); + +describe("dateOnlyWindow", () => { + it("spans UTC midnight to the next UTC midnight, both days included", () => { + expect(dateOnlyWindow("2026-11-11")).toEqual({ + gte: new Date("2026-11-11T00:00:00.000Z"), + lt: new Date("2026-11-12T00:00:00.000Z"), + }); + expect(dateOnlyWindow("2026-12-31", "2027-01-01").lt).toEqual( + new Date("2027-01-02T00:00:00.000Z"), + ); + }); +}); diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts index 5018f530a..4a94de2cd 100644 --- a/src/lib/tz/date-only.ts +++ b/src/lib/tz/date-only.ts @@ -98,3 +98,19 @@ export function dateOnlyKey(value: Date): string { export function dayKeyAsUtcMidnight(key: string): Date { return new Date(`${key}T00:00:00.000Z`); } + +/** + * The window over a date-only column that holds the calendar days `fromKey` + * to `toKey`, both included: UTC midnight to the next UTC midnight, so both + * anchors (midnight and noon UTC) fall on their own date. A date-only value + * read through a person's local-day window sat on the evening before west of + * UTC. + */ +export function dateOnlyWindow( + fromKey: string, + toKey: string = fromKey, +): { gte: Date; lt: Date } { + const lt = dayKeyAsUtcMidnight(toKey); + lt.setUTCDate(lt.getUTCDate() + 1); + return { gte: dayKeyAsUtcMidnight(fromKey), lt }; +}