Compare commits

..

20 Commits
v1.7.0 ... main

Author SHA1 Message Date
902d521094 v1.13.0 (web) : reco prédictive — valeur E2 au creux, régime poolé, cible de creux — miroir Android
- predictedE2 : valeur brute au creux × facteur du ester (auto-calibration
  ON requise — sinon rien, une valeur brute serait trompeuse) ; rendu
  coloré face à la cible dans labs.js + home.js.
- pooledRegimeDoses : les doses de tous les traitements partageant (ester
  effectif, mg) forment UNE séquence — le re-parenting d'historique
  (« 6d-old ») ne redémarre plus la stabilisation. Garde-fous : ester ≠
  jamais poolé, dose ≠ exclue.
- Cible de creux E2 (opt-in) : store + carte settings + backup
  (rétrocompatible — un backup ancien n'efface pas la cible locale) +
  i18n FR/EN. ⚠️ DISTINCT des seuils d'alerte : jamais de notification.
- 15 tests (pooling ×3, statut cible, round-trip backup) ; leçon E2E :
  bump WEB_VERSION sans section CHANGELOG = dialog vide.

199 tests + E2E verts (check.sh). WEB_VERSION alignée sur 1.13.0.
2026-10-07 09:58:52 +02:00
0e9a5672fb Docs §10 : étape 4 (releases) jamais optionnelle — backfill v1.9.5/v1.10.0→v1.12.0 (leçon : commit + tag ≠ release) 2026-09-30 20:48:22 +02:00
4bdcd52970 v1.12.0 (web) : fix #70 (inactifs sans projection) + page Doses (glyphes de forme, marqueurs prise de sang) — miroir Android
- FIX #70 : generateForecastDoses garde !isActive — un traitement archivé
  à Posologie ne projette plus de créneaux ; l'horizon du chip Prévision
  (chart.js) ne considère que les Posologies ACTIVES. §6.bis inchangé :
  l'historique reste simulé, seul le FUTUR s'arrête. 3 tests miroirs.
- Page Doses : routeGlyph (💊💉💧🩹🧴, miroir routeIcon) + encarts
  « prise de sang le même jour » 🧪↑/🧪↓ (labMarkersForDose, miroir) ;
  i18n FR/EN ; 10 tests doses-extras.test.js (⚠️ makeDose(ts, amount,
  overrides)).

194 tests + E2E verts (check.sh). WEB_VERSION alignée sur 1.12.0.
2026-09-29 23:24:18 +02:00
0dd5fa7dfd v1.11.0 (web) : inactifs regroupés en bas de la page Traitements + cartes atténuées — miroir Android
- Helper pur treatmentsForDisplay dans js/data/models.js (miroir du Kotlin,
  tri STABLE : actifs d'abord, inactifs après, ordre relatif conservé dans
  chaque groupe) — demande : « peu discernables, à placer à la fin ».
- Rendu : en-tête « Traitements inactifs » (seulement s'il en existe),
  cartes opacity 0.55 en plus du badge — lisibles et cliquables.
- Clés i18n inactive_section FR/EN ; WEB_VERSION alignée sur 1.11.0.
- 4 tests treatments-display.test.js (miroir TreatmentsDisplayTest.kt).

184 tests + E2E verts (check.sh).
2026-09-29 07:30:59 +02:00
f36e25ef62 Docs : audit post-release v1.10.0 — statut README, comptes de tests (180), description lab-timing alignée sur le fix #68 2026-09-27 23:16:59 +02:00
b865b1606f v1.10.0 (web) : fix #68 — creux pré-injection dans la reco de prise de sang (miroir Android)
Le « creux » était le minimum de la fenêtre entière (créneau N−1 → N) :
pour un ester à montée lente (EEn, pic ~J+5 ≈ intervalle 7 j) il tombait
juste après l'injection PRÉCÉDENTE — date antérieure à la stabilisation
affichée sur la même carte, et « juste avant ton injection du … » faux de
plusieurs jours. Le creux est désormais le niveau PRÉ-INJECTION du créneau
(principe v1.8.0 restauré ; EV inchangé — le point pré-injection EST son
minimum de fenêtre). WEB_VERSION alignée sur 1.10.0.

180 tests + E2E verts (check.sh) ; suite lab-timing inchangée (sa
sémantique épinglée était celle du fix).
2026-09-27 23:10:10 +02:00
ff4a1374db v1.9.7 (web) : tolérance ± 24 h de l'écart inter-doses — miroir Android
La règle de stabilisation comparait l'écart inter-doses EXACTEMENT à
l'écart précédent : un log 30 min plus tard cassait le régime et
repoussait la stabilisation de 5 × t½ à chaque injection (creux fuyant).

Fix : chaque écart doit rester dans l'intervalle de Posologie ± 24 h
(GAP_TOLERANCE_MS, fenêtre vs l'intervalle THÉORIQUE) — des logs à 6,8 j
puis 7,2 j ne se déstabilisent plus en cascade ; un vrai changement de
créneau reste hors fenêtre.

Tests : « interval change » ré-épinglé (+21 j) + 2 tests de tolérance
miroirs (12 verts lab-timing). WEB_VERSION 1.9.7 + CHANGELOG/README.
2026-09-21 22:20:21 +02:00
048a76e540 v1.9.6 (web) : la carte Accueil liste TOUTES les prochaines doses (triées) — miroir Android
- helper PUR nextDoseLineText dans format.js (miroir du Kotlin
  nextDoseLine) : « Xj Yh · sam. 26 10:00 (nom) » > 24 h, « XhMM » sinon,
  « N min » dans l'heure
- home.js : une ligne par traitement, triées par prochaine prise, 1ʳᵉ en
  avant, titre pluriel « Prochaines doses » dès 2 lignes (i18n EN/FR)
- 3 tests miroirs dans misc.test.js (178 verts + E2E)
- WEB_VERSION 1.9.6 + CHANGELOG/README
2026-09-21 21:57:04 +02:00
be4d26b3e5 v1.9.5 : revue de code — garde cutoffHours ESE, portage optimisations v1.8.2, perf canvas, docs alignées
Corrigé (audit) :
- cutoffHours : garde hasModel pour ESE (parité Kotlin) — un backup
  importé ESE + ester non couvert levait TypeError → écran vide
- min/max en une passe : les spreads Math.min(...allPoints) levaient
  RangeError au-delà de ~1e5 arguments (Nuage × zoom max) et allouaient
  par frame
- init MCMC non bloquante : data-ready garantit désormais « asset chargé »

Perf (portage v1.8.2 Android, fin de la dérive de miroir) :
- prepareE2Context/e2AtCtx : doses groupées, cutoffs précalculés, Bateman
  paresseux ; computeCurve ne recalcule plus cutoffHours par point
- résultat identique (175 tests verts)

E2E : version lue via data-version (anti-bug v1.9.3)

Docs/commentaires : pk-engine (ESE analytique), pk-calibration (JSDoc
arrondi), dialogs (ESE=6), lab-timing (import mort, JSDoc orpheline),
home (COLORS), pk-profile-store (test-only), pk_profiles.json exclu du
zip (export-ignore, −548 Ko)
2026-09-20 12:55:51 +02:00
f79a180e36 v1.9.4 (web) : presets ESE étendus à 6 esters + éditeur dynamique + portage v1.9.0 complété
- presets.js : EC huile (5 mg IM), EB (1 mg IM), EUCS suspension
  cristalline (10 mg SC) — 25 entrées, champs Bateman informatifs
- pk-engine.js : Esters.EUCS manquant (portage v1.9.0 incomplet — le
  preset référençait undefined)
- models.js : choicesForModel ESE = 6 esters (parité Android v1.9.0 ;
  était resté sur la couverture des tables ODS à 3) + KDoc à jour
- treatment-editor.js : liste des esters = choicesForModel(model) —
  rebascule sur EV si l'ester sort de la couverture au changement de
  modèle (garde anti 0 pg/mL) + re-render du formulaire
- tests/presets.test.js (nouveau, 6) : couverture 6 esters ESE, unicité,
  i18n EN/FR, TFS 7 / WHSAH 6 inchangés, choicesForModel aligné dispatchs
- versions 1.9.4 (settings + e2e) + CHANGELOG/§4/§8/README
2026-09-19 23:29:40 +02:00
624e047f39 v1.9.4 (web) : presets ESE étendus (EC/EB/EUCS) + i18n — miroir Android
Le modèle ESE analytique (v1.9.0) couvre 6 esters injectables : presets
ajoutés pour EC (huile), EB et EUCS (undécylate suspension cristalline,
exclusif à ce fit) — les champs Bateman reflètent les t½ terminales des
fits (informatifs). Miroir de PKPresets.kt Android.
2026-09-19 22:59:28 +02:00
6d8efc81c0 Docs : §4 arbre (estrannaise-models/cloud + tests + rounding), §8 suites estrannaise/rounding à jour 2026-09-19 22:42:41 +02:00
f8bfbdcf40 v1.9.3 (web) : correction du numéro de version annoncé
Le zip v1.9.2 publié embarquait settings.js 1.9.1 (le commit de fix du
nuage n'incluait pas WEB_VERSION). WEB_VERSION 1.9.3, CHANGELOG [1.9.3]
(méta-correction : aucune autre différence avec 1.9.2), e2e + README à
jour.
2026-09-19 22:22:57 +02:00
ee953507ef v1.9.2 (suite) : WEB_VERSION 1.9.2 + e2e versions + README statut
Suivi du commit précédent : le remplacement de WEB_VERSION (1.9.0 →
cible inexistante, le fichier était en 1.9.1) avait échoué
silencieusement, laissant les assertions e2e et le README en retard.
Corrigé : WEB_VERSION 1.9.2, assertions e2e 1.9.2, README web v1.9.2
(169 tests). check --release vert.
2026-09-19 22:19:57 +02:00
fab5a2abfb v1.9.2 : fix nuage vide sans traitement stocké ESE (filtre par ester effectif) — miroir Android
- estrannaise-cloud.js : le filtre des doses ne teste PLUS le pkModel
  STOCKÉ du traitement — le nuage couvre toutes les doses E2 à profil
  injectable dont l'ESTER EFFECTIF (override compris) est couvert par le
  fit Estrannaise (identique à l'ensemble redessiné par
  computeCurve(modelOverride = "ESE")). L'exclusivité ESE reste portée
  par le chip ; l'oral Bateman reste hors nuage ; les traitements
  inactifs sont inclus (§6.bis).
- Test réécrit : « les doses d'un traitement TFS sont couvertes quand ESE
  est affiché » (épinglé des deux plateformes) ; oral seul → vide.
- WEB_VERSION 1.9.2. CHANGELOG [1.9.2], README statut.
2026-09-19 22:16:13 +02:00
fc1b6e1700 v1.9.1 : fix nuage calibré (il entoure la courbe ESE calibrée) + harmonisation arrondi (miroir Android)
- estrannaise-cloud.js : paramètre scalePerEster — le nuage reçoit la
  MÊME calibration que la courbe ESE (autoByModel ESE). Avant : courbe ESE
  calibrée mais nuage BRUT → échelles différentes (remontée v1.9.1 :
  « le nuage ne s'active que autour du tracé, pas autour du modèle
  Estrannaise »).
- chart.js : autoByModel.get('ESE')?.esterScales passé au nuage ;
  DEFAULT_NB_CURVES importé.
- +1 test : nuage calibré ×2 = nuage brut ×2 (167 verts au total).
- CHANGELOG [1.9.1] : fix nuage calibré + HARMONISATION de l'arrondi de
  la médiane de calibration (le web avait la troncature historique — les
  niveaux web/android convergent ; le niveau correct = l'ARRONDI).
- WEB_VERSION 1.9.1.
2026-09-19 21:14:27 +02:00
174928bb6b v1.9.0 : modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC
Miroir fidèle du Kotlin (doc Android §7.12) :
- estrannaise-models.js : forme close 3C de estrannaise.js (paramètres
  publiés EV/EU/EEn/EC/EB/EUCS), posterior MCMC (313 échantillons/ester,
  asset mcmc_samples.json ≈ 48 Ko fetché au démarrage — remplace le fetch
  des tables ODS, 550 Ko), t½ terminale analytique, mapping en objets
  {d,k1,k2,k3} (miroir du Param Kotlin).
- estrannaise-cloud.js : nuage d'incertitude — 32 courbes du posterior
  (échelonnées, déterministes) superposant les doses des porteurs ESE ;
  exclusif ESE (TFS/WHSAH sans posterior publié), gardes (sans dose,
  nbCurves<2, MCMC non chargé → vide).
- pk-engine.js : dispatch ESE → forme close (plus de fallback PKProfileStore
  au runtime) ; cutoffHours ESE → 10 × t½ analytique ; lab-timing.js t½ ESE
  analytique ; app.js fetch MCMC remplace le fetch des tables ODS
  (démarrage plus rapide).
- chart.js/chart-canvas.js : chip « Nuage » (off, activable si ESE affiché,
  coupure auto si ESE off), clé 'CLOUD' skippée des légendes (leçon #63),
  nuage dessiné en alpha faible, légende dédiée ; seed démo + traitement
  EV/ESE (2 doses) pour démontrer le nuage ; choicesForModel ESE=6.
- Tests : estrannaise-models.test.js (6 : fidélité RMS/pics, dégénérés,
  MCMC 313×6, t½ analytique, gardes) + estrannaise-cloud.test.js (5 :
  32 courbes à dispersion réelle, fenêtre, exclusivité ESE, gardes)
  → 166 verts + E2E (chip/légende/exclusivité nuage) + check --release.
- WEB_VERSION 1.9.0 (versions sync Android). CHANGELOG [1.9.0] web.
2026-09-19 20:45:53 +02:00
61683ba5c4 v1.8.1 : fix stabilisation (changements récents vus) + encart Accueil (miroir Android)
- lab-timing.js : computeRegimeStartMs — le régime courant = séquence
  terminale de doses à (ester effectif, dose, écart inter-doses) constants
  (écart comparé EXACTEMENT) : tout changement récent de dose/ester/
  posologie réinitialise la stabilisation. L'ancien proxy (1ʳᵉ dose du
  traitement) disait « stabilisée depuis février » malgré des changements
  récents (remontée v1.8.0). L'encart porte l'ESTER EFFECTIF de la
  dernière dose (override compris).
- home.js : encart « Prochaine prise de sang (suggestion) » sur l'ACCUEIL
  (demande) — creux daté + créneau associé + mention de stabilisation
  seulement si le régime n'était pas déjà stable.
- 3 nouveaux tests miroirs (155 verts) : dose/intervalle/ester → reset
  épinglé. WEB_VERSION 1.8.1. CHANGELOG [1.8.1], README.
2026-09-17 19:47:49 +02:00
f5951e1806 v1.8.0 : recommandation de prochaine prise de sang (portage Android, miroir)
- js/pk/lab-timing.js : miroir fidèle de pk/LabTiming.kt — creux prévisionnel
  (minimum de la courbe E2 entre 2 injections), saut au premier creux
  STABILISÉ (5 × t½ terminale : TFS/WHS analytiques, Estrannaise lue dans
  la table via le nouveau pkProfileStore.terminalHalfLifeDays — refactor
  de la pente d'extrapolation de sample(), zéro duplication), filtres
  honnêtes (injectable actif + Posologie requis, creux ≥ now+6 h et jamais
  déjà mesuré, horizon borné → null), shouldSuggestPosology (invite quand
  un injectable actif est sans Posologie — demande v1.8.0).
- labs.js : encart « Prochaine prise de sang (suggestion) » ou invite,
  mutuellement exclusifs (miroir carte Android).
- i18n FR/EN (labrec_*), WEB_VERSION 1.8.0, assertions E2E.
- tests/lab-timing.test.js : 7 tests miroirs (152 verts au total) + E2E
  (encart rendu : titre, creux daté, créneau, stabilisation).
- Doc : §4 arbre, §8 suite + E2E, CHANGELOG [1.8.0], README.
2026-09-17 18:46:51 +02:00
1957b73e3c v1.7.1 : fix « une seule note sur deux » dans les Analyses (miroir Android)
- format.js : labNotesForDisplay (helper PUR, miroir du Kotlin) — notes
  DISTINCTES d'une prise E2+T → une ligne par note préfixée du marqueur
  (« E2 : … » / « T : … ») ; note identique (dialog commun) → dédupliquée ;
  vides ignorées ; note seule → brute (v1.0 préservé). L'ancien code
  (.find() première non vide) perdait la note T à l'affichage quand la
  note E2 existait aussi (conservée en base, juste non montrée).
- labs.js : affiche TOUTES les lignes du helper (l'ancienne n'en rendait
  qu'une).
- app.js (seed ?demo=1) : la dernière paire E2/T porte des notes
  distinctes → fixture du scénario E2E.
- E2E : les deux notes préfixées s'affichent sur #labs.
- tests/lab-notes.test.js : 5 tests miroirs du Kotlin (145 verts au total).
- CHANGELOG [1.7.1] (l'oubli de cette entrée aurait laissé le dialog
  « Nouveautés » vide — attrapé par l'E2E, leçon #46 miroir), §4 arbre
  tests, README. WEB_VERSION 1.7.1.
2026-09-17 12:30:59 +02:00
39 changed files with 2598 additions and 158 deletions

4
.gitattributes vendored Normal file
View File

@ -0,0 +1,4 @@
# v1.9.5 (audit) : les tables ODS ne sont plus fetchées au runtime (analytique
# v1.9.0) — l_asset ne survit que pour le TEST de fidélité RMS (miroir Android
# src/test/assets). Exclu du zip de release (git archive) : −548 Ko livrés.
assets/pk_profiles.json export-ignore

View File

@ -17,10 +17,15 @@
> Fie-toi toujours à tes analyses de sang et aux consignes de ton > Fie-toi toujours à tes analyses de sang et aux consignes de ton
> endocrinologue. > endocrinologue.
- **Statut** : web v1.7.0 — parité fonctionnelle avec l'Android v1.7.0 - **Statut** : web v1.13.0 — parité fonctionnelle avec l'Android v1.13.0
(unités des axes portées ; l'auto-backup journalier Android est sur la logique miroirée (reco de prise de sang : **valeur E2 attendue au
structurellement non porté, cf §12 — l'export manuel couvre la donnée) · creux** quand l'auto-calibration est ON + **régime poolé** — un
**140 tests verts** · E2E navigateur ✅ · conteneur testé en re-parenting d'historique ne redémarre plus la stabilisation + **cible de
creux** opt-in colorant la prédiction, jamais de notification ; le fix
#70 et les extras Doses de la v1.12 restent couverts) ;
l'auto-backup journalier et les notifications Android restent
structurellement non portés, cf §12 ·
**199 tests verts** · E2E navigateur ✅ · conteneur testé en
configuration durcie ✅ · lint/i18n ✅ configuration durcie ✅ · lint/i18n ✅
- **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases) - **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases)

1
assets/mcmc_samples.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,305 @@
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog > section la plus récente EN PREMIER ; ce fichier est lu par le dialog
> « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`). > « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`).
## [1.13.0] — 2026-09-30
### Ajouté — recommandation de prise de sang : valeur prédite, régime poolé, cible de creux
- **Valeur E2 attendue au creux recommandé** : la carte affiche désormais
« ≈ X pg/mL » quand l'auto-calibration fournit ses facteurs (sinon rien —
une valeur brute non calibrée serait trompeuse). Miroir de
`LabRecommendation.predictedE2`.
- **Régime poolé** (`pooledRegimeDoses`, miroir du Kotlin) : les doses de
tous les traitements partageant (ester effectif, mg) forment UNE séquence
— un re-parenting d'historique (« … - old » créé a posteriori) ne
redémarre plus la stabilisation. Garde-fous : ester ≠ → jamais poolé ;
dose ≠ → exclue (le trou qui suit casse la séquence, conservateur).
- **Cible de creux E2** (opt-in, Paramètres) : référence personnelle pour la
prochaine prise de sang — la valeur prédite est colorée dans/hors cible.
⚠️ DISTINCT des seuils d'alerte (surveillance continue + notifications) :
la cible ne notifie jamais. Voyage dans le backup (champs optionnels,
rétrocompatibles).
Vérifié : 198 tests + E2E verts (`check.sh`).
## [1.12.0] — 2026-09-29
### Corrigé — un traitement inactif à Posologie projetait encore ses injections (#70)
- Miroir de l'Android v1.12.0 (remontée : « un traitement mis en inactif ne
devrait plus être pris en compte pour les futures prédictions ») :
`generateForecastDoses` (js/pk/pk-engine.js) garde `!isActive` — et
l'horizon du chip Prévision du graphique (js/ui/chart.js) ne considère
que les Posologies ACTIVES.
- §6.bis inchangé : l'historique d'un inactif reste simulé — seul le FUTUR
s'arrête. 3 tests miroirs (inactif vide, actif inchangé, avant/après
archivage).
### Ajouté — page Doses : glyphes de forme de prise + marqueurs « prise de sang le même jour »
- Chaque ligne de dose porte le glyphe de sa forme (💊 oral, 💉 injection
IM/SC, 💧 gel, 🩹 patch, 🧴 autre — helper pur `routeGlyph` dans
js/data/models.js, miroir de `routeIcon`).
- Une analyse de sang LE MÊME JOUR qu'une dose affiche un encart latéral
🧪↑ (avant la dose) et/ou 🧪↓ (après) — helper pur `labMarkersForDose`
(js/util/format.js, jour calendaire LOCAL ; lab à l'heure exacte =
APRÈS, convention documentée).
- 10 tests (`tests/doses-extras.test.js`, miroirs de DosesExtrasTest.kt).
Vérifié : 194 tests verts + E2E (`check.sh`).
## [1.11.0] — 2026-09-29
### Ajouté — les traitements inactifs regroupés en bas de la page Traitements
- Miroir de l'Android v1.11.0 (demande : « les traitements inactifs sont
peu discernables — les placer à la fin, et peut-être les rendre plus
distincts ») : les ACTIFS d'abord, les INACTIFS regroupés **tout en bas**
sous un en-tête « Traitements inactifs » (seulement s'il en existe),
cartes **atténuées** (opacity 0,55) en plus du badge.
- Tri STABLE : l'ordre relatif à l'intérieur de chaque groupe est conservé.
Helper pur `treatmentsForDisplay` dans `js/data/models.js` (miroir du
Kotlin, node-safe) — 4 tests (`tests/treatments-display.test.js`,
miroir de TreatmentsDisplayTest.kt).
Vérifié : 184 tests verts + E2E (`check.sh`).
## [1.10.0] — 2026-09-27
### Corrigé — la recommandation de prise de sang précédait la stabilisation (#68)
- Miroir de l'Android v1.10.0 (remontée + données réelles → 6ᵉ régression
épinglée côté Android) : le « creux » était le **minimum de la fenêtre
entière** (créneau N−1 → créneau N). Pour un ester à montée lente (EEn :
pic ~J+5 ≈ intervalle 7 j), ce minimum tombe **juste après l'injection
PRÉCÉDENTE** (creux d'absorption, ~3 h) — la date proposée était gouvernée
par l'injection N−1, antérieure à la stabilisation, et le texte « juste
avant ton injection du … » était faux de plusieurs jours.
- **Fix** : le creux = **niveau PRÉ-INJECTION du créneau** (dernier point de
courbe avant l'injection) — restaure le principe v1.8.0 documenté et rend
la carte cohérente. Pour les esters à t½ courte (EV), le point
pré-injection EST le minimum de fenêtre : comportement inchangé.
- Tests : la suite lab-timing (12) passe inchangée — sa sémantique épinglée
(« creux juste avant l'injection, écart ≤ 2 h ») était déjà celle du fix ;
180 tests + E2E verts (`check.sh`).
Les autres changements de la v1.10.0 Android (dialog fantôme #69, rappel
sauté si dose déjà loggée) n'ont pas d'équivalent web (notifications et
intents de notification sont spécifiques Android).
## [1.9.7] — 2026-09-20
### Corrigé — la suggestion de prise de sang « change tout le temps » à chaque injection
- Miroir de l'Android v1.9.7 (remontée + données réelles) : la règle de
stabilisation comparait l'écart inter-doses **EXACTEMENT** à l'écart
précédent — un log 30 min plus tard cassait le régime et repoussait la
stabilisation de 5 × t½ à chaque injection (creux recommandé fuyant).
- **Fix (tolérance)** : chaque écart doit rester dans **l'intervalle de
Posologie ± 24 h** (`GAP_TOLERANCE_MS`, fenêtre vs l'intervalle
THÉORIQUE). Un vrai changement de créneau (2 j au lieu de 7 j) reste
hors fenêtre. Tests : « interval change » ré-épinglé (+21 j) + 2 tests
de tolérance miroirs (12 verts lab-timing).
## [1.9.6] — 2026-09-20
### Modifié — la carte de l'accueil liste TOUTES les prochaines prises
- Miroir de l'Android v1.9.6 (remontée : le rappel quotidien CPA masquait
le rappel hebdomadaire EEn — « je ne le vois quasiment pas ») : la carte
« Prochaine dose » devient « **Prochaines doses** » (titre pluriel dès 2
lignes) — **une ligne par traitement, TRIÉES par prochaine prise** ;
la 1ʳᵉ ligne (la plus proche) reste mise en avant. Format de ligne
inchangé (`Xj Yh · sam. 26 10:00 (nom)`, `XhMM · HH:mm`, `N min`).
- Moteur : helper PUR `nextDoseLineText` dans `format.js` (miroir du
Kotlin `nextDoseLine`) — 3 tests miroirs dans misc.test.js.
- Les NOTIFICATIONS de rappel sont inchangées.
## [1.9.5] — 2026-09-20
### Corrigé — revue de code post-v1.9.4 (audit lecture-seule des deux dépôts)
- **Garde de `cutoffHours` pour ESE** (parité du Kotlin) : la branche ESE ne
vérifiait pas `hasModel(ester)` — un traitement IMPORTÉ (le backup accepte
toute string de pkModel/ester) avec un ester non couvert (ex. ECS/PEP en
ESE) levait `TypeError` et laissait un écran vide. Retombe maintenant sur
la garde Bateman (≥ 24 h), comme TFS/WHS le faisaient déjà.
- **min/max en UNE passe** : les spreads `Math.min(...allPoints)` (≈ 5 par
frame) lèvent `RangeError` au-delà de ~10⁵ arguments — atteignable avec
le Nuage actif × zoom max (≈ 230 000 points) — et allouaient un tableau
intermédiaire par appel. Remplacés par une boucle (chart.js +
chart-canvas.js).
- **Init MCMC non bloquante** : le démarrage n'attend plus le fetch de
l'asset (seul le nuage lit le posterior, qui renvoie `[]` proprement) ;
l'init re-rend à la fin et pose `data-ready` — la garantie E2E
« data-ready ⇒ asset chargé » est maintenant RÉELLE (le commentaire
antérieur était faux : le marqueur était posé avant le fetch).
- **Portage des optimisations Android v1.8.2** (la dérive de miroir était
réelle) : contexte moteur pré-calculé (`prepareE2Context`/`e2AtCtx` —
doses groupées, cutoffs par traitement, Bateman paresseux),
`computeCurve` ne recalcule plus `cutoffHours` PAR POINT × traitement ni
le scan complet de doseLogs. Résultats identiques (175 tests verts).
- **E2E version paramétrée** : les asserts lisent `data-version` posé par
l'app au lieu de strings codées en dur à bump à chaque release (l'oubli a
produit le bug v1.9.3).
### Documenté — miroir nettoyé
- En-tête de `pk-engine.js` à jour (ESE analytique v1.9.0, plus de tables
ODS) ; JSDoc « troncature » de `pk-calibration.js` alignée sur
`Math.round` (fix v1.9.2) ; `dialogs.js` (ESE=6) ; import mort et JSDoc
orpheline de `lab-timing.js` retirés ; couleurs de l'accueil via
`COLORS` (source unique avec le canvas).
- **`pk_profiles.json` exclu du zip de release** (`.gitattributes`
export-ignore, −548 Ko livrés) : plus fetché au runtime depuis v1.9.0 —
l'asset ne survit que pour le test de fidélité RMS (miroir Android),
`pk-profile-store.js` documenté test-only.
## [1.9.4] — 2026-09-19
### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables
- **Remontée** : depuis l'ajout du modèle source Estrannaise (v1.9.0), les
presets de création n'offraient toujours que EV/EU/EEn — « les presets
n'ont pas été mis à jour lors de l'ajout du modèle source pour ESE ».
- **Presets** : 3 presets ESE ajoutés (25 entrées, miroir de
`PKPresets.kt`) — EC huile (5 mg IM), EB (1 mg IM), EUCS undécylate
suspension cristalline (10 mg SC, ester exclusif à ce fit). Champs
Bateman informatifs = t½ terminales des fits (pas utilisés au calcul).
- **Éditeur** : la liste des esters suit la couverture RÉELLE du modèle
choisi (`choicesForModel`, parité Android v1.9.0) — ESE liste ses 6
esters injectables ; au changement de modèle, si l'ester courant sort de
la couverture (ex. EUCS + TFS), il rebascule sur EV (garde anti
« 0 pg/mL ») et le formulaire se re-rend.
- **Corrigé (portage v1.9.0 incomplet)** : `Esters.EUCS` manquait dans
`pk-engine.js` (le preset référençait `undefined`) et `choicesForModel`
(`js/data/models.js`) était resté sur la couverture des tables ODS
(3 esters) avec sa KDoc obsolète « produirait 0 pg/mL ».
- **Tests** : `tests/presets.test.js` (nouveau, 6 tests) — couverture des
6 esters ESE, unicité, clés i18n EN/FR, TFS (7) / WHSAH (6) inchangés,
`choicesForModel` aligné sur les dispatchs. Suite web : 175 tests.
## [1.9.3] — 2026-09-19
### Corrigé — le numéro de version annoncé
- Le dialog « Nouveautés » et Paramètres annonçaient **1.9.1** au lieu de
1.9.2 (le commit de fix du nuage n'incluait pas la mise à jour de
`WEB_VERSION`) — méta-correction de version, **aucune autre différence
avec 1.9.2** (mêmes fonctionnalités que l'Android 1.9.2).
## [1.9.2] — 2026-09-19
### Corrigé — le nuage d'incertitude exigeait un traitement STOCKÉ en ESE
- Miroir du fix Android v1.9.2 : le filtre du nuage testait le `pkModel`
**STOCKÉ** du traitement, alors que la courbe ESE affichée redessine
toutes les doses E2 avec `modelOverride = "ESE"` — le nuage restait vide
dès qu'aucun traitement n'était stocké en ESE.
- **Fix** : le nuage couvre **toutes les doses E2 à profil injectable dont
l'ester effectif (override compris) est couvert par le fit
Estrannaise** — indépendamment du modèle stocké. L'exclusivité ESE
reste portée par le chip. L'oral Bateman reste hors nuage.
- Test réécrit : « les doses d'un traitement TFS sont couvertes quand ESE
est affiché » (épinglé des deux plateformes) ; oral seul → vide.
## [1.9.1] — 2026-09-19
### Corrigé — le nuage d'incertitude entoure maintenant la courbe Estrannaise calibrée
- Miroir du fix Android v1.9.1 : la courbe ESE affichée est calibrée
(scalePerEster des labs) mais le nuage était tracé brut → échelles
différentes. Le nuage reçoit maintenant le MÊME scalePerEster que la
courbe ESE.
- Harmonisation de l'arrondi de la médiane de calibration (le web avait
la troncature historique) : les niveaux estimés web et Android
convergent. Le niveau correct = l'ARRONDI (plus juste que la
troncature, cf CHANGELOG Android 1.9.1).
## [1.9.0] — 2026-09-18
### Modèle Estrannaise ANALYTIQUE + nuage d'incertitude (portage Android v1.9.0, versions sync)
- **Le modèle ESE n'est plus les tables horaires ODS** : il est désormais la
**forme close 3C publiée par estrannaise.js** (les sources originelles
dont l'ODS n'était que l'échantillonnage horaire) — fidélité épinglée :
RMS 0,00 vs les tables sur 0→200 j, écart de pic ≤ 0,1 %. Les tables ne
sont plus fetchées au démarrage (−550 Ko, démarrage plus rapide) ;
`pk_profiles.json` reste uniquement comme référence des tests.
- **6 esters injectables couverts** (EV, EU, EEn, EC, EB + **EUCS**, undécylate
suspension cristalline, exclusif à ce fit) — l'ancien ESE n'en couvrait
que 3.
- **NUAGE D'INCERTITUDE (exclusif ESE)** : Estrannaise publie le posterior
MCMC de ses paramètres (313 échantillons/ester) — chip `Nuage`
(off par défaut, activable à volonté, actif SEULEMENT si ESE est affiché)
→ nuage diffus de courbes montrant la plage d'imprécision du modèle,
comme sur le site estrannaise. TFS/WHSAH ne publient pas de posterior →
pas de nuage pour eux.
- t½ terminale ESE désormais **analytique** (ln2/min(k1,k2,k3)) — la
recommandation de prise de sang n'estime plus la t½ par pente de table.
- **Tests** : fidélité (RMS, pics), cas dégénérés, MCMC (313 × 6), nuage
(32 courbes, dispersion, exclusivité ESE), t½ analytique.
## [1.8.1] — 2026-09-17
### Corrigé — la stabilisation ignorait les changements récents (dose/ester/intervalle)
- **Bug remonté** : la recommandation disait « stabilisée depuis février »
alors que l'ester, le dosage ET la posologie venaient de changer —
l'ancien calcul partait de la **1ʳᵉ dose du traitement** (proxy aveugle
aux changements).
- **Fix** : le régime courant = la **séquence terminale de doses** à
(ester effectif, dose mg, **écart inter-doses**) constants — tout
changement récent réinitialise la stabilisation : la recommandation saute
au premier creux **après** la stabilisation du nouveau régime (règle des
~5 demi-vies conservée). L'encart porte l'ester **effectif** de la
dernière dose (override compris).
- **Nouveau : encart sur l'Accueil** (demande) — la suggestion de
prochaine prise de sang est désormais aussi visible directement sur la
page d'accueil (creux daté + créneau associé, mention de stabilisation
si le régime n'était pas déjà stable).
- 3 nouveaux tests miroirs (155 au total) : changement de dose / de
posologie (intervalle) / d'ester → réinitialisation épinglée.
## [1.8.0] — 2026-09-17
### Prochaine prise de sang recommandée — portage de l'Android v1.8.0 (versions sync)
- **Nouvel encart sur la page Analyses** : « Prochaine prise de sang
(suggestion) » — quand un traitement E2 **injectable actif a une
Posologie**, l'app déduit de ses courbes **le creux estimé juste avant
l'injection suivante** (heure exacte + créneau associé + mention « ou
simplement la veille »), **au premier creux où l'ester est stabilisé**
(~5 demi-vies terminales après le dernier changement — on saute les
creux « de transition »).
- `js/pk/lab-timing.js` : miroir fidèle du Kotlin (créneaux
generateForecastDoses, courbe E2 fine sans calibration, filtres
maintenant/+6 h/dernière prise/stabilisation). Le modèle Estrannaise lit
sa demi-vie dans les tables (`pkProfileStore.terminalHalfLifeDays`,
refactor miroir), TFS/WHSAH analytiquement.
- **Sans Posologie** sur l'injectable actif : encart remplacé par l'invite
« renseigne une Posologie… » (demande v1.8.0) — le lien est découvrable.
- 7 tests miroirs (152 au total) + **scénario E2E** (encart rendu sur #labs
avec la démo EEn 7 j : creux daté, créneau, stabilisation).
## [1.7.1] — 2026-09-16
### Corrigé — une seule note sur deux s'affichait dans les Analyses
- **Bug remonté** (identique Android) : dans une prise de sang E2+T, quand
les deux entrées portent des notes **distinctes** (ex. une note clinique
sur l'E2, une note « DHT » sur la T), une seule s'affichait — l'autre
était bien conservée mais perdue à l'affichage.
- **Cause** : l'ancien code ne prenait que la première note non vide du
groupe (hypothèse historique « toutes identiques » — vraie à l'époque du
dialog de création, fausse depuis l'édition unitaire).
- **Fix** : `labNotesForDisplay` (helper PUR dans `format.js`, miroir du
Kotlin) — notes distinctes → une ligne par note préfixée du marqueur
(« E2 : … » / « T : … ») ; note identique → une seule ligne ; vides ignorées.
- Épinglé par : 5 tests unitaires (miroir du Kotlin) + **scénario E2E** (la
dernière paire de démo porte des notes distinctes, les deux s'affichent
sur #labs) + régression data-driven Android sur l'export réel.
## [1.7.0] — 2026-09-13 ## [1.7.0] — 2026-09-13
### Unités des axes du graphique — portage de l'Android v1.7.0 (versions sync) ### Unités des axes du graphique — portage de l'Android v1.7.0 (versions sync)

View File

@ -60,7 +60,7 @@ Règles du portage, fixées au départ :
| Choix | Décision | Pourquoi | | Choix | Décision | Pourquoi |
|---|---|---| |---|---|---|
| Langage | JavaScript ES modules natifs (pas de TS, pas de build) | zéro chaîne de compilation → le déploiement = copier le dossier ; les types critiques sont documentés en JSDoc et épinglés par 132 tests | | Langage | JavaScript ES modules natifs (pas de TS, pas de build) | zéro chaîne de compilation → le déploiement = copier le dossier ; les types critiques sont documentés en JSDoc et épinglés par 199 tests |
| Framework UI | Aucun — DOM via un helper `el()` (`ui/components.js`) | même philosophie que la v1 Android (pas de ViewModel/DI) ; une seule abstraction à connaître | | Framework UI | Aucun — DOM via un helper `el()` (`ui/components.js`) | même philosophie que la v1 Android (pas de ViewModel/DI) ; une seule abstraction à connaître |
| Rendu graphique | Canvas 2D natif (portage de `CurveChart.kt`) | pas de lib de charts : fidélité du rendu, zéro dépendance | | Rendu graphique | Canvas 2D natif (portage de `CurveChart.kt`) | pas de lib de charts : fidélité du rendu, zéro dépendance |
| Persistance | `localStorage` (clés préfixées `hormonetrack.`) | données petites (JSON) ; backend INJECTABLE → testable en Node (`store.setBackend(new Map())`) | | Persistance | `localStorage` (clés préfixées `hormonetrack.`) | données petites (JSON) ; backend INJECTABLE → testable en Node (`store.setBackend(new Map())`) |
@ -170,7 +170,8 @@ HormoneTrack-web/ (dépôt séparé)
│ │ ρ log-linéaire, fenêtre labs) │ │ ρ log-linéaire, fenêtre labs)
│ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état, │ ├── alerts.js Alerts.kt : seuils opt-in, codec d'état,
│ │ │ shouldNotify (anti-spam) │ │ │ shouldNotify (anti-spam)
│ │ ├── presets.js PKPresets.kt (22 presets, clés i18n) │ │ ├── presets.js PKPresets.kt (25 presets — v1.9.4 :
│ │ │ les 6 esters injectables ESE)
│ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt : │ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt :
│ │ │ panDeltaHours (#62), xLabelTicks (#55), │ │ │ panDeltaHours (#62), xLabelTicks (#55),
│ │ │ pointHoursBefore (#53), clampPanHours (#54), │ │ │ pointHoursBefore (#53), clampPanHours (#54),
@ -243,6 +244,28 @@ HormoneTrack-web/ (dépôt séparé)
│ ├── pk-engine.test.js ← PharmacokineticEngineTest.kt (+ #35, §6.bis) │ ├── pk-engine.test.js ← PharmacokineticEngineTest.kt (+ #35, §6.bis)
│ ├── calibration.test.js ← CalibrationPerModel/ScaleFactorWhsahRepro/ │ ├── calibration.test.js ← CalibrationPerModel/ScaleFactorWhsahRepro/
│ │ V120Features (fixes #60/#61, périodes d'ester) │ │ V120Features (fixes #60/#61, périodes d'ester)
│ ├── estrannaise-models.test.js ← EstrannaiseModelsTest.kt (v1.9.0 :
│ │ fidélité RMS 0, pics, dégénérés, MCMC
│ │ 313×6, t½ analytique)
│ ├── estrannaise-cloud.test.js ← EstrannaiseCloudTest.kt (v1.9.0 :
│ │ 32 courbes, dispersion, exclusivité)
│ ├── rounding.test.js ← round2/round3 arrondis (v1.9.2 :
│ │ harmonisation Android, fin de la
│ │ troncature historique)
│ ├── presets.test.js ← PKPresetsTest.kt (v1.9.4 : presets des
│ │ 6 esters ESE, unicité, i18n EN/FR,
│ │ choicesForModel aligné dispatchs)
│ ├── lab-timing.test.js ← LabTimingTest.kt (v1.8.0 : prochaine
│ │ prise de sang recommandée — creux avant
│ │ créneau, saut de stabilisation, gardes)
│ ├── lab-notes.test.js ← labNotesForDisplay (v1.7.1 : notes d'une
│ │ prise E2+T — les DEUX distinctes affichées)
│ ├── treatments-display.test.js ← treatmentsForDisplay (v1.11.0 : actifs
│ │ d'abord, inactifs regroupés en bas — tri
│ │ stable)
│ ├── doses-extras.test.js ← fix #70 garde prévision inactifs +
│ │ routeGlyph + labMarkersForDose (v1.12.0)
│ │ + régime poolé + statut cible (v1.13.0)
│ ├── lab-trajectory-model.test.js ← LabTrajectoryModelTest.kt (v1.5.0 │ ├── lab-trajectory-model.test.js ← LabTrajectoryModelTest.kt (v1.5.0
│ │ « Tracé labs » + v1.6.0 prolongation — │ │ « Tracé labs » + v1.6.0 prolongation —
│ │ passage exact, garde #61, horizon cutoff) │ │ passage exact, garde #61, horizon cutoff)
@ -388,7 +411,7 @@ Pièges rencontrés :
|---|---|---|---| |---|---|---|---|
| Syntaxe | `node --check` | tous les modules ES | `bash scripts/check.sh` | | Syntaxe | `node --check` | tous les modules ES | `bash scripts/check.sh` |
| i18n | `scripts/i18n-check.mjs` | clés FR/EN synchronisées + clés utilisées | (dans check.sh) | | i18n | `scripts/i18n-check.mjs` | clés FR/EN synchronisées + clés utilisées | (dans check.sh) |
| Unitaires | `node --test` | **132 tests** : noyau PK, calibration, backup, store, helpers, alertes, rappels, changelog, tracé labs (v1.5.0) | `npm test` | | Unitaires | `node --test` | **199 tests** : noyau PK (ESE analytique/TFS/WHS), calibration, backup, store, helpers, alertes, rappels, changelog, tracé labs, estrannaise-models/cloud, rounding, presets (v1.9.4), ordre d'affichage des traitements (v1.11.0), garde prévision inactifs + extras Doses (v1.12.0), régime poolé + prédiction creux + statut cible (v1.13.0) | `npm test` |
| E2E navigateur | playwright-core + build Firefox | app réelle : rendu, canvas peint (pixels), navigation, dialog changelog, langue | `npm run e2e` | | E2E navigateur | playwright-core + build Firefox | app réelle : rendu, canvas peint (pixels), navigation, dialog changelog, langue | `npm run e2e` |
| Smoke HTTP | curl | ressources clés en 200 | `check.sh --with-serve` | | Smoke HTTP | curl | ressources clés en 200 | `check.sh --with-serve` |
@ -430,6 +453,30 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
null), dose EV loguée après le dernier lab → pic de prolongation, null), dose EV loguée après le dernier lab → pic de prolongation,
garde #61 sur la prolongation, flag off = v1.5.0 bit-compatible, garde #61 sur la prolongation, flag off = v1.5.0 bit-compatible,
`lastAnchorMs`. `lastAnchorMs`.
- **lab-timing** : v1.8.0 « prochaine prise de sang recommandée » (miroir
du Kotlin, §7.11 Android) : creux = niveau PRÉ-INJECTION du créneau
(**fix #68 v1.10.0**), **régime POOLÉ entre traitements identiques**
(**v1.13.0** — `pooledRegimeDoses`, le re-parenting d'historique ne
redémarre plus la stabilisation), **prédiction E2 au creux**
(`predictedE2`, seulement si calibration fournie) et **statut cible**
(`troughTargetStatus` — jamais de notification, distinct des seuils
d'alerte), saut au premier creux STABILISÉ (5 × t½ — TFS/WHS
analytiques, Estrannaise lue dans la table via terminalHalfLifeDays),
filtres (≥ now+6 h, > dernière prise, Posologie requise), invite
« renseigne une Posologie », oral seul → null.
- **estrannaise-models / estrannaise-cloud / rounding** : v1.9.0/v1.9.2 —
modèle Estrannaise ANALYTIQUE (fidélité RMS 0 vs tables ODS, pics ±0,2 %,
cas dégénérés sans NaN, MCMC 313×6 esters, t½ analytique EV=ln2/0,236)
+ nuage d'incertitude (32 courbes à dispersion réelle, fenêtre,
exclusivité ESE — TFS/oral hors nuage, gardes vides) + **round2/round3
ARRONDIS** (v1.9.2 : fin de la troncature historique — harmonisation des
niveaux web/Android, cf CHANGELOG Android 1.8.2) ;
- **presets** : v1.9.4 (miroir `PKPresetsTest.kt`) — les presets ESE
couvrent les 6 esters du fit analytique (unicité, clés i18n EN/FR),
TFS (7) / WHSAH (6) inchangés, et **`choicesForModel` aligné sur les
dispatchs** (ESE 6 / TFS 7 explicite sans EUCS / WHS 6) — l'éditeur et
le dialog de dose ne peuvent proposer un ester que le moteur sait
tracer ;
- **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés, - **backup** : round-trip v2, rétrocompat v1, écrasement + IDs conservés,
**imports des exports Android réels** (data-driven, hors dépôt). **imports des exports Android réels** (data-driven, hors dépôt).
- **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55), - **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55),
@ -472,6 +519,8 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
vérifie que le chip reste sélectionné ET la légende affichée ; puis vérifie que le chip reste sélectionné ET la légende affichée ; puis
navigation (quitter → revenir) et vérifie le RESET aux défauts (miroir navigation (quitter → revenir) et vérifie le RESET aux défauts (miroir
du `remember` Android quitté). du `remember` Android quitté).
- **v1.8.0 : encart « prochaine prise de sang »** sur #labs (démo EEn 7 j)
— titre, creux daté, créneau d'injection associé, mention de stabilisation ;
- vérifie l'**absence d'erreur console/page** sur toute la session ; - vérifie l'**absence d'erreur console/page** sur toute la session ;
- captures dans `/tmp/hrt-web-shots/` (inspection visuelle). - captures dans `/tmp/hrt-web-shots/` (inspection visuelle).
- ⚠️ Course au screenshot évitée par le marqueur `body[data-ready="1"]` - ⚠️ Course au screenshot évitée par le marqueur `body[data-ready="1"]`
@ -515,8 +564,9 @@ fonctionnalités (sauf impossibilités structurelles §12).
**v1.4.10 = la première release web publiée (8 sept. 2026, cloudyfy)** : **v1.4.10 = la première release web publiée (8 sept. 2026, cloudyfy)** :
tag + release Gitea avec l'archive zip de déploiement, APRES validation tag + release Gitea avec l'archive zip de déploiement, APRES validation
complète (132 tests unitaires + E2E navigateur local + container-test réel complète (132 tests unitaires + E2E navigateur local + container-test réel
en configuration durcie). Le dépôt farewell reste en attente de la création en configuration durcie). Le repo farewell a été créé depuis — les releases
du repo (one-shot côté Gitea). y sont publiées sur les DEUX instances (backfill v1.9.5 + v1.10.0→v1.12.0
le 29 sept. 2026, cf étape 4).
### Remotes ### Remotes
@ -562,8 +612,14 @@ push initial v1.2.3, cf doc Android §16.)
attache l'archive `HormoneTrack-web-vX.Y.Z.zip` construite PAR attache l'archive `HormoneTrack-web-vX.Y.Z.zip` construite PAR
`git archive` AU TAG (jamais le working tree), vérifiée par `git archive` AU TAG (jamais le working tree), vérifiée par
téléchargement (uid des leçons #37/#43 Android : une invocation fait téléchargement (uid des leçons #37/#43 Android : une invocation fait
tout, vérif par téléchargement, échec bruyant). Idem `farewell` une tout, vérif par téléchargement, échec bruyant). Idem `farewell`.
fois le repo créé. ⚠️ **ÉTAPE JAMAIS OPTIONNELLE** (constat 29 sept. 2026) : les tags
v1.10.0 → v1.12.0 avaient été poussés SANS leurs releases (étape
sautée par l'assistant) — découvert par l'utilisatrice, backfill
publié sur les DEUX instances le jour même, + **v1.9.5 backfillée
sur farewell** (jamais publiée à l'époque, seule cloudyfy l'avait
été). Leçon : « commit + tag » n'est PAS « release » — la checklist
s'arrête quand le zip est vérifié par téléchargement.
5. **Smoke-test** : Android sur le Pixel 9 (checklist §21 Android) ; web 5. **Smoke-test** : Android sur le Pixel 9 (checklist §21 Android) ; web
sur le navigateur : import d'un backup réel, pan/zoom, export/import, sur le navigateur : import d'un backup réel, pan/zoom, export/import,
langue. langue.

View File

@ -64,6 +64,9 @@ export function buildBackupJson(collections, tConfig, userSettings = null) {
alertE2Low: userSettings.alertE2Low ?? null, alertE2Low: userSettings.alertE2Low ?? null,
alertTHigh: userSettings.alertTHigh ?? null, alertTHigh: userSettings.alertTHigh ?? null,
alertTLow: userSettings.alertTLow ?? null, alertTLow: userSettings.alertTLow ?? null,
// v1.13.0 : cible de creux (champs optionnels — rétrocompatibles)
troughTargetLow: userSettings.troughTargetLow ?? null,
troughTargetHigh: userSettings.troughTargetHigh ?? null,
}; };
} }
return JSON.stringify(data); return JSON.stringify(data);

