fix(web): polish sleep PDF order and publication labels
This commit is contained in:
parent
a5175fc0ef
commit
3dcb7f6c97
8 changed files with 188 additions and 88 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 >>",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue