fix(report): localize sleep diary pdf
This commit is contained in:
parent
a04e41d252
commit
9652b35189
9 changed files with 1202 additions and 88 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
391
docs/reviews/evidence/sleep-diary-fr-7-days.pdf
Normal file
391
docs/reviews/evidence/sleep-diary-fr-7-days.pdf
Normal 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
|
||||
BIN
docs/reviews/evidence/sleep-diary-fr-7-days.png
Normal file
BIN
docs/reviews/evidence/sleep-diary-fr-7-days.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
42
docs/reviews/sleep_diary_pdf_localization_v1_evidence.md
Normal file
42
docs/reviews/sleep_diary_pdf_localization_v1_evidence.md
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue