From 29ab27477534a5cca9e05b1fc21a71828ee944d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 24 Aug 2026 01:02:20 +0200 Subject: [PATCH 1/9] fix(i18n): name a visit kind instead of printing its enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three surfaces built the label key by interpolation — the doctor-report PDF's visit table, the daily digest's upcoming-visit line and the clinician share view. `EncounterKind` is `ROUTINE` and the bundle leaf is `routine`, so all three rendered `encounters.kind.ROUTINE` verbatim: into a document a practice files, onto a lock screen, and onto the page. Route them through `encounterKindLabelKey`, a literal switch beside the existing `encounterKindLabel`, so the mapping is written down once. The enum-derived i18n guard could not have caught this: it only knows key spaces of the form `prefix + member`, and this one is not. It gains a second arm for spaces whose mapping is a function, with `encounters.kind` as its first entry. Break-proof: point one arm of the resolver back at the uppercase key and six locale cases go red. --- .../dynamic-key-exhaustiveness.test.ts | 55 +++++++++++++++++++ src/lib/daily/digest.ts | 9 ++- .../clinical-records-notes-section.ts | 7 ++- src/lib/encounters/kind-label.ts | 32 +++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/__tests__/dynamic-key-exhaustiveness.test.ts b/src/__tests__/dynamic-key-exhaustiveness.test.ts index 7b5a539d9..2d9614684 100644 --- a/src/__tests__/dynamic-key-exhaustiveness.test.ts +++ b/src/__tests__/dynamic-key-exhaustiveness.test.ts @@ -18,6 +18,8 @@ import { allergyStatusEnum, } from "@/lib/validations/allergy"; import { familyRelationshipEnum } from "@/lib/validations/family-history"; +import { encounterKindEnum } from "@/lib/validations/encounters"; +import { encounterKindLabelKey } from "@/lib/encounters/kind-label"; import { INSTRUMENTS } from "@/lib/mental-health/instruments"; /** @@ -131,6 +133,32 @@ const REGISTRY: readonly KeySpace[] = [ { prefix: "mentalHealth.instrumentDescription", members: INSTRUMENT_SLUGS }, ]; +/** + * The second arm: key spaces whose member → key mapping is NOT `prefix + + * member`, so the registry above cannot express them. The mapping function + * itself is the source, and the guard runs the enum through it. + * + * `encounters.kind` is why this arm exists. The enum member is `ROUTINE`, the + * bundle leaf is `routine`, and three surfaces — the doctor-report PDF's visit + * table, the daily digest's upcoming-visit line and the clinician share view — + * built the key by interpolation. All three printed `encounters.kind.ROUTINE` + * verbatim: into a clinical document, onto a lock screen, and onto a page a + * doctor opens. The prefix-plus-member form of this guard would have caught it + * on the day the interpolation was written, but only if the mapping had been + * expressible; it is not, so it was never registered and nothing else looked. + */ +const RESOLVED_REGISTRY: readonly { + /** What the space is, for the failure message. */ + name: string; + /** Every key the resolver can return, over the whole source enum. */ + keys: readonly string[]; +}[] = [ + { + name: "encounters.kind via encounterKindLabelKey", + keys: encounterKindEnum.options.map(encounterKindLabelKey), + }, +]; + describe("dynamic-key exhaustiveness (enum-derived)", () => { // Six shipped locales. An empty read of `messages/` would register zero // per-locale cases and the suite would report green over nothing. @@ -158,6 +186,22 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => { } }); + it("every resolver-mapped key space returns a distinct key per member", () => { + for (const space of RESOLVED_REGISTRY) { + expect( + space.keys.length, + `${space.name} resolved to an empty key set — source import broke`, + ).toBeGreaterThan(0); + // A resolver whose default arm swallowed a member would collapse two + // enum values onto one key and the per-locale checks below would still + // pass, because the surviving key resolves fine. + expect( + new Set(space.keys).size, + `${space.name} maps two enum members onto one key`, + ).toBe(space.keys.length); + } + }); + for (const { locale, messages } of LOCALES) { for (const space of REGISTRY) { for (const member of space.members) { @@ -171,5 +215,16 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => { }); } } + for (const space of RESOLVED_REGISTRY) { + for (const key of space.keys) { + it(`resolves ${key} in ${locale} (${space.name})`, () => { + const value = resolveKey(messages, key); + expect(value, `${key} missing in ${locale}.json`).toBeTypeOf( + "string", + ); + expect((value ?? "").trim().length).toBeGreaterThan(0); + }); + } + } } }); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 9d14cc933..f453d1f54 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -19,8 +19,10 @@ * provisional→final refresh (sleep-arrival debounce) is S4's work; it will * populate the same two fields the DTO already carries, so no consumer changes. */ +import type { EncounterKind } from "@/generated/prisma/client"; import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema"; import type { ArrivalKind } from "@/lib/arrivals/types"; +import { encounterKindLabelKey } from "@/lib/encounters/kind-label"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import type { ModuleKey } from "@/lib/modules/registry"; import type { ServerTranslator } from "@/lib/i18n/server-translator"; @@ -560,7 +562,12 @@ function buildUpcomingVisitItem( // Today vs tomorrow is decided on the SAME instants the read window used, so // a visit admitted by the query can never be described as neither. const hoursAway = (at.getTime() - now.getTime()) / (60 * 60 * 1000); - const what = visit.practitionerName ?? t(`encounters.kind.${visit.kind}`); + // Through the key resolver, never `encounters.kind.${kind}`: the enum is + // `ROUTINE` and the bundle leaf is `routine`, so the interpolated form put + // raw dot notation on a lock screen. + const what = + visit.practitionerName ?? + t(encounterKindLabelKey(visit.kind as EncounterKind)); return { kind: "upcoming_visit", title: t("daily.item.upcomingVisit.title"), 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 a082cf282..6c1087ead 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -1,5 +1,7 @@ import type { jsPDF } from "jspdf"; import autoTable from "jspdf-autotable"; +import type { EncounterKind } from "@/generated/prisma/client"; +import { encounterKindLabelKey } from "../encounters/kind-label"; import { classifyReferenceRange, formatReferenceRange, @@ -239,7 +241,10 @@ export function buildClinicalRecordsNotesSection( [visit.practitionerName, visit.practitionerSpecialty] .filter(Boolean) .join(" · ") || "—", - t(`encounters.kind.${visit.kind}`), + // Through the key resolver: the enum is `ROUTINE` and the bundle leaf is + // `routine`, so the interpolated key printed raw dot notation into the + // clinical document. + t(encounterKindLabelKey(visit.kind as EncounterKind)), visit.reason ?? "—", visit.outcome ?? "—", visit.conditionLabels.length > 0 ? visit.conditionLabels.join(", ") : "—", diff --git a/src/lib/encounters/kind-label.ts b/src/lib/encounters/kind-label.ts index ed31a78d2..3e664ad08 100644 --- a/src/lib/encounters/kind-label.ts +++ b/src/lib/encounters/kind-label.ts @@ -45,3 +45,35 @@ export function encounterKindLabel( return t("encounters.kind.other"); } } + +/** + * The same mapping as a KEY, for callers that already hold a translator and + * cannot reach for a second one — the doctor-report PDF, the daily digest and + * the clinician share view. + * + * Those three built the key by interpolation (`encounters.kind.${kind}`), and + * the enum is `ROUTINE` while the bundle leaf is `routine`, so all three + * printed raw dot notation into a clinical document. The interpolated form was + * invisible to `i18n-call-site-coverage`, which is the reason it survived: the + * key it asked for never existed and nothing said so. + */ +export function encounterKindLabelKey(kind: EncounterKind): string { + switch (kind) { + case "ROUTINE": + return "encounters.kind.routine"; + case "ACUTE": + return "encounters.kind.acute"; + case "SPECIALIST": + return "encounters.kind.specialist"; + case "PREVENTIVE": + return "encounters.kind.preventive"; + case "EMERGENCY": + return "encounters.kind.emergency"; + case "HOSPITAL": + return "encounters.kind.hospital"; + case "THERAPY": + return "encounters.kind.therapy"; + default: + return "encounters.kind.other"; + } +} From fbe20815062922ecf84a5dbbfc1359d3a9c41614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 24 Aug 2026 01:02:33 +0200 Subject: [PATCH 2/9] feat(share): carry the owner's module verdict beside the payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A leaf on a share link can be absent from the payload for two reasons that look identical downstream: the owner shared it and recorded nothing, or they shared it and the domain is switched off on their account. The aggregator ANDs the selection and the module map and returns the same null either way, so the recipient could not tell an empty section from one that never had a chance to carry anything. `loadShareViewData` now resolves the whole module map — it needed the `doctorReport` key from it anyway — and returns `unavailableLeaves`: the leaves the link DOES carry whose owning module is off. Only selected leaves appear, because a withheld leaf's module state is not the recipient's business in any direction. The map is deliberately NOT handed to the aggregator. The third argument is the frozen selection and there is no fourth, which is what keeps this surface from growing an options object that widens what it asks for; the resolver memoises its reads per request, so the aggregator resolving its own map again costs no round-trip. The module-gate suite moves off `isModuleEnabled` with it, and gains three cases for the new verdict, including the one that proves an unshared leaf is never named. --- .../__tests__/share-view-data.test.ts | 92 +++++++++++++++++-- src/lib/clinician-share/share-view-data.ts | 48 ++++++++-- 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts index 566c86175..b1535f6e0 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -10,8 +10,13 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; +// The loader resolves the whole module MAP rather than the single +// `doctorReport` key. The per-leaf "shared, but switched off at the source" +// verdicts the clinician view renders come off that same read: derived from a +// second one, the notice on the page and the absence of the data behind it +// could disagree. vi.mock("@/lib/modules/gate", () => ({ - isModuleEnabled: vi.fn(async () => true), + resolveModuleMap: vi.fn(async () => allModulesOn()), })); vi.mock("@/lib/doctor-report-data", () => ({ collectDoctorReportData: vi.fn(), @@ -26,7 +31,10 @@ vi.mock("@/lib/db", () => ({ })); import { loadShareViewData } from "../share-view-data"; -import { isModuleEnabled } from "@/lib/modules/gate"; +import { resolveModuleMap } from "@/lib/modules/gate"; +// From the registry, not the gate: the gate is mocked above, so its re-export +// of the key list would come back undefined. +import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry"; import { collectDoctorReportData } from "@/lib/doctor-report-data"; import { selectionToBlob, @@ -35,7 +43,23 @@ import { import { prisma } from "@/lib/db"; import type { ShareContext } from "../resolve-share-token"; +/** Every module on — the shape `resolveModuleMap` returns for a fresh account. */ +function allModulesOn(): Record { + return Object.fromEntries(MODULE_KEYS.map((key) => [key, true])) as Record< + ModuleKey, + boolean + >; +} + +/** Every module on except the named ones. */ +function modulesWithout(...off: ModuleKey[]): Record { + const map = allModulesOn(); + for (const key of off) map[key] = false; + return map; +} + const collect = collectDoctorReportData as ReturnType; +const moduleMap = resolveModuleMap as ReturnType; const findDocs = prisma.clinicianShareLinkDocument.findMany as ReturnType< typeof vi.fn >; @@ -349,7 +373,7 @@ describe("clinician share — owner doctorReport module gate", () => { beforeEach(() => { // Sibling of the suite above, so the outer reset does not reach here. vi.clearAllMocks(); - vi.mocked(isModuleEnabled).mockImplementation(async () => true); + moduleMap.mockResolvedValue(allModulesOn()); collect.mockResolvedValue({ patient: { displayName: "Shared record" } }); }); @@ -365,7 +389,7 @@ describe("clinician share — owner doctorReport module gate", () => { }); it("collapses to documents-only with the module off", async () => { - vi.mocked(isModuleEnabled).mockImplementation(async () => false); + moduleMap.mockResolvedValue(modulesWithout("doctorReport")); findDocs.mockResolvedValue([]); const res = await loadShareViewData(ctx({ documentOnly: false })); @@ -382,13 +406,13 @@ describe("clinician share — owner doctorReport module gate", () => { // from the frozen share context. findDocs.mockResolvedValue([]); await loadShareViewData(ctx({ documentOnly: false })); - expect(isModuleEnabled).toHaveBeenCalledWith("owner-1", "doctorReport"); + expect(moduleMap).toHaveBeenCalledWith("owner-1"); }); it("closes the operator kill-switch path too", async () => { - vi.mocked(isModuleEnabled).mockImplementation( - async (_u: string, key: string) => key !== "doctorReport", - ); + // The operator layer and the per-user layer resolve into the same map, so + // an operator-disabled `doctorReport` arrives here as the same `false`. + moduleMap.mockResolvedValue(modulesWithout("doctorReport")); findDocs.mockResolvedValue([]); const res = await loadShareViewData(ctx({ documentOnly: false })); @@ -397,3 +421,55 @@ describe("clinician share — owner doctorReport module gate", () => { expect(collect).not.toHaveBeenCalled(); }); }); + +/** + * The third state a recipient has to be able to see. + * + * A leaf the owner DID share, in a domain their account has switched off, + * produces exactly the same absence in the payload as a leaf they shared and + * never recorded anything for. The loader carries the module verdict out + * alongside the payload so the page can say which one it is, and it carries + * ONLY leaves the selection admits — a withheld leaf's module state is not the + * recipient's business in any direction. + */ +describe("loadShareViewData — leaves shared but switched off at the source", () => { + beforeEach(() => { + vi.clearAllMocks(); + moduleMap.mockResolvedValue(allModulesOn()); + collect.mockResolvedValue({ patient: { displayName: "Shared record" } }); + findDocs.mockResolvedValue([]); + }); + + it("names a selected leaf whose owning module is off", async () => { + moduleMap.mockResolvedValue(modulesWithout("labs")); + const res = await loadShareViewData( + ctx({ + sectionsJson: selectionToBlob( + selectionFromLeaves(["LAB_RESULTS", "WEIGHT"]), + ), + }), + ); + expect(res.unavailableLeaves).toEqual(["LAB_RESULTS"]); + }); + + it("stays empty when every selected leaf's module is on", async () => { + const res = await loadShareViewData( + ctx({ + sectionsJson: selectionToBlob( + selectionFromLeaves(["LAB_RESULTS", "MOOD", "WEIGHT"]), + ), + }), + ); + expect(res.unavailableLeaves).toEqual([]); + }); + + it("never names a leaf the link does not carry", async () => { + // `mood` is off AND unshared. The recipient learns nothing about it, + // because they were never told it existed. + moduleMap.mockResolvedValue(modulesWithout("mood")); + const res = await loadShareViewData( + ctx({ sectionsJson: selectionToBlob(selectionFromLeaves(["WEIGHT"])) }), + ); + expect(res.unavailableLeaves).toEqual([]); + }); +}); diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts index b6f994412..1d0eb370a 100644 --- a/src/lib/clinician-share/share-view-data.ts +++ b/src/lib/clinician-share/share-view-data.ts @@ -18,9 +18,13 @@ import { type DoctorReportData, type DoctorReportRange, } from "@/lib/doctor-report-data"; -import { isModuleEnabled } from "@/lib/modules/gate"; +import { resolveModuleMap } from "@/lib/modules/gate"; import { servingClassFor } from "@/lib/documents/upload-policy"; import type { DocumentServingClass } from "@/lib/documents/upload-policy"; +import { + LEAF_MODULE, + type ReportLeafId, +} from "@/lib/report-selection/catalogue"; import { isEmptySelection, selectionFromStoredBlob, @@ -61,6 +65,22 @@ export interface ShareViewData { report: DoctorReportData | null; /** The link's frozen selection, resolved. Empty when it carries no scope. */ selection: ReportSelection; + /** + * Leaves the owner DID freeze onto the link and whose owning module is + * switched off on the account it came from. + * + * The aggregator ANDs the two gates and returns the same `null` either way, + * so the payload alone cannot tell "shared, nothing recorded" from "shared, + * but the domain is switched off here". The recipient is owed that + * difference — a doctor reading an empty Lab results card should know + * whether the person has no results or whether the section never had a + * chance to carry any — so the second gate's verdict is carried alongside + * the payload rather than being collapsed into it. + * + * Only leaves the selection carries appear here: a leaf that was never + * shared is not the recipient's business in any state. + */ + unavailableLeaves: ReportLeafId[]; /** v1.28 — the hand-picked documents on this link (metadata only). */ documents: ShareViewDocument[]; /** @@ -122,12 +142,26 @@ export async function loadShareViewData( // the link to exactly the documents the owner attached. That is fail-closed // for the health record while keeping a public link from 500-ing. The // documents themselves are a separate module and keep their own gate. - const reportModuleEnabled = await isModuleEnabled( - context.ownerUserId, - "doctorReport", - ); + // + // The whole map is resolved rather than the one key, because the per-leaf + // verdicts below come off the same read. It is NOT handed to the aggregator: + // the third argument is the frozen selection and there is deliberately no + // fourth, so this surface cannot grow an options object that widens what it + // asks for. `resolveModuleMap` memoises its DB reads per request, so the + // aggregator resolving its own map again costs no round-trip. + const moduleMap = await resolveModuleMap(context.ownerUserId); const documentOnly = - context.documentOnly || isEmptySelection(selection) || !reportModuleEnabled; + context.documentOnly || + isEmptySelection(selection) || + moduleMap.doctorReport === false; + + // Shared, but switched off at the source. Derived from the SAME map the + // aggregator gates with, so the notice on the page and the absence of the + // data behind it can never disagree. + const unavailableLeaves = selection.leaves.filter((leaf) => { + const moduleKey = LEAF_MODULE[leaf]; + return moduleKey !== undefined && moduleMap[moduleKey] === false; + }); const [report, documents] = await Promise.all([ documentOnly @@ -136,7 +170,7 @@ export async function loadShareViewData( loadShareDocuments(context), ]); - return { report, selection, documents, documentOnly }; + return { report, selection, unavailableLeaves, documents, documentOnly }; } /** From 7a9aa141a6623ea0b3b1e153bae532b5e0426bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 24 Aug 2026 01:02:50 +0200 Subject: [PATCH 3/9] feat(share): render the record sections the clinician view withheld MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Someone ticks "Lab values" in the selection, sends the link, and the doctor opens a page that says nothing about lab values — while the PDF download from the same link prints them. Eleven of the seventeen structured leaves were like that: the control existed, the data reached the page object, and no component read it. The person had every reason to believe they had shared something they had not. Rendered, in catalogue-group order, which is also the PDF's: - emergency information, framed and first, as it is page one there; - personal details (name, date of birth, gender, height); - lab values, with the reference window each reading was judged against, printed as the source report printed it; - GLP-1 therapy and the logged-dose ledger; - conditions and illnesses, visits, immunizations; - family history, mood, the menstrual cycle. What each shows follows the PDF, which has already settled what belongs in a section and in what order. The layout does not: a six-column table becomes a labelled block, a four-column one becomes a row with its detail composed onto the value side. Absence is stated, never implied. A leaf the link does not carry renders nothing at all — the recipient was never promised it. A leaf on the link with nothing behind it renders its heading and says so, and one whose domain is switched off on the owner's account says that instead. This extends what the health-profile section has always done per fact to the section level, and the glucose, medication and allergy cards adopt it too; the measurement groups deliberately stay silent, because a group card stands for up to seventeen leaves and twelve empty ones is noise. Every card now asks the frozen selection directly through `LeafScope` rather than inferring consent from the presence of data. The aggregator already applies the selection, but a section that reads only "is there data" trusts a gate it cannot see. Three rows on the emergency card are composed from other leaves — severe allergies, the drug list, chronic conditions. Each appears only when its own leaf is on the link: printing "Not recorded" for a leaf the owner withheld states an absence in the record where the truth is an absence in the share, and on that card it is the dangerous direction to get wrong. The glucose card's heading was "Lab values", which was the wrong name for it even before the actual lab results arrived; it is the glucose group's own label now. `INSURANCE` gets no renderer and must not get one: the create route refuses the leaf outright, so no link can carry it. --- messages/de.json | 12 +- messages/en.json | 12 +- messages/es.json | 12 +- messages/fr.json | 12 +- messages/it.json | 12 +- messages/pl.json | 12 +- src/app/c/[token]/page.tsx | 3 +- .../__tests__/clinician-view.test.tsx | 7 +- src/components/clinician/clinician-view.tsx | 157 +++++++++- src/components/clinician/history-sections.tsx | 274 ++++++++++++++++++ .../clinician/identity-sections.tsx | 258 +++++++++++++++++ src/components/clinician/report-sections.tsx | 253 +++++++++++++--- .../clinician/sensitive-sections.tsx | 215 ++++++++++++++ src/components/clinician/therapy-sections.tsx | 225 ++++++++++++++ 14 files changed, 1402 insertions(+), 62 deletions(-) create mode 100644 src/components/clinician/history-sections.tsx create mode 100644 src/components/clinician/identity-sections.tsx create mode 100644 src/components/clinician/sensitive-sections.tsx create mode 100644 src/components/clinician/therapy-sections.tsx diff --git a/messages/de.json b/messages/de.json index c3cc01318..c0d5433c1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7710,7 +7710,17 @@ }, "downloadPdf": "Als PDF herunterladen", "downloadFhir": "Als FHIR herunterladen", - "bmiSection": "Body-Mass-Index" + "bmiSection": "Body-Mass-Index", + "sectionEmpty": "In diesem Link enthalten, hier ist aber nichts erfasst.", + "sectionUnavailable": "In diesem Link enthalten, dieser Teil der Akte ist im zugehörigen Konto aber abgeschaltet und enthält deshalb keine Daten.", + "lastInjection": "Letzte Injektion", + "identity": { + "name": "Name" + }, + "doses": { + "title": "Erfasste Dosen", + "showing": "Angezeigt werden die {shown} jüngsten von {total} erfassten Dosen." + } }, "cycle": { "symptomCategory": { diff --git a/messages/en.json b/messages/en.json index 4e9238e5f..5f0ba98cf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7710,7 +7710,17 @@ }, "downloadPdf": "Download as PDF", "downloadFhir": "Download as FHIR", - "bmiSection": "Body mass index" + "bmiSection": "Body mass index", + "sectionEmpty": "Included in this link, but nothing is recorded here.", + "sectionUnavailable": "Included in this link, but this part of the record is switched off in the account it comes from, so it holds no data.", + "lastInjection": "Last injection", + "identity": { + "name": "Name" + }, + "doses": { + "title": "Logged doses", + "showing": "Showing the {shown} most recent of {total} logged doses." + } }, "cycle": { "symptomCategory": { diff --git a/messages/es.json b/messages/es.json index bf5db0715..c84ce503f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7710,7 +7710,17 @@ }, "downloadPdf": "Descargar como PDF", "downloadFhir": "Descargar como FHIR", - "bmiSection": "Índice de masa corporal" + "bmiSection": "Índice de masa corporal", + "sectionEmpty": "Incluido en este enlace, pero aquí no hay nada registrado.", + "sectionUnavailable": "Incluido en este enlace, pero esta parte del historial está desactivada en la cuenta de origen, así que no contiene datos.", + "lastInjection": "Última inyección", + "identity": { + "name": "Nombre" + }, + "doses": { + "title": "Dosis registradas", + "showing": "Se muestran las {shown} dosis más recientes de {total} registradas." + } }, "cycle": { "symptomCategory": { diff --git a/messages/fr.json b/messages/fr.json index 73fe2a65f..7812cf02d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7710,7 +7710,17 @@ }, "downloadPdf": "Télécharger en PDF", "downloadFhir": "Télécharger en FHIR", - "bmiSection": "Indice de masse corporelle" + "bmiSection": "Indice de masse corporelle", + "sectionEmpty": "Inclus dans ce lien, mais rien n'est enregistré ici.", + "sectionUnavailable": "Inclus dans ce lien, mais cette partie du dossier est désactivée dans le compte d'origine et ne contient donc aucune donnée.", + "lastInjection": "Dernière injection", + "identity": { + "name": "Nom" + }, + "doses": { + "title": "Doses enregistrées", + "showing": "Affichage des {shown} doses les plus récentes sur {total} enregistrées." + } }, "cycle": { "symptomCategory": { diff --git a/messages/it.json b/messages/it.json index d2c73dcf0..c0bc68d11 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7710,7 +7710,17 @@ }, "downloadPdf": "Scarica come PDF", "downloadFhir": "Scarica come FHIR", - "bmiSection": "Indice di massa corporea" + "bmiSection": "Indice di massa corporea", + "sectionEmpty": "Incluso in questo link, ma qui non è registrato nulla.", + "sectionUnavailable": "Incluso in questo link, ma questa parte della cartella è disattivata nell'account di origine e quindi non contiene dati.", + "lastInjection": "Ultima iniezione", + "identity": { + "name": "Nome" + }, + "doses": { + "title": "Dosi registrate", + "showing": "Sono mostrate le {shown} dosi più recenti su {total} registrate." + } }, "cycle": { "symptomCategory": { diff --git a/messages/pl.json b/messages/pl.json index d44095179..ae6be6cdb 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7710,7 +7710,17 @@ }, "downloadPdf": "Pobierz jako PDF", "downloadFhir": "Pobierz jako FHIR", - "bmiSection": "Wskaźnik masy ciała" + "bmiSection": "Wskaźnik masy ciała", + "sectionEmpty": "Objęte tym linkiem, ale nic tu nie zapisano.", + "sectionUnavailable": "Objęte tym linkiem, ale ta część dokumentacji jest wyłączona na koncie źródłowym, więc nie zawiera danych.", + "lastInjection": "Ostatnie wstrzyknięcie", + "identity": { + "name": "Imię i nazwisko" + }, + "doses": { + "title": "Zarejestrowane dawki", + "showing": "Pokazano {shown} najnowszych z {total} zarejestrowanych dawek." + } }, "cycle": { "symptomCategory": { diff --git a/src/app/c/[token]/page.tsx b/src/app/c/[token]/page.tsx index cfef75753..567292d68 100644 --- a/src/app/c/[token]/page.tsx +++ b/src/app/c/[token]/page.tsx @@ -93,7 +93,7 @@ export default async function ClinicianSharePage({ // profile timezone so they agree with the patient-tz aggregation behind the // stats and with the doctor-report PDF (never the container's zone). const [ - { report, selection, documents, documentOnly }, + { report, selection, unavailableLeaves, documents, documentOnly }, locale, ownerTimezone, ] = await Promise.all([ @@ -110,6 +110,7 @@ export default async function ClinicianSharePage({ expiresAt={context.expiresAt.toISOString()} report={report} selection={selection} + unavailableLeaves={unavailableLeaves} documents={documents} documentOnly={documentOnly} token={token} diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index f9dc092b8..91f475b2c 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -178,8 +178,13 @@ describe("", () => { expect(html).toContain("Body measurements"); // The measurement-type enum renders as the SAME localised label the rest // of the app uses, not the raw enum string and not a second vocabulary. + // + // Asserted against element TEXT rather than against the whole markup: each + // card now carries a `data-leaf` attribute naming the catalogue leaves it + // speaks for, so the enum constant is legitimately in the document as + // machine metadata. What must never happen is a reader seeing it. expect(html).toContain(">Weight<"); - expect(html).not.toContain("WEIGHT"); + expect(html).not.toContain(">WEIGHT<"); }); it("omits the wellness card when there are no scores", () => { diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index ca449f4b3..3443b1371 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -6,22 +6,51 @@ * read-only clinical summary. NO client hooks, NO session, NO AI or coach, NO * markdown — every value renders as escaped React text. * - * Layout: provenance header (with the two machine-format downloads) → the - * measurement groups in selection order → glucose → medications and adherence - * → a FENCED, muted wellness card carrying the load-bearing "descriptive, not - * a clinical assessment" disclaimer → the attached documents. + * Layout follows the catalogue's own group order, which is also the PDF's, so + * the picker a person ticked, the page their doctor opens and the file that + * doctor files all describe the record in the same sequence: the emergency + * sheet first (it is page one of the PDF for the same reason) → personal + * details → the measurement groups → glucose → lab values → medications, GLP-1 + * and the dose log → conditions, visits, immunizations, allergies → family + * history, health profile, mood, cycle → a FENCED, muted wellness card + * carrying the load-bearing "descriptive, not a clinical assessment" + * disclaimer → the attached documents. * - * The section components live in `./report-sections`, the document list in - * `./documents-list`, the downloads in `./download-actions`. + * Every card is gated by the link's frozen selection through {@link LeafScope} + * rather than by the presence of its data. The two are not the same question: + * the aggregator already applies the selection, but a section that reads only + * "is there data" trusts an upstream gate it cannot see, and it cannot tell a + * withheld leaf from an empty one. `LeafScope` also carries the OWNER's module + * state, which is the third case — shared, but the domain is switched off on + * the account it came from. + * + * The section components live in `./report-sections` (measurements, glucose, + * medications, allergies, health profile, wellness), `./identity-sections`, + * `./history-sections`, `./therapy-sections` and `./sensitive-sections`; the + * document list in `./documents-list`, the downloads in `./download-actions`. */ import type { DoctorReportData } from "@/lib/doctor-report-data"; import { makeFormatters } from "@/lib/format-locale"; import type { Locale } from "@/lib/i18n/config"; import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data"; +import type { ReportLeafId } from "@/lib/report-selection/catalogue"; import type { ReportSelection } from "@/lib/report-selection/selection"; import { PageHeader } from "@/components/ui/page-header"; import { DocumentEntry } from "./documents-list"; import { ShareDownloadActions } from "./download-actions"; +import { EmergencySection, PatientIdentitySection } from "./identity-sections"; +import { + IllnessSection, + ImmunizationsSection, + LabResultsSection, + VisitsSection, +} from "./history-sections"; +import { DoseLogSection, Glp1Section } from "./therapy-sections"; +import { + CycleSection, + FamilyHistorySection, + MoodSection, +} from "./sensitive-sections"; import { AllergiesSection, AnamnesisSection, @@ -31,6 +60,7 @@ import { Section, StatRow, WellnessSection, + makeLeafScope, } from "./report-sections"; type Translate = ( @@ -52,6 +82,15 @@ interface ClinicianViewProps { report: DoctorReportData | null; /** The link's frozen selection, resolved. */ selection: ReportSelection; + /** + * Leaves the link DOES carry whose owning module is switched off on the + * owner's account. The payload cannot express this — the aggregator ANDs the + * selection and the module map and returns the same nothing either way — so + * it arrives beside the payload and the affected cards say so in words + * rather than rendering as an empty section the recipient would read as "no + * data recorded". + */ + unavailableLeaves?: readonly ReportLeafId[]; /** * A documents-only link. Hides the reporting-period line (there is no * report) and, together with a `null` report, keeps every health section off @@ -81,6 +120,7 @@ export function ClinicianView({ expiresAt, report, selection, + unavailableLeaves = [], documents = [], documentOnly = false, token = "", @@ -91,7 +131,9 @@ export function ClinicianView({ // guards the zone and falls back to Europe/Berlin on garbage/absence. const fmt = makeFormatters(locale, timezone); const fmtDate = (iso: string) => fmt.date(new Date(iso)); + const fmtDateTime = (iso: string) => fmt.dateTime(new Date(iso)); const fmtNum = (n: number) => Math.round(n * 100) / 100; + const scope = makeLeafScope(selection, unavailableLeaves); return (
{report ? ( <> - - {report.bmi !== null && report.bmi !== undefined ? ( -
+ {/* ── identity ─────────────────────────────────────────── */} + + + + {/* ── measurements ─────────────────────────────────────── */} + + {report.bmi !== null && + report.bmi !== undefined && + scope.admits("BODY_MASS_INDEX") ? ( +
) : null} - - - - - + + {/* ── glucose and labs ─────────────────────────────────── */} + + + + {/* ── medications ──────────────────────────────────────── */} + + + + + {/* ── history ──────────────────────────────────────────── */} + + + + + + {/* ── the fenced tier, as the owner chose it ───────────── */} + + + + + + ) : null} diff --git a/src/components/clinician/history-sections.tsx b/src/components/clinician/history-sections.tsx new file mode 100644 index 000000000..ec3963162 --- /dev/null +++ b/src/components/clinician/history-sections.tsx @@ -0,0 +1,274 @@ +/** + * The `labs` and `history` groups on the clinician view: lab results, illness + * episodes, visits and the immunization record. + * + * All four were selectable on a share link and none of them reached the page, + * while the PDF download from the same link printed every one. What each + * section shows is taken from the PDF's tables — it has already settled what a + * clinician expects in each and in what order — but a page is not a page of + * paper, so a six-column table becomes a labelled block and a four-column one + * becomes a row with its detail composed onto the value side. + * + * A pure server component: no client hooks, no session, no markdown — every + * value renders as escaped React text. + */ +import type { EncounterKind } from "@/generated/prisma/client"; +import { encounterKindLabelKey } from "@/lib/encounters/kind-label"; +import { formatReferenceRange } from "@/lib/labs/reference-range"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { + LeafSection, + StatRow, + type LeafScope, + type Translate, +} from "./report-sections"; + +/** Join the parts of a composed value, dropping the ones with nothing in. */ +function compose(parts: Array): string { + const kept = parts.filter((part): part is string => Boolean(part)); + return kept.length > 0 ? kept.join(" · ") : "—"; +} + +/** + * Structured lab results over the window: one row per analyte, carrying the + * latest reading, the reference window it was judged against, and the date. + * + * The PDF's neutral in/out-of-range glyph is deliberately not carried over. + * On paper it sits in its own column beside the range; inline in a composed + * value an arrow reads as an assertion about the reading rather than as a + * column heading, and this page states what was recorded — it does not + * adjudicate it. + */ +export function LabResultsSection({ + t, + report, + scope, + fmtDate, + fmtNum, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDate: (iso: string) => string; + fmtNum: (n: number) => number; +}) { + const results = report.labResults ?? []; + + return ( + + {results.map((lab) => { + // A qualitative reading ("negative") carries its result text and no + // numeric range — the same split the PDF table makes. + const qualitative = lab.value === null; + const reading = qualitative + ? lab.valueText + : `${fmtNum(lab.value as number)} ${lab.unit}`.trim(); + // The window the reading was judged against, printed as the source + // report printed it when that is where it came from, so a clinician + // comparing against the original reads the same characters. + const reference = qualitative + ? null + : lab.referenceOrigin === "source" && lab.sourceReferenceText + ? lab.sourceReferenceText + : formatReferenceRange( + lab.referenceLow, + lab.referenceHigh, + (value) => String(fmtNum(value)), + { emptyText: "" }, + ); + return ( + + ); + })} + + ); +} + +/** Illness / condition episodes overlapping the window. */ +export function IllnessSection({ + t, + report, + scope, + fmtDate, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDate: (iso: string) => string; +}) { + const episodes = report.illnessEpisodes ?? []; + + return ( + + {episodes.map((episode, index) => ( + + ))} + + ); +} + +/** + * Visits inside the window. Six facts per visit is too many to compose onto + * one line, so each visit is a bordered block of labelled rows — the shape + * `AllergiesSection` already uses for the same reason. + */ +export function VisitsSection({ + t, + report, + scope, + fmtDate, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDate: (iso: string) => string; +}) { + const visits = report.visits ?? []; + + return ( + +
+ {visits.map((visit, index) => ( +
+ + + + + + 0 + ? visit.conditionLabels.join(", ") + : "—" + } + /> +
+ ))} +
+
+ ); +} + +/** + * The immunization record. Reference data, not window-bounded: an Impfpass is + * a lifetime document and the whole of it rides when the leaf and the module + * admit it. No due-status and no gap analysis — the page reproduces the + * record, it does not adjudicate it. + */ +export function ImmunizationsSection({ + t, + report, + scope, + fmtDate, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDate: (iso: string) => string; +}) { + const doses = report.immunizations ?? []; + + /** "3 of 4" / "Booster" / "Dose 2", from the server-resolved series. */ + const doseDisplay = (series: (typeof doses)[number]["series"]) => { + const primary = series[0]; + if (!primary) return null; + if (primary.booster) return t("vaccinations.series.booster"); + if (primary.total !== null) { + return t("vaccinations.series.ofTotal", { + position: primary.position, + total: primary.total, + }); + } + return t("vaccinations.series.doseN", { position: primary.position }); + }; + + return ( + + {doses.map((dose, index) => ( + + ))} + + ); +} diff --git a/src/components/clinician/identity-sections.tsx b/src/components/clinician/identity-sections.tsx new file mode 100644 index 000000000..934982b55 --- /dev/null +++ b/src/components/clinician/identity-sections.tsx @@ -0,0 +1,258 @@ +/** + * The `identity` group on the clinician view: who the record belongs to, and + * the emergency sheet. + * + * Both leaves were selectable on a share link long before anything rendered + * them, so a person who ticked "Emergency information" handed over a link that + * showed none of it. These are the render halves of those two controls. + * + * The third leaf of the group, `INSURANCE`, has no renderer and must not get + * one: `SHARE_LINK_FORBIDDEN_LEAVES` refuses it at share-link creation, so no + * link can carry it and the aggregator therefore never fills the fields. The + * structural guard in `src/__tests__/share-view-leaf-render-guard.test.ts` + * holds that pair together. + * + * A pure server component: no client hooks, no session, no markdown — every + * value renders as escaped React text. + */ +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { + LeafSection, + StatRow, + type LeafScope, + type Translate, +} from "./report-sections"; + +/** + * Only the three stored gender values get a label. A row on a clinical + * document is a positive claim, so an unrecognised string leaves the line out + * rather than asserting a value the account never chose — the same rule the + * PDF cover applies. + */ +const GENDER_LABEL_KEYS: Record = { + MALE: "doctorReport.genderMale", + FEMALE: "doctorReport.genderFemale", + OTHER: "doctorReport.genderOther", +}; + +/** + * Name, date of birth, gender, height — the cover block of the PDF, as rows. + * + * The insurer fields on `report.patient` are NOT read here. They ride the + * `INSURANCE` leaf, which a share link cannot carry, so they are always null + * on this surface; naming them would be the beginning of a path by which they + * one day would not be. + */ +export function PatientIdentitySection({ + t, + report, + scope, + fmtDate, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDate: (iso: string) => string; +}) { + const patient = report.patient; + const name = patient.fullName ?? patient.username ?? null; + const genderKey = patient.gender + ? GENDER_LABEL_KEYS[patient.gender] + : undefined; + const rows: Array<{ label: string; value: string }> = []; + if (name) { + rows.push({ label: t("clinicianView.identity.name"), value: name }); + } + if (patient.dateOfBirth) { + rows.push({ + label: t("doctorReport.dateOfBirth"), + value: fmtDate(patient.dateOfBirth), + }); + } + if (genderKey) { + rows.push({ label: t("doctorReport.gender"), value: t(genderKey) }); + } + if (patient.heightCm) { + rows.push({ + label: t("doctorReport.height"), + value: `${patient.heightCm} cm`, + }); + } + + return ( + + {rows.map((row) => ( + + ))} + + ); +} + +/** + * The emergency sheet. In the PDF this is page one, alone, under a red banner, + * because it is what somebody reads in an acute situation; here it is the + * first card on the page and it is framed rather than plain, for the same + * reason. + * + * Three of its rows are composed from OTHER leaves — severe allergies from + * `ALLERGIES`, the drug list from `MEDICATION_LIST`, chronic conditions from + * `ILLNESS_EPISODES` and `ANAMNESIS`. Each is included only when its own leaf + * is on the link. The alternative — printing "Not recorded" for a leaf the + * owner withheld — states an absence in the record where the truth is an + * absence in the share, and on this card that particular lie is the dangerous + * one. + */ +export function EmergencySection({ + t, + report, + scope, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; +}) { + if (!scope.admits("EMERGENCY")) return null; + + const emergency = report.emergency ?? null; + const unavailable = scope.unavailable("EMERGENCY"); + const notRecorded = t("doctorReport.emergency.none"); + const unreadable = t("doctorReport.emergency.unreadable"); + + const rows: Array<{ label: string; value: string; emphasise?: boolean }> = []; + + if (emergency) { + rows.push({ + label: t("doctorReport.emergency.bloodType"), + value: + emergency.bloodType && emergency.bloodType !== "UNKNOWN" + ? t(`doctorReport.emergency.bloodTypeValues.${emergency.bloodType}`) + : emergency.bloodType === "UNKNOWN" + ? t("doctorReport.emergency.bloodTypeUnknown") + : notRecorded, + emphasise: true, + }); + + if (scope.admits("ALLERGIES")) { + const severe = (report.allergies ?? []).filter( + (a) => a.severity === "SEVERE", + ); + rows.push({ + label: t("doctorReport.emergency.severeAllergies"), + value: + severe.length > 0 + ? severe + .map((a) => { + const reaction = a.reactionUnreadable + ? unreadable + : a.reaction; + return reaction + ? `${a.substance} (${reaction})` + : a.substance; + }) + .join("; ") + : notRecorded, + emphasise: severe.length > 0, + }); + } + + if (scope.admits("MEDICATION_LIST")) { + const meds = report.medications ?? []; + rows.push({ + label: t("doctorReport.emergency.activeMedications"), + value: + meds.length > 0 + ? meds + .map((m) => (m.dose ? `${m.name} ${m.dose}` : m.name)) + .join("; ") + : notRecorded, + }); + } + + const chronic = scope.admits("ILLNESS_EPISODES") + ? (report.illnessEpisodes ?? []) + .filter((e) => e.lifecycle === "CHRONIC_ONGOING") + .map((e) => e.label) + : []; + const anamnesisConditions = scope.admits("ANAMNESIS") + ? (report.anamnesis?.conditions ?? null) + : null; + if (scope.admits("ILLNESS_EPISODES") || scope.admits("ANAMNESIS")) { + const parts = [...chronic]; + if (anamnesisConditions) parts.push(anamnesisConditions); + rows.push({ + label: t("doctorReport.emergency.chronicConditions"), + value: parts.length > 0 ? parts.join("; ") : notRecorded, + }); + } + + rows.push({ + label: t("doctorReport.emergency.implants"), + value: emergency.implantsUnreadable + ? unreadable + : (emergency.implants ?? notRecorded), + }); + rows.push({ + label: t("doctorReport.emergency.advanceDirective"), + value: emergency.advanceDirective + ? t( + `doctorReport.emergency.advanceDirectiveValues.${emergency.advanceDirective}`, + ) + : notRecorded, + }); + rows.push({ + label: t("doctorReport.emergency.organDonor"), + value: emergency.organDonor + ? t(`doctorReport.emergency.organDonorValues.${emergency.organDonor}`) + : notRecorded, + }); + rows.push({ + label: t("doctorReport.emergency.contacts"), + value: emergency.contactsUnreadable + ? unreadable + : (emergency.contacts ?? notRecorded), + emphasise: emergency.contacts !== null, + }); + const note = emergency.noteUnreadable ? unreadable : emergency.note; + if (note) { + rows.push({ label: t("doctorReport.emergency.notes"), value: note }); + } + } + + return ( +
+

+ {t("doctorReport.emergency.title")} +

+

+ {t("doctorReport.emergency.subtitle")} +

+ {unavailable ? ( +

+ {t("clinicianView.sectionUnavailable")} +

+ ) : rows.length === 0 ? ( +

+ {t("clinicianView.sectionEmpty")} +

+ ) : ( + rows.map((row) => ( + + )) + )} +
+ ); +} diff --git a/src/components/clinician/report-sections.tsx b/src/components/clinician/report-sections.tsx index 96c030225..10efc951c 100644 --- a/src/components/clinician/report-sections.tsx +++ b/src/components/clinician/report-sections.tsx @@ -1,6 +1,7 @@ /** * The health sections of the clinician view, grouped the way the owner chose - * them. + * them, plus the leaf primitives every other section file on this surface is + * built from. * * The measurement list used to be a flat `Object.entries(report.stats)` with a * humanised-enum fallback for any type the ten-entry label map missed, so a @@ -16,16 +17,47 @@ import type { MeasurementType } from "@/generated/prisma/client"; import type { DoctorReportData } from "@/lib/doctor-report-data"; import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/lib/measurements/type-label-keys"; import { + isReportLeafId, isStructuredLeafId, REPORT_GROUPS, + type ReportLeafId, } from "@/lib/report-selection/catalogue"; import type { ReportSelection } from "@/lib/report-selection/selection"; -type Translate = ( +export type Translate = ( key: string, vars?: Record, ) => string; +/** + * The two questions every section on this page asks about a leaf, resolved + * once by {@link ClinicianView} and threaded down. + * + * `admits` is the link's own frozen scope. `unavailable` is the OWNER's module + * map — a leaf they did share, in a domain their account has switched off. + * They are separate because the recipient must be able to tell the resulting + * blank sections apart, and the aggregator cannot: it ANDs the two gates and + * returns the same nothing either way. + */ +export interface LeafScope { + /** Whether the link's frozen selection carries this leaf. */ + admits(leaf: ReportLeafId): boolean; + /** Whether the owner's module switch refuses it despite the selection. */ + unavailable(leaf: ReportLeafId): boolean; +} + +/** Build the scope the sections read from the two things the page holds. */ +export function makeLeafScope( + selection: ReportSelection, + unavailableLeaves: readonly ReportLeafId[], +): LeafScope { + const blocked = new Set(unavailableLeaves); + return { + admits: (leaf) => selection.has(leaf), + unavailable: (leaf) => blocked.has(leaf), + }; +} + /** Human-readable display per persisted wellness-score type (i18n key suffix). */ const WELLNESS_KEY: Record = { RECOVERY_SCORE: "recovery", @@ -33,49 +65,147 @@ const WELLNESS_KEY: Record = { STRAIN_SCORE: "strain", }; -/** Render a single labelled stat row. */ -export function StatRow({ label, value }: { label: string; value: string }) { +/** + * Render a single labelled stat row. `emphasise` lifts the weight of the value + * for the two or three facts on the emergency card a reader must not have to + * hunt for; it is weight only, never colour or alpha. + */ +export function StatRow({ + label, + value, + emphasise = false, +}: { + label: string; + value: string; + emphasise?: boolean; +}) { return (
{label} - {value} + + {value} +
); } export function Section({ title, + leaves, children, }: { title: string; + /** + * The catalogue leaves this card speaks for. Emitted as `data-leaf` so the + * structural guard can prove the card is on the page, and so a reader + * inspecting the markup can see which control produced it. + */ + leaves?: readonly ReportLeafId[]; children: React.ReactNode; }) { return ( -
+
0 ? leaves.join(" ") : undefined} + className="border-border bg-card rounded-lg border p-4 md:p-6" + >

{title}

{children}
); } +/** + * A section for one or more catalogue leaves, carrying the three states the + * recipient has to be able to tell apart: + * + * - **not shared** — the link's frozen selection does not carry the leaf, so + * nothing renders at all. Silence is the honest answer: the recipient was + * never promised this part of the record and is owed no account of it. + * - **shared, switched off** — the leaf is on the link but the owner's + * account has the owning module off, so the aggregator never read it. The + * card renders with a line saying exactly that. + * - **shared, nothing recorded** — the leaf is on the link, the domain is + * live, and the window (or the record) holds nothing. The card renders + * with a line saying exactly that. + * + * A blank card is never allowed to stand for any of the three. This follows + * `AnamnesisSection`, which has always printed "Not recorded" per fact rather + * than collapsing, and extends it to the section level. + * + * The MEASUREMENT groups deliberately do NOT use this: a group card is a + * container for up to seventeen leaves, and printing twelve empty group cards + * for a link whose window happens to hold no readings is noise, not honesty. + * There the group's own absence is legible from the cards that are present. + */ +export function LeafSection({ + t, + scope, + leaves, + title, + empty, + children, +}: { + t: Translate; + scope: LeafScope; + leaves: readonly ReportLeafId[]; + title: string; + /** True when the admitted leaves produced no content to render. */ + empty: boolean; + children: React.ReactNode; +}) { + const admitted = leaves.filter((leaf) => scope.admits(leaf)); + if (admitted.length === 0) return null; + + // Every admitted leaf refused by its module ⇒ the card can carry nothing at + // all. When only some are refused the rest still have data to show, and the + // card says so by simply showing it. + const unavailable = admitted.every((leaf) => scope.unavailable(leaf)); + + return ( +
+ {unavailable ? ( +

+ {t("clinicianView.sectionUnavailable")} +

+ ) : empty ? ( +

+ {t("clinicianView.sectionEmpty")} +

+ ) : ( + children + )} +
+ ); +} + /** * The measurement groups that carry data, in selection order. The aggregator - * has already applied the selection, so a type missing from `report.stats` is - * one that was withheld or has no reading. + * has already applied the selection; the scope is re-asked here anyway, so a + * payload assembled by some future caller that forgot its gate still cannot + * put a withheld reading in front of a recipient. */ export function MeasurementGroups({ t, report, + scope, fmtNum, }: { t: Translate; report: DoctorReportData; + scope: LeafScope; fmtNum: (n: number) => number; }) { const groups = REPORT_GROUPS.map((group) => ({ labelKey: group.labelKey, rows: group.leaves .filter((leaf): leaf is MeasurementType => !isStructuredLeafId(leaf)) + .filter((leaf) => scope.admits(leaf)) .map((type) => ({ type, stat: report.stats[type] })) .filter((row) => row.stat !== undefined && row.stat.count > 0), })).filter((group) => group.rows.length > 0); @@ -85,7 +215,11 @@ export function MeasurementGroups({ return ( <> {groups.map((group) => ( -
+
row.type)} + > {group.rows.map(({ type, stat }) => ( number; }) { const entries = Object.entries(report.glucoseStats).filter( ([, s]) => s.count > 0, ); - if (entries.length === 0) return null; return ( -
+ {entries.map(([ctx, s]) => ( ))} -
+ ); } @@ -140,21 +286,28 @@ export function GlucoseSection({ export function MedicationsSection({ t, report, - selection, + scope, }: { t: Translate; report: DoctorReportData; - selection: ReportSelection; + scope: LeafScope; }) { - const medications = report.medications ?? []; - const complianceOn = selection.has("MEDICATION_COMPLIANCE"); + const medications = scope.admits("MEDICATION_LIST") + ? (report.medications ?? []) + : []; + const complianceOn = scope.admits("MEDICATION_COMPLIANCE"); const complianceEntries = complianceOn ? Object.entries(report.compliance).filter(([, c]) => c.total > 0) : []; - if (medications.length === 0 && complianceEntries.length === 0) return null; return ( -
+ {medications.map((med) => { const comp = report.compliance[med.name]; const rate = @@ -184,7 +337,7 @@ export function MedicationsSection({ })} /> ))} -
+ ); } @@ -192,17 +345,22 @@ export function MedicationsSection({ export function AllergiesSection({ t, report, - selection, + scope, }: { t: Translate; report: DoctorReportData; - selection: ReportSelection; + scope: LeafScope; }) { - const allergies = selection.has("ALLERGIES") ? report.allergies : null; - if (!allergies || allergies.length === 0) return null; + const allergies = report.allergies ?? []; return ( -
+
{allergies.map((allergy, index) => (
))}
-
+ ); } @@ -256,14 +414,13 @@ export function AllergiesSection({ export function AnamnesisSection({ t, report, - selection, + scope, }: { t: Translate; report: DoctorReportData; - selection: ReportSelection; + scope: LeafScope; }) { - const anamnesis = selection.has("ANAMNESIS") ? report.anamnesis : null; - if (!anamnesis) return null; + const anamnesis = report.anamnesis ?? null; const absent = t("doctorReport.anamnesisNotRecorded"); const unreadable = t("doctorReport.anamnesisUnreadable"); @@ -271,33 +428,39 @@ export function AnamnesisSection({ kind: "SMOKING_STATUS" | "ALCOHOL_PATTERN" | "SHIFT_SCHEDULE", value: string | null, ): string => { - if (anamnesis.unreadableFacts.includes(kind)) return unreadable; + if (anamnesis?.unreadableFacts.includes(kind)) return unreadable; return value ? t(`records.profileFacts.values.${kind}.${value}`) : absent; }; return ( -
+ -
+ ); } @@ -308,16 +471,26 @@ export function AnamnesisSection({ export function WellnessSection({ t, report, + scope, fmtNum, }: { t: Translate; report: DoctorReportData; + scope: LeafScope; fmtNum: (n: number) => number; }) { - const wellness = report.wellnessScores?.filter((s) => s.count > 0) ?? []; + const wellness = + report.wellnessScores?.filter( + (s) => s.count > 0 && isReportLeafId(s.type) && scope.admits(s.type), + ) ?? []; + // A measurement-backed card, so it collapses rather than printing an + // absence line — see the note on `LeafSection`. if (wellness.length === 0) return null; return ( -
+
s.type).join(" ")} + className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-4 md:p-6" + >

{t("clinicianView.wellness.title")}

diff --git a/src/components/clinician/sensitive-sections.tsx b/src/components/clinician/sensitive-sections.tsx new file mode 100644 index 000000000..ae0ca8dc1 --- /dev/null +++ b/src/components/clinician/sensitive-sections.tsx @@ -0,0 +1,215 @@ +/** + * The `sensitive` group on the clinician view: family history, mood and the + * menstrual cycle. + * + * These are the leaves the catalogue fences in the picker — no group checkbox, + * never in the shipped template, so no single control can switch on more than + * one of them at a time. That fence is about how they are CHOSEN. Once one is + * on a link the owner chose it deliberately and named it, so it renders as an + * ordinary card, exactly as the health-profile leaf beside it already does and + * exactly as the PDF prints them. A second fence at render would read as the + * page second-guessing a decision the person already made. + * + * The fourth leaf of the group, `ANAMNESIS`, lives in `./report-sections` + * because it was the one that was already rendered. + * + * A pure server component: no client hooks, no session, no markdown — every + * value renders as escaped React text. + */ +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { + LeafSection, + StatRow, + type LeafScope, + type Translate, +} from "./report-sections"; + +/** Display label per mood score. Mirrors the PDF's distribution table. */ +const MOOD_LABEL_KEYS: Record = { + 1: "doctorReport.moodAwful", + 2: "doctorReport.moodBad", + 3: "doctorReport.moodNeutral", + 4: "doctorReport.moodGood", + 5: "doctorReport.moodGreat", +}; + +/** + * Family history: relationship, condition and age at onset. Third-party + * information about people who consented to nothing, which is why it is fenced + * in the picker; the free-text note is never read on this path. + */ +export function FamilyHistorySection({ + t, + report, + scope, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; +}) { + const entries = report.familyHistory ?? []; + + return ( + + {entries.map((entry, index) => ( + + ))} + + ); +} + +/** + * Mood over the window: the summary line the PDF prints, then the distribution + * across the five scores. Counts and scores only — no journal text has ever + * reached this payload, and the aggregator does not even select the note + * columns. + */ +export function MoodSection({ + t, + report, + scope, + fmtNum, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtNum: (n: number) => number; +}) { + const mood = report.mood ?? null; + const buckets = mood ? Object.entries(mood.distribution) : []; + + return ( + + {mood ? ( +

+ {t("doctorReport.moodSummary", { + avg: fmtNum(mood.avg), + count: mood.count, + min: fmtNum(mood.min), + max: fmtNum(mood.max), + })} +

+ ) : null} + {buckets.map(([score, count]) => ( + 0 + ? `${count} · ${fmtNum((count / mood.count) * 100)}%` + : String(count) + } + /> + ))} +
+ ); +} + +/** + * The menstrual-cycle summary: last period, average length with its + * variability, average period length, current phase, then the observed cycles. + * Statistics only — no free-text note ever reaches this surface. + */ +export function CycleSection({ + t, + report, + scope, + fmtDate, + fmtNum, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDate: (iso: string) => string; + fmtNum: (n: number) => number; +}) { + const cycle = report.cycle ?? null; + // The summary carries plain dates (YYYY-MM-DD); midday keeps the rendered + // day from sliding a day either way in the owner's zone. + const day = (date: string) => fmtDate(`${date}T12:00:00.000Z`); + const days = t("doctorReport.cycleDays"); + + const rows: Array<{ label: string; value: string }> = []; + if (cycle?.lastPeriodStart) { + rows.push({ + label: t("doctorReport.cycleLmp"), + value: day(cycle.lastPeriodStart), + }); + } + if (cycle && cycle.averageCycleLengthDays !== null) { + rows.push({ + label: t("doctorReport.cycleAvgLength"), + value: + `${fmtNum(cycle.averageCycleLengthDays)} ${days}` + + (cycle.cycleLengthVariabilityDays !== null + ? ` (± ${fmtNum(cycle.cycleLengthVariabilityDays)})` + : ""), + }); + } + if (cycle && cycle.averagePeriodLengthDays !== null) { + rows.push({ + label: t("doctorReport.cycleAvgPeriod"), + value: `${fmtNum(cycle.averagePeriodLengthDays)} ${days}`, + }); + } + if (cycle?.currentPhase) { + rows.push({ + label: t("doctorReport.cyclePhase"), + value: t(`doctorReport.cyclePhases.${cycle.currentPhase}`), + }); + } + + return ( + + {rows.map((row) => ( + + ))} + {(cycle?.recentCycles ?? []).map((observed) => ( + + ))} + + ); +} diff --git a/src/components/clinician/therapy-sections.tsx b/src/components/clinician/therapy-sections.tsx new file mode 100644 index 000000000..097eb6497 --- /dev/null +++ b/src/components/clinician/therapy-sections.tsx @@ -0,0 +1,225 @@ +/** + * The two `medications`-group leaves the clinician view never rendered: GLP-1 + * therapy and the logged-dose ledger. + * + * `MedicationsSection` in `./report-sections` covers the other two leaves of + * the group (the drug list and the adherence rate); these sit beneath it. + * + * A pure server component: no client hooks, no session, no markdown — every + * value renders as escaped React text. + */ +import type { DoctorReportData } from "@/lib/doctor-report-data"; +import { adherenceRatePercent } from "@/lib/doctor-report-data"; +import { GLP1_SIDE_EFFECT_TAG_LABEL_KEYS } from "@/lib/medications/glp1-side-effect-tags"; +import { + LeafSection, + StatRow, + type LeafScope, + type Translate, +} from "./report-sections"; + +/** + * The most recent doses shown in full. The ledger itself is capped in the + * hundreds or thousands (see `resolveMaxMedicationAdministrations`), which is + * a reasonable size for a FHIR bundle a system files and an unreasonable one + * for a page a person reads. Everything the cut hides is counted and named + * rather than dropped silently. + */ +const DOSE_LOG_LIMIT = 20; + +/** + * GLP-1 therapy: the weight curve over the window, then per drug the current + * dose, adherence and titration history, then the side-effect tally. + * + * Content and order follow the PDF's GLP-1 section. The tally is keyed by + * `Glp1SideEffectTag` rather than by the string the mood entry was written + * with, so the symptom is named in the reader's language and not in whichever + * one it happened to be captured in. + */ +export function Glp1Section({ + t, + report, + scope, + fmtDate, + fmtNum, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDate: (iso: string) => string; + fmtNum: (n: number) => number; +}) { + const glp1 = report.glp1 ?? null; + const medications = glp1?.medications ?? []; + const sideEffects = glp1?.sideEffects ?? []; + const weightLine = + glp1 && + glp1.weightDeltaKg !== null && + glp1.weightStartKg !== null && + glp1.weightEndKg !== null + ? t("doctorReport.glp1WeightSummary", { + start: fmtNum(glp1.weightStartKg), + end: fmtNum(glp1.weightEndKg), + delta: fmtNum(glp1.weightDeltaKg), + }) + : null; + + return ( + + {weightLine ?

{weightLine}

: null} +
+ {medications.map((med) => { + const rate = adherenceRatePercent( + med.compliance.taken, + med.compliance.total, + ); + return ( +
+ + {rate !== null ? ( + + ) : null} + {med.doseHistory.map((change, index) => ( + + ))} + {med.lastInjection ? ( + + ) : null} +
+ ); + })} +
+ {sideEffects.length > 0 ? ( +
+

+ {t("doctorReport.glp1SideEffectsTitle")} +

+ {sideEffects.map((effect) => ( + + ))} +
+ ) : null} +
+ ); +} + +/** + * The logged-dose ledger — every intake the person actually actioned over the + * window, taken or deliberately skipped. Pending and missed rows are excluded + * upstream, so nothing here asserts an administration that did not happen. + * + * This leaf reached the FHIR download and neither the page nor the PDF, which + * made "Every logged dose" the one control on the picker whose effect a person + * could only see by opening the bundle in another program. It renders as the + * most recent {@link DOSE_LOG_LIMIT} rows with an explicit count of what the + * cut left out; a five-thousand-row table is not a thing anyone reads, and a + * silent top-20 is the kind of half-answer this whole surface exists to stop. + */ +export function DoseLogSection({ + t, + report, + scope, + fmtDateTime, + fmtNum, +}: { + t: Translate; + report: DoctorReportData; + scope: LeafScope; + fmtDateTime: (iso: string) => string; + fmtNum: (n: number) => number; +}) { + const administrations = report.medicationAdministrations ?? []; + // Newest first: a clinician reads "when did they last take it", not "when + // did the window open". + const ordered = [...administrations].sort((a, b) => + b.effectiveAt.localeCompare(a.effectiveAt), + ); + const shown = ordered.slice(0, DOSE_LOG_LIMIT); + // The aggregator's own cap may already have trimmed the set before it got + // here; its `total` is the honest denominator when it did. + const total = + report.medicationAdministrationsTruncation?.total ?? ordered.length; + + return ( + + {shown.length < total ? ( +

+ {t("clinicianView.doses.showing", { + shown: shown.length, + total, + })} +

+ ) : null} + {shown.map((dose, index) => ( + + ))} +
+ ); +} From ac6fd187d6fc8844f27b82b24bf2588489f0ec36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 24 Aug 2026 01:07:14 +0200 Subject: [PATCH 4/9] test(share): pin every catalogue leaf to a clinician-view renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven leaves stayed invisible for eleven releases because nothing connected the two lists. The picker-to-aggregator guard was satisfied and kept being satisfied; the page was not on either end of it. `DISPOSITIONS` is `Record`, so an eighteenth structured leaf fails `pnpm typecheck` until someone writes down whether it renders or why it cannot. Per leaf the suite then renders the real view over a real fixture and asserts the marker appears; re-renders with that leaf alone removed from the frozen selection, payload untouched, and asserts it is gone; and for a refusal, reads the list that enforces it rather than trusting the note. Once over the whole set: a carried leaf with nothing behind it says so, a leaf whose module is off says something else, and a leaf the link never carried says nothing at all. The `share-downloads` row is asserted here too. Its test id had zero references anywhere in the tree, so the two buttons a practice clicks could have lost a link, pointed at the wrong path, or vanished from a documents-only share unnoticed. Break-proofs, each confirmed red then restored: drop a section element (that leaf's render case); gate `LeafSection` on data instead of scope (thirteen withholding cases); collapse the switched-off arm (the distinction case); empty `SHARE_LINK_FORBIDDEN_LEAVES` (the refusal case); add an eighteenth leaf (typecheck). --- .../share-view-leaf-render-guard.test.ts | 561 ++++++++++++++++++ 1 file changed, 561 insertions(+) create mode 100644 src/__tests__/share-view-leaf-render-guard.test.ts diff --git a/src/__tests__/share-view-leaf-render-guard.test.ts b/src/__tests__/share-view-leaf-render-guard.test.ts new file mode 100644 index 000000000..5a745006a --- /dev/null +++ b/src/__tests__/share-view-leaf-render-guard.test.ts @@ -0,0 +1,561 @@ +/** + * Structural guard: every structured leaf the catalogue declares either + * reaches the clinician view, or has a written, enforced reason why it cannot. + * + * Eleven leaves were selectable on a share link and invisible on the page it + * served. Each of them had a control, a gating path in the aggregator and a + * renderer in the PDF, so `doctor-report-control-gating-guard.test.ts` was + * satisfied and stayed satisfied: it connects the picker to the aggregator, + * and nothing connected either to the page. The two lists sat beside each + * other for eleven releases without a test that could see both. + * + * This file is that connection. It is a pair guard in the same sense: one end + * is `STRUCTURED_LEAF_GROUP`, the other is HTML rendered by the real + * `` over a real fixture. It proves four things per leaf. + * + * 1. `DISPOSITIONS` is `Record`, so an eighteenth + * structured leaf cannot be added to the catalogue without deciding here + * whether it renders — and saying so — or `pnpm typecheck` fails. That is + * the lock, and it is the compiler's, not this suite's. + * 2. A leaf marked `rendered` actually renders: its fixture's own marker + * appears in the page HTML. + * 3. Removing exactly that leaf from the frozen selection removes the + * marker, with the payload left untouched. This is the both-ends half — + * a section that reads only "is there data" would pass (2) and fail (3), + * and several did before `LeafScope`. + * 4. A leaf marked `refused` is refused where the disposition says it is, + * by reading `SHARE_LINK_FORBIDDEN_LEAVES` rather than taking the note's + * word for it. + * + * Plus, once over the whole set: a leaf on the link with no data behind it + * says so in words, and one whose module is off says something different. A + * blank card must never be the answer to either. + * + * Mutation checks are recorded per assertion below. + */ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ClinicianView } from "@/components/clinician/clinician-view"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import { SHARE_LINK_FORBIDDEN_LEAVES } from "@/lib/validations/clinician-share-link"; +import { + ALL_LEAF_IDS, + STRUCTURED_LEAF_IDS, + type ReportLeafId, + type StructuredLeafId, +} from "@/lib/report-selection/catalogue"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; + +/** The payload floor every fixture starts from: a link that carries nothing. */ +function emptyReport(): DoctorReportData { + return { + period: { + days: 30, + since: "2026-01-01T00:00:00.000Z", + start: "2026-01-01T00:00:00.000Z", + end: "2026-01-31T00:00:00.000Z", + }, + patient: { + username: null, + dateOfBirth: null, + gender: null, + heightCm: null, + }, + practiceName: null, + measurements: {}, + stats: {}, + glucoseStats: {}, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { + now: new Date("2026-01-31T00:00:00.000Z"), + }), + glucoseUnit: "mg/dL", + bmi: null, + compliance: {}, + medications: [], + mood: null, + wellnessScores: null, + } as unknown as DoctorReportData; +} + +function render( + report: DoctorReportData, + leaves: readonly ReportLeafId[], + unavailableLeaves: readonly ReportLeafId[] = [], +): string { + const { t } = getServerTranslator("en"); + return renderToStaticMarkup( + ClinicianView({ + t: (key, vars) => t(key, vars), + label: "Clinic", + expiresAt: "2026-03-01T00:00:00.000Z", + report, + selection: selectionFromLeaves(leaves), + unavailableLeaves, + }), + ); +} + +/** + * Every leaf that has a card on the page, read off the `data-leaf` attributes. + * + * A card can speak for more than one leaf — the medications card carries the + * drug list and the adherence rate together — so the attribute is a + * space-separated list and a substring match on `data-leaf="LEAF"` would miss + * exactly those. + */ +function renderedLeaves(html: string): Set { + const found = new Set(); + for (const match of html.matchAll(/data-leaf="([^"]*)"/g)) { + for (const leaf of match[1].split(" ")) { + if (leaf) found.add(leaf); + } + } + return found; +} + +/** The markup of the card speaking for `leaf`, or "" when there is none. */ +function cardFor(html: string, leaf: string): string { + for (const match of html.matchAll( + /
/g, + )) { + if (match[1].split(" ").includes(leaf)) return match[0]; + } + return ""; +} + +type Disposition = + | { + kind: "rendered"; + /** Payload that gives this leaf something to show. */ + data: Partial; + /** A string the page carries only when this leaf rendered. */ + marker: string; + } + | { + kind: "refused"; + /** Why no renderer exists. Asserted, not trusted — see the suite below. */ + reason: string; + }; + +/** + * Every structured leaf, and what the clinician view does with it. + * + * `Record` is exhaustive over the closed union by the + * compiler, mirroring how `STRUCTURED_LEAF_GROUP` forces a group decision in + * the same commit as a new leaf. Adding one here is the same shape of work: + * name the fixture and the marker, or name the refusal. + */ +const DISPOSITIONS: Record = { + PATIENT_IDENTITY: { + kind: "rendered", + data: { + patient: { + username: "shared-account", + dateOfBirth: "1979-04-02T00:00:00.000Z", + gender: "FEMALE", + heightCm: 171, + fullName: "A. Patient", + }, + }, + marker: "A. Patient", + }, + EMERGENCY: { + kind: "rendered", + data: { + emergency: { + bloodType: "O_NEG", + organDonor: "YES", + advanceDirective: "EXISTS", + contacts: "Next of kin, 555 0100", + contactsUnreadable: false, + implants: "Pacemaker, fitted 2021", + implantsUnreadable: false, + note: null, + noteUnreadable: false, + }, + }, + marker: "Pacemaker, fitted 2021", + }, + INSURANCE: { + kind: "refused", + reason: + "Refused at share-link creation by SHARE_LINK_FORBIDDEN_LEAVES, so no " + + "link can carry the leaf and the aggregator never fills the insurer " + + "fields. A renderer here would be a path to a decision already made.", + }, + GLUCOSE_PANEL: { + kind: "rendered", + data: { + glucoseStats: { + FASTING: { avg: 96, min: 88, max: 104, count: 21, latest: 94 }, + }, + }, + marker: "Glucose (fasting)", + }, + LAB_RESULTS: { + kind: "rendered", + data: { + labResults: [ + { + panel: null, + analyte: "Ferritin", + value: 42, + valueText: null, + unit: "ng/mL", + referenceLow: 30, + referenceHigh: 400, + catalogReferenceLow: 30, + catalogReferenceHigh: 400, + sourceReferenceText: null, + referenceOrigin: "catalog", + referenceDivergesFromCatalog: false, + takenAt: "2026-01-20T09:00:00.000Z", + count: 1, + }, + ], + }, + marker: "Ferritin", + }, + MEDICATION_LIST: { + kind: "rendered", + data: { + medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }], + }, + marker: "Ramipril", + }, + MEDICATION_ADMINISTRATIONS: { + kind: "rendered", + data: { + medicationAdministrations: [ + { + medicationName: "Ramipril", + effectiveAt: "2026-01-30T07:10:00.000Z", + status: "completed", + doseText: "5 mg", + dose: null, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "ORAL", + }, + ], + }, + marker: "Logged doses", + }, + MEDICATION_COMPLIANCE: { + kind: "rendered", + data: { + compliance: { + Metformin: { total: 60, taken: 57, skipped: 1, missed: 2 }, + }, + }, + marker: "Metformin", + }, + GLP1_THERAPY: { + kind: "rendered", + data: { + glp1: { + medications: [ + { + name: "Semaglutide", + currentDose: { + value: 1, + unit: "mg", + since: "2026-01-05T00:00:00.000Z", + }, + doseHistory: [], + lastInjection: null, + compliance: { taken: 4, total: 4 }, + }, + ], + weightDeltaKg: null, + weightStartKg: null, + weightEndKg: null, + sideEffects: [], + }, + }, + marker: "Semaglutide", + }, + ALLERGIES: { + kind: "rendered", + data: { + allergies: [ + { + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + reaction: null, + reactionUnreadable: false, + }, + ], + }, + marker: "Penicillin", + }, + ILLNESS_EPISODES: { + kind: "rendered", + data: { + illnessEpisodes: [ + { + label: "Sinusitis", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: "2026-01-08T00:00:00.000Z", + resolvedAt: "2026-01-19T00:00:00.000Z", + }, + ], + }, + marker: "Sinusitis", + }, + VISITS: { + kind: "rendered", + data: { + visits: [ + { + occurredAt: "2026-01-14T08:30:00.000Z", + kind: "SPECIALIST", + status: "DONE", + practitionerName: "Cardiology outpatients", + practitionerSpecialty: null, + reason: "Palpitations", + outcome: null, + conditionLabels: [], + }, + ], + }, + marker: "Palpitations", + }, + IMMUNIZATIONS: { + kind: "rendered", + data: { + immunizations: [ + { + occurredAt: "2025-11-03T00:00:00.000Z", + antigenSlug: null, + vaccineName: "Seasonal influenza", + lotNumber: "LOT-7781", + site: null, + practitionerName: null, + series: [], + }, + ], + }, + marker: "Seasonal influenza", + }, + FAMILY_HISTORY: { + kind: "rendered", + data: { + familyHistory: [ + { + relationship: "MOTHER", + condition: "Type 2 diabetes", + ageAtOnset: 54, + }, + ], + }, + marker: "Type 2 diabetes", + }, + MOOD: { + kind: "rendered", + data: { + mood: { + avg: 3.4, + min: 2, + max: 5, + count: 22, + distribution: { 1: 0, 2: 3, 3: 9, 4: 7, 5: 3 }, + }, + }, + marker: "Mood trajectory", + }, + CYCLE: { + kind: "rendered", + data: { + cycle: { + lastPeriodStart: "2026-01-09", + recentCycles: [ + { startDate: "2026-01-09", lengthDays: 29, periodLengthDays: 5 }, + ], + observedCycleCount: 1, + averageCycleLengthDays: 29, + cycleLengthVariabilityDays: null, + averagePeriodLengthDays: 5, + currentPhase: "LUTEAL", + }, + }, + marker: "Menstrual cycle", + }, + ANAMNESIS: { + kind: "rendered", + data: { + anamnesis: { + conditions: "Hypothyroidism", + conditionsUnreadable: false, + smokingStatus: null, + alcoholPattern: null, + shiftSchedule: null, + unreadableFacts: [], + }, + }, + marker: "Hypothyroidism", + }, +}; + +const RENDERED = STRUCTURED_LEAF_IDS.filter( + (leaf) => DISPOSITIONS[leaf].kind === "rendered", +); +const REFUSED = STRUCTURED_LEAF_IDS.filter( + (leaf) => DISPOSITIONS[leaf].kind === "refused", +); + +describe("clinician view — every catalogue leaf has a renderer or a reason", () => { + it("reads a plausible catalogue", () => { + // Sanity floor: a degraded import would satisfy every loop below + // vacuously, which is the failure mode a pair guard dies of. + expect(STRUCTURED_LEAF_IDS).toHaveLength(17); + expect(Object.keys(DISPOSITIONS).sort()).toEqual( + [...STRUCTURED_LEAF_IDS].sort(), + ); + expect(RENDERED.length).toBeGreaterThan(0); + }); + + it("enforces every refusal where the disposition says it lives", () => { + // Mutation: change INSURANCE to `rendered` and this fails on the count; + // remove it from SHARE_LINK_FORBIDDEN_LEAVES and it fails on membership. + expect(REFUSED).toEqual(["INSURANCE"]); + for (const leaf of REFUSED) { + expect( + (SHARE_LINK_FORBIDDEN_LEAVES as readonly string[]).includes(leaf), + `${leaf} claims to be refused at share-link creation but is not on ` + + "SHARE_LINK_FORBIDDEN_LEAVES, so a link can carry it and nothing " + + "will render it", + ).toBe(true); + } + }); + + for (const leaf of RENDERED) { + const disposition = DISPOSITIONS[leaf]; + if (disposition.kind !== "rendered") continue; + + it(`renders ${leaf} when the link carries it`, () => { + // Mutation: drop the section's element from `` and this + // goes red for that leaf alone. + const html = render( + { ...emptyReport(), ...disposition.data }, + ALL_LEAF_IDS, + ); + expect(html).toContain(disposition.marker); + }); + + it(`withholds ${leaf} when the link does not carry it`, () => { + // The payload is IDENTICAL; only the frozen selection changes. Mutation: + // gate the section on its data rather than on the scope and this goes + // red while the test above stays green. + const html = render( + { ...emptyReport(), ...disposition.data }, + ALL_LEAF_IDS.filter((id) => id !== leaf), + ); + expect(html).not.toContain(disposition.marker); + expect(renderedLeaves(html).has(leaf)).toBe(false); + }); + } +}); + +describe("clinician view — an absence names itself", () => { + const EMPTY_COPY = "Included in this link, but nothing is recorded here."; + const OFF_COPY = "switched off in the account it comes from"; + + it("says so for every rendered leaf carried with nothing behind it", () => { + // One render with everything selected and an empty payload: each card is + // present and each says why it is blank. Mutation: return null from + // `LeafSection` on `empty` and every one of these goes red. + const html = render(emptyReport(), ALL_LEAF_IDS); + const present = renderedLeaves(html); + for (const leaf of RENDERED) { + expect(present.has(leaf), `${leaf} renders no card at all`).toBe(true); + expect(cardFor(html, leaf), `${leaf} renders a blank card`).toContain( + EMPTY_COPY, + ); + } + }); + + it("says something DIFFERENT when the owner's module is off", () => { + // The distinction the payload cannot carry: shared-and-empty against + // shared-and-switched-off. Mutation: drop the `unavailable` arm of + // `LeafSection` and the two collapse into one sentence. + const html = render(emptyReport(), ALL_LEAF_IDS, ["LAB_RESULTS"]); + expect(cardFor(html, "LAB_RESULTS")).toContain(OFF_COPY); + expect(cardFor(html, "LAB_RESULTS")).not.toContain(EMPTY_COPY); + // And it is not the blanket answer: a leaf whose module is on still gets + // the empty sentence. + expect(cardFor(html, "MOOD")).toContain(EMPTY_COPY); + expect(cardFor(html, "MOOD")).not.toContain(OFF_COPY); + }); + + it("renders nothing at all for a leaf the link never carried", () => { + // The third state, and the one that must stay silent: no card, no + // sentence, no acknowledgement that the leaf exists. + const html = render(emptyReport(), ["WEIGHT"]); + const present = renderedLeaves(html); + for (const leaf of STRUCTURED_LEAF_IDS) { + expect( + present.has(leaf), + `${leaf} is announced on a link that withheld it`, + ).toBe(false); + } + expect(html).not.toContain(EMPTY_COPY); + expect(html).not.toContain(OFF_COPY); + }); +}); + +describe("clinician view — the machine-format downloads", () => { + // `data-testid="share-downloads"` had zero references anywhere in the tree: + // the two buttons a practice actually clicks were unasserted, so the row + // could have lost a link, pointed at the wrong path, or stopped rendering + // for a documents-only share without a single test noticing. + function renderWithToken(token: string, documentOnly = false): string { + const { t } = getServerTranslator("en"); + return renderToStaticMarkup( + ClinicianView({ + t: (key, vars) => t(key, vars), + label: "Clinic", + expiresAt: "2026-03-01T00:00:00.000Z", + report: documentOnly ? null : emptyReport(), + selection: selectionFromLeaves(["WEIGHT"]), + documentOnly, + token, + }), + ); + } + + it("offers both formats, scoped to the link's own token", () => { + const html = renderWithToken("hls_abc"); + expect(html).toContain('data-testid="share-downloads"'); + expect(html).toContain('href="/c/hls_abc/report.pdf"'); + expect(html).toContain('href="/c/hls_abc/fhir"'); + expect(html).toContain("Download as PDF"); + expect(html).toContain("Download as FHIR"); + }); + + it("percent-encodes the token into the download hrefs", () => { + // The token reaches the DOM as a URL segment; it is server-minted hex + // today, and the encoding is what keeps that from being load-bearing. + expect(renderWithToken("hls_a/b?c")).toContain( + 'href="/c/hls_a%2Fb%3Fc/report.pdf"', + ); + }); + + it("offers no download on a documents-only link", () => { + // There is no record behind such a link — both routes answer the same + // flat 404 — so a button that produced one would be a lie in the markup. + const html = renderWithToken("hls_abc", true); + expect(html).not.toContain('data-testid="share-downloads"'); + expect(html).not.toContain("/report.pdf"); + expect(html).not.toContain("/fhir"); + }); +}); From b59a78155b901376ae67fdc45ec331b2ca120312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 24 Aug 2026 01:10:24 +0200 Subject: [PATCH 5/9] docs(api): publish the share link's PDF and FHIR downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both routes were exempt from the contract as `shareLink` — "authenticated by the URL credential, for a recipient with no account". That reason holds for the page a person opens. It does not hold for two routes whose whole purpose is to hand a practice a file it files into another system, which is exactly the audience a contract has. `/c/{token}/d/{id}` was published all along, one route away in the same tree. Each entry states the gate it shares with the page, the frozen selection it can never exceed, the flat 404 that covers every miss class including a documents-only link, and the 20/h per-link bucket. The FHIR entry says plainly that it is a download and not a REST face, and names the two resource families the bundle deliberately omits. The create-request description claimed "there is no FHIR or other machine-readable face behind a share token", which stopped being true when these routes landed. It now describes what a link actually serves and records that the insurance leaf is refused outright. The coverage guard gains the other half of its staleness check. It caught an exemption for a route that had disappeared, and never the reverse — an exemption left behind after the route was published, which is how these two would have stayed on the list while the contract already carried them. Break-proof: re-add either entry and it goes red naming it. --- docs/api/openapi.yaml | 94 ++++++++++++++++++- .../openapi-route-coverage-guard.test.ts | 33 ++++++- src/lib/openapi/routes/health-record.ts | 69 +++++++++++++- 3 files changed, 190 insertions(+), 6 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a732bb79e..6cb8fd93b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -993,6 +993,91 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /c/{token}/report.pdf: + get: + tags: + - Export + summary: Download a share link's record as the clinical PDF (public) + description: "Anonymous, no-session download of exactly the record the page at `/c/{token}` renders — the same frozen + selection, resolved through the same `selectionFromStoredBlob`, so the file can never be wider than the page + above it. The raw `hls_` share token in the path is the ONLY credential; a passphrase-protected link + additionally requires the short-lived, token-scoped unlock cookie the browser already holds after unlocking. + Unknown / revoked / expired token, a locked gate, and a documents-only link (which carries no record at all) all + collapse to the same flat 404. Charts are embedded. Dates print in the OWNER's timezone and clock preference, + not the reader's, so a practice west of the record's own zone files a document dated the way the page it came + from was. The insurance number is never carried: the `INSURANCE` leaf is refused at share-link creation. + Rate-limited per link at 20/h — its own bucket, because generating a report is far more expensive than serving a + stored blob." + parameters: + - in: path + name: token + schema: + type: string + description: Raw `hls_` share token. + required: true + description: Raw `hls_` share token. + responses: + "200": + description: "The report as `application/pdf`, `Content-Disposition: attachment`." + content: + application/pdf: + schema: + type: string + format: binary + "404": + description: Flat 404 for every miss class (unknown / revoked / expired token, locked passphrase gate, or a + documents-only link with no record behind it). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: Per-link report-download rate limit exceeded (20/h). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /c/{token}/fhir: + get: + tags: + - Export + summary: Download a share link's record as an HL7 FHIR R4 Bundle (public) + description: "The machine-readable twin of `/c/{token}/report.pdf`: the same gate, the same frozen selection, the same + flat 404 for every miss class, the same 20/h per-link bucket. Returns an HL7 FHIR R4 **document** Bundle as a + download. This is a DOWNLOAD, not a FHIR REST face — a share token authenticates nothing beyond these three + routes and `/c/{token}/d/{id}`, and it never becomes a Bearer credential against `/api/fhir/*`. Allergies and + family history are deliberately absent from the bundle: they reach the owner's own export through a read this + surface does not perform, so the bundle carries the aggregated payload the page renders and nothing beside it. + No insurance number, for the same structural reason as the PDF." + parameters: + - in: path + name: token + schema: + type: string + description: Raw `hls_` share token. + required: true + description: Raw `hls_` share token. + responses: + "200": + description: "FHIR R4 document Bundle (`application/fhir+json`), `Content-Disposition: attachment`." + content: + application/fhir+json: + schema: + type: string + format: binary + "404": + description: Flat 404 for every miss class (unknown / revoked / expired token, locked passphrase gate, or a + documents-only link with no record behind it). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: Per-link report-download rate limit exceeded (20/h). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/fhir/metadata: get: tags: @@ -20207,9 +20292,12 @@ components: description: "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `selection` freezes which record leaves the link may serve, and omitting it means an empty scope - rather than a default one. `documentIds` freezes the documents the link carries; `documentOnly` mints a share - that serves those documents and no record scope at all. A share link serves the rendered page and the documents - frozen onto it — there is no FHIR or other machine-readable face behind a share token. Strict: unknown keys + rather than a default one. The `INSURANCE` leaf is refused outright (422, naming it): the share view has never + decrypted the insurance number, and refusing it here makes that structural. `documentIds` freezes the documents + the link carries; `documentOnly` mints a share that serves those documents and no record scope at all. A share + link serves the rendered page, the documents frozen onto it, and the same record as a PDF or FHIR Bundle + download under the token (`/c/{token}/report.pdf`, `/c/{token}/fhir`) — all behind the same gate and the same + frozen selection. A share token is not a Bearer credential and reaches no other route. Strict: unknown keys 422." EncryptedExportRequest: type: object diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 45114aa95..f1b015563 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -336,8 +336,14 @@ const UNPUBLISHED: Readonly> = { kind: "providerWebhook", methods: ["GET", "POST"], }, - "/c/{token}/fhir": { kind: "shareLink", methods: ["GET"] }, - "/c/{token}/report.pdf": { kind: "shareLink", methods: ["GET"] }, + // `/c/{token}/report.pdf` and `/c/{token}/fhir` were exempt here as + // `shareLink` and are published now. That reason holds for the page a + // recipient opens; it does not hold for two routes that serve machine + // formats a practice files into another system, which is precisely the + // audience a contract has. `/c/{token}/d/{id}` was published all along, one + // route away in the same tree, which is what makes the exemption read as an + // oversight rather than a decision. The `shareLink` reason stays: the next + // recipient-facing route is likelier to want it than not. "/.well-known/apple-app-site-association": { kind: "wellKnown", methods: ["GET"], @@ -462,6 +468,29 @@ describe("every route is published, unpublished, or retired", () => { ).toEqual([]); }); + it("the exemption list names routes that are not published after all", () => { + // The other way an entry goes stale, and the one this list had: a route + // gets published and its exemption stays behind, so the list reads as + // larger than it is and the reason attached to it looks load-bearing when + // nothing rests on it. Two share-link routes sat here in exactly that + // state, beside a published sibling in the same tree. + const table = openApiPaths as Record>; + const redundant: string[] = []; + + for (const [path, exempt] of Object.entries(UNPUBLISHED)) { + for (const method of exempt.methods) { + if (method.toLowerCase() in (table[path] ?? {})) { + redundant.push(`${method} ${path}`); + } + } + } + + expect( + redundant, + "these operations are published AND exempt, so the exemption states a reason nothing depends on", + ).toEqual([]); + }); + it("the contract does not publish a path that no route serves, unless it is retired", () => { const byPath = new Set(disk.map((route) => route.path)); const phantom = Object.keys(openApiPaths).filter( diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 995101bb0..0da6adf1f 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -36,7 +36,7 @@ import { const createShareLinkRequest = createShareLinkSchema.meta({ id: "CreateShareLinkRequest", description: - "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `selection` freezes which record leaves the link may serve, and omitting it means an empty scope rather than a default one. `documentIds` freezes the documents the link carries; `documentOnly` mints a share that serves those documents and no record scope at all. A share link serves the rendered page and the documents frozen onto it — there is no FHIR or other machine-readable face behind a share token. Strict: unknown keys 422.", + "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `selection` freezes which record leaves the link may serve, and omitting it means an empty scope rather than a default one. The `INSURANCE` leaf is refused outright (422, naming it): the share view has never decrypted the insurance number, and refusing it here makes that structural. `documentIds` freezes the documents the link carries; `documentOnly` mints a share that serves those documents and no record scope at all. A share link serves the rendered page, the documents frozen onto it, and the same record as a PDF or FHIR Bundle download under the token (`/c/{token}/report.pdf`, `/c/{token}/fhir`) — all behind the same gate and the same frozen selection. A share token is not a Bearer credential and reaches no other route. Strict: unknown keys 422.", }); const healthRecordExportRequest = exportSelectionSchema.meta({ @@ -281,6 +281,73 @@ export const healthRecordPaths: NonNullable = { }, }, }, + "/c/{token}/report.pdf": { + get: { + tags: ["Export"], + summary: "Download a share link's record as the clinical PDF (public)", + description: + "Anonymous, no-session download of exactly the record the page at `/c/{token}` renders — the same frozen selection, resolved through the same `selectionFromStoredBlob`, so the file can never be wider than the page above it. The raw `hls_` share token in the path is the ONLY credential; a passphrase-protected link additionally requires the short-lived, token-scoped unlock cookie the browser already holds after unlocking. Unknown / revoked / expired token, a locked gate, and a documents-only link (which carries no record at all) all collapse to the same flat 404. Charts are embedded. Dates print in the OWNER's timezone and clock preference, not the reader's, so a practice west of the record's own zone files a document dated the way the page it came from was. The insurance number is never carried: the `INSURANCE` leaf is refused at share-link creation. Rate-limited per link at 20/h — its own bucket, because generating a report is far more expensive than serving a stored blob.", + requestParams: { + path: z.object({ + token: z.string().describe("Raw `hls_` share token."), + }), + }, + responses: { + "200": { + description: + "The report as `application/pdf`, `Content-Disposition: attachment`.", + content: { + "application/pdf": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + "404": { + description: + "Flat 404 for every miss class (unknown / revoked / expired token, locked passphrase gate, or a documents-only link with no record behind it).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: "Per-link report-download rate limit exceeded (20/h).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/c/{token}/fhir": { + get: { + tags: ["Export"], + summary: + "Download a share link's record as an HL7 FHIR R4 Bundle (public)", + description: + "The machine-readable twin of `/c/{token}/report.pdf`: the same gate, the same frozen selection, the same flat 404 for every miss class, the same 20/h per-link bucket. Returns an HL7 FHIR R4 **document** Bundle as a download. This is a DOWNLOAD, not a FHIR REST face — a share token authenticates nothing beyond these three routes and `/c/{token}/d/{id}`, and it never becomes a Bearer credential against `/api/fhir/*`. Allergies and family history are deliberately absent from the bundle: they reach the owner's own export through a read this surface does not perform, so the bundle carries the aggregated payload the page renders and nothing beside it. No insurance number, for the same structural reason as the PDF.", + requestParams: { + path: z.object({ + token: z.string().describe("Raw `hls_` share token."), + }), + }, + responses: { + "200": { + description: + "FHIR R4 document Bundle (`application/fhir+json`), `Content-Disposition: attachment`.", + content: { + "application/fhir+json": { + schema: z.string().meta({ format: "binary" }), + }, + }, + }, + "404": { + description: + "Flat 404 for every miss class (unknown / revoked / expired token, locked passphrase gate, or a documents-only link with no record behind it).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: "Per-link report-download rate limit exceeded (20/h).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/fhir/metadata": { get: { tags: ["FHIR"], From a7452c7a6ca9369b6ebe1a58565273c73d295b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 24 Aug 2026 01:31:05 +0200 Subject: [PATCH 6/9] feat(share): name both reference windows on a diverging lab row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reading judged against the window its own report printed, beside a saved band that states different limits, is two answers about one number. The PDF names both — the range in the column, the saved band in a footnote under the table — and says in as many words that showing one of them is a partial answer. The page carried only the first. Both fit on the value line here, so there is no footnote to place. Break-proof: force the divergence branch off and the case goes red. --- .../__tests__/clinician-view.test.tsx | 31 +++++++++++++++++++ src/components/clinician/history-sections.tsx | 24 +++++++++----- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/components/clinician/__tests__/clinician-view.test.tsx b/src/components/clinician/__tests__/clinician-view.test.tsx index 91f475b2c..e04ee32b3 100644 --- a/src/components/clinician/__tests__/clinician-view.test.tsx +++ b/src/components/clinician/__tests__/clinician-view.test.tsx @@ -156,6 +156,37 @@ describe("", () => { expect(html).not.toContain("Current smoker"); }); + it("names both windows when a lab's report and the saved band disagree", () => { + // Two windows that disagree about the same number, and a card showing one + // of them is a partial answer. The PDF settles this with a footnote under + // the table; inline, both fit on the line. + const html = render( + makeReport({ + labResults: [ + { + panel: null, + analyte: "Potassium", + value: 5.2, + valueText: null, + unit: "mmol/L", + referenceLow: 3.9, + referenceHigh: 5.4, + catalogReferenceLow: 3.5, + catalogReferenceHigh: 5, + sourceReferenceText: null, + referenceOrigin: "source", + referenceDivergesFromCatalog: true, + takenAt: "2026-01-20T09:00:00.000Z", + count: 1, + }, + ], + }), + ); + expect(html).toContain("Potassium"); + expect(html).toContain("Reference 3.9–5.4"); + expect(html).toContain("saved range 3.5–5"); + }); + it("renders the fenced wellness card with the descriptive disclaimer", () => { const html = render(makeReport()); expect(html).toContain("Wellness scores"); diff --git a/src/components/clinician/history-sections.tsx b/src/components/clinician/history-sections.tsx index ec3963162..d26545b5d 100644 --- a/src/components/clinician/history-sections.tsx +++ b/src/components/clinician/history-sections.tsx @@ -69,6 +69,10 @@ export function LabResultsSection({ const reading = qualitative ? lab.valueText : `${fmtNum(lab.value as number)} ${lab.unit}`.trim(); + const range = (low: number | null, high: number | null) => + formatReferenceRange(low, high, (value) => String(fmtNum(value)), { + emptyText: "", + }); // The window the reading was judged against, printed as the source // report printed it when that is where it came from, so a clinician // comparing against the original reads the same characters. @@ -76,12 +80,15 @@ export function LabResultsSection({ ? null : lab.referenceOrigin === "source" && lab.sourceReferenceText ? lab.sourceReferenceText - : formatReferenceRange( - lab.referenceLow, - lab.referenceHigh, - (value) => String(fmtNum(value)), - { emptyText: "" }, - ); + : range(lab.referenceLow, lab.referenceHigh); + // When the report's own window and the saved band disagree about the + // same number, both are named. Showing one of two disagreeing windows + // is a partial answer, and the PDF's footnote says so; here it fits on + // the line rather than under a table. + const catalog = + !qualitative && lab.referenceDivergesFromCatalog + ? range(lab.catalogReferenceLow, lab.catalogReferenceHigh) + : ""; return ( Date: Mon, 24 Aug 2026 01:34:32 +0200 Subject: [PATCH 7/9] test(share): render the clinician view in all six locales MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most labels on this page are resolved from a runtime value, not written as a literal: an illness type, a lifecycle, a visit kind, a vaccine slug, a blood-type constant, a cycle phase, a GLP-1 side-effect tag. The call-site guard cannot see any of them, and the enum-derived guard can only cover a space whose members it can enumerate from a source. A key that does not exist therefore renders as its own dot notation, in front of a doctor, with every other guard green — which is exactly how `encounters.kind.ROUTINE` reached three surfaces. One fixture with every section populated, six renders, one assertion: nothing in the reader-visible text may look like a dotted key path. Break-proof: put the interpolated visit-kind key back and all six go red naming it. --- .../__tests__/clinician-view-locales.test.tsx | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 src/components/clinician/__tests__/clinician-view-locales.test.tsx diff --git a/src/components/clinician/__tests__/clinician-view-locales.test.tsx b/src/components/clinician/__tests__/clinician-view-locales.test.tsx new file mode 100644 index 000000000..6d8e6e188 --- /dev/null +++ b/src/components/clinician/__tests__/clinician-view-locales.test.tsx @@ -0,0 +1,253 @@ +/** + * The clinician view renders in all six shipped locales with no raw i18n key + * reaching the reader. + * + * Most of this page's labels are resolved from a runtime value rather than + * written as a literal: an illness type, a lifecycle, a visit kind, a vaccine + * slug, a blood-type constant, a cycle phase, a GLP-1 side-effect tag. None of + * those are visible to `i18n-call-site-coverage`, which only reads literal + * `t("ns.key")` calls, and the enum-derived guard can only cover a key space + * whose members it can enumerate from a source. So a key that does not exist + * renders as its own dot notation — `encounters.kind.ROUTINE`, verbatim, on a + * page a doctor is reading — and every other guard stays green. + * + * This one renders the whole surface, strips the markup, and fails on anything + * left in the text that looks like a dotted key path. One fixture with every + * section populated, six locales, one assertion. + * + * Mutation check: replace any `t(resolver(x))` call in the section files with + * `t(\`ns.${x}\`)` over an enum whose members are not the bundle's leaf names, + * and all six cases go red naming the leaked key. + */ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ClinicianView } from "../clinician-view"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { computeGlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics"; +import { ALL_LEAF_IDS } from "@/lib/report-selection/catalogue"; +import { selectionFromLeaves } from "@/lib/report-selection/selection"; +import { locales } from "@/lib/i18n/config"; +import type { DoctorReportData } from "@/lib/doctor-report-data"; + +/** + * One record with every section carrying something, so a single render walks + * every branch that resolves a label from a value. + */ +const FULL_RECORD = { + period: { + days: 30, + since: "2026-01-01T00:00:00.000Z", + start: "2026-01-01T00:00:00.000Z", + end: "2026-01-31T00:00:00.000Z", + }, + patient: { + username: "shared-account", + dateOfBirth: "1980-01-01T00:00:00.000Z", + gender: "FEMALE", + heightCm: 170, + fullName: "A. Patient", + }, + practiceName: null, + measurements: {}, + stats: { WEIGHT: { avg: 80, min: 78, max: 82, count: 3, latest: 79 } }, + glucoseStats: { + FASTING: { avg: 96, min: 88, max: 104, count: 5, latest: 94 }, + }, + glucoseRanges: {}, + glucoseClinical: computeGlucoseClinicalMetrics([], { + now: new Date("2026-01-31T00:00:00.000Z"), + }), + glucoseUnit: "mg/dL", + bmi: 24.5, + compliance: { Metformin: { total: 60, taken: 57, skipped: 1, missed: 2 } }, + medications: [{ name: "Ramipril", dose: "5 mg", schedules: [] }], + medicationAdministrations: [ + { + medicationName: "Ramipril", + effectiveAt: "2026-01-30T07:10:00.000Z", + status: "completed", + doseText: "5 mg", + dose: null, + injectionSite: null, + atcCode: null, + rxNormCode: null, + deliveryForm: "ORAL", + }, + ], + glp1: { + medications: [ + { + name: "Semaglutide", + currentDose: { + value: 1, + unit: "mg", + since: "2026-01-05T00:00:00.000Z", + }, + doseHistory: [ + { + value: 0.5, + unit: "mg", + effectiveFrom: "2025-12-01T00:00:00.000Z", + note: null, + }, + ], + lastInjection: { date: "2026-01-28T00:00:00.000Z", site: "ABDOMEN" }, + compliance: { taken: 4, total: 4 }, + }, + ], + weightDeltaKg: -3, + weightStartKg: 83, + weightEndKg: 80, + sideEffects: [{ tag: "nausea", count: 2 }], + }, + mood: { + avg: 3.4, + min: 2, + max: 5, + count: 22, + distribution: { 1: 0, 2: 3, 3: 9, 4: 7, 5: 3 }, + }, + cycle: { + lastPeriodStart: "2026-01-09", + recentCycles: [ + { startDate: "2026-01-09", lengthDays: 29, periodLengthDays: 5 }, + ], + observedCycleCount: 1, + averageCycleLengthDays: 29, + cycleLengthVariabilityDays: 1.5, + averagePeriodLengthDays: 5, + currentPhase: "LUTEAL", + }, + labResults: [ + { + panel: "Blood count", + analyte: "Ferritin", + value: 42, + valueText: null, + unit: "ng/mL", + referenceLow: 30, + referenceHigh: 400, + catalogReferenceLow: 30, + catalogReferenceHigh: 400, + sourceReferenceText: null, + referenceOrigin: "catalog", + referenceDivergesFromCatalog: false, + takenAt: "2026-01-20T09:00:00.000Z", + count: 1, + }, + ], + illnessEpisodes: [ + { + label: "Sinusitis", + type: "INFECTION", + lifecycle: "ACUTE", + onsetAt: "2026-01-08T00:00:00.000Z", + resolvedAt: null, + }, + ], + visits: [ + { + occurredAt: "2026-01-14T08:30:00.000Z", + kind: "SPECIALIST", + status: "DONE", + practitionerName: "Cardiology outpatients", + practitionerSpecialty: "Cardiology", + reason: "Palpitations", + outcome: "Follow up in three months", + conditionLabels: ["Sinusitis"], + }, + ], + immunizations: [ + { + occurredAt: "2025-11-03T00:00:00.000Z", + antigenSlug: "tetanus", + vaccineName: null, + lotNumber: "LOT-7781", + site: null, + practitionerName: null, + series: [{ antigen: "tetanus", position: 3, total: 4, booster: false }], + }, + ], + allergies: [ + { + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + reaction: "Hives", + reactionUnreadable: false, + }, + ], + familyHistory: [ + { relationship: "MOTHER", condition: "Type 2 diabetes", ageAtOnset: 54 }, + ], + anamnesis: { + conditions: "Hypothyroidism", + conditionsUnreadable: false, + smokingStatus: "FORMER", + alcoholPattern: "OCCASIONAL", + shiftSchedule: "ROTATING", + unreadableFacts: [], + }, + emergency: { + bloodType: "O_NEG", + organDonor: "YES", + advanceDirective: "EXISTS", + contacts: "Next of kin, 555 0100", + contactsUnreadable: false, + implants: "Pacemaker", + implantsUnreadable: false, + note: "Carries an emergency card", + noteUnreadable: false, + }, + wellnessScores: [ + { + type: "RECOVERY_SCORE", + latest: 72, + avg: 68, + min: 50, + max: 90, + count: 20, + latestAt: "2026-01-30T00:00:00.000Z", + }, + ], +} as unknown as DoctorReportData; + +/** + * `a.b.c` and deeper, lowercase-initial: the shape an unresolved key falls + * through as. Two segments would catch ordinary prose ("e.g. this"), so the + * floor is three — every namespace on this page is at least that deep. + */ +const DOTTED_KEY = /\b[a-z][A-Za-z0-9]*(\.[A-Za-z0-9_]+){2,}\b/g; + +describe(" resolves every label in every locale", () => { + it("covers all six shipped locales", () => { + // A floor: an empty locale list would make the loop below assert nothing. + expect(locales.length).toBe(6); + }); + + for (const locale of locales) { + it(`leaks no raw i18n key in ${locale}`, () => { + const { t } = getServerTranslator(locale); + const html = renderToStaticMarkup( + ClinicianView({ + t: (key, vars) => t(key, vars), + label: "Clinic", + expiresAt: "2026-03-01T00:00:00.000Z", + report: FULL_RECORD, + selection: selectionFromLeaves(ALL_LEAF_IDS), + locale, + }), + ); + // Strip the tags, so the `data-leaf` enum lists and the class names go + // with them and only what a reader sees is left. + const text = html.replace(/<[^>]*>/g, " "); + expect( + text.match(DOTTED_KEY) ?? [], + `these keys rendered as their own dot notation in ${locale}`, + ).toEqual([]); + }); + } +}); From d7d8e437eedc87f55b34cd646257a5dfc30d6bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 24 Aug 2026 02:00:55 +0200 Subject: [PATCH 8/9] feat(insights): let a caller choose the derived-metric window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/insights/derived` accepts `windowDays`, bounded to 1..90, and threads it to the dispatcher. Omitting it changes nothing: every engine keeps its own default, so a caller that asks for nothing reads exactly what it read before. Ninety is where the tier this route promises runs out. The rollup router opens its WEEK floor at 91 days, and the baseline engines cannot compose a spread from WEEK buckets, so a 91-day request falls through to a raw `measurement.findMany` with no row cap — for a densely sampled type that is a full history scan, and the analytics-read budget allows 120 of them a minute per account. The trailing `series` is capped at 30 points regardless, so past the ceiling a wider window would only broaden the mean behind the value. Out of range is a 422 rather than a clamp. An unhonoured window is visible rather than silent. `provenance.windowDays` is the window the engine actually used and `coverage.historyDays` the days that actually backed it, so a three-week record answering a thirty-day request reports both numbers instead of implying coverage it does not have. `HEALTH_SCORE` composes fixed per-pillar windows and reports the widest; a new suite freezes that it is the only metric that does, so an arm added later cannot quietly drop the parameter. Recovery keeps its canonical resolution under the wider window. A worn band stamps the wake morning and the computed proxy the night before, and only `resolveCanonicalRecovery` pairs them; a test seeds forty such nights, asks for sixty days, and holds the answer to forty nights with a flat trend — a bypass leaves eighty rows and a false climb. Contract: model `WellnessScoreValue` field for field and publish it. The envelope's `value` has to stay an open record because one route serves eighteen differently-shaped metrics, so nothing can `$ref` the shape and it went out undocumented — `series`, `daysInWindow` and `asOf` were on the wire with no way to find them short of inspecting a live response. It is registered in the forced-components slot instead, held against the runtime interface by a test, and the fields it does not cover are named in the schema comment so the remaining gap is known rather than invisible. --- docs/api/openapi.yaml | 122 ++++++++++++++++- .../derived-wellness-value-contract.test.ts | 115 ++++++++++++++++ .../insights/derived/__tests__/route.test.ts | 65 +++++++++ src/app/api/insights/derived/route.ts | 30 +++- .../__tests__/dispatch-window-days.test.ts | 129 ++++++++++++++++++ .../derived/__tests__/wellness-scores.test.ts | 72 ++++++++++ src/lib/insights/derived/types.ts | 26 ++++ src/lib/openapi/routes/index.ts | 12 +- src/lib/openapi/routes/insights/index.ts | 1 + src/lib/openapi/routes/insights/paths.ts | 2 +- src/lib/openapi/routes/insights/schemas.ts | 94 ++++++++++++- 11 files changed, 659 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/derived-wellness-value-contract.test.ts create mode 100644 src/lib/insights/derived/__tests__/dispatch-window-days.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a732bb79e..09a513bb4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -10416,7 +10416,9 @@ paths: coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine - values across metrics. Auth via cookie or Bearer. + values across metrics. `windowDays` widens or narrows the trailing window a metric summarises; + `provenance.windowDays` always reports the window actually used and `coverage.historyDays` the days that + actually backed it. Auth via cookie or Bearer. parameters: - in: query name: metric @@ -10478,6 +10480,30 @@ paths: the four cumulative day metrics — `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `WALKING_RUNNING_DISTANCE`, `FLIGHTS_CLIMBED`. Ignored by the composite metrics. A type the named metric does not support yields an `insufficient` value rather than a 422, so client metric combinations stay forgiving. + - in: query + name: windowDays + schema: + description: "Trailing window the metric summarises, in days (1–90). Omit it and each metric keeps its own default — 14 + days for the wellness-score trend, 30 for the vitals baselines, 90 for BMI, 180 for cardio fitness, 365 + for the vascular-age delta. A value outside the range is a 422, never a silent clamp; the ceiling is where + the derived tier stops resolving against DAY rollup buckets and would fall back to an uncapped raw read. + Always read `provenance.windowDays` back: it is the window the engine ACTUALLY used, and `HEALTH_SCORE` + composes fixed per-pillar windows and reports the widest of them rather than the one you asked for. The + window is a request, not a promise about coverage — `coverage.historyDays` is how many days of record + actually backed the answer, and it is smaller than `windowDays` for anyone whose history is shorter than + the window. Not accepted on the batch route, which always runs the engine defaults." + type: integer + minimum: 1 + maximum: 90 + description: "Trailing window the metric summarises, in days (1–90). Omit it and each metric keeps its own default — 14 + days for the wellness-score trend, 30 for the vitals baselines, 90 for BMI, 180 for cardio fitness, 365 for + the vascular-age delta. A value outside the range is a 422, never a silent clamp; the ceiling is where the + derived tier stops resolving against DAY rollup buckets and would fall back to an uncapped raw read. Always + read `provenance.windowDays` back: it is the window the engine ACTUALLY used, and `HEALTH_SCORE` composes + fixed per-pillar windows and reports the widest of them rather than the one you asked for. The window is a + request, not a promise about coverage — `coverage.historyDays` is how many days of record actually backed + the answer, and it is smaller than `windowDays` for anyone whose history is shorter than the window. Not + accepted on the batch route, which always runs the engine defaults." responses: "200": description: The flat derived-metric value (ok or insufficient). @@ -51988,9 +52014,14 @@ components: type: string additionalProperties: {} - type: "null" - description: Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, - series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); - null when 'insufficient'. + description: Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by + `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / + `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in + `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to + discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, + sampleDays, k, series }, where `series` is the trailing per-day means for the inline sparkline. The + remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside + their engines under `src/lib/insights/derived/`. coverage: $ref: "#/components/schemas/DerivedCoverage" confidence: @@ -68041,6 +68072,89 @@ components: group at that index, un-placed tags follow in their home category. Display-only — placements referencing hidden/archived/unknown keys are silently dropped at read time. Both fields optional: PUT merges preserve-when-absent." + WellnessScoreValue: + type: object + properties: + score: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The latest persisted 0–100 score. + band: + type: string + enum: + - green + - yellow + - red + description: "Server's verdict on the score. Direction-aware: recovery bands high-is-good, stress and strain invert, so + a client must never re-band the number itself." + trendDelta: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + description: Score minus the mean of the earlier days in the window; null when the window holds only one day. + daysInWindow: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days that actually carried a score inside the window. Compare against `provenance.windowDays` — a shorter + record answers a wide request with fewer days, and this is where that shows. + asOf: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: "`measuredAt` of the latest score." + series: + type: array + items: + type: number + description: "Trailing scores, oldest → newest, capped at 30 points however wide the window is. For RECOVERY these are + the canonical per-night values: a worn band and the server's own proxy write the same night on different + clocks, and the server collapses them to one value per night before this array is built." + anchor: + description: STRAIN only — whether this score was judged against the user's own training history or the cold-start + population reference. Null for recovery and stress. + anyOf: + - type: string + enum: + - personal + - population + - type: "null" + components: + description: RECOVERY only, and only when the canonical value is the server's computed proxy — a device-native recovery + percentage is not our blend and carries no decomposition. + anyOf: + - type: array + items: + type: object + properties: + key: + type: string + description: Contributor id, e.g. rhr / hrv / sleep. + value: + anyOf: + - type: number + - type: "null" + description: 0–100 sub-score, null when the input was missing. + weight: + type: number + description: Effective weight after redistributing missing inputs. + required: + - key + - value + - weight + additionalProperties: false + - type: "null" + required: + - score + - band + - trendDelta + - daysInWindow + - asOf + - series + additionalProperties: false parameters: AccountSelector: name: X-HealthLog-Account diff --git a/src/__tests__/derived-wellness-value-contract.test.ts b/src/__tests__/derived-wellness-value-contract.test.ts new file mode 100644 index 000000000..9034c98da --- /dev/null +++ b/src/__tests__/derived-wellness-value-contract.test.ts @@ -0,0 +1,115 @@ +/** + * The published `WellnessScoreValue` schema against the value the engine + * actually returns. + * + * `GET /api/insights/derived` types its `value` as an open record — one + * envelope carries eighteen differently-shaped metrics, so no single `$ref` + * fits — which meant every metric's payload shape was undocumented and a + * client had to inspect a live response to learn what was in it. The three + * wellness scores are modelled now, published through the forced-components + * slot, and this holds the published shape to the runtime one: a field added + * to the engine and not to the schema, or the reverse, fails here rather than + * shipping as a spec that describes a payload nobody sends. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + strainTrimpCache: { findUnique: vi.fn().mockResolvedValue(null) }, + user: { findUnique: vi.fn().mockResolvedValue({ timezone: "UTC" }) }, + }, +})); +vi.mock("@/lib/insights/derived/readiness", () => ({ + computeReadiness: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { computeReadiness } from "@/lib/insights/derived/readiness"; +import { computeWellnessScore } from "@/lib/insights/derived/wellness-scores"; +import { wellnessScoreValue } from "@/lib/openapi/routes/insights/schemas"; +import { openApiComponents } from "@/lib/openapi/routes"; + +const findMany = prisma.measurement.findMany as ReturnType; +const NOW = new Date("2026-06-02T08:00:00Z"); +const PROFILE = { ageYears: 40, sex: "MALE" as const }; + +beforeEach(() => { + vi.mocked(computeReadiness).mockResolvedValue({ + status: "ok", + value: { + score: 74, + band: "green", + components: [ + { key: "rhr", value: 90, weight: 0.5 }, + { key: "hrv", value: null, weight: 0 }, + ], + }, + } as never); +}); + +describe("published WellnessScoreValue", () => { + it("is registered as a component even though nothing $refs it", () => { + expect(openApiComponents.schemas).toHaveProperty("WellnessScoreValue"); + expect(openApiComponents.schemas?.WellnessScoreValue).toBe( + wellnessScoreValue, + ); + }); + + it("accepts the recovery value the engine emits, field for field", async () => { + findMany.mockResolvedValue([ + { + value: 72.4, + measuredAt: new Date("2026-06-01T12:00:00Z"), + source: "COMPUTED", + }, + { + value: 60, + measuredAt: new Date("2026-05-31T12:00:00Z"), + source: "COMPUTED", + }, + ]); + const derived = await computeWellnessScore( + "RECOVERY_SCORE", + "u1", + PROFILE, + { now: NOW }, + ); + expect(derived.status).toBe("ok"); + if (derived.status !== "ok") return; + + const parsed = wellnessScoreValue.safeParse(derived.value); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(parsed.success).toBe(true); + + // Not just "parses" — the published field list and the emitted one are the + // same set. A permissive schema would pass the parse above while quietly + // omitting a field, which is the failure this whole change is about. + expect(Object.keys(wellnessScoreValue.shape).sort()).toEqual( + Object.keys(derived.value as unknown as Record).sort(), + ); + }); + + it("accepts the strain value, whose anchor the other two never carry", async () => { + findMany.mockResolvedValue([ + { + value: 55, + measuredAt: new Date("2026-06-01T12:00:00Z"), + source: "COMPUTED", + }, + ]); + vi.mocked(prisma.strainTrimpCache.findUnique).mockResolvedValue({ + anchor: "personal", + } as never); + const derived = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(derived.status).toBe("ok"); + if (derived.status !== "ok") return; + const parsed = wellnessScoreValue.safeParse(derived.value); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(Object.keys(wellnessScoreValue.shape).sort()).toEqual( + Object.keys(derived.value as unknown as Record).sort(), + ); + }); +}); diff --git a/src/app/api/insights/derived/__tests__/route.test.ts b/src/app/api/insights/derived/__tests__/route.test.ts index d787c2475..2a3f56e68 100644 --- a/src/app/api/insights/derived/__tests__/route.test.ts +++ b/src/app/api/insights/derived/__tests__/route.test.ts @@ -89,6 +89,7 @@ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { computeDerivedMetric } from "@/lib/insights/derived"; +import { DERIVED_MAX_WINDOW_DAYS } from "@/lib/insights/derived/types"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -108,6 +109,14 @@ function makeReq(metric?: string, type?: string): NextRequest { return new NextRequest(url); } +function makeReqWith(params: Record): NextRequest { + const url = new URL("http://localhost/api/insights/derived"); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return new NextRequest(url); +} + beforeEach(() => { vi.clearAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); @@ -205,3 +214,59 @@ describe("GET /api/insights/derived", () => { expect(body.data?.reason).toBe("no_readings_in_window"); }); }); + +describe("GET /api/insights/derived — windowDays", () => { + beforeEach(() => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + }); + + it("threads a caller-supplied window into the compute call", async () => { + await callGet(makeReqWith({ metric: "VITALS_BASELINE", windowDays: "30" })); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ metric: "VITALS_BASELINE", windowDays: 30 }), + ); + }); + + it("leaves the window undefined when the caller omits it, so each engine keeps its own default", async () => { + await callGet(makeReq("VITALS_BASELINE")); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ windowDays: undefined }), + ); + }); + + it("accepts the ceiling exactly", async () => { + const res = await callGet( + makeReqWith({ + metric: "VITALS_BASELINE", + windowDays: String(DERIVED_MAX_WINDOW_DAYS), + }), + ); + expect(res.status).toBe(200); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ windowDays: DERIVED_MAX_WINDOW_DAYS }), + ); + }); + + it("422s one day past the ceiling without touching the compute layer", async () => { + const res = await callGet( + makeReqWith({ + metric: "VITALS_BASELINE", + windowDays: String(DERIVED_MAX_WINDOW_DAYS + 1), + }), + ); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("422s on a zero, negative, fractional or non-numeric window", async () => { + for (const windowDays of ["0", "-7", "1.5", "thirty", ""]) { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet( + makeReqWith({ metric: "VITALS_BASELINE", windowDays }), + ); + expect(res.status, `windowDays=${windowDays}`).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + } + }); +}); diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 7d6b716e9..4d904aa4c 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -1,7 +1,7 @@ /** * v1.10.0 — generic derived-wellness-metric route. * - * `GET /api/insights/derived?metric=[&type=]` + * `GET /api/insights/derived?metric=[&type=][&windowDays=N]` * serves the compute-once `Derived` value for any metric registered * in `derived/registry.ts`. Mirrors the v1.8.7.1 `metric-status` route * precedent: `apiHandler` wrapper, Zod `safeParse` on the query, a closed @@ -33,6 +33,7 @@ import { DERIVED_METRIC_IDS, type DerivedMetricId, } from "@/lib/insights/derived"; +import { DERIVED_MAX_WINDOW_DAYS } from "@/lib/insights/derived/types"; import { resolveDerivedAssessment } from "@/lib/insights/derived/derived-assessment-ai"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; @@ -66,6 +67,20 @@ const derivedQuerySchema = z.object({ // the dispatcher; an unsupported value yields an `insufficient`, not a // 422, so the contract stays forgiving for iOS combinations. type: z.string().optional(), + // Trailing window override, in days. Absent means "engine default" — each + // compute keeps its own (14 for the wellness trend, 30 for the baselines, a + // year for vascular age), so omitting the parameter changes nothing. The + // ceiling is the point past which the derived engines stop resolving against + // DAY rollup buckets and degrade to an uncapped raw read; the reasoning is + // written out at `DERIVED_MAX_WINDOW_DAYS`. Out of range is a 422 rather + // than a silent clamp: a caller that asked for a year should learn that it + // is not on offer, not read back a number it did not choose. + windowDays: z.coerce + .number() + .int() + .min(1) + .max(DERIVED_MAX_WINDOW_DAYS) + .optional(), }); export const GET = apiHandler(async (request: NextRequest) => { @@ -88,6 +103,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const parsed = derivedQuerySchema.safeParse({ metric: request.nextUrl.searchParams.get("metric"), type: request.nextUrl.searchParams.get("type") ?? undefined, + windowDays: request.nextUrl.searchParams.get("windowDays") ?? undefined, }); if (!parsed.success) { annotate({ @@ -113,11 +129,21 @@ export const GET = apiHandler(async (request: NextRequest) => { userId: user.id, profile, type: parsed.data.type ?? null, + windowDays: parsed.data.windowDays, }); annotate({ action: { name: "insights.derived" }, - meta: { metric, status: derived.status }, + meta: { + metric, + status: derived.status, + // Both halves of the window story: what the caller asked for (null when + // they asked for nothing) and what the engine actually read. They differ + // for a metric that composes fixed per-pillar windows, and a dashboard + // that sees them drift apart is looking at a real answer, not a bug. + window_days_requested: parsed.data.windowDays ?? null, + window_days_effective: derived.provenance.windowDays, + }, }); // v1.13.2 — additive per-score assessment: a short "why is this score what diff --git a/src/lib/insights/derived/__tests__/dispatch-window-days.test.ts b/src/lib/insights/derived/__tests__/dispatch-window-days.test.ts new file mode 100644 index 000000000..1a6618097 --- /dev/null +++ b/src/lib/insights/derived/__tests__/dispatch-window-days.test.ts @@ -0,0 +1,129 @@ +/** + * `windowDays` is a route parameter now, and a route parameter that a + * dispatch arm quietly drops is worse than no parameter at all — the caller + * asks for sixty days, gets fourteen, and has nothing in the payload to tell + * them apart. This suite freezes the two halves of that contract: + * + * 1. Every dispatch arm that CAN honour a caller-supplied window does, and + * says so in `provenance.windowDays`. An arm added later that forgets to + * thread `args.windowDays` fails here rather than shipping silently. + * 2. The arms that structurally cannot honour it are named, and their + * `provenance.windowDays` still reports the window they actually used — + * so a client comparing what it asked for against what came back can + * see the difference instead of guessing. + * + * The check is behavioural, not a grep over the dispatcher's source: it calls + * each metric with an unmistakable window and reads the answer back. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: vi.fn().mockResolvedValue([]), + findFirst: vi.fn().mockResolvedValue(null), + }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + strainTrimpCache: { findUnique: vi.fn().mockResolvedValue(null) }, + intradayCumulativeProfile: { findMany: vi.fn().mockResolvedValue([]) }, + user: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: vi.fn().mockResolvedValue(null), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn().mockResolvedValue("UTC"), +})); +// HEALTH_SCORE recomposes the whole pillar report; stub it at the adapter +// boundary and hand back a provenance whose window is one the caller could +// never have asked for, so "it ignored me" is observable rather than inferred. +vi.mock("@/lib/analytics/score/derived", () => ({ + computeHealthScoreDerived: vi.fn(async () => ({ + status: "insufficient" as const, + coverage: { + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [], + }, + provenance: { + inputs: [], + source: "none" as const, + windowDays: 365, + computedAt: "2026-06-02T09:00:00+02:00", + }, + reason: "no_pillars", + })), +})); + +import { computeDerivedMetric } from "../dispatch"; +import { DERIVED_METRIC_IDS, getDerivedMetricMeta } from "../registry"; +import type { DerivedMetricId } from "../registry"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T07:00:00Z"); +/** A window no engine uses as a default, so an echo cannot be a coincidence. */ +const ASKED = 77; + +/** + * Dispatch arms that structurally cannot honour a caller-supplied window. + * + * `HEALTH_SCORE` is a composite of pillar reads, each pinned to the window its + * own clinical convention requires (an HbA1c pillar does not become a 77-day + * question because the caller typed 77). Its `provenance.windowDays` reports + * the widest pillar window it actually used, which is the honest answer. + * + * Adding an id here is a deliberate act: it means the parameter is documented + * as inert for that metric, not that threading it was forgotten. + */ +const WINDOW_AGNOSTIC: DerivedMetricId[] = ["HEALTH_SCORE"]; + +beforeEach(() => vi.clearAllMocks()); + +describe("derived dispatch — windowDays", () => { + const implemented = DERIVED_METRIC_IDS.filter( + (id) => getDerivedMetricMeta(id)?.implemented === true, + ); + + it("has metrics to check (an empty sweep would pass vacuously)", () => { + expect(implemented.length).toBeGreaterThan(10); + }); + + it.each(implemented.filter((id) => !WINDOW_AGNOSTIC.includes(id)))( + "%s reports back the window it was asked for", + async (metric) => { + const derived = await computeDerivedMetric({ + metric, + userId: "u1", + profile: PROFILE, + windowDays: ASKED, + now: NOW, + }); + // A stub arm would answer `not_implemented` with a zero window; the + // registry says these are implemented, so that would be the drift. + expect( + derived.status === "insufficient" ? derived.reason : "ok", + ).not.toBe("not_implemented"); + expect(derived.provenance.windowDays).toBe(ASKED); + }, + ); + + it.each(WINDOW_AGNOSTIC)( + "%s ignores the window but still reports the one it used", + async (metric) => { + const derived = await computeDerivedMetric({ + metric, + userId: "u1", + profile: PROFILE, + windowDays: ASKED, + now: NOW, + }); + expect(derived.provenance.windowDays).not.toBe(ASKED); + expect(derived.provenance.windowDays).toBeGreaterThan(0); + }, + ); +}); diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts index ce8ae1d1b..e7106d71a 100644 --- a/src/lib/insights/derived/__tests__/wellness-scores.test.ts +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -21,6 +21,7 @@ import { bandWellnessScore, type WellnessScoreValue, } from "../wellness-scores"; +import { SPARKLINE_MAX_POINTS } from "../types"; const PROFILE = { ageYears: 40, sex: "MALE" as const }; const NOW = new Date("2026-06-02T08:00:00Z"); @@ -228,6 +229,77 @@ describe("computeWellnessScore", () => { expect(readinessMock).not.toHaveBeenCalled(); }); + it("a widened window still collapses every night through the canonical resolver", async () => { + // The wake-day trap: a worn band stamps the wake morning, the COMPUTED + // proxy stamps the night that ended, so ONE night arrives as two rows a + // calendar day apart. Only `resolveCanonicalRecovery` pairs them. With the + // window fixed at 14 days a bypass would be invisible in most fixtures, so + // this seeds FORTY such nights and asks for sixty days. + const NIGHTS = 40; + const rows: Array<{ value: number; measuredAt: Date; source: string }> = []; + for (let back = 0; back < NIGHTS; back += 1) { + const wake = new Date("2026-06-02T06:00:00Z"); + wake.setUTCDate(wake.getUTCDate() - back); + const priorNoon = new Date("2026-06-01T12:00:00Z"); + priorNoon.setUTCDate(priorNoon.getUTCDate() - back); + rows.push({ value: 80, measuredAt: wake, source: "WHOOP" }); + rows.push({ value: 50, measuredAt: priorNoon, source: "COMPUTED" }); + } + // The reader orders newest-first; mirror that. + rows.sort((a, b) => b.measuredAt.getTime() - a.measuredAt.getTime()); + findMany.mockResolvedValue(rows); + + const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, { + now: NOW, + windowDays: 60, + }); + + expect(r.status).toBe("ok"); + if (r.status !== "ok") return; + const v = r.value as WellnessScoreValue; + // Forty nights, not eighty rows — the resolver ran over the whole window, + // not just its head. + expect(v.daysInWindow).toBe(NIGHTS); + expect(r.coverage.historyDays).toBe(NIGHTS); + // Every canonical row is the WHOOP 80, so the trend against the prior + // nights is flat. A bypass would leave the COMPUTED 50s in the mean and + // push this to roughly +15. + expect(v.score).toBe(80); + expect(v.trendDelta).toBe(0); + expect(v.series.every((point) => point === 80)).toBe(true); + // The sparkline stays capped however wide the window gets. + expect(v.series.length).toBe(SPARKLINE_MAX_POINTS); + // The read itself honoured the sixty days. + const where = findMany.mock.calls[0][0].where as { + measuredAt: { gte: Date }; + }; + const spanDays = Math.round( + (NOW.getTime() - where.measuredAt.gte.getTime()) / (24 * 60 * 60 * 1000), + ); + expect(spanDays).toBe(60); + }); + + it("reports the requested window and the days actually covered as two different numbers", async () => { + // Three weeks of record, thirty days asked for. The window is a request, + // not a promise: provenance carries what was asked, coverage carries what + // backed the answer. + const rows = Array.from({ length: 21 }, (_, back) => { + const at = new Date("2026-06-01T12:00:00Z"); + at.setUTCDate(at.getUTCDate() - back); + return { value: 60, measuredAt: at, source: "COMPUTED" }; + }); + findMany.mockResolvedValue(rows); + const r = await computeWellnessScore("STRESS_SCORE", "u1", PROFILE, { + now: NOW, + windowDays: 30, + }); + expect(r.status).toBe("ok"); + if (r.status !== "ok") return; + expect(r.provenance.windowDays).toBe(30); + expect(r.coverage.historyDays).toBe(21); + expect((r.value as WellnessScoreValue).daysInWindow).toBe(21); + }); + it("STRESS still hard-filters to the COMPUTED source", async () => { findMany.mockResolvedValue([ { diff --git a/src/lib/insights/derived/types.ts b/src/lib/insights/derived/types.ts index 434dd67f5..6a36f04b4 100644 --- a/src/lib/insights/derived/types.ts +++ b/src/lib/insights/derived/types.ts @@ -93,6 +93,32 @@ export interface DerivedInsufficient { */ export const SPARKLINE_MAX_POINTS = 30; +/** + * Ceiling on a CALLER-SUPPLIED trailing window (`?windowDays=` on + * `GET /api/insights/derived`). Engine defaults are untouched by it — a metric + * whose own default is wider (vascular age reads a year, cardio fitness half of + * one) keeps reading that far when the caller names no window. This bounds only + * what a request may ask for. + * + * Ninety is where the tier this route promises runs out, not a tidy number. + * `readBestGranularityRollups` walks its granularity floors coarsest-first and + * the WEEK floor opens at 91 days, so a 91-day request resolves to WEEK buckets + * for any account with rollup coverage. The baseline engines cannot use those — + * a spread composed from WEEK `sd` is not the same statistic — so they reject + * the coarser tier and fall through to the per-type live read, which is a raw + * `measurement.findMany` with no row cap. For a densely sampled type (an + * Apple-Health heart-rate stream is hundreds of rows a day) that is the whole + * history scan this route was built to avoid, and the analytics-read budget + * allows 120 of them a minute per account. One day either side of the floor is + * therefore the difference between a bounded bucket read and an unbounded one. + * + * Nothing renderable is lost at the ceiling: a trailing `series` is capped to + * `SPARKLINE_MAX_POINTS` regardless, so past thirty days a wider window only + * broadens the trend mean behind the value, and ninety days is already three + * times what the sparkline can show and six times the trend default. + */ +export const DERIVED_MAX_WINDOW_DAYS = 90; + export type Derived = DerivedOk | DerivedInsufficient; /** Narrowing type guard — `true` when the value computed successfully. */ diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index ba4a04da3..df326ba58 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -57,7 +57,7 @@ import { encounterPaths } from "./encounters"; import { vaccinationPaths } from "./vaccinations"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; -import { insightsPaths } from "./insights"; +import { insightsPaths, wellnessScoreValue } from "./insights"; import { integrationPaths } from "./integrations"; import { insightsSignalPaths } from "./insights-signals"; import { labsPaths } from "./labs"; @@ -200,12 +200,22 @@ export const openApiComponents: NonNullable = { // but every actual consumer is a `.extend()` of it (`PutCoachPrefsRequest`, // `CoachPrefsResponse`) — `.extend()` builds a new object, so the base // never carries forward to a $ref on its own. + // + // `WellnessScoreValue` is here for a different reason. `GET + // /api/insights/derived` serves eighteen metrics through one envelope, so + // its `value` has to stay an open record — no single `$ref` can describe + // it. That left every metric's payload undocumented, and a client team read + // the DTO and reported two fields as missing that were already on the wire. + // Publishing the shape a `$ref` cannot reach is exactly what this slot is + // for; the `value` description names it as the shape the three score ids + // return. schemas: { Medication: medicationResource, MedicationDoseHistoryImportFatalReason: medicationDoseHistoryImportFatalReasonEnum, CoachPrefs: coachPrefsSchema, MoodTagLayout: moodTagLayout, + WellnessScoreValue: wellnessScoreValue, }, // v1.36.0 — the per-request account selector, defined in the sharing route // module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps diff --git a/src/lib/openapi/routes/insights/index.ts b/src/lib/openapi/routes/insights/index.ts index 22c8041a7..ad2739082 100644 --- a/src/lib/openapi/routes/insights/index.ts +++ b/src/lib/openapi/routes/insights/index.ts @@ -4,3 +4,4 @@ * Part of the OpenAPI route table; aggregated in `../index.ts`. */ export { insightsPaths } from "./paths"; +export { wellnessScoreValue } from "./schemas"; diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index ec3b8220c..4d4f0d0f5 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -804,7 +804,7 @@ export const insightsPaths: NonNullable = { tags: ["Insights"], summary: "Derived wellness metric (compute-once)", description: - "v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. Auth via cookie or Bearer.", + "v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. `windowDays` widens or narrows the trailing window a metric summarises; `provenance.windowDays` always reports the window actually used and `coverage.historyDays` the days that actually backed it. Auth via cookie or Bearer.", requestParams: { query: derivedMetricQuery, }, diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 31778b692..a3391607f 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -13,6 +13,10 @@ import { VITALS_BASELINE_TYPES, SAME_TIME_BASELINE_TYPES, } from "@/lib/insights/derived/registry"; +import { + DERIVED_MAX_WINDOW_DAYS, + SPARKLINE_MAX_POINTS, +} from "@/lib/insights/derived/types"; import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain"; import { PERIOD_DAYS } from "@/lib/insights/narrative/period-narrative"; @@ -144,9 +148,97 @@ export const derivedMetricQuery = z .describe( "The single measurement type a baseline metric works over. `VITALS_BASELINE` takes one of the eleven vitals (`RESTING_HEART_RATE` through `WEIGHT`; defaults to `RESTING_HEART_RATE`). `SAME_TIME_BASELINE` takes one of the four cumulative day metrics — `ACTIVITY_STEPS`, `ACTIVE_ENERGY_BURNED`, `WALKING_RUNNING_DISTANCE`, `FLIGHTS_CLIMBED`. Ignored by the composite metrics. A type the named metric does not support yields an `insufficient` value rather than a 422, so client metric combinations stay forgiving.", ), + windowDays: z.coerce + .number() + .int() + .min(1) + .max(DERIVED_MAX_WINDOW_DAYS) + .optional() + .describe( + `Trailing window the metric summarises, in days (1–${DERIVED_MAX_WINDOW_DAYS}). Omit it and each metric keeps its own default — 14 days for the wellness-score trend, 30 for the vitals baselines, 90 for BMI, 180 for cardio fitness, 365 for the vascular-age delta. A value outside the range is a 422, never a silent clamp; the ceiling is where the derived tier stops resolving against DAY rollup buckets and would fall back to an uncapped raw read. Always read \`provenance.windowDays\` back: it is the window the engine ACTUALLY used, and \`HEALTH_SCORE\` composes fixed per-pillar windows and reports the widest of them rather than the one you asked for. The window is a request, not a promise about coverage — \`coverage.historyDays\` is how many days of record actually backed the answer, and it is smaller than \`windowDays\` for anyone whose history is shorter than the window. Not accepted on the batch route, which always runs the engine defaults.`, + ), }) .meta({ id: "DerivedMetricQuery" }); +/** + * The `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` value object. + * + * Modelled here because the free-form `value` record left every derived + * metric's payload shape undocumented, and a client reading the DTO could not + * see that `series`, `daysInWindow` and `asOf` were already on the wire. + * + * Nothing `$ref`s it — `value` has to stay an open record while one route + * serves eighteen differently-shaped value objects — so it is registered in + * the forced-components slot in `../index.ts`, the same slot `Medication` and + * `CoachPrefs` use. Its fields are held against the runtime interface by + * `derived-wellness-value-contract.test.ts`, so the published shape cannot + * drift away from what the engine actually returns. + * + * STILL OPAQUE, and known to be: the value shapes of `VITALS_BASELINE` (prose + * only, in the `value` description), `FITNESS_AGE`, `VASCULAR_AGE_DELTA`, + * `HRV_BALANCE`, `BMI`, `SLEEP_SCORE`, `READINESS`, `COINCIDENT_DEVIATION`, + * `TRAJECTORY`, `SAME_TIME_BASELINE`, `SIX_MINUTE_WALK_BAND`, `HEALTH_SCORE` + * and the three fixed-type baselines. Each is a TypeScript interface next to + * its engine under `src/lib/insights/derived/`; modelling them is a schema per + * engine and belongs in its own change rather than riding along here. + */ +export const wellnessScoreValue = z + .object({ + score: z.number().int().describe("The latest persisted 0–100 score."), + band: z + .enum(["green", "yellow", "red"]) + .describe( + "Server's verdict on the score. Direction-aware: recovery bands high-is-good, stress and strain invert, so a client must never re-band the number itself.", + ), + trendDelta: z + .number() + .int() + .nullable() + .describe( + "Score minus the mean of the earlier days in the window; null when the window holds only one day.", + ), + daysInWindow: z + .number() + .int() + .describe( + "Days that actually carried a score inside the window. Compare against `provenance.windowDays` — a shorter record answers a wide request with fewer days, and this is where that shows.", + ), + asOf: z.iso + .datetime({ offset: true }) + .describe("`measuredAt` of the latest score."), + series: z + .array(z.number()) + .describe( + `Trailing scores, oldest → newest, capped at ${SPARKLINE_MAX_POINTS} points however wide the window is. For RECOVERY these are the canonical per-night values: a worn band and the server's own proxy write the same night on different clocks, and the server collapses them to one value per night before this array is built.`, + ), + anchor: z + .enum(["personal", "population"]) + .nullable() + .optional() + .describe( + "STRAIN only — whether this score was judged against the user's own training history or the cold-start population reference. Null for recovery and stress.", + ), + components: z + .array( + z.object({ + key: z.string().describe("Contributor id, e.g. rhr / hrv / sleep."), + value: z + .number() + .nullable() + .describe("0–100 sub-score, null when the input was missing."), + weight: z + .number() + .describe("Effective weight after redistributing missing inputs."), + }), + ) + .nullable() + .optional() + .describe( + "RECOVERY only, and only when the canonical value is the server's computed proxy — a device-native recovery percentage is not our blend and carries no decomposition.", + ), + }) + .meta({ id: "WellnessScoreValue" }); + export const derivedCoverage = z .object({ requiredInputs: z @@ -238,7 +330,7 @@ export const derivedMetricResponse = z .record(z.string(), z.unknown()) .nullable() .describe( - "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); null when 'insufficient'.", + "Metric-specific value object when status is 'ok'; null when 'insufficient'. The shape is chosen by `metric`, and the record stays open because one route serves eighteen of them. `RECOVERY_SCORE` / `STRESS_SCORE` / `STRAIN_SCORE` return the `WellnessScoreValue` schema — field-by-field in `components.schemas`, including the `series`, `daysInWindow` and `asOf` a client would otherwise have to discover by inspecting a live payload. `VITALS_BASELINE` returns { type, center, low, high, spread, sampleDays, k, series }, where `series` is the trailing per-day means for the inline sparkline. The remaining metrics' value objects are not modelled here yet; their shapes are TypeScript interfaces beside their engines under `src/lib/insights/derived/`.", ), coverage: derivedCoverage, confidence: derivedConfidence From 7a4e09bb4c59bcf032e256839a508be68fa46aaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 24 Aug 2026 02:58:23 +0200 Subject: [PATCH 9/9] =?UTF-8?q?chore(release):=20v1.37.28=20=E2=80=94=20th?= =?UTF-8?q?e=20parts=20of=20a=20shared=20record=20nobody=20could=20see?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 19 +++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa6f71e1d..880236fd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.37.28] — 2026-08-24 + +Eleven parts of a shared record were selectable and invisible. Ticking one and sending the link showed the recipient nothing, while the same link's PDF download carried it. + +### Fixed + +- A share link now renders every part of the record it carries. Eleven of the seventeen selectable sections reached the page and were never drawn: identity, emergency data, lab results, GLP-1 therapy, logged doses, illness episodes, visits, immunizations, family history, mood and cycle. The data had been arriving all along, from the same aggregator the PDF uses, so someone who ticked "Lab values" had every reason to believe they had shared them and no way to find out otherwise. +- A section that carries nothing now says which kind of nothing it is. A part that was not selected stays absent entirely, because naming it would itself disclose something. A selected part with no records says so. A selected part whose module the account has switched off says that instead, since the recipient would otherwise read an empty card as an empty life. +- Logged medication doses reached the FHIR download and neither the page nor the report. The one control whose effect a person could only see by opening the bundle in another program now shows the twenty most recent, with a count of what the cut left out. +- The doctor report printed the raw translation key for a visit's kind rather than its name, and so did the daily digest's upcoming-visit line. Three surfaces built the label by interpolating an enum member into a key space whose entries are lower case, so a routine appointment read as `encounters.kind.ROUTINE` in a document a practice files and on a phone's lock screen. +- A lab row that diverges from its reference range now names both windows it was measured against instead of one. + +### Changed + +- `GET /api/insights/derived` accepts a `windowDays` parameter, so a client can ask for a longer trend than the fixed fourteen days. The ceiling is ninety, which is the last day before the read leaves the bucketed rollup tier for an unbounded scan; beyond it the request is refused rather than quietly clamped. Omitting the parameter changes nothing. +- The response already carried the two numbers that tell a caller what they actually got, and now says so in the contract: one field for the window that was asked for, another for the history that backed it. A three-week record answering a thirty-day request reports both. +- The wellness-score value object is modelled in the published API document. It was typed as an open record with prose describing one metric, which is why a client team read the contract, built what they could see, and reported two fields as missing that were in the payload all along. The value shapes that stay opaque are named where the gap is, rather than left to be rediscovered. +- The share link's report and FHIR downloads are published in the API document alongside their document sibling. + ## [1.37.27] — 2026-08-23 Five things this release fixes have the same shape: a check that was green because it was not checking. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 24f7a31c6..ebba70075 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.27 + version: 1.37.28 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 7ee5ef1a9..ae18d1e36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.27", + "version": "1.37.28", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index c4bf32e90..e9fe10cdf 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.37.27"; + /* @sw-version-fallback */ "v1.37.28"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`