fix(web): polish sleep PDF order and publication labels

This commit is contained in:
fy59 2026-10-07 10:17:31 +02:00
parent a5175fc0ef
commit 3dcb7f6c97
8 changed files with 188 additions and 88 deletions

View file

@ -2,6 +2,14 @@
## Unreleased — 0.1.7 ## Unreleased — 0.1.7
- Present Sleep PDF nights and the completed-sport appendix newest first while
preserving chronological timed content within each night. Add a restrained
printable header, period and generation date, and pale row separation.
Remove unhelpful validation labels from agenda rows and PDF warnings; the
editor now names the exact-revision synchronization publication action and
retains its meaningful state. Sleep facts, schema, sport rules, Android and
synchronization behavior are unchanged.
- Recovered the Android-matched Web body figures and factual completed-sport - Recovered the Android-matched Web body figures and factual completed-sport
Sleep agenda/PDF overlay after the Program calendar package was built from Sleep agenda/PDF overlay after the Program calendar package was built from
`3a4ec34`, before their commits `c2935b8` and `e2c07f1`. The development `3a4ec34`, before their commits `c2935b8` and `e2c07f1`. The development

View file

@ -262,6 +262,13 @@ presentation-only and atomically drives the detail dates, editor content, and
full-width heart-rate detail; it performs no database mutation. PDF preview full-width heart-rate detail; it performs no database mutation. PDF preview
and download instead share an explicit inclusive range over night start dates, and download instead share an explicit inclusive range over night start dates,
initialized to the loaded history bounds and independent of detail selection. initialized to the loaded history bounds and independent of detail selection.
The vector PDF now presents selected nights newest first, while retaining
chronological timed content within each night; its sport appendix is also
newest first. A light header, period, generation date and pale alternating rows
improve print hierarchy in color and grayscale. Agenda rows and PDF omit the
publication/validation labels. The editor still exposes the exact-revision
publication action and state required for synchronization; PDF selection and
local editing do not depend on that state.
The completed-sport overlay reads factual `sessions.started_at` and `ended_at` The completed-sport overlay reads factual `sessions.started_at` and `ended_at`
from desktop history through a read-only, paginated Web endpoint. from desktop history through a read-only, paginated Web endpoint.
Each row uses its one Sleep-owned 18:00 offset and 24 elapsed-hour window for Each row uses its one Sleep-owned 18:00 offset and 24 elapsed-hour window for

View file

@ -1,7 +1,7 @@
# Sleep Diary: capture, review and PDF # Sleep Diary: capture, review and PDF
This guide describes the Web and Android behavior in the audited `f4ddcfd` This guide describes the Web and Android behavior in the 0.1.7 development
0.1.7 development source. The [current state](current_state.md) owns rollout source. The [current state](current_state.md) owns rollout
and validation status; [synchronization exchange](sync_exchange.md) owns the and validation status; [synchronization exchange](sync_exchange.md) owns the
versioned Sleep companion. Sleep facts and medication intakes are not a medical versioned Sleep companion. Sleep facts and medication intakes are not a medical
interpretation or an automatic training decision. interpretation or an automatic training decision.
@ -37,6 +37,15 @@ their own period control. Select a night to inspect and edit that entry and
its measured heart-rate timeline. Paging does not discard the selected night, its measured heart-rate timeline. Paging does not discard the selected night,
editor state, loaded statistics or PDF range. editor state, loaded statistics or PDF range.
Saving a Web entry creates a durable local revision. The editor's **Allow
synchronization** action publishes that exact revision; only a current
published revision is eligible for the Sleep synchronization companion. A
subsequent edit needs a new publication action. The editor shows local draft,
ready, synchronized or modified state so this choice remains explicit. Agenda
rows and the PDF do not display publication status because it does not describe
the recorded sleep or affect PDF inclusion. The publication API and durable
state are unchanged.
Blue sport bands come from completed session start/end history, not the Blue sport bands come from completed session start/end history, not the
Program plan. A missing or invalid end is shown as a point marker with no Program plan. A missing or invalid end is shown as a point marker with no
invented duration. The Web agenda omits the redundant sport text list; the PDF invented duration. The Web agenda omits the redundant sport text list; the PDF
@ -56,6 +65,14 @@ matching Sleep row. Treatments and remarks wrap by available width and continue
on dated pages instead of truncating silently. Review the chosen range before on dated pages instead of truncating silently. Review the chosen range before
export; a PDF is a local copy of potentially sensitive information. export; a PDF is a local copy of potentially sensitive information.
The PDF prints the newest selected night first and orders its sport appendix by
actual session start, newest first. Events and medication intakes within each
night keep their chronological order. A light, printable header shows the
selected period and generation date; alternating pale rows and a restrained
accent distinguish nights. Publication status and global validation warnings
are omitted from the report. This presentation does not alter Sleep facts,
timing, sport attribution, synchronization or the database schema.
The descriptive heart-rate rise/fall overlays compare measured BPM with a The descriptive heart-rate rise/fall overlays compare measured BPM with a
recent reference. They do not infer sleep stages, causes, readiness or medical recent reference. They do not infer sleep stages, causes, readiness or medical
conditions. The source-backed projection and evidence limits are summarized in conditions. The source-backed projection and evidence limits are summarized in

