Compare commits

...

26 Commits
v1.5.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
d822af2dd2 v1.7.0 : unités des axes du graphique (portage Android) + note auto-backup
- chart-canvas.js : pg/mL (E2, axe gauche) / ng/mL (T, axe droit) au
  sommet des colonnes de labels — miroir exact du fix Android (padTop
  élargi 12 → 26 px). Universelles, PAS des clés i18n.
- WEB_VERSION 1.7.0 (versions sync Android) ; E2E assertions version.
- CHANGELOG [1.7.0] : unités portées ; auto-backup Android NON porté
  (structurellement impossible côté navigateur — cf §12 Limites :
  pas d'OPEN_DOCUMENT_TREE persisté + WorkManager ; l'export/import
  manuel couvre la même donnée).
- Doc : §12 limites, README statut. check.sh vert (140 tests + E2E +
  garde de parité §3.bis).
2026-09-16 19:53:41 +02:00
524f122f15 v1.6.1 : fix — les options du graphique se réinitialisaient toutes seules
Cause (divergence de portage, bug remonté « options reset sans changer de
menu ») : renderRoute() re-crée l'écran ENTIER à chaque tick minute (60 s),
mutation du store ou resize — et l'état des options était une variable
LOCALE de renderChart, retombant aux défauts à chaque re-création.
L'Android est immunisé (remember { mutableStateOf } survit aux
recompositions).

Fix (miroir Compose) :
- chart.js : état d'interaction au NIVEAU MODULE (chartUiState) ;
  renderChart(container, ctx, { preserveState }) réutilise l'état quand
  preserveState=true, le recrée aux défauts sinon ;
- app.js : preserveChartState = (route === 'chart' && lastRoute === 'chart')
  comparé AVANT la mise à jour de lastRoute — tick/store/resize préservent
  l'état, la navigation le réinitialise (miroir du reset d'un remember
  Android quitté).

Épinglé par l'E2E : resize (même code path que le tick) → chip Tracé labs
+ légende restent en place ; navigation → retour aux défauts.

Docs : §4 « État d'interaction persistant », §8 scénario E2E, §11 leçon
#15 (tout état d'UI doit survivre au re-rendu global), CHANGELOG [1.6.1]
(fix web-only : parité fonctionnelle = Android 1.6.0), README.
2026-09-15 12:49:11 +02:00
ed623b786b Docs + process (web v1.6.0) : CHANGELOG, doc §4/§8/§11, garde de parité check.sh §3.bis
- check.sh : NOUVELLE étape 3.bis — garde de PARITÉ du portage
  Android↔web (si le dépôt Android voisin existe) : les symboles clés
  des features partagées (moteur prolongation, chip, avertissement)
  sont vérifiés des DEUX côtés — attrape un portage incomplet.
- Doc : arbre §4, suite lab-trajectory-model (§8), scénario E2E (§8),
  leçons #12 étendue (LABX) et #14 (parité) (§11).
