feat(export): make the doctor report carry only what was chosen

The report resolved an absent key to "included". A caller that sent no
sections object, or an empty group inside one, received the full record at
server-chosen defaults: roughly fifty metrics plus the medication list, labs,
allergies and family history. The web panel sent every key explicitly so it
looked fine from the browser, but the share link, the FHIR REST face, the MCP
doctor-visit resource and the cycle-only export card all went through paths
where absence meant yes. No arrangement of booleans fixes a default that means
yes, so the shape goes.

A selection is now an inclusion list of leaf ids. A leaf is the smallest thing
that can be released or withheld: every measurement type, and thirteen
structured sections — identity, insurer, the glucose panel, labs, the drug
list, the administration ledger, adherence, the GLP-1 block, allergies,
illness episodes, family history, mood, cycle. Membership is inclusion,
absence is exclusion, and there is no defaults constant anywhere to drift.

Three locks keep a metric added later off a report chosen earlier. A stored
selection is a list of what was chosen, so a leaf that did not exist then is
not in it. The catalogue is keyed over the measurement enum, so adding a
member without deciding its group fails typecheck in the same commit. And the
aggregator's filter inverts: a type the catalogue does not know is excluded and
annotated, where it used to be passed through.

The aggregator's third parameter is now required and brand-typed, which turned
the three surfaces that had no scope at all into compile errors. Each one had
to name a source, and each now replays the owner's saved selection — the same
object reviewed in Settings. An account that never saved one gets nothing
there rather than a guess.

Identity and insurance become selectable, and a share link refuses the
insurance leaf outright. The share view never decrypted the number; that is
structural now instead of incidental. The KVNR is not even read unless the
leaf was chosen.

Two surfaces gain a record they always implied. A share link serves its frozen
selection as a FHIR bundle and as the PDF, behind the same unlock cookie the
document route uses, with its own rate bucket, its own audit row and the same
blunt 404 for every miss. That is what the removed FHIR toggle promised, in
the form a practice actually uses.

The AI summary leaves the report. Its text came from the cached briefing with
no relation to the selection, so its scope could not be proven, and prose whose
scope cannot be proven has no place on a document whose whole point is a proven
scope.

The PDF table stops being a nine-type whitelist. It prints every selected leaf
that has data, sub-headed by group, in the same order the panel shows — so
selecting resting heart rate no longer means seeing it in the bundle and not in
the PDF. The clinician view is grouped the same way and reads real labels
instead of "Phq9 score".

Migration 0273 adds the saved-selection column, drops the orphaned prefs column
and the two retired share-link scope columns, and revokes every live record
share link minted before this model. Their frozen scope was "everything except
mood and cycle", which is a scope nobody chose; narrowing it would still be a
scope nobody chose. Documents-only shares are untouched. The sharing list shows
each closed link in a pick-again state with its label, window and expiry kept.

The retired allowFhirApi and resourceTypes stay on the create and list
responses as the constant false and empty list. The request refuses them. They
come off the response once the native client confirms it does not decode them.

Six files are split at seams this change already touched: the aggregator into
its gate, its dense-bucket query, its series, its glucose panel, its medication
ledger and its clinical records; the export route's three emitters into their
own module; the clinician view into its sections, its document list and its
downloads.
This commit is contained in:
Marc-André Bombeck committed 2026-07-26 00:45:08 +02:00
1 parent f90219784b
commit 5e490f31ec
46 files changed
+3734 -3083

No files matched your search

