fix(web): paginate readable sleep PDF treatments and remarks
This commit is contained in:
parent
e7439c5177
commit
3758875dbb
4 changed files with 312 additions and 127 deletions
|
|
@ -2,6 +2,14 @@
|
||||||
|
|
||||||
## Unreleased — 0.1.7
|
## 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
|
- 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
|
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
|
the Sleep row's 18:00 axis in both renderers; blue outlined bands and a
|
||||||
|
|
|
||||||
|
|
@ -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
|
intersect `[start,end)` and are clipped only for display; missing or invalid
|
||||||
ends are point markers without invented duration. The Web keeps a separate
|
ends are point markers without invented duration. The Web keeps a separate
|
||||||
blue lane and accessible session list. Preview and download wait for the
|
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
|
outlined bands and paginated names, factual times, and durations, marking
|
||||||
sessions without a corresponding Sleep row as `sleep not recorded`. Sport
|
sessions without a corresponding Sleep row as `sleep not recorded`. Sport
|
||||||
creates no Sleep event and changes no sleep total, session, schema, or sync
|
creates no Sleep event and changes no sleep total, session, schema, or sync
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import type { SleepEntry, SleepSnapshot } from "../api/sleepDiary";
|
import type { SleepEntry, SleepSnapshot } from "../api/sleepDiary";
|
||||||
import { buildSleepDiaryPdf } from "./sleepDiaryPdf";
|
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 timestamp = (date: string, time: string) => `${date}T${time}:00+02:00`;
|
||||||
|
|
||||||
const completeEntry = (index: number): SleepEntry => {
|
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 => ({
|
const snapshot = (count: number): SleepSnapshot => ({
|
||||||
api_version: 1,
|
api_version: 1,
|
||||||
entries: Array.from({ length: count }, (_, index) => completeEntry(index)),
|
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 () => {
|
it("draws a proportional 45-minute long-awakening interval between sleep ranges", async () => {
|
||||||
const source = await buildSleepDiaryPdf(snapshot(1), "fr").text();
|
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(
|
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(firstSleep).toBeGreaterThan(-1);
|
||||||
expect(longAwakening).toBeGreaterThan(firstSleep);
|
expect(longAwakening).toBeGreaterThan(firstSleep);
|
||||||
expect(secondSleep).toBeGreaterThan(longAwakening);
|
expect(secondSleep).toBeGreaterThan(longAwakening);
|
||||||
|
|
@ -225,13 +227,75 @@ describe("sleep diary PDF", () => {
|
||||||
it("paginates fourteen, twenty-one and thirty days", async () => {
|
it("paginates fourteen, twenty-one and thirty days", async () => {
|
||||||
for (const count of [14, 21, 30]) {
|
for (const count of [14, 21, 30]) {
|
||||||
const source = await buildSleepDiaryPdf(snapshot(count), "en").text();
|
const source = await buildSleepDiaryPdf(snapshot(count), "en").text();
|
||||||
expect((source.match(/\/Type \/Page /g) ?? []).length).toBe(
|
const pageCount = (source.match(/\/Type \/Page /g) ?? []).length;
|
||||||
Math.ceil(count / 9),
|
expect(pageCount).toBeGreaterThan(Math.ceil(count / 9));
|
||||||
);
|
expect(pageCount).toBeLessThan(count);
|
||||||
expect(source).toContain("OBSERVATIONS");
|
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 () => {
|
it("prints factual sport bands and a separate legible listing without changing sleep totals", async () => {
|
||||||
const sessions = [
|
const sessions = [
|
||||||
{ identity: "se_morning", session_type: "training", label: "Musculation",
|
{ identity: "se_morning", session_type: "training", label: "Musculation",
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,136 @@ const pdfString = (value: string) =>
|
||||||
const text = (x: number, y: number, size: number, 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`;
|
`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 compactDate = (isoDate: string) => {
|
||||||
const [year, month, day] = isoDate.split("-");
|
const [year, month, day] = isoDate.split("-");
|
||||||
return `${day}/${month}/${year}`;
|
return `${day}/${month}/${year}`;
|
||||||
|
|
@ -113,25 +243,28 @@ const pointMarker = (type: SleepEventType) => {
|
||||||
|
|
||||||
function row(
|
function row(
|
||||||
stream: string[],
|
stream: string[],
|
||||||
entry: SleepEntry,
|
fragment: RowFragment,
|
||||||
y: number,
|
y: number,
|
||||||
language: Language,
|
language: Language,
|
||||||
sport: readonly SportSession[],
|
sport: readonly SportSession[],
|
||||||
) {
|
) {
|
||||||
|
const { entry, lines, first, height } = fragment;
|
||||||
const projection = projectSleepTimeline(entry);
|
const projection = projectSleepTimeline(entry);
|
||||||
const window = sleepRowWindow(entry);
|
const window = sleepRowWindow(entry);
|
||||||
const timelineX = 112;
|
const timelineX = layout.timelineX;
|
||||||
const width = 500;
|
const width = layout.timelineWidth;
|
||||||
stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`);
|
stream.push(`0.7 G ${layout.left} ${y - height} ${layout.right - layout.left} ${height} re S\n`);
|
||||||
stream.push(text(33, y - 13, 5, compactDate(entry.night_start_date)));
|
stream.push(text(layout.dateX, y - 13, 5, compactDate(entry.night_start_date)));
|
||||||
stream.push(
|
stream.push(
|
||||||
text(
|
text(
|
||||||
33,
|
layout.dateX,
|
||||||
y - 23,
|
y - 23,
|
||||||
5,
|
5,
|
||||||
`${language === "fr" ? "au" : "to"} ${compactDate(entry.night_end_date)}`,
|
`${language === "fr" ? "au" : "to"} ${compactDate(entry.night_end_date)}`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (!first) stream.push(text(layout.dateX, y - 34, 6, language === "fr" ? "suite" : "continued"));
|
||||||
|
if (first) {
|
||||||
for (let hour = 0; hour <= 24; hour++) {
|
for (let hour = 0; hour <= 24; hour++) {
|
||||||
const x = timelineX + (hour / 24) * width;
|
const x = timelineX + (hour / 24) * width;
|
||||||
stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`);
|
stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`);
|
||||||
|
|
@ -184,26 +317,9 @@ function row(
|
||||||
timelinePosition(intake.taken_at, window.start, window.end) * width;
|
timelinePosition(intake.taken_at, window.start, window.end) * width;
|
||||||
stream.push(text(x, y - 36, 6, "M"));
|
stream.push(text(x, y - 36, 6, "M"));
|
||||||
});
|
});
|
||||||
stream.push(text(620, y - 16, 7, entry.sleep_quality ?? "-"));
|
stream.push(text(layout.qualityX[0], y - 16, 7, entry.sleep_quality ?? "-"));
|
||||||
stream.push(text(658, y - 16, 7, entry.wake_quality ?? "-"));
|
stream.push(text(layout.qualityX[1], y - 16, 7, entry.wake_quality ?? "-"));
|
||||||
const medicationText = entry.intakes
|
stream.push(text(layout.qualityX[2], y - 16, 7, entry.day_form ?? "-"));
|
||||||
.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) => {
|
projectSportSegments(sport, window).forEach((segment) => {
|
||||||
const x = timelineX + segment.left * width;
|
const x = timelineX + segment.left * width;
|
||||||
const segmentWidth = segment.end === null ? 2 : Math.max(2, segment.width * width);
|
const segmentWidth = segment.end === null ? 2 : Math.max(2, segment.width * width);
|
||||||
|
|
@ -213,6 +329,12 @@ function row(
|
||||||
`${segmentWidth.toFixed(2)} 5 re B Q\n`);
|
`${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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function summaryLine(snapshot: SleepSnapshot, language: Language) {
|
function summaryLine(snapshot: SleepSnapshot, language: Language) {
|
||||||
if (language === "fr")
|
if (language === "fr")
|
||||||
|
|
@ -236,7 +358,7 @@ function summaryLine(snapshot: SleepSnapshot, language: Language) {
|
||||||
|
|
||||||
function page(
|
function page(
|
||||||
snapshot: SleepSnapshot,
|
snapshot: SleepSnapshot,
|
||||||
entries: SleepEntry[],
|
fragments: RowFragment[],
|
||||||
index: number,
|
index: number,
|
||||||
count: number,
|
count: number,
|
||||||
language: Language,
|
language: Language,
|
||||||
|
|
@ -255,29 +377,31 @@ function page(
|
||||||
text(740, 565, 7, `${index + 1}/${count}`),
|
text(740, 565, 7, `${index + 1}/${count}`),
|
||||||
text(30, 548, 7, "DATE"),
|
text(30, 548, 7, "DATE"),
|
||||||
];
|
];
|
||||||
for (let hour = 0; hour <= 24; hour++)
|
for (let hour = 0; hour < 24; hour++)
|
||||||
stream.push(
|
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") {
|
if (language === "fr") {
|
||||||
stream.push(text(616, 552, 4, "QUALITÉ DU"));
|
stream.push(text(layout.qualityX[0], 552, 4, "QUALITÉ DU"));
|
||||||
stream.push(text(616, 545, 4, "SOMMEIL"));
|
stream.push(text(layout.qualityX[0], 545, 4, "SOMMEIL"));
|
||||||
stream.push(text(654, 552, 4, "QUALITÉ DU"));
|
stream.push(text(layout.qualityX[1], 552, 4, "QUALITÉ DU"));
|
||||||
stream.push(text(654, 545, 4, "RÉVEIL"));
|
stream.push(text(layout.qualityX[1], 545, 4, "RÉVEIL"));
|
||||||
stream.push(text(692, 552, 4, "FORME DE LA"));
|
stream.push(text(layout.qualityX[2], 552, 4, "FORME DE LA"));
|
||||||
stream.push(text(692, 545, 4, "JOURNÉE"));
|
stream.push(text(layout.qualityX[2], 545, 4, "JOURNÉE"));
|
||||||
stream.push(text(728, 552, 4, "TRAITEMENT ET"));
|
stream.push(text(layout.notesX, 552, 4, "TRAITEMENT ET"));
|
||||||
stream.push(text(728, 545, 4, "REMARQUES"));
|
stream.push(text(layout.notesX, 545, 4, "REMARQUES"));
|
||||||
} else {
|
} else {
|
||||||
stream.push(text(616, 552, 4, "SLEEP QUALITY"));
|
stream.push(text(layout.qualityX[0], 552, 4, "SLEEP QUALITY"));
|
||||||
stream.push(text(654, 552, 4, "WAKE QUALITY"));
|
stream.push(text(layout.qualityX[1], 552, 4, "WAKE QUALITY"));
|
||||||
stream.push(text(692, 552, 4, "DAY FORM"));
|
stream.push(text(layout.qualityX[2], 552, 4, "DAY FORM"));
|
||||||
stream.push(text(728, 552, 4, "TREATMENT / NOTES"));
|
stream.push(text(layout.notesX, 552, 4, "TREATMENT / NOTES"));
|
||||||
}
|
}
|
||||||
entries.forEach((entry, rowIndex) =>
|
let rowTop = layout.rowTop;
|
||||||
row(stream, entry, 538 - rowIndex * 40, language, sport),
|
fragments.forEach((fragment) => {
|
||||||
);
|
row(stream, fragment, rowTop, language, sport);
|
||||||
const y = 520 - entries.length * 40;
|
rowTop -= fragment.height;
|
||||||
|
});
|
||||||
|
const y = 120;
|
||||||
stream.push(text(30, y, 9, "OBSERVATIONS"));
|
stream.push(text(30, y, 9, "OBSERVATIONS"));
|
||||||
stream.push(text(30, y - 14, 6, summaryLine(snapshot, language)));
|
stream.push(text(30, y - 14, 6, summaryLine(snapshot, language)));
|
||||||
const unvalidated = snapshot.entries.filter(
|
const unvalidated = snapshot.entries.filter(
|
||||||
|
|
@ -296,19 +420,6 @@ function page(
|
||||||
: `WARNING: ${plural(unvalidated, "unvalidated day", "unvalidated days")} in this export.`,
|
: `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") {
|
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(text(30, 38, 6, "Bleu : séance de sport effectuée (horaires enregistrés ; détails en fin de rapport)."));
|
||||||
stream.push(
|
stream.push(
|
||||||
|
|
@ -398,10 +509,7 @@ export function buildSleepDiaryPdf(
|
||||||
reportStart = snapshot.entries[0]?.night_start_date ?? "—",
|
reportStart = snapshot.entries[0]?.night_start_date ?? "—",
|
||||||
reportEnd = snapshot.entries[snapshot.entries.length - 1]?.night_start_date ?? "—",
|
reportEnd = snapshot.entries[snapshot.entries.length - 1]?.night_start_date ?? "—",
|
||||||
): Blob {
|
): Blob {
|
||||||
const groups = Array.from(
|
const groups = paginate(snapshot.entries, language);
|
||||||
{ length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) },
|
|
||||||
(_, index) => snapshot.entries.slice(index * 9, (index + 1) * 9),
|
|
||||||
);
|
|
||||||
const sportGroups = Array.from({ length: Math.ceil(sport.length / 28) },
|
const sportGroups = Array.from({ length: Math.ceil(sport.length / 28) },
|
||||||
(_, index) => sport.slice(index * 28, (index + 1) * 28));
|
(_, index) => sport.slice(index * 28, (index + 1) * 28));
|
||||||
const reportEndTime = Date.parse(`${reportEnd}T12:00:00Z`);
|
const reportEndTime = Date.parse(`${reportEnd}T12:00:00Z`);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue