Compare commits

..
4 Commits
Author SHA1 Message Date
Marc-André Bombeck 3743f91bce chore(release): v1.7.3 — health-record pdf fixes + multi-time compliance 2026-05-31 22:49:16 +02:00
Marc-André Bombeck 013ad5d967 fix(compliance): expand multi-time legacy schedules through the canonical engine
A medication whose schedule row carried multiple timesOfDay plus a legacy
daysOfWeek (and no rrule / rollingIntervalDays) reported 50% compliance for a
fully-adherent 2x/day dose. The expected-slot denominator always ran the
canonical recurrence engine and counted both daily slots, but the numerator
only delegated to the engine for rrule / rolling / one-shot / non-SCHEDULED
rows; a plain-legacy row fell to the local cadence walker, which emitted a
single windowStart slot per day and ignored timesOfDay. 1 taken / 2 expected
read as 50 percent.

Route expandScheduleSlots through the engine for every schedule shape whenever
a medication context is supplied, so the numerator and denominator read the
same source of truth. Harden the context-less legacy walker to iterate
timesOfDay as well, mirroring the engine's legacy branch, so the single
remaining non-engine path stays correct for multi-time rows. Drop the now-dead
expander gate helper.

Add regression guards: a 2x/day legacy row reports 2/2 through
calculateCompliance, and expectedSlotCountForDay agrees with
buildCadenceTimeline on the slot count for an identical schedule.
2026-05-31 22:47:44 +02:00
Marc-André Bombeck aed83b7747 fix(report): keep modules together, label the chart time axis, fix glyph stretch
Four layout defects in the clinical PDF renderer:

- Stretched lines: the built-in Helvetica is WinAnsi-encoded, so glyphs
  outside it (the trend arrows the summary injects, the superscript-two in
  kg/m2) resolved to the .notdef box at a fallback advance width and stretched
  the surrounding words. Route every drawn string (direct text, wrapped
  paragraphs, and autoTable cells) through a central WinAnsi sanitiser that
  maps the offenders onto safe equivalents. Embedding a Unicode TTF so the
  arrows render as true glyphs is a documented follow-up.

- The sparkline dropped each reading's timestamp and plotted on an index
  axis, so a trend gave no sense of its time span. Plot points by their real
  timestamp and print the first/last dates under the baseline.

- No consistent bottom safe area: scattered y > 240 magic numbers and tables
  with no bottom margin let content run into the footer. Introduce a single
  contentMaxY derived from the page height, footer band, and bottom margin;
  every break check and every autoTable bottom margin refers to it.

- Modules tore mid-block. Add an ensureSpace guard called before each module
  heading and a per-line guard in every free-text loop; give the short
  titration and side-effect tables rowPageBreak: avoid.
2026-05-31 22:47:44 +02:00
Marc-André Bombeck c6e782cdb8 fix(report): pass the in-app locale through to the pdf export
The health-record export panel sent no locale, so the server resolved the
report language from the browser's Accept-Language header. A German user on
an English-default browser got an English doctor report.

Send the active in-app locale from the panel, and on the server resolve
most-specific-first: explicit selection, then the healthlog-locale cookie
(what the user actually chose in the UI), then Accept-Language as the
weakest fallback.
2026-05-31 22:47:44 +02:00
11 changed files with 663 additions and 104 deletions

No files matched your search