- CHANGELOG web : entrée [1.6.0] (séparé du CHANGELOG Android).
- README : statut web v1.6.0, 140 tests verts.
2026-09-12 23:51:20 +02:00
551d8fa29e v1.6.0 (web, UI + E2E) : chip Prolonger, split LAB/LABX, avertissement, WEB_VERSION 1.6.0
- chart.js : chip « Prolonger » (off par défaut, désactivé tant que
  Tracé labs est off), split série "LAB" (ancrée) / "LABX" (prolongée,
  rose atténué rgba(194,24,91,0.55)), légendes dédiées (clés LAB ET LABX
  skippées de la boucle — miroir leçon #63), AVERTISSEMENT « simple
  simulation, sans garantie de correspondre au réel, labs potentiellement
  erronés » affiché sous la légende tant que la prolongation est visible
  (condition factorisée : jamais de courbe prolongée sans avertissement).
- i18n : show_lab_track_extend, legend_lab_track_extend,
  lab_track_extend_warning (FR/EN synchronisées).
- E2E : scénario v1.6.0 (chip désactivé → activation → légende +
  avertissement → disparition à la désactivation) ; assertions version
  1.6.0.
2026-09-12 23:51:20 +02:00
bd338decbf v1.6.0 (web, moteur) : prolongation du tracé labs au-delà du dernier lab
Miroir fidèle du Kotlin (doc Android §7.10.bis) :
- extendBeyondLastLab (ρ_last constant au-delà du dernier ancre),
  lastAnchorMs, extensionHorizonEndMs (dernière dose E2 + cutoff du
  moteur — même sémantique d'extinction, une seule source de vérité).
- 8 nouveaux tests unitaires miroirs (140 verts au total) : identité
  M×ρ_last, suture exacte, horizon cutoff, dose EV après le dernier lab,
  garde #61, flag off = v1.5.0 bit-compatible, gardes du helper,
  lastAnchorMs.
2026-09-12 23:51:20 +02:00
38cf62826e Docs : audit doc↔code — arbre §4 complet, leçons 12/13 (§11), 132 tests
Constats de l'audit systématique :
- arbre §4 sans les fichiers conteneurisation (Dockerfile, nginx.conf,
  compose, .dockerignore) ni container-test.sh / publish-release.py
- check.sh décrit 1→6 (actualisé : + mode --release 7→9)
- suite lab-trajectory absent de l'arbre tests/
- mentions « 121 tests » périmées → 132 (statut/démarrage/§2/§8/footer)
- leçons de portage v1.5.0 ajoutées au §11 (12 : clé LAB skippée de la
  légende, miroir #63 Android ; 13 : imports ESM = modules exacts)
2026-09-11 16:27:56 +02:00
42 changed files with 3236 additions and 193 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,8 +17,16 @@
> 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.5.0 — portage de l'Android v1.5.0 · **132 tests verts** · - **Statut** : web v1.13.0 — parité fonctionnelle avec l'Android v1.13.0
E2E navigateur ✅ · conteneur testé en configuration durcie ✅ · lint/i18n ✅ sur la logique miroirée (reco de prise de sang : **valeur E2 attendue au
creux** quand l'auto-calibration est ON + **régime poolé** — un
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 ✅
- **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)
(zip de déploiement statique vérifié par téléchargement) (zip de déploiement statique vérifié par téléchargement)
@ -182,7 +190,7 @@ in-app s'affiche quand même à l'heure du rappel, page ouverte).
## Doc de développement ## Doc de développement
Architecture, portage fichier par fichier, format du backup, processus de Architecture, portage fichier par fichier, format du backup, processus de
test (121 tests Node + E2E navigateur), processus de push et pièges connus : test (132 tests Node + E2E navigateur), processus de push et pièges connus :
**[docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)**. **[docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)**.
## Licence ## Licence

1
assets/mcmc_samples.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,371 @@
> 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
### Unités des axes du graphique — portage de l'Android v1.7.0 (versions sync)
- **pg/mL** (E2, axe gauche) et **ng/mL** (T, axe droit) affichées au sommet
des colonnes de labels du graphique de simulation — miroir exact du fix
Android (padTop élargi 12 → 26 px, texte gris dans la couleur des labels).
- Universelles, PAS des clés i18n (déjà en dur dans les légendes).
> Note de parité : la **sauvegarde automatique quotidienne** de l'Android
> v1.7.0 n'est PAS portée — le navigateur ne peut pas écrire périodiquement
> dans un dossier arbitraire sans intervention (voir §12 Limites) ;
> l'export/import manuel du backup couvre la même donnée.
## [1.6.1] — 2026-09-13
### Corrigé — les options du graphique se réinitialisaient « toutes seules »
- **Bug remonté** : sur l'écran Graphiques, les options sélectionnées
(modèles affichés, Tracé labs, Prolonger, zoom, panoramique…) revenaient
d'elles-mêmes aux défauts « alors qu'on n'a pas changé de menu ».
- **Cause** (divergence de portage) : le shell web re-crée l'écran ENTIER
à chaque **tick minute** (60 s), **mutation du store** ou **resize** —
et l'état des options vivait dans une variable LOCALE de la fonction de
rendu, donc retombait aux défauts à chaque re-crétion. Côté Android,
l'équivalent vit dans des `remember { mutableStateOf }` qui survivent à
toutes les recompositions : le bug est structurellement impossible côté
APK — c'était un piège du portage, pas de la logique.
- **Fix** : l'état d'interaction du graphique vit au niveau MODULE et le
shell signale les re-rendus du **même** écran (`preserveState: true`)
→ options préservées pendant qu'on regarde le graphique ; à l'entrée
depuis la navigation (quitter puis revenir), retour aux défauts —
comportement identique à l'Android.
- **Épinglé par l'E2E** : re-render déclenché (resize — même code path
que le tick) → chip et légende toujours en place ; navigation → reset.
> Note de parité : fix **web-only** (pas de changement Android) — les
> fonctionnalités portées restent celles de l'Android v1.6.0.
## [1.6.0] — 2026-09-12
### « Tracé labs » prolongé au-delà du dernier lab — portage de l'Android v1.6.0
- **Nouveau chip `Prolonger`** (à côté de `Tracé labs`, **off par défaut**,
désactivé tant que Tracé labs est off) : la courbe ancrée sur tes
prises de sang se PROLONGE au-delà du dernier lab — la limite v1.5.0
rendait invisible justement la période la plus récente.
- **Recette** : au-delà du dernier lab significatif, `courbe(t) = M(t) ×
ρ_dernier lab` — le modèle PK (dosages, esters injectés depuis, pics et
descentes classiques) × le ratio mesuré au dernier lab, supposé constant.
Les doses loguées APRÈS le dernier lab font repartir la courbe en pic
(override d'ester compris).
- **Horizon honnête** : la prolongation s'arrête quand le MODÈLE lui-même
s'éteint (dernière dose + cutoff moteur : 10 t½ terminales V3C/WHS, fin
de table ODS, 30 t½ Bateman) — jamais de ligne à zéro inventée.
- **Lisibilité** : partie prolongée dessinée SÉPARÉMENT (même rose,
atténué) avec sa légende dédiée + **AVERTISSEMENT visible** sous la
légende : simple simulation sans garantie de correspondre au réel,
extrapolée à partir de tes résultats de laboratoire — qui peuvent
eux-mêmes être erronés. Fie-toi à ta prochaine prise de sang.
- Garde anti-ratés inchangée (#61) : un lab hors fenêtre d'action ne
prolonge pas la courbe et n'est jamais traversé.
- Portage fidèle : `extensionHorizonEndMs`/`lastAnchorMs` (miroir du
Kotlin), 8 nouveaux tests unitaires miroirs + **scénario E2E navigateur**
(activation des chips, légende et avertissement rendus).
## [1.5.0] — 2026-09-11 ## [1.5.0] — 2026-09-11
### « Tracé labs » — portage de la version Android v1.5.0 (versions synchronisées) ### « Tracé labs » — portage de la version Android v1.5.0 (versions synchronisées)

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 121 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())`) |
@ -140,6 +140,10 @@ HormoneTrack-web/ (dépôt séparé)
├── index.html page unique (SPA) — shell construit par app.js ├── index.html page unique (SPA) — shell construit par app.js
├── css/style.css thème (couleurs = Color.kt Android), mobile-first ├── css/style.css thème (couleurs = Color.kt Android), mobile-first
├── LICENSE GPL-3.0 (copie de l'Android — dépôt autonome) ├── LICENSE GPL-3.0 (copie de l'Android — dépôt autonome)
├── Dockerfile image de déploiement (nginx-unprivileged, §10)
├── nginx.conf server block no-cache/gzip/sécurité (§10)
├── docker-compose.yml run durci (read_only + tmpfs, §10)
├── .dockerignore runtime seulement (docs/ INCLUS — changelog)
├── .gitignore node_modules, package-lock, local-test-data ├── .gitignore node_modules, package-lock, local-test-data
├── assets/pk_profiles.json COPIE de l'asset Android (tables Estrannaise) ├── assets/pk_profiles.json COPIE de l'asset Android (tables Estrannaise)
├── js/ ├── js/
@ -159,13 +163,15 @@ HormoneTrack-web/ (dépôt séparé)
│ │ ├── transfem-science-models.js V3C méta-analyse TFS (7 esters, params │ │ ├── transfem-science-models.js V3C méta-analyse TFS (7 esters, params
│ │ │ copiés à l'identique du Kotlin) │ │ │ copiés à l'identique du Kotlin)
│ │ ├── whsah-models.js fit WHSAH de Mona (6 esters, params identiques) │ │ ├── whsah-models.js fit WHSAH de Mona (6 esters, params identiques)
│ │ ├── lab-trajectory-model.js « Tracé labs » (v1.5.0) : courbe hybride │ │ ├── lab-trajectory-model.js « Tracé labs » (v1.5.0 + PROLONGATION
│ │ v1.6.0) : courbe hybride + extension ρ const
│ │ M(t)×ρ(t) ancrée sur les labs (miroir strict │ │ M(t)×ρ(t) ancrée sur les labs (miroir strict
│ │ de pk/LabTrajectoryModel.kt — garde #61, │ │ de pk/LabTrajectoryModel.kt — garde #61,
│ │ ρ 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),
@ -219,9 +225,17 @@ HormoneTrack-web/ (dépôt séparé)
│ └── CHANGELOG.md journal web (lu par le dialog « Nouveautés ») │ └── CHANGELOG.md journal web (lu par le dialog « Nouveautés »)
├── scripts/ ├── scripts/
│ ├── serve.py serveur statique de dev (no-cache, MIME ok) │ ├── serve.py serveur statique de dev (no-cache, MIME ok)
│ ├── check.sh vérifications 1→6 (syntaxe, i18n, tests, E2E, HTTP) │ ├── check.sh vérifications 1→6 (syntaxe, i18n, tests, E2E,
│ │ smoke HTTP) + mode --release : 7→9 (cohérence
│ │ version↔changelog↔tag, arbre propre, test
│ │ conteneur si daemon — §10)
│ ├── i18n-check.mjs cohérence FR/EN + clés utilisées │ ├── i18n-check.mjs cohérence FR/EN + clés utilisées
│ └── e2e.mjs E2E playwright (Firefox headless, ?demo=1) │ ├── e2e.mjs E2E playwright (Firefox headless, ?demo=1 ;
│ │ mode conteneur via HRT_E2E_BASE)
│ ├── container-test.sh build + run DURCI + headers/gzip/MIME +
│ │ E2E contre le conteneur ([port] [platform])
│ └── publish-release.py publie la release Gitea (zip = git archive
│ AU TAG, vérifié par téléchargement)
├── tests/ ── node --test (miroirs des suites Kotlin) ── ├── tests/ ── node --test (miroirs des suites Kotlin) ──
│ ├── helpers.js fixtures (makeTreatment/makeDose/makeLab, asset) │ ├── helpers.js fixtures (makeTreatment/makeDose/makeLab, asset)
│ ├── pk-profiles.test.js ← PKProfileStoreTest.kt (pics ODS, casse #22…) │ ├── pk-profiles.test.js ← PKProfileStoreTest.kt (pics ODS, casse #22…)
@ -230,6 +244,31 @@ 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
│ │ « Tracé labs » + v1.6.0 prolongation —
│ │ passage exact, garde #61, horizon cutoff)
│ ├── backup.test.js ← BackupGsonTest.kt + exports Android RÉELS │ ├── backup.test.js ← BackupGsonTest.kt + exports Android RÉELS
│ │ (local-test-data/ si présent, sinon skip) │ │ (local-test-data/ si présent, sinon skip)
│ ├── chart-helpers.test.js ← ChartZoomTest.kt (#53/#54/#55/#58/#62) │ ├── chart-helpers.test.js ← ChartZoomTest.kt (#53/#54/#55/#58/#62)
@ -256,6 +295,16 @@ HormoneTrack-web/ (dépôt séparé)
- **« now » mémoïsé sur un tick minute** (fix #56 transposé) : `ctx.nowMs` - **« now » mémoïsé sur un tick minute** (fix #56 transposé) : `ctx.nowMs`
est fourni par le shell et ne change qu'au re-rendu (tick 60 s, mutation est fourni par le shell et ne change qu'au re-rendu (tick 60 s, mutation
du store, navigation) — jamais de dérive de fenêtre pendant les gestes. du store, navigation) — jamais de dérive de fenêtre pendant les gestes.
- **État d'interaction du graphique persistant entre re-rendus** (v1.6.1,
fix du bug « options qui se reset toutes seules ») : `renderRoute()`
re-crée l'écran ENTIER à chaque tick minute (60 s), mutation du store ou
resize — or l'Android garde les options dans des `remember` qui
SURVIVENT aux recompositions. Le web fait donc vivre l'état AU NIVEAU
MODULE (`chartUiState` dans chart.js) et le shell signale les re-rendus
du même écran via `preserveState: true` (tick/store/resize → préservé ;
navigation → défauts, miroir du reset d'un `remember` quitté). Sans ça,
les options du graphique retombaient aux défauts toutes les 60 s
pendant que l'utilisateur le regarde.
- **Le graphique est purement déclaratif** : pan/zoom vivent dans - **Le graphique est purement déclaratif** : pan/zoom vivent dans
`chart.js` (parent), `chart-canvas.js` ne fait que dessiner une fenêtre `chart.js` (parent), `chart-canvas.js` ne fait que dessiner une fenêtre
donnée — même séparation que Compose (piège #62/§11 Android). donnée — même séparation que Compose (piège #62/§11 Android).
@ -362,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` | **121 tests** : noyau PK, calibration, backup, store, helpers, alertes, rappels, changelog | `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` |
@ -397,7 +446,37 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
pipelines — jamais de médiane contaminée), k T contre l'E2 CALIBRÉE. pipelines — jamais de médiane contaminée), k T contre l'E2 CALIBRÉE.
- **lab-trajectory-model** : v1.5.0 « Tracé labs » — miroir du Kotlin - **lab-trajectory-model** : v1.5.0 « Tracé labs » — miroir du Kotlin
(passage exact, ρ log-linéaire, garde #61, fenêtres, indépendance du (passage exact, ρ log-linéaire, garde #61, fenêtres, indépendance du
scaleFactor, modelOverride, doublons, bornes). scaleFactor, modelOverride, doublons, bornes). v1.6.0 **prolongation**
(miroir §7.10.bis Android) : identité `M × ρ_last` exacte au-delà du
dernier lab, suture exacte, horizon = cutoff du traitement
(`extensionHorizonEndMs` — gardes : pas de dose / modèle déjà éteint →
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,
`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),
@ -427,6 +506,21 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
- vérifie le **rendu réel** : cartes, valeurs, dialog « Nouveautés » - vérifie le **rendu réel** : cartes, valeurs, dialog « Nouveautés »
(markdown rendu), navigation des 5 onglets + Paramètres, **pixels du (markdown rendu), navigation des 5 onglets + Paramètres, **pixels du
canvas** (`getImageData` — le graphique PEINT vraiment) ; canvas** (`getImageData` — le graphique PEINT vraiment) ;
- **v1.6.0 : scénario « Tracé labs prolongé »** — dézoome (dernier lab de
la démo à now − 7 j), vérifie que `Prolonger` est DÉSACTIVÉ tant que
`Tracé labs` est off, active les deux chips, asserting la légende
ancrée, la légende prolongée et l'**AVERTISSEMENT simulation** (sans
garantie, labs potentiellement erronés) — puis désactive et vérifie
qu'ils disparaissent ;
- **v1.6.1 : scénario « persistance des options »** — avec `Tracé labs`
activé, déclenche un re-render via `dispatchEvent(new Event('resize'))`
(MÊME code path que le tick minute et la mutation du store : tous trois
appellent `renderRoute` — le tick de 60 s est trop lent pour un E2E) et
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
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"]`
@ -469,9 +563,10 @@ 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 (121 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
@ -517,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.
@ -667,6 +768,32 @@ Trouvés pendant l'écriture (à ne pas réintroduire) :
11. **`getSettings()` fusionne les défauts** : un stockage partiel ne doit 11. **`getSettings()` fusionne les défauts** : un stockage partiel ne doit
jamais exposer des champs `undefined` (les gardes `!== null` deviennent jamais exposer des champs `undefined` (les gardes `!== null` deviennent
fausses autrement). fausses autrement).
12. **Miroir du fix #63 Android** (v1.5.0, étendu v1.6.0) : les clés
`'LAB'` ET `'LABX'` (Tracé labs ancré / prolongé) dans la liste
`curves` sont SKIPPÉES de la boucle de légende (légendes dédiées à
part) — même bug qu'Android : le catchall (TFS) imprimerait la
légende TFS en doublon. Quand une liste clé→série reçoit une clé,
grepper chaque consommateur de la liste (légende, stats, styles).
13. **Imports ESM = noms de modules EXACTS** (v1.5.0) : `labIsSignificant`
vit dans `pk-calibration.js` (pas `pk-engine.js`) — un import du
mauvais module = SyntaxError AU CHARGEMENT, l'app entière ne démarre
pas ; attrapé par `node --check` + l'E2E « aucune erreur console ».
14. **Parité du portage** (v1.6.0) : une feature Android portée à moitié
(moteur sans UI, clés i18n sans miroir) ne casse aucun test existant —
d'où la garde `check.sh` §3.bis : quand le dépôt Android voisin
existe, les symboles clés des features partagées sont vérifiés des
DEUX côtés (moteur + chip + avertissement).
15. **Re-rendu global = tout état d'UI doit survivre** (v1.6.1, bug
remonté : « les options du graphique se reset alors qu'on n'a pas
changé de menu ») : le shell re-crée l'écran ENTIER sur tick minute,
mutation du store et resize (3 déclencheurs, `renderRoute`) — un
état local à une fonction de rendu retombe donc aux défauts toutes
les 60 s SANS action de l'utilisateur. Règle : tout état d'interaction
d'un écran vit au niveau MODULE du fichier UI, et `renderRoute`
transmet « même écran re-rendu » (`preserveState`) pour le préserver
— miroir de `remember` Compose (survit aux recompositions, reset à la
navigation). Côté Android ce bug est structurellement impossible
(Compose garde l'état) : c'est un piège de portage, pas de logique.
## 12. Limites connues & non-portés ## 12. Limites connues & non-portés
@ -677,6 +804,12 @@ Structurels (plateforme), pas des bugs :
serveur de push (contre la règle « 100 % local, aucun serveur »). serveur de push (contre la règle « 100 % local, aucun serveur »).
- **Agenda récurrent** : impossible (pas de CalendarProvider). L'éditeur - **Agenda récurrent** : impossible (pas de CalendarProvider). L'éditeur
affiche une note explicative ; `calendarEventId` reste null. affiche une note explicative ; `calendarEventId` reste null.
- **Auto-backup journalier** (v1.7.0 Android) : non porté — le navigateur
ne peut pas écrire périodiquement dans un dossier arbitraire sans
intervention (pas d'équivalent d'ACTION_OPEN_DOCUMENT_TREE persisté +
WorkManager ; File System Access API : Chromium seul, et exige une
re-validation de permission). La donnée est couverte par l'export/import
MANUEL du backup (même format, interchangeable avec l'Android).
- **Montre** : hors sujet navigateur. - **Montre** : hors sujet navigateur.
- **Fuseau système** : si le navigateur change de fuseau (voyage), les - **Fuseau système** : si le navigateur change de fuseau (voyage), les
labels X suivent le fuseau CHOISI (Paramètres) ou le nouveau fuseau labels X suivent le fuseau CHOISI (Paramètres) ou le nouveau fuseau
@ -701,7 +834,7 @@ Structurels (plateforme), pas des bugs :
--- ---
*Doc mise à jour le 11 sept. 2026 (web v1.5.0 — sync Android, dépôt séparé *Doc mise à jour le 11 sept. 2026 (web v1.5.0 — sync Android, dépôt séparé
HormoneTrack-web) — portage de l'Android v1.4.10, 121 tests verts + E2E, HormoneTrack-web) — sync Android v1.5.0, 132 tests verts + E2E,
backup compatible bidirectionnel, historique propre (aucune donnée de backup compatible bidirectionnel, historique propre (aucune donnée de
santée, aucune dette de confidentialité), versions alignées sur santée, aucune dette de confidentialité), versions alignées sur
l'Android.* l'Android.*

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

@ -9,10 +9,14 @@ export * from './pk-calibration.js';
export * from './pk-reminders.js'; 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 } 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

@ -1,6 +1,6 @@
/** /**
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
* « Tracé labs » (version web de `pk/LabTrajectoryModel.kt`, v1.5.0) — * « Tracé labs » (version web de `pk/LabTrajectoryModel.kt`, v1.5.0 + v1.6.0) —
* courbe hybride ancrée sur les résultats de sang. * courbe hybride ancrée sur les résultats de sang.
* *
* DEMANDE (décision v1.5.0, débattue avec l'utilisatrice — cf doc de dev * DEMANDE (décision v1.5.0, débattue avec l'utilisatrice — cf doc de dev
@ -18,35 +18,83 @@
* GARDES (invariants Android portés tels quels) : * GARDES (invariants Android portés tels quels) :
* - garde anti-#61 (labIsSignificant) : lab hors fenêtre d'action PAS un ancrage ; * - garde anti-#61 (labIsSignificant) : lab hors fenêtre d'action PAS un ancrage ;
* - E2 uniquement (labs T trop rares + unités variées → bug #26) ; * - E2 uniquement (labs T trop rares + unités variées → bug #26) ;
* - fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique ; * - fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique
* - < 2 labs significatifs → courbe vide ; * (DÉFAUT — cf prolongation ci-dessous) ;
* - < 2 labs significatifs → courbe vide (et donc jamais de prolongation) ;
* - indépendante du scaleFactor stocké (sinon DOUBLE correction — épinglé). * - indépendante du scaleFactor stocké (sinon DOUBLE correction — épinglé).
* *
* ⚠️ display-only : la série n'entre JAMAIS dans levelAt/alertes/reminders * ── PROLONGATION AU-DELÀ DU DERNIER LAB (v1.6.0, miroir §7.10.bis Android) ──
* (invariant §9.bis Android transposé). *
* DEMANDE : permettre au tracé labs de s'étendre au-delà du dernier lab, en
* simulant à partir des labs précédents, du dosage et du type d'ester
* injecté, et de l'évolution classique de l'ester.
*
* Avec `extendBeyondLastLab = true` (chip « Prolonger » — off par défaut),
* la borne droite de la fenêtre devient min(demande, horizon) et ρ cesse
* d'interpoler pour rester CONSTANT :
*
* pour t > t_last_lab : courbe(t) = M(t) × ρ_last
*
* - « l'évolution classique de l'ester » = M(t) (Tmax, queues, accumulation) ;
* - « le dosage et le type d'ester injecté » = M(t) inclut AUTOMATIQUEMENT
* les doses loguées après le dernier lab (une nouvelle injection, même à
* un autre ester, fait repartir la courbe en pic, à l'amplitude calibrée) ;
* - « les données de labs précédentes » = ρ_last (supposé constant).
*
* POURQUOI ρ CONSTANT et pas une extrapolation de pente : la pente entre
* deux labs est un morphing entre deux corrections d'amplitude, pas une
* tendance physiologique — l'extrapoler divergerait sans base. C'est la
* même sémantique que la calibration classique (facteur stocké appliqué
* aux doses futures).
*
* HORIZON (`extensionHorizonEndMs`) : dernière dose E2 + cutoffHours de son
* traitement (10 t½ terminales V3C/WHS, fin de table ODS, 30 t½ Bateman —
* le MÊME cutoff que le moteur). Au-delà, M(t) = 0 : dessiner plus serait
* une ligne à zéro inventée. Retourne null (pas de prolongation) si aucune
* dose E2 ou si le modèle est DÉJÀ éteint au dernier lab.
*
* LIMITES ASSUMÉES (documentées, pas des bugs) : changement d'ester après
* le dernier lab → l'amplitude reste calibrée par le ρ de l'ancien (même
* limite que la calibration classique) ; les doses PRÉVISIONNELLES ne
* participent jamais (la projection reste la série « Prévision »).
*
* ⚠️ display-only : la série (ancrée ET prolongée) n'entre JAMAIS dans
* levelAt/alertes/reminders (invariant §9.bis Android transposé).
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
*/ */
import { e2At } from './pk-engine.js'; import { e2At, cutoffHours } from './pk-engine.js';
import { labIsSignificant } from './pk-calibration.js'; import { labIsSignificant } from './pk-calibration.js';
/** ms par heure (évite un import rien que pour la constante). */
const HOUR_MS = 3600000;
/** /**
* Construit la courbe ancrée sur les labs. * Construit la courbe ancrée sur les labs.
* *
* @param {object[]} treatments TOUS les traitements (actifs ET inactifs — §6.bis) * @param {object[]} treatments TOUS les traitements (actifs ET inactifs — §6.bis)
* @param {object[]} doseLogs Toutes les doses * @param {object[]} doseLogs Toutes les doses RÉELLES (les doses
* loguées APRÈS le dernier lab sont incluses : elles portent la forme
* de la prolongation ; les doses PRÉVISIONNELLES ne participent jamais)
* @param {object[]} labs Tous les LabResult (filtrés E2 ici) * @param {object[]} labs Tous les LabResult (filtrés E2 ici)
* @param {number} startMs Fenêtre demandée (celle du graphique) * @param {number} startMs Fenêtre demandée (celle du graphique)
* @param {number} endMs * @param {number} endMs
* @param {number} [stepMs=3600000] Pas d'échantillonnage (stepForRange) * @param {number} [stepMs=3600000] Pas d'échantillonnage (stepForRange)
* @param {string|null} [modelOverride=null] * @param {string|null} [modelOverride=null]
* @returns {{points:{timestamp:number,e2:number,t:number}[], anchoredLabs:number}} * @param {boolean} [extendBeyondLastLab=false] PROLONGATION (v1.6.0, opt-in) :
* au-delà du dernier lab significatif, ρ reste CONSTANT (= ρ du dernier
* lab) et la courbe continue M(t) × ρ_last jusqu'à
* extensionHorizonEndMs ∩ fenêtre demandée. false = comportement v1.5.0.
* @returns {{points:{timestamp:number,e2:number,t:number}[], anchoredLabs:number,
* lastAnchorMs:number|null}} lastAnchorMs = timestamp du DERNIER lab
* significatif (null si < 2 ancres) — l'UI splitte la série en
* "LAB" (≤ lastAnchorMs, ancrée) et "LABX" (> lastAnchorMs, prolongée).
*/ */
export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, endMs, stepMs = 3600000, modelOverride = null) { export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, endMs, stepMs = 3600000, modelOverride = null, extendBeyondLastLab = false) {
if (startMs >= endMs) return { points: [], anchoredLabs: 0 }; if (startMs >= endMs) return { points: [], anchoredLabs: 0, lastAnchorMs: null };
const estrogenTreatments = treatments.filter((t) => t.type === 'ESTRADIOL'); const estrogenTreatments = treatments.filter((t) => t.type === 'ESTRADIOL');
if (estrogenTreatments.length === 0) return { points: [], anchoredLabs: 0 }; if (estrogenTreatments.length === 0) return { points: [], anchoredLabs: 0, lastAnchorMs: null };
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le facteur) // Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le facteur)
const unscaled = estrogenTreatments.map((t) => ({ ...t, scaleFactor: 1.0 })); const unscaled = estrogenTreatments.map((t) => ({ ...t, scaleFactor: 1.0 }));
@ -64,15 +112,23 @@ export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, end
anchors.push([lab.timestamp, lab.value / predicted]); anchors.push([lab.timestamp, lab.value / predicted]);
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted; if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted;
} }
if (anchors.length < 2) return { points: [], anchoredLabs: anchors.length }; if (anchors.length < 2) return { points: [], anchoredLabs: anchors.length, lastAnchorMs: null };
const firstLabMs = anchors[0][0]; const firstLabMs = anchors[0][0];
const lastLabMs = anchors[anchors.length - 1][0]; const lastLabMs = anchors[anchors.length - 1][0];
// ── 2) Fenêtre effective = [1er ; dernier lab] ∩ fenêtre demandée ───────── // ── 2) Fenêtre effective ───────────────────────────────────────────────────
// v1.5.0 : [1er lab ; dernier lab] ∩ fenêtre demandée.
// v1.6.0 (extendBeyondLastLab) : la borne droite devient le min de la
// fenêtre demandée et de l'HORIZON DE PROLONGATION. ratioAt() retourne
// déjà ρ_last au-delà du dernier ancre (garde défensive) : la boucle de
// calcul est INCHANGÉE — élargir la fenêtre EST la prolongation, et la
// continuité au point de suture est garantie par construction.
const t0 = Math.max(startMs, firstLabMs); const t0 = Math.max(startMs, firstLabMs);
const t1 = Math.min(endMs, lastLabMs); const t1 = extendBeyondLastLab
if (t1 <= t0) return { points: [], anchoredLabs: anchors.length }; ? Math.min(endMs, extensionHorizonEndMs(estrogenTreatments, doseLogs, lastLabMs) ?? lastLabMs)
: Math.min(endMs, lastLabMs);
if (t1 <= t0) return { points: [], anchoredLabs: anchors.length, lastAnchorMs: null };
// ── 3) Courbe = M(t) × ρ(t) sur la grille (t = 0 : pas de courbe T) ─────── // ── 3) Courbe = M(t) × ρ(t) sur la grille (t = 0 : pas de courbe T) ───────
const points = []; const points = [];
@ -81,7 +137,31 @@ export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, end
const rho = ratioAt(anchors, t); const rho = ratioAt(anchors, t);
points.push({ timestamp: t, e2: m * rho, t: 0.0 }); points.push({ timestamp: t, e2: m * rho, t: 0.0 });
} }
return { points, anchoredLabs: anchors.length }; return { points, anchoredLabs: anchors.length, lastAnchorMs: lastLabMs };
}
/**
* BORNE DE PROLONGATION (v1.6.0) : timestamp jusqu'où la partie extrapolée
* a un sens — dernière dose E2 + cutoffHours(son traitement), le MÊME cutoff
* que le moteur (10 t½ V3C/WHS, fin de table ODS, 30 t½ Bateman).
*
* @param {object[]} estrogenTreatments traitements ESTRADIOL (pré-filtrés)
* @param {object[]} doseLogs toutes les doses
* @param {number} lastLabMs timestamp du dernier ancre
* @returns {number|null} fin de prolongation, ou null quand il n'y a RIEN à
* prolonger : aucune dose E2, ou modèle déjà éteint au dernier lab (le
* caller retombe alors sur la fenêtre v1.5.0).
*/
export function extensionHorizonEndMs(estrogenTreatments, doseLogs, lastLabMs) {
const e2Doses = doseLogs.filter((d) => estrogenTreatments.some((tr) => tr.id === d.treatmentId));
if (e2Doses.length === 0) return null;
let lastDose = e2Doses[0];
for (const d of e2Doses) {
if (d.timestamp > lastDose.timestamp) lastDose = d;
}
const tr = estrogenTreatments.find((t) => t.id === lastDose.treatmentId);
const horizonEnd = lastDose.timestamp + cutoffHours(tr) * HOUR_MS;
return horizonEnd > lastLabMs ? horizonEnd : null;
} }
/** /**
@ -90,6 +170,11 @@ export function computeLabAnchoredCurve(treatments, doseLogs, labs, startMs, end
* multiplicative, monotone ; doublons de timestamp → le 2ᵉ ratio gagne * multiplicative, monotone ; doublons de timestamp → le 2ᵉ ratio gagne
* (garde division par zéro) ; gardes défensives aux bornes. * (garde division par zéro) ; gardes défensives aux bornes.
* *
* v1.6.0 : la branche « après le dernier ancre » (retour ρ_last) est
* devenue un COMPORTEMENT, pas une simple garde — c'est elle qui fonde la
* prolongation (ρ constant au-delà du dernier lab) ; en v1.5.0 la fenêtre
* du caller n'y entrait jamais.
*
* @param {[number, number][]} anchors (t_lab, ratio) trié croissant * @param {[number, number][]} anchors (t_lab, ratio) trié croissant
* @param {number} tMs * @param {number} tMs
* @returns {number} * @returns {number}

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;
@ -141,19 +155,28 @@ function currentRoute() {
function renderRoute() { function renderRoute() {
if (!contentEl) return; if (!contentEl) return;
const route = currentRoute(); const route = currentRoute();
// (v1.6.1) Re-rendu du MÊME écran graphique (tick minute, mutation du
// store, resize — ce re-rendu ne vient PAS de la navigation) ? Le chart
// doit alors PRÉSERVER son état d'interaction (chips, zoom, pan,
// Tracé labs…) : miroir Compose où `remember` survit aux recompositions.
// Sans ça, le tick minute de 60 s réinitialisait toutes les options du
// graphique pendant que l'utilisateur le regarde (bug remonté : « les
// options se reset alors qu'on n'a pas changé de menu »). À l'ENTRÉE
// (navigation depuis un autre onglet) : preserveState = false → défauts,
// miroir du reset Android quand on quitte l'écran. ⚠️ comparé AVANT la
// mise à jour de lastRoute ci-dessous.
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);
@ -184,7 +207,7 @@ function renderRoute() {
renderHome(contentEl, { nowMs, openChart: () => { location.hash = '#chart'; } }); renderHome(contentEl, { nowMs, openChart: () => { location.hash = '#chart'; } });
break; break;
case route === 'chart': case route === 'chart':
renderChart(contentEl, { nowMs }); renderChart(contentEl, { nowMs }, { preserveState: preserveChartState });
break; break;
case route === 'doses': case route === 'doses':
renderDoses(contentEl); renderDoses(contentEl);
@ -336,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

@ -75,31 +75,40 @@ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses
const allPoints = series.flatMap((s) => s.points); const allPoints = series.flatMap((s) => s.points);
if (allPoints.length < 2) return; if (allPoints.length < 2) return;
// Marges (px) — miroir padLeft/padRight/padTop/padBottom (42/42/12/26 dp) // Marges (px) — miroir padLeft/padRight/padTop/padBottom (42/42/26/26 dp).
// v1.7.0 : padTop élargi (12 → 26) pour loger les UNITÉS d'axe au sommet
// des colonnes de labels (portage de l'Android v1.7.0 — remontée « on
// n'a jamais ajouté les unités » : pg/mL à gauche, ng/mL à droite).
const padLeft = 42; const padLeft = 42;
const padRight = 42; const padRight = 42;
const padTop = 12; const padTop = 26;
const padBottom = 26; const padBottom = 26;
const w = cssW - padLeft - padRight; const w = cssW - padLeft - padRight;
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);
@ -120,11 +129,16 @@ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses
ctx.font = '10px system-ui, sans-serif'; ctx.font = '10px system-ui, sans-serif';
ctx.textAlign = 'right'; ctx.textAlign = 'right';
ctx.textBaseline = 'middle'; ctx.textBaseline = 'middle';
// Unités des axes (v1.7.0, portage Android) : au sommet de chaque colonne
// de labels, au-dessus du premier tick — universelles, PAS des clés i18n
// (elles sont déjà en dur dans les légendes du chart).
ctx.fillText('pg/mL', padLeft - 6, padTop - 6);
for (let i = 0; i <= 4; i++) { for (let i = 0; i <= 4; i++) {
ctx.fillText(formatValue((e2Max * i) / 4), padLeft - 6, padTop + h * (1 - i / 4)); ctx.fillText(formatValue((e2Max * i) / 4), padLeft - 6, padTop + h * (1 - i / 4));
} }
if (options.showT && series.some((s) => s.tStyle)) { if (options.showT && series.some((s) => s.tStyle)) {
ctx.textAlign = 'left'; ctx.textAlign = 'left';
ctx.fillText('ng/mL', padLeft + w + 6, padTop - 6);
for (let i = 0; i <= 4; i++) { for (let i = 0; i <= 4; i++) {
ctx.fillText(formatValue((tMax * i) / 4), padLeft + w + 6, padTop + h * (1 - i / 4)); ctx.fillText(formatValue((tMax * i) / 4), padLeft + w + 6, padTop + h * (1 - i / 4));
} }

View File

@ -19,6 +19,11 @@
* - « now » mémoïsé sur un tick minute (fix #56 : boucle de recomposition * - « now » mémoïsé sur un tick minute (fix #56 : boucle de recomposition
* côté Android — côté web, même discipline : endMs ne dérive pas à chaque * côté Android — côté web, même discipline : endMs ne dérive pas à chaque
* frame, il ne change qu'au tick ou sur interaction) ; * frame, il ne change qu'au tick ou sur interaction) ;
* - ÉTAT D'INTERACTION PERSISTANT entre re-rendus (v1.6.1) : le tick minute
* de app.js, une mutation du store ou un resize re-créent l'écran entier —
* l'état (chips, zoom, pan, Tracé labs, Prolonger…) est réutilisé quand
* on re-rend le même écran (miroir `remember` Compose) et réinitialisé
* à l'entrée depuis la navigation (miroir du reset Android) ;
* - PAS de scroll horizontal parent : le drag horizontal est réservé au pan * - PAS de scroll horizontal parent : le drag horizontal est réservé au pan
* (piège Android #62/§11). * (piège Android #62/§11).
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
@ -26,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,
@ -43,6 +48,29 @@ const HOUR_MS = 3600000;
const MIN_RANGE_H = 6; const MIN_RANGE_H = 6;
const MAX_RANGE_H = 24 * 300; const MAX_RANGE_H = 24 * 300;
// ── État d'interaction du graphique (v1.6.1 — fix du bug « options qui se
// reset toutes seules ») ──────────────────────────────────────────────────────
// ⚠️ DIVERGENCE DE PORTAGE corrigée : côté Android, les options du graphique
// vivent dans des `remember { mutableStateOf }` — elles SURVIVENT à toutes
// les recompositions (tick minute, nouvelles données, resize) tant qu'on
// reste sur l'écran. Côté web, `renderRoute()` (app.js) re-crée l'écran
// ENTIER à chaque tick minute (60 s), mutation du store ou resize — et un
// `const state = {...}` local à renderChart retombait donc aux DÉFAUTS
// toutes les 60 s pendant qu'on regarde le graphique (remontée : « les
// options se réinitialisent sans qu'on change de menu »).
//
// FIX : l'état vit AU NIVEAU MODULE. `renderChart` le réutilise quand
// l'appelant signale un re-rendu DU MÊME écran (`preserveState: true` —
// miroir de `remember` qui survit aux recompositions) et le RECRÉE aux
// défauts à l'ENTRÉE sur l'écran (navigation depuis un autre onglet —
// miroir du reset d'un `remember` quitté, PAS un rememberSaveable :
// quitter Charts puis revenir réinitialise, comme l'Android).
//
// Un seul objet à la fois : le graphique est un écran unique, le DOM
// précédent est jeté à chaque re-rendu (les closures recaptent `state`,
// qui reste le MÊME objet d'un re-rendu à l'autre quand il est préservé).
let chartUiState = null;
/** Styles visuels des séries (miroir de la map du ChartScreen Android). */ /** Styles visuels des séries (miroir de la map du ChartScreen Android). */
function seriesStyles(model) { function seriesStyles(model) {
if (model === 'ESE') return { e2: { color: COLORS.chartE2, dashed: false }, t: { color: COLORS.chartT, dashed: false } }; if (model === 'ESE') return { e2: { color: COLORS.chartE2, dashed: false }, t: { color: COLORS.chartT, dashed: false } };
@ -52,10 +80,19 @@ function seriesStyles(model) {
/** /**
* Rend l'écran Graphiques dans [container]. * Rend l'écran Graphiques dans [container].
*
* @param {HTMLElement} container * @param {HTMLElement} container
* @param {object} ctx { nowMs } * @param {object} ctx { nowMs }
* @param {object} [opts]
* @param {boolean} [opts.preserveState=false] (v1.6.1) `true` quand on
* re-rend le MÊME écran graphique (tick minute, mutation du store,
* resize — cf app.js renderRoute) : l'état d'interaction [state]
* est réutilisé au lieu d'être recréé aux défauts (miroir Compose :
* `remember` survit aux recompositions). `false` = entrée sur
* l'écran (navigation) : état neuf aux défauts (miroir du reset
* d'un `remember` Android quand on quitte l'écran).
*/ */
export function renderChart(container, ctx) { export function renderChart(container, ctx, { preserveState = false } = {}) {
const treatments = store.getTreatments(); // TOUS (actifs ET inactifs, §6.bis) const treatments = store.getTreatments(); // TOUS (actifs ET inactifs, §6.bis)
const doseLogs = store.getDoseLogs(); const doseLogs = store.getDoseLogs();
const labResults = store.getLabResults(); const labResults = store.getLabResults();
@ -65,20 +102,39 @@ export function renderChart(container, ctx) {
const chartTimeZone = settings.chartTimezone || null; const chartTimeZone = settings.chartTimezone || null;
// ── État d'interaction (miroir des mutableStateOf Compose) ──────────────── // ── État d'interaction (miroir des mutableStateOf Compose) ────────────────
const state = { // v1.6.1 : réutilisé entre les re-rendus du même écran (cf bloc
rangeHours: 24, // « État d'interaction » au-dessus — sans ça, le tick minute de app.js
panHours: 0, // réinitialisait toutes les options toutes les 60 s).
showT: true, if (!preserveState || !chartUiState) {
showLabs: true, chartUiState = {
showEse: true, rangeHours: 24,
showTfs: true, panHours: 0,
showWhs: false, showT: true,
showForecast: false, showLabs: true,
showExtrema: false, showEse: true,
showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0) showTfs: true,
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62) showWhs: false,
togglesInitialized: false, showForecast: false,
}; showExtrema: false,
showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0)
// v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du
// dernier lab constant × modèle, bornée par l'extinction du modèle —
// cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la
// partie prolongée est une ESTIMATION non ancrée, elle doit être
// demandée explicitement ; le chip n'est actif que si Lab track est ON
// (miroir ChartScreen.kt).
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)
togglesInitialized: false,
};
}
const state = chartUiState;
// ── Colonne racine ─────────────────────────────────────────────────────── // ── Colonne racine ───────────────────────────────────────────────────────
const col = el('div', {}); const col = el('div', {});
@ -115,7 +171,10 @@ export function renderChart(container, ctx) {
} }
// ── 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;
@ -203,23 +262,64 @@ export function renderChart(container, ctx) {
// 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)
modelRow.appendChild(filterChip(t('show_lab_track'), state.showLabTrack, () => { state.showLabTrack = !state.showLabTrack; redraw(); })); modelRow.appendChild(filterChip(t('show_lab_track'), state.showLabTrack, () => { state.showLabTrack = !state.showLabTrack; redraw(); }));
// v1.6.0 : prolongation du tracé labs au-delà du dernier lab (off par
// défaut, SANS EFFET — donc désactivé — tant que Lab track est off :
// 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));
// 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 = [];
// ── v1.5.0 : courbe « Tracé labs » ─────────────────────────────────────── // ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────────────
// Indépendante des toggles modèles (modèle STOCKÉ — sémantique Home) et // Indépendante des toggles modèles (modèle STOCKÉ — sémantique Home) et
// de la calibration (elle EST sa propre calibration continue — épinglé). // de la calibration (elle EST sa propre calibration continue — épinglé).
//
// ── v1.6.0 : split ancré / prolongé ──────────────────────────────────────
// Avec « Prolonger », la série est SPLITTÉE au dernier lab significatif
// (lastAnchorMs) : "LAB" = partie ancrée (passe par les labs), "LABX" =
// partie prolongée (M(t) × ρ_last — une estimation). Deux traits
// distincts (LABX en atténué) préservent l'honnêteté de l'affichage.
// ⚠️ 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
// 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 anchored = computeLabAnchoredCurve(
treatments, doseLogs, labResults, startMs, endMs, step, null, treatments, doseLogs, labResults, startMs, endMs, step, null,
state.showLabTrackExtend,
); );
curves.push(['LAB', anchored.points]); if (state.showLabTrackExtend && labCurve.lastAnchorMs !== null) {
const anchored = labCurve.points.filter((p) => p.timestamp <= labCurve.lastAnchorMs);
const extended = labCurve.points.filter((p) => p.timestamp > labCurve.lastAnchorMs);
curves.push(['LAB', anchored]);
if (extended.length > 0) curves.push(['LABX', extended]);
} else {
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
@ -236,7 +336,6 @@ export function renderChart(container, ctx) {
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(
@ -255,12 +354,20 @@ export function renderChart(container, ctx) {
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
@ -283,11 +390,31 @@ export function renderChart(container, ctx) {
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)
return { points: pts, e2Style: { color: COLORS.chartLabTrajectory, dashed: true }, tStyle: null, showExtrema: false }; return { points: pts, e2Style: { color: COLORS.chartLabTrajectory, dashed: true }, tStyle: null, showExtrema: false };
} }
if (model === 'LABX') {
// v1.6.0 : partie PROLONGÉE — même couleur ATTÉNUÉE (alpha 0,55,
// = #c2185b en rgba) pour distinguer l'estimation non ancrée de
// la partie qui passe par les labs. Mêmes exclusions (pas de T,
// pas d'extrema).
return {
points: pts,
e2Style: { color: 'rgba(194,24,91,0.55)', dashed: true },
tStyle: null,
showExtrema: false,
};
}
const st = seriesStyles(model); const st = seriesStyles(model);
return { points: pts, e2Style: st.e2, tStyle: st.t }; return { points: pts, e2Style: st.e2, tStyle: st.t };
}), }),
@ -310,9 +437,10 @@ export function renderChart(container, ctx) {
// Légende dynamique (miroir du bloc Compose) // Légende dynamique (miroir du bloc Compose)
for (const [model] of curves) { for (const [model] of curves) {
// ⚠️ La clé 'LAB' est SKIPPÉE (légende dédiée plus bas, fix #63 // ⚠️ Les clés 'LAB' (v1.5.0) et 'LABX' (v1.6.0) sont SKIPPÉES
// Android-miroir : sinon le `else` imprime la légende TFS en doublon) // (légendes dédiées plus bas, fix #63 Android-miroir : sinon le
if (model === 'LAB') continue; // catchall imprime la légende TFS en doublon)
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));
@ -335,6 +463,22 @@ export function renderChart(container, ctx) {
if (state.showLabTrack && curves.some(([m, pts]) => m === 'LAB' && pts.length >= 2)) { if (state.showLabTrack && curves.some(([m, pts]) => m === 'LAB' && pts.length >= 2)) {
legend.appendChild(legendLine(t('legend_lab_track'), COLORS.chartLabTrajectory)); legend.appendChild(legendLine(t('legend_lab_track'), COLORS.chartLabTrajectory));
} }
// v1.6.0 : légende de la partie PROLONGÉE + AVERTISSEMENT — seulement
// 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) :
// 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
&& curves.some(([m, pts]) => m === 'LABX' && pts.length >= 2);
if (labExtendVisible) {
legend.appendChild(legendLine(t('legend_lab_track_extend'), 'rgba(194,24,91,0.55)'));
legend.appendChild(el('div', { class: 'legend-muted' }, t('lab_track_extend_warning')));
}
legend.appendChild(el('div', { class: 'legend-muted' }, t('drag_hint'))); legend.appendChild(el('div', { class: 'legend-muted' }, t('drag_hint')));
} }

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.5.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,7 +194,24 @@ 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,
// actif seulement quand Lab track est ON — miroir strings.xml Android)
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.',
back_to_present: 'Back to present', back_to_present: 'Back to present',
legend_ese_e2: 'E2 — Estrannaise (blue)', legend_ese_e2: 'E2 — Estrannaise (blue)',
legend_ese_t: 'T — Estrannaise (pink)', legend_ese_t: 'T — Estrannaise (pink)',
@ -193,6 +223,8 @@ const EN = {
legend_forecast_dose: 'Dashed line + ▼ = projected dose', legend_forecast_dose: 'Dashed line + ▼ = projected dose',
legend_extrema: '▲▼ = local peaks & troughs (with estimated values)', legend_extrema: '▲▼ = local peaks & troughs (with estimated values)',
legend_lab_track: 'Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)', legend_lab_track: 'Lab-anchored curve — model shape forced through your labs (E2 only, between first and last lab)',
// v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX")
legend_lab_track_extend: '-- Lab track extended past your last lab (model × last lab ratio — estimate, not anchored)',
drag_hint: 'Drag horizontally to pan (right = past, left = future with forecast). Pinch or − / + to zoom (6 h → 300 d).', drag_hint: 'Drag horizontally to pan (right = past, left = future with forecast). Pinch or − / + to zoom (6 h → 300 d).',
// Rappels (bannière + notification) // Rappels (bannière + notification)
reminder_notif_title: 'HormoneTrack — dose reminder', reminder_notif_title: 'HormoneTrack — dose reminder',
@ -233,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',
@ -251,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)',
@ -359,7 +404,22 @@ 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,
// actif seulement quand Tracé labs est ON — miroir strings.xml Android)
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.',
back_to_present: 'Revenir à maintenant', back_to_present: 'Revenir à maintenant',
legend_ese_e2: 'E2 — Estrannaise (bleu)', legend_ese_e2: 'E2 — Estrannaise (bleu)',
legend_ese_t: 'T — Estrannaise (rose)', legend_ese_t: 'T — Estrannaise (rose)',
@ -371,6 +431,8 @@ const FR = {
legend_forecast_dose: 'Ligne pointillée + ▼ = dose projetée', legend_forecast_dose: 'Ligne pointillée + ▼ = dose projetée',
legend_extrema: '▲▼ = pics/creux locaux (avec valeurs estimées)', legend_extrema: '▲▼ = pics/creux locaux (avec valeurs estimées)',
legend_lab_track: 'E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab)', legend_lab_track: 'E2 ancrée sur tes labs — forme du modèle forcée de passer par chaque prise de sang (entre le 1er et le dernier lab)',
// v1.6.0 : légende de la partie PROLONGÉE du tracé labs (série "LABX")
legend_lab_track_extend: '-- Tracé labs prolongé après ton dernier lab (modèle × ratio du dernier lab — estimation, plus ancrée)',
drag_hint: 'Glisser horizontalement pour panoramique (droite = passé, gauche = futur avec la prévision). Pincer ou − / + pour zoomer (6 h → 300 j).', drag_hint: 'Glisser horizontalement pour panoramique (droite = passé, gauche = futur avec la prévision). Pincer ou − / + pour zoomer (6 h → 300 j).',
reminder_notif_title: 'HormoneTrack — rappel de dose', reminder_notif_title: 'HormoneTrack — rappel de dose',
reminder_notif_body: '{0} · {1}{2} — prévu à {3}', reminder_notif_body: '{0} · {1}{2} — prévu à {3}',
@ -385,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',
@ -410,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

@ -63,6 +63,44 @@ if ! node scripts/i18n-check.mjs; then
fail=1 fail=1
fi fi
echo "── 3.bis Garde de PARITÉ du portage Android ↔ web (soft) ──"
# v1.6.0 (leçon de la session « Prolonger ») : le web est un PORTAGE fidèle
# de l'Android — quand les deux dépôts sont côte à côte (layout standard,
# cf docs/DEVELOPPEMENT.md §3), on vérifie que les symboles CLÉS des
# dernières features partagées existent des DEUX côtés. Attrape le portage
# incomplet (ex. modèle porté mais pas l'UI, ou clés i18n Android sans
# miroir web). Soft : si le dépôt Android voisin est absent (clone web
# isolé, CI), la garde est ignorée avec un message.
ANDROID_DIR="$(dirname "$PWD")/HormoneTrack"
if [[ -d "$ANDROID_DIR" ]]; then
parity=1
check_pair() { # <desc> <fichier-android:pattern> <fichier-web:pattern>
local desc="$1" andro="$2" web="$3"
local af="${andro%%:*}" ap="${andro#*:}"
local wf="${web%%:*}" wp="${web#*:}"
if grep -qE "$ap" "$ANDROID_DIR/$af" 2>/dev/null \
&& grep -qE "$wp" "$wf" 2>/dev/null; then
echo " ✓ $desc"
else
echo "✗ PARITÉ rompue : $desc (Android: $ANDROID_DIR/$af =~ $ap ; web: $wf =~ $wp)"
parity=0
fi
}
# Le modèle de prolongation doit exister des deux côtés (v1.6.0)
check_pair "prolongation tracé labs : moteur" \
"app/src/main/java/com/hormonetrack/pk/LabTrajectoryModel.kt:extensionHorizonEndMs" \
"js/pk/lab-trajectory-model.js:extensionHorizonEndMs"
check_pair "prolongation tracé labs : UI (chip)" \
"app/src/main/res/values/strings.xml:show_lab_track_extend" \
"js/util/i18n.js:show_lab_track_extend"
check_pair "prolongation tracé labs : avertissement" \
"app/src/main/res/values/strings.xml:lab_track_extend_warning" \
"js/util/i18n.js:lab_track_extend_warning"
[[ $parity -eq 1 ]] || fail=1
else
echo " (ignoré : dépôt Android voisin absent — $ANDROID_DIR)"
fi
echo "── 4. Tests unitaires ──" echo "── 4. Tests unitaires ──"
if ! node --test tests/ 2>&1 | tail -6; then if ! node --test tests/ 2>&1 | tail -6; then
fail=1 fail=1

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.5.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');
@ -167,6 +171,132 @@ async function main() {
ok(legendText.includes('Glisser horizontalement'), 'hint panoramique'); ok(legendText.includes('Glisser horizontalement'), 'hint panoramique');
await page.screenshot({ path: join(SHOTS, 'e2e-chart.png') }); await page.screenshot({ path: join(SHOTS, 'e2e-chart.png') });
// ── 2.bis Tracé labs PROLONGÉ (v1.6.0) : chips, légende, avertissement ──
// La démo place le dernier lab à now − 7 j : on dézoome (24 h → 32 j)
// pour que la partie ANCRÉE soit dans la fenêtre, puis on active
// « Tracé labs » puis « Prolonger ». ⚠️ Chaque interaction re-rend le
// DOM (redraw) → re-chercher le chip avant CHAQUE clic. ⚠️ Les chips
// filterChip sont des <span class="chip"> (onclick inline), PAS des
// <button> — seuls − / + sont de vrais boutons ; « disabled » est
// simulé par style opacity:0.4 (pas d'attribut disabled).
console.log('── Tracé labs prolongé (#chart, v1.6.0) ──');
for (let i = 0; i < 5; i++) {
await page.evaluate(() => {
[...document.querySelectorAll('button.chip')].find((x) => x.textContent.trim() === '−')?.click();
});
}
await page.waitForTimeout(400);
// 1) Le chip « Prolonger » est DÉSACTIVÉ tant que « Tracé labs » est off
const chipDisabled = (lbl) => page.evaluate((label) => {
const b = [...document.querySelectorAll('.chip')].find((x) => x.textContent.trim() === label);
return b ? /opacity:\s*0\.4/.test(b.getAttribute('style') || '') : null;
}, 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');
// 2) Active Tracé labs → la légende ancrée apparaît (sans avertissement)
await clickChip('Tracé labs');
await page.waitForTimeout(400);
await page.waitForTimeout(400);
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('Simple simulation, sans garantie'),
'pas d\'avertissement tant que la prolongation est off');
// 3) Active « Prolonger » → légende prolongée + AVERTISSEMENT VISIBLE
await clickChip('Prolonger');
await page.waitForTimeout(400);
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('Simple simulation, sans garantie'),
'AVERTISSEMENT « simulation sans garantie » visible');
ok(extendText.includes('qui peuvent eux-mêmes être erronés'),
'avertissement : les résultats de lab peuvent être erronés');
// 4) Désactive « Prolonger » → avertissement ET légende prolongée disparaissent
await clickChip('Prolonger');
await page.waitForTimeout(400);
const offText = await page.textContent('main.content');
ok(!offText.includes('Tracé labs prolongé') && !offText.includes('Simple simulation, sans garantie'),
'désactivation → légende prolongée + avertissement disparaissent');
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) ───────────────────
// Bug remonté : les options se réinitialisaient « toutes seules » car le
// tick minute / une mutation du store / un resize re-créaient l'écran
// ENTIER (renderRoute) et le state local de renderChart retombait aux
// défauts. Fix : état au niveau module + preserveState du même écran.
// Ici on déclenche le re-render via RESIZE (même code path que le tick :
// store.subscribe et setInterval appellent tous renderRoute) — le tick
// réel de 60 s est trop lent pour un E2E.
console.log('── Persistance des options du graphique (#chart, v1.6.1) ──');
// « Tracé labs » est ON à ce stade (scénario 2.bis) — vérifions avant :
const selectedBefore = await page.evaluate(() =>
[...document.querySelectorAll('.chip')]
.some((x) => x.textContent.trim() === 'Tracé labs' && x.classList.contains('selected')));
ok(selectedBefore, 'pré-état : chip « Tracé labs » activé');
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
await page.waitForTimeout(500);
const stateAfterResize = await page.evaluate(() => ({
labTrackSelected: [...document.querySelectorAll('.chip')]
.some((x) => x.textContent.trim() === 'Tracé labs' && x.classList.contains('selected')),
legend: document.querySelector('main.content').textContent.includes('E2 ancrée sur tes labs'),
}));
ok(stateAfterResize.labTrackSelected,
'resize → le chip « Tracé labs » reste sélectionné (état préservé, bug v1.6.1 corrigé)');
ok(stateAfterResize.legend,
'resize → la légende « Tracé labs » reste affichée (le re-rendu n\'a pas tout remis à zéro)');
// Reset à l'ENTRÉE (navigation) : quitter puis revenir → défauts,
// miroir du reset d'un `remember` Android quand on quitte l'écran
await page.goto(`${base}/#home`, { waitUntil: 'load' });
await page.waitForSelector('body[data-ready="1"]');
await page.goto(`${base}/#chart`, { waitUntil: 'load' });
await page.waitForSelector('body[data-ready="1"]');
await page.waitForTimeout(400);
const stateAfterNav = await page.evaluate(() => ({
labTrackSelected: [...document.querySelectorAll('.chip')]
.some((x) => x.textContent.trim() === 'Tracé labs' && x.classList.contains('selected')),
}));
ok(stateAfterNav.labTrackSelected === false,
'navigation (quitter → revenir) → options retombées aux défauts (miroir Android)');
// ── 3. Navigation : les 5 onglets + settings ──────────────────────────── // ── 3. Navigation : les 5 onglets + settings ────────────────────────────
console.log('── Navigation ──'); console.log('── Navigation ──');
await page.goto(`${base}/#doses`, { waitUntil: 'load' }); await page.goto(`${base}/#doses`, { waitUntil: 'load' });
@ -181,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);
@ -192,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.5.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

