feat(doctor-report): GLP1 therapy section with titration + weight curve + side-effects

Phase 10 of W4d GLP-1 full integration. The doctor-report PDF gains a
dedicated "GLP-1 therapy" section when the patient has at least one
active GLP-1 medication and the existing compliance toggle is on. The
section lists what a prescribing clinician needs at every follow-up:

- Weight delta over the report window (start → end → delta)
- Per-medication block: drug name, current dose + start date,
  compliance count and rate, full titration history table
  (date | dose | note)
- Side-effect frequency table (last N days, counts per tag) when the
  mood section is also enabled (mood is the data source)

Generic accounts (no GLP-1 medication) get `glp1: null` from the
aggregator and the PDF skips the entire section, so existing reports
stay byte-identical.

Aggregator extension lives in `doctor-report-data.ts` — eager-loads
`doseChanges` + the most recent intake event on every active
medication (cheap, indexed); GLP-1 specifics only assemble when at
least one GLP-1 row exists. The field is optional on the type to
preserve compatibility with existing test fixtures.

DE + EN copy for the report section lives under `doctorReport.glp1*`.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 13:56:08 +02:00
1 parent 7b6b9161c3
commit f45adb29f0
4 files changed
+159

No files matched your search

+9
View File
@@ -70,6 +70,15 @@
"bmiTitle": "Body-Mass-Index (BMI)",
"bmiRow": "BMI: {bmi} kg/m² — Klassifikation: {class} (WHO)",
"complianceTitle": "Medikamenten-Compliance",
"glp1Title": "GLP-1-Therapie",
"glp1WeightSummary": "Gewicht im Berichtszeitraum: {start} kg → {end} kg ({delta} kg)",
"glp1CurrentDose": "Aktuelle Dosis: {value} {unit} (seit {since})",
"glp1Compliance": "Compliance: {taken} / {total} ({rate}%)",
"glp1SideEffectsTitle": "Erfasste Nebenwirkungen",
"colGlp1Date": "Datum",
"colGlp1Dose": "Dosis",
"colGlp1Note": "Notiz",
"colGlp1SideEffect": "Nebenwirkung",
"colMedication": "Medikament",
"colTaken": "Eingenommen",
"colSkipped": "Übersprungen",
+9
View File
@@ -70,6 +70,15 @@
"bmiTitle": "Body Mass Index (BMI)",
"bmiRow": "BMI: {bmi} kg/m² — Classification: {class} (WHO)",
"complianceTitle": "Medication compliance",
"glp1Title": "GLP-1 therapy",
"glp1WeightSummary": "Weight over report period: {start} kg → {end} kg ({delta} kg)",
"glp1CurrentDose": "Current dose: {value} {unit} (since {since})",
"glp1Compliance": "Compliance: {taken} / {total} ({rate}%)",
"glp1SideEffectsTitle": "Side effects logged",
"colGlp1Date": "Date",
"colGlp1Dose": "Dose",
"colGlp1Note": "Note",
"colGlp1SideEffect": "Side effect",
"colMedication": "Medication",
"colTaken": "Taken",
"colSkipped": "Skipped",
Binary file not shown.
+141
View File
@@ -455,6 +455,147 @@ export function buildDoctorReportPdfDocument(
.finalY + 8;
}
// v1.4.25 W4d — GLP-1 therapy section. Only renders when the data
// aggregator emitted a non-null `glp1` block (user has at least one
// active GLP-1 medication and the compliance toggle is on). Lists
// current drug + dose, full titration history, weight curve over
// the report window, side-effect frequency, and compliance %.
if (data.glp1) {
if (y > 220) {
doc.addPage();
y = margin;
}
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.glp1Title"), margin, y);
y += 6;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
if (data.glp1.weightDeltaKg !== null && data.glp1.weightStartKg !== null && data.glp1.weightEndKg !== null) {
doc.text(
t("doctorReport.glp1WeightSummary", {
start: num(data.glp1.weightStartKg, 1),
end: num(data.glp1.weightEndKg, 1),
delta: num(data.glp1.weightDeltaKg, 1),
}),
margin,
y,
);
y += 6;
}
for (const med of data.glp1.medications) {
if (y > 240) {
doc.addPage();
y = margin;
}
doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.text(med.name, margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
if (med.currentDose) {
doc.text(
t("doctorReport.glp1CurrentDose", {
value: num(med.currentDose.value, 2),
unit: med.currentDose.unit,
since: new Date(med.currentDose.since).toLocaleDateString(locale),
}),
margin,
y,
);
y += 5;
}
if (med.compliance.total > 0) {
const rate = (med.compliance.taken / med.compliance.total) * 100;
doc.text(
t("doctorReport.glp1Compliance", {
taken: med.compliance.taken,
total: med.compliance.total,
rate: num(rate, 1),
}),
margin,
y,
);
y += 5;
}
if (med.doseHistory.length > 0) {
const historyRows = med.doseHistory.map((dc) => [
new Date(dc.effectiveFrom).toLocaleDateString(locale),
`${num(dc.value, 2)} ${dc.unit}`,
dc.note ?? "",
]);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.colGlp1Date"),
t("doctorReport.colGlp1Dose"),
t("doctorReport.colGlp1Note"),
],
],
body: historyRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 6;
}
}
if (data.glp1.sideEffects.length > 0) {
if (y > 240) {
doc.addPage();
y = margin;
}
doc.setFontSize(11);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.glp1SideEffectsTitle"), margin, y);
y += 5;
const seRows = data.glp1.sideEffects.map((s) => [s.tag, String(s.count)]);
autoTable(doc, {
startY: y,
head: [[t("doctorReport.colGlp1SideEffect"), t("doctorReport.colCount")]],
body: seRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
}
if (data.mood) {
if (y > 240) {
doc.addPage();