trainlog/web/src/report/sleepDiaryPdf.ts
2026-09-21 17:09:24 +02:00

377 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type {
SleepEntry,
SleepEventType,
SleepSnapshot,
} from "../api/sleepDiary";
import { formatDose, formatDuration } from "../dashboard/dashboardFormat";
type Language = "fr" | "en";
const eventLabels: Record<Language, Record<SleepEventType, string>> = {
fr: {
bed_time: "Mise au lit",
final_get_up: "Lever",
night_get_up: "Lever nocturne",
sleep: "SOMMEIL",
nap: "SIESTE",
long_awake: "LONG RÉVEIL",
half_sleep: "DEMI-SOMMEIL",
daytime_sleepiness: "Somnolence",
},
en: {
bed_time: "Bedtime",
final_get_up: "Final get-up",
night_get_up: "Night get-up",
sleep: "SLEEP",
nap: "NAP",
long_awake: "LONG AWAKENING",
half_sleep: "HALF-SLEEP",
daytime_sleepiness: "Sleepiness",
},
};
/*
* WHY: the local vector report uses PDF's built-in Helvetica and must retain
* French accents without embedding a second font asset.
* CONTRACT: user-facing strings are emitted as WinAnsi octal bytes and the
* font dictionary declares the same encoding.
* INVARIANT: content streams stay ASCII, so byte offsets and lengths remain
* deterministic under TextEncoder.
*/
const winAnsi: Record<string, number> = {
"€": 0x80,
"‚": 0x82,
ƒ: 0x83,
"„": 0x84,
"…": 0x85,
"†": 0x86,
"‡": 0x87,
ˆ: 0x88,
"‰": 0x89,
Š: 0x8a,
"‹": 0x8b,
Œ: 0x8c,
Ž: 0x8e,
"‘": 0x91,
"’": 0x92,
"“": 0x93,
"”": 0x94,
"•": 0x95,
"–": 0x96,
"—": 0x97,
"˜": 0x98,
"™": 0x99,
š: 0x9a,
"›": 0x9b,
œ: 0x9c,
ž: 0x9e,
Ÿ: 0x9f,
};
const pdfString = (value: string) =>
Array.from(value, (character) => {
const codePoint = character.codePointAt(0) ?? 0x3f;
if (character === "\\" || character === "(" || character === ")")
return `\\${character}`;
if (codePoint >= 0x20 && codePoint <= 0x7e) return character;
const byte =
codePoint >= 0xa0 && codePoint <= 0xff
? codePoint
: (winAnsi[character] ?? 0x3f);
return `\\${byte.toString(8).padStart(3, "0")}`;
}).join("");
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`;
const minute = (iso: string, night: string) =>
Math.max(
0,
Math.min(
1440,
(new Date(iso).getTime() - new Date(`${night}T18:00:00`).getTime()) /
60000,
),
);
const compactDate = (isoDate: string) => {
const [year, month, day] = isoDate.split("-");
return `${day}/${month}/${year}`;
};
const plural = (count: number, singular: string, pluralForm: string) =>
`${count} ${count === 1 ? singular : pluralForm}`;
const duration = (seconds: number, language: Language) => {
if (language === "fr")
return formatDuration(seconds).replace(/ h 00 min$/, " h");
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0)
return `${hours} h${minutes > 0 ? ` ${minutes.toString().padStart(2, "0")} min` : ""}`;
return `${minutes} min`;
};
const pointMarker = (type: SleepEventType) => {
if (type === "bed_time") return "v";
if (type === "final_get_up" || type === "night_get_up") return "^";
return type === "daytime_sleepiness" ? "S" : "";
};
function row(
stream: string[],
entry: SleepEntry,
y: number,
language: Language,
) {
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)));
stream.push(
text(
33,
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`);
}
entry.events.forEach((event) => {
const x =
timelineX +
(minute(event.start_at, entry.night_start_date) / 1440) * width;
if (event.end_at) {
// INVARIANT: every interval keeps its canonical absolute start/end on
// the shared 18:00-to-18:00 axis; long awakenings therefore interrupt
// rather than replace or merge the adjacent sleep ranges.
const eventWidth = Math.max(
2,
timelineX +
(minute(event.end_at, entry.night_start_date) / 1440) * width -
x,
);
const shade =
event.type === "sleep"
? 0.75
: event.type === "long_awake"
? 0.5
: event.type === "half_sleep"
? 0.87
: 0.93;
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)
stream.push(text(x + 2, y - 23, 5, eventLabels[language][event.type]));
} else {
stream.push(text(x, y - 25, 8, pointMarker(event.type)));
}
});
entry.intakes.forEach((intake) => {
const x =
timelineX +
(minute(intake.taken_at, entry.night_start_date) / 1440) * 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 ?? ""}`
}`,
)
.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)));
}
function summaryLine(snapshot: SleepSnapshot, language: Language) {
if (language === "fr")
return [
plural(snapshot.summary.nights, "nuit", "nuits"),
`Sommeil déclaré : ${duration(snapshot.summary.sleep_duration_seconds, language)}`,
`Longs réveils : ${snapshot.summary.long_awake_count}`,
`Siestes : ${snapshot.summary.nap_count}`,
`Somnolences : ${snapshot.summary.sleepiness_count}`,
`Prises de médicaments : ${snapshot.summary.intake_count}`,
].join(" · ");
return [
plural(snapshot.summary.nights, "night", "nights"),
`Declared sleep: ${duration(snapshot.summary.sleep_duration_seconds, language)}`,
`Long awakenings: ${snapshot.summary.long_awake_count}`,
`Naps: ${snapshot.summary.nap_count}`,
`Sleepiness: ${snapshot.summary.sleepiness_count}`,
`Medication intakes: ${snapshot.summary.intake_count}`,
].join(" · ");
}
function page(
snapshot: SleepSnapshot,
entries: SleepEntry[],
index: number,
count: number,
language: Language,
) {
const stream = [
"0 G 0 g\n",
text(
30,
565,
14,
language === "fr"
? "AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL"
: "TRAINLOG SLEEP AND ALERTNESS DIARY",
),
text(740, 565, 7, `${index + 1}/${count}`),
text(30, 548, 7, "DATE"),
];
for (let hour = 0; hour <= 24; hour++)
stream.push(
text(112 + (hour / 24) * 500, 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"));
} 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"));
}
entries.forEach((entry, rowIndex) =>
row(stream, entry, 538 - rowIndex * 40, language),
);
const y = 520 - entries.length * 40;
stream.push(text(30, y, 9, "OBSERVATIONS"));
stream.push(text(30, y - 14, 6, summaryLine(snapshot, language)));
const unvalidated = snapshot.entries.filter(
(entry) =>
entry.publication_status === "draft" ||
entry.publication_status === "modified",
).length;
if (unvalidated > 0)
stream.push(
text(
30,
y - 24,
6,
language === "fr"
? `AVERTISSEMENT : ${plural(unvalidated, "jour non validé", "jours non validés")} dans cet 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") {
stream.push(
text(
30,
28,
6,
"Légende : v Mise au lit ; ^ Lever ; ^ Lever nocturne ; S Somnolence ; M Prise de médicament.",
),
);
stream.push(
text(
30,
18,
6,
"Sommeil : plage de sommeil ; Sieste : plage de sieste ; Long réveil : interruption prolongée ; Demi-sommeil : plage de demi-sommeil.",
),
);
} else {
stream.push(
text(
30,
28,
6,
"Legend: v Bedtime; ^ Final get-up; ^ Night get-up; S Sleepiness; M Medication intake.",
),
);
stream.push(
text(
30,
18,
6,
"Sleep: sleep range; Nap: nap range; Long awakening: prolonged interruption; Half-sleep: half-sleep range.",
),
);
}
return stream.join("");
}
/** Builds a deterministic local vector document from the Core snapshot, never from screen pixels. */
export function buildSleepDiaryPdf(
snapshot: SleepSnapshot,
language: Language,
): 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 objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
`<< /Type /Pages /Kids [${groups.map((_, index) => `${4 + index * 2} 0 R`).join(" ")}] /Count ${groups.length} >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
];
groups.forEach((entries, index) => {
const content = page(snapshot, entries, index, groups.length, language);
objects.push(
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents ${5 + index * 2} 0 R >>`,
`<< /Length ${new TextEncoder().encode(content).length} >>\nstream\n${content}endstream`,
);
});
let output = "%PDF-1.4\n%Trainlog\n";
const offsets: number[] = [];
objects.forEach((object, index) => {
offsets.push(new TextEncoder().encode(output).length);
output += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
const xref = new TextEncoder().encode(output).length;
output += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map((value) => `${String(value).padStart(10, "0")} 00000 n \n`).join("")}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return new Blob([new TextEncoder().encode(output)], {
type: "application/pdf",
});
}
export function presentSleepDiaryPdf(blob: Blob, download: boolean) {
const url = URL.createObjectURL(blob);
if (download) {
const link = document.createElement("a");
link.href = url;
link.download = "trainlog-sleep-diary.pdf";
link.click();
} else window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}