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 && (