+10
View File
@@ -1,5 +1,15 @@
# Changelog
## [1.7.3] — 2026-05-31 — Health-record PDF fixes, multi-time compliance
Two fixes to live behaviour.
### Fixed
- **Health-record export renders in the account language** — the PDF came out in English regardless of the in-app language because the export only consulted the browser's `Accept-Language`. The client now sends the active language and the server falls back to the language cookie before the browser header.
- **Health-record export layout** — charts now label their time axis (start and end date of the plotted window), modules are kept together instead of breaking across a page, a consistent bottom margin is held on every page, and a glyph-encoding issue that stretched a few lines (trend arrows and the `m²` unit) is resolved by sanitising to the font's supported characters.
- **Compliance counts every scheduled time on multi-time plans** — a medication taken twice a day on a weekday plan with two times but no recurrence rule reported 50% even when both doses were logged: the expected-dose count and the taken count expanded the schedule through two different code paths. Both now run through the one canonical recurrence engine, so the rate is correct for every schedule shape.
## [1.7.2] — 2026-05-31 — Coach source parity, first-paint snapshot, medication card cleanup
A focused follow-up to v1.7.0. The unified dashboard snapshot that shipped in v1.7.0 is now the default, so a cold dashboard paints its above-the-fold tiles together instead of letting the mood tile arrive ahead of the rest. The Coach's in-chat data-source rail now reads and writes the same persisted data clusters as the settings sheet — one source of truth that survives a reload, so what the panel shows always matches what reaches the model. The medication overview cards collapse their action row into a single overflow menu, and the detail page is pared back to the intake history under a read-only plan summary. The advanced-settings sheet widens into a two-column layout and offers a medications export beside the existing import.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.7.2
version: 1.7.3
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.7.2",
"version": "1.7.3",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -9,6 +9,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { PDFParse } from "pdf-parse";
vi.mock("@/lib/db", () => ({
prisma: {
@@ -38,14 +39,27 @@ const SESSION_OK = {
user: { id: "user-1", email: "test@example.com", role: "USER" },
} as const;
function mkReq(body: unknown): NextRequest {
function mkReq(
body: unknown,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest("http://localhost/api/export/health-record", {
method: "POST",
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify(body),
});
}
async function pdfText(res: Response): Promise<string> {
const bytes = new Uint8Array(await res.arrayBuffer());
const parser = new PDFParse({ data: bytes });
try {
return (await parser.getText()).text;
} finally {
await parser.destroy();
}
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
@@ -137,6 +151,46 @@ describe("POST /api/export/health-record — outputs", () => {
expect(buf.subarray(0, 2).toString("latin1")).toBe("PK");
});
it("renders the PDF in the explicit selection locale", async () => {
const { POST } = await import("../route");
const res = await POST(
mkReq({ format: "pdf", locale: "de", practiceName: "Sample Practice" }),
);
const text = await pdfText(res);
// German cover label.
expect(text).toContain("Praxis:");
});
it("falls back to the healthlog-locale cookie when no locale is sent", async () => {
const { POST } = await import("../route");
const res = await POST(
mkReq(
{ format: "pdf", practiceName: "Sample Practice" },
{
// Browser default English, but the in-app cookie says German — the
// cookie must win over Accept-Language.
"accept-language": "en-US,en;q=0.9",
cookie: "healthlog-locale=de",
},
),
);
const text = await pdfText(res);
expect(text).toContain("Praxis:");
expect(text).not.toContain("Practice:");
});
it("uses Accept-Language only when neither selection nor cookie is present", async () => {
const { POST } = await import("../route");
const res = await POST(
mkReq(
{ format: "pdf", practiceName: "Sample Practice" },
{ "accept-language": "en-US,en;q=0.9" },
),
);
const text = await pdfText(res);
expect(text).toContain("Practice:");
});
it("scopes the aggregator measurement read to the session user", async () => {
const { POST } = await import("../route");
await POST(mkReq({ format: "fhir", userId: "user-2" } as never));
+13 -1
View File
@@ -46,7 +46,7 @@ import {
} from "@/lib/validations/health-record-export";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { type Locale } from "@/lib/i18n/config";
import { locales, type Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
export const POST = apiHandler(async (request: NextRequest) => {
@@ -72,8 +72,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
const sections = toDoctorReportPrefs(selection.sections);
const includeCharts = selection.includeCharts ?? true;
// Locale resolution, most-specific first: the explicit selection wins, then
// the in-app `healthlog-locale` cookie (what the user actually chose in the
// UI), and only then the browser's Accept-Language header. The header is the
// weakest signal — a German user on an English-default browser would
// otherwise get an English report.
const cookieValue = request.cookies.get("healthlog-locale")?.value;
const cookieLocale = (locales as readonly string[]).includes(
cookieValue ?? "",
)
? (cookieValue as Locale)
: null;
const locale: Locale =
selection.locale ??
cookieLocale ??
parseLocaleFromAcceptLanguage(
request.headers.get("accept-language") ?? "",
);
@@ -97,7 +97,7 @@ function buildSelectionSections(s: SectionState) {
}
export function HealthRecordExportPanel() {
const { t } = useTranslations();
const { t, locale } = useTranslations();
const [format, setFormat] = useState<ExportFormat>("pdf");
const [days, setDays] = useState<number>(90);
const [practiceName, setPracticeName] = useState("");
@@ -140,6 +140,10 @@ export function HealthRecordExportPanel() {
credentials: "include",
body: JSON.stringify({
format,
// Carry the active in-app locale so the generated artefact matches
// the UI language instead of falling back to the browser's
// Accept-Language header on the server.
locale,
range: { days },
practiceName: practiceName.trim() || undefined,
includeCharts,
@@ -3,6 +3,7 @@ import { PDFParse } from "pdf-parse";
import {
buildDoctorReportPdfDocument,
renderDoctorReportPdfBytes,
sanitiseForPdf,
DOCTOR_REPORT_VITAL_TYPES,
DOCTOR_REPORT_TYPE_LABEL_KEYS,
DOCTOR_REPORT_TYPE_UNIT_KEYS,
@@ -183,6 +184,96 @@ describe("buildDoctorReportPdfDocument", () => {
});
});
// ── WinAnsi sanitiser ── the rendered Helvetica is WinAnsi-encoded; glyphs
// outside it (trend arrows, superscripts) resolve to the .notdef box at a
// fallback advance width and stretch the surrounding line. The sanitiser
// maps every offender the report can emit onto a WinAnsi-safe equivalent.
describe("sanitiseForPdf", () => {
it("maps trend arrows to ASCII equivalents", () => {
expect(sanitiseForPdf("↑")).toBe("^");
expect(sanitiseForPdf("↓")).toBe("v");
expect(sanitiseForPdf("→")).toBe("->");
});
it("maps the superscript-two in kg/m² to a plain 2", () => {
expect(sanitiseForPdf("kg/m²")).toBe("kg/m2");
});
it("leaves WinAnsi-safe glyphs (umlauts, ß, em-dash) untouched", () => {
expect(sanitiseForPdf("Müller — Größe")).toBe("Müller — Größe");
});
it("renders the GLP-1 weight summary without any non-WinAnsi glyph", async () => {
const data = makeData({
glp1: {
weightStartKg: 92,
weightEndKg: 86,
weightDeltaKg: -6,
medications: [],
sideEffects: [],
} as unknown as DoctorReportData["glp1"],
});
const bytes = renderDoctorReportPdfBytes(data, {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
const text = await extractText(bytes);
// The arrow separator must have been folded to ASCII; no raw arrow in
// the document text layer.
expect(text).not.toMatch(/[↑↓→²]/);
expect(text).toContain("->");
});
});
// ── chart time axis ── the sparkline must anchor the trend in real time so a
// reader can tell whether the curve spans a week or a year.
describe("doctor-report sparkline time axis", () => {
it("prints the series start and end dates under the chart", async () => {
const data = makeData({
measurements: {
WEIGHT: [
{ value: 82, measuredAt: "2026-02-05T08:00:00.000Z" },
{ value: 81, measuredAt: "2026-03-10T08:00:00.000Z" },
{ value: 79.5, measuredAt: "2026-04-28T08:00:00.000Z" },
],
},
});
const bytes = renderDoctorReportPdfBytes(data, {
t: getServerTranslator("de").t,
locale: "de",
includeCharts: true,
now: FIXED_NOW,
});
const text = await extractText(bytes);
// dd.mm short labels (Berlin tz). First + last sample dates.
expect(text).toContain("05.02.");
expect(text).toContain("28.04.");
});
});
// ── page-break discipline ── a long table must not orphan its heading or
// collide with the footer; the renderer paginates without throwing.
describe("doctor-report pagination", () => {
it("paginates a long compliance table across pages without throwing", () => {
const compliance: DoctorReportData["compliance"] = {};
for (let i = 0; i < 60; i++) {
compliance[`Medication ${i}`] = {
total: 90,
taken: 80,
skipped: 5,
missed: 5,
};
}
const doc = buildDoctorReportPdfDocument(makeData({ compliance }), {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
expect(doc.getNumberOfPages()).toBeGreaterThan(1);
});
});
// Audit-2026-05-07 / phase P0 / closes audit C-9: server-side PDF used a
// stale type map and silently dropped body composition while the browser
// PDF rendered them. These tests pin the contract.
@@ -23,6 +23,7 @@ import {
type ComplianceSchedule,
} from "../compliance";
import type { IntakeTimingClass } from "../compliance";
import { buildCadenceTimeline } from "@/lib/medications/scheduling/cadence";
import { getUserTodayBounds } from "@/lib/timezone";
import { userDayKey } from "@/lib/tz/format";
@@ -557,6 +558,39 @@ describe("calculateCompliance — engine-routed (medicationContext)", () => {
expect(result.rate).toBe(100);
expect(result.missed).toBe(0);
});
it("B15: legacy daysOfWeek row with two timesOfDay — 2×/day taken reads 100%, not 50%", () => {
// Regression for the compliance divergence: a plain `daysOfWeek`
// schedule (no rrule / rolling, SCHEDULED, not one-shot) carrying two
// `timesOfDay` must expand its numerator through the same engine as
// the denominator. Before the fix the numerator's local legacy walker
// emitted one slot/day from `windowStart`, while the denominator's
// engine counted both — collapsing a fully-adherent 2×/day med to
// 1/2 = 50%.
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "19:30",
daysOfWeek: null, // every day
timesOfDay: ["08:00", "19:00"],
// no rrule, no rollingIntervalDays, scheduleType defaults SCHEDULED
},
];
// Pin a single-day window so exactly two slots are expected. NOW is
// late evening UTC so both of today's slots are in the past.
vi.setSystemTime(new Date("2025-06-09T23:30:00Z"));
const events = [
eventAt(new Date("2025-06-09T08:00:00Z"), true),
eventAt(new Date("2025-06-09T19:00:00Z"), true),
];
const result = calculateCompliance(events, schedules, 1, undefined, {
medicationContext: ctx({ startsOn: new Date("2025-05-01T00:00:00Z") }),
});
// Both daily slots expand and both pair with a taken intake → 2/2.
expect(result.totalExpected).toBe(2);
expect(result.taken).toBe(2);
expect(result.rate).toBe(100);
});
});
// ────────────────────────────────────────────────────────────────────
@@ -645,6 +679,62 @@ describe("expectedSlotCountForDay + lastNonSkippedTakenAt", () => {
expect(count).toBe(2);
});
it("B15 invariant: legacy multi-time row — denominator and numerator expand identically", () => {
// The B15 bug lived in the numerator's expander, not here — this
// helper always ran the engine. Pin the convergence directly: for a
// plain `daysOfWeek` row carrying two `timesOfDay` (no rrule), the
// per-day denominator and the cadence numerator
// (`buildCadenceTimeline`) must report the same slot count.
const dayStart = new Date("2025-06-09T00:00:00Z");
const dayEnd = new Date("2025-06-10T00:00:00Z");
const schedules: ComplianceSchedule[] = [
{
windowStart: "08:00",
windowEnd: "19:30",
daysOfWeek: null,
timesOfDay: ["08:00", "19:00"],
},
];
const medCtx = ctx({
startsOn: new Date("2025-05-01T00:00:00Z"),
timeZone: "Europe/Berlin",
});
const denominator = expectedSlotCountForDay(
schedules,
dayStart,
dayEnd,
medCtx,
);
const numerator = buildCadenceTimeline(
[
{
windowStart: "08:00",
windowEnd: "19:30",
daysOfWeek: null,
timesOfDay: ["08:00", "19:00"],
},
],
[],
new Date("2025-06-09T23:59:00Z"),
1,
dayStart,
medCtx.timeZone,
{
startsOn: medCtx.startsOn,
endsOn: medCtx.endsOn,
oneShot: medCtx.oneShot,
createdAt: medCtx.createdAt,
lastIntakeAt: medCtx.lastIntakeAt,
timeZone: medCtx.timeZone,
},
).length;
expect(numerator).toBe(denominator);
expect(numerator).toBe(2);
});
it("lastNonSkippedTakenAt picks the newest non-skipped takenAt", () => {
const events = [
{ takenAt: new Date("2025-01-10T08:00:00Z"), skipped: false },
+262 -59
View File
@@ -18,6 +18,93 @@ import 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;
@@ -234,10 +321,34 @@ function buildClinicalSummaryLines(
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;
/**
* Draw a jsPDF-native trend sparkline (label + min/max ticks + polyline).
* Returns the new `y` cursor below the drawn chart. Vector-only — uses
* `doc.lines()`, no raster image, no native canvas module.
* 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,
@@ -246,20 +357,23 @@ function drawSparkline(
y: number;
width: number;
label: string;
values: number[];
points: SparklinePoint[];
num: FormatNum;
unit: string;
dateShort: (iso: string) => string;
},
): number {
const { x, y, width, label, values, num, unit } = opts;
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;
@@ -275,20 +389,36 @@ function drawSparkline(
doc.setLineWidth(0.2);
doc.line(x, chartBottom, x + chartWidth, chartBottom);
// Polyline points → jsPDF relative `lines()` deltas.
const stepX = values.length > 1 ? chartWidth / (values.length - 1) : 0;
const points = values.map((v, i) => ({
px: x + i * stepX,
py: chartBottom - ((v - min) / range) * chartHeight,
}));
// 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 < points.length; i++) {
deltas.push([points[i].px - points[i - 1].px, points[i].py - points[i - 1].py]);
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, points[0].px, points[0].py);
doc.lines(deltas, plotted[0].px, plotted[0].py);
}
// Min/max labels in the right gutter.
@@ -299,7 +429,17 @@ function drawSparkline(
doc.text(`${num(max, 1)}${unitSuffix}`, x + chartWidth + 2, chartTop + 2);
doc.text(`${num(min, 1)}${unitSuffix}`, x + chartWidth + 2, chartBottom);
return chartBottom + 2;
// 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;
}
/**
@@ -336,6 +476,9 @@ export function buildDoctorReportPdfDocument(
};
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
@@ -348,9 +491,33 @@ export function buildDoctorReportPdfDocument(
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);
@@ -449,6 +616,9 @@ export function buildDoctorReportPdfDocument(
// 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);
@@ -460,6 +630,10 @@ export function buildDoctorReportPdfDocument(
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;
}
@@ -467,6 +641,8 @@ export function buildDoctorReportPdfDocument(
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);
@@ -535,7 +711,12 @@ export function buildDoctorReportPdfDocument(
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
@@ -550,10 +731,8 @@ export function buildDoctorReportPdfDocument(
(type) => (data.measurements[type]?.length ?? 0) >= 2,
);
if (chartTypes.length > 0) {
if (y > 235) {
doc.addPage();
y = margin;
}
// 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);
@@ -561,19 +740,19 @@ export function buildDoctorReportPdfDocument(
y += 6;
for (const type of chartTypes) {
const series = data.measurements[type] ?? [];
if (y > 250) {
doc.addPage();
y = margin;
}
// 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,
values: series.map((p) => p.value),
points: series,
num,
unit: unitFor(type),
dateShort: (iso) => formatters.dateShort(iso),
});
y += 4;
}
@@ -584,6 +763,7 @@ export function buildDoctorReportPdfDocument(
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);
@@ -601,6 +781,7 @@ export function buildDoctorReportPdfDocument(
}
if (data.bmi) {
y = ensureSpace(y, 5 + 8);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.bmiTitle"), margin, y);
@@ -624,10 +805,7 @@ export function buildDoctorReportPdfDocument(
(ctx) => data.glucoseStats?.[ctx],
);
if (loggedGlucose.length > 0) {
if (y > 240) {
doc.addPage();
y = margin;
}
y = ensureSpace(y, 5 + 5 + 3);
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y);
@@ -645,6 +823,10 @@ export function buildDoctorReportPdfDocument(
: 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]),
@@ -664,10 +846,9 @@ export function buildDoctorReportPdfDocument(
const complianceEntries = Object.entries(data.compliance);
if (complianceEntries.length > 0) {
if (y > 240) {
doc.addPage();
y = margin;
}
// 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");
@@ -713,7 +894,12 @@ export function buildDoctorReportPdfDocument(
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
@@ -726,10 +912,8 @@ export function buildDoctorReportPdfDocument(
// current drug + dose, full titration history, weight curve over
// the report window, side-effect frequency, and compliance %.
if (data.glp1) {
if (y > 220) {
doc.addPage();
y = margin;
}
// Keep the GLP-1 heading with its first content line.
y = ensureSpace(y, 6 + 6 + 5);
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
@@ -756,10 +940,8 @@ export function buildDoctorReportPdfDocument(
}
for (const med of data.glp1.medications) {
if (y > 240) {
doc.addPage();
y = margin;
}
// 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);
@@ -797,6 +979,9 @@ export function buildDoctorReportPdfDocument(
`${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: [
@@ -808,6 +993,7 @@ export function buildDoctorReportPdfDocument(
],
body: historyRows,
theme: "grid",
rowPageBreak: "avoid",
styles: {
fontSize: 9,
cellPadding: 3,
@@ -821,7 +1007,12 @@ export function buildDoctorReportPdfDocument(
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
@@ -830,15 +1021,14 @@ export function buildDoctorReportPdfDocument(
}
if (data.glp1.sideEffects.length > 0) {
if (y > 240) {
doc.addPage();
y = margin;
}
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;
const seRows = data.glp1.sideEffects.map((s) => [s.tag, String(s.count)]);
autoTable(doc, {
startY: y,
head: [
@@ -846,6 +1036,7 @@ export function buildDoctorReportPdfDocument(
],
body: seRows,
theme: "grid",
rowPageBreak: "avoid",
styles: {
fontSize: 9,
cellPadding: 3,
@@ -859,7 +1050,12 @@ export function buildDoctorReportPdfDocument(
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
@@ -868,10 +1064,10 @@ export function buildDoctorReportPdfDocument(
}
if (data.mood) {
if (y > 240) {
doc.addPage();
y = margin;
}
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");
@@ -925,7 +1121,12 @@ export function buildDoctorReportPdfDocument(
textColor: [30, 30, 30],
fontStyle: "bold",
},
margin: { left: margin, right: margin },
margin: {
left: margin,
right: margin,
top: margin,
bottom: tableBottomMargin,
},
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
@@ -938,10 +1139,8 @@ export function buildDoctorReportPdfDocument(
// for a machine-generated diagnosis.
const aiText = typeof aiSummary === "string" ? aiSummary.trim() : "";
if (aiText.length > 0) {
if (y > 220) {
doc.addPage();
y = margin;
}
// 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);
@@ -955,6 +1154,10 @@ export function buildDoctorReportPdfDocument(
pageWidth - 2 * margin,
);
for (const line of disclaimer) {
if (y + 4 > contentMaxY) {
doc.addPage();
y = margin;
}
doc.text(line, margin, y);
y += 4;
}
@@ -964,7 +1167,7 @@ export function buildDoctorReportPdfDocument(
doc.setTextColor(60, 60, 60);
const wrapped = doc.splitTextToSize(aiText, pageWidth - 2 * margin);
for (const line of wrapped) {
if (y > 270) {
if (y + 4.5 > contentMaxY) {
doc.addPage();
y = margin;
}
@@ -7,6 +7,7 @@
import { describe, expect, it } from "vitest";
import { complianceChips } from "../compliance";
import { buildCadenceTimeline } from "../cadence";
import type {
CadenceEngineContext,
IntakeEventLike,
@@ -318,4 +319,41 @@ describe("complianceChips — canonical-engine delegation (v1.7.0 SB-SCHED-2)",
// than the equivalent every-day SCHEDULED expansion.
expect(engine.missedLast30).toBeLessThan(everyDay.missedLast30);
});
it("B15: a legacy daysOfWeek row with multiple timesOfDay emits one slot per time", () => {
// Repro of the compliance divergence: a plain `daysOfWeek` schedule
// (no rrule / rolling, SCHEDULED, not one-shot) carrying two
// `timesOfDay` must expand to two slots per qualifying day through the
// engine. Before the fix the cadence numerator routed such a row to
// the local legacy walker, which emitted a single slot/day from
// `windowStart` — so a 2×/day medication reported 1/2 = 50%.
const twiceDaily: ScheduleLike = {
windowStart: "08:00",
windowEnd: "19:30",
daysOfWeek: null, // every day
timesOfDay: ["08:00", "19:00"],
// no rrule, no rollingIntervalDays, scheduleType defaults to SCHEDULED
};
// A single 24-hour window starting at local midnight Berlin.
const dayStart = d("2025-06-09T00:00:00Z");
const NOW = d("2025-06-09T23:59:00Z");
const ctx = engineCtx({
startsOn: d("2025-05-01T00:00:00Z"),
timeZone: "Europe/Berlin",
});
const timeline = buildCadenceTimeline(
[twiceDaily],
[],
NOW,
1,
dayStart,
ctx.timeZone,
ctx,
);
// Both configured times land inside the one-day window → two slots.
expect(timeline.length).toBe(2);
});
});
+96 -39
View File
@@ -44,16 +44,20 @@ export interface ScheduleLike {
/** Encoded recurrence string per `serializeScheduleRecurrence`. */
daysOfWeek: string | null;
/**
* v1.7.0 SB-SCHED-2 — canonical-engine fields. When any of these is
* present AND the caller supplies a `CadenceEngineContext`,
* v1.7.0 SB-SCHED-2 — canonical-engine fields. As of v1.7.3 (B15),
* `expandScheduleSlots` delegates the expected-slot grid to the
* canonical recurrence engine (`occurrencesBetween`) instead of the
* legacy `daysOfWeek` walker. This routes compliance through the same
* engine the projector + reminder worker use, fixing the long-standing
* bug where an `rrule = "FREQ=WEEKLY;BYDAY=MO"` schedule expanded to
* canonical recurrence engine (`occurrencesBetween`) for EVERY schedule
* shape whenever the caller supplies a `CadenceEngineContext` — these
* fields carry the recurrence detail the engine reads (rrule / rolling
* / cyclic), and a plain legacy `daysOfWeek` row routes through the
* engine's `expandLegacy` branch all the same. This keeps compliance on
* the same engine the projector + reminder worker use, fixing the bug
* where an `rrule = "FREQ=WEEKLY;BYDAY=MO"` schedule expanded to
* daily-every-day in compliance (because `daysOfWeek = null` reads as
* "every day"). Absent fields keep the legacy weekday path so existing
* fixtures / pre-v1.7 callers are byte-stable.
* "every day") AND the B15 bug where a multi-`timesOfDay` legacy row
* collapsed to one slot/day in the numerator. Only callers that omit a
* context fall to the legacy weekday walker (byte-stable for pure-math
* / pre-v1.7 fixtures).
*/
rrule?: string | null;
rollingIntervalDays?: number | null;
@@ -84,16 +88,6 @@ export interface CadenceEngineContext {
timeZone: string;
}
/** A schedule routes through the canonical engine iff it carries a
* non-legacy recurrence shape OR a non-SCHEDULED schedule type. */
function usesCanonicalEngine(schedule: ScheduleLike): boolean {
return (
(schedule.rrule ?? null) !== null ||
(schedule.rollingIntervalDays ?? null) !== null ||
(schedule.scheduleType ?? "SCHEDULED") !== "SCHEDULED"
);
}
/** Build a `CanonicalSchedule` from a `ScheduleLike` + its index. */
function toCanonical(
schedule: ScheduleLike,
@@ -230,6 +224,15 @@ function applyTime(day: Date, hhmm: string, tz: string | undefined): Date {
return instantInTz(parts.year, parts.month, parts.day, h, m, tz);
}
/** Parse "HH:mm" to minutes-since-midnight, or null when malformed. */
function hhmmToMinutes(hhmm: string): number | null {
const [hStr, mStr] = hhmm.split(":");
const h = Number(hStr);
const m = Number(mStr);
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
return h * 60 + m;
}
/** Snap a Date down to the user-local midnight. */
function startOfLocalDay(d: Date, tz: string | undefined): Date {
const parts = wallClockInTz(d, tz);
@@ -267,14 +270,25 @@ export function expandScheduleSlots(
): ExpectedDose[] {
if (to <= from) return [];
// v1.7.0 SB-SCHED-2 — Option B delegation. When the caller supplies a
// medication context AND the schedule carries a canonical recurrence
// (rrule / rolling) or a non-SCHEDULED type, expand the expected-slot
// grid through the canonical engine so compliance, the cadence chart,
// and the projector all read the same source of truth. The legacy
// weekday walker below stays the path for plain `daysOfWeek` schedules
// and for every caller that doesn't thread a context (byte-stable).
if (engineCtx && (engineCtx.oneShot || usesCanonicalEngine(schedule))) {
// v1.7.3 B15 — canonical-engine delegation. When the caller supplies a
// medication context, expand the expected-slot grid through the
// canonical engine for EVERY schedule shape (rrule / rolling / one-shot
// / PRN / cyclic AND plain legacy `daysOfWeek`). The engine's
// `expandLegacy` branch iterates `effectiveTimesOfDay` correctly, so a
// single legacy row carrying multiple `timesOfDay` emits one slot per
// time — matching exactly what `expectedSlotCountForDay` already counts.
//
// The earlier gate (`oneShot || usesCanonicalEngine`) diverged the
// compliance numerator from the denominator: a 2×/day legacy schedule
// (`daysOfWeek` set, two `timesOfDay`, no rrule) fell through to the
// local legacy walker below, which only ever emits ONE slot/day from
// `windowStart`, while `expectedSlotCountForDay` always ran the engine
// and counted both slots → 1/2 = 50%. Routing both sides through the
// single engine converges them for every schedule form.
//
// The local legacy walker below stays the path only when no context is
// threaded (pure-math callers / pre-v1.7 fixtures) — byte-stable.
if (engineCtx) {
const canonical = toCanonical(schedule, scheduleIndex);
const recurrenceCtx = toRecurrenceContext(engineCtx);
// v1.7.0 code-correctness M5 — `occurrencesBetween` is inclusive of
@@ -333,22 +347,65 @@ export function expandScheduleSlots(
}
}
const wStart = applyTime(day, schedule.windowStart, timeZone);
let wEnd = applyTime(day, schedule.windowEnd, timeZone);
// Overnight window: windowEnd <= windowStart means next day.
if (wEnd <= wStart) {
wEnd = new Date(wEnd.getTime() + DAY_MS);
// v1.7.3 B15 — defence-in-depth for the context-less legacy path. A
// schedule carrying multiple `timesOfDay` must emit one slot per time
// per qualifying day, mirroring the engine's `expandLegacy`. Pre-fix
// this walker only ever emitted a single `windowStart..windowEnd`
// window, so a 2×/day legacy row read as one slot/day here — the
// numerator half of the B15 divergence. When `timesOfDay` is empty we
// keep the historical single-window behaviour (byte-stable for every
// pre-v1.7 fixture that relies on it).
const times =
schedule.timesOfDay && schedule.timesOfDay.length > 0
? schedule.timesOfDay
: null;
if (times === null) {
const wStart = applyTime(day, schedule.windowStart, timeZone);
let wEnd = applyTime(day, schedule.windowEnd, timeZone);
// Overnight window: windowEnd <= windowStart means next day.
if (wEnd <= wStart) {
wEnd = new Date(wEnd.getTime() + DAY_MS);
}
if (wStart >= to) continue;
if (wEnd <= from) continue;
slots.push({
day,
windowStart: wStart,
windowEnd: wEnd,
scheduleIndex,
});
continue;
}
if (wStart >= to) continue;
if (wEnd <= from) continue;
for (const time of times) {
const wStart = applyTime(day, time, timeZone);
// Each time-of-day slot spans the schedule's window length. Reuse
// the windowStart..windowEnd span so the pairing radius and the
// chart cell keep their existing shape; an overnight window pushes
// the end to the next day.
const startMin = hhmmToMinutes(schedule.windowStart);
const endMin = hhmmToMinutes(schedule.windowEnd);
let spanMs = DAY_MS;
if (startMin !== null && endMin !== null) {
let span = endMin - startMin;
if (span <= 0) span += 24 * 60; // overnight window
spanMs = span * 60_000;
}
const wEnd = new Date(wStart.getTime() + spanMs);
slots.push({
day,
windowStart: wStart,
windowEnd: wEnd,
scheduleIndex,
});
if (wStart >= to) continue;
if (wEnd <= from) continue;
slots.push({
day,
windowStart: wStart,
windowEnd: wEnd,
scheduleIndex,
});
}
}
return slots;