fix(web): paginate readable sleep PDF treatments and remarks

This commit is contained in:
fy59 2026-10-01 21:42:00 +02:00
parent e7439c5177
commit 3758875dbb
4 changed files with 312 additions and 127 deletions

View file

@ -2,6 +2,14 @@
## Unreleased — 0.1.7
- Widened the Sleep diary PDF treatment/remarks column from 84 to 180 pt
and raised its text from 4 to 7.2 pt. Width-aware wrapping keeps paragraphs,
separate medication intakes, accents, doses, units, and quantities visible.
Row heights and page count now follow content; exceptional entries continue
on clearly dated pages. The former truncated observations duplicate is gone.
The 18:00 timeline and factual blue sport overlay use one shared PDF geometry;
Sleep, sport, database, and synchronization data are unchanged.
- Added a read-only completed-session overlay to the Web Sleep agenda and its
local vector PDF preview/export. Factual session start and end times share
the Sleep row's 18:00 axis in both renderers; blue outlined bands and a

View file

@ -236,7 +236,12 @@ Sleep, medication, and sport in both Web and vector PDF. Valid sport intervals
intersect `[start,end)` and are clipped only for display; missing or invalid
ends are point markers without invented duration. The Web keeps a separate
blue lane and accessible session list. Preview and download wait for the
complete sport read and use the same selected sessions. The PDF prints blue
complete sport read and use the same selected sessions. The PDF uses a 180 pt treatment/remarks column with 7.2 pt text,
width-aware wrapping, content-driven row heights, and dated continuation pages
for exceptional notes. Each selected intake and remark is printed once without
a fixed character limit. The shorter 430 pt timeline shares one coordinate
mapping across hour ticks, grid, sleep markers, medication markers, and blue
sport bands. The PDF prints blue
outlined bands and paginated names, factual times, and durations, marking
sessions without a corresponding Sleep row as `sleep not recorded`. Sport
creates no Sleep event and changes no sleep total, session, schema, or sync

View file