View File

@ -40,10 +40,10 @@ export const AdministrationRoute = {
/** /**
* Esters proposables dans l'override par dose pour un MODÈLE PK donné * Esters proposables dans l'override par dose pour un MODÈLE PK donné
* (miroir de `Esters.choicesForModel`, v1.4.6 — la liste suit la couverture * (miroir de `Esters.choicesForModel` — la liste suit la couverture RÉELLE
* du modèle : TFS = 7 esters ; WHSAH = 6 (PEP non couvert) ; Estrannaise * du modèle : TFS = 7 esters ; WHSAH = 6 (PEP non couvert) ; Estrannaise
* (tables ODS) = EV/EU/EEn seulement — un traitement ESE + EB produirait * ANALYTIQUE (v1.9.0) = 6 esters injectables, dont EUCS exclusif à ce fit —
* 0 pg/mL). * un ester hors liste produirait 0 pg/mL : aucun fallback silencieux).
* *
* @param {string} pkModel * @param {string} pkModel
* @returns {string[]} * @returns {string[]}
@ -51,6 +51,7 @@ export const AdministrationRoute = {
export function choicesForModel(pkModel) { export function choicesForModel(pkModel) {
if (pkModel === 'TFS') return ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS', 'PEP']; if (pkModel === 'TFS') return ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS', 'PEP'];
if (pkModel === 'WHS') return ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS']; if (pkModel === 'WHS') return ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS'];
if (pkModel === 'ESE') return ['EV', 'EU', 'EEN', 'EC', 'EB', 'EUCS']; // v1.9.4 : analytique (parité Android v1.9.0)
return ['EV', 'EU', 'EEN']; return ['EV', 'EU', 'EEN'];
} }
@ -210,3 +211,43 @@ export function newLabResult(init = {}) {
...init, ...init,
}; };
} }
/**
* ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, miroir fidèle de
* `ui/screens/TreatmentsScreen.kt` — même KDoc) : les ACTIFS d'abord, les
* INACTIFS regroupés TOUT EN BAS — l'ancien ordre brut du store mélangeait
* les deux groupes et le badge discret ne suffisait plus à les discerner
* (« peu discernables », remontée).
*
* Tri STABLE : l'ordre relatif À L'INTÉRIEUR de chaque groupe est conservé.
* PUR (aucune dépendance DOM) → testable en node
* (tests/treatments-display.test.js, miroir de TreatmentsDisplayTest.kt).
*
* @param {Array<object>} treatments
* @returns {Array<object>} nouvelle liste triée (l'entrée n'est pas mutée)
*/
export function treatmentsForDisplay(treatments) {
return [...treatments].sort((a, b) => Number(b.isActive) - Number(a.isActive));
}
/**
* GLYPHE de FORME DE PRISE (v1.12.0, miroir de `routeIcon` —
* `ui/screens/DosesScreen.kt`, même KDoc) : l'app web n'a pas d'icônes
* Material → émojis, même mapping : ORAL = pilule 💊 ; IM/SC = seringue 💉
* (glyphe partagé, la page Doses différencie par le texte de la ligne) ;
* GEL = goutte 💧 ; PATCH = sparadrap 🩹 ; OTHER/inconnu = compte-gouttes
* 🧴. PUR → testable en node (tests/doses-extras.test.js).
*
* @param {string|null} route
* @returns {string} émoji
*/
export function routeGlyph(route) {
switch (route) {
case 'ORAL': return '💊';
case 'INJECTION_IM':
case 'INJECTION_SUBCUT': return '💉';
case 'TRANSDERMAL_GEL': return '💧';
case 'TRANSDERMAL_PATCH': return '🩹';
default: return '🧴';
}
}

View File

@ -226,6 +226,8 @@ class WebStore {
alertE2Low: null, alertE2Low: null,
alertTHigh: null, alertTHigh: null,
alertTLow: null, alertTLow: null,
troughTargetLow: null, // cible de creux (v1.13.0, opt-in — référence de lab, JAMAIS de notification)
troughTargetHigh: null,
chartTimezone: null, // fuseau de LECTURE du graphique (null = auto) chartTimezone: null, // fuseau de LECTURE du graphique (null = auto)
changelogSeenVersion: null, // dialog « Nouveautés » changelogSeenVersion: null, // dialog « Nouveautés »
alertNotifiedState: null, // anti-spam des notifications d'alerte alertNotifiedState: null, // anti-spam des notifications d'alerte

129
js/pk/estrannaise-cloud.js Normal file
View File

@ -0,0 +1,129 @@
/**
* NUAGE D'INCERTITUDE (v1.9.0, miroir fidèle de `pk/EstrannaiseCloud.kt`) —
* feature **exclusive au modèle ESE**.
*
* SOURCE : Estrannaise publie le **posterior MCMC** de ses paramètres
* (313 échantillons `(d, k1, k2, k3)` par ester injectable — cf
* js/pk/estrannaise-models.js). Le graphique dessine un **nuage diffus** :
* M courbes (une par échantillon échelonné) qui montrent la plage
* d'imprécision possible du modèle — l'affichage du site estrannaise.
* TFS/WHSAH ne publient pas de posterior → pas de nuage (choix confirmé).
*
* QUELLES DOSES LE NUAGE COUVRE-T-IL (v1.9.2) : **toutes les doses E2 à
* profil injectable dont l'ESTER EFFECTIF (override compris) est couvert
* par le fit Estrannaise** — c'est exactement l'ensemble des doses que la
* courbe ESE trace (`computeCurve(modelOverride = "ESE")`). Le `pkModel`
* STOCKÉ du traitement n'importe pas : quand le chip Nuage est actif avec
* ESE affiché, la courbe ESE redessine toutes ces doses avec le modèle
* Estrannaise, et le nuage les redessine avec les paramètres MCMC de
* LEUR ester. (Fix v1.9.2 : l'ancien filtre `pkModel stocké == ESE`
* laissait le nuage vide dès qu'aucun traitement n'était stocké en ESE —
* remontée : « le nuage ne s'active que autour du tracé émulé, pas autour
* du modèle Estrannaise ». L'exclusivité ESE reste portée par le CHIP,
* pas par le filtre des doses.)
*
* Les contributions Bateman (oral/gel — sans ester échantillonnable) ne
* sont PAS dans le nuage : le posterior MCMC ne s'applique qu'aux esters
* (limite documentée — le nuage montre l'incertitude du modèle
* Estrannaise, pas le niveau total).
*
* DÉTERMINISME : échantillons **échelonnés** (stride, pas d'aléatoire) —
* le nuage est identique à chaque affichage et testable en Node.
*
* PUR (aucun DOM) → testable en Node (tests/estrannaise-cloud.test.js).
*/
import * as EstrannaiseModels from './estrannaise-models.js';
import { usesProfileModel, doseEster } from './pk-engine.js';
/** Nombre de courbes du nuage (compromis étendue visuelle / coût ×32). */
export const DEFAULT_NB_CURVES = 32;
/** Un creux à moins de 6 h n'est pas exploitable (réutilisé pour l'horizon). */
export const MIN_HORIZON_HOURS = 6;
/**
* Calcule le nuage d'incertitude.
*
* @param {object[]} treatments TOUS les traitements (actifs ET inactifs, §6.bis)
* @param {object[]} doseLogs toutes les doses réelles
* @param {number} startMs fenêtre demandée (celle du graphique)
* @param {number} endMs
* @param {number} stepMs pas de la grille (stepForRange)
* @param {number} [nbCurves=32]
* @returns {Array<Array<{timestamp:number,e2:number,t:number}>>} M courbes —
* **vide** si rien n'est calculable : aucun traitement E2 actif à profil
* ESE couvert, aucune dose, ou MCMC non chargé.
*/
export function compute(
treatments, doseLogs, startMs, endMs, stepMs,
nbCurves = DEFAULT_NB_CURVES,
/**
* Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne s'active
* que autour du tracé, pas autour du modèle Estrannaise ») : passer le
* MÊME scalePerEster que la courbe ESE pour que le nuage ENTOURE la
* courbe calibrée.
*/
scalePerEster = null,
) {
if (startMs >= endMs || stepMs <= 0 || nbCurves < 2) return [];
// Doses tracées par la courbe ESE (v1.9.2) : toutes les doses E2 à profil
// injectable dont l'ESTER EFFECTIF (override compris) est couvert par le
// fit Estrannaise — peu importe le pkModel STOCKÉ du traitement (fix :
// quand ESE est affiché, computeCurve redessine toutes ces doses avec
// modelOverride = "ESE" et le nuage doit les suivre). Les traitements
// inactifs sont inclus (§6.bis) ; l'oral Bateman est naturellement exclu.
const carrierDoses = [];
const samplesByEster = new Map();
for (const dose of doseLogs) {
if (dose.timestamp > endMs) continue;
const tr = treatments.find((x) => x.id === dose.treatmentId);
if (!tr || tr.type !== 'ESTRADIOL' || !usesProfileModel(tr)) continue;
const ester = doseEster(tr, dose);
if (!EstrannaiseModels.hasModel(ester)) continue;
if (!samplesByEster.has(ester)) {
const samples = EstrannaiseModels.mcmcSamples(ester);
if (samples.length === 0) return []; // MCMC non chargé → pas de nuage
samplesByEster.set(ester, samples);
}
carrierDoses.push([dose, tr]);
}
if (carrierDoses.length === 0) return [];
const maxSamples = Math.min(...[...samplesByEster.values()].map((s) => s.length));
const stride = Math.max(1, Math.trunc(maxSamples / Math.min(nbCurves, maxSamples)));
// Grille : de max(start, 1ʳᵉ dose du porteur) à endMs — rien avant la 1ʳᵉ dose
const firstDoseMs = Math.min(...carrierDoses.map(([dose]) => dose.timestamp));
const t0 = Math.max(startMs, firstDoseMs);
if (t0 > endMs) return [];
// ── M courbes ──────────────────────────────────────────────────────────────
const cloud = [];
for (let m = 0; m < nbCurves; m++) {
const sampleIdx = Math.min(m * stride, maxSamples - 1);
const paramsByEster = new Map(
[...samplesByEster.entries()].map(([ester, samples]) => [
ester, samples[Math.min(sampleIdx, samples.length - 1)],
]),
);
const curve = [];
for (let t = t0; t <= endMs; t += stepMs) {
let e2 = 0.0;
for (const [dose, tr] of carrierDoses) {
if (dose.timestamp > t) continue;
const dtH = (t - dose.timestamp) / 3600000;
const ester = doseEster(tr, dose);
// Même calibration que la courbe ESE (fix v1.9.1)
const scale = scalePerEster?.[ester] ?? tr.scaleFactor;
e2 += EstrannaiseModels.sampleWithParams(
ester, dtH, paramsByEster.get(ester),
) * dose.doseAmount * scale;
}
curve.push({ timestamp: t, e2, t: 0.0 });
}
if (curve.length >= 2) cloud.push(curve);
}
return cloud;
}

141
js/pk/estrannaise-models.js Normal file
View File

@ -0,0 +1,141 @@
/**
* ─────────────────────────────────────────────────────────────────────────────
* Modèle **Estrannaise analytique** (v1.9.0, miroir fidèle de
* `pk/EstrannaiseModels.kt`) — remplace définitivement les tables horaires
* ODS pour le modèle ESE.
*
* SOURCE ORIGINELLE : https://github.com/WHSAH/estrannaise.js/ — le modèle 3
* compartiments avec les paramètres d'ajustement **publiés par Estrannaise**
* (t en JOURS, dose en mg → pg/mL) :
*
* C(t) = dose · d · k1·k2 · [ e^(−k1·t)/((k1−k2)(k1−k3))
* − e^(−k2·t)/((k1−k2)(k2−k3))
* + e^(−k3·t)/((k1−k3)(k2−k3)) ]
*
* FIDÉLITÉ (épinglée par EstrannaiseModelsTest) : cette forme close
* reproduit les anciennes tables ODS à l'identique (RMS 0,00 sur 0→200 j,
* écart de pic ≤ 0,1 %) — l'ODS n'était que l'échantillonnage horaire de
* ces formules. Décision v1.9.0 : « tous les modèles ont maintenant leurs
* sources originelles — on abandonne complètement les liens avec le
* fichier ODS ».
*
* NUAGE D'INCERTITUDE (v1.9.0, exclusif ESE) : Estrannaise publie le
* **posterior MCMC** de ses paramètres (313 échantillons par ester —
* `assets/mcmc_samples.json`, 48 Ko, fetché au démarrage). Le graphique
* peut dessiner un nuage diffus de courbes montrant la plage
* d'imprécision du modèle. TFS/WHSAH ne publient pas de posterior.
*
* PUR sauf [init] (fetch de l'asset MCMC) — la formule est testable en
* Node (tests/estrannaise-models.test.js).
* ─────────────────────────────────────────────────────────────────────────────
*/
// ── Paramètres d'ajustement publiés par Estrannaise (src/modeldata.js) ──────
// Clés = clés esters de l'app. Mapping depuis le repo : « EV im »→EV,
// « EUn im »→EU, « EEn im »→EEn, « EC im »→EC, « EB im »→EB,
// « EUn casubq »→EUCS (nouveau code ester v1.9.0).
const PK_PARAMETERS = {
EV: [478.0, 0.236, 4.85, 1.24],
EU: [471.5, 0.01729, 6.528, 2.285],
EEN: [191.4, 0.119, 0.601, 0.402],
EC: [246.0, 0.0825, 3.57, 0.669],
EB: [1893.1, 0.67, 61.5, 4.34],
EUCS: [16.15, 0.046, 0.022, 0.101],
};
let mcmc = null; // posterior MCMC (chargé via init()) — { ester: [[d,k1,k2,k3] × 313] }
/**
* Fetch de l'asset `assets/mcmc_samples.json` (48 Ko) puis init — appelé au
* démarrage par app.js (remplace l'ancien fetch des tables ODS, 550 Ko).
* Silencieux en cas d'échec (le nuage restera simplement vide — les courbes
* nommées ne dépendent pas du posterior).
*/
export async function init() {
try {
const resp = await fetch('assets/mcmc_samples.json');
if (resp.ok) initWithJson(await resp.text());
} catch (_e) {
// best-effort : sans MCMC, pas de nuage — les courbes restent calculables
}
}
export function hasModel(ester) {
return Object.prototype.hasOwnProperty.call(PK_PARAMETERS, String(ester).toUpperCase());
}
/** Les paramètres de l'ester, ou null (lookup insensible à la casse). */
export function model(ester) {
const key = String(ester).toUpperCase();
if (!hasModel(key)) return null;
const [d, k1, k2, k3] = PK_PARAMETERS[key];
return {
d, k1, k2, k3,
/** t½ terminale ANALYTIQUE : la phase la plus lente domine la queue. */
get terminalHalfLifeDays() {
return Math.log(2) / Math.min(k1, k2, k3);
},
};
}
/** Forme close 3C (t en JOURS) — cas général + 3 cas dégénérés (taux égaux). */
function curveDays(t, dose, m) {
if (t < 0) return 0.0;
let ret = 0.0;
if (dose > 0 && m.d > 0) {
const { k1, k2, k3 } = m;
if (k1 === k2 && k2 === k3) {
ret += dose * m.d * k1 * k1 * t * t * Math.exp(-k1 * t) / 2;
} else if (k1 === k2 && k2 !== k3) {
ret += dose * m.d * k1 * k1 * (Math.exp(-k3 * t) - Math.exp(-k1 * t) * (1 + (k1 - k3) * t)) / (k1 - k3) / (k1 - k3);
} else if (k1 !== k2 && k1 === k3) {
ret += dose * m.d * k1 * k2 * (Math.exp(-k2 * t) - Math.exp(-k1 * t) * (1 + (k1 - k2) * t)) / (k1 - k2) / (k1 - k2);
} else if (k1 !== k2 && k2 === k3) {
ret += dose * m.d * k1 * k2 * (Math.exp(-k1 * t) - Math.exp(-k2 * t) * (1 - (k1 - k2) * t)) / (k1 - k2) / (k1 - k2);
} else {
ret += dose * m.d * k1 * k2 * (
Math.exp(-k1 * t) / (k1 - k2) / (k1 - k3)
- Math.exp(-k2 * t) / (k1 - k2) / (k2 - k3)
+ Math.exp(-k3 * t) / (k1 - k3) / (k2 - k3)
);
}
}
return Number.isNaN(ret) ? 0.0 : ret;
}
/**
* Réponse normalisée (pg/mL PAR mg) à dtHours — t converti en jours ici.
* dtHours ≤ 0 → 0.
*/
export function sample(ester, dtHours) {
const m = model(ester);
if (!m || dtHours <= 0) return 0.0;
return curveDays(dtHours / 24, 1.0, m);
}
/** Courbe avec un jeu de paramètres DONNÉ (échantillon MCMC) — nuage. */
export function sampleWithParams(ester, dtHours, p) {
if (dtHours <= 0) return 0.0;
return curveDays(dtHours / 24, 1.0, p);
}
/**
* Charge le posterior MCMC depuis `assets/mcmc_samples.json` (appelé au
* démarrage par app.js — remplace le fetch des tables ODS).
* @param {string} json contenu JSON brut
*/
export function initWithJson(json) {
const raw = JSON.parse(json);
// Mapper vers des objets {d, k1, k2, k3} (miroir du Param Kotlin) — les
// tableaux bruts du JSON ne portent pas les noms de champs
const parsed = {};
for (const [ester, arr] of Object.entries(raw)) {
parsed[ester.toUpperCase()] = arr.map(([d, k1, k2, k3]) => ({ d, k1, k2, k3 }));
}
mcmc = parsed;
}
/** Les échantillons MCMC d'un ester (vide si non chargé / inconnu). */
export function mcmcSamples(ester) {
return (mcmc && mcmc[String(ester).toUpperCase()]) || [];
}

View File

@ -10,9 +10,13 @@ export * from './pk-reminders.js';
export * from './pk-extrema.js'; export * from './pk-extrema.js';
export * as LabTrajectoryModel from './lab-trajectory-model.js'; export * as LabTrajectoryModel from './lab-trajectory-model.js';
export { computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs } from './lab-trajectory-model.js'; export { computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs } from './lab-trajectory-model.js';
export * as LabTiming from './lab-timing.js';
export * as EstrannaiseModels from './estrannaise-models.js';
export { sample as estrannaiseSample, hasModel as estrannaiseHasModel } from './estrannaise-models.js';
export { nextBloodDrawRecommendation, shouldSuggestPosology, troughTargetStatus } from './lab-timing.js';
export { compute as computeEstrannaiseCloud, DEFAULT_NB_CURVES } from './estrannaise-cloud.js';
export * from './chart-helpers.js'; export * from './chart-helpers.js';
export * as Alerts from './alerts.js'; export * as Alerts from './alerts.js';
export * as PKProfileStore from './pk-profile-store.js';
export * as TransfemScienceModels from './transfem-science-models.js'; export * as TransfemScienceModels from './transfem-science-models.js';
export * as WhsahModels from './whsah-models.js'; export * as WhsahModels from './whsah-models.js';
export * as PKPresets from './presets.js'; export * as PKPresets from './presets.js';

308
js/pk/lab-timing.js Normal file
View File

@ -0,0 +1,308 @@
/**
* ─────────────────────────────────────────────────────────────────────────────
* RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0, miroir fidèle de
* `pk/LabTiming.kt` Android) — affichée dans la page Analyses
* (« Prochaine analyse recommandée »).
*
* PRINCIPE PHARMACOCINÉTIQUE (choix validé avec l'utilisatrice) :
* 1. le **creux (trough) juste avant l'injection suivante** est le moment le
* plus informatif et reproductible pour un ester injectable (le pic varie
* énormément, le creux reflète le niveau de fond et l'accumulation) ;
* 2. un creux n'est interprétable que si le régime est **stabilisé** — règle
* des 5 demi-vies terminales (≈ 97 % de l'équilibre) ;
* 3. **ON SAUTE donc au premier creux STABILISÉ** (décision v1.8.0 ; fix
* #68 v1.10.0 : le creux = niveau PRÉ-INJECTION du créneau — l'ancien
* minimum de fenêtre entière tombait juste après l'injection PRÉCÉDENTE
* pour les esters à montée lente, date antérieure à la stabilisation).
*
* CALCUL (déduit de ce que l'app connaît déjà) :
* - traitement porteur : ESTRADIOL + injectable + ACTIF + Posologie
* (`forecastIntervalDays > 0`) — sinon null (la page Analyses affiche
* alors l'invite « renseigne une Posologie ») ;
* - t½ terminale : ANALYTIQUE pour les trois modèles (`terminalHalfLifeDays`
* — v1.9.0 : Estrannaise analytique aussi, plus aucune lecture de table) ;
* - début du régime courant = 1ʳᵉ dose du traitement actif — PROXY assumé :
* l'app ne trace pas l'historique des éditions de Posologie/dose ;
* - creux = minimum local de la courbe E2 PRÉVISIONNELLE (doses réelles +
* créneaux generateForecastDoses) entre deux injections — la FORME suffit
* (calcul sans calibration : le facteur ne déplace pas le minimum) ;
* - filtres : creux ≥ maintenant + 6 h, creux > dernière prise de sang
* (jamais recommander un creux déjà mesuré), créneau ≥ stabilisation.
*
* LIMITES ASSUMÉES (doc web §7.11/§12) : le creux est une estimation du
* modèle (disclaimer affiché) ; pas de Posologie → pas de recommandation ;
* stabilisation hors horizon (ester ultra-long) → null (carte cachée).
*
* PUR (aucun DOM) → testable en Node (tests/lab-timing.test.js).
* ─────────────────────────────────────────────────────────────────────────────
*/
import { computeCurve, generateForecastDoses, TConfig, usesProfileModel, doseEster } from './pk-engine.js';
import * as EstrannaiseModels from './estrannaise-models.js';
import { model as tfsModel } from './transfem-science-models.js';
import { model as whsahModel } from './whsah-models.js';
/** Règle de stabilisation : 5 demi-vies terminales (≈ 97 % de l'équilibre). */
export const STABILIZATION_HALF_LIVES = 5;
/** Horizon max de créneaux explorés (ester ultra-long → null, carte cachée). */
export const MAX_FORECAST_INTERVALS = 12;
/** Un creux à moins de 6 h n'est pas exploitable (organisation d'une prise). */
export const MIN_HORIZON_HOURS = 6;
const HOUR_MS = 3600000;
/**
* Calcule la prochaine prise de sang recommandée, ou `null` si rien n'est
* calculable honnêtement (l'appelant cache l'encart sur null, et peut
* afficher l'invite « renseigne une Posologie » via [shouldSuggestPosology]).
*
* @param {object[]} treatments TOUS les traitements (actifs ET inactifs, §6.bis)
* @param {object[]} doseLogs toutes les doses
* @param {object[]} labs toutes les prises de sang existantes
* @param {number} [nowMs=Date.now()]
* @param {Object<string,number>} [scalePerEster=null] facteurs d'échelle PAR
* ESTER (v1.13.0, issus de l'auto-calibration quand elle est activée) —
* alimente `predictedE2` ; absent → `predictedE2 = null` (carte sans valeur)
* @returns {{troughMs:number, injectionMs:number, ester:string,
* terminalHalfLifeDays:number, regimeStartMs:number, stabilizedAtMs:number,
* wasAlreadyStabilized:boolean, predictedE2:number|null}|null}
*/
export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs = Date.now(), scalePerEster = null) {
// ── 1) Traitement PORTEUR : E2 + injectable + actif + Posologie ───────────
const carrier = treatments.find((t) =>
t.type === 'ESTRADIOL'
&& t.isActive
&& usesProfileModel(t)
&& (t.forecastIntervalDays ?? 0) > 0
);
if (!carrier) return null;
// ── 2) t½ terminale de l'ester (analytique ou lue dans la table) ──────────
let tHalfDays = null;
if (carrier.pkModel === 'TFS') tHalfDays = tfsModel(carrier.esterType)?.terminalHalfLifeDays ?? null;
else if (carrier.pkModel === 'WHS') tHalfDays = whsahModel(carrier.esterType)?.terminalHalfLifeDays ?? null;
// v1.9.0 : ESE analytique (estrannaise.js) — t½ terminale analytique
else if (carrier.pkModel === 'ESE') tHalfDays = EstrannaiseModels.model(carrier.esterType)?.terminalHalfLifeDays ?? null;
if (tHalfDays === null || tHalfDays === undefined) return null;
// ── 3) Début du régime courant + date de stabilisation ────────────────────
const myDoses = doseLogs.filter((d) => d.treatmentId === carrier.id);
if (myDoses.length === 0) return null;
// v1.13.0 : régime POOLÉ (cf pooledRegimeDoses) — le re-parenting
// d'historique (ex. « 6d - old » créé a posteriori, remontée v1.12.0)
// ne doit pas faire croire à un nouveau régime.
const regimeStartMs = computeRegimeStartMs(
carrier, pooledRegimeDoses(carrier, doseLogs, treatments),
);
const stabilizedAtMs = regimeStartMs + Math.round(STABILIZATION_HALF_LIVES * tHalfDays * 24 * HOUR_MS);
// Ester EFFECTIF de la dernière dose (override compris) : c'est lui qui
// gouverne les creux futurs et le texte de la carte
let lastDose = myDoses[0];
for (const d of myDoses) {
if (d.timestamp > lastDose.timestamp) lastDose = d;
}
const activeEster = String(doseEster(carrier, lastDose)).toUpperCase();
// ── 4) Créneaux prévisionnels : assez pour couvrir la stabilisation ───────
const intervalDays = carrier.forecastIntervalDays;
const intervalsNeeded = Math.min(
MAX_FORECAST_INTERVALS,
Math.max(3, Math.ceil((STABILIZATION_HALF_LIVES * tHalfDays) / intervalDays) + 1),
);
const horizonMs = nowMs + Math.round(intervalsNeeded * intervalDays * 24 * HOUR_MS);
const slots = generateForecastDoses(carrier, doseLogs, horizonMs, nowMs);
if (slots.length === 0) return null;
// ── 5) Courbe E2 fine (FORME brute — sans calibration) ────────────────────
const curve = computeCurve(
treatments, doseLogs.concat(slots), nowMs, slots[slots.length - 1].timestamp, HOUR_MS,
new TConfig(), {},
);
// ── 6) Premier creux STABILISÉ jamais mesuré ──────────────────────────────
const lastLabMs = labs.length ? Math.max(...labs.map((l) => l.timestamp)) : 0;
const minTroughMs = nowMs + MIN_HORIZON_HOURS * HOUR_MS;
let windowStart = nowMs;
for (const slot of slots) {
// ⚠️ FIX #68 (v1.10.0, miroir du Kotlin) : le creux = niveau
// PRÉ-INJECTION du créneau (dernier point de courbe avant l'injection —
// principe 1, « le moment le plus comparable »). L'ancien MINIMUM DE LA
// FENÊTRE ENTIÈRE tombait au DÉBUT de la fenêtre pour les esters à
// montée lente (EEn : pic ~J+5 ≈ intervalle 7 j → le minimum local est
// le creux d'absorption, quelques heures après l'injection PRÉCÉDENTE) :
// la date proposée précédait la stabilisation affichée sur la MÊME
// carte, et le texte « juste avant ton injection du … » était faux de
// plusieurs jours (remontée v1.9.8). Pour les esters à t½ courte (EV),
// le point pré-injection EST le minimum de fenêtre : inchangé.
let trough = null;
for (const p of curve) {
if (p.timestamp > windowStart && p.timestamp < slot.timestamp) trough = p;
}
if (
trough !== null
&& trough.timestamp >= minTroughMs
&& trough.timestamp > lastLabMs
&& trough.timestamp >= stabilizedAtMs // sauter au premier creux stabilisé
) {
return {
troughMs: trough.timestamp,
injectionMs: slot.timestamp,
ester: activeEster,
terminalHalfLifeDays: tHalfDays,
regimeStartMs,
stabilizedAtMs,
wasAlreadyStabilized: stabilizedAtMs <= nowMs,
// v1.13.0 : E2 attendue au creux = valeur brute du modèle × facteur
// du ester actif (lookup insensible à la casse) ; null sans calibration
predictedE2: (() => {
if (!scalePerEster) return null;
const entry = Object.entries(scalePerEster).find(
([k]) => String(k).toUpperCase() === activeEster,
);
return entry ? trough.e2 * entry[1] : null;
})(),
};
}
windowStart = slot.timestamp;
}
return null; // stabilisation hors horizon (ester ultra-long) → carte cachée
}
/**
* DÉBUT DU RÉGIME COURANT (v1.8.1 — correction du proxy v1.8.0, miroir du
* Kotlin). Critique remontée : « changé d'ester, de dosage ET de posologie,
* et l'app me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose
* du traitement) ne voyait aucun de ces changements.
*
* NOUVELLE RÈGLE : le régime courant = la séquence terminale de doses où
* (a) l'ESTER EFFECTIF et la DOSE (mg) sont identiques à la dose la plus
* récente, ET (b) l'ÉCART entre doses consécutives est constant (= l'écart
* des deux doses les plus récentes). On remonte depuis la dose la plus
* récente tant que ces conditions tiennent.
*
* Pourquoi l'écart fait partie du régime : 7 j → 2 j → 9 j (posologie
* modifiée/irrégulière) change le creux — conservateur assumé : une série
* d'intervalles chaotiques maintient la carte « non stabilisée », ce qui est
* pharmacocinétiquement vrai (le trough n'est comparable que sur un
* intervalle régulier).
*
* v1.9.7 (miroir du Kotlin, remontée : « la suggestion change tout le temps
* à chaque injection si l'injection n'est pas faite pile à la même heure ») :
* la comparaison EXACTE cassait le régime pour un log à 12:30 au lieu de
* 12:00 — chaque écart doit rester dans l'INTERVALLE DE POSOLOGIE ± 24 h
* (« je m'injecte le même jour, à l'heure près »). La fenêtre se réfère à
* l'intervalle THÉORIQUE, pas à l'écart précédent : des logs à 6,8 j puis
* 7,2 j ne se déstabilisent plus en cascade. Un vrai changement (2 j au lieu
* de 7 j) reste hors fenêtre.
*
* @private
* @param {object} carrier traitement porteur
* @param {object[]} myDoses doses du porteur (ordre indifférent)
* @returns {number} timestamp de la 1ʳᵉ dose du régime courant
*/
function computeRegimeStartMs(carrier, myDoses) {
const sorted = myDoses.slice().sort((a, b) => a.timestamp - b.timestamp);
let idx = sorted.length - 1;
let regimeStart = sorted[idx].timestamp;
let current = sorted[idx];
// Fenêtre d'écart = INTERVALLE DE POSOLOGIE ± 24 h (la Posologie est un
// prérequis de la carte : absente → on s'arrête à la dernière dose).
const intervalDays = carrier.forecastIntervalDays;
if (!intervalDays || intervalDays <= 0) return regimeStart;
const intervalMs = Math.round(intervalDays * 24 * HOUR_MS);
const gapMin = intervalMs - GAP_TOLERANCE_MS;
const gapMax = intervalMs + GAP_TOLERANCE_MS;
const DOSE_EPS = 1e-6;
while (idx > 0) {
const prev = sorted[idx - 1];
const gap = current.timestamp - prev.timestamp;
const sameMarker = String(doseEster(carrier, current)) === String(doseEster(carrier, prev))
&& Math.abs(current.doseAmount - prev.doseAmount) < DOSE_EPS;
// gap > 0 : un doublon de log n'est jamais un régime
const sameGap = gap > 0 && gap >= gapMin && gap <= gapMax;
if (!sameMarker || !sameGap) break;
regimeStart = prev.timestamp;
current = prev;
idx--;
}
return regimeStart;
}
/** Tolérance de l'écart inter-doses (v1.9.7, miroir GAP_TOLERANCE_MS Kotlin) :
* ± 24 h autour de l'intervalle de Posologie — un log à 12:30 au lieu de
* 12:00 ne repousse plus la stabilisation de 5 × t½. */
const GAP_TOLERANCE_MS = 24 * HOUR_MS;
/**
* Invite d'AFFICHAGE (v1.8.0, demande explicite) : existe-t-il un traitement
* E2 injectable ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la
* renseigner pour recevoir des recommandations. Ne s'affiche QUE si aucune
* recommandation n'est calculable.
*
* @param {object[]} treatments
* @returns {boolean}
*/
export function shouldSuggestPosology(treatments) {
return treatments.some((t) =>
t.type === 'ESTRADIOL'
&& t.isActive
&& usesProfileModel(t)
&& (t.forecastIntervalDays ?? 0) <= 0
);
}
/**
* SÉQUENCE DE RÉGIME POOLÉE (v1.13.0, miroir fidèle du Kotlin — même KDoc) :
* toutes les doses de TOUS les traitements partageant l'ester effectif du
* porteur ET son mg exact (± eps), triées par timestamp. Un re-parenting
* d'historique (« … - old ») ne doit plus redémarrer la stabilisation.
* Garde-fous : ester ≠ → pas de mutualisation ; dose ≠ → exclue (le trou
* qui suit casse la séquence — conservateur).
*
* @param {object} carrier
* @param {object[]} allDoseLogs
* @param {object[]} allTreatments
* @returns {object[]} triée par timestamp
*/
export function pooledRegimeDoses(carrier, allDoseLogs, allTreatments) {
let last = null;
for (const d of allDoseLogs) {
if (d.treatmentId === carrier.id && (last === null || d.timestamp > last.timestamp)) last = d;
}
if (!last) return [];
const activeEster = String(doseEster(carrier, last)).toUpperCase();
const amount = last.doseAmount;
const ownerById = new Map(allTreatments.map((t) => [t.id, t]));
const out = [];
for (const d of allDoseLogs) {
const owner = ownerById.get(d.treatmentId);
if (!owner) continue;
// Ester EFFECTIF via le traitement PROPRIÉTAIRE (override compris)
if (String(doseEster(owner, d)).toUpperCase() !== activeEster) continue;
if (Math.abs(d.doseAmount - amount) >= 1e-6) continue;
out.push(d);
}
return out.sort((a, b) => a.timestamp - b.timestamp);
}
/**
* Statut d'une valeur par rapport à la CIBLE DE CREUX personnelle (v1.13.0,
* miroir du Kotlin — même KDoc). ⚠️ DISTINCT des seuils d'alerte (alerts.js,
* §9.bis) : les alertes surveillent le niveau estimé EN CONTINU et
* notifient ; la cible de creux est une RÉFÉRENCE DE LAB, évaluée en
* lecture seule sur la carte, JAMAIS de notification.
*
* @param {number|null} predictedE2
* @param {number|null} low
* @param {number|null} high
* @returns {'IN_TARGET'|'BELOW'|'ABOVE'|null} null si prédiction ou cible absente
*/
export function troughTargetStatus(predictedE2, low, high) {
if (predictedE2 === null || predictedE2 === undefined) return null;
if (low === null || low === undefined || high === null || high === undefined) return null;
if (predictedE2 < low) return 'BELOW';
if (predictedE2 > high) return 'ABOVE';
return 'IN_TARGET';
}

View File

@ -54,14 +54,20 @@ function median(values) {
return (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0; return (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0;
} }
/** Arrondi « facteur » à 2 décimales, troncature (miroir du Kotlin (x*100|0)/100). */ /** Arrondi « facteur » à 2 décimales (v1.9.2 : Math.round — miroir
function round2(x) { * medianRounded Kotlin v1.8.2 ; la troncature historique (x*100|0)/100
return Math.trunc(x * 100) / 100; * faussait les échelles d'un cran, cf le test rounding.test.js). */
export function round2(x) {
// v1.9.2 : ARRONDI (Math.round) au lieu de troncature — miroir du fix
// Android v1.8.2 (medianRounded) : la troncature au dernier bit flottant
// faussait les échelles d'un cran (0,8999… → 0,89 au lieu de 0,90) et
// divergeait de l'Android (harmonisation des niveaux web/Android)
return Math.round(x * 100) / 100;
} }
/** Arrondi « k T » à 3 décimales, troncature. */ /** Arrondi « k T » à 3 décimales (v1.9.2 : Math.round, cf round2). */
function round3(x) { export function round3(x) {
return Math.trunc(x * 1000) / 1000; return Math.round(x * 1000) / 1000;
} }
/** Dernier élément (par timestamp) d'une liste non vide ; null si vide. */ /** Dernier élément (par timestamp) d'une liste non vide ; null si vide. */

View File

@ -8,15 +8,19 @@
* *
* Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la * Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la
* réponse normalisée (pg/mL par mg) ; les contributions se superposent. * réponse normalisée (pg/mL par mg) ; les contributions se superposent.
* Trois modèles superposables : * Trois modèles ANALYTIQUES superposables (l'ODS a quitté le runtime en
* - **Estrannaise (ESE)** = tables horaires de l'asset (ODS) ; * v1.9.0 — les tables ne survivent que dans le test de fidélité RMS) :
* - **Estrannaise (ESE)** = forme close 3C de estrannaise.js (6 esters
* injectables, posterior MCMC pour le nuage, cf estrannaise-models.js) ;
* - **Transfem Science (TFS)** = forme close V3C de la méta-analyse (7 esters) ; * - **Transfem Science (TFS)** = forme close V3C de la méta-analyse (7 esters) ;
* - **WHSAH (WHS)** = fit « license-free » de Mona (6 esters, PEP non couvert). * - **WHSAH (WHS)** = fit « license-free » de Mona (6 esters, PEP non couvert).
* Gel/patch/oral → modèle de Bateman paramétrable. * Gel/patch/oral → modèle de Bateman paramétrable.
* *
* ⚠️ INVARIANTS à préserver (doc Android §14 — bugs à ne pas réintroduire) : * ⚠️ INVARIANTS à préserver (doc Android §14 — bugs à ne pas réintroduire) :
* - `computeKa` : bisection DANS LE BON SENS (#19) ; * - `computeKa` : bisection DANS LE BON SENS (#19) ;
* - tables ESE : modèle strict + extrapolation terminale ≥ 1 % du pic (#20/#21) ; * - modèles de PROFIL stricts : un ester NON couvert par le modèle du
* traitement → 0 (jamais un autre modèle) — #20/#21, cf
* `choicesForModel` (models.js) qui n'offre que les esters tracés ;
* - `convertTToNgMl` appliquée à la calibration ET au rendu (#23) ; * - `convertTToNgMl` appliquée à la calibration ET au rendu (#23) ;
* - `generateForecastDoses` : créneaux passés SAUTÉS (#35). * - `generateForecastDoses` : créneaux passés SAUTÉS (#35).
* *
@ -26,7 +30,7 @@
import * as TransfemScienceModels from './transfem-science-models.js'; import * as TransfemScienceModels from './transfem-science-models.js';
import * as WhsahModels from './whsah-models.js'; import * as WhsahModels from './whsah-models.js';
import * as PKProfileStore from './pk-profile-store.js'; import { sample as estrannaiseSample, hasModel as estrannaiseHasModel, model as estrannaiseModel } from './estrannaise-models.js';
/** Une heure en ms. */ /** Une heure en ms. */
export const HOUR_MS = 3600000; export const HOUR_MS = 3600000;
@ -55,6 +59,7 @@ export class TConfig {
export const Esters = { export const Esters = {
NONE: 'NONE', EV: 'EV', EU: 'EU', EEN: 'EEN', NONE: 'NONE', EV: 'EV', EU: 'EU', EEN: 'EEN',
EB: 'EB', EC: 'EC', ECS: 'ECS', PEP: 'PEP', EB: 'EB', EC: 'EC', ECS: 'ECS', PEP: 'PEP',
EUCS: 'EUCS', // v1.9.4 : undécylate suspension cristalline (fit Estrannaise seulement)
}; };
/** Constantes de modèles PK (miroir de `PKModels.kt`). */ /** Constantes de modèles PK (miroir de `PKModels.kt`). */
@ -140,7 +145,7 @@ export function doseEster(treatment, dose) {
* 1. traitement à PROFIL PK (injection + ester ≠ NONE) : * 1. traitement à PROFIL PK (injection + ester ≠ NONE) :
* - TFS + ester couvert par le V3C → TransfemScienceModels ; * - TFS + ester couvert par le V3C → TransfemScienceModels ;
* - WHSAH + ester couvert par le fit Mona → WhsahModels ; * - WHSAH + ester couvert par le fit Mona → WhsahModels ;
* - sinon → tables ODS via PKProfileStore (ester sans modèle → 0) ; * - ESE → forme close estrannaise.js (v1.9.0, analytique) ; ester sans modèle → 0 ;
* 2. sinon → Bateman (gel / patch / oral / custom). * 2. sinon → Bateman (gel / patch / oral / custom).
* *
* `modelOverride` force ESE/TFS/WHS pour CE calcul (le graphique dessine les * `modelOverride` force ESE/TFS/WHS pour CE calcul (le graphique dessine les
@ -173,7 +178,10 @@ export function concentrationOfDose(treatment, dose, queryTimeMs, bateman = null
if (mod === PKModels.WHSAH && WhsahModels.hasModel(ester)) { if (mod === PKModels.WHSAH && WhsahModels.hasModel(ester)) {
return WhsahModels.sample(ester, dtH) * mg; return WhsahModels.sample(ester, dtH) * mg;
} }
return PKProfileStore.sample(ester, mod, dtH) * mg; if (mod === 'ESE' && estrannaiseHasModel(ester)) {
return estrannaiseSample(ester, dtH) * mg;
}
return 0.0; // ester sans modèle (ni TFS ni WHSAH ni ESE analytique)
} }
} }
@ -207,7 +215,14 @@ export function cutoffHours(treatment) {
} else if (mod === PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) { } else if (mod === PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) {
profileH = TransfemScienceModels.model(treatment.esterType).terminalHalfLifeDays * 24.0 * 10.0; profileH = TransfemScienceModels.model(treatment.esterType).terminalHalfLifeDays * 24.0 * 10.0;
} else { } else {
profileH = PKProfileStore.profileLength(treatment.esterType, mod); // v1.9.0 : ESE analytique → t½ terminale analytique (comme TFS/WHS).
// v1.9.5 (audit, parité du Kotlin) : garde hasModel — un traitement
// IMPORTÉ peut porter n'importe quelle string de pkModel/ester
// (schéma de backup permissif) : ESE + ester non couvert (ex. ECS/PEP)
// ne doit PAS lever TypeError mais retomber sur la garde Bateman ≥ 24 h.
profileH = estrannaiseHasModel(treatment.esterType)
? 10 * 24 * estrannaiseModel(treatment.esterType).terminalHalfLifeDays
: 0.0;
} }
} }
const batemanH = usesProfileModel(treatment) ? 0.0 : 30.0 * treatment.eliminationHalfLifeHours; const batemanH = usesProfileModel(treatment) ? 0.0 : 30.0 * treatment.eliminationHalfLifeHours;
@ -237,19 +252,53 @@ export function cutoffHours(treatment) {
* @param {Object<string,number>|null} [scalePerEster=null] * @param {Object<string,number>|null} [scalePerEster=null]
* @returns {number} pg/mL * @returns {number} pg/mL
*/ */
export function e2At(treatments, doseLogs, tMs, modelOverride = null, scalePerEster = null) { /**
* Contexte PRÉ-CALCULÉ d'une série d'appels e2At (v1.9.5 — miroir de
* `E2Context` Kotlin, audit) : doses groupées par traitement, cutoff
* précalculé, Bateman PARESSEUX (inutile pour les traitements à PROFIL —
* `concentrationOfDose` ne lit le paramètre que dans la branche Bateman,
* inatteignable quand usesProfileModel). Le chemin chaud du tracé labs
* appelle e2At par point de grille : préparer ce contexte UNE fois évite
* le re-grouping × 720 points × N traitements. L'ordre de sommation est
* PRÉSERVÉ (groupBy conserve l'ordre relatif des doses) → résultats
* bit-identiques à l'ancienne forme.
*
* @param {object[]} treatments
* @param {object[]} doseLogs
* @returns {{treatments: object[], dosesByTreatment: Map, cutoffByTreatment: Map, batemanCache: Map}}
*/
export function prepareE2Context(treatments, doseLogs) {
const dosesByTreatment = new Map();
for (const dose of doseLogs) {
const list = dosesByTreatment.get(dose.treatmentId);
if (list) list.push(dose);
else dosesByTreatment.set(dose.treatmentId, [dose]);
}
const cutoffByTreatment = new Map();
const batemanCache = new Map(); const batemanCache = new Map();
let total = 0.0;
for (const treatment of treatments) { for (const treatment of treatments) {
if (treatment.type !== 'ESTRADIOL') continue; // AA/progestatifs → 0 en E2 if (treatment.type !== 'ESTRADIOL') continue; // AA/progestatifs → 0 en E2
if (!batemanCache.has(treatment.id)) { cutoffByTreatment.set(treatment.id, cutoffHours(treatment));
if (!usesProfileModel(treatment)) {
batemanCache.set(treatment.id, batemanParams(treatment)); batemanCache.set(treatment.id, batemanParams(treatment));
} }
for (const dose of doseLogs) { }
if (dose.treatmentId !== treatment.id || dose.timestamp > tMs) continue; return { treatments, dosesByTreatment, cutoffByTreatment, batemanCache };
}
/** e2At sur contexte pré-calculé (voir prepareE2Context) — la forme WORKHORSE. */
export function e2AtCtx(ctx, tMs, modelOverride = null, scalePerEster = null) {
let total = 0.0;
for (const treatment of ctx.treatments) {
if (treatment.type !== 'ESTRADIOL') continue; // AA/progestatifs → 0 en E2
const doses = ctx.dosesByTreatment.get(treatment.id);
if (!doses) continue;
const cutoff = ctx.cutoffByTreatment.get(treatment.id);
for (const dose of doses) {
if (dose.timestamp > tMs) continue;
const dtH = (tMs - dose.timestamp) / 3600000.0; const dtH = (tMs - dose.timestamp) / 3600000.0;
if (dtH > cutoffHours(treatment)) continue; if (dtH > cutoff) continue;
const c = concentrationOfDose(treatment, dose, tMs, batemanCache.get(treatment.id), modelOverride); const c = concentrationOfDose(treatment, dose, tMs, ctx.batemanCache.get(treatment.id), modelOverride);
if (c > 0.0) { if (c > 0.0) {
const scale = (scalePerEster && scalePerEster[doseEster(treatment, dose)]) ?? treatment.scaleFactor; const scale = (scalePerEster && scalePerEster[doseEster(treatment, dose)]) ?? treatment.scaleFactor;
total += c * scale; total += c * scale;
@ -259,6 +308,11 @@ export function e2At(treatments, doseLogs, tMs, modelOverride = null, scalePerEs
return total; return total;
} }
/** e2At à appel unique : prépare le contexte puis délègue. */
export function e2At(treatments, doseLogs, tMs, modelOverride = null, scalePerEster = null) {
return e2AtCtx(prepareE2Context(treatments, doseLogs), tMs, modelOverride, scalePerEster);
}
/** /**
* Modèle T EMPIRIQUE (non publié — étiqueté « estimation » partout) : * Modèle T EMPIRIQUE (non publié — étiqueté « estimation » partout) :
* `T = floor + (base − floor) / (1 + k·E2)` en ng/mL. * `T = floor + (base − floor) / (1 + k·E2)` en ng/mL.
@ -369,12 +423,29 @@ export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConf
const earliestDose = doseLogs.reduce((m, d) => Math.min(m, d.timestamp), Infinity); const earliestDose = doseLogs.reduce((m, d) => Math.min(m, d.timestamp), Infinity);
const searchStart = Math.max(startMs, earliestDose); const searchStart = Math.max(startMs, earliestDose);
// Pré-calculs PAR TRAITEMENT (v1.9.5 — portage de l'optimisation Kotlin
// v1.8.2 : cutoffHours était recalculé PAR POINT × traitement, le scan
// doseLogs complet par point, et Bateman calculé même pour les profils
// qui ne le lisent jamais) — ordre de sommation PRÉSERVÉ → résultats
// identiques (épinglés par les 172 tests).
const cutoffByTreatment = new Map();
const batemanCache = new Map(); const batemanCache = new Map();
relevantTreatments.forEach((tr) => batemanCache.set(tr.id, batemanParams(tr))); const treatmentById = new Map();
relevantTreatments.forEach((tr) => {
cutoffByTreatment.set(tr.id, cutoffHours(tr));
treatmentById.set(tr.id, tr);
if (!usesProfileModel(tr)) batemanCache.set(tr.id, batemanParams(tr));
});
const dosesByTreatment = new Map();
for (const dose of doseLogs) {
const list = dosesByTreatment.get(dose.treatmentId);
if (list) list.push(dose);
else dosesByTreatment.set(dose.treatmentId, [dose]);
}
// Doses triées pour suivre l'« ester actif » le long de la grille (k de T) // Doses triées pour suivre l'« ester actif » le long de la grille (k de T)
const sortedEstrogenDoses = doseLogs const sortedEstrogenDoses = doseLogs
.filter((d) => relevantTreatments.some((tr) => tr.id === d.treatmentId)) .filter((d) => treatmentById.has(d.treatmentId))
.slice() .slice()
.sort((a, b) => a.timestamp - b.timestamp); .sort((a, b) => a.timestamp - b.timestamp);
let doseCursor = 0; let doseCursor = 0;
@ -387,16 +458,15 @@ export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConf
// Avance le curseur : la dose à t définit l'ester actif // Avance le curseur : la dose à t définit l'ester actif
while (doseCursor < sortedEstrogenDoses.length && sortedEstrogenDoses[doseCursor].timestamp <= t) { while (doseCursor < sortedEstrogenDoses.length && sortedEstrogenDoses[doseCursor].timestamp <= t) {
const d = sortedEstrogenDoses[doseCursor]; const d = sortedEstrogenDoses[doseCursor];
const tr = relevantTreatments.find((x) => x.id === d.treatmentId); activeEster = doseEster(treatmentById.get(d.treatmentId), d);
activeEster = doseEster(tr, d);
doseCursor++; doseCursor++;
} }
const k = (activeEster && tKPerEster && tKPerEster[activeEster]) ?? tConfig.k; const k = (activeEster && tKPerEster && tKPerEster[activeEster]) ?? tConfig.k;
for (const treatment of relevantTreatments) { for (const treatment of relevantTreatments) {
const cutoff = cutoffHours(treatment); const cutoff = cutoffByTreatment.get(treatment.id);
const p = batemanCache.get(treatment.id); const p = batemanCache.get(treatment.id);
for (const dose of doseLogs) { for (const dose of dosesByTreatment.get(treatment.id) ?? []) {
if (dose.treatmentId !== treatment.id || dose.timestamp > t) continue; if (dose.timestamp > t) continue;
const dtH = (t - dose.timestamp) / 3600000.0; const dtH = (t - dose.timestamp) / 3600000.0;
if (dtH > cutoff) continue; if (dtH > cutoff) continue;
const c = concentrationOfDose(treatment, dose, t, p, modelOverride); const c = concentrationOfDose(treatment, dose, t, p, modelOverride);
@ -442,6 +512,14 @@ export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConf
export function generateForecastDoses(treatment, allDoseLogs, toMs, nowMs = Date.now()) { export function generateForecastDoses(treatment, allDoseLogs, toMs, nowMs = Date.now()) {
const intervalDays = treatment.forecastIntervalDays; const intervalDays = treatment.forecastIntervalDays;
if (intervalDays === null || intervalDays === undefined || intervalDays <= 0.0) return []; if (intervalDays === null || intervalDays === undefined || intervalDays <= 0.0) return [];
// ⚠️ FIX #70 (v1.12.0, miroir du Kotlin) : un traitement INACTIF ne
// projette PLUS de créneaux — l'archivage arrête les prédictions
// d'injections futures (graphique Prévision, extension de fenêtre,
// horizon LabTiming). Cohérent avec les rappels (nextReminderFireFor
// garde déjà !isActive). ⚠️ §6.bis inchangé : l'INACTIF reste simulé
// pour son HISTORIQUE (computeCurve ne passe pas ici) — seul le FUTUR
// s'arrête.
if (!treatment.isActive) return [];
const intervalMs = Math.trunc(intervalDays * 24.0 * HOUR_MS); const intervalMs = Math.trunc(intervalDays * 24.0 * HOUR_MS);
if (intervalMs <= 0) return []; if (intervalMs <= 0) return [];

View File

@ -1,6 +1,12 @@
/** /**
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
* PKProfileStore — chargeur de l'asset `pk_profiles.json` + échantillonnage * PKProfileStore — chargeur de l'asset `pk_profiles.json` + échantillonnage
*
* ⚠️ HORS RUNTIME WEB depuis v1.9.0 (modèle Estrannaise analytique,
* estrannaise-models.js) : ce module n'est plus importé par js/ui — il ne
* survit que pour le TEST de fidélité RMS (miroir de PKProfileStore.kt,
* test-only côté Android aussi). L'asset est exclu du zip de release
* (.gitattributes export-ignore).
* (portage web de `pk/PKProfileStore.kt`). * (portage web de `pk/PKProfileStore.kt`).
* *
* L'asset contient les tables horaires Estrannaise extraites du tableur * L'asset contient les tables horaires Estrannaise extraites du tableur
@ -137,24 +143,8 @@ export function sample(ester, mod, dtHours) {
// plate) → on part du dernier point encore ≥ 1 % du pic, avec le TAUX // plate) → on part du dernier point encore ≥ 1 % du pic, avec le TAUX
// logarithmique moyen des 48 h précédentes (jamais avant le pic) — // logarithmique moyen des 48 h précédentes (jamais avant le pic) —
// extrapolation EXPONENTIELLE, donc jamais croissante. // extrapolation EXPONENTIELLE, donc jamais croissante.
let peakIdx = 0; const [j, rate] = terminalDecayParameters(arr) ?? [0, 0.0];
let peakV = 0;
for (let idx = 0; idx < arr.length; idx++) {
if (arr[idx] > peakV) {
peakV = arr[idx];
peakIdx = idx;
}
}
if (peakV <= 0) return 0.0;
let j = lastIdx;
while (j > 0 && arr[j] < peakV * 0.01) j--;
if (j <= 0) return 0.0; if (j <= 0) return 0.0;
const WINDOW = 48;
const j0 = Math.max(peakIdx, j - WINDOW);
let rate = 0.0;
if (j > j0) {
rate = Math.log(Math.max(arr[j], 1e-12) / Math.max(arr[j0], 1e-12)) / (j - j0);
}
return arr[j] * Math.exp(rate * (dtHours - j)); return arr[j] * Math.exp(rate * (dtHours - j));
} }
@ -165,6 +155,62 @@ export function sample(ester, mod, dtHours) {
return arr[i0] + (arr[i0 + 1] - arr[i0]) * frac; return arr[i0] + (arr[i0 + 1] - arr[i0]) * frac;
} }
/**
* Paramètres de DÉCROISSANCE TERMINALE d'une table (v1.8.0 — partagés par
* [sample] et [terminalHalfLifeDays], miroir du Kotlin) : dernier point
* ≥ 1 % du pic + pente log-linéaire /h sur les 48 h précédentes (jamais
* avant le pic).
*
* @private
* @param {number[]} arr table horaire
* @returns {[number, number]|null} [index j, pente /h] — null si ni pic ni
* queue exploitable
*/
function terminalDecayParameters(arr) {
let peakIdx = 0;
let peakV = 0;
for (let idx = 0; idx < arr.length; idx++) {
if (arr[idx] > peakV) {
peakV = arr[idx];
peakIdx = idx;
}
}
if (peakV <= 0) return null;
let j = arr.length - 1;
while (j > 0 && arr[j] < peakV * 0.01) j--;
if (j <= 0) return null;
const WINDOW = 48;
const j0 = Math.max(peakIdx, j - WINDOW);
if (j <= j0) return null;
const rate = Math.log(Math.max(arr[j], 1e-12) / Math.max(arr[j0], 1e-12)) / (j - j0);
return [j, rate];
}
/**
* Demi-vie TERMINALE (JOURS) estimée depuis la table horaire (v1.8.0, miroir
* du Kotlin) : t½ = ln2 ÷ |pente terminale| — cf [terminalDecayParameters].
*
* Pourquoi : la recommandation de prochaine prise de sang (js/pk/lab-timing.js)
* juge de la STABILISATION du régime via 5 × t½ — et le modèle Estrannaise
* (tables ODS) n'a pas de forme close : sa t½ se LIT dans la table (les
* modèles TFS/WHSAH exposent la leur analytiquement).
*
* @param {string} ester Clé ester ("EV"…)
* @param {string} mod "ESE" | "TFS"
* @returns {number|null} t½ en jours, ou null si pas de décroissance
* terminale exploitable (ester inconnu, pic nul, pas de queue)
*/
export function terminalHalfLifeDays(ester, mod) {
if (!profiles) return null;
const suffix = MODEL_SUFFIX[mod];
if (!suffix) return 0.0;
const arr = lookup(`${ester}_${suffix}`);
if (!arr) return null;
const decay = terminalDecayParameters(arr);
if (!decay || decay[1] >= 0) return null;
return Math.log(2) / -decay[1] / 24.0; // pente /h → t½ en jours
}
/** @returns {boolean} true si le store a été initialisé (asset chargé). */ /** @returns {boolean} true si le store a été initialisé (asset chargé). */
export function isInitialized() { export function isInitialized() {
return profiles !== null; return profiles !== null;

View File

@ -49,6 +49,13 @@ export const all = [
new PKPreset('preset_ev_ese', 'ESTRADIOL', 'INJECTION_IM', Esters.EV, PKModels.ESTRANNAISE, 46, 100, 1, 'mg', 4.0), new PKPreset('preset_ev_ese', 'ESTRADIOL', 'INJECTION_IM', Esters.EV, PKModels.ESTRANNAISE, 46, 100, 1, 'mg', 4.0),
new PKPreset('preset_eu_ese', 'ESTRADIOL', 'INJECTION_IM', Esters.EU, PKModels.ESTRANNAISE, 55, 400, 1, 'mg', 10.0), new PKPreset('preset_eu_ese', 'ESTRADIOL', 'INJECTION_IM', Esters.EU, PKModels.ESTRANNAISE, 55, 400, 1, 'mg', 10.0),
new PKPreset('preset_een_ese', 'ESTRADIOL', 'INJECTION_SUBCUT', Esters.EEN, PKModels.ESTRANNAISE, 152, 150, 1, 'mg', 10.0), new PKPreset('preset_een_ese', 'ESTRADIOL', 'INJECTION_SUBCUT', Esters.EEN, PKModels.ESTRANNAISE, 152, 150, 1, 'mg', 10.0),
// --- v1.9.4 : le modèle ESE est ANALYTIQUE (estrannaise.js) et couvre
// 6 esters injectables — presets ajoutés pour EC (huile), EB et EUCS
// (undécylate suspension cristalline, exclusif à ce fit). Champs
// Bateman = t½ terminales des fits (pas utilisés au calcul).
new PKPreset('preset_ec_ese', 'ESTRADIOL', 'INJECTION_IM', Esters.EC, PKModels.ESTRANNAISE, 7, 202, 1, 'mg', 5.0),
new PKPreset('preset_eb_ese', 'ESTRADIOL', 'INJECTION_IM', Esters.EB, PKModels.ESTRANNAISE, 1, 4, 1, 'mg', 1.0),
new PKPreset('preset_eucs_ese', 'ESTRADIOL', 'INJECTION_SUBCUT', Esters.EUCS, PKModels.ESTRANNAISE, 22, 756, 1, 'mg', 10.0),
// --- Injections : modèles Transfem Science --- // --- Injections : modèles Transfem Science ---
new PKPreset('preset_ev_tfs', 'ESTRADIOL', 'INJECTION_IM', Esters.EV, PKModels.TRANSFEM_SCIENCE, 50, 72, 1, 'mg', 4.0), new PKPreset('preset_ev_tfs', 'ESTRADIOL', 'INJECTION_IM', Esters.EV, PKModels.TRANSFEM_SCIENCE, 50, 72, 1, 'mg', 4.0),
new PKPreset('preset_eu_tfs', 'ESTRADIOL', 'INJECTION_IM', Esters.EU, PKModels.TRANSFEM_SCIENCE, 204, 528, 1, 'mg', 10.0), new PKPreset('preset_eu_tfs', 'ESTRADIOL', 'INJECTION_IM', Esters.EU, PKModels.TRANSFEM_SCIENCE, 204, 528, 1, 'mg', 10.0),

View File

@ -3,7 +3,7 @@
* HormoneTrack Web — shell applicatif (portage de HormoneTrackApp.kt + * HormoneTrack Web — shell applicatif (portage de HormoneTrackApp.kt +
* MainActivity.kt + HormoneTrackRoot.kt). * MainActivity.kt + HormoneTrackRoot.kt).
* *
* - attend l'initialisation de PKProfileStore (fetch de l'asset) ; * - attend l'initialisation de l'asset MCMC (nuage d'incertitude ESE, v1.9.0) ;
* - routing par hash (#home/#chart/#doses/#labs/#treatments/#settings/ * - routing par hash (#home/#chart/#doses/#labs/#treatments/#settings/
* #treatment-edit/{id}) — permet la navigation headless et le lien profond ; * #treatment-edit/{id}) — permet la navigation headless et le lien profond ;
* - NavigationBar 5 onglets (barre masquée sur settings/éditeur, miroir * - NavigationBar 5 onglets (barre masquée sur settings/éditeur, miroir
@ -20,7 +20,7 @@
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
*/ */
import * as PKProfileStore from '../pk/pk-profile-store.js'; import * as EstrannaiseModels from '../pk/estrannaise-models.js';
import { store } from '../data/store.js'; import { store } from '../data/store.js';
import * as AppLog from '../util/app-log.js'; import * as AppLog from '../util/app-log.js';
import { isVersionNewer, sectionsSince } from '../util/changelog.js'; import { isVersionNewer, sectionsSince } from '../util/changelog.js';
@ -55,9 +55,21 @@ async function main() {
renderRoute(); // affiche l'écran courant avec le marqueur de chargement renderRoute(); // affiche l'écran courant avec le marqueur de chargement
// 4) Asset PK (équivalent de PKProfileStore.init(context) dans // 4) Asset PK (équivalent de PKProfileStore.init(context) dans
// HormoneTrackApp.onCreate) — les courbes ne sont calculées qu'après ; // HormoneTrackApp.onCreate).
// le re-rendu ci-dessous remplace le « Chargement… » par les données. // v1.9.0 : l'asset MCMC (nuage ESE, 48 Ko) remplace le fetch des tables
await PKProfileStore.init(); // ODS (550 Ko) — démarrage plus rapide, runtime 100 % sources analytiques.
// v1.9.5 (audit) : l'init n'est PLUS BLOQUANTE — seul le nuage lit le
// posterior (compute renvoie [] proprement tant qu'il n'est pas chargé) :
// l'abonnement au store, la boucle de rappels et le dialog « Nouveautés »
// démarrent sans attendre le fetch. L'init re-rend à la fin (le
// « Chargement… » cède la place aux données) et pose data-ready —
// garantie E2E « data-ready ⇒ asset chargé » (le commentaire antérieur
// était faux : le marqueur était posé avant le fetch).
EstrannaiseModels.init().then(() => {
booted = true;
renderRoute();
document.body.setAttribute('data-ready', '1');
});
// 5) Langue au démarrage (miroir MainActivity : setApplicationLocales) // 5) Langue au démarrage (miroir MainActivity : setApplicationLocales)
document.documentElement.lang = resolveLocale(store.getSettings().language); document.documentElement.lang = resolveLocale(store.getSettings().language);
@ -94,6 +106,8 @@ let contentEl = null;
let bannerSlot = null; let bannerSlot = null;
/** Dernière route rendue (le tick minute ne re-rend que si inchangée). */ /** Dernière route rendue (le tick minute ne re-rend que si inchangée). */
let lastRoute = null; let lastRoute = null;
/** Asset MCMC chargé (fin du démarrage) — conditionne le marqueur data-ready. */
let booted = false;
/** Mode démo (?demo=1) : bannière affichée au-dessus du contenu. */ /** Mode démo (?demo=1) : bannière affichée au-dessus du contenu. */
let demoMode = false; let demoMode = false;
@ -154,17 +168,15 @@ function renderRoute() {
const preserveChartState = route === 'chart' && lastRoute === 'chart'; const preserveChartState = route === 'chart' && lastRoute === 'chart';
lastRoute = route; lastRoute = route;
// Écran de chargement tant que l'asset PK n'est pas chargé (les écrans // Version exposée à l'E2E (data-version) : les asserts se branchent sur
// afficheraient « aucune donnée » à tort — les profils valent 0 avant init). // CETTE valeur au lieu d'une string codée en dur à bump à chaque release
if (!PKProfileStore.isInitialized()) { // — l'oubli a produit le bug v1.9.3 (version annoncée fausse).
contentEl.textContent = ''; document.body.dataset.version = WEB_VERSION;
contentEl.appendChild(el('p', { class: 'hint', style: 'padding:24px 0;' }, t('loading'))); // Marqueur E2E : l'app est prête — utilisé par scripts/e2e.mjs pour
return; // attendre la fin du rendu (plus de course au screenshot). Au DÉMARRAGE
} // (booted=false), c'est la fin de l'init MCMC qui le pose : data-ready
// garantit « asset chargé, écran rendu » (v1.9.5, audit).
// Marqueur E2E : l'app est prête (asset chargé, écran rendu) — utilisé par if (booted) document.body.setAttribute('data-ready', '1');
// scripts/e2e.mjs pour attendre la fin du rendu (plus de course au screenshot).
document.body.setAttribute('data-ready', '1');
// Onglet actif // Onglet actif
document.querySelectorAll('.tabbar button').forEach((b) => { document.querySelectorAll('.tabbar button').forEach((b) => {
b.classList.toggle('active', b.getAttribute('data-route') === route); b.classList.toggle('active', b.getAttribute('data-route') === route);
@ -347,13 +359,58 @@ function maybeLoadDemoData() {
esterType: null, esterType: null,
}); });
} }
// Labs E2/T plausibles (1 prise de sang toutes les 2 semaines) // Labs E2/T plausibles (1 prise de sang toutes les 2 semaines).
// v1.7.1 : la DERNIÈRE paire porte des notes DISTINCTES (E2 ≠ T) — cas
// réel du bug « une seule note sur deux s'affichait » (fix labNotesForDisplay)
// et fixture du scénario E2E correspondant.
const labs = [[280, 0.35], [300, 0.31], [260, 0.4], [290, 0.33], [275, 0.36], [305, 0.3]]; const labs = [[280, 0.35], [300, 0.31], [260, 0.4], [290, 0.33], [275, 0.36], [305, 0.3]];
labs.forEach(([e2, tVal], i) => { labs.forEach(([e2, tVal], i) => {
const ts = now - (11 - i * 2) * 7 * DAY; const ts = now - (11 - i * 2) * 7 * DAY;
store.upsertLabResult({ marker: 'E2', value: e2, unit: 'pg/mL', timestamp: ts, notes: null }); const isLastPair = i === labs.length - 1;
store.upsertLabResult({ marker: 'T', value: Math.round(tVal * 100), unit: 'ng/dL', timestamp: ts, notes: null }); store.upsertLabResult({
marker: 'E2', value: e2, unit: 'pg/mL', timestamp: ts,
notes: isLastPair ? 'Prise de sang après des maux de tête.' : null,
}); });
store.upsertLabResult({
marker: 'T', value: Math.round(tVal * 100), unit: 'ng/dL', timestamp: ts,
notes: isLastPair ? 'DHT : 12 ng/dL' : null,
});
});
// v1.9.0 : un 2ᵉ traitement E2 à modèle ESE (EV) avec 2 doses — permet
// de démontrer/tester le nuage d'incertitude ESE et la couverture
// multi-esters sans toucher au traitement EEn (TFS) principal.
const evId = store.upsertTreatment({
name: 'EV — Estrannaise (démo)',
type: 'ESTRADIOL',
route: 'INJECTION_IM',
doseAmount: 4.0,
doseUnit: 'mg',
isActive: true,
notes: null,
esterType: 'EV',
pkModel: 'ESE',
absorptionHours: 45,
eliminationHalfLifeHours: 70,
bioavailabilityFraction: 1,
scaleFactor: 1.0,
forecastIntervalDays: null,
reminderHour: null,
reminderMinute: null,
reminderEnabled: false,
calendarEventId: null,
createdAt: now - 60 * DAY,
});
// doses vieilles de 10-24 j : contribution résiduelle au présent (la
// courbe EV/SE fait partie du mini-chart sans saturer le niveau actuel)
for (const k of [24, 17, 10]) {
store.upsertDoseLog({
treatmentId: evId,
timestamp: now - k * DAY,
doseAmount: 4.0,
notes: null,
esterType: null,
});
}
AppLog.log('demo', 'données de démonstration chargées (?demo=1)'); AppLog.log('demo', 'données de démonstration chargées (?demo=1)');
} }

View File

@ -87,22 +87,28 @@ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses
const h = cssH - padTop - padBottom; const h = cssH - padTop - padBottom;
if (w <= 0 || h <= 0) return; if (w <= 0 || h <= 0) return;
const t0 = Math.min(...allPoints.map((p) => p.timestamp)); // v1.9.5 (audit) : t0/t1/e2Max/tMax en UNE passe (RangeError possible au-delà
const t1 = Math.max(...allPoints.map((p) => p.timestamp)); // de ~10⁵ arguments de spread — atteignable avec le Nuage actif ; et 5
// allocations intermédiaires par frame de pan évitées).
let t0 = Infinity;
let t1 = -Infinity;
let e2DataMax = 0;
let tDataMax = 0;
for (const p of allPoints) {
if (p.timestamp < t0) t0 = p.timestamp;
if (p.timestamp > t1) t1 = p.timestamp;
if (p.e2 > e2DataMax) e2DataMax = p.e2;
if (p.t > tDataMax) tDataMax = p.t;
}
if (t1 <= t0) return; if (t1 <= t0) return;
// Labs T convertis en ng/mL AVANT l'échelle (fix #23) // Labs T convertis en ng/mL AVANT l'échelle (fix #23)
const convertedTLabs = tLabs.map((lab) => ({ ...lab, value: convertTToNgMl(lab.value, lab.unit) })); const convertedTLabs = tLabs.map((lab) => ({ ...lab, value: convertTToNgMl(lab.value, lab.unit) }));
if (options.showLabs) {
const e2DataMax = Math.max( for (const l of e2Labs) if (l.value > e2DataMax) e2DataMax = l.value;
Math.max(...allPoints.map((p) => p.e2)), for (const l of convertedTLabs) if (l.value > tDataMax) tDataMax = l.value;
options.showLabs && e2Labs.length ? Math.max(...e2Labs.map((l) => l.value)) : 0, }
);
const e2Max = niceCeil(Math.max(e2DataMax, 50.0)); const e2Max = niceCeil(Math.max(e2DataMax, 50.0));
const tDataMax = Math.max(
Math.max(...allPoints.map((p) => p.t)),
options.showLabs && convertedTLabs.length ? Math.max(...convertedTLabs.map((l) => l.value)) : 0,
);
const tMax = niceCeil(tDataMax * 1.1); const tMax = niceCeil(tDataMax * 1.1);
const xOf = (ms) => padLeft + (w * (ms - t0)) / (t1 - t0); const xOf = (ms) => padLeft + (w * (ms - t0)) / (t1 - t0);

View File

@ -31,7 +31,7 @@
import { import {
computeCurve, generateForecastDoses, autoCalibrated, TConfig, computeCurve, generateForecastDoses, autoCalibrated, TConfig,
computeLabAnchoredCurve, computeLabAnchoredCurve, computeEstrannaiseCloud, DEFAULT_NB_CURVES,
} from '../pk/index.js'; } from '../pk/index.js';
import { import {
stepForRange, defaultModelToggles, panDeltaHours, clampPanHours, stepForRange, defaultModelToggles, panDeltaHours, clampPanHours,
@ -124,6 +124,12 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// demandée explicitement ; le chip n'est actif que si Lab track est ON // demandée explicitement ; le chip n'est actif que si Lab track est ON
// (miroir ChartScreen.kt). // (miroir ChartScreen.kt).
showLabTrackExtend: false, showLabTrackExtend: false,
// v1.9.0 : NUAGE D'INCERTITUDE du modèle ESE (posterior MCMC de
// estrannaise.js) — OFF par défaut, activable à volonté, ACTIVABLE
// SEULEMENT si le modèle ESE est affiché (exclusif à ce modèle — les
// fits TFS/WHSAH ne publient pas de posterior). Éteindre ESE coupe le
// nuage automatiquement (miroir ChartScreen.kt).
showCloud: false,
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62) panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
togglesInitialized: false, togglesInitialized: false,
}; };
@ -165,7 +171,10 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
} }
// ── Horizon de prévision (12 × plus grande Posologie, borné 30 j–1 an) ──── // ── Horizon de prévision (12 × plus grande Posologie, borné 30 j–1 an) ────
// ⚠️ FIX #70 (v1.12.0, miroir du Kotlin) : SEULS les traitements ACTIFS
// comptent — la Posologie d'un archivé n'active plus le chip Prévision.
const maxIntervalDays = treatments const maxIntervalDays = treatments
.filter((tr) => tr.isActive)
.map((tr) => tr.forecastIntervalDays) .map((tr) => tr.forecastIntervalDays)
.filter((x) => x !== null && x !== undefined) .filter((x) => x !== null && x !== undefined)
.reduce((m, x) => Math.max(m, x), 0) || null; .reduce((m, x) => Math.max(m, x), 0) || null;
@ -253,7 +262,13 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// Rangée 3 : toggles modèles superposables // Rangée 3 : toggles modèles superposables
modelRow.textContent = ''; modelRow.textContent = '';
modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => { state.showEse = !state.showEse; redraw(); })); modelRow.appendChild(filterChip(t('model_ese'), state.showEse, () => {
state.showEse = !state.showEse;
// v1.9.0 : le nuage est exclusif à ESE — éteindre ESE éteint le nuage
// (sinon il flotterait sans sa courbe)
if (!state.showEse) state.showCloud = false;
redraw();
}));
modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); })); modelRow.appendChild(filterChip(t('model_tfs'), state.showTfs, () => { state.showTfs = !state.showTfs; redraw(); }));
modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); })); modelRow.appendChild(filterChip(t('model_whs'), state.showWhs, () => { state.showWhs = !state.showWhs; redraw(); }));
// v1.5.0 : courbe hybride ancrée sur les labs (off par défaut) // v1.5.0 : courbe hybride ancrée sur les labs (off par défaut)
@ -262,6 +277,9 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// défaut, SANS EFFET — donc désactivé — tant que Lab track est off : // défaut, SANS EFFET — donc désactivé — tant que Lab track est off :
// la prolongation n'existe que comme extension de CETTE courbe) // la prolongation n'existe que comme extension de CETTE courbe)
modelRow.appendChild(filterChip(t('show_lab_track_extend'), state.showLabTrackExtend, () => { state.showLabTrackExtend = !state.showLabTrackExtend; redraw(); }, !state.showLabTrack)); modelRow.appendChild(filterChip(t('show_lab_track_extend'), state.showLabTrackExtend, () => { state.showLabTrackExtend = !state.showLabTrackExtend; redraw(); }, !state.showLabTrack));
// v1.9.0 : nuage d'incertitude (off par défaut, activable à volonté,
// SEULEMENT si ESE est affiché — même pattern que Prolonger/Tracé labs)
modelRow.appendChild(filterChip(t('show_cloud'), state.showCloud, () => { state.showCloud = !state.showCloud; redraw(); }, !state.showEse));
// ── Courbes par modèle sélectionné ───────────────────────────────────── // ── Courbes par modèle sélectionné ─────────────────────────────────────
const curves = []; const curves = [];
@ -277,8 +295,8 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// ⚠️ Les DEUX clés doivent être SKIPPÉES par la boucle de légende // ⚠️ Les DEUX clés doivent être SKIPPÉES par la boucle de légende
// (leçon #63 Android : sinon le catchall imprime la légende TFS en // (leçon #63 Android : sinon le catchall imprime la légende TFS en
// doublon) et avoir leurs entrées dédiées plus bas. // doublon) et avoir leurs entrées dédiées plus bas.
const step = stepForRange(state.rangeHours); // partagé : LAB + modèles (v1.9.0)
if (state.showLabTrack) { if (state.showLabTrack) {
const step = stepForRange(state.rangeHours);
const labCurve = computeLabAnchoredCurve( const labCurve = computeLabAnchoredCurve(
treatments, doseLogs, labResults, startMs, endMs, step, null, treatments, doseLogs, labResults, startMs, endMs, step, null,
state.showLabTrackExtend, state.showLabTrackExtend,
@ -292,6 +310,17 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
curves.push(['LAB', labCurve.points]); curves.push(['LAB', labCurve.points]);
} }
} }
// v1.9.0 : NUAGE D'INCERTITUDE ESE — INDÉPENDANT du tracé labs (il ne
// dépend que de showCloud && showEse) : M courbes du posterior MCMC, cf
// EstrannaiseCloud. Clé 'CLOUD' skippée des légendes (leçon #63 miroir)
// et dessinée en alpha faible.
if (state.showCloud) {
const cloud = computeEstrannaiseCloud(
treatments, doseLogs, startMs, endMs, step,
DEFAULT_NB_CURVES, autoByModel.get('ESE')?.esterScales ?? null,
);
if (cloud.length > 0) curves.push(['CLOUD', cloud.flat()]);
}
if (state.showEse || state.showTfs || state.showWhs) { if (state.showEse || state.showTfs || state.showWhs) {
// Home utilise le modèle stocké ; le graphique calibre chaque modèle // Home utilise le modèle stocké ; le graphique calibre chaque modèle
// avec SA prédiction (fix #60). Référence = auto du modèle TFS (les // avec SA prédiction (fix #60). Référence = auto du modèle TFS (les
@ -307,7 +336,6 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
const allDoses = doseLogs.concat(forecastDoses); const allDoses = doseLogs.concat(forecastDoses);
// Pas d'échantillonnage adaptatif au zoom (v1.2.9) // Pas d'échantillonnage adaptatif au zoom (v1.2.9)
const step = stepForRange(state.rangeHours);
const addCurve = (model) => { const addCurve = (model) => {
const autoM = autoByModel.get(model); const autoM = autoByModel.get(model);
curves.push([model, computeCurve( curves.push([model, computeCurve(
@ -326,12 +354,20 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
const allPoints = curves.flatMap(([, pts]) => pts); const allPoints = curves.flatMap(([, pts]) => pts);
// Labs filtrés sur la fenêtre ; le toggle T masque AUSSI les labs T // Labs filtrés sur la fenêtre ; le toggle T masque AUSSI les labs T.
// v1.9.5 (audit) : min/max en UNE passe (boucle) — les spreads
// `Math.min(...arr)` lèvent RangeError au-delà de ~10⁵ arguments
// (atteignable : Nuage actif × zoom max ≈ 230 000 points) et
// allouent un tableau intermédiaire par appel × frame de pan.
let a = 0; let a = 0;
let b = 0; let b = 0;
if (allPoints.length >= 2) { if (allPoints.length >= 2) {
a = Math.min(...allPoints.map((p) => p.timestamp)); a = allPoints[0].timestamp;
b = Math.max(...allPoints.map((p) => p.timestamp)); b = a;
for (const p of allPoints) {
if (p.timestamp < a) a = p.timestamp;
if (p.timestamp > b) b = p.timestamp;
}
} }
const e2Labs = labResults.filter((l) => String(l.marker).toUpperCase() === 'E2' && l.timestamp >= a && l.timestamp <= b); const e2Labs = labResults.filter((l) => String(l.marker).toUpperCase() === 'E2' && l.timestamp >= a && l.timestamp <= b);
const tLabs = state.showT const tLabs = state.showT
@ -354,6 +390,14 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
drawChart( drawChart(
canvas, canvas,
curves.map(([model, pts]) => { curves.map(([model, pts]) => {
if (model === 'CLOUD') {
// v1.9.0 : nuage MCMC — chaque courbe individuelle est tracée
// en alpha faible ; PAS de labels/extrema (incertitude, pas des
// courbes nommées). Ne participe pas aux échelles via allPoints
// ? Si : allPoints inclut CLOUD — les échelles s'adaptent donc
// au nuage (cohérent : le nuage montre le range réel possible).
return { points: pts, e2Style: { color: 'rgba(194,24,91,0.12)', dashed: true }, tStyle: null, showExtrema: false };
}
if (model === 'LAB') { if (model === 'LAB') {
// v1.5.0 : Tracé labs = E2 rose foncé pointillé, PAS de courbe T // v1.5.0 : Tracé labs = E2 rose foncé pointillé, PAS de courbe T
// (série E2-only) et hors pics/creux (showExtrema=false) // (série E2-only) et hors pics/creux (showExtrema=false)
@ -396,7 +440,7 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// ⚠️ Les clés 'LAB' (v1.5.0) et 'LABX' (v1.6.0) sont SKIPPÉES // ⚠️ Les clés 'LAB' (v1.5.0) et 'LABX' (v1.6.0) sont SKIPPÉES
// (légendes dédiées plus bas, fix #63 Android-miroir : sinon le // (légendes dédiées plus bas, fix #63 Android-miroir : sinon le
// catchall imprime la légende TFS en doublon) // catchall imprime la légende TFS en doublon)
if (model === 'LAB' || model === 'LABX') continue; if (model === 'LAB' || model === 'LABX' || model === 'CLOUD') continue;
const st = seriesStyles(model); const st = seriesStyles(model);
if (model === 'ESE') { if (model === 'ESE') {
legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color)); legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color));
@ -423,6 +467,12 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
// si la série "LABX" existe (chip Prolonger actif ET horizon atteint // si la série "LABX" existe (chip Prolonger actif ET horizon atteint
// dans la fenêtre visible). Même condition pour les deux (factorisée) : // dans la fenêtre visible). Même condition pour les deux (factorisée) :
// il ne peut jamais y avoir de courbe prolongée sans son avertissement. // il ne peut jamais y avoir de courbe prolongée sans son avertissement.
// v1.9.0 : légende du nuage d'incertitude (ESE) — visible tant que
// le nuage est affiché (l'alpha faible le rend nettement secondaire)
if (state.showCloud && state.showEse
&& curves.some(([m, pts]) => m === 'CLOUD' && pts.length >= 2)) {
legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_cloud')));
}
const labExtendVisible = state.showLabTrack && state.showLabTrackExtend const labExtendVisible = state.showLabTrack && state.showLabTrackExtend
&& curves.some(([m, pts]) => m === 'LABX' && pts.length >= 2); && curves.some(([m, pts]) => m === 'LABX' && pts.length >= 2);
if (labExtendVisible) { if (labExtendVisible) {

View File

@ -7,7 +7,8 @@
* - create (traitements ACTIFS proposés) / edit (TOUS les traitements — * - create (traitements ACTIFS proposés) / edit (TOUS les traitements —
* la dose éditée reste rattachable à son traitement même inactivé, §6.bis) ; * la dose éditée reste rattachable à son traitement même inactivé, §6.bis) ;
* - override d'ESTER par injection (comme le tableur d'origine) : la liste * - override d'ESTER par injection (comme le tableur d'origine) : la liste
* suit le MODÈLE du traitement via choicesForModel (TFS=7, WHS=6, ESE=3). * suit le MODÈLE du traitement via choicesForModel (v1.9.4 : TFS=7,
* WHSAH=6, ESE analytique=6).
* *
* LabDialog (v1.2.2) : prise de sang E2 + T en UNE entrée (chacune * LabDialog (v1.2.2) : prise de sang E2 + T en UNE entrée (chacune
* optionnelle) → 1 ou 2 LabResult au même timestamp ; unités T suggérées. * optionnelle) → 1 ou 2 LabResult au même timestamp ; unités T suggérées.

View File

@ -15,8 +15,9 @@
*/ */
import { store } from '../data/store.js'; import { store } from '../data/store.js';
import { routeGlyph, routeLabelKey } from '../data/models.js';
import { t } from '../util/i18n.js'; import { t } from '../util/i18n.js';
import { formatDose, formatTime, formatLongDate, formatISODate, HrtDuration } from '../util/format.js'; import { formatDose, formatTime, formatLongDate, formatISODate, HrtDuration, labMarkersForDose } from '../util/format.js';
import { el, assistChip, confirmDialog, fab } from './components.js'; import { el, assistChip, confirmDialog, fab } from './components.js';
import { openDoseDialog } from './dialogs.js'; import { openDoseDialog } from './dialogs.js';
@ -91,7 +92,17 @@ export function renderDoses(container) {
col.appendChild(el('div', { class: 'group-header' }, dateKey)); col.appendChild(el('div', { class: 'group-header' }, dateKey));
for (const log of logs) { for (const log of logs) {
const tr = treatmentMap.get(log.treatmentId); const tr = treatmentMap.get(log.treatmentId);
// v1.12.0 : marqueurs « prise de sang le même jour » (↑ avant / ↓
// après) — miroir labMarkersForDose de DosesScreen.kt
const markers = labMarkersForDose(store.getLabResults(), log.timestamp);
const item = el('div', { class: 'list-item', onclick: () => openDoseDialog({ treatments, preselected: null, existing: log }) }); const item = el('div', { class: 'list-item', onclick: () => openDoseDialog({ treatments, preselected: null, existing: log }) });
// v1.12.0 : GLYPHE de forme de prise à gauche (💊 oral, 💉 injection
// IM/SC, 💧 gel, 🩹 patch, 🧴 autre — cf routeGlyph dans models.js)
item.appendChild(el('span', {
class: 'sub',
style: 'align-self:center;font-size:1.1em;',
title: tr ? t(routeLabelKey(tr.route)) : '',
}, routeGlyph(tr ? tr.route : null)));
const main = el('div', { class: 'main' }); const main = el('div', { class: 'main' });
main.appendChild(el('div', { class: 'title' }, main.appendChild(el('div', { class: 'title' },
`${tr ? tr.name : '?'} · ${formatDose(log.doseAmount)} ${tr ? tr.doseUnit : ''}`)); `${tr ? tr.name : '?'} · ${formatDose(log.doseAmount)} ${tr ? tr.doseUnit : ''}`));
@ -107,12 +118,28 @@ export function renderDoses(container) {
if (log.notes && log.notes.trim() !== '') sub.appendChild(el('span', {}, log.notes)); if (log.notes && log.notes.trim() !== '') sub.appendChild(el('span', {}, log.notes));
main.appendChild(sub); main.appendChild(sub);
item.appendChild(main); item.appendChild(main);
const side = el('div', { class: 'row', style: 'align-items:center;gap:2px;' });
// Encart latéral « prise de sang le même jour » (title = phrase
// complète pour l'accessibilité, miroir des contentDescription)
if (markers.before) {
side.appendChild(el('span', {
class: 'sub', style: 'color:var(--accent,#d6589e);',
title: t('lab_before_dose'),
}, '🧪↑'));
}
if (markers.after) {
side.appendChild(el('span', {
class: 'sub', style: 'color:var(--accent,#d6589e);',
title: t('lab_after_dose'),
}, '🧪↓'));
}
const del = el('button', { class: 'icon-btn', 'aria-label': t('delete') }, '🗑'); const del = el('button', { class: 'icon-btn', 'aria-label': t('delete') }, '🗑');
del.addEventListener('click', (e) => { del.addEventListener('click', (e) => {
e.stopPropagation(); // ne pas déclencher l'édition e.stopPropagation(); // ne pas déclencher l'édition
confirmDialog(t('delete'), t('confirm_delete'), () => store.deleteDoseLog(log.id)); confirmDialog(t('delete'), t('confirm_delete'), () => store.deleteDoseLog(log.id));
}); });
item.appendChild(del); side.appendChild(del);
item.appendChild(side);
col.appendChild(item); col.appendChild(item);
} }
} }

View File

@ -18,12 +18,12 @@
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
*/ */
import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore } from '../pk/index.js'; import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore, nextBloodDrawRecommendation, troughTargetStatus } from '../pk/index.js';
import { store } from '../data/store.js'; import { store } from '../data/store.js';
import { t } from '../util/i18n.js'; import { t } from '../util/i18n.js';
import { formatDose, formatValue, formatTime, formatWeekdayTime, HrtDuration } from '../util/format.js'; import { formatDose, formatValue, formatDateTime, formatWeekdayTime, nextDoseLineText } from '../util/format.js';
import { el } from './components.js'; import { el } from './components.js';
import { drawChart } from './chart-canvas.js'; import { drawChart, COLORS } from './chart-canvas.js';
import { openDoseDialog } from './dialogs.js'; import { openDoseDialog } from './dialogs.js';
const HOUR_MS = 3600000; const HOUR_MS = 3600000;
@ -109,7 +109,14 @@ export function renderHome(container, ctx) {
} }
} }
// ── Carte « prochaine dose » (grille Posologie via le moteur, fix #52) ─── // ── Carte « prochaines doses » (grille Posologie via le moteur, fix #52) ──
// v1.9.6 (demande) : la carte liste TOUTES les prochaines prises, UNE
// ligne par traitement TRIÉES par prochaine prise (helper pur
// nextDoseLineText, miroir du Kotlin nextDoseLine) — l'ancienne carte ne
// montrait que la plus proche, et une prise quotidienne (CPA) masquait
// totalement le rappel de l'injection hebdomadaire. Le titre passe au
// pluriel dès qu'il y a au moins deux lignes ; la 1ʳᵉ ligne (la plus
// proche) reste mise en avant (font-weight 600).
const fires = []; const fires = [];
for (const tr of treatments) { for (const tr of treatments) {
const fire = nextReminderFireFor(tr, doseLogs, nowMs); const fire = nextReminderFireFor(tr, doseLogs, nowMs);
@ -117,27 +124,55 @@ export function renderHome(container, ctx) {
} }
if (fires.length) { if (fires.length) {
fires.sort((a, b) => a[1] - b[1]); fires.sort((a, b) => a[1] - b[1]);
const [nextTreatment, next] = fires[0];
const deltaMs = next - nowMs;
const h = Math.floor(deltaMs / HOUR_MS);
const m = Math.floor((deltaMs % HOUR_MS) / 60000);
const card = el('div', { class: 'card' }); const card = el('div', { class: 'card' });
card.appendChild(el('div', { class: 'label' }, t('next_dose'))); card.appendChild(el('div', { class: 'label' }, t(fires.length === 1 ? 'next_dose' : 'next_doses')));
if (h >= 24) { fires.forEach(([treatment, next], idx) => {
// v1.4.1 : au-delà de 24 h, afficher en JOURS + jour de semaine card.appendChild(el('div', { style: idx === 0 ? 'font-weight:600;' : '' },
const [days, remH] = HrtDuration.daysAndHours(h); nextDoseLineText(nowMs, next, treatment.name)));
card.appendChild(el('div', { style: 'font-weight:600;' }, });
t('next_dose_days', days, remH, formatWeekdayTime(next), nextTreatment.name)));
} else if (h > 0) {
card.appendChild(el('div', { style: 'font-weight:600;' },
`${h}h${String(m).padStart(2, '0')} · ${formatTime(next)} (${nextTreatment.name})`));
} else {
card.appendChild(el('div', { style: 'font-weight:600;' },
`${m} min · ${formatTime(next)} (${nextTreatment.name})`));
}
col.appendChild(card); col.appendChild(card);
} }
// ── Recommandation de prochaine prise de sang (v1.8.1) ───────────────────
// Même calcul que la page Analyses (pk/LabTiming.js) — encart COMPACT sur
// l'accueil (demande v1.8.1) : creux daté + créneau associé + mention de
// stabilisation SEULEMENT si le régime n'était pas déjà stable (sinon le
// texte est identique à chaque ouverture). Caché si non calculable.
// v1.13.0 : valeur E2 ATTENDUE au creux (seulement si l'auto-calibration
// fournit ses facteurs) + statut face à la cible de creux (lecture seule,
// jamais de notification — distinct des seuils d'alerte).
const labRec = nextBloodDrawRecommendation(
treatments, doseLogs, labResults, nowMs, effectiveAuto ? effectiveAuto.esterScales : null,
);
const troughTarget = store.getSettings().troughTargetLow !== null
&& store.getSettings().troughTargetHigh !== null
? { low: store.getSettings().troughTargetLow, high: store.getSettings().troughTargetHigh }
: null;
if (labRec) {
const recCard = el('div', { class: 'card variant' });
recCard.appendChild(el('div', { class: 'label' }, t('labrec_section')));
recCard.appendChild(el('div', { style: 'font-weight:600;' },
t('labrec_when', formatWeekdayTime(labRec.troughMs))));
if (labRec.predictedE2 !== null && labRec.predictedE2 !== undefined) {
const status = troughTargetStatus(
labRec.predictedE2, troughTarget ? troughTarget.low : null, troughTarget ? troughTarget.high : null,
);
const statusText = status === 'IN_TARGET' ? ` — ${t('labrec_in_target')}`
: status !== null ? ` — ${t('labrec_out_of_target')}` : '';
const statusColor = status === 'IN_TARGET' ? 'var(--primary,#4f5bd5)'
: status !== null ? 'var(--accent,#d6589e)' : '';
recCard.appendChild(el('div', { style: statusColor ? `color:${statusColor};` : '' },
t('labrec_predicted', formatDose(Math.round(labRec.predictedE2))) + statusText));
}
recCard.appendChild(el('div', {},
t('labrec_before_injection', labRec.ester, formatWeekdayTime(labRec.injectionMs))));
if (!labRec.wasAlreadyStabilized) {
recCard.appendChild(el('div', { class: 'legend-muted' },
t('labrec_not_stabilized', labRec.ester, formatDateTime(new Date(labRec.stabilizedAtMs)))));
}
col.appendChild(recCard);
}
// ── Log rapide : chips des traitements ACTIFS uniquement (saisie) ──────── // ── Log rapide : chips des traitements ACTIFS uniquement (saisie) ────────
const quickCard = el('div', { class: 'card variant' }); const quickCard = el('div', { class: 'card variant' });
quickCard.appendChild(el('h3', {}, t('logged_today'))); quickCard.appendChild(el('h3', {}, t('logged_today')));
@ -168,12 +203,12 @@ export function renderHome(container, ctx) {
const tLabs = labResults.filter((l) => String(l.marker).toUpperCase() === 'T' && l.timestamp >= a && l.timestamp <= b); const tLabs = labResults.filter((l) => String(l.marker).toUpperCase() === 'T' && l.timestamp >= a && l.timestamp <= b);
// Dessin différé : le canvas doit être dans le DOM pour connaître sa taille // Dessin différé : le canvas doit être dans le DOM pour connaître sa taille
requestAnimationFrame(() => { requestAnimationFrame(() => {
drawChart(canvas, [{ points: curve, e2Style: { color: '#4f5bd5', dashed: false }, tStyle: { color: '#d6589e', dashed: false } }], drawChart(canvas, [{ points: curve, e2Style: { color: COLORS.chartE2, dashed: false }, tStyle: { color: COLORS.chartT, dashed: false } }],
e2Labs, tLabs, { showT: true, showLabs: true, showExtrema: false, nowMs }, [], [], null); e2Labs, tLabs, { showT: true, showLabs: true, showExtrema: false, nowMs }, [], [], null);
}); });
} }
chartCard.appendChild(el('div', { class: 'legend-text', style: 'color:#4f5bd5;' }, t('home_legend_e2'))); chartCard.appendChild(el('div', { class: 'legend-text', style: `color:${COLORS.chartE2};` }, t('home_legend_e2')));
chartCard.appendChild(el('div', { class: 'legend-text', style: 'color:#d6589e;' }, t('home_legend_t'))); chartCard.appendChild(el('div', { class: 'legend-text', style: `color:${COLORS.chartT};` }, t('home_legend_t')));
chartCard.appendChild(el('div', { class: 'legend-muted' }, t('open_charts_hint'))); chartCard.appendChild(el('div', { class: 'legend-muted' }, t('open_charts_hint')));
col.appendChild(chartCard); col.appendChild(chartCard);

View File

@ -14,7 +14,8 @@
import { store } from '../data/store.js'; import { store } from '../data/store.js';
import { t } from '../util/i18n.js'; import { t } from '../util/i18n.js';
import { formatLabValue, formatDateTime } from '../util/format.js'; import { formatLabValue, formatDateTime, formatDose, labNotesForDisplay } from '../util/format.js';
import { nextBloodDrawRecommendation, shouldSuggestPosology, troughTargetStatus, autoCalibrated } from '../pk/index.js';
import { el, confirmDialog, fab, showDialog } from './components.js'; import { el, confirmDialog, fab, showDialog } from './components.js';
import { openLabDialog } from './dialogs.js'; import { openLabDialog } from './dialogs.js';
@ -55,6 +56,62 @@ export function renderLabs(container) {
const col = el('div', {}); const col = el('div', {});
container.appendChild(col); container.appendChild(col);
// ── Recommandation de prochaine prise de sang (v1.8.0) ───────────────────
// Miroir de la carte Android : calculée par le moteur (pk/LabTiming.js) sur
// TOUS les traitements + toutes les doses (§6.bis). Si le calcul n'est pas
// possible mais qu'un injectable E2 actif est SANS Posologie : invite à la
// renseigner (demande v1.8.0 — rendre le lien Posologie → recommandation
// découvrable). Les deux encarts sont mutuellement exclusifs.
const treatments = store.getTreatments();
const doseLogs = store.getDoseLogs();
// v1.13.0 : auto-calibration → facteurs d'échelle pour la prédiction
// « ≈ X pg/mL au creux » (null si l'option est OFF — même pattern home.js)
const settings = store.getSettings();
const effectiveAuto = settings.autoCalibrate
? autoCalibrated(treatments, doseLogs, labs, store.getTConfig(), null)
: null;
const rec = nextBloodDrawRecommendation(
treatments, doseLogs, labs, Date.now(), effectiveAuto ? effectiveAuto.esterScales : null,
);
// Cible de creux (v1.13.0) : colore la prédiction (lecture seule)
const troughTarget = settings.troughTargetLow !== null && settings.troughTargetHigh !== null
? { low: settings.troughTargetLow, high: settings.troughTargetHigh }
: null;
if (rec) {
const card = el('div', { class: 'card' });
card.appendChild(el('div', { class: 'title' }, t('labrec_section')));
const mainLines = [
el('div', {}, t('labrec_when', formatDateTime(rec.troughMs))),
];
// Prédiction E2 + statut cible (v1.13.0 — lecture seule, jamais de notif)
if (rec.predictedE2 !== null && rec.predictedE2 !== undefined) {
const status = troughTargetStatus(
rec.predictedE2, troughTarget ? troughTarget.low : null, troughTarget ? troughTarget.high : null,
);
const statusText = status === 'IN_TARGET' ? ` — ${t('labrec_in_target')}`
: status !== null ? ` — ${t('labrec_out_of_target')}` : '';
const statusColor = status === 'IN_TARGET' ? 'var(--primary,#4f5bd5)'
: status !== null ? 'var(--accent,#d6589e)' : '';
mainLines.push(el('div', { style: statusColor ? `color:${statusColor};` : '' },
t('labrec_predicted', formatDose(Math.round(rec.predictedE2))) + statusText));
}
mainLines.push(
el('div', {}, t('labrec_before_injection', rec.ester, formatDateTime(rec.injectionMs))),
el('div', { class: 'legend-muted' },
rec.wasAlreadyStabilized
? t('labrec_stabilized', rec.ester, formatDateTime(rec.stabilizedAtMs))
: t('labrec_not_stabilized', rec.ester, formatDateTime(rec.stabilizedAtMs))),
el('div', { class: 'legend-muted' }, t('labrec_disclaimer')),
);
card.appendChild(el('div', { class: 'main' }, mainLines));
col.appendChild(card);
} else if (shouldSuggestPosology(treatments)) {
const card = el('div', { class: 'card' });
card.appendChild(el('div', { class: 'title' }, t('labrec_section')));
card.appendChild(el('div', { class: 'main legend-muted' }, t('labrec_needs_posology')));
col.appendChild(card);
}
if (grouped.length === 0) { if (grouped.length === 0) {
col.appendChild(el('p', {}, t('no_data'))); col.appendChild(el('p', {}, t('no_data')));
container.appendChild(fab(() => openLabDialog({ existing: null }))); container.appendChild(fab(() => openLabDialog({ existing: null })));
@ -70,8 +127,14 @@ export function renderLabs(container) {
const main = el('div', { class: 'main' }); const main = el('div', { class: 'main' });
main.appendChild(el('div', { class: 'title' }, headline)); main.appendChild(el('div', { class: 'title' }, headline));
main.appendChild(el('div', { class: 'sub' }, formatDateTime(ts))); main.appendChild(el('div', { class: 'sub' }, formatDateTime(ts)));
const firstNotes = group.map((g) => g.notes).find((n) => n && n.trim() !== ''); // v1.7.1 : TOUTES les notes distinctes du groupe (préfixées du marqueur
if (firstNotes) main.appendChild(el('div', { class: 'sub' }, firstNotes)); // quand elles diffèrent) — l'ancien code ne prenait que la PREMIÈRE note
// non vide du groupe (« une note sur deux » : la note T disparaissait de
// l'affichage quand la note E2 existait aussi, bien qu'elle fût
// sauvegardée). Même fix que labNotesForDisplay côté Android.
for (const note of labNotesForDisplay(group)) {
main.appendChild(el('div', { class: 'sub' }, note));
}
item.appendChild(main); item.appendChild(main);
// Tap → sélecteur E2/T si paire, édition directe sinon // Tap → sélecteur E2/T si paire, édition directe sinon

View File

@ -31,7 +31,7 @@ import { el, showDialog, field, switchRow, filterChip, toast } from './component
* (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`). * (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`).
* v1.4.10 = portage initial de l'Android v1.4.10. * v1.4.10 = portage initial de l'Android v1.4.10.
*/ */
export const WEB_VERSION = '1.7.0'; export const WEB_VERSION = '1.13.0';
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */ /** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
function parseNumOrNull(text) { function parseNumOrNull(text) {
@ -185,6 +185,46 @@ export function renderSettings(container) {
}, t('save'))); }, t('save')));
col.appendChild(alertCard); col.appendChild(alertCard);
// ── Cible de creux E2 (v1.13.0, opt-in) ────────────────────────────────
// ⚠️ DISTINCT des seuils d'alerte (carte au-dessus) : référence de lab
// personnelle (« je veux être dans [basse, haute] au creux recommandé »),
// lecture seule sur la carte reco — JAMAIS de notification (miroir
// TroughTargetCard.kt).
const targetCard = el('div', { class: 'card' });
targetCard.appendChild(el('h3', {}, t('trough_target_section')));
targetCard.appendChild(el('div', { class: 'hint' }, t('trough_target_hint')));
const ttLowField = field({ label: t('trough_target_low'), type: 'number', value: settings.troughTargetLow === null ? '' : String(settings.troughTargetLow), attrs: { step: 'any', inputmode: 'decimal' } });
const ttHighField = field({ label: t('trough_target_high'), type: 'number', value: settings.troughTargetHigh === null ? '' : String(settings.troughTargetHigh), attrs: { step: 'any', inputmode: 'decimal' } });
const ttRow = el('div', { class: 'row' });
ttRow.appendChild(ttLowField);
ttRow.appendChild(ttHighField);
targetCard.appendChild(ttRow);
const ttError = el('div', { class: 'error-text', style: 'display:none;' });
targetCard.appendChild(ttError);
targetCard.appendChild(el('button', {
class: 'btn',
onclick: () => {
const low = parseNumOrNull(ttLowField.input.value);
const high = parseNumOrNull(ttHighField.input.value);
if ((low === null) !== (high === null)) {
// Un seul champ rempli = saisie incomplète (pas de « demi-cible »)
ttError.textContent = t('trough_target_incomplete');
ttError.style.display = '';
} else if (low !== null && high !== null && high <= low) {
ttError.textContent = t('trough_target_invalid');
ttError.style.display = '';
} else {
ttError.style.display = 'none';
// null/null = désactivation (setTroughTarget efface, miroir Android)
store.setSettings({
troughTargetLow: low, troughTargetHigh: high,
});
toast(t('saved'));
}
},
}, t('save')));
col.appendChild(targetCard);
// ── Fuseau du graphique (v1.4.5) ─────────────────────────────────────── // ── Fuseau du graphique (v1.4.5) ───────────────────────────────────────
const tzCard = el('div', { class: 'card' }); const tzCard = el('div', { class: 'card' });
tzCard.appendChild(el('h3', {}, t('timezone_section'))); tzCard.appendChild(el('h3', {}, t('timezone_section')));
@ -341,13 +381,23 @@ export function renderSettings(container) {
store.setTConfig(data.tConfig); store.setTConfig(data.tConfig);
// ⚠️ Paramètres du backup (null = backup v1 → ne rien toucher) // ⚠️ Paramètres du backup (null = backup v1 → ne rien toucher)
if (data.settings) { if (data.settings) {
store.setSettings({ const patch = {
autoCalibrate: data.settings.autoCalibrate ?? false, autoCalibrate: data.settings.autoCalibrate ?? false,
alertE2High: data.settings.alertE2High ?? null, alertE2High: data.settings.alertE2High ?? null,
alertE2Low: data.settings.alertE2Low ?? null, alertE2Low: data.settings.alertE2Low ?? null,
alertTHigh: data.settings.alertTHigh ?? null, alertTHigh: data.settings.alertTHigh ?? null,
alertTLow: data.settings.alertTLow ?? null, alertTLow: data.settings.alertTLow ?? null,
}); };
// v1.13.0 : cible de creux — restaurée SEULEMENT si le backup
// en porte une (champs non nuls ET cohérents) ; un backup
// ancien ne doit pas effacer la cible locale (miroir Android)
if (data.settings.troughTargetLow !== null && data.settings.troughTargetLow !== undefined
&& data.settings.troughTargetHigh !== null && data.settings.troughTargetHigh !== undefined
&& data.settings.troughTargetHigh > data.settings.troughTargetLow) {
patch.troughTargetLow = data.settings.troughTargetLow;
patch.troughTargetHigh = data.settings.troughTargetHigh;
}
store.setSettings(patch);
if (data.settings.language) { if (data.settings.language) {
store.setSettings({ language: data.settings.language }); store.setSettings({ language: data.settings.language });
} }

View File

@ -2,8 +2,13 @@
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
* Éditeur de traitement (portage de `ui/screens/TreatmentEditorScreen.kt`). * Éditeur de traitement (portage de `ui/screens/TreatmentEditorScreen.kt`).
* *
* - presets (PKPresets, 22 entrées) pré-remplissent tout (création) ; * - presets (PKPresets, 25 entrées) pré-remplissent tout (création) —
* - champs conditionnels : ester + modèle PK si injection, Bateman sinon ; * v1.9.4 : le modèle ESE offre ses 6 esters injectables (EV/EU/EEn/EC/
* EB/EUCS), au même niveau que les presets TFS (7) et WHSAH (6) ;
* - champs conditionnels : ester + modèle PK si injection, Bateman sinon —
* v1.9.4 : la liste des esters suit la couverture RÉELLE du modèle
* choisi (choicesForModel) et rebascule sur EV si l'ester courant
* n'y est plus (garde anti « 0 pg/mL ») ;
* - carte Calibration : facteur manuel + bouton « Calibrer avec les * - carte Calibration : facteur manuel + bouton « Calibrer avec les
* analyses » (computeScaleFactor — médiane lab ÷ prédiction, garde #61) ; * analyses » (computeScaleFactor — médiane lab ÷ prédiction, garde #61) ;
* hint quand l'auto-calibration écrase le facteur à l'affichage (v1.4.9) ; * hint quand l'auto-calibration écrase le facteur à l'affichage (v1.4.9) ;
@ -22,7 +27,7 @@
*/ */
import { store } from '../data/store.js'; import { store } from '../data/store.js';
import { newTreatment, isInjectionRoute, typeLabelKey, routeLabelKey, modelLabelKey } from '../data/models.js'; import { newTreatment, isInjectionRoute, typeLabelKey, routeLabelKey, modelLabelKey, choicesForModel } from '../data/models.js';
import { all as allPresets } from '../pk/presets.js'; import { all as allPresets } from '../pk/presets.js';
import { computeScaleFactor } from '../pk/pk-calibration.js'; import { computeScaleFactor } from '../pk/pk-calibration.js';
import { t } from '../util/i18n.js'; import { t } from '../util/i18n.js';
@ -124,14 +129,25 @@ export function renderTreatmentEditor(container, ctx) {
})); }));
if (isInjectionRoute(st.route)) { if (isInjectionRoute(st.route)) {
// Ester : liste simple (miroir Android — EV/EU/EEn dans l'éditeur ; // Ester : liste = couverture RÉELLE du modèle choisi (v1.9.4 —
// les 7 esters sont proposables dans l'OVERRIDE par dose du DoseDialog) // miroir Android Esters.choicesForModel) : ESE offre ses 6 esters
const esterOptions = ['EV', 'EU', 'EEN'].map((x) => ({ value: x, label: x })); // injectables depuis l'éditeur, au lieu des seuls EV/EU/EEn de
// l'époque des tables ODS. L'override par dose du dialog garde le
// même helper (pas de divergence des deux listes).
const esterOptions = choicesForModel(st.model).map((x) => ({ value: x, label: x }));
form.appendChild(dropdown(t('ester'), st.ester, esterOptions, (v) => { st.ester = v; })); form.appendChild(dropdown(t('ester'), st.ester, esterOptions, (v) => { st.ester = v; }));
// Modèle PK : 3 choix (v1.4.6 — WHSAH ajouté) // Modèle PK : 3 choix (v1.4.6 — WHSAH ajouté). Au changement de
// modèle, l'ester courant peut sortir de la couverture (ex. EUCS +
// TFS : le V3C n'a pas de paramètres EUCS → 0 pg/mL) : on rebascule
// sur le premier ester couvert (EV partout) puis on re-rend le
// formulaire pour reconstruire la liste des esters (v1.9.4).
const modelOptions = ['ESE', 'TFS', 'WHS'].map((x) => ({ value: x, label: t(modelLabelKey(x)) })); const modelOptions = ['ESE', 'TFS', 'WHS'].map((x) => ({ value: x, label: t(modelLabelKey(x)) }));
form.appendChild(dropdown(t('pk_model'), st.model, modelOptions, (v) => { st.model = v; })); form.appendChild(dropdown(t('pk_model'), st.model, modelOptions, (v) => {
st.model = v;
if (!choicesForModel(v).includes(st.ester)) st.ester = choicesForModel(v)[0];
rerenderForm();
}));
} else { } else {
const row1 = el('div', { class: 'row' }); const row1 = el('div', { class: 'row' });
row1.appendChild(field({ label: t('pk_absorption'), type: 'number', value: st.tmaxText, onInput: (v) => { st.tmaxText = v; }, attrs: { step: 'any' } })); row1.appendChild(field({ label: t('pk_absorption'), type: 'number', value: st.tmaxText, onInput: (v) => { st.tmaxText = v; }, attrs: { step: 'any' } }));

View File

@ -5,11 +5,15 @@
* Cartes (nom, voie, dose, chips ester·modèle / Tmax / ×scale / ⏰ rappel, * Cartes (nom, voie, dose, chips ester·modèle / Tmax / ×scale / ⏰ rappel,
* badge inactif) → tap = éditeur. ⚠️ Le chip modèle passe par modelLabelKey * badge inactif) → tap = éditeur. ⚠️ Le chip modèle passe par modelLabelKey
* (fix #59 : un ternaire à 2 branches affichait « Estrannaise » pour WHSAH). * (fix #59 : un ternaire à 2 branches affichait « Estrannaise » pour WHSAH).
*
* v1.11.0 (demande utilisatrice, miroir Android) : les ACTIFS d'abord, les
* INACTIFS regroupés TOUT EN BAS sous un en-tête de section, cartes
* ATTÉNUÉES (opacity 0.55) en plus du badge — « peu discernables ».
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
*/ */
import { store } from '../data/store.js'; import { store } from '../data/store.js';
import { modelLabelKey, routeLabelKey, treatmentFlags } from '../data/models.js'; import { modelLabelKey, routeLabelKey, treatmentFlags, treatmentsForDisplay } from '../data/models.js';
import { t } from '../util/i18n.js'; import { t } from '../util/i18n.js';
import { formatDose } from '../util/format.js'; import { formatDose } from '../util/format.js';
import { el, assistChip, fab } from './components.js'; import { el, assistChip, fab } from './components.js';
@ -20,7 +24,10 @@ import { el, assistChip, fab } from './components.js';
* @param {object} ctx { openEditor: (id:number)=>void, openNew: ()=>void } * @param {object} ctx { openEditor: (id:number)=>void, openNew: ()=>void }
*/ */
export function renderTreatments(container, ctx) { export function renderTreatments(container, ctx) {
const treatments = store.getTreatments(); // v1.11.0 : actifs d'abord, inactifs regroupés en bas (cf treatmentsForDisplay)
const treatments = treatmentsForDisplay(store.getTreatments());
const active = treatments.filter((tr) => tr.isActive);
const inactive = treatments.filter((tr) => !tr.isActive);
const col = el('div', {}); const col = el('div', {});
container.appendChild(col); container.appendChild(col);
@ -29,9 +36,18 @@ export function renderTreatments(container, ctx) {
col.appendChild(el('p', {}, t('no_data'))); col.appendChild(el('p', {}, t('no_data')));
} }
for (const tr of treatments) { /**
* Rend UNE carte traitement. @param {object} tr @param {boolean} dimmed
* carte inactive → opacité réduite (second signal visuel au-delà du
* badge ; reste lisible et cliquable).
*/
const renderCard = (tr, dimmed) => {
const flags = treatmentFlags(tr); // miroir des getters Kotlin const flags = treatmentFlags(tr); // miroir des getters Kotlin
const card = el('div', { class: 'card', style: 'cursor:pointer;', onclick: () => ctx.openEditor(tr.id) }); const card = el('div', {
class: 'card',
style: `cursor:pointer;${dimmed ? 'opacity:0.55;' : ''}`,
onclick: () => ctx.openEditor(tr.id),
});
const titleRow = el('div', { class: 'row' }); const titleRow = el('div', { class: 'row' });
titleRow.appendChild(el('span', { style: 'font-weight:600;flex:1;' }, tr.name)); titleRow.appendChild(el('span', { style: 'font-weight:600;flex:1;' }, tr.name));
@ -55,7 +71,19 @@ export function renderTreatments(container, ctx) {
chipRow.appendChild(assistChip(`⏰ ${hh}:${mm}`)); chipRow.appendChild(assistChip(`⏰ ${hh}:${mm}`));
} }
card.appendChild(chipRow); card.appendChild(chipRow);
col.appendChild(card); return card;
};
for (const tr of active) {
col.appendChild(renderCard(tr, false));
}
// En-tête de section SEULEMENT s'il existe des inactifs (miroir Android)
if (inactive.length > 0) {
col.appendChild(el('p', { class: 'sub', style: 'margin:14px 0 4px;' }, t('inactive_section')));
}
for (const tr of inactive) {
col.appendChild(renderCard(tr, true));
} }
container.appendChild(fab(ctx.openNew)); container.appendChild(fab(ctx.openNew));

View File

@ -113,6 +113,33 @@ export function formatLongDate(ms, timeZone = null) {
}), timeZone).format(new Date(ms)); }), timeZone).format(new Date(ms));
} }
/**
* Ligne « prochaine prise » d'un traitement (v1.9.6 — miroir du helper
* `nextDoseLine` Kotlin) : « Xj Yh · sam. 26 10:00 (nom) » au-delà de 24 h
* (v1.4.1 : les heures deviennent peu lisibles), « XhMM · HH:mm (nom) » en
* dessous, « N min · HH:mm (nom) » dans l'heure. PUR, testé dans
* tests/misc.test.js — sert à CHAQUE ligne de la carte « Prochaines doses »
* de l'accueil (une ligne par traitement, triées par prochaine prise).
*
* @param {number} nowMs maintenant
* @param {number} fireMs prochaine prise du traitement
* @param {string} name nom du traitement
* @param {string} [timeZone=null]
* @returns {string}
*/
export function nextDoseLineText(nowMs, fireMs, name, timeZone = null) {
const deltaMs = fireMs - nowMs;
const h = Math.floor(deltaMs / 3600000);
const m = Math.floor((deltaMs % 3600000) / 60000);
if (h >= 24) {
const days = Math.floor(h / 24);
const remH = h % 24;
return `${days}j ${remH}h · ${formatWeekdayTime(fireMs, timeZone)} (${name})`;
}
if (h > 0) return `${h}h${String(m).padStart(2, '0')} · ${formatTime(fireMs, timeZone)} (${name})`;
return `${m} min · ${formatTime(fireMs, timeZone)} (${name})`;
}
/** /**
* « EEE d HH:mm » (carte prochaine dose au-delà de 24 h — jour de semaine * « EEE d HH:mm » (carte prochaine dose au-delà de 24 h — jour de semaine
* inclus, cf v1.4.1). * inclus, cf v1.4.1).
@ -172,3 +199,73 @@ export function inputValuesToMs(dateISO, timeHM) {
const ms = new Date(y, mo - 1, d, h, mi, 0, 0).getTime(); const ms = new Date(y, mo - 1, d, h, mi, 0, 0).getTime();
return Number.isFinite(ms) ? ms : null; return Number.isFinite(ms) ? ms : null;
} }
/**
* Notes à AFFICHER pour un groupe de labs (v1.7.1 — miroir de
* `labNotesForDisplay` Android, fix du bug « une seule note sur deux
* s'affiche »). Historique : le dialog de création mettait LA MÊME note aux
* deux entrées d'une prise E2+T ; l'édition unitaire a cassé cette
* hypothèse → l'ancien affichage (`.find()` première non vide) perdait la
* note de la seconde entrée (conservée en base, juste non affichée).
*
* Règles (identiques à l'Android) :
* - notes vides/blanches ignorées ; ordre du groupe préservé (E2 avant T) ;
* - texte identique sur plusieurs entrées → une seule ligne (dédupliqué) ;
* - notes distinctes → une ligne par note, préfixée du marqueur
* (« E2 : … » / « T : … ») ;
* - une seule note distincte → brute (comportement historique préservé).
*
* PUR (aucun DOM) → testé en Node (tests/lab-notes.test.js).
*
* @param {{marker:string, notes?:string|null}[]} group labs du même timestamp
* @returns {string[]} lignes de notes à afficher (vide si aucune)
*/
export function labNotesForDisplay(group) {
const notes = group
.map((lab) => (lab.notes && String(lab.notes).trim() !== ''
? [String(lab.marker).toUpperCase(), String(lab.notes).trim()]
: null))
.filter(Boolean);
if (notes.length === 0) return [];
// Déduplication (première occurrence de chaque texte, marqueur de sa
// propre entrée conservé pour le préfixe)
const seen = new Set();
const distinctPairs = [];
for (const [marker, text] of notes) {
if (!seen.has(text)) {
seen.add(text);
distinctPairs.push([marker, text]);
}
}
if (distinctPairs.length === 1) return [distinctPairs[0][1]];
return distinctPairs.map(([marker, text]) => `${marker} : ${text}`);
}
/**
* Marqueurs d'ANALYSE DE SANG pour UNE dose (v1.12.0, miroir fidèle de
* `ui/screens/DosesScreen.kt` — même KDoc). Une analyse compte si elle
* tombe le MÊME JOUR calendaire LOCAL que la dose ; une analyse exactement
* À l'heure de la dose compte comme APRÈS (convention documentée).
*
* PUR → testable en node (tests/doses-extras.test.js).
*
* @param {Array<object>} labs LabResult[]
* @param {number} doseMs
* @param {string} [timeZone=null] IANA (défaut : locale du navigateur)
* @returns {{before: boolean, after: boolean}}
*/
export function labMarkersForDose(labs, doseMs, timeZone = null) {
// Comparaison de JOURS CALENDAIRES : 'fr-CA' rend YYYY-MM-DD (CLDR/ISO) —
// honore `timeZone` quand fourni, sinon la locale du navigateur/node.
const opts = timeZone ? { timeZone } : {};
const day = new Date(doseMs).toLocaleDateString('fr-CA', opts);
let before = false;
let after = false;
for (const lab of labs) {
if (new Date(lab.timestamp).toLocaleDateString('fr-CA', opts) === day) {
if (lab.timestamp < doseMs) before = true;
else after = true;
}
}
return { before, after };
}

View File

@ -42,6 +42,7 @@ const EN = {
no_data_short: 'No data — log an injection to see estimates', no_data_short: 'No data — log an injection to see estimates',
delta_6h: '{0} {1} pg/mL vs 6 h ago', delta_6h: '{0} {1} pg/mL vs 6 h ago',
next_dose: 'Next dose', next_dose: 'Next dose',
next_doses: 'Upcoming doses',
next_dose_days: '{0} d {1} h · {2} ({3})', next_dose_days: '{0} d {1} h · {2} ({3})',
logged_today: 'Quick log', logged_today: 'Quick log',
home_chart_title: 'Last 24 h', home_chart_title: 'Last 24 h',
@ -63,8 +64,20 @@ const EN = {
// Labs // Labs
edit_which_lab: 'Which entry to edit?', edit_which_lab: 'Which entry to edit?',
confirm_delete_blood_draw: 'Delete the whole blood draw ({0})?', confirm_delete_blood_draw: 'Delete the whole blood draw ({0})?',
lab_before_dose: 'Blood test earlier the same day, before this dose',
lab_after_dose: 'Blood test later the same day, after this dose',
labrec_predicted: 'Expected at this trough: ≈ {0} pg/mL',
labrec_in_target: 'within your target',
labrec_out_of_target: 'outside your target',
trough_target_section: 'Trough target (blood draw)',
trough_target_hint: 'Personal reference for the recommended blood draw: the predicted trough value is colored against this band. Distinct from the alert thresholds above — those watch your estimated level continuously and can notify you; this one never does.',
trough_target_low: 'Target low (pg/mL)',
trough_target_high: 'Target high (pg/mL)',
trough_target_incomplete: 'Fill both fields — or leave both empty to disable.',
trough_target_invalid: 'Target high must be greater than target low.',
// Traitements // Traitements
inactive: 'inactive', inactive: 'inactive',
inactive_section: 'Inactive treatments',
pk_absorption: 'Tmax', pk_absorption: 'Tmax',
pk_halflife: 'Half-life (h)', pk_halflife: 'Half-life (h)',
pk_bioavail: 'Bioavailability (0–1)', pk_bioavail: 'Bioavailability (0–1)',
@ -181,10 +194,23 @@ const EN = {
show_labs: 'Labs', show_labs: 'Labs',
show_forecast: 'Forecast', show_forecast: 'Forecast',
show_extrema: 'Peaks & troughs', show_extrema: 'Peaks & troughs',
// v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé —
// miroir strings.xml Android ; t() remplace {0}/{1} par les args)
labrec_section: 'Next blood draw (suggested)',
labrec_when: 'At the estimated trough: {0}',
labrec_before_injection: 'Just before your {0} injection of {1} — or simply the day before that slot.',
labrec_stabilized: 'Your {0} has been stable since {1} (~5 terminal half-lives).',
labrec_not_stabilized: 'Your {0} was not stable until {1} (~5 terminal half-lives): this is the first reliable trough after your latest change.',
labrec_disclaimer: 'Estimation from the pharmacokinetic model, not medical advice — blood draw at the trough simply gives the most comparable result. Always follow your endocrinologist.',
labrec_needs_posology: 'Tip: set a Dosage interval on your active injectable E2 treatment (Treatments → edit) and this page will suggest when to do your next blood draw (at the trough, just before the injection).',
show_lab_track: 'Lab track', show_lab_track: 'Lab track',
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in, // v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
// actif seulement quand Lab track est ON — miroir strings.xml Android) // actif seulement quand Lab track est ON — miroir strings.xml Android)
show_lab_track_extend: 'Extend', show_lab_track_extend: 'Extend',
// v1.9.0 : nuage d'incertitude du modèle ESE (posterior MCMC de
// estrannaise.js) — exclusif à ESE, off par défaut
show_cloud: 'Cloud',
legend_cloud: 'Dotted cloud = Estrannaise MCMC parameter uncertainty (ESE model only)',
lab_track_extend_warning: '⚠ Simulation only, with no guarantee of matching reality: the extension extrapolates your model from your lab results — which can themselves be inaccurate. Trust your next blood test, not this curve.', lab_track_extend_warning: '⚠ Simulation only, with no guarantee of matching reality: the extension extrapolates your model from your lab results — which can themselves be inaccurate. Trust your next blood test, not this curve.',
back_to_present: 'Back to present', back_to_present: 'Back to present',
legend_ese_e2: 'E2 — Estrannaise (blue)', legend_ese_e2: 'E2 — Estrannaise (blue)',
@ -239,6 +265,7 @@ const FR = {
no_data_short: 'Aucune donnée — logue une injection pour voir les estimations', no_data_short: 'Aucune donnée — logue une injection pour voir les estimations',
delta_6h: '{0} {1} pg/mL vs il y a 6 h', delta_6h: '{0} {1} pg/mL vs il y a 6 h',
next_dose: 'Prochaine dose', next_dose: 'Prochaine dose',
next_doses: 'Prochaines doses',
next_dose_days: '{0} j {1} h · {2} ({3})', next_dose_days: '{0} j {1} h · {2} ({3})',
logged_today: 'Log rapide', logged_today: 'Log rapide',
home_chart_title: 'Dernières 24 h', home_chart_title: 'Dernières 24 h',
@ -257,7 +284,19 @@ const FR = {
confirm_delete_treatment: 'Supprimer ce traitement ? Son historique de doses sera supprimé aussi (les analyses sont conservées).', confirm_delete_treatment: 'Supprimer ce traitement ? Son historique de doses sera supprimé aussi (les analyses sont conservées).',
edit_which_lab: 'Quelle entrée modifier ?', edit_which_lab: 'Quelle entrée modifier ?',
confirm_delete_blood_draw: 'Supprimer la prise de sang entière ({0}) ?', confirm_delete_blood_draw: 'Supprimer la prise de sang entière ({0}) ?',
lab_before_dose: 'Prise de sang plus tôt le même jour, avant cette dose',
lab_after_dose: 'Prise de sang plus tard le même jour, après cette dose',
labrec_predicted: 'Attendu à ce creux : ≈ {0} pg/mL',
labrec_in_target: 'dans ta cible',
labrec_out_of_target: 'hors de ta cible',
trough_target_section: 'Cible de creux (prise de sang)',
trough_target_hint: 'Référence personnelle pour la prochaine prise de sang : la valeur de creux prédite est colorée par rapport à cette bande. Distinct des seuils d\u0027alerte ci-dessus — ceux-là surveillent ton niveau estimé en continu et peuvent te notifier ; celui-ci jamais.',
trough_target_low: 'Cible basse (pg/mL)',
trough_target_high: 'Cible haute (pg/mL)',
trough_target_incomplete: 'Remplis les deux champs — ou laisse les deux vides pour désactiver.',
trough_target_invalid: 'La cible haute doit être supérieure à la cible basse.',
inactive: 'inactive', inactive: 'inactive',
inactive_section: 'Traitements inactifs',
pk_absorption: 'Tmax', pk_absorption: 'Tmax',
pk_halflife: 'Demi-vie (h)', pk_halflife: 'Demi-vie (h)',
pk_bioavail: 'Biodisponibilité (0–1)', pk_bioavail: 'Biodisponibilité (0–1)',
@ -365,10 +404,21 @@ const FR = {
show_labs: 'Labs', show_labs: 'Labs',
show_forecast: 'Prévision', show_forecast: 'Prévision',
show_extrema: 'Pics & creux', show_extrema: 'Pics & creux',
// v1.8.0 : recommandation de prochaine prise de sang (creux stabilisé —
// miroir strings.xml Android)
labrec_section: 'Prochaine prise de sang (suggestion)',
labrec_when: 'Au creux estimé : {0}',
labrec_before_injection: 'Juste avant ton injection de {0} du {1} — ou simplement la veille de ce créneau.',
labrec_stabilized: 'Ton {0} est stabilisé depuis le {1} (~5 demi-vies terminales).',
labrec_not_stabilized: 'Ton {0} n\'était pas stabilisé avant le {1} (~5 demi-vies terminales) : c\'est le premier creux fiable depuis ton dernier changement.',
labrec_disclaimer: 'Estimation du modèle pharmacocinétique, pas un avis médical — la prise au creux donne simplement le résultat le plus comparable. Suis toujours ton endocrinologue.',
labrec_needs_posology: 'Astuce : renseigne une Posologie (intervalle entre prises) sur ton traitement E2 injectable actif (Traitements → éditer) et cette page suggérera quand faire ta prochaine prise de sang (au creux, juste avant l\'injection).',
show_lab_track: 'Tracé labs', show_lab_track: 'Tracé labs',
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in, // v1.6.0 : prolongation du tracé labs au-delà du dernier lab (opt-in,
// actif seulement quand Tracé labs est ON — miroir strings.xml Android) // actif seulement quand Tracé labs est ON — miroir strings.xml Android)
show_lab_track_extend: 'Prolonger', show_lab_track_extend: 'Prolonger',
show_cloud: 'Nuage',
legend_cloud: 'Nuage pointillé = incertitude des paramètres MCMC de l\'Estrannaise (modèle ESE seul)',
lab_track_extend_warning: '⚠ Simple simulation, sans garantie de correspondre au réel : la prolongation extrapole ton modèle à partir de tes résultats de laboratoire — qui peuvent eux-mêmes être erronés. Fie-toi à ta prochaine prise de sang, pas à cette courbe.', lab_track_extend_warning: '⚠ Simple simulation, sans garantie de correspondre au réel : la prolongation extrapole ton modèle à partir de tes résultats de laboratoire — qui peuvent eux-mêmes être erronés. Fie-toi à ta prochaine prise de sang, pas à cette courbe.',
back_to_present: 'Revenir à maintenant', back_to_present: 'Revenir à maintenant',
legend_ese_e2: 'E2 — Estrannaise (bleu)', legend_ese_e2: 'E2 — Estrannaise (bleu)',
@ -397,6 +447,9 @@ const FR = {
/** Presets (même libellé que les strings Android). */ /** Presets (même libellé que les strings Android). */
const EN_PRESETS = { const EN_PRESETS = {
preset_ev_ese: 'EV injection — Estrannaise', preset_ev_ese: 'EV injection — Estrannaise',
preset_ec_ese: 'EC injection (oil) — Estrannaise',
preset_eb_ese: 'EB injection — Estrannaise',
preset_eucs_ese: 'EUCS injection (crystal suspension) — Estrannaise',
preset_eu_ese: 'EU injection — Estrannaise', preset_eu_ese: 'EU injection — Estrannaise',
preset_een_ese: 'EEn injection — Estrannaise', preset_een_ese: 'EEn injection — Estrannaise',
preset_ev_tfs: 'EV injection — Transfem Science', preset_ev_tfs: 'EV injection — Transfem Science',
@ -422,6 +475,9 @@ const EN_PRESETS = {
const FR_PRESETS = { const FR_PRESETS = {
preset_ev_ese: 'Injection EV — Estrannaise', preset_ev_ese: 'Injection EV — Estrannaise',
preset_ec_ese: 'Injection EC (huile) — Estrannaise',
preset_eb_ese: 'Injection EB — Estrannaise',
preset_eucs_ese: 'Injection EUCS (suspension cristalline) — Estrannaise',
preset_eu_ese: 'Injection EU — Estrannaise', preset_eu_ese: 'Injection EU — Estrannaise',
preset_een_ese: 'Injection EEn — Estrannaise', preset_een_ese: 'Injection EEn — Estrannaise',
preset_ev_tfs: 'Injection EV — Transfem Science', preset_ev_tfs: 'Injection EV — Transfem Science',

View File

@ -131,7 +131,11 @@ async function main() {
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)'); ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
if (dialog) { if (dialog) {
const dlgText = await dialog.textContent(); const dlgText = await dialog.textContent();
ok(dlgText.includes('Version 1.7.0'), 'dialog : section de la version courante'); // v1.9.5 : la version attendue vient de l'APP (data-version posée par
// app.js) — plus aucune string codée en dur à bump à chaque release
// (l'oubli a produit le bug v1.9.3 : version annoncée fausse).
const appVersion = await page.getAttribute('body', 'data-version');
ok(dlgText.includes(`Version ${appVersion}`), 'dialog : section de la version courante');
const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong'); const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut'); ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
await page.click('.dialog .actions button'); await page.click('.dialog .actions button');
@ -183,24 +187,25 @@ async function main() {
} }
await page.waitForTimeout(400); await page.waitForTimeout(400);
// 1) Le chip « Prolonger » est DÉSACTIVÉ tant que « Tracé labs » est off // 1) Le chip « Prolonger » est DÉSACTIVÉ tant que « Tracé labs » est off
const extendDisabledBefore = await page.evaluate((lbl) => { const chipDisabled = (lbl) => page.evaluate((label) => {
const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl); const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === label);
return b ? /opacity:\s*0\.4/.test(b.getAttribute('style') || '') : null; return b ? /opacity:\s*0\.4/.test(b.getAttribute('style') || '') : null;
}, 'Prolonger'); }, lbl);
const clickChip = (lbl) => page.evaluate((label) => {
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === label)?.click();
}, lbl);
const extendDisabledBefore = await chipDisabled('Prolonger');
ok(extendDisabledBefore === true, 'chip « Prolonger » désactivé tant que Tracé labs est off'); ok(extendDisabledBefore === true, 'chip « Prolonger » désactivé tant que Tracé labs est off');
// 2) Active Tracé labs → la légende ancrée apparaît (sans avertissement) // 2) Active Tracé labs → la légende ancrée apparaît (sans avertissement)
await page.evaluate((lbl) => { await clickChip('Tracé labs');
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click(); await page.waitForTimeout(400);
}, 'Tracé labs');
await page.waitForTimeout(400); await page.waitForTimeout(400);
const labTrackText = await page.textContent('main.content'); const labTrackText = await page.textContent('main.content');
ok(labTrackText.includes('E2 ancrée sur tes labs'), 'légende « Tracé labs » (ancrée) affichée'); ok(labTrackText.includes('E2 ancrée sur tes labs'), 'légende « Tracé labs » (ancrée) affichée');
ok(!labTrackText.includes('Simple simulation, sans garantie'), ok(!labTrackText.includes('Simple simulation, sans garantie'),
'pas d\'avertissement tant que la prolongation est off'); 'pas d\'avertissement tant que la prolongation est off');
// 3) Active « Prolonger » → légende prolongée + AVERTISSEMENT VISIBLE // 3) Active « Prolonger » → légende prolongée + AVERTISSEMENT VISIBLE
await page.evaluate((lbl) => { await clickChip('Prolonger');
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
}, 'Prolonger');
await page.waitForTimeout(400); await page.waitForTimeout(400);
const extendText = await page.textContent('main.content'); const extendText = await page.textContent('main.content');
ok(extendText.includes('Tracé labs prolongé'), 'légende de la partie prolongée affichée'); ok(extendText.includes('Tracé labs prolongé'), 'légende de la partie prolongée affichée');
@ -209,15 +214,50 @@ async function main() {
ok(extendText.includes('qui peuvent eux-mêmes être erronés'), ok(extendText.includes('qui peuvent eux-mêmes être erronés'),
'avertissement : les résultats de lab peuvent être erronés'); 'avertissement : les résultats de lab peuvent être erronés');
// 4) Désactive « Prolonger » → avertissement ET légende prolongée disparaissent // 4) Désactive « Prolonger » → avertissement ET légende prolongée disparaissent
await page.evaluate((lbl) => { await clickChip('Prolonger');
[...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === lbl)?.click();
}, 'Prolonger');
await page.waitForTimeout(400); await page.waitForTimeout(400);
const offText = await page.textContent('main.content'); const offText = await page.textContent('main.content');
ok(!offText.includes('Tracé labs prolongé') && !offText.includes('Simple simulation, sans garantie'), ok(!offText.includes('Tracé labs prolongé') && !offText.includes('Simple simulation, sans garantie'),
'désactivation → légende prolongée + avertissement disparaissent'); 'désactivation → légende prolongée + avertissement disparaissent');
await page.screenshot({ path: join(SHOTS, 'e2e-chart-extend.png') }); await page.screenshot({ path: join(SHOTS, 'e2e-chart-extend.png') });
// ── 2.quater NUAGE D'INCERTITUDE (v1.9.0, exclusif ESE) ────────────────
// La démo a maintenant 2 traitements E2 (EEn TFS + EV ESE) → toggles
// alignés : ESE et TFS ON par défaut (v1.4.7) → le chip Nuage est
// ACTIVABLE dès le départ. Séquence : activer Nuage → légende visible ;
// désactiver ESE → le nuage se coupe AUTOMATIQUEMENT (exclusivité) ;
// réactiver ESE → nuage de retour.
console.log('── Nuage d incertitude (#chart, v1.9.0) ──');
const cloudIsSelected = () => page.evaluate(() =>
[...document.querySelectorAll('.chip')]
.some((x) => x.textContent.trim() === 'Nuage' && x.classList.contains('selected')));
const cloudIsDisabled = () => page.evaluate(() => {
const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === 'Nuage');
return b ? /opacity:\s*0\.4/.test(b.getAttribute('style') || '') : null;
});
// ESE est ON à ce stade (scénario 2.bis) → Nuage activable
await clickChip('Nuage');
await page.waitForTimeout(500);
ok(await cloudIsSelected() === true, 'chip « Nuage » activé (ESE affiché)');
const cloudText1 = await page.textContent('main.content');
ok(cloudText1.includes('Nuage pointillé = incertitude des paramètres MCMC'),
'légende du nuage MCMC affichée (ESE + Nuage ON)');
// Désactiver ESE → le nuage se coupe AUTOMATIQUEMENT (exclusivité)
await clickChip('Estrannaise'); // ESE OFF
await page.waitForTimeout(500);
ok(await cloudIsSelected() === false,
'désactiver ESE → le nuage se coupe automatiquement (exclusivité ESE)');
const offAfter = await page.evaluate(() => {
const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === 'Nuage');
return b ? /opacity:\s*0\.4/.test(b.getAttribute('style') || '') : null;
});
ok(offAfter === true, 'chip « Nuage » redevient désactivé sans ESE');
// Réactiver ESE → Nuage ré-activable
await clickChip('Estrannaise');
await page.waitForTimeout(400);
ok(await cloudIsDisabled() === false, 'réactiver ESE → Nuage redevient activable');
await page.screenshot({ path: join(SHOTS, 'e2e-chart-cloud.png') });
// ── 2.ter PERSISTANCE de l'état du graphique (v1.6.1) ─────────────────── // ── 2.ter PERSISTANCE de l'état du graphique (v1.6.1) ───────────────────
// Bug remonté : les options se réinitialisaient « toutes seules » car le // Bug remonté : les options se réinitialisaient « toutes seules » car le
// tick minute / une mutation du store / un resize re-créaient l'écran // tick minute / une mutation du store / un resize re-créaient l'écran
@ -271,6 +311,22 @@ async function main() {
await page.waitForTimeout(300); await page.waitForTimeout(300);
text = await page.textContent('main.content'); text = await page.textContent('main.content');
ok(text.includes('E2') && text.includes('ng/dL'), 'Labs : prise groupée E2 + T (ng/dL converti au rendu seulement)'); ok(text.includes('E2') && text.includes('ng/dL'), 'Labs : prise groupée E2 + T (ng/dL converti au rendu seulement)');
// v1.7.1 : la dernière paire de démo porte des notes DISTINCTES (E2 ≠ T)
// → les DEUX doivent s'afficher, préfixées de leur marqueur (fix du bug
// « une seule note sur deux »)
ok(text.includes('E2 : Prise de sang après des maux de tête.'),
'Labs : note E2 affichée (préfixée)');
ok(text.includes('T : DHT : 12 ng/dL'),
'Labs : note T affichée (préfixée) — les deux notes coexistent');
// v1.8.0 : encart « prochaine prise de sang recommandée » — la démo a un
// EEn actif à Posologie 7 j → l'encart DOIT être là (creux + créneau + note)
ok(text.includes('Prochaine prise de sang (suggestion)'),
'Labs : encart recommandation de prochaine prise de sang rendu');
ok(/Au creux estimé : \d{2}\/\d{2}\/\d{4}/.test(text),
'Labs : creux estimé daté dans l\'encart');
ok(text.includes('Juste avant ton injection de EEN'),
'Labs : créneau d\'injection associé mentionné');
ok(text.includes('stabilisé'), 'Labs : mention de stabilisation présente');
await page.goto(`${base}/#treatments`, { waitUntil: 'load' }); await page.goto(`${base}/#treatments`, { waitUntil: 'load' });
await page.waitForTimeout(300); await page.waitForTimeout(300);
@ -282,7 +338,7 @@ async function main() {
await page.waitForTimeout(300); await page.waitForTimeout(300);
text = await page.textContent('main.content'); text = await page.textContent('main.content');
ok(text.includes('Langue'), 'Paramètres : carte langue'); ok(text.includes('Langue'), 'Paramètres : carte langue');
ok(text.includes('Version 1.7.0 (web)'), 'Paramètres : version affichée'); ok(text.includes(`Version ${await page.getAttribute('body', 'data-version')} (web)`), 'Paramètres : version affichée');
ok(text.includes('100 % local'), 'Paramètres : note vie privée'); ok(text.includes('100 % local'), 'Paramètres : note vie privée');
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') }); await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });

View File

@ -46,9 +46,11 @@ describe('format BackupData v2 (compatibilité Android)', () => {
assert.equal(parsed.labResults.length, 1); assert.equal(parsed.labResults.length, 1);
assert.deepEqual(parsed.tConfig, tConfig); assert.deepEqual(parsed.tConfig, tConfig);
// champs plats UserSettings exactement comme Gson // champs plats UserSettings exactement comme Gson
// (v1.13.0 : la cible de creux voyage aussi — champs optionnels)
assert.deepEqual(parsed.settings, { assert.deepEqual(parsed.settings, {
language: 'fr', autoCalibrate: true, language: 'fr', autoCalibrate: true,
alertE2High: 400, alertE2Low: 50, alertTHigh: 1.2, alertTLow: null, alertE2High: 400, alertE2Low: 50, alertTHigh: 1.2, alertTLow: null,
troughTargetLow: null, troughTargetHigh: null,
}); });
// fidélité des entités (champs Gson/Kotlin) // fidélité des entités (champs Gson/Kotlin)
assert.equal(parsed.treatments[0].calendarEventId, null); assert.equal(parsed.treatments[0].calendarEventId, null);

186
tests/doses-extras.test.js Normal file
View File

@ -0,0 +1,186 @@
/**
* Tests des ajouts v1.12.0 à la page Doses (miroir de DosesExtrasTest.kt +
* ForecastDosesGuardTest.kt) :
* - FIX #70 : un traitement INACTIF ne projette PLUS de créneaux
* (generateForecastDoses) — l'historique reste simulé (§6.bis) ;
* - routeGlyph : un émoji PAR forme de prise (IM/SC partagent la seringue
* par design — différenciées par le texte de la ligne côté web) ;
* - labMarkersForDose : marqueurs « prise de sang le même jour » —
* avant/après la dose, jour calendaire LOCAL, frontière minuit.
*
* Déterministes (instants construits via Date locale, hors changement
* d'heure — même discipline que misc.test.js).
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { generateForecastDoses } from '../js/pk/pk-engine.js';
import { routeGlyph, treatmentsForDisplay } from '../js/data/models.js';
import { labMarkersForDose } from '../js/util/format.js';
import { makeTreatment, makeDose, DAY_MS, HOUR_MS } from './helpers.js';
const NOW = 1_790_000_000_000;
describe('forecast guard for inactive treatments (fix #70, v1.12.0)', () => {
test('un inactif à Posologie + historique ne projette plus rien', () => {
const archived = makeTreatment({ id: 1, isActive: false, forecastIntervalDays: 7 });
const doses = [
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }),
makeDose(NOW - DAY_MS, 5, { treatmentId: 1 }),
];
assert.equal(generateForecastDoses(archived, doses, NOW + 30 * DAY_MS, NOW).length, 0);
});
test('un actif jumeau projette toujours (comportement inchangé)', () => {
const active = makeTreatment({ id: 2, isActive: true, forecastIntervalDays: 7 });
const doses = [
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 2 }),
makeDose(NOW - DAY_MS, 5, { treatmentId: 2 }),
];
const slots = generateForecastDoses(active, doses, NOW + 30 * DAY_MS, NOW);
assert.ok(slots.length > 0, 'un actif à Posologie projette');
assert.equal(slots[0].timestamp, NOW - DAY_MS + 7 * DAY_MS);
});
test('archiver un traitement actif arrête ses créneaux futurs', () => {
const treatment = makeTreatment({ id: 3, isActive: true, forecastIntervalDays: 7 });
const doses = [makeDose(NOW - DAY_MS, 5, { treatmentId: 3 })];
const before = generateForecastDoses(treatment, doses, NOW + 30 * DAY_MS, NOW);
const after = generateForecastDoses(
{ ...treatment, isActive: false }, doses, NOW + 30 * DAY_MS, NOW,
);
assert.ok(before.length > 0);
assert.equal(after.length, 0);
});
});
describe('route glyphs (v1.12.0)', () => {
test('un glyphe par forme (IM/SC partagent la seringue par design)', () => {
const oral = routeGlyph('ORAL');
const im = routeGlyph('INJECTION_IM');
const sc = routeGlyph('INJECTION_SUBCUT');
const gel = routeGlyph('TRANSDERMAL_GEL');
const patch = routeGlyph('TRANSDERMAL_PATCH');
const other = routeGlyph('OTHER');
assert.notEqual(oral, im);
assert.notEqual(oral, gel);
assert.notEqual(im, gel);
assert.notEqual(gel, patch);
assert.notEqual(patch, other);
assert.equal(im, sc, 'IM/SC : même seringue (texte de ligne distinct côté web)');
});
test('route inconnue/null → glyphe OTHER', () => {
assert.equal(routeGlyph(null), routeGlyph('OTHER'));
assert.equal(routeGlyph(undefined), routeGlyph('OTHER'));
});
});
describe('lab markers per dose (v1.12.0)', () => {
// Jour calendaire LOCAL de node (hors changement d'heure)
const noon = new Date(2026, 8, 27, 12, 0).getTime(); // 27 sept 2026 12:00 local
const lab = (atMs) => ({ marker: 'E2', value: 100, unit: 'pg/mL', timestamp: atMs });
test('labs avant ET après la dose le même jour → les deux marqueurs', () => {
const m = labMarkersForDose([lab(noon - HOUR_MS), lab(noon + HOUR_MS)], noon);
assert.equal(m.before, true);
assert.equal(m.after, true);
});
test('lab d\'un autre jour ne marque jamais', () => {
const m = labMarkersForDose([lab(noon - 24 * HOUR_MS)], noon);
assert.equal(m.before, false);
assert.equal(m.after, false);
});
test('frontière minuit : 23:50 la veille ≠ jour suivant', () => {
const lateEvening = new Date(2026, 8, 26, 23, 50).getTime();
const earlyMorning = new Date(2026, 8, 27, 0, 10).getTime();
const m = labMarkersForDose([lab(lateEvening)], earlyMorning);
assert.equal(m.before, false);
assert.equal(m.after, false);
});
test('lab exactement à l\'heure de la dose compte comme APRÈS (convention)', () => {
const m = labMarkersForDose([lab(noon)], noon);
assert.equal(m.before, false);
assert.equal(m.after, true);
});
test('aucun lab → aucun marqueur', () => {
const m = labMarkersForDose([], noon);
assert.equal(m.before, false);
assert.equal(m.after, false);
});
});
// ── v1.13.0 : régime poolé + prédiction au creux + statut cible ────────────
import { pooledRegimeDoses, troughTargetStatus } from '../js/pk/lab-timing.js';
import { buildBackupJson, parseBackupJson } from '../js/data/backup.js';
describe('pooled regime doses (v1.13.0, miroir LabTimingV113Test)', () => {
test('le pool traverse la frontière de re-parenting (même ester, même mg)', () => {
const active = makeTreatment({ id: 1, isActive: true, forecastIntervalDays: 7 });
const old = makeTreatment({ id: 10, isActive: false, forecastIntervalDays: 6 });
const all = [
makeDose(NOW - 50 * DAY_MS, 5, { treatmentId: 1 }),
makeDose(NOW - 43 * DAY_MS, 5, { treatmentId: 1 }),
makeDose(NOW - 36 * DAY_MS, 5, { treatmentId: 10 }),
makeDose(NOW - 29 * DAY_MS, 5, { treatmentId: 10 }),
makeDose(NOW - 22 * DAY_MS, 5, { treatmentId: 1 }),
makeDose(NOW - 15 * DAY_MS, 5, { treatmentId: 1 }),
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }),
];
const pooled = pooledRegimeDoses(active, all, [active, old]);
assert.equal(pooled.length, 7, 'le pool traverse la frontière t1/t10');
});
test('ester différent → jamais poolé (EV inactif hors du régime EEn)', () => {
const active = makeTreatment({ id: 1, isActive: true, esterType: 'EEN' });
const inactiveEv = makeTreatment({ id: 2, isActive: false, esterType: 'EV' });
const all = [
makeDose(NOW - 20 * DAY_MS, 5, { treatmentId: 2 }),
makeDose(NOW - 8 * DAY_MS, 5, { treatmentId: 1 }),
];
assert.equal(pooledRegimeDoses(active, all, [active, inactiveEv]).length, 1);
});
test('dose de mg différent → exclue (le 8 mg casse le régime)', () => {
const active = makeTreatment({ id: 1, isActive: true, forecastIntervalDays: 7 });
const all = [
makeDose(NOW - 28 * DAY_MS, 5, { treatmentId: 1 }),
makeDose(NOW - 21 * DAY_MS, 5, { treatmentId: 1 }),
makeDose(NOW - 14 * DAY_MS, 5, { treatmentId: 1 }),
makeDose(NOW - 7 * DAY_MS, 8, { treatmentId: 1 }),
makeDose(NOW, 5, { treatmentId: 1 }),
];
const pooled = pooledRegimeDoses(active, all, [active]);
assert.equal(pooled.length, 4, 'le 8 mg est exclu du pool');
});
});
describe('trough target status (v1.13.0)', () => {
test('matrice in/below/above + bornes incluses + null', () => {
assert.equal(troughTargetStatus(175, 150, 200), 'IN_TARGET');
assert.equal(troughTargetStatus(140, 150, 200), 'BELOW');
assert.equal(troughTargetStatus(220, 150, 200), 'ABOVE');
assert.equal(troughTargetStatus(150, 150, 200), 'IN_TARGET');
assert.equal(troughTargetStatus(200, 150, 200), 'IN_TARGET');
assert.equal(troughTargetStatus(null, 150, 200), null);
assert.equal(troughTargetStatus(175, null, 200), null);
assert.equal(troughTargetStatus(175, 150, null), null);
});
});
describe('backup round-trip trough target (v1.13.0)', () => {
test('les champs voyagent et restent absents quand non définis', () => {
const userSettings = { language: 'fr', troughTargetLow: 150, troughTargetHigh: 200 };
const parsed = parseBackupJson(buildBackupJson({ treatments: [], doseLogs: [], labResults: [] }, { base: 6.0, floor: 0.2, k: 0.19 }, userSettings));
assert.equal(parsed.settings.troughTargetLow, 150);
assert.equal(parsed.settings.troughTargetHigh, 200);
const parsed2 = parseBackupJson(buildBackupJson({ treatments: [], doseLogs: [], labResults: [] }, { base: 6.0, floor: 0.2, k: 0.19 }, { language: 'en' }));
assert.equal(parsed2.settings.troughTargetLow, null);
assert.equal(parsed2.settings.troughTargetHigh, null);
});
});

View File

@ -0,0 +1,92 @@
/**
* Tests du NUAGE D'INCERTITUDE (v1.9.0 — miroir de `EstrannaiseCloudTest.kt`,
* `js/pk/estrannaise-cloud.js`) : 32 courbes par défaut, dispersion réelle
* entre courbes, fenêtre respectée, exclusivité ESE (TFS/oral → vide).
*/
import { test, describe, before } from 'node:test';
import { join } from 'node:path';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { initProfiles, makeTreatment, makeDose, DAY_MS, WEB_ROOT } from './helpers.js';
import { compute } from '../js/pk/estrannaise-cloud.js';
import { initWithJson as initMcmc } from '../js/pk/estrannaise-models.js';
const NOW = 1_790_000_000_000;
function ese() {
return makeTreatment({
name: 'EEn ESE', type: 'ESTRADIOL', route: 'INJECTION_IM',
doseAmount: 5.0, doseUnit: 'mg', esterType: 'EEN', pkModel: 'ESE',
forecastIntervalDays: 7.0, isActive: true,
});
}
function doses() {
return [4, 3, 2, 1].map((k) => makeDose(NOW - k * DAY_MS, 5.0));
}
describe('EstrannaiseCloud web (miroir Android v1.9.0)', () => {
before(() => {
initProfiles();
// Le nuage exige le posterior MCMC chargé (313 échantillons/ester)
initMcmc(readFileSync(join(WEB_ROOT, 'assets/mcmc_samples.json'), 'utf8'));
});
test('32 courbes avec dispersion réelle entre elles', () => {
const cloud = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000);
assert.equal(cloud.length, 32);
assert.ok(cloud.every((c) => c.length >= 2));
const peaks = [...new Set(cloud.map((c) => Math.max(...c.map((p) => p.e2))))];
assert.ok(peaks.length >= 8, `les courbes divergent (${peaks.length} maxima distincts)`);
});
test('le nuage reste dans la fenêtre demandée', () => {
const start = NOW - 10 * DAY_MS;
const end = NOW;
const cloud = compute([ese()], doses(), start, end, 3_600_000);
assert.ok(cloud.every((c) => c.every((p) => p.timestamp >= start && p.timestamp <= end)));
});
test('les doses d un traitement TFS sont couvertes quand ESE est affiché (fix)', () => {
// Fix v1.9.2 : le pkModel STOCKÉ n\'est pas un prérequis — quand ESE
// est affiché, la courbe redessine toutes les doses E2 avec le modèle
// Estrannaise, donc le nuage les couvre aussi (l\'exclusivité ESE est
// portée par le CHIP, pas par le filtre des doses)
const tfs = { ...ese(), pkModel: 'TFS' };
const cloud = compute([tfs], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000);
assert.equal(cloud.length, 32);
assert.ok(cloud.every((c) => c.length >= 2));
const oral = makeTreatment({
name: 'CPA', type: 'ANTI_ANDROGEN', route: 'ORAL',
doseAmount: 12.5, doseUnit: 'mg', esterType: 'NONE',
forecastIntervalDays: 2.0, isActive: true,
});
// L'oral Bateman reste hors nuage (pas d'ester échantillonnable)
assert.equal(compute([oral], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000).length, 0);
});
test('aucune dose sur le porteur → nuage vide', () => {
assert.equal(compute([ese()], [], NOW - 30 * DAY_MS, NOW, 3_600_000).length, 0);
});
test('nbCurves < 2 → nuage vide', () => {
assert.equal(compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, 1).length, 0);
});
test('le nuage suit la calibration (fix v1.9.1, miroir Android)', () => {
// scalePerEster ×2 → nuage calibré = nuage brut ×2 (le nuage ENTOURE
// la courbe ESE calibrée au lieu de flotter à une autre échelle)
const scaled = { EEN: 2.0 };
const calibrated = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, 32, scaled);
const raw = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000);
assert.equal(calibrated.length, raw.length);
assert.ok(raw[raw.length - 1].length >= 2);
const rawLast = raw[raw.length - 1][raw[raw.length - 1].length - 1].e2;
const calLast = calibrated[calibrated.length - 1][calibrated[calibrated.length - 1].length - 1].e2;
assert.ok(
Math.abs(calLast - rawLast * 2) <= rawLast * 0.01,
`calibré (${calLast}) ≈ 2× brut (${rawLast})`,
);
});
});

View File

@ -0,0 +1,96 @@
/**
* Tests du modèle **Estrannaise analytique** (v1.9.0 — miroir de
* `EstrannaiseModelsTest.kt`) : forme close 3C publiée par estrannaise.js,
* fidélité aux tables ODS (RMS ≈ 0), cas dégénérés, MCMC 313, t½ analytique.
*/
import { test, describe, before } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { initProfiles, WEB_ROOT } from './helpers.js';
import {
sample, hasModel, model, mcmcSamples, initWithJson, sampleWithParams,
} from '../js/pk/estrannaise-models.js';
describe('EstrannaiseModels web (miroir Android v1.9.0)', () => {
before(() => {
initProfiles(); // tables ODS (référence de fidélité, src/test side)
const mcmc = readFileSync(join(WEB_ROOT, 'assets/mcmc_samples.json'), 'utf8');
initWithJson(mcmc);
});
test('FIDÉLITÉ : la forme close reproduit les tables ODS (RMS ≈ 0)', () => {
const profiles = JSON.parse(
readFileSync(join(WEB_ROOT, 'assets/pk_profiles.json'), 'utf8'),
).profiles;
for (const ester of ['EV', 'EU', 'EEN']) {
const key = Object.keys(profiles)
.find((k) => k.toLowerCase().startsWith(ester.toLowerCase() + '_') && k.toLowerCase().endsWith('ese'));
const arr = profiles[key];
let maxAbs = 0;
let sumSq = 0;
for (let h = 0; h <= 4800; h++) {
const diff = sample(ester, h) - arr[h];
maxAbs = Math.max(maxAbs, Math.abs(diff));
sumSq += diff * diff;
}
const rms = Math.sqrt(sumSq / 4801);
assert.ok(maxAbs <= 0.01 && rms <= 0.01, `${ester} : maxAbs=${maxAbs} rms=${rms}`);
}
});
test('les pics publiés sont reproduits (61,12 / 3,44 / 31,35 pg/mL/mg)', () => {
const expects = [
['EV', 61.12], ['EU', 3.44], ['EEN', 31.35],
];
for (const [ester, peakExpect] of expects) {
let peak = 0;
for (let t = 0; t <= 200; t += 0.25 / 24) {
peak = Math.max(peak, sample(ester, t * 24));
}
assert.ok(
Math.abs(peak - peakExpect) <= peakExpect * 0.002,
`pic ${ester} = ${peak} (attendu ${peakExpect} ±0,2 %)`,
);
}
});
test('taux dégénérés : solutions limites sans NaN ni négatif', () => {
for (const p of [
{ d: 478, k1: 0.236, k2: 0.236, k3: 1.24 },
{ d: 478, k1: 0.236, k2: 1.24, k3: 0.236 },
{ d: 478, k1: 0.236, k2: 1.24, k3: 1.24 },
{ d: 478, k1: 0.5, k2: 0.5, k3: 0.5 },
]) {
for (const t of [0.5, 1.0, 24.0, 240.0, 2400.0]) {
const v = sampleWithParams('EV', t, p);
assert.ok(v >= 0 && !Number.isNaN(v), `finie et ≥ 0 (t=${t})`);
}
}
});
test('MCMC : 313 échantillons par ester injectable (6 esters)', () => {
for (const ester of ['EV', 'EU', 'EEN', 'EC', 'EB', 'EUCS']) {
assert.equal(mcmcSamples(ester).length, 313, `posterior ${ester}`);
assert.notEqual(
mcmcSamples(ester)[0].d, mcmcSamples(ester)[312].d,
'le posterior couvre une plage',
);
}
});
test('t½ terminale ANALYTIQUE : EV = ln2/0,236 ≈ 2,94 j', () => {
const tHalf = model('EV').terminalHalfLifeDays;
assert.ok(Math.abs(tHalf - Math.log(2) / 0.236) < 1e-12);
});
test('normalisation par mg + gardes (dt ≤ 0, ester inconnu, EUCS couvert)', () => {
assert.equal(sample('EEN', 0), 0);
assert.equal(sample('EEN', -1), 0);
assert.equal(sample('XXX', 24), 0);
assert.ok(sample('EUCS', 24) > 0);
assert.equal(hasModel('XXX'), false);
assert.equal(hasModel('EUCS'), true);
});
});

45
tests/lab-notes.test.js Normal file
View File

@ -0,0 +1,45 @@
/**
* Tests des NOTES d'un groupe de labs (v1.7.1 — miroir de `LabsGroupingTest`
* Android, [labNotesForDisplay]) — fix du bug « une seule note sur deux
* s'affiche » : quand E2 et T portent des notes DISTINCTES, les deux doivent
* apparaître (préfixées du marqueur) ; la note identique (dialog commun) ne
* s'affiche qu'une fois ; les notes vides/malformées sont ignorées.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { labNotesForDisplay } from '../js/util/format.js';
const lab = (marker, notes) => ({ marker, notes });
describe('labNotesForDisplay (miroir Android v1.7.1)', () => {
test('les notes distinctes de E2 et T sont TOUTES affichées, préfixées', () => {
const group = [
lab('E2', 'Analyse faite suite à des maux de tête.'),
lab('T', 'DHT : 12 ng/dL'),
];
assert.deepEqual(labNotesForDisplay(group), [
'E2 : Analyse faite suite à des maux de tête.',
'T : DHT : 12 ng/dL',
]);
});
test('la note identique sur les deux entrées n\'est affichée qu\'une fois', () => {
const group = [lab('E2', 'Prise à jeun'), lab('T', 'Prise à jeun')];
assert.deepEqual(labNotesForDisplay(group), ['Prise à jeun']);
});
test('une seule note reste brute (comportement historique préservé)', () => {
const group = [lab('E2', 'Seule note'), lab('T', null)];
assert.deepEqual(labNotesForDisplay(group), ['Seule note']);
});
test('notes vides/blanches ignorées, ordre du groupe préservé', () => {
const group = [lab('E2', ' '), lab('T', 'note T'), lab('PRL', 'note PRL')];
assert.deepEqual(labNotesForDisplay(group), ['T : note T', 'PRL : note PRL']);
});
test('aucune note → aucune ligne', () => {
assert.deepEqual(labNotesForDisplay([lab('E2', null), lab('T', '')]), []);
});
});

159
tests/lab-timing.test.js Normal file
View File

@ -0,0 +1,159 @@
/**
* Tests de la RECOMMANDATION DE PROCHAINE PRISE DE SANG (v1.8.0 — miroir de
* `LabTimingTest.kt` Android, `js/pk/lab-timing.js`) :
* - creux recommandé JUSTE AVANT le créneau d'injection associé ;
* - régime non stabilisé → saut au premier creux stabilisé (5 × t½) ;
* - régime stabilisé → premier creux (`wasAlreadyStabilized = true`) ;
* - sans Posologie / sans injectable E2 → null (+ invite Posologie) ;
* - prise récente → creux SUIVANT (jamais un creux déjà mesuré).
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { nextBloodDrawRecommendation, shouldSuggestPosology } from '../js/pk/lab-timing.js';
import { makeTreatment, makeDose, makeLab } from './helpers.js';
const DAY_MS = 86_400_000;
const HOUR_MS = 3_600_000;
/** Maintenant FIXE : les tests sont déterministes. */
const NOW = 1_790_000_000_000;
/** Traitement EEn TFS actif, Posologie 7 j (fixture de l'utilisatrice). */
function eenTfs(posologyDays = 7.0) {
return makeTreatment({
name: 'EEn', type: 'ESTRADIOL', route: 'INJECTION_IM',
doseAmount: 5.0, doseUnit: 'mg', esterType: 'EEN', pkModel: 'TFS',
forecastIntervalDays: posologyDays, isActive: true,
});
}
/** Doses hebdomadaires sur [weeks] semaines (la plus récente à NOW − 7 j). */
function weeklyDoses(weeks) {
const out = [];
for (let k = weeks; k >= 1; k--) {
out.push(makeDose(NOW - k * 7 * DAY_MS, 5.0));
}
return out;
}
const lab = (ts) => makeLab('E2', 200, 'pg/mL', ts);
describe('LabTiming (miroir Android v1.8.0)', () => {
test('le creux est trouvé juste avant le créneau d injection associé', () => {
const rec = nextBloodDrawRecommendation(
[eenTfs()], weeklyDoses(9), [], NOW,
);
assert.ok(rec, 'une recommandation est calculable');
assert.equal(rec.injectionMs, NOW + 7 * DAY_MS);
const gap = rec.injectionMs - rec.troughMs;
assert.ok(gap > 0 && gap <= 2 * HOUR_MS, `creux juste avant l'injection (écart ${gap} ms)`);
assert.equal(rec.wasAlreadyStabilized, true);
});
test('non stabilisé - saute au premier creux STABILISÉ', () => {
// Une seule dose il y a 7 j : EEn TFS t½ = ln2/0.15291485 ≈ 4,53 j →
// stabilisation ≈ NOW + 15,7 j → créneaux +7/+14 trop tôt, +21 premier stable
const rec = nextBloodDrawRecommendation([eenTfs()], weeklyDoses(1), [], NOW);
assert.equal(rec.injectionMs, NOW + 21 * DAY_MS);
assert.equal(rec.wasAlreadyStabilized, false);
});
test('déjà stabilisé - premier creux, wasAlreadyStabilized à true', () => {
const rec = nextBloodDrawRecommendation([eenTfs()], weeklyDoses(9), [], NOW);
assert.equal(rec.injectionMs, NOW + 7 * DAY_MS);
assert.equal(rec.wasAlreadyStabilized, true);
assert.ok(rec.stabilizedAtMs < NOW);
});
test('prise de sang récente → recommande le creux SUIVANT', () => {
const recentLab = lab(NOW + 7 * DAY_MS - 30 * 60_000); // 30 min avant le créneau
const rec = nextBloodDrawRecommendation(
[eenTfs()], weeklyDoses(9), [recentLab], NOW,
);
assert.equal(rec.injectionMs, NOW + 14 * DAY_MS);
});
test('sans Posologie → null', () => {
assert.equal(
nextBloodDrawRecommendation([eenTfs(null)], weeklyDoses(9), [], NOW),
null,
);
});
test('oral seul → null (et pas d\'invite Posologie)', () => {
const oral = makeTreatment({
name: 'CPA', type: 'ANTI_ANDROGEN', route: 'ORAL',
doseAmount: 12.5, doseUnit: 'mg', esterType: 'NONE',
forecastIntervalDays: 2.0, isActive: true,
});
assert.equal(nextBloodDrawRecommendation([oral], weeklyDoses(9), [], NOW), null);
assert.equal(shouldSuggestPosology([oral]), false);
});
test('invite Posologie seulement pour un injectable E2 sans Posologie', () => {
assert.equal(shouldSuggestPosology([eenTfs(null)]), true);
assert.equal(shouldSuggestPosology([eenTfs(7.0)]), false);
});
// ── v1.8.1 : le régime courant voit les CHANGEMENTS RÉCENTS ──────────────
// Critique v1.8.0 : « changé d'ester, de dosage ET de posologie, et l'app
// me disait stabilisée depuis février » — l'ancien proxy (1ʳᵉ dose du
// traitement) ne détectait rien. NOUVELLE règle : le régime = séquence
// terminale à (ester, dose, écart inter-doses) constants ; tout changement
// récent réinitialise la stabilisation.
test('changement de dose récent réinitialise la stabilisation', () => {
// 9 semaines à 5 mg, puis 3 mg il y a 2 j : le régime (3 mg) n'a que
// 2 j → non stabilisé → créneau ≥ NOW−2 j + ~22,7 j ≈ NOW+26 j (le 1ᵉʳ
// créneau part de la DERNIÈRE dose réelle, pas de NOW)
const doses = weeklyDoses(9).concat([makeDose(NOW - 2 * DAY_MS, 3.0)]);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.injectionMs, NOW + 26 * DAY_MS);
assert.equal(rec.wasAlreadyStabilized, false);
});
test('changement d\'intervalle récent réinitialise la stabilisation', () => {
// 9 semaines à 7 j, puis une dose 2 j après la précédente : l'écart de
// 2 j (48 h) est HORS de la fenêtre Posologie ± 24 h (v1.9.7, [6 j ;
// 8 j] pour 7 j) → le régime s'arrête à la dose NOW−7 j (un vrai
// changement de créneau reste un changement — miroir Android)
const doses = weeklyDoses(9).concat([makeDose(NOW - 9 * DAY_MS, 5.0)]);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
// régimeStart = NOW−7 j → stabilisation ≈ NOW+15,7 j → 1ᵉʳ créneau : +21 j
assert.equal(rec.injectionMs, NOW + 21 * DAY_MS);
assert.equal(rec.wasAlreadyStabilized, false);
});
test('le flou d\'heure de log ne réinitialise PAS la stabilisation (v1.9.7)', () => {
// REMONTÉE v1.9.7 (miroir Android) : « la suggestion change tout le
// temps à chaque injection si l'injection n'est pas faite pile à la
// même heure » — chaque écart reste dans Posologie ± 24 h → le régime
// remonte la séquence entière (63 j) → déjà stabilisée.
const jitterHours = [2, -3, 5, -1, 23, -23, 1, -0.5, 0.75];
const doses = jitterHours.map((jh, i) =>
makeDose(NOW - (63 - 7 * i) * DAY_MS + jh * HOUR_MS, 5.0));
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.wasAlreadyStabilized, true);
});
test('un écart au-delà de 24 h de tolérance réinitialise (v1.9.7)', () => {
// 8 écarts réguliers, puis un retard de 25 h sur le 7 j : hors fenêtre
// → le régime ne remonte pas avant la dose en retard → non stabilisée
const doses = weeklyDoses(9).map((d, i) =>
i === 7 ? makeDose(NOW - 14 * DAY_MS + 25 * HOUR_MS, 5.0) : d);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.wasAlreadyStabilized, false);
});
test('override d\'ester récent réinitialise la stabilisation (ester effectif)', () => {
// Même dose (5 mg), même écart (7 j), mais la dernière dose est un
// override EV : switch d'ester = nouveau régime + l'ester EFFECTIF
// (EV, pas EEN) alimente la carte
const doses = weeklyDoses(9).concat([
Object.assign(makeDose(NOW, 5.0), { esterType: 'EV' }),
]);
const rec = nextBloodDrawRecommendation([eenTfs()], doses, [], NOW);
assert.equal(rec.ester, 'EV');
assert.equal(rec.wasAlreadyStabilized, false);
});
});

View File

@ -14,7 +14,7 @@ import { nextReminderFireFor, nextReminderFireMs } from '../js/pk/pk-reminders.j
import { detectExtrema } from '../js/pk/pk-extrema.js'; import { detectExtrema } from '../js/pk/pk-extrema.js';
import { LevelPoint } from '../js/pk/pk-engine.js'; import { LevelPoint } from '../js/pk/pk-engine.js';
import { isVersionNewer, sectionsSince } from '../js/util/changelog.js'; import { isVersionNewer, sectionsSince } from '../js/util/changelog.js';
import { HrtDuration } from '../js/util/format.js'; import { HrtDuration, nextDoseLineText } from '../js/util/format.js';
import { makeTreatment, makeDose, DAY_MS, HOUR_MS, assertClose } from './helpers.js'; import { makeTreatment, makeDose, DAY_MS, HOUR_MS, assertClose } from './helpers.js';
/** Met (h, m) sur le jour calendaire de [ms] (locale — miroir du module testé). */ /** Met (h, m) sur le jour calendaire de [ms] (locale — miroir du module testé). */
@ -212,3 +212,30 @@ describe('HrtDuration (temps sous THS)', () => {
assert.throws(() => assertClose(300, 100, 0.02)); assert.throws(() => assertClose(300, 100, 0.02));
}); });
}); });
describe('nextDoseLineText (carte « Prochaines doses » — v1.9.6, miroir NextDoseLineTest.kt)', () => {
// Hors DST : les heures attendues sont construites avec les MÊMES
// primitives (Intl fuseau système) que le module testé.
const now = new Date(2026, 8, 21, 12, 0, 0).getTime(); // lundi 21/09/2026 12:00
const timeOf = (h, m) => new Date(2026, 8, 21, h, m, 0).getTime();
test('au-delà de 24 h — jours + jour de semaine', () => {
// fire = samedi 26/09 10:00 (118 h plus tard)
const fire = now + 118 * HOUR_MS;
const line = nextDoseLineText(now, fire, 'Injection EEn 5mg 7d');
assert.match(line, /^4j 22h · .+ \(Injection EEn 5mg 7d\)$/);
// le jour de semaine est présent (le format Intl diffère selon la locale
// système : on épingle la STRUCTURE, pas le libellé)
assert.ok(!line.startsWith('118'), 'delta affiché en jours, pas en heures');
});
test('entre 1 h et 24 h — heures + minutes', () => {
const fire = timeOf(14, 5);
assert.equal(nextDoseLineText(now, fire, 'CPA'), `2h05 · 14:05 (CPA)`);
});
test('moins d\'une heure — minutes', () => {
const fire = timeOf(12, 7);
assert.equal(nextDoseLineText(now, fire, 'CPA'), `7 min · 12:07 (CPA)`);
});
});

70
tests/presets.test.js Normal file
View File

@ -0,0 +1,70 @@
/**
* Tests des PRESETS de traitements (v1.9.4 — miroir de `PKPresets.kt`) :
* le modèle ESE ANALYTIQUE couvre 6 esters injectables — les presets de
* création doivent offrir les 6 (comme TFS offre les 7 du V3C et WHSAH
* les 6 du fit Mona). Avant v1.9.4, les presets ESE n'offraient que
* EV/EU/EEn (l'héritage des tables ODS) — remontée : « les presets n'ont
* pas été mis à jour lors de l'ajout du modèle source ESE ».
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { all } from '../js/pk/presets.js';
describe('PKPresets web (presets ESE étendus — v1.9.4)', () => {
test('les presets ESE couvrent les 6 esters du fit analytique', () => {
const eseInjectable = all.filter((p) => p.pkModel === 'ESE' && p.esterType !== 'NONE');
const esters = eseInjectable.map((p) => p.esterType).sort();
assert.deepEqual(esters, ['EB', 'EC', 'EEN', 'EU', 'EUCS', 'EV']);
});
test('chaque ester couvert a EXACTEMENT un preset ESE (pas de doublon)', () => {
const eseInjectable = all.filter((p) => p.pkModel === 'ESE' && p.esterType !== 'NONE');
const names = eseInjectable.map((p) => p.nameKey);
assert.equal(new Set(names).size, names.length);
});
test('chaque preset a une clé i18n présente dans EN et FR', async () => {
const { store } = await import('../js/data/store.js');
store.setBackend(new Map()); // Node : pas de localStorage
const i18n = await import('../js/util/i18n.js');
// t() lit la langue du store (pas de setLanguage exporté) : on force
// successivement 'en' puis 'fr' via les settings (miroir de l'app).
for (const lang of ['en', 'fr']) {
store.setSettings({ language: lang });
for (const p of all) {
// t() retombe sur la clé brute si absente : un libellé égal à la
// clé = clé manquante (leçon §12 web : une string manquante
// s'affiche brute dans l'UI)
assert.notEqual(i18n.t(p.nameKey), p.nameKey, `clé i18n manquante (${lang}) : ${p.nameKey}`);
}
}
});
test('les presets TFS (7) et WHSAH (6) restent inchangés', () => {
const injectables = (model) => all.filter((p) => p.pkModel === model && p.esterType !== 'NONE');
assert.equal(injectables('TFS').length, 7);
assert.equal(injectables('WHS').length, 6);
});
test('choicesForModel (éditeur + dialog de dose) suit la couverture du fit ESE', async () => {
// v1.9.4 : l'éditeur liste choicesForModel(model) — si ce helper restait
// sur la couverture des tables ODS (3 esters), l'éditeur n'offrirait pas
// EC/EB/EUCS même avec les presets à jour (parité Android v1.9.0).
const { choicesForModel } = await import('../js/data/models.js');
assert.deepEqual(choicesForModel('ESE').sort(), ['EB', 'EC', 'EEN', 'EU', 'EUCS', 'EV']);
// TFS reste EXPLICITE à 7 (pas d'EUCS dans le V3C — sinon 0 pg/mL) et
// WHSAH à 6 (PEP non couvert).
assert.deepEqual(choicesForModel('TFS').sort(), ['EB', 'EC', 'ECS', 'EEN', 'EU', 'EV', 'PEP']);
assert.deepEqual(choicesForModel('WHS').sort(), ['EB', 'EC', 'ECS', 'EEN', 'EU', 'EV']);
assert.equal(choicesForModel('TFS').includes('EUCS'), false);
});
test('les presets ESE non-injectables (gel/patch/oral) restent en Bateman', () => {
const eseNonInjectable = all.filter(
(p) => p.pkModel === 'ESE' && (p.esterType === 'NONE' || p.esterType == null),
);
assert.ok(eseNonInjectable.length >= 3, 'gel/patch/oral toujours présents');
assert.ok(eseNonInjectable.every((p) => p.esterType === 'NONE'));
});
});

25
tests/rounding.test.js Normal file
View File

@ -0,0 +1,25 @@
/**
* Tests de l'ARRONDI de la médiane de calibration (v1.9.2 — miroir du fix
* Android v1.8.2 `medianRounded`) : `round2`/`round3` ARRONDISSENT au plus
* proche (Math.round) au lieu de tronquer — la troncature au dernier bit
* flottant faussait les échelles d'un cran (0,8999… → 0,89).
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { round2, round3 } from '../js/pk/pk-calibration.js';
describe('round2 / round3 (arrondi, pas troncature — v1.9.2)', () => {
test('round2 arrondit au plus proche (0,8999… → 0,90, pas 0,89)', () => {
assert.equal(round2(0.8999999999999999), 0.9);
assert.equal(round3(0.8999999999999999), 0.9);
// la troncature historique aurait donné 0,89
assert.notEqual(Math.trunc(0.8999999999999999 * 100) / 100, 0.9);
});
test('round2 / round3 : cas limites', () => {
assert.equal(round2(2.0), 2.0);
assert.equal(round3(1.2346), 1.235);
assert.equal(round2(0.001), 0.0);
});
});

View File

@ -0,0 +1,66 @@
/**
* Tests de l'ORDRE D'AFFICHAGE de la page Traitements (v1.11.0, miroir de
* TreatmentsDisplayTest.kt — demande utilisatrice : « les traitements
* inactifs faudrait les placer à la fin (tout en bas) de la liste ») :
* - les ACTIFS d'abord, les INACTIFS regroupés après ;
* - tri STABLE (ordre conservé à l'intérieur de chaque groupe) ;
* - cas limites : liste vide, tout actif, tout inactif (identité).
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { treatmentsForDisplay } from '../js/data/models.js';
import { makeTreatment } from './helpers.js';
describe('treatments display order (v1.11.0)', () => {
test('les inactifs sont regroupés à la fin', () => {
const mixed = [
makeTreatment({ id: 1, name: 'EEn', isActive: true }),
makeTreatment({ id: 2, name: 'EV-old', isActive: false }),
makeTreatment({ id: 3, name: 'CPA', isActive: true }),
makeTreatment({ id: 4, name: 'EEn-old', isActive: false }),
makeTreatment({ id: 5, name: 'Ritaline', isActive: true }),
];
assert.deepEqual(
treatmentsForDisplay(mixed).map((t) => t.id),
[1, 3, 5, 2, 4],
'actifs d\'abord (ordre conservé), inactifs après (ordre conservé)',
);
});
test('tri stable : ordre conservé à l\'intérieur de chaque groupe', () => {
const mixed = [
makeTreatment({ id: 10, name: 'A', isActive: false }),
makeTreatment({ id: 11, name: 'B', isActive: true }),
makeTreatment({ id: 12, name: 'C', isActive: false }),
makeTreatment({ id: 13, name: 'D', isActive: true }),
];
assert.deepEqual(
treatmentsForDisplay(mixed).map((t) => t.id),
[11, 13, 10, 12],
);
});
test('tout actif / tout inactif : ordre inchangé (identité)', () => {
const actifs = [
makeTreatment({ id: 3, isActive: true }),
makeTreatment({ id: 1, isActive: true }),
];
assert.deepEqual(treatmentsForDisplay(actifs).map((t) => t.id), [3, 1]);
const inactifs = [
makeTreatment({ id: 2, isActive: false }),
makeTreatment({ id: 1, isActive: false }),
];
assert.deepEqual(treatmentsForDisplay(inactifs).map((t) => t.id), [2, 1]);
});
test('liste vide reste vide ; entrée non mutée', () => {
assert.equal(treatmentsForDisplay([]).length, 0);
const mixed = [
makeTreatment({ id: 2, isActive: false }),
makeTreatment({ id: 1, isActive: true }),
];
treatmentsForDisplay(mixed);
assert.deepEqual(mixed.map((t) => t.id), [2, 1], 'la liste d\'entrée n\'est pas mutée');
});
});