View file

@ -409,6 +409,11 @@ subset summaries cannot inherit facts from unselected nights. The same real
Firefox scenario exports a seven-night French PDF from the embedded production Firefox scenario exports a seven-night French PDF from the embedded production
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
with `pdftoppm` for retained visual inspection. with `pdftoppm` for retained visual inspection.
The 0.1.7 PDF polish tests also assert newest-first night and sport appendix
order, unchanged chronological intervals and medication intakes within a night,
7/14/21/28-night pagination, and absence of global validation warnings. Web
tests keep the exact-revision publication action and editing while excluding
publication labels from agenda rows.
The completed-sport Sleep overlay is covered by C read-only history The completed-sport Sleep overlay is covered by C read-only history
intersection/pagination checks, Web pagination and failure-state tests, and intersection/pagination checks, Web pagination and failure-state tests, and

View file

@ -134,7 +134,7 @@ describe("sleep diary PDF", () => {
expect(new TextDecoder().decode(bytes.slice(0, 8))).toBe("%PDF-1.4"); expect(new TextDecoder().decode(bytes.slice(0, 8))).toBe("%PDF-1.4");
expect( expect(
await textualContent(buildSleepDiaryPdf(snapshot(7), "fr")), await textualContent(buildSleepDiaryPdf(snapshot(7), "fr")),
).toContain("AGENDA TRAINLOG"); ).toContain("Trainlog — Agenda sommeil");
}); });
it("fully localizes the complete French document", async () => { it("fully localizes the complete French document", async () => {
@ -158,7 +158,7 @@ describe("sleep diary PDF", () => {
"Demi-sommeil", "Demi-sommeil",
"1 nuit", "1 nuit",
"Sommeil déclaré : 7 h 15 min", "Sommeil déclaré : 7 h 15 min",
"AVERTISSEMENT : 1 jour non validé dans cet export.", "Trainlog — Agenda sommeil",
"20/09/2026", "20/09/2026",
"au 21/09/2026", "au 21/09/2026",
"loxapine", "loxapine",
@ -167,6 +167,7 @@ describe("sleep diary PDF", () => {
"37,5 mg", "37,5 mg",
]) ])
expect(content).toContain(expected); expect(content).toContain(expected);
expect(content).not.toContain("non validé");
for (const forbidden of [ for (const forbidden of [
"SLEEP", "SLEEP",
@ -198,16 +199,48 @@ describe("sleep diary PDF", () => {
).not.toContain("AWAKE"); ).not.toContain("AWAKE");
}); });
it("uses correct French plurals for multiple draft days", async () => { it("keeps the factual summary without a global validation warning", async () => {
const content = await textualContent(buildSleepDiaryPdf(snapshot(2), "fr")); const content = await textualContent(buildSleepDiaryPdf(snapshot(2), "fr"));
expect(content).toContain("2 nuits"); expect(content).toContain("2 nuits");
expect(content).toContain( expect(content).not.toContain("non validé");
"AVERTISSEMENT : 2 jours non validés dans cet export.", expect(content).not.toContain("validation");
);
expect(content).not.toContain("jour(s)"); expect(content).not.toContain("jour(s)");
expect(content).not.toContain("nuit(s)"); expect(content).not.toContain("nuit(s)");
}); });
it("starts with the newest night even when input is oldest first", async () => {
const data = snapshot(3);
data.entries.reverse();
const source = await buildSleepDiaryPdf(data, "fr", [], undefined, undefined,
new Date("2026-10-07T12:00:00Z")).text();
const dates = ["20/09/2026", "19/09/2026", "18/09/2026"];
const positions = dates.map((date) => source.indexOf(`(${date})`));
expect(positions.every((position) => position > -1)).toBe(true);
expect(positions).toEqual([...positions].sort((a, b) => a - b));
const content = await textualContent(buildSleepDiaryPdf(data, "fr", [],
undefined, undefined, new Date("2026-10-07T12:00:00Z")));
expect(content).toContain("Période : 18/09/2026 – 20/09/2026");
expect(content).toContain("Généré le 07/10/2026");
});
it("keeps timed content within one night chronological", async () => {
const data = snapshot(1);
data.entries[0].events.reverse();
data.entries[0].intakes = [
{ ...data.entries[0].intakes[0], intake_id: "late", medication_name: "LateMed",
taken_at: timestamp("2026-09-21", "07:30") },
{ ...data.entries[0].intakes[1], intake_id: "early", medication_name: "EarlyMed",
taken_at: timestamp("2026-09-20", "21:30") },
];
const source = await buildSleepDiaryPdf(data, "fr").text();
const content = await textualContent(buildSleepDiaryPdf(data, "fr"));
expect(content.indexOf("EarlyMed")).toBeLessThan(content.indexOf("LateMed"));
expect(source.indexOf("0.75 g 183.58 509.00")).toBeLessThan(
source.indexOf("0.98 0.70 0.53 rg 255.25 509.00"));
expect(source.indexOf("0.98 0.70 0.53 rg 255.25 509.00")).toBeLessThan(
source.indexOf("0.75 g 268.69 509.00"));
});
it("prints structured medication quantity without replacing the unit dose", async () => { it("prints structured medication quantity without replacing the unit dose", async () => {
const content = await textualContent(buildSleepDiaryPdf(snapshot(1), "fr")); const content = await textualContent(buildSleepDiaryPdf(snapshot(1), "fr"));
expect(content).toContain("venlafaxine — 75"); expect(content).toContain("venlafaxine — 75");
@ -220,12 +253,13 @@ describe("sleep diary PDF", () => {
expect(content).toContain("WAKE QUALITY"); expect(content).toContain("WAKE QUALITY");
expect(content).toContain("Long awakening"); expect(content).toContain("Long awakening");
expect(content).toContain("Medication intake"); expect(content).toContain("Medication intake");
expect(content).not.toContain("unvalidated day");
expect(content).not.toContain("QUALITÉ"); expect(content).not.toContain("QUALITÉ");
expect(content).not.toContain("Long réveil"); expect(content).not.toContain("Long réveil");
}); });
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, 28, 30]) {
const source = await buildSleepDiaryPdf(snapshot(count), "en").text(); const source = await buildSleepDiaryPdf(snapshot(count), "en").text();
const pageCount = (source.match(/\/Type \/Page /g) ?? []).length; const pageCount = (source.match(/\/Type \/Page /g) ?? []).length;
expect(pageCount).toBeGreaterThan(Math.ceil(count / 9)); expect(pageCount).toBeGreaterThan(Math.ceil(count / 9));
@ -280,8 +314,8 @@ describe("sleep diary PDF", () => {
expect(content).toContain("Short note."); expect(content).toContain("Short note.");
}); });
it("paginates 7, 14, 21 and 30 selected nights in FR and EN without empty pages", async () => { it("paginates 7, 14, 21, 28 and 30 selected nights in FR and EN without empty pages", async () => {
for (const count of [7, 14, 21, 30]) { for (const count of [7, 14, 21, 28, 30]) {
for (const language of ["fr", "en"] as const) { for (const language of ["fr", "en"] as const) {
const data = snapshot(count); const data = snapshot(count);
const source = await buildSleepDiaryPdf(data, language).text(); const source = await buildSleepDiaryPdf(data, language).text();
@ -310,6 +344,7 @@ describe("sleep diary PDF", () => {
expect(content).toContain("Musculation"); expect(content).toContain("Musculation");
expect(content).toContain("05:00"); expect(content).toContain("05:00");
expect(content).toContain("Étirements"); expect(content).toContain("Étirements");
expect(content.indexOf("Étirements")).toBeLessThan(content.indexOf("Musculation"));
expect(content).toContain("sommeil non renseigné"); expect(content).toContain("sommeil non renseigné");
expect(content).toContain("1 nuit"); expect(content).toContain("1 nuit");
expect((source.match(/\/Type \/Page /g) ?? []).length).toBe(2); expect((source.match(/\/Type \/Page /g) ?? []).length).toBe(2);

View file

@ -84,8 +84,14 @@ const pdfString = (value: string) =>
return `\\${byte.toString(8).padStart(3, "0")}`; return `\\${byte.toString(8).padStart(3, "0")}`;
}).join(""); }).join("");
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`; color = "0 g") =>
`${color} BT /F1 ${size} Tf ${x.toFixed(1)} ${y.toFixed(1)} Td (${pdfString(value)}) Tj ET\n`;
// CONTRACT: restrained accents retain contrast when the report is printed in
// grayscale; the blue sport mark remains outlined independently of color.
const pdfAccent = "0.25 0.23 0.40 rg";
const pdfMuted = "0.33 0.34 0.40 rg";
// CONTRACT: all timeline marks, grid ticks, headers, and sport bands use these // 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. // same coordinates. The notes column gets over twice its former 84 pt width.
@ -167,7 +173,11 @@ function noteLines(entry: SleepEntry, language: Language): NoteLine[] {
const lines: NoteLine[] = []; const lines: NoteLine[] = [];
if (entry.intakes.length) { if (entry.intakes.length) {
lines.push({ value: language === "fr" ? "TRAITEMENTS" : "TREATMENTS" }); lines.push({ value: language === "fr" ? "TRAITEMENTS" : "TREATMENTS" });
for (const intake of entry.intakes) { // INVARIANT: a night is newest-first only as a whole. Its timed content
// remains chronological even if a caller supplied unsorted intake rows.
for (const intake of [...entry.intakes].sort((left, right) =>
Date.parse(left.taken_at) - Date.parse(right.taken_at) ||
left.intake_id.localeCompare(right.intake_id))) {
const dose = intake.dose_value === null ? "" : const dose = intake.dose_value === null ? "" :
` — ${formatDose(intake.dose_value)}${intake.dose_unit ? ` ${intake.dose_unit}` : ""}`; ` — ${formatDose(intake.dose_value)}${intake.dose_unit ? ` ${intake.dose_unit}` : ""}`;
const quantity = intake.quantity > 1 ? ` ×${intake.quantity}` : ""; const quantity = intake.quantity > 1 ? ` ×${intake.quantity}` : "";
@ -218,6 +228,7 @@ function paginate(entries: readonly SleepEntry[], language: Language): RowFragme
} }
const compactDate = (isoDate: string) => { const compactDate = (isoDate: string) => {
if (!/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) return isoDate;
const [year, month, day] = isoDate.split("-"); const [year, month, day] = isoDate.split("-");
return `${day}/${month}/${year}`; return `${day}/${month}/${year}`;
}; };
@ -247,14 +258,18 @@ function row(
y: number, y: number,
language: Language, language: Language,
sport: readonly SportSession[], sport: readonly SportSession[],
shaded: boolean,
) { ) {
const { entry, lines, first, height } = fragment; const { entry, lines, first, height } = fragment;
const projection = projectSleepTimeline(entry); const projection = projectSleepTimeline(entry);
const window = sleepRowWindow(entry); const window = sleepRowWindow(entry);
const timelineX = layout.timelineX; const timelineX = layout.timelineX;
const width = layout.timelineWidth; const width = layout.timelineWidth;
stream.push(`0.7 G ${layout.left} ${y - height} ${layout.right - layout.left} ${height} re S\n`); if (shaded) stream.push(`q 0.975 0.974 0.985 rg ${layout.left} ${y - height} ` +
stream.push(text(layout.dateX, y - 13, 5, compactDate(entry.night_start_date))); `${layout.right - layout.left} ${height} re f Q\n`);
stream.push(`0.75 G ${layout.left} ${y - height} ${layout.right - layout.left} ${height} re S\n`);
stream.push(`q 0.40 0.35 0.57 rg ${layout.left} ${y - height} 2 ${height} re f Q\n`);
stream.push(text(layout.dateX, y - 13, 6.5, compactDate(entry.night_start_date), pdfAccent));
stream.push( stream.push(
text( text(
layout.dateX, layout.dateX,
@ -332,10 +347,31 @@ function row(
lines.forEach((line, index) => { lines.forEach((line, index) => {
const baseline = y - 12 - index * layout.noteLeading; 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`); 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)); const heading = line.value === "TRAITEMENTS" || line.value === "TREATMENTS" ||
line.value === "REMARQUES" || line.value === "NOTES";
stream.push(text(layout.notesX + layout.notePadding, baseline, layout.noteFont,
line.value, heading ? pdfAccent : "0 g"));
}); });
} }
function reportHeader(title: string, index: number, count: number,
language: Language, first: string, last: string, generatedAt: Date,
dividerY = 555): string[] {
const generationDate = new Intl.DateTimeFormat(language === "fr" ? "fr-FR" : "en-GB",
{ year: "numeric", month: "2-digit", day: "2-digit" }).format(generatedAt);
return [
"0 G 0 g\n",
text(30, 575, 15, title, pdfAccent),
text(760, 575, 7, `${index + 1}/${count}`, pdfMuted),
text(30, 560, 7,
`${language === "fr" ? "Période" : "Period"} : ${compactDate(first)} – ${compactDate(last)}`,
pdfMuted),
text(700, 560, 6,
`${language === "fr" ? "Généré le" : "Generated"} ${generationDate}`, pdfMuted),
`0.55 0.51 0.69 RG 30 ${dividerY} m 812 ${dividerY} l S 0 G\n`,
];
}
function summaryLine(snapshot: SleepSnapshot, language: Language) { function summaryLine(snapshot: SleepSnapshot, language: Language) {
if (language === "fr") if (language === "fr")
return [ return [
@ -363,20 +399,13 @@ function page(
count: number, count: number,
language: Language, language: Language,
sport: readonly SportSession[], sport: readonly SportSession[],
first: string,
last: string,
generatedAt: Date,
) { ) {
const stream = [ const stream = reportHeader(language === "fr" ? "Trainlog — Agenda sommeil" :
"0 G 0 g\n", "Trainlog — Sleep diary", index, count, language, first, last, generatedAt, 540);
text( stream.push(text(30, 548, 7, "DATE", pdfAccent));
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++) for (let hour = 0; hour < 24; hour++)
stream.push( stream.push(
text(layout.timelineX + (hour / 24) * layout.timelineWidth, 548, 4, String((18 + hour) % 24)), text(layout.timelineX + (hour / 24) * layout.timelineWidth, 548, 4, String((18 + hour) % 24)),
@ -397,29 +426,13 @@ function page(
stream.push(text(layout.notesX, 552, 4, "TREATMENT / NOTES")); stream.push(text(layout.notesX, 552, 4, "TREATMENT / NOTES"));
} }
let rowTop = layout.rowTop; let rowTop = layout.rowTop;
fragments.forEach((fragment) => { fragments.forEach((fragment, fragmentIndex) => {
row(stream, fragment, rowTop, language, sport); row(stream, fragment, rowTop, language, sport, fragmentIndex % 2 === 0);
rowTop -= fragment.height; rowTop -= fragment.height;
}); });
const y = 120; 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(
(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.`,
),
);
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(
@ -468,19 +481,17 @@ function sportPage(
language: Language, language: Language,
first: string, first: string,
last: string, last: string,
generatedAt: Date,
): string { ): string {
const fr = language === "fr"; const fr = language === "fr";
const stream = [ const stream = reportHeader(fr ? "Trainlog — Séances de sport" :
"0 G 0 g\n", "Trainlog — Sport sessions", index, count, language, first, last, generatedAt);
text(30, 565, 14, fr ? "SÉANCES DE SPORT EFFECTUÉES" : "COMPLETED SPORT SESSIONS"), stream.push(text(30, 539, 8, fr ? "SÉANCES EFFECTUÉES" : "COMPLETED SESSIONS", pdfAccent));
text(740, 565, 7, `${index + 1}/${count}`), stream.push(text(30, 526, 7, fr
text(30, 547, 7, `${fr ? "Période des lignes" : "Row period"} : ${first} 18:00 - ${last} 18:00`),
text(30, 532, 7, fr
? "Horaires enregistrés ; la durée est celle de la séance. Aucun lien médical déduit." ? "Horaires enregistrés ; la durée est celle de la séance. Aucun lien médical déduit."
: "Recorded times and session duration. No medical conclusion is inferred."), : "Recorded times and session duration. No medical conclusion is inferred."));
];
sessions.forEach((session, rowIndex) => { sessions.forEach((session, rowIndex) => {
const y = 510 - rowIndex * 16; const y = 505 - rowIndex * 16;
const start = Date.parse(session.started_at); const start = Date.parse(session.started_at);
const end = session.ended_at === null ? NaN : Date.parse(session.ended_at); const end = session.ended_at === null ? NaN : Date.parse(session.ended_at);
const validEnd = Number.isFinite(start) && Number.isFinite(end) && end > start; const validEnd = Number.isFinite(start) && Number.isFinite(end) && end > start;
@ -506,23 +517,34 @@ export function buildSleepDiaryPdf(
snapshot: SleepSnapshot, snapshot: SleepSnapshot,
language: Language, language: Language,
sport: readonly SportSession[] = [], sport: readonly SportSession[] = [],
reportStart = snapshot.entries[0]?.night_start_date ?? "—", reportStart?: string,
reportEnd = snapshot.entries[snapshot.entries.length - 1]?.night_start_date ?? "—", reportEnd?: string,
generatedAt = new Date(),
): Blob { ): Blob {
const groups = paginate(snapshot.entries, language); // CONTRACT: reverse only day/appendix presentation order. Timed details in
const sportGroups = Array.from({ length: Math.ceil(sport.length / 28) }, // each night continue to use the shared chronological projection.
(_, index) => sport.slice(index * 28, (index + 1) * 28)); const orderedEntries = [...snapshot.entries].sort((left, right) =>
const reportEndTime = Date.parse(`${reportEnd}T12:00:00Z`); right.night_start_date.localeCompare(left.night_start_date) ||
left.entry_id.localeCompare(right.entry_id));
const orderedSport = [...sport].sort((left, right) =>
Date.parse(right.started_at) - Date.parse(left.started_at) ||
left.identity.localeCompare(right.identity));
const first = reportStart ?? orderedEntries[orderedEntries.length - 1]?.night_start_date ?? "—";
const last = reportEnd ?? orderedEntries[0]?.night_start_date ?? "—";
const groups = paginate(orderedEntries, language);
const sportGroups = Array.from({ length: Math.ceil(orderedSport.length / 28) },
(_, index) => orderedSport.slice(index * 28, (index + 1) * 28));
const reportEndTime = Date.parse(`${last}T12:00:00Z`);
const reportEndExclusive = Number.isFinite(reportEndTime) const reportEndExclusive = Number.isFinite(reportEndTime)
? new Date(reportEndTime + 86400000).toISOString().slice(0, 10) ? new Date(reportEndTime + 86400000).toISOString().slice(0, 10)
: reportEnd; : last;
const contents = [ const contents = [
...groups.map((entries, index) => page(snapshot, entries, index, ...groups.map((entries, index) => page(snapshot, entries, index,
groups.length + sportGroups.length, language, sport)), groups.length + sportGroups.length, language, orderedSport, first, last, generatedAt)),
...sportGroups.map((sessions, index) => sportPage(sessions, snapshot.entries, ...sportGroups.map((sessions, index) => sportPage(sessions, orderedEntries,
groups.length + index, groups.length + sportGroups.length, language, groups.length + index, groups.length + sportGroups.length, language,
reportStart, first,
snapshot.entries[snapshot.entries.length - 1]?.night_end_date ?? reportEndExclusive)), orderedEntries[0]?.night_end_date ?? reportEndExclusive, generatedAt)),
]; ];
const objects = [ const objects = [
"<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Catalog /Pages 2 0 R >>",

View file

@ -1061,9 +1061,9 @@ describe("SleepDiaryWorkspace", () => {
await screen.findByTestId("sleep-agenda-event-bed_time"), await screen.findByTestId("sleep-agenda-event-bed_time"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent( expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
"Journée validée", "Prête à synchroniser",
); );
expect(screen.queryByText("Prête à synchroniser")).toBeNull(); expect(screen.queryByText("Journée validée")).toBeNull();
expect( expect(
screen.getByLabelText("Traitement et remarques particulières"), screen.getByLabelText("Traitement et remarques particulières"),
).toHaveValue("Observation du jour A"); ).toHaveValue("Observation du jour A");
@ -1104,7 +1104,7 @@ describe("SleepDiaryWorkspace", () => {
screen.getByLabelText("Traitement et remarques particulières"), screen.getByLabelText("Traitement et remarques particulières"),
).toHaveValue("Observation du jour A"); ).toHaveValue("Observation du jour A");
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent( expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
"Journée validée", "Prête à synchroniser",
); );
}); });
@ -1137,7 +1137,7 @@ describe("SleepDiaryWorkspace", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("presents validation as a local completion state without transport wording", async () => { it("keeps the publication action while removing validation labels from agenda rows", async () => {
let current: SleepEntry = { let current: SleepEntry = {
...dayAEntry(), ...dayAEntry(),
publication_status: "draft", publication_status: "draft",
@ -1159,15 +1159,18 @@ describe("SleepDiaryWorkspace", () => {
render(<SleepDiaryWorkspace period="30d" language="fr" />); render(<SleepDiaryWorkspace period="30d" language="fr" />);
expect( expect(
await screen.findByTestId("sleep-publication-status"), await screen.findByTestId("sleep-publication-status"),
).toHaveTextContent("Brouillon"); ).toHaveTextContent("Brouillon local");
expect(screen.getByTestId(`sleep-agenda-row-${current.entry_id}`)).not.toHaveTextContent("Brouillon");
fireEvent.click(screen.getByTestId("sleep-validate-day")); fireEvent.click(screen.getByTestId("sleep-validate-day"));
await waitFor(() => await waitFor(() =>
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent( expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
"Journée validée", "Prête à synchroniser",
), ),
); );
expect(screen.queryByText("Prête à synchroniser")).toBeNull(); expect(api.validateSleepEntry).toHaveBeenCalled();
expect(screen.getByTestId(`sleep-agenda-row-${current.entry_id}`)).not.toHaveTextContent("synchroniser");
expect(screen.queryByText("Journée validée")).toBeNull();
expect(screen.getByTestId("sleep-save-state")).toHaveTextContent( expect(screen.getByTestId("sleep-save-state")).toHaveTextContent(
"Enregistré localement", "Enregistré localement",
); );

View file

@ -118,7 +118,7 @@ const copy = {
end: "Fin", end: "Fin",
dose: "Dosage", dose: "Dosage",
modify: "Modifier", modify: "Modifier",
validate: "Valider la journée", validate: "Autoriser la synchronisation",
continue: "Continuer", continue: "Continuer",
saved: "Enregistré localement", saved: "Enregistré localement",
saving: "Enregistrement…", saving: "Enregistrement…",
@ -132,10 +132,10 @@ const copy = {
medicationEmpty: "Aucun médicament enregistré.", medicationEmpty: "Aucun médicament enregistré.",
notesPlaceholder: "Notes utiles pour cette nuit…", notesPlaceholder: "Notes utiles pour cette nuit…",
statuses: { statuses: {
draft: "Brouillon", draft: "Brouillon local",
ready: "Journée validée", ready: "Prête à synchroniser",
synchronized: "Journée validée", synchronized: "Synchronisée",
modified: "Modifiée", modified: "Modifiée depuis la synchronisation",
}, },
qualities: ["Très bon", "Bon", "Moyen", "Mauvais", "Très mauvais"], qualities: ["Très bon", "Bon", "Moyen", "Mauvais", "Très mauvais"],
}, },
@ -201,13 +201,13 @@ const copy = {
deleteEntry: "Delete entry", deleteEntry: "Delete entry",
medicationEmpty: "No saved medications.", medicationEmpty: "No saved medications.",
notesPlaceholder: "Useful notes about this night…", notesPlaceholder: "Useful notes about this night…",
validate: "Validate day", validate: "Allow synchronization",
continue: "Continue", continue: "Continue",
statuses: { statuses: {
draft: "Draft", draft: "Local draft",
ready: "Day validated", ready: "Ready to sync",
synchronized: "Day validated", synchronized: "Synchronized",
modified: "Modified", modified: "Modified since synchronization",
}, },
qualities: ["Very good", "Good", "Average", "Bad", "Very bad"], qualities: ["Very good", "Good", "Average", "Bad", "Very bad"],
}, },
@ -774,8 +774,9 @@ export function SleepDiaryWorkspace({
agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ?? agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ??
activeEntry ?? activeEntry ??
agendaEntries[agendaEntries.length - 1]; agendaEntries[agendaEntries.length - 1];
// INVARIANT: presentation sorting and slicing never mutate or narrow the // INVARIANT: agenda sorting and slicing never mutate or narrow the
// ascending, deduplicated collection used by PDF and sport projections. // deduplicated collection used by PDF and sport projections. The PDF owns
// its own newest-first presentation order.
const recentEntries = [...agendaEntries].sort((left, right) => const recentEntries = [...agendaEntries].sort((left, right) =>
right.night_start_date.localeCompare(left.night_start_date) || right.night_start_date.localeCompare(left.night_start_date) ||
left.entry_id.localeCompare(right.entry_id)); left.entry_id.localeCompare(right.entry_id));
@ -1005,7 +1006,7 @@ export function SleepDiaryWorkspace({
<strong> <strong>
{t.night} {displayDate(entry.night_start_date, language)} {t.night} {displayDate(entry.night_start_date, language)}
<small> <small>
{t.statuses[entry.publication_status]} · {t.continue} {t.continue}
</small> </small>
</strong> </strong>
<span className="sleep-track"> <span className="sleep-track">
@ -1618,6 +1619,8 @@ export function SleepDiaryWorkspace({
disabled={pending} disabled={pending}
onClick={() => void validate()} onClick={() => void validate()}
> >
{/* CONTRACT: this action publishes the exact saved revision for
synchronization; its durable publication state stays intact. */}
{t.validate} {t.validate}
</button> </button>
{draft.expected_revision && ( {draft.expected_revision && (