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

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

View file

@ -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

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

View file

@ -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);

View file

@ -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 >>",

View file

@ -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(<SleepDiaryWorkspace period="30d" language="fr" />);
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",
);

View file

@ -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({
<strong>
{t.night} {displayDate(entry.night_start_date, language)}
<small>
{t.statuses[entry.publication_status]} · {t.continue}
{t.continue}
</small>
</strong>
<span className="sleep-track">
@ -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}
</button>
{draft.expected_revision && (