@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { SleepEntry, SleepSnapshot } from "../api/sleepDiary";
import { buildSleepDiaryPdf } from "./sleepDiaryPdf";
const day = (value: number) => `2026-09-${String(value).padStart(2, "0")}`;
const day = (value: number) => new Date(Date.UTC(2026, 8, value)).toISOString().slice(0, 10);
const timestamp = (date: string, time: string) => `${date}T${time}:00+02:00`;
const completeEntry = (index: number): SleepEntry => {
@ -97,6 +97,8 @@ const completeEntry = (index: number): SleepEntry => {
};
};
const compactDateForTest = (date: string) => date.split("-").reverse().join("/");
const snapshot = (count: number): SleepSnapshot => ({
api_version: 1,
entries: Array.from({ length: count }, (_, index) => completeEntry(index)),
@ -183,11 +185,11 @@ describe("sleep diary PDF", () => {
it("draws a proportional 45-minute long-awakening interval between sleep ranges", async () => {
const source = await buildSleepDiaryPdf(snapshot(1), "fr").text();
const firstSleep = source.indexOf("0.75 g 216.17 509.00 83.33 18.00 re f");
const firstSleep = source.indexOf("0.75 g 183.58 509.00 71.67 18.00 re f");
const longAwakening = source.indexOf(
"0.98 0.70 0.53 rg 299.50 509.00 15.63 18.00 re f",
"0.98 0.70 0.53 rg 255.25 509.00 13.44 18.00 re f",
);
const secondSleep = source.indexOf("0.75 g 315.13 509.00 67.71 18.00 re f");
const secondSleep = source.indexOf("0.75 g 268.69 509.00 58.23 18.00 re f");
expect(firstSleep).toBeGreaterThan(-1);
expect(longAwakening).toBeGreaterThan(firstSleep);
expect(secondSleep).toBeGreaterThan(longAwakening);
@ -225,13 +227,75 @@ describe("sleep diary PDF", () => {
it("paginates fourteen, twenty-one and thirty days", async () => {
for (const count of [14, 21, 30]) {
const source = await buildSleepDiaryPdf(snapshot(count), "en").text();
expect((source.match(/\/Type \/Page /g) ?? []).length).toBe(
Math.ceil(count / 9),
);
const pageCount = (source.match(/\/Type \/Page /g) ?? []).length;
expect(pageCount).toBeGreaterThan(Math.ceil(count / 9));
expect(pageCount).toBeLessThan(count);
expect(source).toContain("OBSERVATIONS");
}
});
it("preserves every selected treatment and all remarks beyond former limits", async () => {
const data = snapshot(7);
data.entries.forEach((entry, index) => {
entry.treatment_and_notes = `Paragraph ${index}: ${"unbrokenword".repeat(20)}\n\n` +
`Second paragraph with accents: éèê œ, dose 2,5 mg ×3. END-${index}`;
});
const source = await buildSleepDiaryPdf(data, "fr").text();
const content = await textualContent(buildSleepDiaryPdf(data, "fr"));
for (let index = 0; index < 7; index++) {
expect(content).toContain(`END-${index}`);
expect(content).toContain(`Paragraph ${index}:`);
}
expect(content).toContain("œ, dose 2,5");
expect(content).toContain("mg ×3. END-0");
expect(content).toContain("TRAITEMENTS");
expect(content).toContain("REMARQUES");
expect(content).toContain("mg ×2");
expect(source).not.toContain("slice(0, 160)");
});
it("creates dated continuations for a single oversized entry", async () => {
const data = snapshot(1);
data.entries[0].treatment_and_notes = Array.from({ length: 150 }, (_, index) =>
`Unique paragraph ${index} with meaningful complete text.`).join("\n");
const source = await buildSleepDiaryPdf(data, "en").text();
const content = await textualContent(buildSleepDiaryPdf(data, "en"));
expect((source.match(/\/Type \/Page /g) ?? []).length).toBeGreaterThan(1);
expect(content).toContain("continued");
expect(content).toContain("Unique paragraph 0");
expect(content).toContain("Unique paragraph 149");
expect((content.match(/Unique paragraph /g) ?? []).length).toBe(150);
});
it("keeps empty and short notes compact in both languages", async () => {
const data = snapshot(7);
data.entries.forEach((entry) => { entry.intakes = []; entry.treatment_and_notes = ""; });
for (const language of ["fr", "en"] as const) {
const source = await buildSleepDiaryPdf(data, language).text();
expect((source.match(/\/Type \/Page /g) ?? []).length).toBe(1);
expect(source).not.toContain("continued");
}
data.entries[0].treatment_and_notes = "Short note.";
const content = await textualContent(buildSleepDiaryPdf(data, "en"));
expect(content).toContain("Short note.");
});
it("paginates 7, 14, 21 and 30 selected nights in FR and EN without empty pages", async () => {
for (const count of [7, 14, 21, 30]) {
for (const language of ["fr", "en"] as const) {
const data = snapshot(count);
const source = await buildSleepDiaryPdf(data, language).text();
const content = await textualContent(buildSleepDiaryPdf(data, language));
const pages = (source.match(/\/Type \/Page /g) ?? []).length;
expect(pages).toBeGreaterThan(0);
expect((content.match(/OBSERVATIONS/g) ?? []).length).toBe(pages);
for (const entry of data.entries)
expect(content).toContain(compactDateForTest(entry.night_start_date));
expect(content).not.toContain("OUTSIDE-SELECTION");
}
}
});
it("prints factual sport bands and a separate legible listing without changing sleep totals", async () => {
const sessions = [
{ identity: "se_morning", session_type: "training", label: "Musculation",

View file

@ -87,6 +87,136 @@ const pdfString = (value: string) =>
const text = (x: number, y: number, size: number, value: string) =>
`0 g BT /F1 ${size} Tf ${x.toFixed(1)} ${y.toFixed(1)} Td (${pdfString(value)}) Tj ET\n`;
// CONTRACT: all timeline marks, grid ticks, headers, and sport bands use these
// same coordinates. The notes column gets over twice its former 84 pt width.
const layout = {
left: 30,
right: 812,
dateX: 33,
timelineX: 94,
timelineWidth: 430,
qualityX: [528, 562, 596],
notesX: 632,
notesRight: 812,
rowTop: 538,
rowBottom: 137,
minRowHeight: 40,
noteFont: 7.2,
noteLeading: 9.5,
notePadding: 5,
} as const;
type NoteLine = { value: string; separator?: boolean };
type RowFragment = { entry: SleepEntry; lines: NoteLine[]; first: boolean; height: number };
// WHY: PDF Helvetica is not monospaced. Wrapping by character count loses
// content or puts it outside the cell. These are the built-in Helvetica widths
// in thousandths of an em; unsupported Unicode uses the same '?' fallback as
// pdfString. An extra margin absorbs viewer/font rounding differences.
const helveticaAscii = [
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278,
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556,
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778,
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556,
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556,
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
];
const glyphWidth = (character: string) => {
const code = character.codePointAt(0) ?? 63;
if (code >= 32 && code <= 126) return helveticaAscii[code - 32];
const base = character.normalize("NFD").charAt(0);
const baseCode = base.codePointAt(0) ?? 63;
if (baseCode >= 32 && baseCode <= 126) return helveticaAscii[baseCode - 32];
return character === "œ" ? 833 : character === "Œ" ? 1000 : 600;
};
const lineWidth = (value: string) =>
Array.from(value).reduce((sum, character) => sum + glyphWidth(character), 0) * layout.noteFont / 1000;
const maxNoteWidth = layout.notesRight - layout.notesX - layout.notePadding * 2 - 4;
function wrapParagraph(paragraph: string): string[] {
if (paragraph === "") return [""];
const words = paragraph.match(/\S+|\s+/gu) ?? [];
const lines: string[] = [];
let current = "";
for (const word of words) {
if (/^\s+$/u.test(word) && current === "") continue;
if (lineWidth(current + word) <= maxNoteWidth) {
current += word;
continue;
}
if (current.trim()) lines.push(current.trimEnd());
current = "";
if (/^\s+$/u.test(word)) continue;
if (lineWidth(word) <= maxNoteWidth) {
current = word;
continue;
}
for (const character of Array.from(word)) {
if (current && lineWidth(current + character) > maxNoteWidth) {
lines.push(current);
current = "";
}
current += character;
}
}
if (current) lines.push(current.trimEnd());
return lines;
}
function noteLines(entry: SleepEntry, language: Language): NoteLine[] {
const lines: NoteLine[] = [];
if (entry.intakes.length) {
lines.push({ value: language === "fr" ? "TRAITEMENTS" : "TREATMENTS" });
for (const intake of entry.intakes) {
const dose = intake.dose_value === null ? "" :
` — ${formatDose(intake.dose_value)}${intake.dose_unit ? ` ${intake.dose_unit}` : ""}`;
const quantity = intake.quantity > 1 ? ` ×${intake.quantity}` : "";
for (const value of wrapParagraph(`${intake.taken_at.slice(11, 16)} — ${intake.medication_name}${dose}${quantity}`))
lines.push({ value });
}
}
if (entry.treatment_and_notes) {
lines.push({ value: language === "fr" ? "REMARQUES" : "NOTES", separator: lines.length > 0 });
for (const paragraph of entry.treatment_and_notes.split(/\r\n|\n|\r/u))
for (const value of wrapParagraph(paragraph)) lines.push({ value });
}
return lines;
}
const rowHeight = (lineCount: number) =>
Math.max(layout.minRowHeight, 9 + lineCount * layout.noteLeading + 4);
// INVARIANT: a fragment is emitted only when it fits. Oversized entries are
// split into dated continuations; every note line belongs to exactly one page.
function paginate(entries: readonly SleepEntry[], language: Language): RowFragment[][] {
const pages: RowFragment[][] = [[]];
let remaining = layout.rowTop - layout.rowBottom;
const fresh = () => {
pages.push([]);
remaining = layout.rowTop - layout.rowBottom;
};
for (const entry of entries) {
const lines = noteLines(entry, language);
let offset = 0;
let first = true;
do {
const fullHeight = rowHeight(lines.length - offset);
if (fullHeight > remaining && pages[pages.length - 1].length > 0) fresh();
const maxLines = Math.floor((remaining - 13) / layout.noteLeading);
const count = Math.min(lines.length - offset, Math.max(1, maxLines));
const fragmentLines = lines.slice(offset, offset + count);
const height = rowHeight(fragmentLines.length);
if (height > remaining) throw new Error("PDF row exceeds available page height");
pages[pages.length - 1].push({ entry, lines: fragmentLines, first, height });
remaining -= height;
offset += count;
first = false;
if (offset < lines.length) fresh();
} while (offset < lines.length);
}
return pages;
}
const compactDate = (isoDate: string) => {
const [year, month, day] = isoDate.split("-");
return `${day}/${month}/${year}`;
@ -113,104 +243,96 @@ const pointMarker = (type: SleepEventType) => {
function row(
stream: string[],
entry: SleepEntry,
fragment: RowFragment,
y: number,
language: Language,
sport: readonly SportSession[],
) {
const { entry, lines, first, height } = fragment;
const projection = projectSleepTimeline(entry);
const window = sleepRowWindow(entry);
const timelineX = 112;
const width = 500;
stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`);
stream.push(text(33, y - 13, 5, compactDate(entry.night_start_date)));
const timelineX = layout.timelineX;
const width = layout.timelineWidth;
stream.push(`0.7 G ${layout.left} ${y - height} ${layout.right - layout.left} ${height} re S\n`);
stream.push(text(layout.dateX, y - 13, 5, compactDate(entry.night_start_date)));
stream.push(
text(
33,
layout.dateX,
y - 23,
5,
`${language === "fr" ? "au" : "to"} ${compactDate(entry.night_end_date)}`,
),
);
for (let hour = 0; hour <= 24; hour++) {
const x = timelineX + (hour / 24) * width;
stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`);
}
[...projection.sleep, ...projection.awake, ...projection.otherIntervals]
.sort((left, right) => left.start - right.start || left.end - right.end)
.forEach((range) => {
const event = range.event;
const x =
timelineX +
timelinePosition(range.start, window.start, window.end) * width;
// INVARIANT: every projected interval keeps its canonical absolute
// start/end. An awakening interrupts rather than moves adjacent sleep;
// an estimated band is presentation-only and stays visually distinct.
const eventWidth = Math.max(
2,
timelineX +
timelinePosition(range.end, window.start, window.end) * width -
x,
);
const shade = range.kind === "sleep"
? range.estimated ? 0.88 : 0.75
: event?.type === "half_sleep" ? 0.87 : 0.93;
if (range.kind === "awake") {
// CONTRACT: structured awakening intervals use the same orange semantic
// role in Web, HR, preview, and PDF; point markers remain separate.
stream.push(
`0.98 0.70 0.53 rg ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0.78 0.42 0.22 RG ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S 0 G 0 g\n`,
);
} else {
stream.push(
`${shade} g ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0 G ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S\n`,
);
}
if (eventWidth > 24) {
const label = event
? eventLabels[language][event.type]
: language === "fr" ? "SOMMEIL ESTIMÉ" : "ESTIMATED SLEEP";
stream.push(text(x + 2, y - 23, 5, label));
if (!first) stream.push(text(layout.dateX, y - 34, 6, language === "fr" ? "suite" : "continued"));
if (first) {
for (let hour = 0; hour <= 24; hour++) {
const x = timelineX + (hour / 24) * width;
stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`);
}
[...projection.sleep, ...projection.awake, ...projection.otherIntervals]
.sort((left, right) => left.start - right.start || left.end - right.end)
.forEach((range) => {
const event = range.event;
const x =
timelineX +
timelinePosition(range.start, window.start, window.end) * width;
// INVARIANT: every projected interval keeps its canonical absolute
// start/end. An awakening interrupts rather than moves adjacent sleep;
// an estimated band is presentation-only and stays visually distinct.
const eventWidth = Math.max(
2,
timelineX +
timelinePosition(range.end, window.start, window.end) * width -
x,
);
const shade = range.kind === "sleep"
? range.estimated ? 0.88 : 0.75
: event?.type === "half_sleep" ? 0.87 : 0.93;
if (range.kind === "awake") {
// CONTRACT: structured awakening intervals use the same orange semantic
// role in Web, HR, preview, and PDF; point markers remain separate.
stream.push(
`0.98 0.70 0.53 rg ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0.78 0.42 0.22 RG ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S 0 G 0 g\n`,
);
} else {
stream.push(
`${shade} g ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0 G ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S\n`,
);
}
if (eventWidth > 24) {
const label = event
? eventLabels[language][event.type]
: language === "fr" ? "SOMMEIL ESTIMÉ" : "ESTIMATED SLEEP";
stream.push(text(x + 2, y - 23, 5, label));
}
});
projection.pointEvents.forEach((event) => {
const x = timelineX +
timelinePosition(event.start_at, window.start, window.end) * width;
stream.push(text(x, y - 25, 8, pointMarker(event.type)));
});
projection.pointEvents.forEach((event) => {
const x = timelineX +
timelinePosition(event.start_at, window.start, window.end) * width;
stream.push(text(x, y - 25, 8, pointMarker(event.type)));
});
projection.intakes.forEach((intake) => {
const x =
timelineX +
timelinePosition(intake.taken_at, window.start, window.end) * width;
stream.push(text(x, y - 36, 6, "M"));
});
stream.push(text(620, y - 16, 7, entry.sleep_quality ?? "-"));
stream.push(text(658, y - 16, 7, entry.wake_quality ?? "-"));
const medicationText = entry.intakes
.map(
(intake) =>
`${intake.taken_at.slice(11, 16)} — ${intake.medication_name}${
intake.dose_value === null
? ""
: ` — ${formatDose(intake.dose_value)} ${intake.dose_unit ?? ""}${intake.quantity > 1 ? ` ×${intake.quantity}` : ""}`
}`,
)
.join(" ; ");
const notes = [medicationText, entry.treatment_and_notes]
.filter(Boolean)
.join(" | ");
stream.push(text(696, y - 16, 7, entry.day_form ?? "-"));
stream.push(text(730, y - 7, 4, notes.slice(0, 40)));
stream.push(text(730, y - 16, 4, notes.slice(40, 80)));
stream.push(text(730, y - 25, 4, notes.slice(80, 120)));
stream.push(text(730, y - 34, 4, notes.slice(120, 160)));
projectSportSegments(sport, window).forEach((segment) => {
const x = timelineX + segment.left * width;
const segmentWidth = segment.end === null ? 2 : Math.max(2, segment.width * width);
// PDF graphics state contains the blue fill and black outline. It cannot
// tint later sleep marks, grid lines, medication labels, or body text.
stream.push(`q 0.10 0.34 0.82 rg 0 G ${x.toFixed(2)} ${(y - 9).toFixed(2)} ` +
`${segmentWidth.toFixed(2)} 5 re B Q\n`);
projection.intakes.forEach((intake) => {
const x =
timelineX +
timelinePosition(intake.taken_at, window.start, window.end) * width;
stream.push(text(x, y - 36, 6, "M"));
});
stream.push(text(layout.qualityX[0], y - 16, 7, entry.sleep_quality ?? "-"));
stream.push(text(layout.qualityX[1], y - 16, 7, entry.wake_quality ?? "-"));
stream.push(text(layout.qualityX[2], y - 16, 7, entry.day_form ?? "-"));
projectSportSegments(sport, window).forEach((segment) => {
const x = timelineX + segment.left * width;
const segmentWidth = segment.end === null ? 2 : Math.max(2, segment.width * width);
// PDF graphics state contains the blue fill and black outline. It cannot
// tint later sleep marks, grid lines, medication labels, or body text.
stream.push(`q 0.10 0.34 0.82 rg 0 G ${x.toFixed(2)} ${(y - 9).toFixed(2)} ` +
`${segmentWidth.toFixed(2)} 5 re B Q\n`);
});
}
lines.forEach((line, index) => {
const baseline = y - 12 - index * layout.noteLeading;
if (line.separator) stream.push(`0.8 G ${layout.notesX + 3} ${(baseline + 5).toFixed(2)} m ${layout.notesRight - 3} ${(baseline + 5).toFixed(2)} l S 0 G\n`);
stream.push(text(layout.notesX + layout.notePadding, baseline, layout.noteFont, line.value));
});
}
@ -236,7 +358,7 @@ function summaryLine(snapshot: SleepSnapshot, language: Language) {
function page(
snapshot: SleepSnapshot,
entries: SleepEntry[],
fragments: RowFragment[],
index: number,
count: number,
language: Language,
@ -255,29 +377,31 @@ function page(
text(740, 565, 7, `${index + 1}/${count}`),
text(30, 548, 7, "DATE"),
];
for (let hour = 0; hour <= 24; hour++)
for (let hour = 0; hour < 24; hour++)
stream.push(
text(112 + (hour / 24) * 500, 548, 4, String((18 + hour) % 24)),
text(layout.timelineX + (hour / 24) * layout.timelineWidth, 548, 4, String((18 + hour) % 24)),
);
if (language === "fr") {
stream.push(text(616, 552, 4, "QUALITÉ DU"));
stream.push(text(616, 545, 4, "SOMMEIL"));
stream.push(text(654, 552, 4, "QUALITÉ DU"));
stream.push(text(654, 545, 4, "RÉVEIL"));
stream.push(text(692, 552, 4, "FORME DE LA"));
stream.push(text(692, 545, 4, "JOURNÉE"));
stream.push(text(728, 552, 4, "TRAITEMENT ET"));
stream.push(text(728, 545, 4, "REMARQUES"));
stream.push(text(layout.qualityX[0], 552, 4, "QUALITÉ DU"));
stream.push(text(layout.qualityX[0], 545, 4, "SOMMEIL"));
stream.push(text(layout.qualityX[1], 552, 4, "QUALITÉ DU"));
stream.push(text(layout.qualityX[1], 545, 4, "RÉVEIL"));
stream.push(text(layout.qualityX[2], 552, 4, "FORME DE LA"));
stream.push(text(layout.qualityX[2], 545, 4, "JOURNÉE"));
stream.push(text(layout.notesX, 552, 4, "TRAITEMENT ET"));
stream.push(text(layout.notesX, 545, 4, "REMARQUES"));
} else {
stream.push(text(616, 552, 4, "SLEEP QUALITY"));
stream.push(text(654, 552, 4, "WAKE QUALITY"));
stream.push(text(692, 552, 4, "DAY FORM"));
stream.push(text(728, 552, 4, "TREATMENT / NOTES"));
stream.push(text(layout.qualityX[0], 552, 4, "SLEEP QUALITY"));
stream.push(text(layout.qualityX[1], 552, 4, "WAKE QUALITY"));
stream.push(text(layout.qualityX[2], 552, 4, "DAY FORM"));
stream.push(text(layout.notesX, 552, 4, "TREATMENT / NOTES"));
}
entries.forEach((entry, rowIndex) =>
row(stream, entry, 538 - rowIndex * 40, language, sport),
);
const y = 520 - entries.length * 40;
let rowTop = layout.rowTop;
fragments.forEach((fragment) => {
row(stream, fragment, rowTop, language, sport);
rowTop -= fragment.height;
});
const y = 120;
stream.push(text(30, y, 9, "OBSERVATIONS"));
stream.push(text(30, y - 14, 6, summaryLine(snapshot, language)));
const unvalidated = snapshot.entries.filter(
@ -296,19 +420,6 @@ function page(
: `WARNING: ${plural(unvalidated, "unvalidated day", "unvalidated days")} in this export.`,
),
);
entries
.filter((entry) => entry.treatment_and_notes)
.slice(0, 3)
.forEach((entry, noteIndex) =>
stream.push(
text(
30,
y - 36 - noteIndex * 10,
6,
`${compactDate(entry.night_start_date)} : ${entry.treatment_and_notes.slice(0, 110)}`,
),
),
);
if (language === "fr") {
stream.push(text(30, 38, 6, "Bleu : séance de sport effectuée (horaires enregistrés ; détails en fin de rapport)."));
stream.push(
@ -398,10 +509,7 @@ export function buildSleepDiaryPdf(
reportStart = snapshot.entries[0]?.night_start_date ?? "—",
reportEnd = snapshot.entries[snapshot.entries.length - 1]?.night_start_date ?? "—",
): Blob {
const groups = Array.from(
{ length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) },
(_, index) => snapshot.entries.slice(index * 9, (index + 1) * 9),
);
const groups = paginate(snapshot.entries, language);
const sportGroups = Array.from({ length: Math.ceil(sport.length / 28) },
(_, index) => sport.slice(index * 28, (index + 1) * 28));
const reportEndTime = Date.parse(`${reportEnd}T12:00:00Z`);