mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(share): render the record sections the clinician view withheld
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.
This commit is contained in:
1 parent
fbe2081506
commit
7a9aa141a6
14 files changed
+1402
-62
No files matched your search
+11
-1
@@ -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": {
|
||||
|
||||
+11
-1
@@ -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": {
|
||||
|
||||
+11
-1
@@ -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": {
|
||||
|
||||
+11
-1
@@ -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": {
|
||||
|
||||
+11
-1
@@ -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": {
|
||||
|
||||
+11
-1
@@ -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": {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -178,8 +178,13 @@ describe("<ClinicianView>", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -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 (
|
||||
<main
|
||||
@@ -123,20 +165,107 @@ export function ClinicianView({
|
||||
<div className="space-y-4">
|
||||
{report ? (
|
||||
<>
|
||||
<MeasurementGroups t={t} report={report} fmtNum={fmtNum} />
|
||||
{report.bmi !== null && report.bmi !== undefined ? (
|
||||
<Section title={t("clinicianView.bmiSection")}>
|
||||
{/* ── identity ─────────────────────────────────────────── */}
|
||||
<EmergencySection t={t} report={report} scope={scope} />
|
||||
<PatientIdentitySection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
|
||||
{/* ── measurements ─────────────────────────────────────── */}
|
||||
<MeasurementGroups
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtNum={fmtNum}
|
||||
/>
|
||||
{report.bmi !== null &&
|
||||
report.bmi !== undefined &&
|
||||
scope.admits("BODY_MASS_INDEX") ? (
|
||||
<Section
|
||||
title={t("clinicianView.bmiSection")}
|
||||
leaves={["BODY_MASS_INDEX"]}
|
||||
>
|
||||
<StatRow
|
||||
label={t("clinicianView.bmi")}
|
||||
value={String(fmtNum(report.bmi))}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
<GlucoseSection t={t} report={report} fmtNum={fmtNum} />
|
||||
<MedicationsSection t={t} report={report} selection={selection} />
|
||||
<AllergiesSection t={t} report={report} selection={selection} />
|
||||
<AnamnesisSection t={t} report={report} selection={selection} />
|
||||
<WellnessSection t={t} report={report} fmtNum={fmtNum} />
|
||||
|
||||
{/* ── glucose and labs ─────────────────────────────────── */}
|
||||
<GlucoseSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtNum={fmtNum}
|
||||
/>
|
||||
<LabResultsSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
fmtNum={fmtNum}
|
||||
/>
|
||||
|
||||
{/* ── medications ──────────────────────────────────────── */}
|
||||
<MedicationsSection t={t} report={report} scope={scope} />
|
||||
<Glp1Section
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
fmtNum={fmtNum}
|
||||
/>
|
||||
<DoseLogSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDateTime={fmtDateTime}
|
||||
fmtNum={fmtNum}
|
||||
/>
|
||||
|
||||
{/* ── history ──────────────────────────────────────────── */}
|
||||
<IllnessSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
<VisitsSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
<ImmunizationsSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
<AllergiesSection t={t} report={report} scope={scope} />
|
||||
|
||||
{/* ── the fenced tier, as the owner chose it ───────────── */}
|
||||
<FamilyHistorySection t={t} report={report} scope={scope} />
|
||||
<AnamnesisSection t={t} report={report} scope={scope} />
|
||||
<MoodSection t={t} report={report} scope={scope} fmtNum={fmtNum} />
|
||||
<CycleSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
fmtNum={fmtNum}
|
||||
/>
|
||||
|
||||
<WellnessSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtNum={fmtNum}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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 | null | undefined>): 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 (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["LAB_RESULTS"]}
|
||||
title={t("clinicianView.labs")}
|
||||
empty={results.length === 0}
|
||||
>
|
||||
{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 (
|
||||
<StatRow
|
||||
key={`${lab.panel ?? ""}-${lab.analyte}`}
|
||||
label={lab.panel ? `${lab.analyte} (${lab.panel})` : lab.analyte}
|
||||
value={compose([
|
||||
reading,
|
||||
reference
|
||||
? `${t("doctorReport.labsColReference")} ${reference}`
|
||||
: null,
|
||||
fmtDate(lab.takenAt),
|
||||
])}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["ILLNESS_EPISODES"]}
|
||||
title={t("doctorReport.illnessTitle")}
|
||||
empty={episodes.length === 0}
|
||||
>
|
||||
{episodes.map((episode, index) => (
|
||||
<StatRow
|
||||
key={`${episode.label}-${episode.onsetAt}-${index}`}
|
||||
label={episode.label}
|
||||
value={compose([
|
||||
t(`illness.type.${episode.type}`),
|
||||
t(`illness.lifecycle.${episode.lifecycle}`),
|
||||
`${fmtDate(episode.onsetAt)} – ${
|
||||
episode.resolvedAt
|
||||
? fmtDate(episode.resolvedAt)
|
||||
: t("doctorReport.illnessOngoing")
|
||||
}`,
|
||||
])}
|
||||
/>
|
||||
))}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["VISITS"]}
|
||||
title={t("doctorReport.visitsTitle")}
|
||||
empty={visits.length === 0}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{visits.map((visit, index) => (
|
||||
<div
|
||||
key={`${visit.occurredAt}-${index}`}
|
||||
className="border-border/60 rounded-md border px-3"
|
||||
>
|
||||
<StatRow
|
||||
label={t("doctorReport.visitsColDate")}
|
||||
value={fmtDate(visit.occurredAt)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("doctorReport.visitsColPractice")}
|
||||
value={compose([
|
||||
visit.practitionerName,
|
||||
visit.practitionerSpecialty,
|
||||
])}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("doctorReport.visitsColKind")}
|
||||
value={t(encounterKindLabelKey(visit.kind as EncounterKind))}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("doctorReport.visitsColReason")}
|
||||
value={visit.reason ?? "—"}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("doctorReport.visitsColOutcome")}
|
||||
value={visit.outcome ?? "—"}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("doctorReport.visitsColConditions")}
|
||||
value={
|
||||
visit.conditionLabels.length > 0
|
||||
? visit.conditionLabels.join(", ")
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["IMMUNIZATIONS"]}
|
||||
title={t("doctorReport.immunizationsTitle")}
|
||||
empty={doses.length === 0}
|
||||
>
|
||||
{doses.map((dose, index) => (
|
||||
<StatRow
|
||||
key={`${dose.occurredAt}-${index}`}
|
||||
label={
|
||||
dose.antigenSlug
|
||||
? t(`vaccinations.catalog.${dose.antigenSlug}`)
|
||||
: (dose.vaccineName ?? "—")
|
||||
}
|
||||
value={compose([
|
||||
fmtDate(dose.occurredAt),
|
||||
doseDisplay(dose.series),
|
||||
dose.lotNumber
|
||||
? `${t("doctorReport.immunizationsColLot")} ${dose.lotNumber}`
|
||||
: null,
|
||||
])}
|
||||
/>
|
||||
))}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["PATIENT_IDENTITY"]}
|
||||
title={t("reportSelection.groupIdentity")}
|
||||
empty={rows.length === 0}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<StatRow key={row.label} label={row.label} value={row.value} />
|
||||
))}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<section
|
||||
data-leaf="EMERGENCY"
|
||||
className="border-destructive/50 bg-destructive/5 rounded-lg border p-4 md:p-6"
|
||||
>
|
||||
<h2 className="mb-1 text-base font-semibold">
|
||||
{t("doctorReport.emergency.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
{t("doctorReport.emergency.subtitle")}
|
||||
</p>
|
||||
{unavailable ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("clinicianView.sectionUnavailable")}
|
||||
</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("clinicianView.sectionEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
rows.map((row) => (
|
||||
<StatRow
|
||||
key={row.label}
|
||||
label={row.label}
|
||||
value={row.value}
|
||||
emphasise={row.emphasise}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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, string | number>,
|
||||
) => 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<ReportLeafId>(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<string, string> = {
|
||||
RECOVERY_SCORE: "recovery",
|
||||
@@ -33,49 +65,147 @@ const WELLNESS_KEY: Record<string, string> = {
|
||||
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 (
|
||||
<div className="border-border/40 flex items-baseline justify-between gap-4 border-b py-2 last:border-0">
|
||||
<span className="text-muted-foreground text-sm">{label}</span>
|
||||
<span className="text-sm font-medium tabular-nums">{value}</span>
|
||||
<span
|
||||
className={
|
||||
emphasise
|
||||
? "text-sm font-semibold tabular-nums"
|
||||
: "text-sm font-medium tabular-nums"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="border-border bg-card rounded-lg border p-4 md:p-6">
|
||||
<section
|
||||
data-leaf={leaves && leaves.length > 0 ? leaves.join(" ") : undefined}
|
||||
className="border-border bg-card rounded-lg border p-4 md:p-6"
|
||||
>
|
||||
<h2 className="mb-3 text-base font-semibold">{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Section title={title} leaves={admitted}>
|
||||
{unavailable ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("clinicianView.sectionUnavailable")}
|
||||
</p>
|
||||
) : empty ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("clinicianView.sectionEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => (
|
||||
<Section key={group.labelKey} title={t(group.labelKey)}>
|
||||
<Section
|
||||
key={group.labelKey}
|
||||
title={t(group.labelKey)}
|
||||
leaves={group.rows.map((row) => row.type)}
|
||||
>
|
||||
{group.rows.map(({ type, stat }) => (
|
||||
<StatRow
|
||||
key={type}
|
||||
@@ -104,22 +238,34 @@ export function MeasurementGroups({
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-context glucose stats. Present only when the glucose panel was chosen. */
|
||||
/**
|
||||
* Per-context glucose stats. The heading used to read "Lab values", which was
|
||||
* the wrong name for it even before the record's actual lab results reached
|
||||
* this page; it is the glucose group's own label now, and "Lab values" belongs
|
||||
* to the section that carries them.
|
||||
*/
|
||||
export function GlucoseSection({
|
||||
t,
|
||||
report,
|
||||
scope,
|
||||
fmtNum,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
fmtNum: (n: number) => number;
|
||||
}) {
|
||||
const entries = Object.entries(report.glucoseStats).filter(
|
||||
([, s]) => s.count > 0,
|
||||
);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<Section title={t("clinicianView.labs")}>
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["GLUCOSE_PANEL"]}
|
||||
title={t("reportSelection.groupGlucose")}
|
||||
empty={entries.length === 0}
|
||||
>
|
||||
{entries.map(([ctx, s]) => (
|
||||
<StatRow
|
||||
key={ctx}
|
||||
@@ -132,7 +278,7 @@ export function GlucoseSection({
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Section title={t("clinicianView.medications")}>
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["MEDICATION_LIST", "MEDICATION_COMPLIANCE"]}
|
||||
title={t("clinicianView.medications")}
|
||||
empty={medications.length === 0 && complianceEntries.length === 0}
|
||||
>
|
||||
{medications.map((med) => {
|
||||
const comp = report.compliance[med.name];
|
||||
const rate =
|
||||
@@ -184,7 +337,7 @@ export function MedicationsSection({
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Section>
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Section title={t("doctorReport.allergiesTitle")}>
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["ALLERGIES"]}
|
||||
title={t("doctorReport.allergiesTitle")}
|
||||
empty={allergies.length === 0}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{allergies.map((allergy, index) => (
|
||||
<div
|
||||
@@ -244,7 +402,7 @@ export function AllergiesSection({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Section title={t("doctorReport.anamnesisTitle")}>
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["ANAMNESIS"]}
|
||||
title={t("doctorReport.anamnesisTitle")}
|
||||
empty={anamnesis === null}
|
||||
>
|
||||
<StatRow
|
||||
label={t("doctorReport.anamnesisConditions")}
|
||||
value={
|
||||
anamnesis.conditionsUnreadable
|
||||
anamnesis?.conditionsUnreadable
|
||||
? unreadable
|
||||
: (anamnesis.conditions ?? absent)
|
||||
: (anamnesis?.conditions ?? absent)
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("doctorReport.anamnesisSmoking")}
|
||||
value={factValue("SMOKING_STATUS", anamnesis.smokingStatus)}
|
||||
value={factValue("SMOKING_STATUS", anamnesis?.smokingStatus ?? null)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("doctorReport.anamnesisAlcohol")}
|
||||
value={factValue("ALCOHOL_PATTERN", anamnesis.alcoholPattern)}
|
||||
value={factValue("ALCOHOL_PATTERN", anamnesis?.alcoholPattern ?? null)}
|
||||
/>
|
||||
<StatRow
|
||||
label={t("doctorReport.anamnesisShiftSchedule")}
|
||||
value={factValue("SHIFT_SCHEDULE", anamnesis.shiftSchedule)}
|
||||
value={factValue("SHIFT_SCHEDULE", anamnesis?.shiftSchedule ?? null)}
|
||||
/>
|
||||
</Section>
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<section className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-4 md:p-6">
|
||||
<section
|
||||
data-leaf={wellness.map((s) => s.type).join(" ")}
|
||||
className="border-warning/50 bg-warning/5 rounded-lg border border-dashed p-4 md:p-6"
|
||||
>
|
||||
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
|
||||
{t("clinicianView.wellness.title")}
|
||||
</h2>
|
||||
|
||||
@@ -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<number, string> = {
|
||||
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 (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["FAMILY_HISTORY"]}
|
||||
title={t("doctorReport.familyHistoryTitle")}
|
||||
empty={entries.length === 0}
|
||||
>
|
||||
{entries.map((entry, index) => (
|
||||
<StatRow
|
||||
key={`${entry.relationship}-${entry.condition}-${index}`}
|
||||
label={t(`records.family.relationship.${entry.relationship}`)}
|
||||
value={
|
||||
entry.ageAtOnset !== null
|
||||
? `${entry.condition} · ${t(
|
||||
"doctorReport.familyHistoryColAgeAtOnset",
|
||||
)} ${entry.ageAtOnset}`
|
||||
: entry.condition
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["MOOD"]}
|
||||
title={t("doctorReport.moodTitle")}
|
||||
empty={mood === null}
|
||||
>
|
||||
{mood ? (
|
||||
<p className="mb-3 text-sm">
|
||||
{t("doctorReport.moodSummary", {
|
||||
avg: fmtNum(mood.avg),
|
||||
count: mood.count,
|
||||
min: fmtNum(mood.min),
|
||||
max: fmtNum(mood.max),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{buckets.map(([score, count]) => (
|
||||
<StatRow
|
||||
key={score}
|
||||
label={t(
|
||||
MOOD_LABEL_KEYS[Number(score)] ?? "doctorReport.moodNeutral",
|
||||
)}
|
||||
value={
|
||||
mood && mood.count > 0
|
||||
? `${count} · ${fmtNum((count / mood.count) * 100)}%`
|
||||
: String(count)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["CYCLE"]}
|
||||
title={t("doctorReport.cycleTitle")}
|
||||
empty={cycle === null}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<StatRow key={row.label} label={row.label} value={row.value} />
|
||||
))}
|
||||
{(cycle?.recentCycles ?? []).map((observed) => (
|
||||
<StatRow
|
||||
key={observed.startDate}
|
||||
label={`${t("doctorReport.cycleColStart")} ${day(observed.startDate)}`}
|
||||
value={[
|
||||
observed.lengthDays !== null
|
||||
? `${t("doctorReport.cycleColLength")} ${observed.lengthDays}`
|
||||
: null,
|
||||
observed.periodLengthDays !== null
|
||||
? `${t("doctorReport.cycleColPeriod")} ${observed.periodLengthDays}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
/>
|
||||
))}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["GLP1_THERAPY"]}
|
||||
title={t("doctorReport.glp1Title")}
|
||||
empty={glp1 === null}
|
||||
>
|
||||
{weightLine ? <p className="mb-3 text-sm">{weightLine}</p> : null}
|
||||
<div className="space-y-4">
|
||||
{medications.map((med) => {
|
||||
const rate = adherenceRatePercent(
|
||||
med.compliance.taken,
|
||||
med.compliance.total,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={med.name}
|
||||
className="border-border/60 rounded-md border px-3"
|
||||
>
|
||||
<StatRow
|
||||
label={med.name}
|
||||
value={
|
||||
med.currentDose
|
||||
? t("doctorReport.glp1CurrentDose", {
|
||||
value: fmtNum(med.currentDose.value),
|
||||
unit: med.currentDose.unit,
|
||||
since: fmtDate(med.currentDose.since),
|
||||
})
|
||||
: "—"
|
||||
}
|
||||
emphasise
|
||||
/>
|
||||
{rate !== null ? (
|
||||
<StatRow
|
||||
label={t("doctorReport.colComplianceRate")}
|
||||
value={t("doctorReport.glp1Compliance", {
|
||||
taken: med.compliance.taken,
|
||||
total: med.compliance.total,
|
||||
rate: Math.round(rate),
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
{med.doseHistory.map((change, index) => (
|
||||
<StatRow
|
||||
key={`${change.effectiveFrom}-${index}`}
|
||||
label={fmtDate(change.effectiveFrom)}
|
||||
value={
|
||||
`${fmtNum(change.value)} ${change.unit}` +
|
||||
(change.note ? ` · ${change.note}` : "")
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{med.lastInjection ? (
|
||||
<StatRow
|
||||
label={t("clinicianView.lastInjection")}
|
||||
value={
|
||||
fmtDate(med.lastInjection.date) +
|
||||
(med.lastInjection.site
|
||||
? ` · ${med.lastInjection.site}`
|
||||
: "")
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{sideEffects.length > 0 ? (
|
||||
<div className="mt-4">
|
||||
<h3 className="mb-2 text-sm font-semibold">
|
||||
{t("doctorReport.glp1SideEffectsTitle")}
|
||||
</h3>
|
||||
{sideEffects.map((effect) => (
|
||||
<StatRow
|
||||
key={effect.tag}
|
||||
label={t(GLP1_SIDE_EFFECT_TAG_LABEL_KEYS[effect.tag])}
|
||||
value={String(effect.count)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<LeafSection
|
||||
t={t}
|
||||
scope={scope}
|
||||
leaves={["MEDICATION_ADMINISTRATIONS"]}
|
||||
title={t("clinicianView.doses.title")}
|
||||
empty={ordered.length === 0}
|
||||
>
|
||||
{shown.length < total ? (
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
{t("clinicianView.doses.showing", {
|
||||
shown: shown.length,
|
||||
total,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{shown.map((dose, index) => (
|
||||
<StatRow
|
||||
key={`${dose.medicationName}-${dose.effectiveAt}-${index}`}
|
||||
label={`${fmtDateTime(dose.effectiveAt)} — ${dose.medicationName}`}
|
||||
value={
|
||||
(dose.status === "completed"
|
||||
? t("doctorReport.colTaken")
|
||||
: t("doctorReport.colSkipped")) +
|
||||
(dose.dose
|
||||
? ` · ${fmtNum(dose.dose.value)} ${dose.dose.unit}`
|
||||
: dose.doseText
|
||||
? ` · ${dose.doseText}`
|
||||
: "") +
|
||||
(dose.injectionSite ? ` · ${dose.injectionSite}` : "")
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</LeafSection>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user