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) => ( + + ))} +
+ ); +}