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
|
## Unreleased — 0.1.7
|
||||||
|
|
||||||
|
- Present Sleep PDF nights and the completed-sport appendix newest first while
|
||||||
|
preserving chronological timed content within each night. Add a restrained
|
||||||
|
printable header, period and generation date, and pale row separation.
|
||||||
|
Remove unhelpful validation labels from agenda rows and PDF warnings; the
|
||||||
|
editor now names the exact-revision synchronization publication action and
|
||||||
|
retains its meaningful state. Sleep facts, schema, sport rules, Android and
|
||||||
|
synchronization behavior are unchanged.
|
||||||
|
|
||||||
- Recovered the Android-matched Web body figures and factual completed-sport
|
- Recovered the Android-matched Web body figures and factual completed-sport
|
||||||
Sleep agenda/PDF overlay after the Program calendar package was built from
|
Sleep agenda/PDF overlay after the Program calendar package was built from
|
||||||
`3a4ec34`, before their commits `c2935b8` and `e2c07f1`. The development
|
`3a4ec34`, before their commits `c2935b8` and `e2c07f1`. The development
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,13 @@ presentation-only and atomically drives the detail dates, editor content, and
|
||||||
full-width heart-rate detail; it performs no database mutation. PDF preview
|
full-width heart-rate detail; it performs no database mutation. PDF preview
|
||||||
and download instead share an explicit inclusive range over night start dates,
|
and download instead share an explicit inclusive range over night start dates,
|
||||||
initialized to the loaded history bounds and independent of detail selection.
|
initialized to the loaded history bounds and independent of detail selection.
|
||||||
|
The vector PDF now presents selected nights newest first, while retaining
|
||||||
|
chronological timed content within each night; its sport appendix is also
|
||||||
|
newest first. A light header, period, generation date and pale alternating rows
|
||||||
|
improve print hierarchy in color and grayscale. Agenda rows and PDF omit the
|
||||||
|
publication/validation labels. The editor still exposes the exact-revision
|
||||||
|
publication action and state required for synchronization; PDF selection and
|
||||||
|
local editing do not depend on that state.
|
||||||
The completed-sport overlay reads factual `sessions.started_at` and `ended_at`
|
The completed-sport overlay reads factual `sessions.started_at` and `ended_at`
|
||||||
from desktop history through a read-only, paginated Web endpoint.
|
from desktop history through a read-only, paginated Web endpoint.
|
||||||
Each row uses its one Sleep-owned 18:00 offset and 24 elapsed-hour window for
|
Each row uses its one Sleep-owned 18:00 offset and 24 elapsed-hour window for
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Sleep Diary: capture, review and PDF
|
# Sleep Diary: capture, review and PDF
|
||||||
|
|
||||||
This guide describes the Web and Android behavior in the audited `f4ddcfd`
|
This guide describes the Web and Android behavior in the 0.1.7 development
|
||||||
0.1.7 development source. The [current state](current_state.md) owns rollout
|
source. The [current state](current_state.md) owns rollout
|
||||||
and validation status; [synchronization exchange](sync_exchange.md) owns the
|
and validation status; [synchronization exchange](sync_exchange.md) owns the
|
||||||
versioned Sleep companion. Sleep facts and medication intakes are not a medical
|
versioned Sleep companion. Sleep facts and medication intakes are not a medical
|
||||||
interpretation or an automatic training decision.
|
interpretation or an automatic training decision.
|
||||||
|
|
@ -37,6 +37,15 @@ their own period control. Select a night to inspect and edit that entry and
|
||||||
its measured heart-rate timeline. Paging does not discard the selected night,
|
its measured heart-rate timeline. Paging does not discard the selected night,
|
||||||
editor state, loaded statistics or PDF range.
|
editor state, loaded statistics or PDF range.
|
||||||
|
|
||||||
|
Saving a Web entry creates a durable local revision. The editor's **Allow
|
||||||
|
synchronization** action publishes that exact revision; only a current
|
||||||
|
published revision is eligible for the Sleep synchronization companion. A
|
||||||
|
subsequent edit needs a new publication action. The editor shows local draft,
|
||||||
|
ready, synchronized or modified state so this choice remains explicit. Agenda
|
||||||
|
rows and the PDF do not display publication status because it does not describe
|
||||||
|
the recorded sleep or affect PDF inclusion. The publication API and durable
|
||||||
|
state are unchanged.
|
||||||
|
|
||||||
Blue sport bands come from completed session start/end history, not the
|
Blue sport bands come from completed session start/end history, not the
|
||||||
Program plan. A missing or invalid end is shown as a point marker with no
|
Program plan. A missing or invalid end is shown as a point marker with no
|
||||||
invented duration. The Web agenda omits the redundant sport text list; the PDF
|
invented duration. The Web agenda omits the redundant sport text list; the PDF
|
||||||
|
|
@ -56,6 +65,14 @@ matching Sleep row. Treatments and remarks wrap by available width and continue
|
||||||
on dated pages instead of truncating silently. Review the chosen range before
|
on dated pages instead of truncating silently. Review the chosen range before
|
||||||
export; a PDF is a local copy of potentially sensitive information.
|
export; a PDF is a local copy of potentially sensitive information.
|
||||||
|
|
||||||
|
The PDF prints the newest selected night first and orders its sport appendix by
|
||||||
|
actual session start, newest first. Events and medication intakes within each
|
||||||
|
night keep their chronological order. A light, printable header shows the
|
||||||
|
selected period and generation date; alternating pale rows and a restrained
|
||||||
|
accent distinguish nights. Publication status and global validation warnings
|
||||||
|
are omitted from the report. This presentation does not alter Sleep facts,
|
||||||
|
timing, sport attribution, synchronization or the database schema.
|
||||||
|
|
||||||
The descriptive heart-rate rise/fall overlays compare measured BPM with a
|
The descriptive heart-rate rise/fall overlays compare measured BPM with a
|
||||||
recent reference. They do not infer sleep stages, causes, readiness or medical
|
recent reference. They do not infer sleep stages, causes, readiness or medical
|
||||||
conditions. The source-backed projection and evidence limits are summarized in
|
conditions. The source-backed projection and evidence limits are summarized in
|
||||||
|
|
|
||||||
|
|
@ -409,6 +409,11 @@ subset summaries cannot inherit facts from unselected nights. The same real
|
||||||
Firefox scenario exports a seven-night French PDF from the embedded production
|
Firefox scenario exports a seven-night French PDF from the embedded production
|
||||||
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
|
bundle, extracts its text with `pdftotext` and rasterizes its A4 landscape page
|
||||||
with `pdftoppm` for retained visual inspection.
|
with `pdftoppm` for retained visual inspection.
|
||||||
|
The 0.1.7 PDF polish tests also assert newest-first night and sport appendix
|
||||||
|
order, unchanged chronological intervals and medication intakes within a night,
|
||||||
|
7/14/21/28-night pagination, and absence of global validation warnings. Web
|
||||||
|
tests keep the exact-revision publication action and editing while excluding
|
||||||
|
publication labels from agenda rows.
|
||||||
|
|
||||||
The completed-sport Sleep overlay is covered by C read-only history
|
The completed-sport Sleep overlay is covered by C read-only history
|
||||||
intersection/pagination checks, Web pagination and failure-state tests, and
|
intersection/pagination checks, Web pagination and failure-state tests, and
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ describe("sleep diary PDF", () => {
|
||||||
expect(new TextDecoder().decode(bytes.slice(0, 8))).toBe("%PDF-1.4");
|
expect(new TextDecoder().decode(bytes.slice(0, 8))).toBe("%PDF-1.4");
|
||||||
expect(
|
expect(
|
||||||
await textualContent(buildSleepDiaryPdf(snapshot(7), "fr")),
|
await textualContent(buildSleepDiaryPdf(snapshot(7), "fr")),
|
||||||
).toContain("AGENDA TRAINLOG");
|
).toContain("Trainlog — Agenda sommeil");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fully localizes the complete French document", async () => {
|
it("fully localizes the complete French document", async () => {
|
||||||
|
|
@ -158,7 +158,7 @@ describe("sleep diary PDF", () => {
|
||||||
"Demi-sommeil",
|
"Demi-sommeil",
|
||||||
"1 nuit",
|
"1 nuit",
|
||||||
"Sommeil déclaré : 7 h 15 min",
|
"Sommeil déclaré : 7 h 15 min",
|
||||||
"AVERTISSEMENT : 1 jour non validé dans cet export.",
|
"Trainlog — Agenda sommeil",
|
||||||
"20/09/2026",
|
"20/09/2026",
|
||||||
"au 21/09/2026",
|
"au 21/09/2026",
|
||||||
"loxapine",
|
"loxapine",
|
||||||
|
|
@ -167,6 +167,7 @@ describe("sleep diary PDF", () => {
|
||||||
"37,5 mg",
|
"37,5 mg",
|
||||||
])
|
])
|
||||||
expect(content).toContain(expected);
|
expect(content).toContain(expected);
|
||||||
|
expect(content).not.toContain("non validé");
|
||||||
|
|
||||||
for (const forbidden of [
|
for (const forbidden of [
|
||||||
"SLEEP",
|
"SLEEP",
|
||||||
|
|
@ -198,16 +199,48 @@ describe("sleep diary PDF", () => {
|
||||||
).not.toContain("AWAKE");
|
).not.toContain("AWAKE");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses correct French plurals for multiple draft days", async () => {
|
it("keeps the factual summary without a global validation warning", async () => {
|
||||||
const content = await textualContent(buildSleepDiaryPdf(snapshot(2), "fr"));
|
const content = await textualContent(buildSleepDiaryPdf(snapshot(2), "fr"));
|
||||||
expect(content).toContain("2 nuits");
|
expect(content).toContain("2 nuits");
|
||||||
expect(content).toContain(
|
expect(content).not.toContain("non validé");
|
||||||
"AVERTISSEMENT : 2 jours non validés dans cet export.",
|
expect(content).not.toContain("validation");
|
||||||
);
|
|
||||||
expect(content).not.toContain("jour(s)");
|
expect(content).not.toContain("jour(s)");
|
||||||
expect(content).not.toContain("nuit(s)");
|
expect(content).not.toContain("nuit(s)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts with the newest night even when input is oldest first", async () => {
|
||||||
|
const data = snapshot(3);
|
||||||
|
data.entries.reverse();
|
||||||
|
const source = await buildSleepDiaryPdf(data, "fr", [], undefined, undefined,
|
||||||
|
new Date("2026-10-07T12:00:00Z")).text();
|
||||||
|
const dates = ["20/09/2026", "19/09/2026", "18/09/2026"];
|
||||||
|
const positions = dates.map((date) => source.indexOf(`(${date})`));
|
||||||
|
expect(positions.every((position) => position > -1)).toBe(true);
|
||||||
|
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
||||||
|
const content = await textualContent(buildSleepDiaryPdf(data, "fr", [],
|
||||||
|
undefined, undefined, new Date("2026-10-07T12:00:00Z")));
|
||||||
|
expect(content).toContain("Période : 18/09/2026 – 20/09/2026");
|
||||||
|
expect(content).toContain("Généré le 07/10/2026");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps timed content within one night chronological", async () => {
|
||||||
|
const data = snapshot(1);
|
||||||
|
data.entries[0].events.reverse();
|
||||||
|
data.entries[0].intakes = [
|
||||||
|
{ ...data.entries[0].intakes[0], intake_id: "late", medication_name: "LateMed",
|
||||||
|
taken_at: timestamp("2026-09-21", "07:30") },
|
||||||
|
{ ...data.entries[0].intakes[1], intake_id: "early", medication_name: "EarlyMed",
|
||||||
|
taken_at: timestamp("2026-09-20", "21:30") },
|
||||||
|
];
|
||||||
|
const source = await buildSleepDiaryPdf(data, "fr").text();
|
||||||
|
const content = await textualContent(buildSleepDiaryPdf(data, "fr"));
|
||||||
|
expect(content.indexOf("EarlyMed")).toBeLessThan(content.indexOf("LateMed"));
|
||||||
|
expect(source.indexOf("0.75 g 183.58 509.00")).toBeLessThan(
|
||||||
|
source.indexOf("0.98 0.70 0.53 rg 255.25 509.00"));
|
||||||
|
expect(source.indexOf("0.98 0.70 0.53 rg 255.25 509.00")).toBeLessThan(
|
||||||
|
source.indexOf("0.75 g 268.69 509.00"));
|
||||||
|
});
|
||||||
|
|
||||||
it("prints structured medication quantity without replacing the unit dose", async () => {
|
it("prints structured medication quantity without replacing the unit dose", async () => {
|
||||||
const content = await textualContent(buildSleepDiaryPdf(snapshot(1), "fr"));
|
const content = await textualContent(buildSleepDiaryPdf(snapshot(1), "fr"));
|
||||||
expect(content).toContain("venlafaxine — 75");
|
expect(content).toContain("venlafaxine — 75");
|
||||||
|
|
@ -220,12 +253,13 @@ describe("sleep diary PDF", () => {
|
||||||
expect(content).toContain("WAKE QUALITY");
|
expect(content).toContain("WAKE QUALITY");
|
||||||
expect(content).toContain("Long awakening");
|
expect(content).toContain("Long awakening");
|
||||||
expect(content).toContain("Medication intake");
|
expect(content).toContain("Medication intake");
|
||||||
|
expect(content).not.toContain("unvalidated day");
|
||||||
expect(content).not.toContain("QUALITÉ");
|
expect(content).not.toContain("QUALITÉ");
|
||||||
expect(content).not.toContain("Long réveil");
|
expect(content).not.toContain("Long réveil");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("paginates fourteen, twenty-one and thirty days", async () => {
|
it("paginates fourteen, twenty-one and thirty days", async () => {
|
||||||
for (const count of [14, 21, 30]) {
|
for (const count of [14, 21, 28, 30]) {
|
||||||
const source = await buildSleepDiaryPdf(snapshot(count), "en").text();
|
const source = await buildSleepDiaryPdf(snapshot(count), "en").text();
|
||||||
const pageCount = (source.match(/\/Type \/Page /g) ?? []).length;
|
const pageCount = (source.match(/\/Type \/Page /g) ?? []).length;
|
||||||
expect(pageCount).toBeGreaterThan(Math.ceil(count / 9));
|
expect(pageCount).toBeGreaterThan(Math.ceil(count / 9));
|
||||||
|
|
@ -280,8 +314,8 @@ describe("sleep diary PDF", () => {
|
||||||
expect(content).toContain("Short note.");
|
expect(content).toContain("Short note.");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("paginates 7, 14, 21 and 30 selected nights in FR and EN without empty pages", async () => {
|
it("paginates 7, 14, 21, 28 and 30 selected nights in FR and EN without empty pages", async () => {
|
||||||
for (const count of [7, 14, 21, 30]) {
|
for (const count of [7, 14, 21, 28, 30]) {
|
||||||
for (const language of ["fr", "en"] as const) {
|
for (const language of ["fr", "en"] as const) {
|
||||||
const data = snapshot(count);
|
const data = snapshot(count);
|
||||||
const source = await buildSleepDiaryPdf(data, language).text();
|
const source = await buildSleepDiaryPdf(data, language).text();
|
||||||
|
|
@ -310,6 +344,7 @@ describe("sleep diary PDF", () => {
|
||||||
expect(content).toContain("Musculation");
|
expect(content).toContain("Musculation");
|
||||||
expect(content).toContain("05:00");
|
expect(content).toContain("05:00");
|
||||||
expect(content).toContain("Étirements");
|
expect(content).toContain("Étirements");
|
||||||
|
expect(content.indexOf("Étirements")).toBeLessThan(content.indexOf("Musculation"));
|
||||||
expect(content).toContain("sommeil non renseigné");
|
expect(content).toContain("sommeil non renseigné");
|
||||||
expect(content).toContain("1 nuit");
|
expect(content).toContain("1 nuit");
|
||||||
expect((source.match(/\/Type \/Page /g) ?? []).length).toBe(2);
|
expect((source.match(/\/Type \/Page /g) ?? []).length).toBe(2);
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,14 @@ const pdfString = (value: string) =>
|
||||||
return `\\${byte.toString(8).padStart(3, "0")}`;
|
return `\\${byte.toString(8).padStart(3, "0")}`;
|
||||||
}).join("");
|
}).join("");
|
||||||
|
|
||||||
const text = (x: number, y: number, size: number, value: string) =>
|
const text = (x: number, y: number, size: number, value: string,
|
||||||
`0 g BT /F1 ${size} Tf ${x.toFixed(1)} ${y.toFixed(1)} Td (${pdfString(value)}) Tj ET\n`;
|
color = "0 g") =>
|
||||||
|
`${color} BT /F1 ${size} Tf ${x.toFixed(1)} ${y.toFixed(1)} Td (${pdfString(value)}) Tj ET\n`;
|
||||||
|
|
||||||
|
// CONTRACT: restrained accents retain contrast when the report is printed in
|
||||||
|
// grayscale; the blue sport mark remains outlined independently of color.
|
||||||
|
const pdfAccent = "0.25 0.23 0.40 rg";
|
||||||
|
const pdfMuted = "0.33 0.34 0.40 rg";
|
||||||
|
|
||||||
// CONTRACT: all timeline marks, grid ticks, headers, and sport bands use these
|
// CONTRACT: all timeline marks, grid ticks, headers, and sport bands use these
|
||||||
// same coordinates. The notes column gets over twice its former 84 pt width.
|
// same coordinates. The notes column gets over twice its former 84 pt width.
|
||||||
|
|
@ -167,7 +173,11 @@ function noteLines(entry: SleepEntry, language: Language): NoteLine[] {
|
||||||
const lines: NoteLine[] = [];
|
const lines: NoteLine[] = [];
|
||||||
if (entry.intakes.length) {
|
if (entry.intakes.length) {
|
||||||
lines.push({ value: language === "fr" ? "TRAITEMENTS" : "TREATMENTS" });
|
lines.push({ value: language === "fr" ? "TRAITEMENTS" : "TREATMENTS" });
|
||||||
for (const intake of entry.intakes) {
|
// INVARIANT: a night is newest-first only as a whole. Its timed content
|
||||||
|
// remains chronological even if a caller supplied unsorted intake rows.
|
||||||
|
for (const intake of [...entry.intakes].sort((left, right) =>
|
||||||
|
Date.parse(left.taken_at) - Date.parse(right.taken_at) ||
|
||||||
|
left.intake_id.localeCompare(right.intake_id))) {
|
||||||
const dose = intake.dose_value === null ? "" :
|
const dose = intake.dose_value === null ? "" :
|
||||||
` — ${formatDose(intake.dose_value)}${intake.dose_unit ? ` ${intake.dose_unit}` : ""}`;
|
` — ${formatDose(intake.dose_value)}${intake.dose_unit ? ` ${intake.dose_unit}` : ""}`;
|
||||||
const quantity = intake.quantity > 1 ? ` ×${intake.quantity}` : "";
|
const quantity = intake.quantity > 1 ? ` ×${intake.quantity}` : "";
|
||||||
|
|
@ -218,6 +228,7 @@ function paginate(entries: readonly SleepEntry[], language: Language): RowFragme
|
||||||
}
|
}
|
||||||
|
|
||||||
const compactDate = (isoDate: string) => {
|
const compactDate = (isoDate: string) => {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) return isoDate;
|
||||||
const [year, month, day] = isoDate.split("-");
|
const [year, month, day] = isoDate.split("-");
|
||||||
return `${day}/${month}/${year}`;
|
return `${day}/${month}/${year}`;
|
||||||
};
|
};
|
||||||
|
|
@ -247,14 +258,18 @@ function row(
|
||||||
y: number,
|
y: number,
|
||||||
language: Language,
|
language: Language,
|
||||||
sport: readonly SportSession[],
|
sport: readonly SportSession[],
|
||||||
|
shaded: boolean,
|
||||||
) {
|
) {
|
||||||
const { entry, lines, first, height } = fragment;
|
const { entry, lines, first, height } = fragment;
|
||||||
const projection = projectSleepTimeline(entry);
|
const projection = projectSleepTimeline(entry);
|
||||||
const window = sleepRowWindow(entry);
|
const window = sleepRowWindow(entry);
|
||||||
const timelineX = layout.timelineX;
|
const timelineX = layout.timelineX;
|
||||||
const width = layout.timelineWidth;
|
const width = layout.timelineWidth;
|
||||||
stream.push(`0.7 G ${layout.left} ${y - height} ${layout.right - layout.left} ${height} re S\n`);
|
if (shaded) stream.push(`q 0.975 0.974 0.985 rg ${layout.left} ${y - height} ` +
|
||||||
stream.push(text(layout.dateX, y - 13, 5, compactDate(entry.night_start_date)));
|
`${layout.right - layout.left} ${height} re f Q\n`);
|
||||||
|
stream.push(`0.75 G ${layout.left} ${y - height} ${layout.right - layout.left} ${height} re S\n`);
|
||||||
|
stream.push(`q 0.40 0.35 0.57 rg ${layout.left} ${y - height} 2 ${height} re f Q\n`);
|
||||||
|
stream.push(text(layout.dateX, y - 13, 6.5, compactDate(entry.night_start_date), pdfAccent));
|
||||||
stream.push(
|
stream.push(
|
||||||
text(
|
text(
|
||||||
layout.dateX,
|
layout.dateX,
|
||||||
|
|
@ -332,10 +347,31 @@ function row(
|
||||||
lines.forEach((line, index) => {
|
lines.forEach((line, index) => {
|
||||||
const baseline = y - 12 - index * layout.noteLeading;
|
const baseline = y - 12 - index * layout.noteLeading;
|
||||||
if (line.separator) stream.push(`0.8 G ${layout.notesX + 3} ${(baseline + 5).toFixed(2)} m ${layout.notesRight - 3} ${(baseline + 5).toFixed(2)} l S 0 G\n`);
|
if (line.separator) stream.push(`0.8 G ${layout.notesX + 3} ${(baseline + 5).toFixed(2)} m ${layout.notesRight - 3} ${(baseline + 5).toFixed(2)} l S 0 G\n`);
|
||||||
stream.push(text(layout.notesX + layout.notePadding, baseline, layout.noteFont, line.value));
|
const heading = line.value === "TRAITEMENTS" || line.value === "TREATMENTS" ||
|
||||||
|
line.value === "REMARQUES" || line.value === "NOTES";
|
||||||
|
stream.push(text(layout.notesX + layout.notePadding, baseline, layout.noteFont,
|
||||||
|
line.value, heading ? pdfAccent : "0 g"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reportHeader(title: string, index: number, count: number,
|
||||||
|
language: Language, first: string, last: string, generatedAt: Date,
|
||||||
|
dividerY = 555): string[] {
|
||||||
|
const generationDate = new Intl.DateTimeFormat(language === "fr" ? "fr-FR" : "en-GB",
|
||||||
|
{ year: "numeric", month: "2-digit", day: "2-digit" }).format(generatedAt);
|
||||||
|
return [
|
||||||
|
"0 G 0 g\n",
|
||||||
|
text(30, 575, 15, title, pdfAccent),
|
||||||
|
text(760, 575, 7, `${index + 1}/${count}`, pdfMuted),
|
||||||
|
text(30, 560, 7,
|
||||||
|
`${language === "fr" ? "Période" : "Period"} : ${compactDate(first)} – ${compactDate(last)}`,
|
||||||
|
pdfMuted),
|
||||||
|
text(700, 560, 6,
|
||||||
|
`${language === "fr" ? "Généré le" : "Generated"} ${generationDate}`, pdfMuted),
|
||||||
|
`0.55 0.51 0.69 RG 30 ${dividerY} m 812 ${dividerY} l S 0 G\n`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function summaryLine(snapshot: SleepSnapshot, language: Language) {
|
function summaryLine(snapshot: SleepSnapshot, language: Language) {
|
||||||
if (language === "fr")
|
if (language === "fr")
|
||||||
return [
|
return [
|
||||||
|
|
@ -363,20 +399,13 @@ function page(
|
||||||
count: number,
|
count: number,
|
||||||
language: Language,
|
language: Language,
|
||||||
sport: readonly SportSession[],
|
sport: readonly SportSession[],
|
||||||
|
first: string,
|
||||||
|
last: string,
|
||||||
|
generatedAt: Date,
|
||||||
) {
|
) {
|
||||||
const stream = [
|
const stream = reportHeader(language === "fr" ? "Trainlog — Agenda sommeil" :
|
||||||
"0 G 0 g\n",
|
"Trainlog — Sleep diary", index, count, language, first, last, generatedAt, 540);
|
||||||
text(
|
stream.push(text(30, 548, 7, "DATE", pdfAccent));
|
||||||
30,
|
|
||||||
565,
|
|
||||||
14,
|
|
||||||
language === "fr"
|
|
||||||
? "AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL"
|
|
||||||
: "TRAINLOG SLEEP AND ALERTNESS DIARY",
|
|
||||||
),
|
|
||||||
text(740, 565, 7, `${index + 1}/${count}`),
|
|
||||||
text(30, 548, 7, "DATE"),
|
|
||||||
];
|
|
||||||
for (let hour = 0; hour < 24; hour++)
|
for (let hour = 0; hour < 24; hour++)
|
||||||
stream.push(
|
stream.push(
|
||||||
text(layout.timelineX + (hour / 24) * layout.timelineWidth, 548, 4, String((18 + hour) % 24)),
|
text(layout.timelineX + (hour / 24) * layout.timelineWidth, 548, 4, String((18 + hour) % 24)),
|
||||||
|
|
@ -397,29 +426,13 @@ function page(
|
||||||
stream.push(text(layout.notesX, 552, 4, "TREATMENT / NOTES"));
|
stream.push(text(layout.notesX, 552, 4, "TREATMENT / NOTES"));
|
||||||
}
|
}
|
||||||
let rowTop = layout.rowTop;
|
let rowTop = layout.rowTop;
|
||||||
fragments.forEach((fragment) => {
|
fragments.forEach((fragment, fragmentIndex) => {
|
||||||
row(stream, fragment, rowTop, language, sport);
|
row(stream, fragment, rowTop, language, sport, fragmentIndex % 2 === 0);
|
||||||
rowTop -= fragment.height;
|
rowTop -= fragment.height;
|
||||||
});
|
});
|
||||||
const y = 120;
|
const y = 120;
|
||||||
stream.push(text(30, y, 9, "OBSERVATIONS"));
|
stream.push(text(30, y, 9, "OBSERVATIONS"));
|
||||||
stream.push(text(30, y - 14, 6, summaryLine(snapshot, language)));
|
stream.push(text(30, y - 14, 6, summaryLine(snapshot, language)));
|
||||||
const unvalidated = snapshot.entries.filter(
|
|
||||||
(entry) =>
|
|
||||||
entry.publication_status === "draft" ||
|
|
||||||
entry.publication_status === "modified",
|
|
||||||
).length;
|
|
||||||
if (unvalidated > 0)
|
|
||||||
stream.push(
|
|
||||||
text(
|
|
||||||
30,
|
|
||||||
y - 24,
|
|
||||||
6,
|
|
||||||
language === "fr"
|
|
||||||
? `AVERTISSEMENT : ${plural(unvalidated, "jour non validé", "jours non validés")} dans cet export.`
|
|
||||||
: `WARNING: ${plural(unvalidated, "unvalidated day", "unvalidated days")} in this export.`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (language === "fr") {
|
if (language === "fr") {
|
||||||
stream.push(text(30, 38, 6, "Bleu : séance de sport effectuée (horaires enregistrés ; détails en fin de rapport)."));
|
stream.push(text(30, 38, 6, "Bleu : séance de sport effectuée (horaires enregistrés ; détails en fin de rapport)."));
|
||||||
stream.push(
|
stream.push(
|
||||||
|
|
@ -468,19 +481,17 @@ function sportPage(
|
||||||
language: Language,
|
language: Language,
|
||||||
first: string,
|
first: string,
|
||||||
last: string,
|
last: string,
|
||||||
|
generatedAt: Date,
|
||||||
): string {
|
): string {
|
||||||
const fr = language === "fr";
|
const fr = language === "fr";
|
||||||
const stream = [
|
const stream = reportHeader(fr ? "Trainlog — Séances de sport" :
|
||||||
"0 G 0 g\n",
|
"Trainlog — Sport sessions", index, count, language, first, last, generatedAt);
|
||||||
text(30, 565, 14, fr ? "SÉANCES DE SPORT EFFECTUÉES" : "COMPLETED SPORT SESSIONS"),
|
stream.push(text(30, 539, 8, fr ? "SÉANCES EFFECTUÉES" : "COMPLETED SESSIONS", pdfAccent));
|
||||||
text(740, 565, 7, `${index + 1}/${count}`),
|
stream.push(text(30, 526, 7, fr
|
||||||
text(30, 547, 7, `${fr ? "Période des lignes" : "Row period"} : ${first} 18:00 - ${last} 18:00`),
|
|
||||||
text(30, 532, 7, fr
|
|
||||||
? "Horaires enregistrés ; la durée est celle de la séance. Aucun lien médical déduit."
|
? "Horaires enregistrés ; la durée est celle de la séance. Aucun lien médical déduit."
|
||||||
: "Recorded times and session duration. No medical conclusion is inferred."),
|
: "Recorded times and session duration. No medical conclusion is inferred."));
|
||||||
];
|
|
||||||
sessions.forEach((session, rowIndex) => {
|
sessions.forEach((session, rowIndex) => {
|
||||||
const y = 510 - rowIndex * 16;
|
const y = 505 - rowIndex * 16;
|
||||||
const start = Date.parse(session.started_at);
|
const start = Date.parse(session.started_at);
|
||||||
const end = session.ended_at === null ? NaN : Date.parse(session.ended_at);
|
const end = session.ended_at === null ? NaN : Date.parse(session.ended_at);
|
||||||
const validEnd = Number.isFinite(start) && Number.isFinite(end) && end > start;
|
const validEnd = Number.isFinite(start) && Number.isFinite(end) && end > start;
|
||||||
|
|
@ -506,23 +517,34 @@ export function buildSleepDiaryPdf(
|
||||||
snapshot: SleepSnapshot,
|
snapshot: SleepSnapshot,
|
||||||
language: Language,
|
language: Language,
|
||||||
sport: readonly SportSession[] = [],
|
sport: readonly SportSession[] = [],
|
||||||
reportStart = snapshot.entries[0]?.night_start_date ?? "—",
|
reportStart?: string,
|
||||||
reportEnd = snapshot.entries[snapshot.entries.length - 1]?.night_start_date ?? "—",
|
reportEnd?: string,
|
||||||
|
generatedAt = new Date(),
|
||||||
): Blob {
|
): Blob {
|
||||||
const groups = paginate(snapshot.entries, language);
|
// CONTRACT: reverse only day/appendix presentation order. Timed details in
|
||||||
const sportGroups = Array.from({ length: Math.ceil(sport.length / 28) },
|
// each night continue to use the shared chronological projection.
|
||||||
(_, index) => sport.slice(index * 28, (index + 1) * 28));
|
const orderedEntries = [...snapshot.entries].sort((left, right) =>
|
||||||
const reportEndTime = Date.parse(`${reportEnd}T12:00:00Z`);
|
right.night_start_date.localeCompare(left.night_start_date) ||
|
||||||
|
left.entry_id.localeCompare(right.entry_id));
|
||||||
|
const orderedSport = [...sport].sort((left, right) =>
|
||||||
|
Date.parse(right.started_at) - Date.parse(left.started_at) ||
|
||||||
|
left.identity.localeCompare(right.identity));
|
||||||
|
const first = reportStart ?? orderedEntries[orderedEntries.length - 1]?.night_start_date ?? "—";
|
||||||
|
const last = reportEnd ?? orderedEntries[0]?.night_start_date ?? "—";
|
||||||
|
const groups = paginate(orderedEntries, language);
|
||||||
|
const sportGroups = Array.from({ length: Math.ceil(orderedSport.length / 28) },
|
||||||
|
(_, index) => orderedSport.slice(index * 28, (index + 1) * 28));
|
||||||
|
const reportEndTime = Date.parse(`${last}T12:00:00Z`);
|
||||||
const reportEndExclusive = Number.isFinite(reportEndTime)
|
const reportEndExclusive = Number.isFinite(reportEndTime)
|
||||||
? new Date(reportEndTime + 86400000).toISOString().slice(0, 10)
|
? new Date(reportEndTime + 86400000).toISOString().slice(0, 10)
|
||||||
: reportEnd;
|
: last;
|
||||||
const contents = [
|
const contents = [
|
||||||
...groups.map((entries, index) => page(snapshot, entries, index,
|
...groups.map((entries, index) => page(snapshot, entries, index,
|
||||||
groups.length + sportGroups.length, language, sport)),
|
groups.length + sportGroups.length, language, orderedSport, first, last, generatedAt)),
|
||||||
...sportGroups.map((sessions, index) => sportPage(sessions, snapshot.entries,
|
...sportGroups.map((sessions, index) => sportPage(sessions, orderedEntries,
|
||||||
groups.length + index, groups.length + sportGroups.length, language,
|
groups.length + index, groups.length + sportGroups.length, language,
|
||||||
reportStart,
|
first,
|
||||||
snapshot.entries[snapshot.entries.length - 1]?.night_end_date ?? reportEndExclusive)),
|
orderedEntries[0]?.night_end_date ?? reportEndExclusive, generatedAt)),
|
||||||
];
|
];
|
||||||
const objects = [
|
const objects = [
|
||||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||||
|
|
|
||||||
|
|
@ -1061,9 +1061,9 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
await screen.findByTestId("sleep-agenda-event-bed_time"),
|
await screen.findByTestId("sleep-agenda-event-bed_time"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
|
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
|
||||||
"Journée validée",
|
"Prête à synchroniser",
|
||||||
);
|
);
|
||||||
expect(screen.queryByText("Prête à synchroniser")).toBeNull();
|
expect(screen.queryByText("Journée validée")).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen.getByLabelText("Traitement et remarques particulières"),
|
screen.getByLabelText("Traitement et remarques particulières"),
|
||||||
).toHaveValue("Observation du jour A");
|
).toHaveValue("Observation du jour A");
|
||||||
|
|
@ -1104,7 +1104,7 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
screen.getByLabelText("Traitement et remarques particulières"),
|
screen.getByLabelText("Traitement et remarques particulières"),
|
||||||
).toHaveValue("Observation du jour A");
|
).toHaveValue("Observation du jour A");
|
||||||
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
|
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
|
||||||
"Journée validée",
|
"Prête à synchroniser",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1137,7 +1137,7 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("presents validation as a local completion state without transport wording", async () => {
|
it("keeps the publication action while removing validation labels from agenda rows", async () => {
|
||||||
let current: SleepEntry = {
|
let current: SleepEntry = {
|
||||||
...dayAEntry(),
|
...dayAEntry(),
|
||||||
publication_status: "draft",
|
publication_status: "draft",
|
||||||
|
|
@ -1159,15 +1159,18 @@ describe("SleepDiaryWorkspace", () => {
|
||||||
render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
render(<SleepDiaryWorkspace period="30d" language="fr" />);
|
||||||
expect(
|
expect(
|
||||||
await screen.findByTestId("sleep-publication-status"),
|
await screen.findByTestId("sleep-publication-status"),
|
||||||
).toHaveTextContent("Brouillon");
|
).toHaveTextContent("Brouillon local");
|
||||||
|
expect(screen.getByTestId(`sleep-agenda-row-${current.entry_id}`)).not.toHaveTextContent("Brouillon");
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("sleep-validate-day"));
|
fireEvent.click(screen.getByTestId("sleep-validate-day"));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
|
expect(screen.getByTestId("sleep-publication-status")).toHaveTextContent(
|
||||||
"Journée validée",
|
"Prête à synchroniser",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
expect(screen.queryByText("Prête à synchroniser")).toBeNull();
|
expect(api.validateSleepEntry).toHaveBeenCalled();
|
||||||
|
expect(screen.getByTestId(`sleep-agenda-row-${current.entry_id}`)).not.toHaveTextContent("synchroniser");
|
||||||
|
expect(screen.queryByText("Journée validée")).toBeNull();
|
||||||
expect(screen.getByTestId("sleep-save-state")).toHaveTextContent(
|
expect(screen.getByTestId("sleep-save-state")).toHaveTextContent(
|
||||||
"Enregistré localement",
|
"Enregistré localement",
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ const copy = {
|
||||||
end: "Fin",
|
end: "Fin",
|
||||||
dose: "Dosage",
|
dose: "Dosage",
|
||||||
modify: "Modifier",
|
modify: "Modifier",
|
||||||
validate: "Valider la journée",
|
validate: "Autoriser la synchronisation",
|
||||||
continue: "Continuer",
|
continue: "Continuer",
|
||||||
saved: "Enregistré localement",
|
saved: "Enregistré localement",
|
||||||
saving: "Enregistrement…",
|
saving: "Enregistrement…",
|
||||||
|
|
@ -132,10 +132,10 @@ const copy = {
|
||||||
medicationEmpty: "Aucun médicament enregistré.",
|
medicationEmpty: "Aucun médicament enregistré.",
|
||||||
notesPlaceholder: "Notes utiles pour cette nuit…",
|
notesPlaceholder: "Notes utiles pour cette nuit…",
|
||||||
statuses: {
|
statuses: {
|
||||||
draft: "Brouillon",
|
draft: "Brouillon local",
|
||||||
ready: "Journée validée",
|
ready: "Prête à synchroniser",
|
||||||
synchronized: "Journée validée",
|
synchronized: "Synchronisée",
|
||||||
modified: "Modifiée",
|
modified: "Modifiée depuis la synchronisation",
|
||||||
},
|
},
|
||||||
qualities: ["Très bon", "Bon", "Moyen", "Mauvais", "Très mauvais"],
|
qualities: ["Très bon", "Bon", "Moyen", "Mauvais", "Très mauvais"],
|
||||||
},
|
},
|
||||||
|
|
@ -201,13 +201,13 @@ const copy = {
|
||||||
deleteEntry: "Delete entry",
|
deleteEntry: "Delete entry",
|
||||||
medicationEmpty: "No saved medications.",
|
medicationEmpty: "No saved medications.",
|
||||||
notesPlaceholder: "Useful notes about this night…",
|
notesPlaceholder: "Useful notes about this night…",
|
||||||
validate: "Validate day",
|
validate: "Allow synchronization",
|
||||||
continue: "Continue",
|
continue: "Continue",
|
||||||
statuses: {
|
statuses: {
|
||||||
draft: "Draft",
|
draft: "Local draft",
|
||||||
ready: "Day validated",
|
ready: "Ready to sync",
|
||||||
synchronized: "Day validated",
|
synchronized: "Synchronized",
|
||||||
modified: "Modified",
|
modified: "Modified since synchronization",
|
||||||
},
|
},
|
||||||
qualities: ["Very good", "Good", "Average", "Bad", "Very bad"],
|
qualities: ["Very good", "Good", "Average", "Bad", "Very bad"],
|
||||||
},
|
},
|
||||||
|
|
@ -774,8 +774,9 @@ export function SleepDiaryWorkspace({
|
||||||
agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ??
|
agendaEntries.find((entry) => entry.entry_id === selectedEntryId) ??
|
||||||
activeEntry ??
|
activeEntry ??
|
||||||
agendaEntries[agendaEntries.length - 1];
|
agendaEntries[agendaEntries.length - 1];
|
||||||
// INVARIANT: presentation sorting and slicing never mutate or narrow the
|
// INVARIANT: agenda sorting and slicing never mutate or narrow the
|
||||||
// ascending, deduplicated collection used by PDF and sport projections.
|
// deduplicated collection used by PDF and sport projections. The PDF owns
|
||||||
|
// its own newest-first presentation order.
|
||||||
const recentEntries = [...agendaEntries].sort((left, right) =>
|
const recentEntries = [...agendaEntries].sort((left, right) =>
|
||||||
right.night_start_date.localeCompare(left.night_start_date) ||
|
right.night_start_date.localeCompare(left.night_start_date) ||
|
||||||
left.entry_id.localeCompare(right.entry_id));
|
left.entry_id.localeCompare(right.entry_id));
|
||||||
|
|
@ -1005,7 +1006,7 @@ export function SleepDiaryWorkspace({
|
||||||
<strong>
|
<strong>
|
||||||
{t.night} {displayDate(entry.night_start_date, language)}
|
{t.night} {displayDate(entry.night_start_date, language)}
|
||||||
<small>
|
<small>
|
||||||
{t.statuses[entry.publication_status]} · {t.continue}
|
{t.continue}
|
||||||
</small>
|
</small>
|
||||||
</strong>
|
</strong>
|
||||||
<span className="sleep-track">
|
<span className="sleep-track">
|
||||||
|
|
@ -1618,6 +1619,8 @@ export function SleepDiaryWorkspace({
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onClick={() => void validate()}
|
onClick={() => void validate()}
|
||||||
>
|
>
|
||||||
|
{/* CONTRACT: this action publishes the exact saved revision for
|
||||||
|
synchronization; its durable publication state stays intact. */}
|
||||||
{t.validate}
|
{t.validate}
|
||||||
</button>
|
</button>
|
||||||
{draft.expected_revision && (
|
{draft.expected_revision && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue