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
|
## 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
|
- Made the Sleep Diary Agenda a live projection of the latest locally
|
||||||
persisted entry revision. Event and intake mutations now re-read and adopt
|
persisted entry revision. Event and intake mutations now re-read and adopt
|
||||||
the canonical Core snapshot, serialized autosaves carry the returned
|
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 tiles | `WEB_DASHBOARD_TILES_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
| Web Dashboard visualizations | `WEB_DASHBOARD_VISUALIZATIONS_V1=PASS/FROZEN` |
|
||||||
| Web Dashboard V1 | `WEB_DASHBOARD_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 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) |
|
| 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) |
|
| 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
|
the embedded production bundle in real Firefox against an isolated synthetic
|
||||||
database, observes every mutation without reload, verifies HTTP success and
|
database, observes every mutation without reload, verifies HTTP success and
|
||||||
browser error collectors, then reloads and compares the persisted Agenda.
|
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
|
`WEB_DASHBOARD_TILES_V1` adds strict TypeScript parsing for every consumed
|
||||||
field and Vitest fixtures confined to test sources. Coverage proves the 90-day
|
field and Vitest fixtures confined to test sources. Coverage proves the 90-day
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -69,9 +70,17 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
break
|
break
|
||||||
time.sleep(0.025)
|
time.sleep(0.025)
|
||||||
|
|
||||||
|
EVIDENCE.mkdir(parents=True, exist_ok=True)
|
||||||
options = webdriver.FirefoxOptions()
|
options = webdriver.FirefoxOptions()
|
||||||
options.add_argument("-headless")
|
options.add_argument("-headless")
|
||||||
options.set_capability("webSocketUrl", True)
|
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 = webdriver.Firefox(options=options, service=Service(str(GECKO)))
|
||||||
driver.set_window_size(1440, 1100)
|
driver.set_window_size(1440, 1100)
|
||||||
wait = WebDriverWait(driver, 15)
|
wait = WebDriverWait(driver, 15)
|
||||||
|
|
@ -327,13 +336,171 @@ class BrowserSleepDiaryTest(unittest.TestCase):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual("success", json.loads(bidi.recv())["type"])
|
self.assertEqual("success", json.loads(bidi.recv())["type"])
|
||||||
bidi.close()
|
|
||||||
EVIDENCE.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
driver.save_screenshot(
|
driver.save_screenshot(
|
||||||
str(EVIDENCE / "sleep-diary-live-agenda-firefox.png")
|
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:
|
finally:
|
||||||
if driver is not None:
|
if driver is not None:
|
||||||
driver.quit()
|
driver.quit()
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,227 @@
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from "vitest";
|
||||||
import { buildSleepDiaryPdf } from './sleepDiaryPdf'
|
import type { SleepEntry, SleepSnapshot } from "../api/sleepDiary";
|
||||||
import type { SleepEntry, SleepSnapshot } from '../api/sleepDiary'
|
import { buildSleepDiaryPdf } from "./sleepDiaryPdf";
|
||||||
|
|
||||||
const entry = (index: number): SleepEntry => ({
|
const day = (value: number) => `2026-09-${String(value).padStart(2, "0")}`;
|
||||||
publication_status: index === 0 ? 'draft' : 'synchronized',
|
const timestamp = (date: string, time: string) => `${date}T${time}:00+02:00`;
|
||||||
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 } })
|
|
||||||
|
|
||||||
describe('sleep diary PDF', () => {
|
const completeEntry = (index: number): SleepEntry => {
|
||||||
it('builds a vector PDF without screen capture', async () => {
|
const startDate = day(20 - index);
|
||||||
const bytes = new Uint8Array(await buildSleepDiaryPdf(snapshot(7), 'fr').arrayBuffer())
|
const endDate = day(21 - index);
|
||||||
expect(new TextDecoder().decode(bytes.slice(0, 8))).toBe('%PDF-1.4')
|
const at = (time: string) =>
|
||||||
expect(new TextDecoder().decode(bytes)).toContain('AGENDA TRAINLOG')
|
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]) {
|
for (const count of [14, 21, 30]) {
|
||||||
const text = await buildSleepDiaryPdf(snapshot(count), 'en').text()
|
const source = await buildSleepDiaryPdf(snapshot(count), "en").text();
|
||||||
expect((text.match(/\/Type \/Page /g) ?? []).length).toBe(Math.ceil(count / 9))
|
expect((source.match(/\/Type \/Page /g) ?? []).length).toBe(
|
||||||
expect(text).toContain('OBSERVATIONS')
|
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, '?')
|
type Language = "fr" | "en";
|
||||||
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' }
|
|
||||||
|
|
||||||
function row(stream: string[], entry: SleepEntry, y: number) {
|
const eventLabels: Record<Language, Record<SleepEventType, string>> = {
|
||||||
const timelineX = 112; const width = 500
|
fr: {
|
||||||
stream.push(`0.7 G 30 ${y - 40} 782 40 re S\n`)
|
bed_time: "Mise au lit",
|
||||||
stream.push(text(33, y - 16, 5, `${entry.night_start_date} > ${entry.night_end_date}`))
|
final_get_up: "Lever",
|
||||||
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`) }
|
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) => {
|
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) {
|
if (event.end_at) {
|
||||||
const eventWidth = Math.max(2, timelineX + minute(event.end_at, entry.night_start_date) / 1440 * width - x)
|
// INVARIANT: every interval keeps its canonical absolute start/end on
|
||||||
const shade = event.type === 'sleep' ? 0.75 : event.type === 'half_sleep' ? 0.87 : 0.93
|
// the shared 18:00-to-18:00 axis; long awakenings therefore interrupt
|
||||||
stream.push(`${shade} g ${x} ${y - 29} ${eventWidth} 18 re f 0 G ${x} ${y - 29} ${eventWidth} 18 re S\n`)
|
// rather than replace or merge the adjacent sleep ranges.
|
||||||
if (eventWidth > 24) stream.push(text(x + 2, y - 23, 5, labels[event.type]))
|
const eventWidth = Math.max(
|
||||||
} else stream.push(text(x, y - 25, 8, labels[event.type]))
|
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) => {
|
entry.intakes.forEach((intake) => {
|
||||||
const x = timelineX + minute(intake.taken_at, entry.night_start_date) / 1440 * width
|
const x =
|
||||||
stream.push(text(x, y - 36, 6, 'M'))
|
timelineX +
|
||||||
})
|
(minute(intake.taken_at, entry.night_start_date) / 1440) * width;
|
||||||
stream.push(text(620, y - 16, 7, entry.sleep_quality ?? '-')); stream.push(text(658, y - 16, 7, entry.wake_quality ?? '-'))
|
stream.push(text(x, y - 36, 6, "M"));
|
||||||
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(620, y - 16, 7, entry.sleep_quality ?? "-"));
|
||||||
stream.push(text(696, y - 16, 7, entry.day_form ?? '-')); stream.push(text(730, y - 13, 4, notes.slice(0, 32)))
|
stream.push(text(658, y - 16, 7, entry.wake_quality ?? "-"));
|
||||||
stream.push(text(730, y - 22, 4, notes.slice(32, 64)))
|
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') {
|
function summaryLine(snapshot: SleepSnapshot, 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')]
|
if (language === "fr")
|
||||||
for (let hour = 0; hour <= 24; hour++) stream.push(text(112 + hour / 24 * 500, 548, 4, String((18 + hour) % 24)))
|
return [
|
||||||
stream.push(text(618, 548, 4, 'SLEEP'), text(656, 548, 4, 'WAKE'), text(694, 548, 4, 'DAY'), text(730, 548, 4, 'TREATMENT / NOTES'))
|
plural(snapshot.summary.nights, "nuit", "nuits"),
|
||||||
entries.forEach((entry, rowIndex) => row(stream, entry, 538 - rowIndex * 40))
|
`Sommeil déclaré : ${duration(snapshot.summary.sleep_duration_seconds, language)}`,
|
||||||
const y = 520 - entries.length * 40
|
`Longs réveils : ${snapshot.summary.long_awake_count}`,
|
||||||
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}`))
|
`Siestes : ${snapshot.summary.nap_count}`,
|
||||||
const unvalidated = snapshot.entries.filter((entry) => entry.publication_status === 'draft' || entry.publication_status === 'modified').length
|
`Somnolences : ${snapshot.summary.sleepiness_count}`,
|
||||||
if (unvalidated > 0) stream.push(text(30, y - 24, 6, language === 'fr'
|
`Prises de médicaments : ${snapshot.summary.intake_count}`,
|
||||||
? `AVERTISSEMENT: ${unvalidated} jour(s) non valide(s) dans cet export.`
|
].join(" · ");
|
||||||
: `WARNING: ${unvalidated} unvalidated day(s) in this export.`))
|
return [
|
||||||
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)}`)))
|
plural(snapshot.summary.nights, "night", "nights"),
|
||||||
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.'))
|
`Declared sleep: ${duration(snapshot.summary.sleep_duration_seconds, language)}`,
|
||||||
return stream.join('')
|
`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. */
|
/** Builds a deterministic local vector document from the Core snapshot, never from screen pixels. */
|
||||||
export function buildSleepDiaryPdf(snapshot: SleepSnapshot, language: 'fr' | 'en'): Blob {
|
export function buildSleepDiaryPdf(
|
||||||
const groups = Array.from({ length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) }, (_, index) => snapshot.entries.slice(index * 9, (index + 1) * 9))
|
snapshot: SleepSnapshot,
|
||||||
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 >>']
|
language: Language,
|
||||||
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`) })
|
): Blob {
|
||||||
let output = '%PDF-1.4\n%Trainlog\n'; const offsets: number[] = []
|
const groups = Array.from(
|
||||||
objects.forEach((object, index) => { offsets.push(new TextEncoder().encode(output).length); output += `${index + 1} 0 obj\n${object}\nendobj\n` })
|
{ length: Math.max(1, Math.ceil(snapshot.entries.length / 9)) },
|
||||||
const xref = new TextEncoder().encode(output).length
|
(_, index) => snapshot.entries.slice(index * 9, (index + 1) * 9),
|
||||||
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' })
|
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) {
|
export function presentSleepDiaryPdf(blob: Blob, download: boolean) {
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob);
|
||||||
if (download) { const link = document.createElement('a'); link.href = url; link.download = 'trainlog-sleep-diary.pdf'; link.click() }
|
if (download) {
|
||||||
else window.open(url, '_blank', 'noopener,noreferrer')
|
const link = document.createElement("a");
|
||||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000)
|
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