mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
68fd659f04
commit
90c7da2a5e
5 files changed
+49
-11
No files matched your search
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in new issue
Block a user