fix(report): localize sleep diary pdf

This commit is contained in:
fy59 2026-09-21 17:09:24 +02:00
parent a04e41d252
commit 9652b35189
9 changed files with 1202 additions and 88 deletions

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View file

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

View file

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

View file

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

View file

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

View file

@ -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<SleepEventType, string> = { 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<Language, Record<SleepEventType, string>> = {
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<string, number> = {
"€": 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);
}