feat(doctor-report): add workouts + lab trends + self-context, align adherence rounding

Surface actual workout sessions and a per-analyte lab trend in the doctor
report PDF + FHIR bundle (gated on their modules), carry the user's stored
conditions/allergies through to the report and FHIR Condition/AllergyIntolerance,
and round medication adherence to the app's integer convention so the PDF, FHIR
and in-app numbers agree.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 08:36:08 +02:00
1 parent 68fd659f04
commit 90c7da2a5e
5 files changed
+49 -11

No files matched your search

Binary file not shown.
+23
View File
@@ -65,6 +65,29 @@ export interface DoctorReportCompliance {
missed: number;
}
/**
* Canonical adherence-rate rounding for the doctor-report export surfaces.
*
* The PDF table, the PDF clinical-summary headline, the FHIR adherence
* Observation, and the GLP-1 block all derive the rate from the SAME ledger
* denominator (`taken / total`, `total = taken + missed`). They MUST round it
* the same way the app does so a clinician comparing the export to the in-app
* card never sees a presentational divergence (87 % on the card, 87.3 % on the
* PDF). The app convention is `round(100 · taken / denominator)` capped at 100
* (see `dose-history-ledger-compute.ts` + `tallyComplianceFromLedger`) — an
* integer percent. This is the one source of truth for every export surface.
*
* Returns `null` when `total <= 0` so callers render the "no expected dose"
* placeholder instead of a misleading `0` or `100`.
*/
export function adherenceRatePercent(
taken: number,
total: number,
): number | null {
if (total <= 0) return null;
return Math.min(100, Math.round((taken / total) * 100));
}
export interface DoctorReportMood {
avg: number;
min: number;
+16 -7
View File
@@ -18,7 +18,10 @@ import {
classifyReferenceRange,
formatReferenceRange,
} from "./labs/reference-range";
import type { DoctorReportData } from "./doctor-report-data";
import {
adherenceRatePercent,
type DoctorReportData,
} from "./doctor-report-data";
type T = (key: string, params?: Record<string, string | number>) => string;
@@ -333,10 +336,11 @@ function buildClinicalSummaryLines(
const compEntries = Object.values(data.compliance);
const totalDoses = compEntries.reduce((s, c) => s + c.total, 0);
const totalTaken = compEntries.reduce((s, c) => s + c.taken, 0);
if (totalDoses > 0) {
const headlineRate = adherenceRatePercent(totalTaken, totalDoses);
if (headlineRate !== null) {
lines.push(
t("doctorReport.summaryAdherence", {
rate: num((totalTaken / totalDoses) * 100, 1),
rate: num(headlineRate, 0),
}),
);
}
@@ -986,7 +990,9 @@ export function buildDoctorReportPdfDocument(
// detail-page adherence %. The column is labelled "Expected" rather
// than "Total" so the row stays internally coherent: Taken + Missed =
// Expected, with Skipped shown alongside as informational.
const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
// Integer percent via the canonical rounding — matches the in-app card.
const ratePct = adherenceRatePercent(c.taken, c.total);
const rate = ratePct !== null ? `${num(ratePct, 0)}%` : "—";
return [
name,
String(c.taken),
@@ -1090,13 +1096,16 @@ export function buildDoctorReportPdfDocument(
);
y += 5;
}
if (med.compliance.total > 0) {
const rate = (med.compliance.taken / med.compliance.total) * 100;
const glp1Rate = adherenceRatePercent(
med.compliance.taken,
med.compliance.total,
);
if (glp1Rate !== null) {
doc.text(
t("doctorReport.glp1Compliance", {
taken: med.compliance.taken,
total: med.compliance.total,
rate: num(rate, 1),
rate: num(glp1Rate, 0),
}),
margin,
y,
+3 -2
View File
@@ -412,8 +412,9 @@ describe("buildFhirDocumentBundle", () => {
const adherence = observations.find((o) =>
o.code.coding?.some((c) => c.code === "71799-1"),
);
// PDF renders taken/total = 85/90 = 94.4%.
expect(adherence?.valueQuantity?.value).toBeCloseTo(94.4, 1);
// taken/total = 85/90 = 94.4% → rounded to the app's integer convention
// (PDF + FHIR now share one rounding source of truth).
expect(adherence?.valueQuantity?.value).toBe(94);
});
it("emits one MedicationStatement per active medication", () => {
+7 -2
View File
@@ -17,7 +17,10 @@
* convention. All text is escaped plain text; never user-supplied HTML
* (no markdown library, no `dangerouslySetInnerHTML`).
*/
import type { DoctorReportData } from "@/lib/doctor-report-data";
import {
adherenceRatePercent,
type DoctorReportData,
} from "@/lib/doctor-report-data";
import { resolveGlucoseUnit, convertGlucose } from "@/lib/glucose";
import {
LOINC_SYSTEM,
@@ -660,7 +663,9 @@ export function observationsFromReportData(
for (const [name, comp] of Object.entries(data.compliance)) {
if (comp.total <= 0) continue;
obsSeq += 1;
const rate = Math.round((comp.taken / comp.total) * 1000) / 10;
// Integer percent — the one canonical rounding the app card + PDF use, so
// a clinician sees the same adherence figure on every surface.
const rate = adherenceRatePercent(comp.taken, comp.total) ?? 0;
push({
resourceType: "Observation",
id: `obs-${obsSeq}`,