@ -1,7 +1,9 @@
/** /**
* Tests « Tracé labs » web (miroir de `LabTrajectoryModelTest.kt` Android) * Tests « Tracé labs » web (miroir de `LabTrajectoryModelTest.kt` Android)
* — mêmes garanties épinglées : passage exact, ρ log-linéaire, garde #61, * — mêmes garanties épinglées : passage exact, ρ log-linéaire, garde #61,
* fenêtre bornée aux labs, indépendance du scaleFactor. * fenêtre bornée aux labs, indépendance du scaleFactor. v1.6.0 :
* PROLONGATION au-delà du dernier lab (ρ constant, horizon = cutoff du
* traitement, doses après le dernier lab, garde #61, flag off = v1.5.0).
*/ */
import { test, describe, before } from 'node:test'; import { test, describe, before } from 'node:test';
@ -9,9 +11,9 @@ import assert from 'node:assert/strict';
import { import {
initProfiles, makeTreatment, makeDose, makeLab, HOUR_MS, DAY_MS, assertClose, initProfiles, makeTreatment, makeDose, makeLab, HOUR_MS, DAY_MS, assertClose,
} from './helpers.js'; } from './helpers.js';
import { e2At } from '../js/pk/pk-engine.js'; import { e2At, cutoffHours } from '../js/pk/pk-engine.js';
import { import {
computeLabAnchoredCurve, ratioAt, computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs,
} from '../js/pk/lab-trajectory-model.js'; } from '../js/pk/lab-trajectory-model.js';
/** Traitement injectable EEn/TFS 5 mg, une dose à t=0, scaleFactor paramétrable. */ /** Traitement injectable EEn/TFS 5 mg, une dose à t=0, scaleFactor paramétrable. */
function treatment(scaleFactor = 1.0) { function treatment(scaleFactor = 1.0) {
@ -138,3 +140,120 @@ describe('LabTrajectoryModel web (miroir Android v1.5.0)', () => {
assertClose(ratioAt(anchors, 100), 2.0, 1e-12); assertClose(ratioAt(anchors, 100), 2.0, 1e-12);
}); });
}); });
// ── Prolongation au-delà du dernier lab (v1.6.0 — miroir Android) ───────────
// Garanties (cf doc web §7.10.bis / KDoc du module) :
// - au-delà du dernier lab : courbe(t) = M(t) × ρ_last (ρ CONSTANT) ;
// - continuité au point de suture (le dernier lab reste exact) ;
// - horizon = dernière dose E2 + cutoff de son traitement (JAMAIS au-delà) ;
// - une dose loguée après le dernier lab (autre ester compris) refait
// monter la courbe via M(t) — « dosage + ester + évolution classique » ;
// - dernier lab non significatif : ni ancre ni départ de prolongation (#61) ;
// - flag OFF (défaut) = comportement v1.5.0 bit-compatible.
describe('LabTrajectoryModel prolongation (miroir Android v1.6.0)', () => {
before(() => initProfiles());
/** Labs DATA-DRIVEN ancrés à 2 j (ρ=0,9) et 7 j (ρ=1,2) sur la fixture EEn/TFS. */
function anchoredLabs(tr, ds) {
const m2 = e2At([tr], ds, 2 * DAY_MS, null, null);
const m7 = e2At([tr], ds, 7 * DAY_MS, null, null);
return [lab(0.9 * m2, 2 * DAY_MS), lab(1.2 * m7, 7 * DAY_MS)];
}
const RHO_LAST = 1.2;
test('extension - la courbe continue après le dernier lab avec le rho du dernier lab', () => {
const tr = treatment();
const ds = doses();
const curve = computeLabAnchoredCurve([tr], ds, anchoredLabs(tr, ds), 0, 60 * DAY_MS, HOUR_MS, null, true);
assert.ok(curve.points.some((p) => p.timestamp > 7 * DAY_MS), 'prolongation présente');
// Suture : le dernier lab reste EXACT (continuité par construction)
const m7 = e2At([{ ...tr, scaleFactor: 1.0 }], ds, 7 * DAY_MS, null, null);
assertClose(curve.points.find((p) => p.timestamp === 7 * DAY_MS).e2, 1.2 * m7, 1e-6,
'le point au dernier lab vaut toujours le lab (1,2 × M)');
// Identité de la zone extrapolée : courbe(t) = M(t) × ρ_last, exact
for (const day of [10, 20, 30]) {
const t = day * DAY_MS;
const p = curve.points.find((q) => q.timestamp === t);
const m = e2At([{ ...tr, scaleFactor: 1.0 }], ds, t, null, null);
assertClose(p.e2, m * RHO_LAST, 1e-9, `courbe(${day} j) == M × ρ_last`);
}
});
test('extension - horizon borné par le cutoff de la dernière dose', () => {
const tr = treatment();
const curve = computeLabAnchoredCurve([tr], doses(), anchoredLabs(tr, doses()), 0, 90 * DAY_MS, HOUR_MS, null, true);
const horizonEnd = extensionHorizonEndMs([tr], doses(), 7 * DAY_MS);
assert.ok(horizonEnd > 7 * DAY_MS, 'l\'horizon EEn/TFS dépasse le dernier lab (sinon le test ne prouve rien)');
assert.ok(curve.points.every((p) => p.timestamp <= horizonEnd), 'aucun point au-delà de l\'extinction du modèle');
const last = curve.points[curve.points.length - 1].timestamp;
assert.ok(last <= horizonEnd && last + HOUR_MS > horizonEnd, 'le dernier point s\'arrête AU cutoff (pas au-delà d\'un pas)');
});
test('extension - une dose après le dernier lab refait monter la courbe (dose et ester)', () => {
const tr = treatment();
const ds = doses().concat([makeDose(10 * DAY_MS, 4, { esterType: 'EV' })]);
const curve = computeLabAnchoredCurve([tr], ds, anchoredLabs(tr, doses()), 0, 20 * DAY_MS, HOUR_MS, null, true);
const extension = curve.points.filter((p) => p.timestamp > 7 * DAY_MS);
assert.ok(extension.length > 0);
// L'identité vaut AUSSI avec la dose EV : M(t) inclut les deux doses
const t12 = 12 * DAY_MS;
const m12 = e2At([{ ...tr, scaleFactor: 1.0 }], ds, t12, null, null);
assertClose(extension.find((p) => p.timestamp === t12).e2, m12 * RHO_LAST, 1e-9);
// La FORME suit l'injection EV : montée nette après le 10 j (Tmax ~45 h)
const at8 = extension.find((p) => p.timestamp === 8 * DAY_MS).e2;
const peak = Math.max(...extension
.filter((p) => p.timestamp >= 10 * DAY_MS && p.timestamp <= 14 * DAY_MS)
.map((p) => p.e2));
assert.ok(peak > at8 * 1.3, `le pic EV (${peak}) doit dépasser le niveau pré-injection (${at8})`);
});
test('extension désactivée - rien après le dernier lab (comportement initial)', () => {
const tr = treatment();
const labs = anchoredLabs(tr, doses());
const byDefault = computeLabAnchoredCurve([tr], doses(), labs, 0, 60 * DAY_MS, HOUR_MS);
const explicitOff = computeLabAnchoredCurve([tr], doses(), labs, 0, 60 * DAY_MS, HOUR_MS, null, false);
assert.deepEqual(byDefault.points, explicitOff.points);
assert.ok(byDefault.points.every((p) => p.timestamp <= 7 * DAY_MS));
assert.equal(byDefault.lastAnchorMs, 7 * DAY_MS, 'lastAnchorMs positionné même sans prolongation');
});
test('extension - un lab non significatif ne prolonge pas (garde 61)', () => {
const tr = treatment();
const labs = anchoredLabs(tr, doses()).concat([lab(3000, 60 * DAY_MS)]);
const curve = computeLabAnchoredCurve([tr], doses(), labs, 0, 90 * DAY_MS, HOUR_MS, null, true);
assert.equal(curve.anchoredLabs, 2, 'le lab tardif reste rejeté');
assert.equal(curve.lastAnchorMs, 7 * DAY_MS, 'la prolongation part du dernier lab SIGNIFICATIF');
assert.ok(curve.points.some((p) => p.timestamp > 7 * DAY_MS), 'des points prolongés existent après le dernier ancre');
assert.ok(curve.points.every((p) => p.e2 <= 600), 'aucun point ne suit la valeur absurde du lab rejeté');
});
test('extension - demande finissant avant le dernier lab ne prolonge rien', () => {
const tr = treatment();
const curve = computeLabAnchoredCurve([tr], doses(), anchoredLabs(tr, doses()), 0, 5 * DAY_MS, HOUR_MS, null, true);
assert.ok(curve.points.every((p) => p.timestamp <= 5 * DAY_MS));
assert.ok(curve.points.every((p) => p.timestamp <= 7 * DAY_MS));
});
test('extensionHorizonEndMs - gardes (aucune dose ou modèle déjà éteint)', () => {
const tr = treatment();
const trs = [tr];
assert.equal(extensionHorizonEndMs(trs, [], 7 * DAY_MS), null, 'aucune dose E2 → rien à prolonger');
// Dose à t=0 → horizon = cutoff du traitement (EEn TFS : 10 t½ term.)
const expected = cutoffHours(tr) * HOUR_MS;
assert.equal(extensionHorizonEndMs(trs, doses(), 7 * DAY_MS), expected);
// Modèle DÉJÀ éteint au dernier lab → null (retour fenêtre initiale)
assert.equal(extensionHorizonEndMs(trs, doses(), expected + HOUR_MS), null);
});
test('lastAnchorMs - null si moins de 2 ancres, positionné sinon', () => {
const tr = treatment();
const one = computeLabAnchoredCurve([tr], doses(), [lab(200, 3 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS);
assert.equal(one.points.length, 0);
assert.equal(one.lastAnchorMs, null);
const two = computeLabAnchoredCurve([tr], doses(), [lab(300, 2 * DAY_MS), lab(260, 7 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS);
assert.equal(two.lastAnchorMs, 7 * DAY_MS);
});
});

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');
});
});