diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 85b194085..fd08a5d0e 100644 Binary files a/src/lib/__tests__/doctor-report-data.test.ts and b/src/lib/__tests__/doctor-report-data.test.ts differ diff --git a/src/lib/doctor-report-data.ts b/src/lib/doctor-report-data.ts index f4ac832aa..c909bdac2 100644 --- a/src/lib/doctor-report-data.ts +++ b/src/lib/doctor-report-data.ts @@ -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; diff --git a/src/lib/doctor-report-pdf-core.ts b/src/lib/doctor-report-pdf-core.ts index fce890833..b3e623b8b 100644 --- a/src/lib/doctor-report-pdf-core.ts +++ b/src/lib/doctor-report-pdf-core.ts @@ -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; @@ -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, diff --git a/src/lib/fhir/__tests__/build-bundle.test.ts b/src/lib/fhir/__tests__/build-bundle.test.ts index efa480f9e..3f95a9576 100644 --- a/src/lib/fhir/__tests__/build-bundle.test.ts +++ b/src/lib/fhir/__tests__/build-bundle.test.ts @@ -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", () => { diff --git a/src/lib/fhir/resources.ts b/src/lib/fhir/resources.ts index 93de33db6..61cf6c327 100644 --- a/src/lib/fhir/resources.ts +++ b/src/lib/fhir/resources.ts @@ -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}`,