mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Oura's daily_resilience collection reports a categorical level — limited, adequate, solid, strong, exceptional. Encode it ordinally (limited=1 … exceptional=5) into the numeric measurement value so it fits the existing model with no new categorical column: add the RESILIENCE measurement type (migration 0186), fetch and map daily_resilience in the Oura sync, and skip an unknown or missing level rather than coercing it to zero. RESILIENCE_LEVELS in the Oura client is the single source of truth for the encoding. The day-keyed externalId keeps a re-sync idempotent so a re-scored day overwrites in place. Extend the measurement-type label, unit, range, category, chart-token, and PR-direction maps; add the metric label across all six locales.
1691 lines
54 KiB
TypeScript
1691 lines
54 KiB
TypeScript
/**
|
||
* Isomorphic PDF renderer for the doctor report.
|
||
*
|
||
* Runs identically in the browser (settings page download) and in Node
|
||
* (server-rendered `/api/doctor-report/pdf` endpoint). All locale-sensitive
|
||
* strings and number/date formatting are driven by the injected
|
||
* `{ t, locale }` so DE and EN output match the user's UI language.
|
||
*
|
||
* jsPDF is fully isomorphic: `doc.output("arraybuffer")` returns a valid
|
||
* `%PDF-` byte stream in both environments.
|
||
*/
|
||
import { jsPDF } from "jspdf";
|
||
import autoTable from "jspdf-autotable";
|
||
import { makeFormatters } from "./format-locale";
|
||
import type { Locale } from "./i18n/config";
|
||
import { convertGlucose, resolveGlucoseUnit } from "./glucose";
|
||
import {
|
||
classifyReferenceRange,
|
||
formatReferenceRange,
|
||
} from "./labs/reference-range";
|
||
import {
|
||
adherenceRatePercent,
|
||
type DoctorReportData,
|
||
} from "./doctor-report-data";
|
||
|
||
type T = (key: string, params?: Record<string, string | number>) => string;
|
||
|
||
/**
|
||
* jsPDF's built-in Helvetica is WinAnsi-encoded. Latin-1 glyphs (umlauts,
|
||
* ß, em-/en-dash, typographic quotes, °, µ) carry correct metrics, but any
|
||
* code point outside WinAnsi resolves to the `.notdef` box at a single
|
||
* fallback advance width. The widths then disagree with the drawn glyph and
|
||
* the surrounding words stretch / shift — the visible "stretched line" bug.
|
||
*
|
||
* This sanitiser maps every glyph the report can emit that falls outside
|
||
* WinAnsi onto a WinAnsi-safe equivalent, applied centrally to every string
|
||
* just before it reaches `doc.text` / `doc.splitTextToSize` (see
|
||
* `patchPdfTextSanitiser`). The offenders that actually occur in the report
|
||
* are the trend arrows (↑ ↓ →) injected by `trendArrow()` + the `glp1WeightSummary`
|
||
* separator, and the superscript-two in "kg/m²". The mapping is exhaustive
|
||
* for those and degrades gracefully for any future stray symbol.
|
||
*
|
||
* Premium follow-up (documented, not a hotfix blocker): embed a Unicode TTF
|
||
* (e.g. DejaVuSans) via `doc.addFileToVFS` / `addFont` so the arrows and
|
||
* superscripts render as their true glyphs instead of ASCII equivalents.
|
||
*/
|
||
const WINANSI_REPLACEMENTS: Record<string, string> = {
|
||
// Trend arrows → ASCII so the metrics match the drawn glyph.
|
||
"↑": "^", // ↑ up
|
||
"↓": "v", // ↓ down
|
||
"→": "->", // → right (also the glp1 weight separator)
|
||
"←": "<-", // ←
|
||
"↔": "<->", // ↔
|
||
// Super-/subscripts used in "kg/m²".
|
||
"²": "2", // ²
|
||
"³": "3", // ³
|
||
"¹": "1", // ¹
|
||
// Defensive: a few maths/symbol glyphs that are outside WinAnsi but read
|
||
// fine as ASCII, in case a future string introduces them.
|
||
"≈": "~", // ≈
|
||
"≤": "<=", // ≤
|
||
"≥": ">=", // ≥
|
||
"×": "x", // ×
|
||
"€": "EUR", // €
|
||
};
|
||
|
||
const WINANSI_REPLACE_RE = new RegExp(
|
||
`[${Object.keys(WINANSI_REPLACEMENTS).join("")}]`,
|
||
"g",
|
||
);
|
||
|
||
/** Map non-WinAnsi glyphs onto safe equivalents. Pure; exported for tests. */
|
||
export function sanitiseForPdf(text: string): string {
|
||
return text.replace(
|
||
WINANSI_REPLACE_RE,
|
||
(ch) => WINANSI_REPLACEMENTS[ch] ?? ch,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Patch `doc.text` + `doc.splitTextToSize` on a single jsPDF instance so
|
||
* every drawn string (direct text, wrapped paragraphs, AND `jspdf-autotable`
|
||
* cells — which route their content through `doc.text` too) passes through
|
||
* `sanitiseForPdf` first. One choke point instead of a sanitiser call at
|
||
* every site keeps the renderer readable and guarantees coverage.
|
||
*/
|
||
function patchPdfTextSanitiser(doc: jsPDF): void {
|
||
const clean = (value: unknown): unknown =>
|
||
typeof value === "string"
|
||
? sanitiseForPdf(value)
|
||
: Array.isArray(value)
|
||
? value.map((v) => (typeof v === "string" ? sanitiseForPdf(v) : v))
|
||
: value;
|
||
|
||
const originalText = doc.text.bind(doc);
|
||
doc.text = function patchedText(
|
||
this: jsPDF,
|
||
...args: Parameters<jsPDF["text"]>
|
||
): jsPDF {
|
||
const next = [...args] as unknown[];
|
||
next[0] = clean(next[0]);
|
||
return originalText(...(next as Parameters<jsPDF["text"]>));
|
||
} as jsPDF["text"];
|
||
|
||
const originalSplit = doc.splitTextToSize.bind(doc);
|
||
doc.splitTextToSize = function patchedSplit(
|
||
this: jsPDF,
|
||
...args: Parameters<jsPDF["splitTextToSize"]>
|
||
): ReturnType<jsPDF["splitTextToSize"]> {
|
||
const next = [...args] as unknown[];
|
||
next[0] = clean(next[0]);
|
||
return originalSplit(...(next as Parameters<jsPDF["splitTextToSize"]>));
|
||
} as jsPDF["splitTextToSize"];
|
||
}
|
||
|
||
export interface DoctorReportRenderOptions {
|
||
t: T;
|
||
locale: Locale;
|
||
/**
|
||
* Optional fixed timestamp for "createdOn"/footer. Useful for deterministic
|
||
* tests; defaults to `new Date()`.
|
||
*/
|
||
now?: Date;
|
||
/**
|
||
* v1.4.25 W7 — per-user display timezone. When omitted the report
|
||
* renders timestamps in Europe/Berlin (legacy contract). Server
|
||
* callers pass `resolveUserTimezone(user.id)`; client callers pass
|
||
* the value from auth context so a US user's PDF carries
|
||
* Eastern-time rows even when generated in the browser.
|
||
*/
|
||
userTz?: string;
|
||
/**
|
||
* v1.7.0 — decrypted KVNR (German insurance number). Printed on the
|
||
* cover when present; the column is encrypted at rest, so the route
|
||
* decrypts it and hands the plaintext in here. Null/undefined omits
|
||
* the cover line exactly like an unset practice name.
|
||
*/
|
||
insuranceNumber?: string | null;
|
||
/**
|
||
* v1.7.0 — embed jsPDF-native trend sparklines per primary vital.
|
||
* Defaults to `true`. Off produces a compact text-only report.
|
||
*/
|
||
includeCharts?: boolean;
|
||
/**
|
||
* v1.7.0 — optional AI summary text. OUT of the clinical PDF by
|
||
* default; rendered ONLY when the user explicitly opts in, under a
|
||
* clearly-labelled "AI summary — not clinically validated" heading.
|
||
* Null/undefined/empty omits the section entirely.
|
||
*/
|
||
aiSummary?: string | null;
|
||
}
|
||
|
||
/**
|
||
* Per-vital label keys. Exported for coverage tests (issue #109 / phase P0)
|
||
* so a future enum addition is caught by a unit test rather than reaching
|
||
* production as a raw enum string in the PDF.
|
||
*/
|
||
export const DOCTOR_REPORT_TYPE_LABEL_KEYS: Record<string, string> = {
|
||
WEIGHT: "doctorReport.typeWeight",
|
||
BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
|
||
BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
|
||
PULSE: "doctorReport.typePulse",
|
||
BODY_FAT: "doctorReport.typeBodyFat",
|
||
SLEEP_DURATION: "doctorReport.typeSleep",
|
||
ACTIVITY_STEPS: "doctorReport.typeSteps",
|
||
TOTAL_BODY_WATER: "doctorReport.typeTotalBodyWater",
|
||
BONE_MASS: "doctorReport.typeBoneMass",
|
||
OXYGEN_SATURATION: "doctorReport.typeOxygenSaturation",
|
||
};
|
||
|
||
export const DOCTOR_REPORT_TYPE_UNIT_KEYS: Record<string, string | null> = {
|
||
WEIGHT: "kg",
|
||
BLOOD_PRESSURE_SYS: "mmHg",
|
||
BLOOD_PRESSURE_DIA: "mmHg",
|
||
PULSE: "bpm",
|
||
BODY_FAT: "%",
|
||
SLEEP_DURATION: "h",
|
||
ACTIVITY_STEPS: null, // translated unit
|
||
TOTAL_BODY_WATER: "kg",
|
||
BONE_MASS: "kg",
|
||
OXYGEN_SATURATION: "%",
|
||
};
|
||
|
||
/**
|
||
* Vital types rendered in the main vitals table. Body composition
|
||
* (TOTAL_BODY_WATER, BONE_MASS) ships alongside body fat — Withings
|
||
* smart scales report all three together. SpO2 (Withings ScanWatch type
|
||
* 54, HealthKit, n8n / Health Connect) is rendered last in the same
|
||
* table for clinical readability. Glucose ships separately via
|
||
* per-context `glucoseStats`. Sleep + activity are intentionally
|
||
* excluded from a clinical-focused report.
|
||
*
|
||
* v1.10.0 — computed scores (WX-C). The server-derived `*_SCORE` types
|
||
* (RECOVERY_SCORE / STRESS_SCORE / STRAIN_SCORE) are NOT clinical vitals:
|
||
* they are 0–100 composites recomputed nightly from the underlying signals
|
||
* and carry a "descriptive, not a clinical assessment" disclaimer. They stay
|
||
* out of this table by design (paired with `PDF_VITAL_EXCLUSIONS` in
|
||
* `measurement-type-enum-coverage.test.ts`).
|
||
*
|
||
* v1.17.1 — Oura coverage completion. `SLEEP_SCORE` (a nightly derived
|
||
* composite) and `BODY_TEMPERATURE_DEVIATION` (a signed baseline offset, not an
|
||
* absolute reading) are excluded for the same reason as the other derived /
|
||
* lifestyle signals — they are descriptive, not measured clinical vitals.
|
||
*
|
||
* v1.19.0 — Oura resilience. `RESILIENCE` is an ordinal-encoded categorical
|
||
* band (limited=1 … exceptional=5), a derived recovery composite — descriptive,
|
||
* not a measured clinical vital — so it is excluded too.
|
||
*/
|
||
export const DOCTOR_REPORT_VITAL_TYPES = [
|
||
"WEIGHT",
|
||
"BLOOD_PRESSURE_SYS",
|
||
"BLOOD_PRESSURE_DIA",
|
||
"PULSE",
|
||
"BODY_FAT",
|
||
"TOTAL_BODY_WATER",
|
||
"BONE_MASS",
|
||
"OXYGEN_SATURATION",
|
||
] as const;
|
||
|
||
const MOOD_LABEL_KEYS: Record<number, string> = {
|
||
1: "doctorReport.moodAwful",
|
||
2: "doctorReport.moodBad",
|
||
3: "doctorReport.moodNeutral",
|
||
4: "doctorReport.moodGood",
|
||
5: "doctorReport.moodGreat",
|
||
};
|
||
|
||
/** v1.10.0 — display-label key per persisted wellness-score type. */
|
||
const WELLNESS_SCORE_LABEL_KEYS: Record<string, string> = {
|
||
RECOVERY_SCORE: "doctorReport.wellnessRecovery",
|
||
STRESS_SCORE: "doctorReport.wellnessStress",
|
||
STRAIN_SCORE: "doctorReport.wellnessStrain",
|
||
};
|
||
|
||
const GLUCOSE_LABEL_KEYS = {
|
||
FASTING: "doctorReport.typeGlucoseFasting",
|
||
POSTPRANDIAL: "doctorReport.typeGlucosePostprandial",
|
||
RANDOM: "doctorReport.typeGlucoseRandom",
|
||
BEDTIME: "doctorReport.typeGlucoseBedtime",
|
||
} as const;
|
||
|
||
const DEFAULT_GLUCOSE_RANGES = {
|
||
FASTING: { min: 70, max: 99 },
|
||
POSTPRANDIAL: { min: 70, max: 140 },
|
||
RANDOM: { min: 70, max: 140 },
|
||
BEDTIME: { min: 90, max: 150 },
|
||
} as const;
|
||
|
||
const GLUCOSE_CONTEXTS: Array<keyof typeof GLUCOSE_LABEL_KEYS> = [
|
||
"FASTING",
|
||
"POSTPRANDIAL",
|
||
"RANDOM",
|
||
"BEDTIME",
|
||
];
|
||
|
||
function getBmiClassificationKey(bmi: number): string {
|
||
if (bmi < 18.5) return "doctorReport.bmiUnderweight";
|
||
if (bmi < 25) return "doctorReport.bmiNormal";
|
||
if (bmi < 30) return "doctorReport.bmiOverweight";
|
||
if (bmi < 35) return "doctorReport.bmiObeseGrade1";
|
||
if (bmi < 40) return "doctorReport.bmiObeseGrade2";
|
||
return "doctorReport.bmiObeseGrade3";
|
||
}
|
||
|
||
function getBpClassificationKey(sys: number, dia: number): string {
|
||
if (sys < 120 && dia < 80) return "doctorReport.bpOptimal";
|
||
if (sys < 130 && dia < 85) return "doctorReport.bpNormal";
|
||
if (sys < 140 && dia < 90) return "doctorReport.bpHighNormal";
|
||
if (sys < 160 && dia < 100) return "doctorReport.bpHypertensionGrade1";
|
||
if (sys < 180 && dia < 110) return "doctorReport.bpHypertensionGrade2";
|
||
return "doctorReport.bpHypertensionGrade3";
|
||
}
|
||
|
||
/** Primary vitals that get a trend sparkline + a summary trend arrow. */
|
||
const SPARKLINE_TYPES = ["WEIGHT", "BLOOD_PRESSURE_SYS", "PULSE"] as const;
|
||
|
||
type FormatNum = (value: number, decimals?: number) => string;
|
||
|
||
/** First-half vs second-half mean → "↑" / "↓" / "→". */
|
||
function trendArrow(values: number[]): "↑" | "↓" | "→" {
|
||
if (values.length < 2) return "→";
|
||
const mid = Math.floor(values.length / 2);
|
||
const firstHalf = values.slice(0, mid);
|
||
const secondHalf = values.slice(mid);
|
||
const mean = (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length;
|
||
const delta = mean(secondHalf) - mean(firstHalf);
|
||
// Threshold at ~1% of the first-half mean so flat series read "→".
|
||
const threshold = Math.abs(mean(firstHalf)) * 0.01;
|
||
if (delta > threshold) return "↑";
|
||
if (delta < -threshold) return "↓";
|
||
return "→";
|
||
}
|
||
|
||
/**
|
||
* Deterministic clinical-summary lines. Pure data — no AI. Mirrors the
|
||
* existing BP/BMI classification approach: factual, reproducible.
|
||
*/
|
||
function buildClinicalSummaryLines(
|
||
data: DoctorReportData,
|
||
t: T,
|
||
num: FormatNum,
|
||
): string[] {
|
||
const lines: string[] = [];
|
||
|
||
const totalReadings = Object.values(data.measurements).reduce(
|
||
(sum, arr) => sum + arr.length,
|
||
0,
|
||
);
|
||
const paramCount = Object.keys(data.stats).length;
|
||
if (totalReadings > 0) {
|
||
lines.push(
|
||
t("doctorReport.summaryReadings", {
|
||
days: data.period.days,
|
||
count: totalReadings,
|
||
params: paramCount,
|
||
}),
|
||
);
|
||
}
|
||
|
||
// Per-primary-vital latest + trend arrow.
|
||
for (const type of SPARKLINE_TYPES) {
|
||
const series = data.measurements[type];
|
||
const stat = data.stats[type];
|
||
if (!series || series.length === 0 || !stat) continue;
|
||
const arrow = trendArrow(series.map((p) => p.value));
|
||
lines.push(
|
||
t("doctorReport.summaryTrend", {
|
||
label: t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""),
|
||
latest: num(stat.latest, 1),
|
||
arrow,
|
||
}),
|
||
);
|
||
}
|
||
|
||
// Medication-adherence headline (weighted mean across meds).
|
||
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);
|
||
const headlineRate = adherenceRatePercent(totalTaken, totalDoses);
|
||
if (headlineRate !== null) {
|
||
lines.push(
|
||
t("doctorReport.summaryAdherence", {
|
||
rate: num(headlineRate, 0),
|
||
}),
|
||
);
|
||
}
|
||
|
||
return lines;
|
||
}
|
||
|
||
/** A single charted reading. */
|
||
type SparklinePoint = { value: number; measuredAt: string };
|
||
|
||
/** Label band (5 mm) + plot (16 mm) + axis-date line (4.5 mm) + trailing gap. */
|
||
const SPARKLINE_HEIGHT = 5 + 16 + 4.5 + 2;
|
||
|
||
/**
|
||
* Rough autoTable height estimate (mm) used by the page-break guards so a
|
||
* heading + its table do not orphan. The grid theme at fontSize 9 +
|
||
* cellPadding 3 renders header + each body row at ~9 mm. Deliberately a
|
||
* conservative over-estimate — a slightly early break is harmless, a missed
|
||
* one tears the module.
|
||
*/
|
||
function estimateTableHeight(rowCount: number): number {
|
||
const HEADER_MM = 9;
|
||
const ROW_MM = 9;
|
||
return HEADER_MM + rowCount * ROW_MM;
|
||
}
|
||
|
||
/**
|
||
* Draw a jsPDF-native trend sparkline (label + min/max ticks + polyline +
|
||
* a time axis). Returns the new `y` cursor below the drawn chart. Vector-only
|
||
* — uses `doc.lines()`, no raster image, no native canvas module.
|
||
*
|
||
* Each point carries its `measuredAt` so the x-axis is anchored in real time:
|
||
* points are positioned by their timestamp across the report window, and the
|
||
* first/last dates are printed under the baseline. Without the axis a reader
|
||
* cannot tell whether the trend spans a week or a year.
|
||
*/
|
||
function drawSparkline(
|
||
doc: jsPDF,
|
||
opts: {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
label: string;
|
||
points: SparklinePoint[];
|
||
num: FormatNum;
|
||
unit: string;
|
||
dateShort: (iso: string) => string;
|
||
},
|
||
): number {
|
||
const { x, y, width, label, points, num, unit, dateShort } = opts;
|
||
const chartHeight = 16;
|
||
const labelHeight = 5;
|
||
const axisLabelHeight = 4.5;
|
||
|
||
doc.setFontSize(9);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.setTextColor(30, 30, 30);
|
||
doc.text(label, x, y + labelHeight - 1.5);
|
||
|
||
const values = points.map((p) => p.value);
|
||
const min = Math.min(...values);
|
||
const max = Math.max(...values);
|
||
const range = max - min || 1;
|
||
|
||
const chartTop = y + labelHeight;
|
||
const chartBottom = chartTop + chartHeight;
|
||
// Reserve a right gutter for the min/max value labels.
|
||
const gutter = 22;
|
||
const chartWidth = width - gutter;
|
||
|
||
// Baseline box.
|
||
doc.setDrawColor(220, 220, 220);
|
||
doc.setLineWidth(0.2);
|
||
doc.line(x, chartBottom, x + chartWidth, chartBottom);
|
||
|
||
// X position is anchored in time: map each reading's timestamp across the
|
||
// [first, last] span. When every timestamp collapses to one instant fall
|
||
// back to even spacing so a same-day cluster still plots.
|
||
const times = points.map((p) => new Date(p.measuredAt).getTime());
|
||
const tMin = Math.min(...times);
|
||
const tMax = Math.max(...times);
|
||
const tSpan = tMax - tMin;
|
||
const plotted = points.map((p, i) => {
|
||
const frac =
|
||
tSpan > 0
|
||
? (times[i] - tMin) / tSpan
|
||
: points.length > 1
|
||
? i / (points.length - 1)
|
||
: 0;
|
||
return {
|
||
px: x + frac * chartWidth,
|
||
py: chartBottom - ((p.value - min) / range) * chartHeight,
|
||
};
|
||
});
|
||
doc.setDrawColor(80, 110, 200);
|
||
doc.setLineWidth(0.4);
|
||
const deltas: [number, number][] = [];
|
||
for (let i = 1; i < plotted.length; i++) {
|
||
deltas.push([
|
||
plotted[i].px - plotted[i - 1].px,
|
||
plotted[i].py - plotted[i - 1].py,
|
||
]);
|
||
}
|
||
if (deltas.length > 0) {
|
||
doc.lines(deltas, plotted[0].px, plotted[0].py);
|
||
}
|
||
|
||
// Min/max labels in the right gutter.
|
||
doc.setFontSize(7);
|
||
doc.setFont("helvetica", "normal");
|
||
doc.setTextColor(120, 120, 120);
|
||
const unitSuffix = unit ? ` ${unit}` : "";
|
||
doc.text(`${num(max, 1)}${unitSuffix}`, x + chartWidth + 2, chartTop + 2);
|
||
doc.text(`${num(min, 1)}${unitSuffix}`, x + chartWidth + 2, chartBottom);
|
||
|
||
// Time axis: first date left-aligned, last date right-aligned under the
|
||
// baseline so the trend's span is unambiguous.
|
||
const axisY = chartBottom + axisLabelHeight - 1;
|
||
const startLabel = dateShort(points[0]!.measuredAt);
|
||
const endLabel = dateShort(points[points.length - 1]!.measuredAt);
|
||
doc.text(startLabel, x, axisY);
|
||
if (endLabel !== startLabel) {
|
||
doc.text(endLabel, x + chartWidth, axisY, { align: "right" });
|
||
}
|
||
|
||
return chartBottom + axisLabelHeight + 2;
|
||
}
|
||
|
||
/**
|
||
* Render the doctor report into a `jsPDF` instance.
|
||
*
|
||
* Used internally by both the client wrapper (which calls `.save()` on the
|
||
* returned doc) and the server renderer (which calls `.output("arraybuffer")`
|
||
* via `renderDoctorReportPdfBytes`).
|
||
*/
|
||
export function buildDoctorReportPdfDocument(
|
||
data: DoctorReportData,
|
||
options: DoctorReportRenderOptions,
|
||
): jsPDF {
|
||
const {
|
||
t,
|
||
locale,
|
||
now = new Date(),
|
||
userTz,
|
||
insuranceNumber = null,
|
||
includeCharts = true,
|
||
aiSummary = null,
|
||
} = options;
|
||
const formatters = makeFormatters(locale, userTz);
|
||
const num = (value: number, decimals = 1) =>
|
||
formatters.number(value, decimals);
|
||
const fmtDate = (iso: string) => formatters.date(iso);
|
||
|
||
const unitFor = (type: string): string => {
|
||
const staticUnit = DOCTOR_REPORT_TYPE_UNIT_KEYS[type];
|
||
if (staticUnit === null && type === "ACTIVITY_STEPS") {
|
||
return t("doctorReport.unitSteps");
|
||
}
|
||
return staticUnit ?? "";
|
||
};
|
||
|
||
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
||
// Route every drawn string through the WinAnsi sanitiser so non-WinAnsi
|
||
// glyphs (trend arrows, superscripts) can never stretch a line.
|
||
patchPdfTextSanitiser(doc);
|
||
// v1.7.0 — document metadata (PDF/A-leaning). jsPDF embeds the standard
|
||
// Helvetica fonts and pulls no external resources; setting the document
|
||
// properties + a deterministic creation date gives most practice systems
|
||
// an acceptable file without the full PDF/A-1b XMP/OutputIntent work
|
||
// (documented as a follow-up, not a release blocker).
|
||
doc.setProperties({
|
||
title: t("doctorReport.title"),
|
||
subject: t("doctorReport.subtitle"),
|
||
creator: "HealthLog",
|
||
author: data.patient.fullName ?? data.patient.username ?? "HealthLog",
|
||
});
|
||
const pageWidth = doc.internal.pageSize.getWidth();
|
||
const pageHeight = doc.internal.pageSize.getHeight();
|
||
const margin = 20;
|
||
// Bottom safe area: the three-line footer disclaimer sits in the last
|
||
// ~14 mm; reserve it plus a small breathing gap so body content never
|
||
// collides with the footer or runs off the page. `contentMaxY` is the
|
||
// single source of truth every break check + autoTable bottom margin
|
||
// refers to — no more scattered `y > 240` magic numbers.
|
||
const FOOTER_HEIGHT = 16;
|
||
const bottomMargin = 6;
|
||
const contentMaxY = pageHeight - bottomMargin - FOOTER_HEIGHT;
|
||
const tableBottomMargin = bottomMargin + FOOTER_HEIGHT;
|
||
let y = margin;
|
||
|
||
/**
|
||
* Page-break guard. Adds a page and resets the cursor to the top margin
|
||
* when the upcoming block (height `needed`, in mm) would not fit above
|
||
* `contentMaxY`. Call BEFORE drawing a module heading so a heading never
|
||
* gets orphaned at the bottom of a page. Returns the (possibly reset) y.
|
||
*/
|
||
const ensureSpace = (current: number, needed: number): number => {
|
||
if (current + needed > contentMaxY) {
|
||
doc.addPage();
|
||
return margin;
|
||
}
|
||
return current;
|
||
};
|
||
|
||
doc.setFontSize(22);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.setTextColor(30, 30, 30);
|
||
doc.text(t("doctorReport.title"), margin, y);
|
||
y += 8;
|
||
|
||
doc.setFontSize(10);
|
||
doc.setFont("helvetica", "normal");
|
||
doc.setTextColor(100, 100, 100);
|
||
doc.text(t("doctorReport.subtitle"), margin, y);
|
||
y += 6;
|
||
|
||
// Practice / clinic name on the cover. Rendered prominently above the
|
||
// separator so the addressee is the first thing the doctor sees when
|
||
// skimming the printout. Skipped entirely when the user did not supply
|
||
// one — keeps the cover compact for self-archive use.
|
||
if (data.practiceName) {
|
||
doc.setFontSize(11);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.setTextColor(30, 30, 30);
|
||
doc.text(
|
||
`${t("doctorReport.practiceLabel")}: ${data.practiceName}`,
|
||
margin,
|
||
y,
|
||
);
|
||
y += 6;
|
||
doc.setFont("helvetica", "normal");
|
||
}
|
||
|
||
doc.setDrawColor(200, 200, 200);
|
||
doc.setLineWidth(0.5);
|
||
doc.line(margin, y, pageWidth - margin, y);
|
||
y += 8;
|
||
|
||
doc.setFontSize(9);
|
||
doc.setTextColor(80, 80, 80);
|
||
const patientInfo: string[] = [];
|
||
// v1.7.0 — prefer the legal full name on the cover; fall back to the
|
||
// username when no full name is set (collapses exactly like the
|
||
// practice name when neither is present).
|
||
const patientName = data.patient.fullName ?? data.patient.username ?? null;
|
||
if (patientName) {
|
||
patientInfo.push(`${t("doctorReport.patient")}: ${patientName}`);
|
||
}
|
||
if (data.patient.dateOfBirth) {
|
||
patientInfo.push(
|
||
`${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`,
|
||
);
|
||
}
|
||
if (data.patient.gender) {
|
||
const genderKeys: Record<string, string> = {
|
||
MALE: "doctorReport.genderMale",
|
||
FEMALE: "doctorReport.genderFemale",
|
||
};
|
||
const genderKey =
|
||
genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
|
||
patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
|
||
}
|
||
if (data.patient.heightCm) {
|
||
patientInfo.push(
|
||
`${t("doctorReport.height")}: ${data.patient.heightCm} cm`,
|
||
);
|
||
}
|
||
// v1.7.0 — optional insurer + KVNR. Each line collapses when its field
|
||
// is unset, mirroring the existing practice-name behaviour.
|
||
if (data.patient.insurerName) {
|
||
patientInfo.push(
|
||
`${t("doctorReport.insurer")}: ${data.patient.insurerName}`,
|
||
);
|
||
}
|
||
if (insuranceNumber) {
|
||
patientInfo.push(
|
||
`${t("doctorReport.insuranceNumber")}: ${insuranceNumber}`,
|
||
);
|
||
}
|
||
// Reporting period uses the explicit `start`/`end` from the data payload
|
||
// (set by `normaliseDateRange()`). For older payloads that only carried
|
||
// `since`, fall back to (since, now()) to preserve previous behaviour.
|
||
const periodStart = data.period.start ?? data.period.since;
|
||
const periodEnd = data.period.end ?? now.toISOString();
|
||
patientInfo.push(
|
||
`${t("doctorReport.period")}: ${fmtDate(periodStart)} — ${fmtDate(periodEnd)}`,
|
||
);
|
||
patientInfo.push(
|
||
`${t("doctorReport.createdOn")}: ${fmtDate(now.toISOString())}`,
|
||
);
|
||
|
||
for (const line of patientInfo) {
|
||
doc.text(line, margin, y);
|
||
y += 4.5;
|
||
}
|
||
y += 4;
|
||
|
||
// v1.7.0 — deterministic clinical-summary block. Pure data (NOT AI),
|
||
// built from the same aggregated stats the tables print. Gives a
|
||
// physician a one-paragraph orientation before the detail tables.
|
||
const summaryLines = buildClinicalSummaryLines(data, t, num);
|
||
if (summaryLines.length > 0) {
|
||
// Keep the heading with at least its first line (heading 5 mm + one body
|
||
// line 4.5 mm + trailing gap).
|
||
y = ensureSpace(y, 5 + 4.5 + 4);
|
||
doc.setFontSize(12);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.setTextColor(30, 30, 30);
|
||
doc.text(t("doctorReport.summaryTitle"), margin, y);
|
||
y += 5;
|
||
doc.setFontSize(9);
|
||
doc.setFont("helvetica", "normal");
|
||
doc.setTextColor(60, 60, 60);
|
||
for (const line of summaryLines) {
|
||
const wrapped = doc.splitTextToSize(line, pageWidth - 2 * margin);
|
||
for (const w of wrapped) {
|
||
if (y + 4.5 > contentMaxY) {
|
||
doc.addPage();
|
||
y = margin;
|
||
}
|
||
doc.text(w, margin, y);
|
||
y += 4.5;
|
||
}
|
||
}
|
||
y += 4;
|
||
}
|
||
|
||
// Vitals heading — keep it with the start of its table.
|
||
y = ensureSpace(y, 6 + 18);
|
||
doc.setFontSize(14);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.setTextColor(30, 30, 30);
|
||
doc.text(t("doctorReport.vitalsTitle"), margin, y);
|
||
y += 6;
|
||
|
||
const vitalRows: string[][] = [];
|
||
|
||
for (const type of DOCTOR_REPORT_VITAL_TYPES) {
|
||
const s = data.stats[type];
|
||
if (!s) continue;
|
||
const unit = unitFor(type);
|
||
vitalRows.push([
|
||
t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? ""),
|
||
`${num(s.latest)} ${unit}`.trim(),
|
||
`${num(s.avg)} ${unit}`.trim(),
|
||
num(s.min),
|
||
num(s.max),
|
||
String(s.count),
|
||
]);
|
||
}
|
||
|
||
// Per-context glucose rows. Values stored canonically in mg/dL — convert to
|
||
// the user's display unit. Reference ranges come from the server-side
|
||
// `getEffectiveRange()` (already baked into `data.glucoseRanges`).
|
||
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
|
||
for (const ctx of GLUCOSE_CONTEXTS) {
|
||
const s = data.glucoseStats?.[ctx];
|
||
if (!s) continue;
|
||
const conv = (v: number) => convertGlucose(v, glucoseUnit);
|
||
vitalRows.push([
|
||
t(GLUCOSE_LABEL_KEYS[ctx]),
|
||
`${num(conv(s.latest))} ${glucoseUnit}`.trim(),
|
||
`${num(conv(s.avg))} ${glucoseUnit}`.trim(),
|
||
num(conv(s.min)),
|
||
num(conv(s.max)),
|
||
String(s.count),
|
||
]);
|
||
}
|
||
|
||
if (vitalRows.length > 0) {
|
||
autoTable(doc, {
|
||
startY: y,
|
||
head: [
|
||
[
|
||
t("doctorReport.colParameter"),
|
||
t("doctorReport.colCurrent"),
|
||
t("doctorReport.colAvgPeriod"),
|
||
t("doctorReport.colMin"),
|
||
t("doctorReport.colMax"),
|
||
t("doctorReport.colN"),
|
||
],
|
||
],
|
||
body: vitalRows,
|
||
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,
|
||
top: margin,
|
||
bottom: tableBottomMargin,
|
||
},
|
||
});
|
||
y =
|
||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||
.finalY + 8;
|
||
}
|
||
|
||
// v1.18.0 — clinical glucose panel (TIR / GMI / eA1C / mean / CV%). The
|
||
// server already computes this (`data.glucoseClinical`) by the one
|
||
// literature-locked engine the insights panel + coach consume, and zeroes it
|
||
// when the glucose module is off (readingCount === 0). Render it whenever the
|
||
// window carries glucose readings so the PDF surfaces the same numbers the
|
||
// app shows. A `pct` helper turns the engine's 0–1 fractions into percent.
|
||
const clinical = data.glucoseClinical;
|
||
if (clinical && clinical.readingCount > 0 && clinical.distribution) {
|
||
const pct = (fraction: number) => `${num(fraction * 100)} %`;
|
||
const clinicalRows: string[][] = [];
|
||
const dist = clinical.distribution;
|
||
clinicalRows.push([
|
||
t("doctorReport.glucoseClinical.tirInRange"),
|
||
pct(dist.tir),
|
||
]);
|
||
clinicalRows.push([
|
||
t("doctorReport.glucoseClinical.tirLow"),
|
||
pct(dist.tbrLevel1),
|
||
]);
|
||
clinicalRows.push([
|
||
t("doctorReport.glucoseClinical.tirVeryLow"),
|
||
pct(dist.tbrLevel2),
|
||
]);
|
||
clinicalRows.push([
|
||
t("doctorReport.glucoseClinical.tirHigh"),
|
||
pct(dist.tarLevel1),
|
||
]);
|
||
clinicalRows.push([
|
||
t("doctorReport.glucoseClinical.tirVeryHigh"),
|
||
pct(dist.tarLevel2),
|
||
]);
|
||
if (clinical.meanMgdl !== null) {
|
||
clinicalRows.push([
|
||
t("doctorReport.glucoseClinical.mean"),
|
||
`${num(convertGlucose(clinical.meanMgdl, glucoseUnit))} ${glucoseUnit}`.trim(),
|
||
]);
|
||
}
|
||
if (clinical.gmi !== null) {
|
||
clinicalRows.push([
|
||
t("doctorReport.glucoseClinical.gmi"),
|
||
`${num(clinical.gmi)} %`,
|
||
]);
|
||
}
|
||
if (clinical.estimatedA1c !== null) {
|
||
clinicalRows.push([
|
||
t("doctorReport.glucoseClinical.eA1c"),
|
||
`${num(clinical.estimatedA1c)} %`,
|
||
]);
|
||
}
|
||
if (clinical.variability !== null) {
|
||
clinicalRows.push([
|
||
t("doctorReport.glucoseClinical.cv"),
|
||
`${num(clinical.variability.cv)} %`,
|
||
]);
|
||
}
|
||
|
||
// Keep the heading with the start of its table.
|
||
y = ensureSpace(y, 6 + 14);
|
||
doc.setFontSize(12);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.setTextColor(30, 30, 30);
|
||
doc.text(t("doctorReport.glucoseClinical.title"), margin, y);
|
||
y += 6;
|
||
|
||
autoTable(doc, {
|
||
startY: y,
|
||
head: [
|
||
[
|
||
t("doctorReport.glucoseClinical.colMetric"),
|
||
t("doctorReport.glucoseClinical.colValue"),
|
||
],
|
||
],
|
||
body: clinicalRows,
|
||
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,
|
||
top: margin,
|
||
bottom: tableBottomMargin,
|
||
},
|
||
});
|
||
y =
|
||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||
.finalY + 4;
|
||
|
||
// Spot-reading caveat when the engine flags the readings as sparse, so the
|
||
// clinician reads the panel as a direction-of-travel, not a CGM AGP.
|
||
if (clinical.isSpotEstimate) {
|
||
doc.setFontSize(8);
|
||
doc.setFont("helvetica", "italic");
|
||
doc.setTextColor(110, 110, 110);
|
||
const caveat = doc.splitTextToSize(
|
||
t("doctorReport.glucoseClinical.spotCaveat"),
|
||
pageWidth - 2 * margin,
|
||
);
|
||
for (const line of caveat) {
|
||
y = ensureSpace(y, 4);
|
||
doc.text(line, margin, y);
|
||
y += 4;
|
||
}
|
||
}
|
||
y += 4;
|
||
}
|
||
|
||
// v1.7.0 — jsPDF-native trend sparklines per primary vital. Vector
|
||
// polylines drawn with `doc.lines()` — zero new dependency, no native
|
||
// canvas module, isomorphic. Selection-gated via `includeCharts`.
|
||
if (includeCharts) {
|
||
const chartTypes = SPARKLINE_TYPES.filter(
|
||
(type) => (data.measurements[type]?.length ?? 0) >= 2,
|
||
);
|
||
if (chartTypes.length > 0) {
|
||
// Keep the heading with at least the first chart.
|
||
y = ensureSpace(y, 6 + SPARKLINE_HEIGHT + 4);
|
||
doc.setFontSize(12);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.setTextColor(30, 30, 30);
|
||
doc.text(t("doctorReport.chartsTitle"), margin, y);
|
||
y += 6;
|
||
for (const type of chartTypes) {
|
||
const series = data.measurements[type] ?? [];
|
||
// A chart never tears across a page boundary — break before drawing
|
||
// it whole.
|
||
y = ensureSpace(y, SPARKLINE_HEIGHT + 4);
|
||
const label = t(DOCTOR_REPORT_TYPE_LABEL_KEYS[type] ?? "");
|
||
y = drawSparkline(doc, {
|
||
x: margin,
|
||
y,
|
||
width: pageWidth - 2 * margin,
|
||
label,
|
||
points: series,
|
||
num,
|
||
unit: unitFor(type),
|
||
dateShort: (iso) => formatters.dateShort(iso),
|
||
});
|
||
y += 4;
|
||
}
|
||
y += 2;
|
||
}
|
||
}
|
||
|
||
const sysStat = data.stats.BLOOD_PRESSURE_SYS;
|
||
const diaStat = data.stats.BLOOD_PRESSURE_DIA;
|
||
if (sysStat && diaStat) {
|
||
y = ensureSpace(y, 5 + 8);
|
||
doc.setFontSize(12);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.text(t("doctorReport.bpClassificationTitle"), margin, y);
|
||
y += 5;
|
||
|
||
doc.setFontSize(9);
|
||
doc.setFont("helvetica", "normal");
|
||
const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg));
|
||
doc.text(
|
||
`${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`,
|
||
margin,
|
||
y,
|
||
);
|
||
y += 8;
|
||
}
|
||
|
||
if (data.bmi) {
|
||
y = ensureSpace(y, 5 + 8);
|
||
doc.setFontSize(12);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.text(t("doctorReport.bmiTitle"), margin, y);
|
||
y += 5;
|
||
|
||
doc.setFontSize(9);
|
||
doc.setFont("helvetica", "normal");
|
||
doc.text(
|
||
t("doctorReport.bmiRow", {
|
||
bmi: num(data.bmi, 1),
|
||
class: t(getBmiClassificationKey(data.bmi)),
|
||
}),
|
||
margin,
|
||
y,
|
||
);
|
||
y += 8;
|
||
}
|
||
|
||
// Glucose classification — one line per logged context.
|
||
const loggedGlucose = GLUCOSE_CONTEXTS.filter(
|
||
(ctx) => data.glucoseStats?.[ctx],
|
||
);
|
||
if (loggedGlucose.length > 0) {
|
||
y = ensureSpace(y, 5 + 5 + 3);
|
||
doc.setFontSize(12);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y);
|
||
y += 5;
|
||
doc.setFontSize(9);
|
||
doc.setFont("helvetica", "normal");
|
||
for (const ctx of loggedGlucose) {
|
||
const s = data.glucoseStats[ctx];
|
||
if (!s) continue;
|
||
const range = data.glucoseRanges?.[ctx] ?? DEFAULT_GLUCOSE_RANGES[ctx];
|
||
const conv = (v: number) => convertGlucose(v, glucoseUnit);
|
||
const inRange = s.avg >= range.min && s.avg <= range.max;
|
||
const classKey = inRange
|
||
? "doctorReport.glucoseInTarget"
|
||
: s.avg < range.min
|
||
? "doctorReport.glucoseBelowTarget"
|
||
: "doctorReport.glucoseAboveTarget";
|
||
if (y + 5 > contentMaxY) {
|
||
doc.addPage();
|
||
y = margin;
|
||
}
|
||
doc.text(
|
||
t("doctorReport.glucoseRow", {
|
||
label: t(GLUCOSE_LABEL_KEYS[ctx]),
|
||
avg: num(conv(s.avg), 1),
|
||
unit: glucoseUnit,
|
||
rangeMin: num(conv(range.min)),
|
||
rangeMax: num(conv(range.max)),
|
||
class: t(classKey),
|
||
}),
|
||
margin,
|
||
y,
|
||
);
|
||
y += 5;
|
||
}
|
||
y += 3;
|
||
}
|
||
|
||
const complianceEntries = Object.entries(data.compliance);
|
||
if (complianceEntries.length > 0) {
|
||
// Keep the heading with the table header + first row (~6 mm heading +
|
||
// ~9 mm header + ~9 mm row).
|
||
y = ensureSpace(
|
||
y,
|
||
6 + estimateTableHeight(Math.min(complianceEntries.length, 1)),
|
||
);
|
||
|
||
doc.setFontSize(14);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.text(t("doctorReport.complianceTitle"), margin, y);
|
||
y += 6;
|
||
|
||
const compRows = complianceEntries.map(([name, c]) => {
|
||
// v1.17 W1a — `total` is the ledger rate denominator (taken + missed,
|
||
// deliberate skips excluded), so `taken / total` matches the app's
|
||
// 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.
|
||
// 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),
|
||
String(c.skipped),
|
||
String(c.missed),
|
||
String(c.total),
|
||
rate,
|
||
];
|
||
});
|
||
|
||
autoTable(doc, {
|
||
startY: y,
|
||
head: [
|
||
[
|
||
t("doctorReport.colMedication"),
|
||
t("doctorReport.colTaken"),
|
||
t("doctorReport.colSkipped"),
|
||
t("doctorReport.colMissed"),
|
||
t("doctorReport.colExpected"),
|
||
t("doctorReport.colComplianceRate"),
|
||
],
|
||
],
|
||
body: compRows,
|
||
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,
|
||
top: margin,
|
||
bottom: tableBottomMargin,
|
||
},
|
||
});
|
||
y =
|
||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||
.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) {
|
||
// Keep the GLP-1 heading with its first content line.
|
||
y = ensureSpace(y, 6 + 6 + 5);
|
||
|
||
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) {
|
||
// Keep the med name with at least its first detail line.
|
||
y = ensureSpace(y, 5 + 5);
|
||
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;
|
||
}
|
||
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(glp1Rate, 0),
|
||
}),
|
||
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 ?? "",
|
||
]);
|
||
// A titration history reads as one block — break before it if the
|
||
// whole table would not fit, and tell autoTable not to split a row.
|
||
y = ensureSpace(y, estimateTableHeight(historyRows.length));
|
||
autoTable(doc, {
|
||
startY: y,
|
||
head: [
|
||
[
|
||
t("doctorReport.colGlp1Date"),
|
||
t("doctorReport.colGlp1Dose"),
|
||
t("doctorReport.colGlp1Note"),
|
||
],
|
||
],
|
||
body: historyRows,
|
||
theme: "grid",
|
||
rowPageBreak: "avoid",
|
||
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,
|
||
top: margin,
|
||
bottom: tableBottomMargin,
|
||
},
|
||
});
|
||
y =
|
||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||
.finalY + 6;
|
||
}
|
||
}
|
||
|
||
if (data.glp1.sideEffects.length > 0) {
|
||
const seRows = data.glp1.sideEffects.map((s) => [s.tag, String(s.count)]);
|
||
// Keep the heading with the table; the side-effect tally reads as one
|
||
// block.
|
||
y = ensureSpace(y, 5 + estimateTableHeight(seRows.length));
|
||
doc.setFontSize(11);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.text(t("doctorReport.glp1SideEffectsTitle"), margin, y);
|
||
y += 5;
|
||
autoTable(doc, {
|
||
startY: y,
|
||
head: [
|
||
[t("doctorReport.colGlp1SideEffect"), t("doctorReport.colCount")],
|
||
],
|
||
body: seRows,
|
||
theme: "grid",
|
||
rowPageBreak: "avoid",
|
||
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,
|
||
top: margin,
|
||
bottom: tableBottomMargin,
|
||
},
|
||
});
|
||
y =
|
||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||
.finalY + 8;
|
||
}
|
||
}
|
||
|
||
if (data.mood) {
|
||
const moodRowCount = Object.keys(data.mood.distribution).length;
|
||
// Keep the heading + summary line with the start of the distribution
|
||
// table.
|
||
y = ensureSpace(y, 6 + 6 + estimateTableHeight(Math.min(moodRowCount, 1)));
|
||
|
||
doc.setFontSize(14);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.text(t("doctorReport.moodTitle"), margin, y);
|
||
y += 6;
|
||
|
||
doc.setFontSize(9);
|
||
doc.setFont("helvetica", "normal");
|
||
doc.text(
|
||
t("doctorReport.moodSummary", {
|
||
avg: num(data.mood.avg, 1),
|
||
count: data.mood.count,
|
||
min: num(data.mood.min, 0),
|
||
max: num(data.mood.max, 0),
|
||
}),
|
||
margin,
|
||
y,
|
||
);
|
||
y += 6;
|
||
|
||
const distRows = Object.entries(data.mood.distribution).map(
|
||
([score, count]) => [
|
||
t(MOOD_LABEL_KEYS[Number(score)] ?? "doctorReport.moodNeutral"),
|
||
String(count),
|
||
data.mood && data.mood.count > 0
|
||
? `${num((count / data.mood.count) * 100, 1)}%`
|
||
: "0%",
|
||
],
|
||
);
|
||
|
||
autoTable(doc, {
|
||
startY: y,
|
||
head: [
|
||
[
|
||
t("doctorReport.colMood"),
|
||
t("doctorReport.colCount"),
|
||
t("doctorReport.colShare"),
|
||
],
|
||
],
|
||
body: distRows,
|
||
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",
|
||
},
|
||
margin: {
|
||
left: margin,
|
||
right: margin,
|
||
top: margin,
|
||
bottom: tableBottomMargin,
|
||
},
|
||
});
|
||
y =
|
||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||
.finalY + 8;
|
||
}
|
||
|
||
// v1.10.0 — wellness summary. The server-derived nightly scores
|
||
// (recovery / stress / strain) are 0–100 DESCRIPTIVE composites, NOT
|
||
// clinical vitals — they render in their own clearly-labelled section,
|
||
// separate from the vitals table, with a "descriptive, not a clinical
|
||
// assessment" disclaimer so a physician never mistakes a band for a
|
||
// finding. Skipped entirely when the aggregator emitted no scores.
|
||
if (data.wellnessScores && data.wellnessScores.length > 0) {
|
||
const scoreRows = data.wellnessScores.map((s) => [
|
||
t(WELLNESS_SCORE_LABEL_KEYS[s.type] ?? "doctorReport.wellnessTitle"),
|
||
String(s.latest),
|
||
String(s.avg),
|
||
String(s.min),
|
||
String(s.max),
|
||
String(s.count),
|
||
]);
|
||
|
||
// Keep the heading + disclaimer with the first table row.
|
||
y = ensureSpace(y, 6 + 6 + 6 + estimateTableHeight(1));
|
||
|
||
doc.setFontSize(14);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.text(t("doctorReport.wellnessTitle"), margin, y);
|
||
y += 6;
|
||
|
||
doc.setFontSize(8);
|
||
doc.setFont("helvetica", "italic");
|
||
const wellnessDisclaimer = doc.splitTextToSize(
|
||
t("doctorReport.wellnessDisclaimer"),
|
||
pageWidth - margin * 2,
|
||
);
|
||
for (const line of wellnessDisclaimer) {
|
||
doc.text(line, margin, y);
|
||
y += 4;
|
||
}
|
||
y += 2;
|
||
doc.setFont("helvetica", "normal");
|
||
|
||
autoTable(doc, {
|
||
startY: y,
|
||
head: [
|
||
[
|
||
t("doctorReport.colParameter"),
|
||
t("doctorReport.colCurrent"),
|
||
t("doctorReport.colAvgPeriod"),
|
||
t("doctorReport.colMin"),
|
||
t("doctorReport.colMax"),
|
||
t("doctorReport.colN"),
|
||
],
|
||
],
|
||
body: scoreRows,
|
||
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",
|
||
},
|
||
margin: {
|
||
left: margin,
|
||
right: margin,
|
||
top: margin,
|
||
bottom: tableBottomMargin,
|
||
},
|
||
});
|
||
y =
|
||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||
.finalY + 8;
|
||
}
|
||
|
||
// v1.15.0 — cycle / reproductive-health section. Privacy default OFF: the
|
||
// aggregator only populates `data.cycle` when the cycle section toggle is
|
||
// explicitly ON AND the user has an observed cycle. Statistics only (no
|
||
// free-text notes). Skipped entirely otherwise.
|
||
if (data.cycle) {
|
||
const cyc = data.cycle;
|
||
y = ensureSpace(y, 6 + 6 + estimateTableHeight(1));
|
||
|
||
doc.setFontSize(14);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.text(t("doctorReport.cycleTitle"), margin, y);
|
||
y += 6;
|
||
|
||
// Summary line: LMP, avg length ± variability, avg period, phase.
|
||
doc.setFontSize(9);
|
||
doc.setFont("helvetica", "normal");
|
||
const summaryBits: string[] = [];
|
||
if (cyc.lastPeriodStart) {
|
||
summaryBits.push(
|
||
`${t("doctorReport.cycleLmp")}: ${fmtDate(`${cyc.lastPeriodStart}T12:00:00.000Z`)}`,
|
||
);
|
||
}
|
||
if (cyc.averageCycleLengthDays !== null) {
|
||
const varSuffix =
|
||
cyc.cycleLengthVariabilityDays !== null
|
||
? ` (± ${num(cyc.cycleLengthVariabilityDays, 1)})`
|
||
: "";
|
||
summaryBits.push(
|
||
`${t("doctorReport.cycleAvgLength")}: ${num(cyc.averageCycleLengthDays, 1)} ${t("doctorReport.cycleDays")}${varSuffix}`,
|
||
);
|
||
}
|
||
if (cyc.averagePeriodLengthDays !== null) {
|
||
summaryBits.push(
|
||
`${t("doctorReport.cycleAvgPeriod")}: ${num(cyc.averagePeriodLengthDays, 1)} ${t("doctorReport.cycleDays")}`,
|
||
);
|
||
}
|
||
if (cyc.currentPhase) {
|
||
summaryBits.push(
|
||
`${t("doctorReport.cyclePhase")}: ${t(`doctorReport.cyclePhases.${cyc.currentPhase}`)}`,
|
||
);
|
||
}
|
||
for (const line of doc.splitTextToSize(
|
||
summaryBits.join(" • "),
|
||
pageWidth - margin * 2,
|
||
)) {
|
||
doc.text(line, margin, y);
|
||
y += 4.5;
|
||
}
|
||
y += 2;
|
||
|
||
if (cyc.recentCycles.length > 0) {
|
||
const cycleRows = cyc.recentCycles.map((c) => [
|
||
fmtDate(`${c.startDate}T12:00:00.000Z`),
|
||
c.lengthDays !== null ? String(c.lengthDays) : "—",
|
||
c.periodLengthDays !== null ? String(c.periodLengthDays) : "—",
|
||
]);
|
||
autoTable(doc, {
|
||
startY: y,
|
||
head: [
|
||
[
|
||
t("doctorReport.cycleColStart"),
|
||
t("doctorReport.cycleColLength"),
|
||
t("doctorReport.cycleColPeriod"),
|
||
],
|
||
],
|
||
body: cycleRows,
|
||
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",
|
||
},
|
||
margin: {
|
||
left: margin,
|
||
right: margin,
|
||
top: margin,
|
||
bottom: tableBottomMargin,
|
||
},
|
||
});
|
||
y =
|
||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||
.finalY + 8;
|
||
}
|
||
}
|
||
|
||
// v1.17.1 — structured lab-results section. Populated when the `labs`
|
||
// toggle is ON (default) and the user recorded at least one result in the
|
||
// window. One row per analyte (latest reading), with the lab's reference
|
||
// range and a NEUTRAL in/out-of-range marker — informative, never an
|
||
// alarming red. The marker is a quiet glyph (↓ / ↑ / —), not a colour, so
|
||
// the clinical PDF stays calm.
|
||
if (data.labResults && data.labResults.length > 0) {
|
||
y = ensureSpace(y, 6 + 18);
|
||
doc.setFontSize(14);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.setTextColor(30, 30, 30);
|
||
doc.text(t("doctorReport.labsTitle"), margin, y);
|
||
y += 6;
|
||
|
||
const rangeText = (low: number | null, high: number | null): string =>
|
||
formatReferenceRange(low, high, num, { emptyText: "—" });
|
||
// Quiet, non-colour status glyph. In-range reads as a neutral dash so
|
||
// the table is not a field of warning marks; out-of-range reads as a
|
||
// direction arrow the clinician can scan, with no alarm tint.
|
||
const statusGlyph = (
|
||
value: number,
|
||
low: number | null,
|
||
high: number | null,
|
||
): string => {
|
||
switch (classifyReferenceRange(value, low, high)) {
|
||
case "unknown":
|
||
return "";
|
||
case "below":
|
||
return "↓";
|
||
case "above":
|
||
return "↑";
|
||
default:
|
||
return "—";
|
||
}
|
||
};
|
||
|
||
const labRows = data.labResults.map((lr) => {
|
||
// v1.18.9 — a qualitative reading (`value === null`) prints its result
|
||
// text in the value column and has no numeric range / status glyph.
|
||
const isQualitative = lr.value === null;
|
||
return [
|
||
lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte,
|
||
isQualitative
|
||
? (lr.valueText ?? "")
|
||
: `${num(lr.value as number)} ${lr.unit}`.trim(),
|
||
isQualitative ? "—" : rangeText(lr.referenceLow, lr.referenceHigh),
|
||
isQualitative
|
||
? ""
|
||
: statusGlyph(lr.value as number, lr.referenceLow, lr.referenceHigh),
|
||
fmtDate(lr.takenAt),
|
||
];
|
||
});
|
||
|
||
autoTable(doc, {
|
||
startY: y,
|
||
head: [
|
||
[
|
||
t("doctorReport.labsColAnalyte"),
|
||
t("doctorReport.labsColValue"),
|
||
t("doctorReport.labsColReference"),
|
||
t("doctorReport.labsColStatus"),
|
||
t("doctorReport.labsColDate"),
|
||
],
|
||
],
|
||
body: labRows,
|
||
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,
|
||
top: margin,
|
||
bottom: tableBottomMargin,
|
||
},
|
||
});
|
||
y =
|
||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||
.finalY + 8;
|
||
}
|
||
|
||
// v1.18.1 P4 — illness / condition episodes overlapping the window. Present
|
||
// only when the illness module is on AND the window held an episode (the
|
||
// aggregator gates `data.illnessEpisodes`). Labels + lifecycle + dates only;
|
||
// the encrypted note is never read. A purely retrospective, factual table —
|
||
// no colour, no severity tint — matching the clinical-document register.
|
||
if (data.illnessEpisodes && data.illnessEpisodes.length > 0) {
|
||
y = ensureSpace(y, 6 + 18);
|
||
doc.setFontSize(14);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.setTextColor(30, 30, 30);
|
||
doc.text(t("doctorReport.illnessTitle"), margin, y);
|
||
y += 6;
|
||
|
||
const illnessRows = data.illnessEpisodes.map((ep) => [
|
||
ep.label,
|
||
t(`illness.type.${ep.type}`),
|
||
t(`illness.lifecycle.${ep.lifecycle}`),
|
||
fmtDate(ep.onsetAt),
|
||
ep.resolvedAt ? fmtDate(ep.resolvedAt) : t("doctorReport.illnessOngoing"),
|
||
]);
|
||
|
||
autoTable(doc, {
|
||
startY: y,
|
||
head: [
|
||
[
|
||
t("doctorReport.illnessColCondition"),
|
||
t("doctorReport.illnessColType"),
|
||
t("doctorReport.illnessColLifecycle"),
|
||
t("doctorReport.illnessColOnset"),
|
||
t("doctorReport.illnessColResolved"),
|
||
],
|
||
],
|
||
body: illnessRows,
|
||
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,
|
||
top: margin,
|
||
bottom: tableBottomMargin,
|
||
},
|
||
});
|
||
y =
|
||
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
|
||
.finalY + 8;
|
||
}
|
||
|
||
// v1.7.0 — optional AI summary. OUT of the clinical PDF by default;
|
||
// rendered ONLY when the user explicitly opted in. Clearly labelled and
|
||
// flagged as not clinically validated so a physician never mistakes it
|
||
// for a machine-generated diagnosis.
|
||
const aiText = typeof aiSummary === "string" ? aiSummary.trim() : "";
|
||
if (aiText.length > 0) {
|
||
// Keep the heading + the first disclaimer line together.
|
||
y = ensureSpace(y, 5 + 4 + 4.5);
|
||
doc.setFontSize(12);
|
||
doc.setFont("helvetica", "bold");
|
||
doc.setTextColor(120, 80, 0);
|
||
doc.text(t("doctorReport.aiSummaryTitle"), margin, y);
|
||
y += 5;
|
||
doc.setFontSize(8);
|
||
doc.setFont("helvetica", "italic");
|
||
doc.setTextColor(150, 110, 40);
|
||
const disclaimer = doc.splitTextToSize(
|
||
t("doctorReport.aiSummaryDisclaimer"),
|
||
pageWidth - 2 * margin,
|
||
);
|
||
for (const line of disclaimer) {
|
||
if (y + 4 > contentMaxY) {
|
||
doc.addPage();
|
||
y = margin;
|
||
}
|
||
doc.text(line, margin, y);
|
||
y += 4;
|
||
}
|
||
y += 2;
|
||
doc.setFontSize(9);
|
||
doc.setFont("helvetica", "normal");
|
||
doc.setTextColor(60, 60, 60);
|
||
const wrapped = doc.splitTextToSize(aiText, pageWidth - 2 * margin);
|
||
for (const line of wrapped) {
|
||
if (y + 4.5 > contentMaxY) {
|
||
doc.addPage();
|
||
y = margin;
|
||
}
|
||
doc.text(line, margin, y);
|
||
y += 4.5;
|
||
}
|
||
y += 4;
|
||
}
|
||
|
||
const addFooter = (pageDoc: jsPDF) => {
|
||
const pageHeight = pageDoc.internal.pageSize.getHeight();
|
||
pageDoc.setFontSize(7);
|
||
pageDoc.setFont("helvetica", "italic");
|
||
pageDoc.setTextColor(140, 140, 140);
|
||
pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14);
|
||
pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10);
|
||
pageDoc.text(
|
||
t("doctorReport.footerSource", {
|
||
timestamp: formatters.dateTime(now),
|
||
}),
|
||
margin,
|
||
pageHeight - 6,
|
||
);
|
||
};
|
||
|
||
const totalPages = doc.getNumberOfPages();
|
||
for (let i = 1; i <= totalPages; i++) {
|
||
doc.setPage(i);
|
||
addFooter(doc);
|
||
}
|
||
|
||
return doc;
|
||
}
|
||
|
||
/**
|
||
* Render the doctor report and return the PDF as a `Uint8Array`.
|
||
* Isomorphic — works in browser and Node.
|
||
*/
|
||
export function renderDoctorReportPdfBytes(
|
||
data: DoctorReportData,
|
||
options: DoctorReportRenderOptions,
|
||
): Uint8Array {
|
||
const doc = buildDoctorReportPdfDocument(data, options);
|
||
const ab = doc.output("arraybuffer");
|
||
return new Uint8Array(ab);
|
||
}
|