Files
HealthLog/src/lib/doctor-report-pdf-core.ts
T
Marc-André Bombeck aff4e9abeb feat(oura): capture daily resilience as an ordinal RESILIENCE metric
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.
2026-06-21 10:44:15 +02:00

1691 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}