+51 -282
View File
@@ -1,26 +1,34 @@
/**
* v1.11.0 — clinician-view presentation (Epic C, C5).
* The public clinician view.
*
* A pure server component: it receives an already-resolved, owner-scoped
* {@link DoctorReportData} plus a server-side translator and renders a
* read-only clinical summary. NO client hooks, NO session, NO AI/coach, NO
* 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 → clinical vitals/labs → medications + adherence →
* a FENCED, muted wellness card carrying the load-bearing "descriptive, not a
* clinical assessment / not a diagnosis" disclaimer.
* 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.
*
* The section components live in `./report-sections`, the document list in
* `./documents-list`, the downloads in `./download-actions`.
*/
import { Download } from "lucide-react";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { formatBytes } from "@/components/documents/vault-utils";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import { DOCTOR_REPORT_TYPE_LABEL_KEYS } from "@/lib/doctor-report/type-label-keys";
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { makeFormatters } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
import type { DoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import type { ReportSelection } from "@/lib/report-selection/selection";
import { DocumentEntry } from "./documents-list";
import { ShareDownloadActions } from "./download-actions";
import {
GlucoseSection,
MeasurementGroups,
MedicationsSection,
Section,
StatRow,
WellnessSection,
} from "./report-sections";
type Translate = (
key: string,
@@ -34,22 +42,22 @@ interface ClinicianViewProps {
/** ISO expiry instant — surfaced so the clinician knows the link lifetime. */
expiresAt: string;
/**
* The owner-scoped report payload, or `null` for a documents-only share
* (no report section enabled). When `null` NO health metric is rendered —
* only the header, disclaimer, and the attached documents.
* The owner-scoped report payload, or `null` for a documents-only share.
* When `null` NO health metric is rendered — only the header, the
* disclaimer, and the attached documents.
*/
report: DoctorReportData | null;
sections: DoctorReportPrefs;
/** The link's frozen selection, resolved. */
selection: ReportSelection;
/**
* v1.28.13 — a documents-only link. Hides the reporting-period line (there is
* no report) and, together with a `null` report, keeps every health section
* off the page. "Share this document" means the document, not the record.
* A documents-only link. Hides the reporting-period line (there is no
* report) and, together with a `null` report, keeps every health section off
* the page.
*/
documentOnly?: boolean;
/**
* The frozen document set on this link (metadata only — never bytes). Each
* entry points at the token-scoped serve route (`/c/<token>/d/<id>`), the
* one decrypt path. Empty when the owner shared no documents.
* entry points at the token-scoped serve route, the one decrypt path.
*/
documents?: ShareViewDocument[];
/** The raw share token from the path — used to build serve-route URLs. */
@@ -57,154 +65,19 @@ interface ClinicianViewProps {
/** Viewer locale (byte formatting only). Defaults to English. */
locale?: Locale;
/**
* Issue #490 — the share OWNER's (patient's) profile timezone. Period
* start/end and the expiry date render in this zone so the dates agree
* with the patient-tz aggregation behind the stats (and with the
* doctor-report PDF). Pre-fix the dates rendered via a bare
* `toLocaleDateString()` — the server CONTAINER's zone (≈ UTC), neither
* the patient's nor the viewer's, with US-English field order regardless
* of the resolved page locale.
* Issue #490 — the share OWNER's profile timezone. Period start/end and the
* expiry date render in this zone so the dates agree with the patient-tz
* aggregation behind the stats (and with the doctor-report PDF).
*/
timezone?: string;
}
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
const WELLNESS_KEY: Record<string, string> = {
RECOVERY_SCORE: "recovery",
STRESS_SCORE: "stress",
STRAIN_SCORE: "strain",
};
/**
* Localised label for a measurement-type enum, reusing the doctor-report type
* key map so the clinician view reads in the viewer's locale alongside the rest
* of the page. A type with no key falls back to a humanised enum form
* (`BLOOD_PRESSURE_SYS` → "Blood pressure sys").
*/
function typeLabel(type: string, t: Translate): string {
const key = DOCTOR_REPORT_TYPE_LABEL_KEYS[type];
if (key) return t(key);
const lower = type.replace(/_/g, " ").toLowerCase();
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
/** Render a single labelled stat row. */
function StatRow({ label, value }: { label: string; value: string }) {
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>
</div>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section 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>
);
}
/**
* One shared document. Class A (inline) renders a preview straight at the
* token-scoped serve route — an `<img>` for raster images, a framed PDF
* otherwise; the proxy's share-serve CSP (`default-src 'none';
* frame-ancestors 'self'`) fences the framed context. Class B (attachment)
* offers a plain download link — the serve route answers it as an opaque
* `application/octet-stream` attachment. No bytes reach this component; only
* the metadata and the URL.
*/
function DocumentEntry({
t,
doc,
token,
locale,
}: {
t: Translate;
doc: ShareViewDocument;
token: string;
locale: string;
}) {
const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`;
const title = doc.title ?? t("clinicianView.documents.untitled");
const Icon =
DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined;
const meta = [doc.documentDate, formatBytes(doc.byteSize, locale)].filter(
Boolean,
) as string[];
const isImage = doc.mimeType.startsWith("image/");
const isInline = doc.servingClass === "inline";
return (
<li className="border-border bg-card rounded-lg border p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
{Icon ? (
<Icon className="text-foreground mt-0.5 size-5 shrink-0" />
) : null}
<div className="min-w-0">
<p className="text-sm font-medium break-words">{title}</p>
{meta.length > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
{meta.join(" · ")}
</p>
) : null}
</div>
</div>
{/* Download is always available (inline or attachment) so the recipient
can keep a copy; the anchor hits the same token-scoped serve route. */}
<a
href={serveUrl}
download
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex min-h-9 shrink-0 items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-visible:ring-[3px] focus-visible:outline-none"
>
<Download className="size-3.5" aria-hidden />
{t("clinicianView.documents.download")}
</a>
</div>
{isInline ? (
<div className="mt-3">
{isImage ? (
// The served image is the frozen original with EXIF/GPS stripped on
// egress; it renders inline within the Class A carve-out.
// eslint-disable-next-line @next/next/no-img-element
<img
src={serveUrl}
alt={title}
className="border-border mx-auto max-h-[70vh] w-auto max-w-full rounded-md border"
/>
) : (
<iframe
src={serveUrl}
title={title}
className="border-border h-[70vh] max-h-[80vh] w-full rounded-md border"
/>
)}
</div>
) : (
<p className="text-muted-foreground mt-2 text-xs">
{t("clinicianView.documents.attachmentHint")}
</p>
)}
</li>
);
}
export function ClinicianView({
t,
label,
expiresAt,
report,
sections,
selection,
documents = [],
documentOnly = false,
token = "",
@@ -217,22 +90,6 @@ export function ClinicianView({
const fmtDate = (iso: string) => fmt.date(new Date(iso));
const fmtNum = (n: number) => Math.round(n * 100) / 100;
// A documents-only share carries no report — the aggregator is never called
// server-side, so `report` is null. Every health-derived list below stays
// empty by construction and only the attached documents render.
const measurementEntries = report
? Object.entries(report.stats).filter(([, s]) => s.count > 0)
: [];
const glucoseEntries = report
? Object.entries(report.glucoseStats).filter(([, s]) => s.count > 0)
: [];
const complianceEntries =
report && sections.compliance
? Object.entries(report.compliance).filter(([, c]) => c.total > 0)
: [];
const wellness = report?.wellnessScores?.filter((s) => s.count > 0) ?? [];
const medications = report?.medications ?? [];
return (
<main
id="main-content"
@@ -257,118 +114,30 @@ export function ClinicianView({
<p className="text-muted-foreground mt-1 text-xs">
{t("clinicianView.expires", { date: fmtDate(expiresAt) })}
</p>
{report && !documentOnly && token ? (
<ShareDownloadActions t={t} token={token} />
) : null}
<p className="border-border bg-muted/40 text-muted-foreground mt-3 rounded-md border p-3 text-xs">
{t("clinicianView.provenance")}
</p>
</header>
<div className="space-y-4">
{/* ── Clinical vitals ─────────────────────────────────────── */}
{measurementEntries.length > 0 ? (
<Section title={t("clinicianView.vitals")}>
{measurementEntries.map(([type, s]) => (
<StatRow
key={type}
label={typeLabel(type, t)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
{report &&
report.bmi !== null &&
report.bmi !== undefined &&
sections.bmi ? (
<StatRow
label={t("clinicianView.bmi")}
value={String(fmtNum(report.bmi))}
/>
{report ? (
<>
<MeasurementGroups t={t} report={report} fmtNum={fmtNum} />
{report.bmi !== null && report.bmi !== undefined ? (
<Section title={t("clinicianView.bmiSection")}>
<StatRow
label={t("clinicianView.bmi")}
value={String(fmtNum(report.bmi))}
/>
</Section>
) : null}
</Section>
) : null}
{/* ── Labs (glucose) ──────────────────────────────────────── */}
{glucoseEntries.length > 0 ? (
<Section title={t("clinicianView.labs")}>
{glucoseEntries.map(([ctx, s]) => (
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
</Section>
) : null}
{/* ── Medications + adherence ─────────────────────────────── */}
{medications.length > 0 ? (
<Section title={t("clinicianView.medications")}>
{medications.map((med) => {
const comp = report?.compliance[med.name];
const rate =
sections.compliance && comp && comp.total > 0
? `${Math.round((comp.taken / comp.total) * 100)}%`
: null;
return (
<StatRow
key={med.name}
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
value={
rate
? t("clinicianView.adherence", { rate })
: t("clinicianView.noAdherence")
}
/>
);
})}
{complianceEntries
.filter(([name]) => !medications.some((m) => m.name === name))
.map(([name, c]) => (
<StatRow
key={name}
label={name}
value={t("clinicianView.adherence", {
rate: `${Math.round((c.taken / c.total) * 100)}%`,
})}
/>
))}
</Section>
) : null}
{/* ── Fenced wellness card (descriptive, NOT clinical) ────── */}
{wellness.length > 0 ? (
<section 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>
<p className="text-muted-foreground mb-3 text-xs">
{t("clinicianView.wellness.disclaimer")}
</p>
<div>
{wellness.map((s) => (
<StatRow
key={s.type}
label={t(
`clinicianView.wellness.${WELLNESS_KEY[s.type] ?? "score"}`,
)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
</div>
</section>
<GlucoseSection t={t} report={report} fmtNum={fmtNum} />
<MedicationsSection t={t} report={report} selection={selection} />
<WellnessSection t={t} report={report} fmtNum={fmtNum} />
</>
) : null}
{/* ── Shared documents ────────────────────────────────────── */}
@@ -0,0 +1,98 @@
/**
* The frozen document set on a share link, as a list of entries.
*
* No bytes reach this component: each entry points at the token-scoped serve
* route, which is the one decrypt path. Class A (inline) renders a preview
* straight at that route — an `<img>` for raster images, a framed PDF
* otherwise, fenced by the proxy's share-serve CSP. Class B is download-only.
*
* Split out of `clinician-view.tsx` with the selection rework.
*/
import { Download } from "lucide-react";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { formatBytes } from "@/components/documents/vault-utils";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents";
type Translate = (
key: string,
vars?: Record<string, string | number>,
) => string;
export function DocumentEntry({
t,
doc,
token,
locale,
}: {
t: Translate;
doc: ShareViewDocument;
token: string;
locale: string;
}) {
const serveUrl = `/c/${encodeURIComponent(token)}/d/${encodeURIComponent(doc.id)}`;
const title = doc.title ?? t("clinicianView.documents.untitled");
const Icon =
DOCUMENT_KIND_ICONS[doc.kind as InboundDocumentKindValue] ?? undefined;
const meta = [doc.documentDate, formatBytes(doc.byteSize, locale)].filter(
Boolean,
) as string[];
const isImage = doc.mimeType.startsWith("image/");
const isInline = doc.servingClass === "inline";
return (
<li className="border-border bg-card rounded-lg border p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-2.5">
{Icon ? (
<Icon className="text-foreground mt-0.5 size-5 shrink-0" />
) : null}
<div className="min-w-0">
<p className="text-sm font-medium break-words">{title}</p>
{meta.length > 0 ? (
<p className="text-muted-foreground mt-0.5 text-xs">
{meta.join(" · ")}
</p>
) : null}
</div>
</div>
{/* Download is always available (inline or attachment) so the recipient
can keep a copy; the anchor hits the same token-scoped serve route. */}
<a
href={serveUrl}
download
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex min-h-9 shrink-0 items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-visible:ring-[3px] focus-visible:outline-none"
>
<Download className="size-3.5" aria-hidden />
{t("clinicianView.documents.download")}
</a>
</div>
{isInline ? (
<div className="mt-3">
{isImage ? (
// The served image is the frozen original with EXIF/GPS stripped on
// egress; it renders inline within the Class A carve-out.
// eslint-disable-next-line @next/next/no-img-element
<img
src={serveUrl}
alt={title}
className="border-border mx-auto max-h-[70vh] w-auto max-w-full rounded-md border"
/>
) : (
<iframe
src={serveUrl}
title={title}
className="border-border h-[70vh] max-h-[80vh] w-full rounded-md border"
/>
)}
</div>
) : (
<p className="text-muted-foreground mt-2 text-xs">
{t("clinicianView.documents.attachmentHint")}
</p>
)}
</li>
);
}
@@ -0,0 +1,46 @@
/**
* The two machine-format downloads on a share link.
*
* A practice opens the link, unlocks it, and saves the file into their system.
* These serve exactly the link's frozen selection — the same object the page
* above renders from — behind the same unlock cookie the document route
* enforces. There is no bearer face and no new scope.
*
* Rendered only for a link that actually carries a record; a documents-only
* share has nothing to download here.
*/
import { Download } from "lucide-react";
type Translate = (
key: string,
vars?: Record<string, string | number>,
) => string;
export function ShareDownloadActions({
t,
token,
}: {
t: Translate;
token: string;
}) {
const base = `/c/${encodeURIComponent(token)}`;
const actions = [
{ href: `${base}/report.pdf`, label: t("clinicianView.downloadPdf") },
{ href: `${base}/fhir`, label: t("clinicianView.downloadFhir") },
];
return (
<div className="mt-3 flex flex-wrap gap-2" data-testid="share-downloads">
{actions.map((action) => (
<a
key={action.href}
href={action.href}
download
className="border-border hover:bg-muted focus-visible:ring-ring/50 inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-md border px-3 text-xs font-medium focus-visible:ring-[3px] focus-visible:outline-none sm:min-h-9"
>
<Download className="size-3.5" aria-hidden />
{action.label}
</a>
))}
</div>
);
}
@@ -0,0 +1,232 @@
/**
* The health sections of the clinician view, grouped the way the owner chose
* them.
*
* 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
* clinician read rows called "Phq9 score" and "Audio exposure headphone" in
* one undifferentiated column. It now renders in the same twelve groups the
* selection panel shows, with the real localised labels, so the page and the
* PDF describe the record in the same order.
*
* A pure server component: no client hooks, no session, no markdown — every
* value renders as escaped React text.
*/
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 {
isStructuredLeafId,
REPORT_GROUPS,
} from "@/lib/report-selection/catalogue";
import type { ReportSelection } from "@/lib/report-selection/selection";
type Translate = (
key: string,
vars?: Record<string, string | number>,
) => string;
/** Human-readable display per persisted wellness-score type (i18n key suffix). */
const WELLNESS_KEY: Record<string, string> = {
RECOVERY_SCORE: "recovery",
STRESS_SCORE: "stress",
STRAIN_SCORE: "strain",
};
/** Render a single labelled stat row. */
export function StatRow({ label, value }: { label: string; value: string }) {
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>
</div>
);
}
export function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section 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>
);
}
/**
* 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.
*/
export function MeasurementGroups({
t,
report,
fmtNum,
}: {
t: Translate;
report: DoctorReportData;
fmtNum: (n: number) => number;
}) {
const groups = REPORT_GROUPS.map((group) => ({
labelKey: group.labelKey,
rows: group.leaves
.filter((leaf): leaf is MeasurementType => !isStructuredLeafId(leaf))
.map((type) => ({ type, stat: report.stats[type] }))
.filter((row) => row.stat !== undefined && row.stat.count > 0),
})).filter((group) => group.rows.length > 0);
if (groups.length === 0) return null;
return (
<>
{groups.map((group) => (
<Section key={group.labelKey} title={t(group.labelKey)}>
{group.rows.map(({ type, stat }) => (
<StatRow
key={type}
label={t(MEASUREMENT_TYPE_LABEL_KEYS[type])}
value={t("clinicianView.statSummary", {
latest: fmtNum(stat!.latest),
avg: fmtNum(stat!.avg),
min: fmtNum(stat!.min),
max: fmtNum(stat!.max),
})}
/>
))}
</Section>
))}
</>
);
}
/** Per-context glucose stats. Present only when the glucose panel was chosen. */
export function GlucoseSection({
t,
report,
fmtNum,
}: {
t: Translate;
report: DoctorReportData;
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")}>
{entries.map(([ctx, s]) => (
<StatRow
key={ctx}
label={t(`clinicianView.glucose.${ctx}`)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
</Section>
);
}
/** The medication list with the adherence rate beside each drug. */
export function MedicationsSection({
t,
report,
selection,
}: {
t: Translate;
report: DoctorReportData;
selection: ReportSelection;
}) {
const medications = report.medications ?? [];
const complianceOn = selection.has("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")}>
{medications.map((med) => {
const comp = report.compliance[med.name];
const rate =
complianceOn && comp && comp.total > 0
? `${Math.round((comp.taken / comp.total) * 100)}%`
: null;
return (
<StatRow
key={med.name}
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
value={
rate
? t("clinicianView.adherence", { rate })
: t("clinicianView.noAdherence")
}
/>
);
})}
{complianceEntries
.filter(([name]) => !medications.some((m) => m.name === name))
.map(([name, c]) => (
<StatRow
key={name}
label={name}
value={t("clinicianView.adherence", {
rate: `${Math.round((c.taken / c.total) * 100)}%`,
})}
/>
))}
</Section>
);
}
/**
* The fenced wellness card. Descriptive composites, not clinical assessments —
* the disclaimer is load-bearing and sits above the numbers, not under them.
*/
export function WellnessSection({
t,
report,
fmtNum,
}: {
t: Translate;
report: DoctorReportData;
fmtNum: (n: number) => number;
}) {
const wellness = report.wellnessScores?.filter((s) => s.count > 0) ?? [];
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">
<h2 className="text-muted-foreground mb-1 text-base font-semibold">
{t("clinicianView.wellness.title")}
</h2>
<p className="text-muted-foreground mb-3 text-xs">
{t("clinicianView.wellness.disclaimer")}
</p>
<div>
{wellness.map((s) => (
<StatRow
key={s.type}
label={t(
`clinicianView.wellness.${WELLNESS_KEY[s.type] ?? "score"}`,
)}
value={t("clinicianView.statSummary", {
latest: fmtNum(s.latest),
avg: fmtNum(s.avg),
min: fmtNum(s.min),
max: fmtNum(s.max),
})}
/>
))}
</div>
</section>
);
}