diff --git a/CHANGELOG.md b/CHANGELOG.md index 5146d6f..00a6581 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased — 0.1.4 +- Fully localized the French Sleep Diary PDF without changing its canonical + data source or event semantics. Column headings, timeline labels, legend, + factual summary, draft warning, dates and plurals are now French, accented + text uses the PDF WinAnsi encoding, and durations use human-readable units. + Long awakenings remain proportional intervals between independent sleep + ranges, while medication intake snapshots retain localized dose and unit. + The English report keeps an independent English vocabulary. - Made the Sleep Diary Agenda a live projection of the latest locally persisted entry revision. Event and intake mutations now re-read and adopt the canonical Core snapshot, serialized autosaves carry the returned diff --git a/docs/current_state.md b/docs/current_state.md index f953a05..45e500f 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -138,7 +138,7 @@ parallel implementation of its rules. | Web Dashboard tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` | | Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` | | Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` | -| Sleep Diary V1 | Implemented on the 0.1.4 review branch: timestamped causal diary, stable Web/Android capture, full-generation companion and local vector PDF; the Web Agenda, Chronology and factual summary adopt the latest persisted revision without reload, medication choices expose dose/unit, and real-Firefox mutation/reload coverage plus a section-local error boundary protect capture | +| Sleep Diary V1 | Implemented on the 0.1.4 review branch: timestamped causal diary, stable Web/Android capture, full-generation companion and fully localized FR/EN local vector PDF; the Web Agenda, Chronology and factual summary adopt the latest persisted revision without reload, medication choices expose dose/unit, and real-Firefox mutation/reload coverage plus a section-local error boundary protect capture | | Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) | | Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) | | Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) | diff --git a/docs/reviews/evidence/sleep-diary-fr-7-days.pdf b/docs/reviews/evidence/sleep-diary-fr-7-days.pdf new file mode 100644 index 0000000..6ccda4c --- /dev/null +++ b/docs/reviews/evidence/sleep-diary-fr-7-days.pdf @@ -0,0 +1,391 @@ +%PDF-1.4 +%Trainlog +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [4 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> +endobj +4 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >> +endobj +5 0 obj +<< /Length 17914 >> +stream +0 G 0 g +0 g BT /F1 14 Tf 30.0 565.0 Td (AGENDA TRAINLOG DE VIGILANCE ET DE SOMMEIL) Tj ET +0 g BT /F1 7 Tf 740.0 565.0 Td (1/1) Tj ET +0 g BT /F1 7 Tf 30.0 548.0 Td (DATE) Tj ET +0 g BT /F1 4 Tf 112.0 548.0 Td (18) Tj ET +0 g BT /F1 4 Tf 132.8 548.0 Td (19) Tj ET +0 g BT /F1 4 Tf 153.7 548.0 Td (20) Tj ET +0 g BT /F1 4 Tf 174.5 548.0 Td (21) Tj ET +0 g BT /F1 4 Tf 195.3 548.0 Td (22) Tj ET +0 g BT /F1 4 Tf 216.2 548.0 Td (23) Tj ET +0 g BT /F1 4 Tf 237.0 548.0 Td (0) Tj ET +0 g BT /F1 4 Tf 257.8 548.0 Td (1) Tj ET +0 g BT /F1 4 Tf 278.7 548.0 Td (2) Tj ET +0 g BT /F1 4 Tf 299.5 548.0 Td (3) Tj ET +0 g BT /F1 4 Tf 320.3 548.0 Td (4) Tj ET +0 g BT /F1 4 Tf 341.2 548.0 Td (5) Tj ET +0 g BT /F1 4 Tf 362.0 548.0 Td (6) Tj ET +0 g BT /F1 4 Tf 382.8 548.0 Td (7) Tj ET +0 g BT /F1 4 Tf 403.7 548.0 Td (8) Tj ET +0 g BT /F1 4 Tf 424.5 548.0 Td (9) Tj ET +0 g BT /F1 4 Tf 445.3 548.0 Td (10) Tj ET +0 g BT /F1 4 Tf 466.2 548.0 Td (11) Tj ET +0 g BT /F1 4 Tf 487.0 548.0 Td (12) Tj ET +0 g BT /F1 4 Tf 507.8 548.0 Td (13) Tj ET +0 g BT /F1 4 Tf 528.7 548.0 Td (14) Tj ET +0 g BT /F1 4 Tf 549.5 548.0 Td (15) Tj ET +0 g BT /F1 4 Tf 570.3 548.0 Td (16) Tj ET +0 g BT /F1 4 Tf 591.2 548.0 Td (17) Tj ET +0 g BT /F1 4 Tf 612.0 548.0 Td (18) Tj ET +0 g BT /F1 4 Tf 616.0 552.0 Td (QUALIT\311 DU) Tj ET +0 g BT /F1 4 Tf 616.0 545.0 Td (SOMMEIL) Tj ET +0 g BT /F1 4 Tf 654.0 552.0 Td (QUALIT\311 DU) Tj ET +0 g BT /F1 4 Tf 654.0 545.0 Td (R\311VEIL) Tj ET +0 g BT /F1 4 Tf 692.0 552.0 Td (FORME DE LA) Tj ET +0 g BT /F1 4 Tf 692.0 545.0 Td (JOURN\311E) Tj ET +0 g BT /F1 4 Tf 728.0 552.0 Td (TRAITEMENT ET) Tj ET +0 g BT /F1 4 Tf 728.0 545.0 Td (REMARQUES) Tj ET +0.7 G 30 498 782 40 re S +0 g BT /F1 5 Tf 33.0 525.0 Td (20/09/2026) Tj ET +0 g BT /F1 5 Tf 33.0 515.0 Td (au 21/09/2026) Tj ET +0.9 G 112 498 m 112 538 l S +0.9 G 132.83333333333334 498 m 132.83333333333334 538 l S +0.9 G 153.66666666666666 498 m 153.66666666666666 538 l S +0.9 G 174.5 498 m 174.5 538 l S +0.9 G 195.33333333333331 498 m 195.33333333333331 538 l S +0.9 G 216.16666666666669 498 m 216.16666666666669 538 l S +0.9 G 237 498 m 237 538 l S +0.9 G 257.83333333333337 498 m 257.83333333333337 538 l S +0.9 G 278.66666666666663 498 m 278.66666666666663 538 l S +0.9 G 299.5 498 m 299.5 538 l S +0.9 G 320.33333333333337 498 m 320.33333333333337 538 l S +0.9 G 341.16666666666663 498 m 341.16666666666663 538 l S +0.9 G 362 498 m 362 538 l S +0.9 G 382.8333333333333 498 m 382.8333333333333 538 l S +0.9 G 403.6666666666667 498 m 403.6666666666667 538 l S +0.9 G 424.5 498 m 424.5 538 l S +0.9 G 445.3333333333333 498 m 445.3333333333333 538 l S +0.9 G 466.1666666666667 498 m 466.1666666666667 538 l S +0.9 G 487 498 m 487 538 l S +0.9 G 507.8333333333333 498 m 507.8333333333333 538 l S +0.9 G 528.6666666666667 498 m 528.6666666666667 538 l S +0.9 G 549.5 498 m 549.5 538 l S +0.9 G 570.3333333333333 498 m 570.3333333333333 538 l S +0.9 G 591.1666666666667 498 m 591.1666666666667 538 l S +0.9 G 612 498 m 612 538 l S +0 g BT /F1 8 Tf 211.0 513.0 Td (v) Tj ET +0.75 g 221.38 509.00 78.13 18.00 re f 0 G 221.38 509.00 78.13 18.00 re S +0 g BT /F1 5 Tf 223.4 515.0 Td (SOMMEIL) Tj ET +0.5 g 299.50 509.00 10.42 18.00 re f 0 G 299.50 509.00 10.42 18.00 re S +0.75 g 309.92 509.00 67.71 18.00 re f 0 G 309.92 509.00 67.71 18.00 re S +0 g BT /F1 5 Tf 311.9 515.0 Td (SOMMEIL) Tj ET +0 g BT /F1 8 Tf 386.3 513.0 Td (^) Tj ET +0 g BT /F1 6 Tf 195.3 502.0 Td (M) Tj ET +0 g BT /F1 6 Tf 195.3 502.0 Td (M) Tj ET +0 g BT /F1 7 Tf 620.0 522.0 Td (-) Tj ET +0 g BT /F1 7 Tf 658.0 522.0 Td (-) Tj ET +0 g BT /F1 7 Tf 696.0 522.0 Td (-) Tj ET +0 g BT /F1 4 Tf 730.0 531.0 Td (22:00 \227 venlafaxine \227 37,5 mg ; 22:00 \227 ) Tj ET +0 g BT /F1 4 Tf 730.0 522.0 Td (venlafaxine \227 75 mg) Tj ET +0 g BT /F1 4 Tf 730.0 513.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 504.0 Td () Tj ET +0.7 G 30 458 782 40 re S +0 g BT /F1 5 Tf 33.0 485.0 Td (19/09/2026) Tj ET +0 g BT /F1 5 Tf 33.0 475.0 Td (au 20/09/2026) Tj ET +0.9 G 112 458 m 112 498 l S +0.9 G 132.83333333333334 458 m 132.83333333333334 498 l S +0.9 G 153.66666666666666 458 m 153.66666666666666 498 l S +0.9 G 174.5 458 m 174.5 498 l S +0.9 G 195.33333333333331 458 m 195.33333333333331 498 l S +0.9 G 216.16666666666669 458 m 216.16666666666669 498 l S +0.9 G 237 458 m 237 498 l S +0.9 G 257.83333333333337 458 m 257.83333333333337 498 l S +0.9 G 278.66666666666663 458 m 278.66666666666663 498 l S +0.9 G 299.5 458 m 299.5 498 l S +0.9 G 320.33333333333337 458 m 320.33333333333337 498 l S +0.9 G 341.16666666666663 458 m 341.16666666666663 498 l S +0.9 G 362 458 m 362 498 l S +0.9 G 382.8333333333333 458 m 382.8333333333333 498 l S +0.9 G 403.6666666666667 458 m 403.6666666666667 498 l S +0.9 G 424.5 458 m 424.5 498 l S +0.9 G 445.3333333333333 458 m 445.3333333333333 498 l S +0.9 G 466.1666666666667 458 m 466.1666666666667 498 l S +0.9 G 487 458 m 487 498 l S +0.9 G 507.8333333333333 458 m 507.8333333333333 498 l S +0.9 G 528.6666666666667 458 m 528.6666666666667 498 l S +0.9 G 549.5 458 m 549.5 498 l S +0.9 G 570.3333333333333 458 m 570.3333333333333 498 l S +0.9 G 591.1666666666667 458 m 591.1666666666667 498 l S +0.9 G 612 458 m 612 498 l S +0 g BT /F1 8 Tf 205.8 473.0 Td (v) Tj ET +0.75 g 216.17 469.00 83.33 18.00 re f 0 G 216.17 469.00 83.33 18.00 re S +0 g BT /F1 5 Tf 218.2 475.0 Td (SOMMEIL) Tj ET +0.5 g 299.50 469.00 15.63 18.00 re f 0 G 299.50 469.00 15.63 18.00 re S +0.75 g 315.13 469.00 67.71 18.00 re f 0 G 315.13 469.00 67.71 18.00 re S +0 g BT /F1 5 Tf 317.1 475.0 Td (SOMMEIL) Tj ET +0.87 g 382.83 469.00 3.47 18.00 re f 0 G 382.83 469.00 3.47 18.00 re S +0 g BT /F1 8 Tf 388.0 473.0 Td (^) Tj ET +0.93 g 528.67 469.00 12.15 18.00 re f 0 G 528.67 469.00 12.15 18.00 re S +0 g BT /F1 8 Tf 549.5 473.0 Td (S) Tj ET +0 g BT /F1 7 Tf 620.0 482.0 Td (B) Tj ET +0 g BT /F1 7 Tf 658.0 482.0 Td (Moy) Tj ET +0 g BT /F1 7 Tf 696.0 482.0 Td (TB) Tj ET +0 g BT /F1 4 Tf 730.0 491.0 Td (Remarque fran\347aise synth\351tique.) Tj ET +0 g BT /F1 4 Tf 730.0 482.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 473.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 464.0 Td () Tj ET +0.7 G 30 418 782 40 re S +0 g BT /F1 5 Tf 33.0 445.0 Td (18/09/2026) Tj ET +0 g BT /F1 5 Tf 33.0 435.0 Td (au 19/09/2026) Tj ET +0.9 G 112 418 m 112 458 l S +0.9 G 132.83333333333334 418 m 132.83333333333334 458 l S +0.9 G 153.66666666666666 418 m 153.66666666666666 458 l S +0.9 G 174.5 418 m 174.5 458 l S +0.9 G 195.33333333333331 418 m 195.33333333333331 458 l S +0.9 G 216.16666666666669 418 m 216.16666666666669 458 l S +0.9 G 237 418 m 237 458 l S +0.9 G 257.83333333333337 418 m 257.83333333333337 458 l S +0.9 G 278.66666666666663 418 m 278.66666666666663 458 l S +0.9 G 299.5 418 m 299.5 458 l S +0.9 G 320.33333333333337 418 m 320.33333333333337 458 l S +0.9 G 341.16666666666663 418 m 341.16666666666663 458 l S +0.9 G 362 418 m 362 458 l S +0.9 G 382.8333333333333 418 m 382.8333333333333 458 l S +0.9 G 403.6666666666667 418 m 403.6666666666667 458 l S +0.9 G 424.5 418 m 424.5 458 l S +0.9 G 445.3333333333333 418 m 445.3333333333333 458 l S +0.9 G 466.1666666666667 418 m 466.1666666666667 458 l S +0.9 G 487 418 m 487 458 l S +0.9 G 507.8333333333333 418 m 507.8333333333333 458 l S +0.9 G 528.6666666666667 418 m 528.6666666666667 458 l S +0.9 G 549.5 418 m 549.5 458 l S +0.9 G 570.3333333333333 418 m 570.3333333333333 458 l S +0.9 G 591.1666666666667 418 m 591.1666666666667 458 l S +0.9 G 612 418 m 612 458 l S +0 g BT /F1 8 Tf 205.8 433.0 Td (v) Tj ET +0.75 g 216.17 429.00 83.33 18.00 re f 0 G 216.17 429.00 83.33 18.00 re S +0 g BT /F1 5 Tf 218.2 435.0 Td (SOMMEIL) Tj ET +0.5 g 299.50 429.00 15.63 18.00 re f 0 G 299.50 429.00 15.63 18.00 re S +0.75 g 315.13 429.00 67.71 18.00 re f 0 G 315.13 429.00 67.71 18.00 re S +0 g BT /F1 5 Tf 317.1 435.0 Td (SOMMEIL) Tj ET +0.87 g 382.83 429.00 3.47 18.00 re f 0 G 382.83 429.00 3.47 18.00 re S +0 g BT /F1 8 Tf 388.0 433.0 Td (^) Tj ET +0.93 g 528.67 429.00 12.15 18.00 re f 0 G 528.67 429.00 12.15 18.00 re S +0 g BT /F1 8 Tf 549.5 433.0 Td (S) Tj ET +0 g BT /F1 7 Tf 620.0 442.0 Td (B) Tj ET +0 g BT /F1 7 Tf 658.0 442.0 Td (Moy) Tj ET +0 g BT /F1 7 Tf 696.0 442.0 Td (TB) Tj ET +0 g BT /F1 4 Tf 730.0 451.0 Td (Remarque fran\347aise synth\351tique.) Tj ET +0 g BT /F1 4 Tf 730.0 442.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 433.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 424.0 Td () Tj ET +0.7 G 30 378 782 40 re S +0 g BT /F1 5 Tf 33.0 405.0 Td (17/09/2026) Tj ET +0 g BT /F1 5 Tf 33.0 395.0 Td (au 18/09/2026) Tj ET +0.9 G 112 378 m 112 418 l S +0.9 G 132.83333333333334 378 m 132.83333333333334 418 l S +0.9 G 153.66666666666666 378 m 153.66666666666666 418 l S +0.9 G 174.5 378 m 174.5 418 l S +0.9 G 195.33333333333331 378 m 195.33333333333331 418 l S +0.9 G 216.16666666666669 378 m 216.16666666666669 418 l S +0.9 G 237 378 m 237 418 l S +0.9 G 257.83333333333337 378 m 257.83333333333337 418 l S +0.9 G 278.66666666666663 378 m 278.66666666666663 418 l S +0.9 G 299.5 378 m 299.5 418 l S +0.9 G 320.33333333333337 378 m 320.33333333333337 418 l S +0.9 G 341.16666666666663 378 m 341.16666666666663 418 l S +0.9 G 362 378 m 362 418 l S +0.9 G 382.8333333333333 378 m 382.8333333333333 418 l S +0.9 G 403.6666666666667 378 m 403.6666666666667 418 l S +0.9 G 424.5 378 m 424.5 418 l S +0.9 G 445.3333333333333 378 m 445.3333333333333 418 l S +0.9 G 466.1666666666667 378 m 466.1666666666667 418 l S +0.9 G 487 378 m 487 418 l S +0.9 G 507.8333333333333 378 m 507.8333333333333 418 l S +0.9 G 528.6666666666667 378 m 528.6666666666667 418 l S +0.9 G 549.5 378 m 549.5 418 l S +0.9 G 570.3333333333333 378 m 570.3333333333333 418 l S +0.9 G 591.1666666666667 378 m 591.1666666666667 418 l S +0.9 G 612 378 m 612 418 l S +0 g BT /F1 8 Tf 205.8 393.0 Td (v) Tj ET +0.75 g 216.17 389.00 83.33 18.00 re f 0 G 216.17 389.00 83.33 18.00 re S +0 g BT /F1 5 Tf 218.2 395.0 Td (SOMMEIL) Tj ET +0.5 g 299.50 389.00 15.63 18.00 re f 0 G 299.50 389.00 15.63 18.00 re S +0.75 g 315.13 389.00 67.71 18.00 re f 0 G 315.13 389.00 67.71 18.00 re S +0 g BT /F1 5 Tf 317.1 395.0 Td (SOMMEIL) Tj ET +0.87 g 382.83 389.00 3.47 18.00 re f 0 G 382.83 389.00 3.47 18.00 re S +0 g BT /F1 8 Tf 388.0 393.0 Td (^) Tj ET +0.93 g 528.67 389.00 12.15 18.00 re f 0 G 528.67 389.00 12.15 18.00 re S +0 g BT /F1 8 Tf 549.5 393.0 Td (S) Tj ET +0 g BT /F1 7 Tf 620.0 402.0 Td (B) Tj ET +0 g BT /F1 7 Tf 658.0 402.0 Td (Moy) Tj ET +0 g BT /F1 7 Tf 696.0 402.0 Td (TB) Tj ET +0 g BT /F1 4 Tf 730.0 411.0 Td (Remarque fran\347aise synth\351tique.) Tj ET +0 g BT /F1 4 Tf 730.0 402.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 393.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 384.0 Td () Tj ET +0.7 G 30 338 782 40 re S +0 g BT /F1 5 Tf 33.0 365.0 Td (16/09/2026) Tj ET +0 g BT /F1 5 Tf 33.0 355.0 Td (au 17/09/2026) Tj ET +0.9 G 112 338 m 112 378 l S +0.9 G 132.83333333333334 338 m 132.83333333333334 378 l S +0.9 G 153.66666666666666 338 m 153.66666666666666 378 l S +0.9 G 174.5 338 m 174.5 378 l S +0.9 G 195.33333333333331 338 m 195.33333333333331 378 l S +0.9 G 216.16666666666669 338 m 216.16666666666669 378 l S +0.9 G 237 338 m 237 378 l S +0.9 G 257.83333333333337 338 m 257.83333333333337 378 l S +0.9 G 278.66666666666663 338 m 278.66666666666663 378 l S +0.9 G 299.5 338 m 299.5 378 l S +0.9 G 320.33333333333337 338 m 320.33333333333337 378 l S +0.9 G 341.16666666666663 338 m 341.16666666666663 378 l S +0.9 G 362 338 m 362 378 l S +0.9 G 382.8333333333333 338 m 382.8333333333333 378 l S +0.9 G 403.6666666666667 338 m 403.6666666666667 378 l S +0.9 G 424.5 338 m 424.5 378 l S +0.9 G 445.3333333333333 338 m 445.3333333333333 378 l S +0.9 G 466.1666666666667 338 m 466.1666666666667 378 l S +0.9 G 487 338 m 487 378 l S +0.9 G 507.8333333333333 338 m 507.8333333333333 378 l S +0.9 G 528.6666666666667 338 m 528.6666666666667 378 l S +0.9 G 549.5 338 m 549.5 378 l S +0.9 G 570.3333333333333 338 m 570.3333333333333 378 l S +0.9 G 591.1666666666667 338 m 591.1666666666667 378 l S +0.9 G 612 338 m 612 378 l S +0 g BT /F1 8 Tf 205.8 353.0 Td (v) Tj ET +0.75 g 216.17 349.00 83.33 18.00 re f 0 G 216.17 349.00 83.33 18.00 re S +0 g BT /F1 5 Tf 218.2 355.0 Td (SOMMEIL) Tj ET +0.5 g 299.50 349.00 15.63 18.00 re f 0 G 299.50 349.00 15.63 18.00 re S +0.75 g 315.13 349.00 67.71 18.00 re f 0 G 315.13 349.00 67.71 18.00 re S +0 g BT /F1 5 Tf 317.1 355.0 Td (SOMMEIL) Tj ET +0.87 g 382.83 349.00 3.47 18.00 re f 0 G 382.83 349.00 3.47 18.00 re S +0 g BT /F1 8 Tf 388.0 353.0 Td (^) Tj ET +0.93 g 528.67 349.00 12.15 18.00 re f 0 G 528.67 349.00 12.15 18.00 re S +0 g BT /F1 8 Tf 549.5 353.0 Td (S) Tj ET +0 g BT /F1 7 Tf 620.0 362.0 Td (B) Tj ET +0 g BT /F1 7 Tf 658.0 362.0 Td (Moy) Tj ET +0 g BT /F1 7 Tf 696.0 362.0 Td (TB) Tj ET +0 g BT /F1 4 Tf 730.0 371.0 Td (Remarque fran\347aise synth\351tique.) Tj ET +0 g BT /F1 4 Tf 730.0 362.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 353.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 344.0 Td () Tj ET +0.7 G 30 298 782 40 re S +0 g BT /F1 5 Tf 33.0 325.0 Td (15/09/2026) Tj ET +0 g BT /F1 5 Tf 33.0 315.0 Td (au 16/09/2026) Tj ET +0.9 G 112 298 m 112 338 l S +0.9 G 132.83333333333334 298 m 132.83333333333334 338 l S +0.9 G 153.66666666666666 298 m 153.66666666666666 338 l S +0.9 G 174.5 298 m 174.5 338 l S +0.9 G 195.33333333333331 298 m 195.33333333333331 338 l S +0.9 G 216.16666666666669 298 m 216.16666666666669 338 l S +0.9 G 237 298 m 237 338 l S +0.9 G 257.83333333333337 298 m 257.83333333333337 338 l S +0.9 G 278.66666666666663 298 m 278.66666666666663 338 l S +0.9 G 299.5 298 m 299.5 338 l S +0.9 G 320.33333333333337 298 m 320.33333333333337 338 l S +0.9 G 341.16666666666663 298 m 341.16666666666663 338 l S +0.9 G 362 298 m 362 338 l S +0.9 G 382.8333333333333 298 m 382.8333333333333 338 l S +0.9 G 403.6666666666667 298 m 403.6666666666667 338 l S +0.9 G 424.5 298 m 424.5 338 l S +0.9 G 445.3333333333333 298 m 445.3333333333333 338 l S +0.9 G 466.1666666666667 298 m 466.1666666666667 338 l S +0.9 G 487 298 m 487 338 l S +0.9 G 507.8333333333333 298 m 507.8333333333333 338 l S +0.9 G 528.6666666666667 298 m 528.6666666666667 338 l S +0.9 G 549.5 298 m 549.5 338 l S +0.9 G 570.3333333333333 298 m 570.3333333333333 338 l S +0.9 G 591.1666666666667 298 m 591.1666666666667 338 l S +0.9 G 612 298 m 612 338 l S +0 g BT /F1 8 Tf 205.8 313.0 Td (v) Tj ET +0.75 g 216.17 309.00 83.33 18.00 re f 0 G 216.17 309.00 83.33 18.00 re S +0 g BT /F1 5 Tf 218.2 315.0 Td (SOMMEIL) Tj ET +0.5 g 299.50 309.00 15.63 18.00 re f 0 G 299.50 309.00 15.63 18.00 re S +0.75 g 315.13 309.00 67.71 18.00 re f 0 G 315.13 309.00 67.71 18.00 re S +0 g BT /F1 5 Tf 317.1 315.0 Td (SOMMEIL) Tj ET +0.87 g 382.83 309.00 3.47 18.00 re f 0 G 382.83 309.00 3.47 18.00 re S +0 g BT /F1 8 Tf 388.0 313.0 Td (^) Tj ET +0.93 g 528.67 309.00 12.15 18.00 re f 0 G 528.67 309.00 12.15 18.00 re S +0 g BT /F1 8 Tf 549.5 313.0 Td (S) Tj ET +0 g BT /F1 7 Tf 620.0 322.0 Td (B) Tj ET +0 g BT /F1 7 Tf 658.0 322.0 Td (Moy) Tj ET +0 g BT /F1 7 Tf 696.0 322.0 Td (TB) Tj ET +0 g BT /F1 4 Tf 730.0 331.0 Td (Remarque fran\347aise synth\351tique.) Tj ET +0 g BT /F1 4 Tf 730.0 322.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 313.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 304.0 Td () Tj ET +0.7 G 30 258 782 40 re S +0 g BT /F1 5 Tf 33.0 285.0 Td (14/09/2026) Tj ET +0 g BT /F1 5 Tf 33.0 275.0 Td (au 15/09/2026) Tj ET +0.9 G 112 258 m 112 298 l S +0.9 G 132.83333333333334 258 m 132.83333333333334 298 l S +0.9 G 153.66666666666666 258 m 153.66666666666666 298 l S +0.9 G 174.5 258 m 174.5 298 l S +0.9 G 195.33333333333331 258 m 195.33333333333331 298 l S +0.9 G 216.16666666666669 258 m 216.16666666666669 298 l S +0.9 G 237 258 m 237 298 l S +0.9 G 257.83333333333337 258 m 257.83333333333337 298 l S +0.9 G 278.66666666666663 258 m 278.66666666666663 298 l S +0.9 G 299.5 258 m 299.5 298 l S +0.9 G 320.33333333333337 258 m 320.33333333333337 298 l S +0.9 G 341.16666666666663 258 m 341.16666666666663 298 l S +0.9 G 362 258 m 362 298 l S +0.9 G 382.8333333333333 258 m 382.8333333333333 298 l S +0.9 G 403.6666666666667 258 m 403.6666666666667 298 l S +0.9 G 424.5 258 m 424.5 298 l S +0.9 G 445.3333333333333 258 m 445.3333333333333 298 l S +0.9 G 466.1666666666667 258 m 466.1666666666667 298 l S +0.9 G 487 258 m 487 298 l S +0.9 G 507.8333333333333 258 m 507.8333333333333 298 l S +0.9 G 528.6666666666667 258 m 528.6666666666667 298 l S +0.9 G 549.5 258 m 549.5 298 l S +0.9 G 570.3333333333333 258 m 570.3333333333333 298 l S +0.9 G 591.1666666666667 258 m 591.1666666666667 298 l S +0.9 G 612 258 m 612 298 l S +0 g BT /F1 8 Tf 205.8 273.0 Td (v) Tj ET +0.75 g 216.17 269.00 83.33 18.00 re f 0 G 216.17 269.00 83.33 18.00 re S +0 g BT /F1 5 Tf 218.2 275.0 Td (SOMMEIL) Tj ET +0.5 g 299.50 269.00 15.63 18.00 re f 0 G 299.50 269.00 15.63 18.00 re S +0.75 g 315.13 269.00 67.71 18.00 re f 0 G 315.13 269.00 67.71 18.00 re S +0 g BT /F1 5 Tf 317.1 275.0 Td (SOMMEIL) Tj ET +0.87 g 382.83 269.00 3.47 18.00 re f 0 G 382.83 269.00 3.47 18.00 re S +0 g BT /F1 8 Tf 388.0 273.0 Td (^) Tj ET +0.93 g 528.67 269.00 12.15 18.00 re f 0 G 528.67 269.00 12.15 18.00 re S +0 g BT /F1 8 Tf 549.5 273.0 Td (S) Tj ET +0 g BT /F1 7 Tf 620.0 282.0 Td (B) Tj ET +0 g BT /F1 7 Tf 658.0 282.0 Td (Moy) Tj ET +0 g BT /F1 7 Tf 696.0 282.0 Td (TB) Tj ET +0 g BT /F1 4 Tf 730.0 291.0 Td (Remarque fran\347aise synth\351tique.) Tj ET +0 g BT /F1 4 Tf 730.0 282.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 273.0 Td () Tj ET +0 g BT /F1 4 Tf 730.0 264.0 Td () Tj ET +0 g BT /F1 9 Tf 30.0 240.0 Td (OBSERVATIONS) Tj ET +0 g BT /F1 6 Tf 30.0 226.0 Td (7 nuits \267 Sommeil d\351clar\351 : 50 h 30 min \267 Longs r\351veils : 7 \267 Siestes : 6 \267 Somnolences : 6 \267 Prises de m\351dicaments : 2) Tj ET +0 g BT /F1 6 Tf 30.0 216.0 Td (AVERTISSEMENT : 7 jours non valid\351s dans cet export.) Tj ET +0 g BT /F1 6 Tf 30.0 204.0 Td (19/09/2026 : Remarque fran\347aise synth\351tique.) Tj ET +0 g BT /F1 6 Tf 30.0 194.0 Td (18/09/2026 : Remarque fran\347aise synth\351tique.) Tj ET +0 g BT /F1 6 Tf 30.0 184.0 Td (17/09/2026 : Remarque fran\347aise synth\351tique.) Tj ET +0 g BT /F1 6 Tf 30.0 28.0 Td (L\351gende : v Mise au lit ; ^ Lever ; ^ Lever nocturne ; S Somnolence ; M Prise de m\351dicament.) Tj ET +0 g BT /F1 6 Tf 30.0 18.0 Td (Sommeil : plage de sommeil ; Sieste : plage de sieste ; Long r\351veil : interruption prolong\351e ; Demi-sommeil : plage de demi-sommeil.) Tj ET +endstream +endobj +xref +0 6 +0000000000 65535 f +0000000019 00000 n +0000000068 00000 n +0000000125 00000 n +0000000222 00000 n +0000000348 00000 n +trailer +<< /Size 6 /Root 1 0 R >> +startxref +18314 +%%EOF diff --git a/docs/reviews/evidence/sleep-diary-fr-7-days.png b/docs/reviews/evidence/sleep-diary-fr-7-days.png new file mode 100644 index 0000000..bd1c2fe Binary files /dev/null and b/docs/reviews/evidence/sleep-diary-fr-7-days.png differ diff --git a/docs/reviews/sleep_diary_pdf_localization_v1_evidence.md b/docs/reviews/sleep_diary_pdf_localization_v1_evidence.md new file mode 100644 index 0000000..68ff375 --- /dev/null +++ b/docs/reviews/sleep_diary_pdf_localization_v1_evidence.md @@ -0,0 +1,42 @@ +# Sleep Diary PDF localization evidence + +## Scope + +This focused 0.1.4 correction changes only the presentation and encoding of the +local vector Sleep Diary PDF. It does not change Sleep Diary event codes, +timestamps, revisions, schemas, synchronization, publication or Android. + +## French localization + +The French PDF owns a complete vocabulary for column headings, timeline +ranges, point markers, legend, observations, draft warning and plurals. +`QUALITÉ DU RÉVEIL` remains an appreciation column; `Long réveil` identifies +the distinct temporal interval. Dates render as two localized lines such as +`20/09/2026` and `au 21/09/2026`. WinAnsi PDF strings preserve French accents +in rendering and text extraction. + +The factual summary uses human-readable durations and correct singular/plural +forms. Medication rows keep the actual intake timestamp, name, localized dose +and unit; same-name intakes with different doses remain separate snapshots. +The English generator uses an independent English vocabulary. + +## Long-awakening geometry + +The regression fixture contains sleep from 23:00 to 03:00, a long awakening +from 03:00 to 03:45 and sleep from 03:45 to 07:00. The generated content draws +three ordered, non-overlapping proportional rectangles. The 45-minute interval +uses a distinct dark fill and visibly interrupts the two lighter sleep ranges; +the French legend names it `Long réveil`, never `AWAKE`. + +## Extraction and visual evidence + +Real Firefox exported a seven-night French PDF through the embedded production +Web bundle and an isolated synthetic database. `pdftotext` confirms the French +headings, legend, observations, pluralized seven-day warning and absence of the +forbidden English labels. `pdfinfo` reports one unencrypted A4 landscape PDF +1.4 page without JavaScript. `pdftoppm` produced the retained raster used for +visual inspection of headings, rows, temporal intervals, medications, +observations, legend and pagination. + +- [Seven-night French PDF](evidence/sleep-diary-fr-7-days.pdf) +- [Rasterized first page](evidence/sleep-diary-fr-7-days.png) diff --git a/docs/tests.md b/docs/tests.md index e00a9a3..9b4a262 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -258,6 +258,13 @@ doses and editable intake-dose prefill. `test_web_sleep_browser.py` exercises the embedded production bundle in real Firefox against an isolated synthetic database, observes every mutation without reload, verifies HTTP success and browser error collectors, then reloads and compares the persisted Agenda. +The PDF suite separately asserts complete French and English vocabularies, +accent-preserving WinAnsi text, human-readable duration and plural forms, +localized dates, medication dose snapshots, pagination and the exact ordered +geometry of sleep / 45-minute long-awakening / sleep intervals. 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. `WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed field and Vitest fixtures confined to test sources. Coverage proves the 90-day diff --git a/tests/test_web_sleep_browser.py b/tests/test_web_sleep_browser.py index b7aede0..5092a67 100644 --- a/tests/test_web_sleep_browser.py +++ b/tests/test_web_sleep_browser.py @@ -9,6 +9,7 @@ import subprocess import tempfile import time import unittest +import uuid from pathlib import Path @@ -69,9 +70,17 @@ class BrowserSleepDiaryTest(unittest.TestCase): break time.sleep(0.025) + EVIDENCE.mkdir(parents=True, exist_ok=True) options = webdriver.FirefoxOptions() options.add_argument("-headless") options.set_capability("webSocketUrl", True) + options.set_preference("browser.download.folderList", 2) + options.set_preference("browser.download.dir", str(EVIDENCE)) + options.set_preference("browser.download.useDownloadDir", True) + options.set_preference( + "browser.helperApps.neverAsk.saveToDisk", "application/pdf" + ) + options.set_preference("pdfjs.disabled", True) driver = webdriver.Firefox(options=options, service=Service(str(GECKO))) driver.set_window_size(1440, 1100) wait = WebDriverWait(driver, 15) @@ -327,13 +336,171 @@ class BrowserSleepDiaryTest(unittest.TestCase): ) ) self.assertEqual("success", json.loads(bidi.recv())["type"]) - bidi.close() - EVIDENCE.mkdir(parents=True, exist_ok=True) self.assertTrue( driver.save_screenshot( str(EVIDENCE / "sleep-diary-live-agenda-firefox.png") ) ) + + def stable_id(prefix: str) -> str: + return f"{prefix}_{uuid.uuid4()}" + + extra_entries = [] + for day in range(14, 20): + start_date = f"2026-09-{day:02d}" + end_date = f"2026-09-{day + 1:02d}" + + def at(clock: str) -> str: + date = start_date if int(clock[:2]) >= 18 else end_date + return f"{date}T{clock}:00+02:00" + + extra_entries.append( + { + "entry_id": "", + "expected_revision": None, + "night_start_date": start_date, + "night_end_date": end_date, + "created_at": at("18:00"), + "updated_at": at("18:00"), + "sleep_quality": "B", + "wake_quality": "Moy", + "day_form": "TB", + "treatment_and_notes": "Remarque française synthétique.", + "events": [ + { + "event_id": stable_id("sle"), + "type": "bed_time", + "start_at": at("22:30"), + "end_at": None, + }, + { + "event_id": stable_id("sle"), + "type": "sleep", + "start_at": at("23:00"), + "end_at": at("03:00"), + }, + { + "event_id": stable_id("sle"), + "type": "long_awake", + "start_at": at("03:00"), + "end_at": at("03:45"), + }, + { + "event_id": stable_id("sle"), + "type": "sleep", + "start_at": at("03:45"), + "end_at": at("07:00"), + }, + { + "event_id": stable_id("sle"), + "type": "half_sleep", + "start_at": at("07:00"), + "end_at": at("07:10"), + }, + { + "event_id": stable_id("sle"), + "type": "final_get_up", + "start_at": at("07:15"), + "end_at": None, + }, + { + "event_id": stable_id("sle"), + "type": "nap", + "start_at": at("14:00"), + "end_at": at("14:35"), + }, + { + "event_id": stable_id("sle"), + "type": "daytime_sleepiness", + "start_at": at("15:00"), + "end_at": None, + }, + ], + "intakes": [], + } + ) + responses = driver.execute_async_script( + """ + const entries=arguments[0], done=arguments[arguments.length-1]; + (async () => { + const status = await fetch('/api/v1/sync/status'); + const token = status.headers.get('X-Trainlog-CSRF-Token'); + const responses = []; + for (const entry of entries) { + const response = await fetch('/api/v1/sleep-diary', { + method: 'POST', + headers: {'Accept':'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':token}, + body: JSON.stringify(entry), + }); + responses.push({status: response.status, body: await response.json()}); + } + done(responses); + })().catch(error => done([{status:0, body:String(error)}])); + """, + extra_entries, + ) + self.assertEqual([200] * 6, [item["status"] for item in responses]) + driver.refresh() + wait.until( + lambda current: len( + current.find_elements( + By.CSS_SELECTOR, "[data-testid^='sleep-agenda-row-']" + ) + ) + == 7 + ) + EVIDENCE.mkdir(parents=True, exist_ok=True) + download_path = EVIDENCE / "trainlog-sleep-diary.pdf" + pdf_path = EVIDENCE / "sleep-diary-fr-7-days.pdf" + raster_prefix = EVIDENCE / "sleep-diary-fr-7-days" + raster_path = EVIDENCE / "sleep-diary-fr-7-days.png" + numbered_download = EVIDENCE / "trainlog-sleep-diary(1).pdf" + for artifact in ( + download_path, + numbered_download, + pdf_path, + raster_path, + ): + if artifact.exists(): + artifact.unlink() + click(driver.find_element(By.XPATH, "//button[normalize-space()='Exporter PDF']")) + wait.until( + lambda current: download_path.is_file() + and download_path.stat().st_size > 0 + ) + download_path.replace(pdf_path) + extracted = root / "sleep-diary-fr-7-days.txt" + subprocess.run( + ["pdftotext", str(pdf_path), str(extracted)], check=True + ) + pdf_text = extracted.read_text(encoding="utf-8") + self.assertIn("QUALITÉ DU", pdf_text) + self.assertIn("LONG RÉVEIL", pdf_text.upper()) + self.assertIn("AVERTISSEMENT : 7 jours non validés", pdf_text) + for forbidden in ( + "WAKE", + "TREATMENT", + "AWAKE", + "sleepiness", + "medication intake", + "nights", + ): + self.assertNotIn(forbidden, pdf_text) + subprocess.run( + [ + "pdftoppm", + "-png", + "-f", + "1", + "-singlefile", + "-r", + "150", + str(pdf_path), + str(raster_prefix), + ], + check=True, + ) + bidi.close() finally: if driver is not None: driver.quit() diff --git a/web/src/report/sleepDiaryPdf.test.ts b/web/src/report/sleepDiaryPdf.test.ts index 49f7ee7..a73125d 100644 --- a/web/src/report/sleepDiaryPdf.test.ts +++ b/web/src/report/sleepDiaryPdf.test.ts @@ -1,36 +1,227 @@ -import { describe, expect, it } from 'vitest' -import { buildSleepDiaryPdf } from './sleepDiaryPdf' -import type { SleepEntry, SleepSnapshot } from '../api/sleepDiary' +import { describe, expect, it } from "vitest"; +import type { SleepEntry, SleepSnapshot } from "../api/sleepDiary"; +import { buildSleepDiaryPdf } from "./sleepDiaryPdf"; -const entry = (index: number): SleepEntry => ({ - publication_status: index === 0 ? 'draft' : 'synchronized', - entry_id: `sl_${index}`, night_start_date: `2026-09-${String(index + 1).padStart(2, '0')}`, - night_end_date: `2026-09-${String(index + 2).padStart(2, '0')}`, created_at: '2026-09-01T18:00:00+02:00', - updated_at: '2026-09-01T18:00:00+02:00', revision_id: `slr_${index}`, sleep_quality: 'B', - wake_quality: 'Moy', day_form: 'TB', treatment_and_notes: 'Synthetic observation', events: [ - { event_id: `sle_${index}`, type: 'sleep', start_at: '2026-09-01T23:30:00+02:00', end_at: '2026-09-02T06:30:00+02:00' }, - ], intakes: [{ intake_id: `mdi_${index}`, medication_id: `med_${index}`, - medication_name: 'Synthetic medication', taken_at: '2026-09-01T22:30:00+02:00', - dose_value: 5, dose_unit: 'mg', note: '', created_at: '2026-09-01T18:00:00+02:00' }], -}) -const snapshot = (count: number): SleepSnapshot => ({ api_version: 1, - entries: Array.from({ length: count }, (_, index) => entry(index)), summary: { nights: count, - long_awake_count: 1, nap_count: 1, sleepiness_count: 1, intake_count: count, sleep_duration_seconds: 25_200, - long_awake_duration_seconds: 1_800, nap_duration_seconds: 1_200, average_bed_minute: 330, - average_get_up_minute: 750 } }) +const day = (value: number) => `2026-09-${String(value).padStart(2, "0")}`; +const timestamp = (date: string, time: string) => `${date}T${time}:00+02:00`; -describe('sleep diary PDF', () => { - it('builds a vector PDF without screen capture', async () => { - const bytes = new Uint8Array(await buildSleepDiaryPdf(snapshot(7), 'fr').arrayBuffer()) - expect(new TextDecoder().decode(bytes.slice(0, 8))).toBe('%PDF-1.4') - expect(new TextDecoder().decode(bytes)).toContain('AGENDA TRAINLOG') - }) +const completeEntry = (index: number): SleepEntry => { + const startDate = day(20 - index); + const endDate = day(21 - index); + const at = (time: string) => + timestamp(Number(time.slice(0, 2)) >= 18 ? startDate : endDate, time); + return { + publication_status: index < 2 ? "draft" : "synchronized", + entry_id: `sl_${index}`, + night_start_date: startDate, + night_end_date: endDate, + created_at: at("18:00"), + updated_at: at("18:00"), + revision_id: `slr_${index}`, + sleep_quality: "B", + wake_quality: "Moy", + day_form: "TB", + treatment_and_notes: "Remarque synthétique complète.", + events: [ + { + event_id: `bed_${index}`, + type: "bed_time", + start_at: at("22:30"), + end_at: null, + }, + { + event_id: `sleep_a_${index}`, + type: "sleep", + start_at: at("23:00"), + end_at: at("03:00"), + }, + { + event_id: `awake_${index}`, + type: "long_awake", + start_at: at("03:00"), + end_at: at("03:45"), + }, + { + event_id: `sleep_b_${index}`, + type: "sleep", + start_at: at("03:45"), + end_at: at("07:00"), + }, + { + event_id: `half_${index}`, + type: "half_sleep", + start_at: at("07:00"), + end_at: at("07:10"), + }, + { + event_id: `night_up_${index}`, + type: "night_get_up", + start_at: at("04:30"), + end_at: null, + }, + { + event_id: `up_${index}`, + type: "final_get_up", + start_at: at("07:15"), + end_at: null, + }, + { + event_id: `nap_${index}`, + type: "nap", + start_at: at("14:00"), + end_at: at("14:35"), + }, + { + event_id: `sleepiness_${index}`, + type: "daytime_sleepiness", + start_at: at("15:00"), + end_at: null, + }, + ], + intakes: [ + ["loxapine", 12.5], + ["diazepam", 5], + ["venlafaxine", 75], + ["venlafaxine", 37.5], + ].map(([name, dose], intakeIndex) => ({ + intake_id: `mdi_${index}_${intakeIndex}`, + medication_id: `med_${index}_${intakeIndex}`, + medication_name: String(name), + taken_at: at("22:30"), + dose_value: Number(dose), + dose_unit: "mg", + note: "", + created_at: at("18:00"), + })), + }; +}; - it('paginates fourteen, twenty-one and thirty days', async () => { +const snapshot = (count: number): SleepSnapshot => ({ + api_version: 1, + entries: Array.from({ length: count }, (_, index) => completeEntry(index)), + summary: { + nights: count, + long_awake_count: count, + nap_count: count, + sleepiness_count: count, + intake_count: count * 4, + sleep_duration_seconds: count * 26_100, + long_awake_duration_seconds: count * 2_700, + nap_duration_seconds: count * 2_100, + average_bed_minute: 270, + average_get_up_minute: 795, + }, +}); + +const textualContent = async (blob: Blob) => { + const source = await blob.text(); + const bytes = source.replace(/\\([0-7]{3})/g, (_, octal: string) => + String.fromCharCode(Number.parseInt(octal, 8)), + ); + return new TextDecoder("windows-1252").decode( + Uint8Array.from(bytes, (character) => character.charCodeAt(0)), + ); +}; + +describe("sleep diary PDF", () => { + it("builds a vector PDF without screen capture", async () => { + const bytes = new Uint8Array( + await buildSleepDiaryPdf(snapshot(7), "fr").arrayBuffer(), + ); + expect(new TextDecoder().decode(bytes.slice(0, 8))).toBe("%PDF-1.4"); + expect( + await textualContent(buildSleepDiaryPdf(snapshot(7), "fr")), + ).toContain("AGENDA TRAINLOG"); + }); + + it("fully localizes the complete French document", async () => { + const content = await textualContent(buildSleepDiaryPdf(snapshot(1), "fr")); + for (const expected of [ + "QUALITÉ DU", + "QUALITÉ DU", + "RÉVEIL", + "FORME DE LA", + "JOURNÉE", + "TRAITEMENT ET", + "REMARQUES", + "SOMMEIL", + "Mise au lit", + "Lever", + "Lever nocturne", + "Somnolence", + "Prise de médicament", + "Sieste", + "Long réveil", + "Demi-sommeil", + "1 nuit", + "Sommeil déclaré : 7 h 15 min", + "AVERTISSEMENT : 1 jour non validé dans cet export.", + "20/09/2026", + "au 21/09/2026", + "loxapine", + "12,5 mg", + "venlafaxine", + "37,5 mg", + ]) + expect(content).toContain(expected); + + for (const forbidden of [ + "SLEEP", + "WAKE", + "DAY", + "TREATMENT", + "AWAKE", + "NAP", + "HALF", + "sleepiness", + "medication intake", + "nights", + ]) + expect(content).not.toContain(forbidden); + }); + + it("draws a proportional 45-minute long-awakening interval between sleep ranges", async () => { + const source = await buildSleepDiaryPdf(snapshot(1), "fr").text(); + const firstSleep = source.indexOf("0.75 g 216.17 509.00 83.33 18.00 re f"); + const longAwakening = source.indexOf( + "0.5 g 299.50 509.00 15.63 18.00 re f", + ); + const secondSleep = source.indexOf("0.75 g 315.13 509.00 67.71 18.00 re f"); + expect(firstSleep).toBeGreaterThan(-1); + expect(longAwakening).toBeGreaterThan(firstSleep); + expect(secondSleep).toBeGreaterThan(longAwakening); + expect( + await textualContent(buildSleepDiaryPdf(snapshot(1), "fr")), + ).not.toContain("AWAKE"); + }); + + it("uses correct French plurals for multiple draft days", 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("jour(s)"); + expect(content).not.toContain("nuit(s)"); + }); + + it("keeps the English document independently localized", async () => { + const content = await textualContent(buildSleepDiaryPdf(snapshot(1), "en")); + expect(content).toContain("SLEEP QUALITY"); + expect(content).toContain("WAKE QUALITY"); + expect(content).toContain("Long awakening"); + expect(content).toContain("Medication intake"); + 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]) { - const text = await buildSleepDiaryPdf(snapshot(count), 'en').text() - expect((text.match(/\/Type \/Page /g) ?? []).length).toBe(Math.ceil(count / 9)) - expect(text).toContain('OBSERVATIONS') + const source = await buildSleepDiaryPdf(snapshot(count), "en").text(); + expect((source.match(/\/Type \/Page /g) ?? []).length).toBe( + Math.ceil(count / 9), + ); + expect(source).toContain("OBSERVATIONS"); } - }) -}) + }); +}); diff --git a/web/src/report/sleepDiaryPdf.ts b/web/src/report/sleepDiaryPdf.ts index b48b2e0..32e1965 100644 --- a/web/src/report/sleepDiaryPdf.ts +++ b/web/src/report/sleepDiaryPdf.ts @@ -1,68 +1,377 @@ -import type { SleepEntry, SleepEventType, SleepSnapshot } from '../api/sleepDiary' +import type { + SleepEntry, + SleepEventType, + SleepSnapshot, +} from "../api/sleepDiary"; +import { formatDose, formatDuration } from "../dashboard/dashboardFormat"; -const ascii = (value: string) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^\x20-\x7e]/g, '?') -const text = (x: number, y: number, size: number, value: string) => `0 g BT /F1 ${size} Tf ${x.toFixed(1)} ${y.toFixed(1)} Td (${ascii(value).replace(/([\\()])/g, '\\$1')}) Tj ET\n` -const minute = (iso: string, night: string) => Math.max(0, Math.min(1440, - (new Date(iso).getTime() - new Date(`${night}T18:00:00`).getTime()) / 60000)) -const labels: Record = { bed_time: 'v', final_get_up: '^', night_get_up: '^', - sleep: 'SLEEP', nap: 'NAP', long_awake: 'AWAKE', half_sleep: 'HALF', daytime_sleepiness: 'S' } +type Language = "fr" | "en"; -function row(stream: string[], entry: SleepEntry, y: number) { - const timelineX = 112; const width = 500 - stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`) - stream.push(text(33, y - 16, 5, `${entry.night_start_date} > ${entry.night_end_date}`)) - for (let hour = 0; hour <= 24; hour++) { const x = timelineX + hour / 24 * width; stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`) } +const eventLabels: Record> = { + fr: { + bed_time: "Mise au lit", + final_get_up: "Lever", + night_get_up: "Lever nocturne", + sleep: "SOMMEIL", + nap: "SIESTE", + long_awake: "LONG RÉVEIL", + half_sleep: "DEMI-SOMMEIL", + daytime_sleepiness: "Somnolence", + }, + en: { + bed_time: "Bedtime", + final_get_up: "Final get-up", + night_get_up: "Night get-up", + sleep: "SLEEP", + nap: "NAP", + long_awake: "LONG AWAKENING", + half_sleep: "HALF-SLEEP", + daytime_sleepiness: "Sleepiness", + }, +}; + +/* + * WHY: the local vector report uses PDF's built-in Helvetica and must retain + * French accents without embedding a second font asset. + * CONTRACT: user-facing strings are emitted as WinAnsi octal bytes and the + * font dictionary declares the same encoding. + * INVARIANT: content streams stay ASCII, so byte offsets and lengths remain + * deterministic under TextEncoder. + */ +const winAnsi: Record = { + "€": 0x80, + "‚": 0x82, + ƒ: 0x83, + "„": 0x84, + "…": 0x85, + "†": 0x86, + "‡": 0x87, + ˆ: 0x88, + "‰": 0x89, + Š: 0x8a, + "‹": 0x8b, + Œ: 0x8c, + Ž: 0x8e, + "‘": 0x91, + "’": 0x92, + "“": 0x93, + "”": 0x94, + "•": 0x95, + "–": 0x96, + "—": 0x97, + "˜": 0x98, + "™": 0x99, + š: 0x9a, + "›": 0x9b, + œ: 0x9c, + ž: 0x9e, + Ÿ: 0x9f, +}; + +const pdfString = (value: string) => + Array.from(value, (character) => { + const codePoint = character.codePointAt(0) ?? 0x3f; + if (character === "\\" || character === "(" || character === ")") + return `\\${character}`; + if (codePoint >= 0x20 && codePoint <= 0x7e) return character; + const byte = + codePoint >= 0xa0 && codePoint <= 0xff + ? codePoint + : (winAnsi[character] ?? 0x3f); + 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 minute = (iso: string, night: string) => + Math.max( + 0, + Math.min( + 1440, + (new Date(iso).getTime() - new Date(`${night}T18:00:00`).getTime()) / + 60000, + ), + ); + +const compactDate = (isoDate: string) => { + const [year, month, day] = isoDate.split("-"); + return `${day}/${month}/${year}`; +}; + +const plural = (count: number, singular: string, pluralForm: string) => + `${count} ${count === 1 ? singular : pluralForm}`; + +const duration = (seconds: number, language: Language) => { + if (language === "fr") + return formatDuration(seconds).replace(/ h 00 min$/, " h"); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) + return `${hours} h${minutes > 0 ? ` ${minutes.toString().padStart(2, "0")} min` : ""}`; + return `${minutes} min`; +}; + +const pointMarker = (type: SleepEventType) => { + if (type === "bed_time") return "v"; + if (type === "final_get_up" || type === "night_get_up") return "^"; + return type === "daytime_sleepiness" ? "S" : ""; +}; + +function row( + stream: string[], + entry: SleepEntry, + y: number, + language: Language, +) { + const timelineX = 112; + const width = 500; + stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`); + stream.push(text(33, y - 13, 5, compactDate(entry.night_start_date))); + stream.push( + text( + 33, + y - 23, + 5, + `${language === "fr" ? "au" : "to"} ${compactDate(entry.night_end_date)}`, + ), + ); + for (let hour = 0; hour <= 24; hour++) { + const x = timelineX + (hour / 24) * width; + stream.push(`0.9 G ${x} ${y - 40} m ${x} ${y} l S\n`); + } entry.events.forEach((event) => { - const x = timelineX + minute(event.start_at, entry.night_start_date) / 1440 * width + const x = + timelineX + + (minute(event.start_at, entry.night_start_date) / 1440) * width; if (event.end_at) { - const eventWidth = Math.max(2, timelineX + minute(event.end_at, entry.night_start_date) / 1440 * width - x) - const shade = event.type === 'sleep' ? 0.75 : event.type === 'half_sleep' ? 0.87 : 0.93 - stream.push(`${shade} g ${x} ${y - 29} ${eventWidth} 18 re f 0 G ${x} ${y - 29} ${eventWidth} 18 re S\n`) - if (eventWidth > 24) stream.push(text(x + 2, y - 23, 5, labels[event.type])) - } else stream.push(text(x, y - 25, 8, labels[event.type])) - }) + // INVARIANT: every interval keeps its canonical absolute start/end on + // the shared 18:00-to-18:00 axis; long awakenings therefore interrupt + // rather than replace or merge the adjacent sleep ranges. + const eventWidth = Math.max( + 2, + timelineX + + (minute(event.end_at, entry.night_start_date) / 1440) * width - + x, + ); + const shade = + event.type === "sleep" + ? 0.75 + : event.type === "long_awake" + ? 0.5 + : event.type === "half_sleep" + ? 0.87 + : 0.93; + stream.push( + `${shade} g ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re f 0 G ${x.toFixed(2)} ${(y - 29).toFixed(2)} ${eventWidth.toFixed(2)} 18.00 re S\n`, + ); + if (eventWidth > 24) + stream.push(text(x + 2, y - 23, 5, eventLabels[language][event.type])); + } else { + stream.push(text(x, y - 25, 8, pointMarker(event.type))); + } + }); entry.intakes.forEach((intake) => { - const x = timelineX + minute(intake.taken_at, entry.night_start_date) / 1440 * width - stream.push(text(x, y - 36, 6, 'M')) - }) - stream.push(text(620, y - 16, 7, entry.sleep_quality ?? '-')); stream.push(text(658, y - 16, 7, entry.wake_quality ?? '-')) - const medicationText = entry.intakes.map((intake) => `${intake.taken_at.slice(11, 16)} ${intake.medication_name}${intake.dose_value === null ? '' : ` ${intake.dose_value} ${intake.dose_unit}`}`).join('; ') - const notes = [medicationText, entry.treatment_and_notes].filter(Boolean).join(' | ') - stream.push(text(696, y - 16, 7, entry.day_form ?? '-')); stream.push(text(730, y - 13, 4, notes.slice(0, 32))) - stream.push(text(730, y - 22, 4, notes.slice(32, 64))) + const x = + timelineX + + (minute(intake.taken_at, entry.night_start_date) / 1440) * width; + stream.push(text(x, y - 36, 6, "M")); + }); + stream.push(text(620, y - 16, 7, entry.sleep_quality ?? "-")); + stream.push(text(658, y - 16, 7, entry.wake_quality ?? "-")); + const medicationText = entry.intakes + .map( + (intake) => + `${intake.taken_at.slice(11, 16)} — ${intake.medication_name}${ + intake.dose_value === null + ? "" + : ` — ${formatDose(intake.dose_value)} ${intake.dose_unit ?? ""}` + }`, + ) + .join(" ; "); + const notes = [medicationText, entry.treatment_and_notes] + .filter(Boolean) + .join(" | "); + stream.push(text(696, y - 16, 7, entry.day_form ?? "-")); + stream.push(text(730, y - 7, 4, notes.slice(0, 40))); + stream.push(text(730, y - 16, 4, notes.slice(40, 80))); + stream.push(text(730, y - 25, 4, notes.slice(80, 120))); + stream.push(text(730, y - 34, 4, notes.slice(120, 160))); } -function page(snapshot: SleepSnapshot, entries: SleepEntry[], index: number, count: number, language: 'fr' | 'en') { - 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')] - for (let hour = 0; hour <= 24; hour++) stream.push(text(112 + hour / 24 * 500, 548, 4, String((18 + hour) % 24))) - stream.push(text(618, 548, 4, 'SLEEP'), text(656, 548, 4, 'WAKE'), text(694, 548, 4, 'DAY'), text(730, 548, 4, 'TREATMENT / NOTES')) - entries.forEach((entry, rowIndex) => row(stream, entry, 538 - rowIndex * 40)) - const y = 520 - entries.length * 40 - stream.push(text(30, y, 9, 'OBSERVATIONS'), text(30, y - 14, 6, `${snapshot.summary.nights} nights; sleep ${Math.round(snapshot.summary.sleep_duration_seconds / 60)} min; long awake ${snapshot.summary.long_awake_count}; naps ${snapshot.summary.nap_count}; sleepiness ${snapshot.summary.sleepiness_count}; intakes ${snapshot.summary.intake_count}`)) - 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: ${unvalidated} jour(s) non valide(s) dans cet export.` - : `WARNING: ${unvalidated} unvalidated day(s) in this export.`)) - entries.filter((entry) => entry.treatment_and_notes).slice(0, 3).forEach((entry, noteIndex) => stream.push(text(30, y - 36 - noteIndex * 10, 6, `${entry.night_start_date}: ${entry.treatment_and_notes.slice(0, 110)}`))) - stream.push(text(30, 18, 6, 'Legend: v bedtime; ^ get-up; S sleepiness; M medication intake; SLEEP sleep; NAP nap; AWAKE long awakening; HALF half-sleep.')) - return stream.join('') +function summaryLine(snapshot: SleepSnapshot, language: Language) { + if (language === "fr") + return [ + plural(snapshot.summary.nights, "nuit", "nuits"), + `Sommeil déclaré : ${duration(snapshot.summary.sleep_duration_seconds, language)}`, + `Longs réveils : ${snapshot.summary.long_awake_count}`, + `Siestes : ${snapshot.summary.nap_count}`, + `Somnolences : ${snapshot.summary.sleepiness_count}`, + `Prises de médicaments : ${snapshot.summary.intake_count}`, + ].join(" · "); + return [ + plural(snapshot.summary.nights, "night", "nights"), + `Declared sleep: ${duration(snapshot.summary.sleep_duration_seconds, language)}`, + `Long awakenings: ${snapshot.summary.long_awake_count}`, + `Naps: ${snapshot.summary.nap_count}`, + `Sleepiness: ${snapshot.summary.sleepiness_count}`, + `Medication intakes: ${snapshot.summary.intake_count}`, + ].join(" · "); +} + +function page( + snapshot: SleepSnapshot, + entries: SleepEntry[], + index: number, + count: number, + language: Language, +) { + 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"), + ]; + for (let hour = 0; hour <= 24; hour++) + stream.push( + text(112 + (hour / 24) * 500, 548, 4, String((18 + hour) % 24)), + ); + if (language === "fr") { + stream.push(text(616, 552, 4, "QUALITÉ DU")); + stream.push(text(616, 545, 4, "SOMMEIL")); + stream.push(text(654, 552, 4, "QUALITÉ DU")); + stream.push(text(654, 545, 4, "RÉVEIL")); + stream.push(text(692, 552, 4, "FORME DE LA")); + stream.push(text(692, 545, 4, "JOURNÉE")); + stream.push(text(728, 552, 4, "TRAITEMENT ET")); + stream.push(text(728, 545, 4, "REMARQUES")); + } else { + stream.push(text(616, 552, 4, "SLEEP QUALITY")); + stream.push(text(654, 552, 4, "WAKE QUALITY")); + stream.push(text(692, 552, 4, "DAY FORM")); + stream.push(text(728, 552, 4, "TREATMENT / NOTES")); + } + entries.forEach((entry, rowIndex) => + row(stream, entry, 538 - rowIndex * 40, language), + ); + const y = 520 - entries.length * 40; + 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.`, + ), + ); + entries + .filter((entry) => entry.treatment_and_notes) + .slice(0, 3) + .forEach((entry, noteIndex) => + stream.push( + text( + 30, + y - 36 - noteIndex * 10, + 6, + `${compactDate(entry.night_start_date)} : ${entry.treatment_and_notes.slice(0, 110)}`, + ), + ), + ); + if (language === "fr") { + stream.push( + text( + 30, + 28, + 6, + "Légende : v Mise au lit ; ^ Lever ; ^ Lever nocturne ; S Somnolence ; M Prise de médicament.", + ), + ); + stream.push( + text( + 30, + 18, + 6, + "Sommeil : plage de sommeil ; Sieste : plage de sieste ; Long réveil : interruption prolongée ; Demi-sommeil : plage de demi-sommeil.", + ), + ); + } else { + stream.push( + text( + 30, + 28, + 6, + "Legend: v Bedtime; ^ Final get-up; ^ Night get-up; S Sleepiness; M Medication intake.", + ), + ); + stream.push( + text( + 30, + 18, + 6, + "Sleep: sleep range; Nap: nap range; Long awakening: prolonged interruption; Half-sleep: half-sleep range.", + ), + ); + } + return stream.join(""); } /** Builds a deterministic local vector document from the Core snapshot, never from screen pixels. */ -export function buildSleepDiaryPdf(snapshot: SleepSnapshot, language: 'fr' | 'en'): Blob { - const groups = Array.from({ length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) }, (_, index) => snapshot.entries.slice(index * 9, (index + 1) * 9)) - const objects = ['<< /Type /Catalog /Pages 2 0 R >>', `<< /Type /Pages /Kids [${groups.map((_, index) => `${4 + index * 2} 0 R`).join(' ')}] /Count ${groups.length} >>`, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'] - groups.forEach((entries, index) => { const content = page(snapshot, entries, index, groups.length, language); objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents ${5 + index * 2} 0 R >>`, `<< /Length ${new TextEncoder().encode(content).length} >>\nstream\n${content}endstream`) }) - let output = '%PDF-1.4\n%Trainlog\n'; const offsets: number[] = [] - objects.forEach((object, index) => { offsets.push(new TextEncoder().encode(output).length); output += `${index + 1} 0 obj\n${object}\nendobj\n` }) - const xref = new TextEncoder().encode(output).length - output += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map((value) => `${String(value).padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n` - return new Blob([new TextEncoder().encode(output)], { type: 'application/pdf' }) +export function buildSleepDiaryPdf( + snapshot: SleepSnapshot, + language: Language, +): Blob { + const groups = Array.from( + { length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) }, + (_, index) => snapshot.entries.slice(index * 9, (index + 1) * 9), + ); + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + `<< /Type /Pages /Kids [${groups.map((_, index) => `${4 + index * 2} 0 R`).join(" ")}] /Count ${groups.length} >>`, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + groups.forEach((entries, index) => { + const content = page(snapshot, entries, index, groups.length, language); + objects.push( + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 842 595] /Resources << /Font << /F1 3 0 R >> >> /Contents ${5 + index * 2} 0 R >>`, + `<< /Length ${new TextEncoder().encode(content).length} >>\nstream\n${content}endstream`, + ); + }); + let output = "%PDF-1.4\n%Trainlog\n"; + const offsets: number[] = []; + objects.forEach((object, index) => { + offsets.push(new TextEncoder().encode(output).length); + output += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + const xref = new TextEncoder().encode(output).length; + output += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map((value) => `${String(value).padStart(10, "0")} 00000 n \n`).join("")}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return new Blob([new TextEncoder().encode(output)], { + type: "application/pdf", + }); } export function presentSleepDiaryPdf(blob: Blob, download: boolean) { - const url = URL.createObjectURL(blob) - if (download) { const link = document.createElement('a'); link.href = url; link.download = 'trainlog-sleep-diary.pdf'; link.click() } - else window.open(url, '_blank', 'noopener,noreferrer') - window.setTimeout(() => URL.revokeObjectURL(url), 60_000) + const url = URL.createObjectURL(blob); + if (download) { + const link = document.createElement("a"); + link.href = url; + link.download = "trainlog-sleep-diary.pdf"; + link.click(); + } else window.open(url, "_blank", "noopener,noreferrer"); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); }