From 3758875dbb19517db02b440c6d4caa1d3205371f Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 1 Oct 2026 21:42:00 +0200 Subject: [PATCH] fix(web): paginate readable sleep PDF treatments and remarks --- CHANGELOG.md | 8 + docs/current_state.md | 7 +- web/src/report/sleepDiaryPdf.test.ts | 78 +++++- web/src/report/sleepDiaryPdf.ts | 346 ++++++++++++++++++--------- 4 files changed, 312 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6799eb..8213ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/current_state.md b/docs/current_state.md index 36dda5a..3e213e5 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -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 diff --git a/web/src/report/sleepDiaryPdf.test.ts b/web/src/report/sleepDiaryPdf.test.ts index 9f32e77..ea2039c 100644 --- a/web/src/report/sleepDiaryPdf.test.ts +++ b/web/src/report/sleepDiaryPdf.test.ts @@ -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", diff --git a/web/src/report/sleepDiaryPdf.ts b/web/src/report/sleepDiaryPdf.ts index 1ddcb73..d4f9d52 100644 --- a/web/src/report/sleepDiaryPdf.ts +++ b/web/src/report/sleepDiaryPdf.ts @@ -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`);