diff --git a/CHANGELOG.md b/CHANGELOG.md
index f991c19..40e1cfa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
## 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
Sleep agenda/PDF overlay after the Program calendar package was built from
`3a4ec34`, before their commits `c2935b8` and `e2c07f1`. The development
diff --git a/docs/current_state.md b/docs/current_state.md
index 3bd7cdf..17bbe07 100644
--- a/docs/current_state.md
+++ b/docs/current_state.md
@@ -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
and download instead share an explicit inclusive range over night start dates,
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`
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
diff --git a/docs/sleep_diary.md b/docs/sleep_diary.md
index 43a93ca..9480dfd 100644
--- a/docs/sleep_diary.md
+++ b/docs/sleep_diary.md
@@ -1,7 +1,7 @@
# Sleep Diary: capture, review and PDF
-This guide describes the Web and Android behavior in the audited `f4ddcfd`
-0.1.7 development source. The [current state](current_state.md) owns rollout
+This guide describes the Web and Android behavior in the 0.1.7 development
+source. The [current state](current_state.md) owns rollout
and validation status; [synchronization exchange](sync_exchange.md) owns the
versioned Sleep companion. Sleep facts and medication intakes are not a medical
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,
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
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
@@ -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
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
recent reference. They do not infer sleep stages, causes, readiness or medical
conditions. The source-backed projection and evidence limits are summarized in
diff --git a/docs/tests.md b/docs/tests.md
index a8678e5..acc06d2 100644
--- a/docs/tests.md
+++ b/docs/tests.md
@@ -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
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
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
intersection/pagination checks, Web pagination and failure-state tests, and
diff --git a/web/src/report/sleepDiaryPdf.test.ts b/web/src/report/sleepDiaryPdf.test.ts
index ea2039c..62b87d0 100644
--- a/web/src/report/sleepDiaryPdf.test.ts
+++ b/web/src/report/sleepDiaryPdf.test.ts
@@ -134,7 +134,7 @@ describe("sleep diary PDF", () => {
expect(new TextDecoder().decode(bytes.slice(0, 8))).toBe("%PDF-1.4");
expect(
await textualContent(buildSleepDiaryPdf(snapshot(7), "fr")),
- ).toContain("AGENDA TRAINLOG");
+ ).toContain("Trainlog — Agenda sommeil");
});
it("fully localizes the complete French document", async () => {
@@ -158,7 +158,7 @@ describe("sleep diary PDF", () => {
"Demi-sommeil",
"1 nuit",
"Sommeil déclaré : 7 h 15 min",
- "AVERTISSEMENT : 1 jour non validé dans cet export.",
+ "Trainlog — Agenda sommeil",
"20/09/2026",
"au 21/09/2026",
"loxapine",
@@ -167,6 +167,7 @@ describe("sleep diary PDF", () => {
"37,5 mg",
])
expect(content).toContain(expected);
+ expect(content).not.toContain("non validé");
for (const forbidden of [
"SLEEP",
@@ -198,16 +199,48 @@ describe("sleep diary PDF", () => {
).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"));
expect(content).toContain("2 nuits");
- expect(content).toContain(
- "AVERTISSEMENT : 2 jours non validés dans cet export.",
- );
+ expect(content).not.toContain("non validé");
+ expect(content).not.toContain("validation");
expect(content).not.toContain("jour(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 () => {
const content = await textualContent(buildSleepDiaryPdf(snapshot(1), "fr"));
expect(content).toContain("venlafaxine — 75");
@@ -220,12 +253,13 @@ describe("sleep diary PDF", () => {
expect(content).toContain("WAKE QUALITY");
expect(content).toContain("Long awakening");
expect(content).toContain("Medication intake");
+ expect(content).not.toContain("unvalidated day");
expect(content).not.toContain("QUALITÉ");
expect(content).not.toContain("Long réveil");
});
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 pageCount = (source.match(/\/Type \/Page /g) ?? []).length;
expect(pageCount).toBeGreaterThan(Math.ceil(count / 9));
@@ -280,8 +314,8 @@ describe("sleep diary PDF", () => {
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]) {
+ 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, 28, 30]) {
for (const language of ["fr", "en"] as const) {
const data = snapshot(count);
const source = await buildSleepDiaryPdf(data, language).text();
@@ -310,6 +344,7 @@ describe("sleep diary PDF", () => {
expect(content).toContain("Musculation");
expect(content).toContain("05:00");
expect(content).toContain("Étirements");
+ expect(content.indexOf("Étirements")).toBeLessThan(content.indexOf("Musculation"));
expect(content).toContain("sommeil non renseigné");
expect(content).toContain("1 nuit");
expect((source.match(/\/Type \/Page /g) ?? []).length).toBe(2);
diff --git a/web/src/report/sleepDiaryPdf.ts b/web/src/report/sleepDiaryPdf.ts
index d4f9d52..abb3107 100644
--- a/web/src/report/sleepDiaryPdf.ts
+++ b/web/src/report/sleepDiaryPdf.ts
@@ -84,8 +84,14 @@ const pdfString = (value: string) =>
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 text = (x: number, y: number, size: number, value: string,
+ 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
// 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[] = [];
if (entry.intakes.length) {
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 ? "" :
` — ${formatDose(intake.dose_value)}${intake.dose_unit ? ` ${intake.dose_unit}` : ""}`;
const quantity = intake.quantity > 1 ? ` ×${intake.quantity}` : "";
@@ -218,6 +228,7 @@ function paginate(entries: readonly SleepEntry[], language: Language): RowFragme
}
const compactDate = (isoDate: string) => {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) return isoDate;
const [year, month, day] = isoDate.split("-");
return `${day}/${month}/${year}`;
};
@@ -247,14 +258,18 @@ function row(
y: number,
language: Language,
sport: readonly SportSession[],
+ shaded: boolean,
) {
const { entry, lines, first, height } = fragment;
const projection = projectSleepTimeline(entry);
const window = sleepRowWindow(entry);
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)));
+ if (shaded) stream.push(`q 0.975 0.974 0.985 rg ${layout.left} ${y - height} ` +
+ `${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(
text(
layout.dateX,
@@ -332,10 +347,31 @@ function row(
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));
+ 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) {
if (language === "fr")
return [
@@ -363,20 +399,13 @@ function page(
count: number,
language: Language,
sport: readonly SportSession[],
+ first: string,
+ last: string,
+ generatedAt: Date,
) {
- 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"),
- ];
+ const stream = reportHeader(language === "fr" ? "Trainlog — Agenda sommeil" :
+ "Trainlog — Sleep diary", index, count, language, first, last, generatedAt, 540);
+ stream.push(text(30, 548, 7, "DATE", pdfAccent));
for (let hour = 0; hour < 24; hour++)
stream.push(
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"));
}
let rowTop = layout.rowTop;
- fragments.forEach((fragment) => {
- row(stream, fragment, rowTop, language, sport);
+ fragments.forEach((fragment, fragmentIndex) => {
+ row(stream, fragment, rowTop, language, sport, fragmentIndex % 2 === 0);
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(
- (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") {
stream.push(text(30, 38, 6, "Bleu : séance de sport effectuée (horaires enregistrés ; détails en fin de rapport)."));
stream.push(
@@ -468,19 +481,17 @@ function sportPage(
language: Language,
first: string,
last: string,
+ generatedAt: Date,
): string {
const fr = language === "fr";
- const stream = [
- "0 G 0 g\n",
- text(30, 565, 14, fr ? "SÉANCES DE SPORT EFFECTUÉES" : "COMPLETED SPORT SESSIONS"),
- text(740, 565, 7, `${index + 1}/${count}`),
- text(30, 547, 7, `${fr ? "Période des lignes" : "Row period"} : ${first} 18:00 - ${last} 18:00`),
- text(30, 532, 7, fr
+ const stream = reportHeader(fr ? "Trainlog — Séances de sport" :
+ "Trainlog — Sport sessions", index, count, language, first, last, generatedAt);
+ stream.push(text(30, 539, 8, fr ? "SÉANCES EFFECTUÉES" : "COMPLETED SESSIONS", pdfAccent));
+ stream.push(text(30, 526, 7, fr
? "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) => {
- const y = 510 - rowIndex * 16;
+ const y = 505 - rowIndex * 16;
const start = Date.parse(session.started_at);
const end = session.ended_at === null ? NaN : Date.parse(session.ended_at);
const validEnd = Number.isFinite(start) && Number.isFinite(end) && end > start;
@@ -506,23 +517,34 @@ export function buildSleepDiaryPdf(
snapshot: SleepSnapshot,
language: Language,
sport: readonly SportSession[] = [],
- reportStart = snapshot.entries[0]?.night_start_date ?? "—",
- reportEnd = snapshot.entries[snapshot.entries.length - 1]?.night_start_date ?? "—",
+ reportStart?: string,
+ reportEnd?: string,
+ generatedAt = new Date(),
): Blob {
- 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`);
+ // CONTRACT: reverse only day/appendix presentation order. Timed details in
+ // each night continue to use the shared chronological projection.
+ const orderedEntries = [...snapshot.entries].sort((left, right) =>
+ 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)
? new Date(reportEndTime + 86400000).toISOString().slice(0, 10)
- : reportEnd;
+ : last;
const contents = [
...groups.map((entries, index) => page(snapshot, entries, index,
- groups.length + sportGroups.length, language, sport)),
- ...sportGroups.map((sessions, index) => sportPage(sessions, snapshot.entries,
+ groups.length + sportGroups.length, language, orderedSport, first, last, generatedAt)),
+ ...sportGroups.map((sessions, index) => sportPage(sessions, orderedEntries,
groups.length + index, groups.length + sportGroups.length, language,
- reportStart,
- snapshot.entries[snapshot.entries.length - 1]?.night_end_date ?? reportEndExclusive)),
+ first,
+ orderedEntries[0]?.night_end_date ?? reportEndExclusive, generatedAt)),
];
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
diff --git a/web/src/routes/SleepDiaryWorkspace.test.tsx b/web/src/routes/SleepDiaryWorkspace.test.tsx
index 54305b0..e451ca1 100644
--- a/web/src/routes/SleepDiaryWorkspace.test.tsx
+++ b/web/src/routes/SleepDiaryWorkspace.test.tsx
@@ -1061,9 +1061,9 @@ describe("SleepDiaryWorkspace", () => {
await screen.findByTestId("sleep-agenda-event-bed_time"),
).toBeInTheDocument();
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(
screen.getByLabelText("Traitement et remarques particulières"),
).toHaveValue("Observation du jour A");
@@ -1104,7 +1104,7 @@ describe("SleepDiaryWorkspace", () => {
screen.getByLabelText("Traitement et remarques particulières"),
).toHaveValue("Observation du jour A");
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
- "Journée validée",
+ "Prête à synchroniser",
);
});
@@ -1137,7 +1137,7 @@ describe("SleepDiaryWorkspace", () => {
).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 = {
...dayAEntry(),
publication_status: "draft",
@@ -1159,15 +1159,18 @@ describe("SleepDiaryWorkspace", () => {
render();
expect(
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"));
await waitFor(() =>
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(
"Enregistré localement",
);
diff --git a/web/src/routes/SleepDiaryWorkspace.tsx b/web/src/routes/SleepDiaryWorkspace.tsx
index 52279e3..cb16280 100644
--- a/web/src/routes/SleepDiaryWorkspace.tsx
+++ b/web/src/routes/SleepDiaryWorkspace.tsx
@@ -118,7 +118,7 @@ const copy = {
end: "Fin",
dose: "Dosage",
modify: "Modifier",
- validate: "Valider la journée",
+ validate: "Autoriser la synchronisation",
continue: "Continuer",
saved: "Enregistré localement",
saving: "Enregistrement…",
@@ -132,10 +132,10 @@ const copy = {
medicationEmpty: "Aucun médicament enregistré.",
notesPlaceholder: "Notes utiles pour cette nuit…",
statuses: {
- draft: "Brouillon",
- ready: "Journée validée",
- synchronized: "Journée validée",
- modified: "Modifiée",
+ draft: "Brouillon local",
+ ready: "Prête à synchroniser",
+ synchronized: "Synchronisée",
+ modified: "Modifiée depuis la synchronisation",
},
qualities: ["Très bon", "Bon", "Moyen", "Mauvais", "Très mauvais"],
},
@@ -201,13 +201,13 @@ const copy = {
deleteEntry: "Delete entry",
medicationEmpty: "No saved medications.",
notesPlaceholder: "Useful notes about this night…",
- validate: "Validate day",
+ validate: "Allow synchronization",
continue: "Continue",
statuses: {
- draft: "Draft",
- ready: "Day validated",
- synchronized: "Day validated",
- modified: "Modified",
+ draft: "Local draft",
+ ready: "Ready to sync",
+ synchronized: "Synchronized",
+ modified: "Modified since synchronization",
},
qualities: ["Very good", "Good", "Average", "Bad", "Very bad"],
},
@@ -774,8 +774,9 @@ export function SleepDiaryWorkspace({
agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ??
activeEntry ??
agendaEntries[agendaEntries.length - 1];
- // INVARIANT: presentation sorting and slicing never mutate or narrow the
- // ascending, deduplicated collection used by PDF and sport projections.
+ // INVARIANT: agenda sorting and slicing never mutate or narrow the
+ // deduplicated collection used by PDF and sport projections. The PDF owns
+ // its own newest-first presentation order.
const recentEntries = [...agendaEntries].sort((left, right) =>
right.night_start_date.localeCompare(left.night_start_date) ||
left.entry_id.localeCompare(right.entry_id));
@@ -1005,7 +1006,7 @@ export function SleepDiaryWorkspace({
{t.night} {displayDate(entry.night_start_date, language)}
- {t.statuses[entry.publication_status]} · {t.continue}
+ {t.continue}
@@ -1618,6 +1619,8 @@ export function SleepDiaryWorkspace({
disabled={pending}
onClick={() => void validate()}
>
+ {/* CONTRACT: this action publishes the exact saved revision for
+ synchronization; its durable publication state stays intact. */}
{t.validate}
{draft.expected_revision && (