Compare commits
No commits in common. "main" and "v1.4.10" have entirely different histories.
@ -1,27 +0,0 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# .dockerignore — ce qui n'entre PAS dans l'image de déploiement.
|
||||
#
|
||||
# L'image ne sert que le RUNTIME : app (index.html, css/, js/, assets/),
|
||||
# docs/ (⚠️ requis — le dialog « Nouveautés » fetch docs/CHANGELOG.md),
|
||||
# LICENSE/README. Tout le reste est exclu :
|
||||
# - .git, node_modules, package-lock.json : jamais dans une image ;
|
||||
# - tests/, scripts/ : outils de dev — inutiles au runtime (et l'image
|
||||
# devient plus propre à auditer : `docker run … find /usr/share/nginx/html`);
|
||||
# - local-test-data/ : DOUBLON DE SÉCURITÉ — exports de santé réels jamais
|
||||
# versionnés (cf docs/DEVELOPPEMENT.md §3) ; s'ils existaient par erreur
|
||||
# à la racine du contexte de build, ils n'iraient JAMAIS dans l'image ;
|
||||
# - fichiers de config du conteneur exclus aussi (Dockerfile/compose sont
|
||||
# des instructions, pas des fichiers à servir).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
node_modules
|
||||
package-lock.json
|
||||
tests
|
||||
scripts
|
||||
local-test-data
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
4
.gitattributes
vendored
4
.gitattributes
vendored
@ -1,4 +0,0 @@
|
||||
# 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
|
||||
41
Dockerfile
41
Dockerfile
@ -1,41 +0,0 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# HormoneTrack Web — image de déploiement Docker (site statique, 100 % local).
|
||||
#
|
||||
# POURQUOI C'EST SIMPLE : l'app n'a AUCUN build (ES modules natifs) et AUCUN
|
||||
# composant serveur — « conteneuriser » = servir les fichiers avec nginx.
|
||||
# Le conteneur ne détient AUCUNE donnée : tout vit dans le localStorage des
|
||||
# navigateurs clients (le serveur ne fait que distribuer des fichiers, cf
|
||||
# docs/DEVELOPPEMENT.md §10 — la vie privée est inchangée).
|
||||
#
|
||||
# Choix d'image : `nginx-unprivileged` (utilisateur 101, port 8080, écritures
|
||||
# limitées à /tmp) — meilleure pratique conteneur (pas de root), même image
|
||||
# que pour un self-host NAS/VPS. Alpine = ~50 Mo décompressé.
|
||||
#
|
||||
# Build : docker build -t hormonetrack-web .
|
||||
# Run : docker run -p 8080:8080 hormonetrack-web (ou docker compose up -d)
|
||||
# Cf docs/DEVELOPPEMENT.md §10 « Déploiement ».
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
|
||||
# ── Métadonnées OCI (traçabilité du conteneur déployé) ───────────────────────
|
||||
LABEL org.opencontainers.image.title="HormoneTrack Web" \
|
||||
org.opencontainers.image.description="Suivi de thérapie hormonale — app web 100 % locale (site statique). Portage navigateur de l'app Android." \
|
||||
org.opencontainers.image.source="https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web" \
|
||||
org.opencontainers.image.licenses="GPL-3.0"
|
||||
|
||||
# ── Config nginx spécifique (port non-privilégié, no-cache, gzip, sécurité) ──
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# ── Fichiers du site ────────────────────────────────────────────────────────
|
||||
# Tout le dépôt SAUF ce qui n'est pas runtime (tests, scripts, npm) — cf
|
||||
# .dockerignore. ⚠️ docs/ DOIT rester dans l'image : le dialog « Nouveautés »
|
||||
# fetch docs/CHANGELOG.md au démarrage.
|
||||
COPY . /usr/share/nginx/html/
|
||||
|
||||
# Le site est statique : le healthcheck synthétique vérifie que nginx répond
|
||||
# (busybox wget est présent dans l'image alpine).
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8080/ || exit 1
|
||||
|
||||
# Port non privilégié (l'image unprivileged n'a pas le droit de binde <1024)
|
||||
EXPOSE 8080
|
||||
48
README.md
48
README.md
@ -17,19 +17,9 @@
|
||||
> Fie-toi toujours à tes analyses de sang et aux consignes de ton
|
||||
> endocrinologue.
|
||||
|
||||
- **Statut** : web v1.13.0 — parité fonctionnelle avec l'Android v1.13.0
|
||||
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 ✅
|
||||
- **Statut** : web v1.4.10 — portage de l'Android v1.4.10 · **121 tests verts** ·
|
||||
E2E navigateur ✅ · lint/i18n ✅
|
||||
- **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)
|
||||
(zip de déploiement statique vérifié par téléchargement)
|
||||
- **Versionnage** : les versions web sont **alignées sur l'Android porté**
|
||||
(web v1.4.10 = toutes les fonctionnalités de l'Android v1.4.10, sauf les
|
||||
impossibilités structurelles du navigateur) — cf
|
||||
@ -67,36 +57,6 @@ server {
|
||||
}
|
||||
```
|
||||
|
||||
### Avec Docker (self-host NAS/VPS)
|
||||
|
||||
L'image ne fait **que servir les fichiers** : aucune donnée dedans, la vie
|
||||
privée est identique (les données vivent dans les navigateurs). Deux
|
||||
procédures équivalentes — **l'image doit être pour l'arch CPU de l'hôte**
|
||||
(« exec format error » = arch incompatible, cf
|
||||
[docs/DEVELOPPEMENT.md §10](docs/DEVELOPPEMENT.md)) :
|
||||
|
||||
```bash
|
||||
# ── Option 1 (recommandée) : build SUR le serveur cible
|
||||
git clone https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web && cd HormoneTrack-web
|
||||
docker compose up -d --build # arch native, toujours correcte
|
||||
|
||||
# ── Option 2 : multi-arch depuis un Mac (push vers ton registry)
|
||||
docker buildx build --platform linux/amd64,linux/arm64 \
|
||||
-t tonuser/hormonetrack-web:latest --push .
|
||||
```
|
||||
|
||||
- Image **non privilégiée** (`nginx-unprivileged`, utilisateur 101, port
|
||||
8080), filesystem en **lecture seule** + tmpfs (composé durci), vérif
|
||||
healthcheck intégrée, gzip sur l'asset PK (550 Ko → ~150 Ko).
|
||||
- Headers `no-cache` : une mise à jour d'image est prise en compte au
|
||||
rechargement suivant, sans code périmé chez les clients.
|
||||
- Validation en 3 commandes sur l'hôte qui a Docker (cf
|
||||
[docs/DEVELOPPEMENT.md §10](docs/DEVELOPPEMENT.md)) : build, run, puis
|
||||
`HRT_E2E_BASE=http://127.0.0.1:8080 node scripts/e2e.mjs` — le même test
|
||||
E2E que le CI pilote le site SERVI PAR LE CONTENEUR. Pour tester l'arch
|
||||
cible AVANT déploiement :
|
||||
`bash scripts/container-test.sh 8979 linux/amd64`.
|
||||
|
||||
Aucun build, aucune variable d'environnement, aucun composant serveur : les
|
||||
données de chaque personne vivent **dans SON navigateur**, jamais sur la
|
||||
machine qui sert les fichiers.
|
||||
@ -121,8 +81,6 @@ machine qui sert les fichiers.
|
||||
(auto-calibration optionnelle, désactivée par défaut + bouton manuel).
|
||||
- **Seuils d'alerte** configurables (E2/T haut/bas) avec notification
|
||||
navigateur et anti-spam.
|
||||
- **« Tracé labs » (v1.5.0)** : courbe hybride ancrée sur tes labs
|
||||
(chip `Tracé labs`, off par défaut) — miroir strict de l'Android v1.5.0
|
||||
- **Rappels** via les notifications du navigateur (actions « Loguer
|
||||
maintenant » / « Reporter 1 h ») — tant que la page est ouverte, cf
|
||||
[limites](#limites-vs-lapp-android).
|
||||
@ -190,7 +148,7 @@ in-app s'affiche quand même à l'heure du rappel, page ouverte).
|
||||
## Doc de développement
|
||||
|
||||
Architecture, portage fichier par fichier, format du backup, processus de
|
||||
test (132 tests Node + E2E navigateur), processus de push et pièges connus :
|
||||
test (121 tests Node + E2E navigateur), processus de push et pièges connus :
|
||||
**[docs/DEVELOPPEMENT.md](docs/DEVELOPPEMENT.md)**.
|
||||
|
||||
## Licence
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,34 +0,0 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# docker-compose — un commandement pour tourner HormoneTrack Web.
|
||||
#
|
||||
# docker compose up -d → http://<hôte>:8080/
|
||||
# docker compose up -d --build (rebuild après mise à jour du dépôt)
|
||||
#
|
||||
# Le conteneur ne détient AUCUNE donnée (site statique) : le volume n'est
|
||||
# pas nécessaire, la persistance vit dans les navigateurs. Pas de variable
|
||||
# d'environnement, pas de volume — la config = le code servi.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
# Nom d'image stable : retaguer par version à chaque release sync si
|
||||
# tu veux garder un historique (docker tag hormonetrack-web hormonetrack-web:vX.Y.Z)
|
||||
image: hormonetrack-web:latest
|
||||
container_name: hormonetrack-web
|
||||
ports:
|
||||
- "8080:8080" # port non privilégié de l'image unprivileged nginx
|
||||
restart: unless-stopped
|
||||
|
||||
# ── Durcissement (site statique : AUCUNE écriture nécessaire) ───────────
|
||||
# Filesystem en lecture seule + tmpfs pour les seuls dirs que nginx
|
||||
# touche : l'image unprivileged place ses temporaires dans /tmp
|
||||
# (proxy_temp, client_temp, pid) — sans ce tmpfs, nginx émerge
|
||||
# « mkdir /tmp/proxy_temp failed (30: Read-only file system) » au
|
||||
# démarrage avec read_only (constaté au premier container-test 8 sept.).
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /var/cache/nginx
|
||||
- /run
|
||||
- /tmp
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@ -5,389 +5,6 @@
|
||||
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog
|
||||
> « 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
|
||||
|
||||
### « Tracé labs » — portage de la version Android v1.5.0 (versions synchronisées)
|
||||
|
||||
- **Nouvelle courbe superposable** (chip `Tracé labs`, **off par défaut**)
|
||||
: `courbe(t) = M(t) × ρ(t)` — le modèle PK garde la forme biologique
|
||||
(Tmax, queues, accumulation), l'amplitude est recalée sur CHAQUE
|
||||
prise de sang — **passage EXACT garanti** sur chaque lab, transition
|
||||
log-linéaire d'un lab au suivant (jamais de saut vertical).
|
||||
- Garde anti-ratés (#61) : un lab hors fenêtre d'action n'est pas un
|
||||
ancrage ; E2 uniquement ; fenêtre bornée [1er ; dernier lab] — la
|
||||
courbe s'arrête au dernier lab (pas d'extrapolation).
|
||||
- Indépendante de la calibration (sa propre référence) ; hors
|
||||
pics/creux ; n'entre JAMAIS dans niveau accueil/alertes.
|
||||
- La rangée de chips du graphique défile horizontalement (4ᵉ chip —
|
||||
miroir du fix Android v1.5.0).
|
||||
- 11 tests miroirs de `LabTrajectoryModelTest.kt` (Android).
|
||||
|
||||
## [1.4.10] — 2026-09-08
|
||||
|
||||
Version initiale de la version web — portage navigateur de l'app Android
|
||||
|
||||
@ -60,7 +60,7 @@ Règles du portage, fixées au départ :
|
||||
|
||||
| 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 199 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 121 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 |
|
||||
| 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())`) |
|
||||
@ -140,10 +140,6 @@ HormoneTrack-web/ (dépôt séparé)
|
||||
├── index.html page unique (SPA) — shell construit par app.js
|
||||
├── css/style.css thème (couleurs = Color.kt Android), mobile-first
|
||||
├── 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
|
||||
├── assets/pk_profiles.json COPIE de l'asset Android (tables Estrannaise)
|
||||
├── js/
|
||||
@ -163,15 +159,9 @@ HormoneTrack-web/ (dépôt séparé)
|
||||
│ │ ├── transfem-science-models.js V3C méta-analyse TFS (7 esters, params
|
||||
│ │ │ copiés à l'identique du Kotlin)
|
||||
│ │ ├── whsah-models.js fit WHSAH de Mona (6 esters, params identiques)
|
||||
│ │ ├── 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
|
||||
│ │ de pk/LabTrajectoryModel.kt — garde #61,
|
||||
│ │ ρ 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)
|
||||
│ │ ├── presets.js PKPresets.kt (25 presets — v1.9.4 :
|
||||
│ │ │ les 6 esters injectables ESE)
|
||||
│ │ ├── presets.js PKPresets.kt (22 presets, clés i18n)
|
||||
│ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt :
|
||||
│ │ │ panDeltaHours (#62), xLabelTicks (#55),
|
||||
│ │ │ pointHoursBefore (#53), clampPanHours (#54),
|
||||
@ -225,17 +215,9 @@ HormoneTrack-web/ (dépôt séparé)
|
||||
│ └── CHANGELOG.md journal web (lu par le dialog « Nouveautés »)
|
||||
├── scripts/
|
||||
│ ├── serve.py serveur statique de dev (no-cache, MIME ok)
|
||||
│ ├── 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)
|
||||
│ ├── check.sh vérifications 1→6 (syntaxe, i18n, tests, E2E, HTTP)
|
||||
│ ├── i18n-check.mjs cohérence FR/EN + clés utilisées
|
||||
│ ├── 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)
|
||||
│ └── e2e.mjs E2E playwright (Firefox headless, ?demo=1)
|
||||
├── tests/ ── node --test (miroirs des suites Kotlin) ──
|
||||
│ ├── helpers.js fixtures (makeTreatment/makeDose/makeLab, asset)
|
||||
│ ├── pk-profiles.test.js ← PKProfileStoreTest.kt (pics ODS, casse #22…)
|
||||
@ -244,31 +226,6 @@ HormoneTrack-web/ (dépôt séparé)
|
||||
│ ├── pk-engine.test.js ← PharmacokineticEngineTest.kt (+ #35, §6.bis)
|
||||
│ ├── calibration.test.js ← CalibrationPerModel/ScaleFactorWhsahRepro/
|
||||
│ │ 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
|
||||
│ │ (local-test-data/ si présent, sinon skip)
|
||||
│ ├── chart-helpers.test.js ← ChartZoomTest.kt (#53/#54/#55/#58/#62)
|
||||
@ -295,16 +252,6 @@ HormoneTrack-web/ (dépôt séparé)
|
||||
- **« 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
|
||||
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
|
||||
`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).
|
||||
@ -411,7 +358,7 @@ Pièges rencontrés :
|
||||
|---|---|---|---|
|
||||
| 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) |
|
||||
| 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` |
|
||||
| Unitaires | `node --test` | **121 tests** : noyau PK, calibration, backup, store, helpers, alertes, rappels, changelog | `npm test` |
|
||||
| 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` |
|
||||
|
||||
@ -444,39 +391,6 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
|
||||
- **calibration** : par période d'ester, **par modèle** (#60, chaque courbe
|
||||
calibrée passe par le lab), garde #61 (labs hors fenêtre exclus des DEUX
|
||||
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
|
||||
(passage exact, ρ log-linéaire, garde #61, fenêtres, indépendance du
|
||||
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,
|
||||
**imports des exports Android réels** (data-driven, hors dépôt).
|
||||
- **chart-helpers** : cumul fractionnaire du pan (#62), minuit local (#55),
|
||||
@ -506,21 +420,6 @@ 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 »
|
||||
(markdown rendu), navigation des 5 onglets + Paramètres, **pixels du
|
||||
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 ;
|
||||
- captures dans `/tmp/hrt-web-shots/` (inspection visuelle).
|
||||
- ⚠️ Course au screenshot évitée par le marqueur `body[data-ready="1"]`
|
||||
@ -559,14 +458,10 @@ Règles de code (appliquées partout, à conserver) :
|
||||
Les releases web sont **SYNCHRONISÉES avec les releases APK** (décision du
|
||||
8 sept. 2026) : chaque feature release de l'Android est portée et tagguée
|
||||
au MÊME numéro dans ce dépôt — l'utilisatrice a toujours la parité des
|
||||
fonctionnalités (sauf impossibilités structurelles §12).
|
||||
|
||||
**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
|
||||
complète (132 tests unitaires + E2E navigateur local + container-test réel
|
||||
en configuration durcie). Le repo farewell a été créé depuis — les releases
|
||||
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).
|
||||
fonctionnalités (sauf impossibilités structurelles §12). La publication
|
||||
Gitea (release avec assets) du côté web est GELÉE tant que la qualité n'est
|
||||
pas validée au standard APK : en attendant, une release web = commit + tag
|
||||
UNIQUEMENT.
|
||||
|
||||
### Remotes
|
||||
|
||||
@ -607,19 +502,11 @@ push initial v1.2.3, cf doc Android §16.)
|
||||
4. **Releases Gitea** :
|
||||
- Android : `publish-release.py` sur les deux instances (2 APK), cf doc
|
||||
Android §16.bis ;
|
||||
- Web : `python3 scripts/publish-release.py cloudyfy vX.Y.Z` —
|
||||
crée/met à jour la release (corps = section `docs/CHANGELOG.md`) et
|
||||
attache l'archive `HormoneTrack-web-vX.Y.Z.zip` construite 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
|
||||
tout, vérif par téléchargement, échec bruyant). Idem `farewell`.
|
||||
⚠️ **É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.
|
||||
- Web : **GELÉ** tant que la parité qualité n'est pas validée — sinon
|
||||
créer la release avec le corps = section `docs/CHANGELOG.md` et, si
|
||||
un jour on distribue un zip, le vérifier par téléchargement (leçon
|
||||
#43 Android : une invocation fait tout, les uploads rapprochés se
|
||||
remplacent mutuellement).
|
||||
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,
|
||||
langue.
|
||||
@ -634,99 +521,12 @@ push initial v1.2.3, cf doc Android §16.)
|
||||
3. si l'asset PK change : les DEUX dépôts dans la même session (tests
|
||||
pins identiques) — jamais un seul côté.
|
||||
|
||||
### Déploiement — Docker (recommandé pour self-host)
|
||||
### Déploiement du site (quand décidé)
|
||||
|
||||
L'app étant un site **100 % statique**, la conteneurisation est triviale :
|
||||
l'image ajoute UNIQUEMENT un serveur web. Points structurants :
|
||||
|
||||
| Aspect | Décision | Pourquoi |
|
||||
|---|---|---|
|
||||
| Image de base | `nginxinc/nginx-unprivileged:alpine` | run en uid 101 (pas de root), port 8080, writes limités à /tmp — meilleure pratique conteneur, ~50 Mo |
|
||||
| Données | **AUCUNE dans l'image** (`.dockerignore` exclut aussi `local-test-data` en filet de sécurité) | le conteneur distribue des fichiers ; la vie privée est identique (localStorage clients) — cf §3/§5 |
|
||||
| Contenu servi | index.html + css/js/assets + **docs/** (⚠️ le dialog « Nouveautés » fetch `docs/CHANGELOG.md` au démarrage) | pas de `tests/`, `scripts/`, npm dans l'image (audit facile : `find /usr/share/nginx/html`) |
|
||||
| Cache | `no-cache, must-revalidate` sur TOUT | l'app = un jeu de fichiers qui doivent rester COHÉRENTS entre eux ; un JS périmé au moment d'une mise à jour = état incohérent. Total ≈ 600 Ko + gzip sur l'asset PK (550 Ko → ~150 Ko) : coût nul |
|
||||
| Ports | 8080 (non privilégié) | l'image unprivileged ne peut pas binde <1024 |
|
||||
| Durcissement | `read_only: true` + tmpfs `/var/cache/nginx` + `/run`, `no-new-privileges` | site statique : AUCUNE écriture nécessaire ; si un hôte compose refuse les tmpfs, retirer les deux lignes |
|
||||
| Healthcheck | busybox `wget` sur `/` (30 s/3 s) | intégré au Dockerfile — orchestrateurs (compose/NAS) savent si le site répond |
|
||||
|
||||
**Fichiers** : `Dockerfile`, `nginx.conf` (server block remplacé),
|
||||
`.dockerignore` (exclusions documentées — `docs/` reste inclus),
|
||||
`docker-compose.yml` (durcissement inclus).
|
||||
|
||||
```bash
|
||||
# ── Build + run ──
|
||||
docker compose up -d # build + run → http://<hôte>:8080/
|
||||
docker compose up -d --build # rebuild après git pull (mise à jour = 30 s)
|
||||
docker tag hormonetrack-web:latest hormonetrack-web:vX.Y.Z # garder un historique d'images par release sync
|
||||
```
|
||||
|
||||
**Valider le conteneur avec le MÊME test E2E que le CI** (le script pilote
|
||||
n'importe quel serveur via `HRT_E2E_BASE` — à lancer sur l'hôte qui a
|
||||
Docker ET exécute `npm i && npx playwright install firefox` une fois, cf
|
||||
§8) :
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
HRT_E2E_BASE=http://127.0.0.1:8080 node scripts/e2e.mjs
|
||||
# → les mêmes ~30 assertions (rendu, canvas peint pixel par pixel,
|
||||
# navigation, écran changelog, 0 erreur console) contre le site SERVI
|
||||
# PAR LE CONTENEUR — un bug nginx/MIME casse l'app, le test le voit.
|
||||
```
|
||||
|
||||
**Fichiers servis vérifiés** : `/`, `/css/style.css`, `/assets/pk_profiles.json`,
|
||||
`/js/ui/app.js`, `/docs/CHANGELOG.md` — les mêmes que le smoke HTTP de
|
||||
`check.sh --with-serve`.
|
||||
|
||||
**Statut (8 sept. 2026)** : build et run RÉELS validés localement via
|
||||
colima (daemon Docker léger, 2 CPU / 2 Go) — `scripts/container-test.sh`
|
||||
vert en configuration DURCIE pour **les DEUX architectures** (arm64 native
|
||||
et linux/amd64 cross-compilée : healthcheck healthy, endpoints, headers,
|
||||
gzip, MIME, E2E complet contre le conteneur). Problèmes réels trouvés aux
|
||||
premiers tests, corrigés et documentés : (a) tmpfs `/tmp` REQUIS en
|
||||
read-only (« mkdir /tmp/proxy_temp failed » sinon), (b) curl `%header_json`
|
||||
minuscule les clés (bug du TEST), (c) **exec format error sur l'hôte
|
||||
amd64** (cf §10 bis). Si le durcissement pose problème sur ton NAS :
|
||||
retirer `read_only`/`tmpfs`/`security_opt` du compose (aucune perte de
|
||||
sécurité cruciale — le conteneur ne détient rien).
|
||||
|
||||
### Cross-arch (ⓘ leçon du 8 sept. 2026 — « exec /docker-entrypoint.sh:
|
||||
exec format error »)
|
||||
|
||||
`exec format error` au `docker run` = l'image est pour une AUTRE
|
||||
architecture CPU que l'hôte (image arm64 buildée sur Mac → serveur amd64).
|
||||
Le Dockerfile ne contient **AUCUN `RUN`** (labels + COPY + HEALTHCHECK) :
|
||||
la cross-compilation est FAIBLE — buildx produit l'image pour n'importe
|
||||
quelle platform SANS émulation de build (constaté : build amd64 instantané
|
||||
sur l'arm64). Seul le RUN du conteneur émule (Rosetta via colima VZ / qemu
|
||||
sur l'hôte de build — sous émulation nginx journalise un
|
||||
`io_setup() failed (ENOSYS)` inoffensif : il retombe et répond 200 ; ce
|
||||
syscall existe sur un vrai Linux amd64).
|
||||
|
||||
**Règle** : le conteneur de PROD doit être buildé pour L'ARCH de l'hôte —
|
||||
deux procédures équivalentes :
|
||||
|
||||
1. **Sur le serveur cible** (recommandé — zéro registre, zéro émulation) :
|
||||
```bash
|
||||
git clone https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web && cd HormoneTrack-web
|
||||
docker compose up -d --build # arch native du serveur, toujours bon
|
||||
```
|
||||
2. **Multi-arch depuis le Mac** (push vers un registry — Docker Hub, GHCR,
|
||||
ou le registry interne du NAS) :
|
||||
```bash
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t <user>/hormonetrack-web:latest --push .
|
||||
```
|
||||
(build natif des deux archs — cf container-test.sh [platform] pour
|
||||
tester CHAQUE arch localement avant push : l'E2E complet tourne contre
|
||||
l'image cross-archémulée.)
|
||||
|
||||
**Test de l'arch cible AVANT push/déploiement** :
|
||||
`bash scripts/container-test.sh <port> linux/amd64` — c'est le test qui
|
||||
aurait attrapé l'incident « exec format error » du premier déploiement
|
||||
NAS (image arm64 seule déployée sur hôte amd64).
|
||||
|
||||
Composition de l'utilisatrice (8 sept.) : service `image:` depuis un
|
||||
registry, `env_file`/limites de ressources/réseaux externes — tout est
|
||||
inoffensif pour ce conteneur statique (aucune variable lue, no volumes).
|
||||
- **copier le dépôt** (ou `git archive vX.Y.Z`) sur n'importe quel
|
||||
hébergeur statique ; rien à compiler ;
|
||||
- optionnel : exclure `tests/`, `scripts/`, `docs/` du document root
|
||||
(inoffensifs mais inutiles en prod).
|
||||
|
||||
## 11. Bugs potentiels évités pendant le portage
|
||||
|
||||
@ -768,32 +568,6 @@ Trouvés pendant l'écriture (à ne pas réintroduire) :
|
||||
11. **`getSettings()` fusionne les défauts** : un stockage partiel ne doit
|
||||
jamais exposer des champs `undefined` (les gardes `!== null` deviennent
|
||||
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
|
||||
|
||||
@ -804,12 +578,6 @@ Structurels (plateforme), pas des bugs :
|
||||
serveur de push (contre la règle « 100 % local, aucun serveur »).
|
||||
- **Agenda récurrent** : impossible (pas de CalendarProvider). L'éditeur
|
||||
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.
|
||||
- **Fuseau système** : si le navigateur change de fuseau (voyage), les
|
||||
labels X suivent le fuseau CHOISI (Paramètres) ou le nouveau fuseau
|
||||
@ -833,8 +601,8 @@ Structurels (plateforme), pas des bugs :
|
||||
§16.ter de la doc mère).
|
||||
|
||||
---
|
||||
*Doc mise à jour le 11 sept. 2026 (web v1.5.0 — sync Android, dépôt séparé
|
||||
HormoneTrack-web) — sync Android v1.5.0, 132 tests verts + E2E,
|
||||
*Doc mise à jour le 8 sept. 2026 (web v1.4.10, dépôt séparé
|
||||
HormoneTrack-web) — portage de l'Android v1.4.10, 121 tests verts + E2E,
|
||||
backup compatible bidirectionnel, historique propre (aucune donnée de
|
||||
santée, aucune dette de confidentialité), versions alignées sur
|
||||
l'Android.*
|
||||
|
||||
@ -64,9 +64,6 @@ export function buildBackupJson(collections, tConfig, userSettings = null) {
|
||||
alertE2Low: userSettings.alertE2Low ?? null,
|
||||
alertTHigh: userSettings.alertTHigh ?? 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);
|
||||
|
||||
@ -40,10 +40,10 @@ export const AdministrationRoute = {
|
||||
|
||||
/**
|
||||
* Esters proposables dans l'override par dose pour un MODÈLE PK donné
|
||||
* (miroir de `Esters.choicesForModel` — la liste suit la couverture RÉELLE
|
||||
* (miroir de `Esters.choicesForModel`, v1.4.6 — la liste suit la couverture
|
||||
* du modèle : TFS = 7 esters ; WHSAH = 6 (PEP non couvert) ; Estrannaise
|
||||
* ANALYTIQUE (v1.9.0) = 6 esters injectables, dont EUCS exclusif à ce fit —
|
||||
* un ester hors liste produirait 0 pg/mL : aucun fallback silencieux).
|
||||
* (tables ODS) = EV/EU/EEn seulement — un traitement ESE + EB produirait
|
||||
* 0 pg/mL).
|
||||
*
|
||||
* @param {string} pkModel
|
||||
* @returns {string[]}
|
||||
@ -51,7 +51,6 @@ export const AdministrationRoute = {
|
||||
export function choicesForModel(pkModel) {
|
||||
if (pkModel === 'TFS') return ['EV', 'EU', 'EEN', 'EB', 'EC', 'ECS', 'PEP'];
|
||||
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'];
|
||||
}
|
||||
|
||||
@ -211,43 +210,3 @@ export function newLabResult(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 '🧴';
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,8 +226,6 @@ class WebStore {
|
||||
alertE2Low: null,
|
||||
alertTHigh: 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)
|
||||
changelogSeenVersion: null, // dialog « Nouveautés »
|
||||
alertNotifiedState: null, // anti-spam des notifications d'alerte
|
||||
|
||||
@ -1,129 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@ -1,141 +0,0 @@
|
||||
/**
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 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()]) || [];
|
||||
}
|
||||
@ -8,15 +8,9 @@ export * from './pk-engine.js';
|
||||
export * from './pk-calibration.js';
|
||||
export * from './pk-reminders.js';
|
||||
export * from './pk-extrema.js';
|
||||
export * as LabTrajectoryModel 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 * as Alerts from './alerts.js';
|
||||
export * as PKProfileStore from './pk-profile-store.js';
|
||||
export * as TransfemScienceModels from './transfem-science-models.js';
|
||||
export * as WhsahModels from './whsah-models.js';
|
||||
export * as PKPresets from './presets.js';
|
||||
|
||||
@ -1,308 +0,0 @@
|
||||
/**
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 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';
|
||||
}
|
||||
@ -1,197 +0,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.
|
||||
*
|
||||
* DEMANDE (décision v1.5.0, débattue avec l'utilisatrice — cf doc de dev
|
||||
* web §7.10/§3) : courbe « biologiquement plausible MAIS passant par tous
|
||||
* les points ». Formule identique à l'Android :
|
||||
*
|
||||
* courbe(t) = M(t) × ρ(t)
|
||||
*
|
||||
* - **M(t)** = niveau du modèle PK BRUT (scaleFactor forcé à 1, modèle
|
||||
* stocké ou modelOverride) — porte toute la physiologie entre les labs ;
|
||||
* - **ρ_k = lab_k / M(t_k)** à chaque lab ; ENTRE deux labs consécutifs ρ
|
||||
* interpole LOG-LINÉAIREMENT (morphing d'amplitude continu) ;
|
||||
* - courbe(t_k) = lab_k EXACTEMENT (garantie par construction, épinglé).
|
||||
*
|
||||
* GARDES (invariants Android portés tels quels) :
|
||||
* - 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) ;
|
||||
* - fenêtre = [1er lab significatif ; dernier lab] ∩ fenêtre du graphique
|
||||
* (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é).
|
||||
*
|
||||
* ── PROLONGATION AU-DELÀ DU DERNIER LAB (v1.6.0, miroir §7.10.bis Android) ──
|
||||
*
|
||||
* 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, cutoffHours } from './pk-engine.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.
|
||||
*
|
||||
* @param {object[]} treatments TOUS les traitements (actifs ET inactifs — §6.bis)
|
||||
* @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 {number} startMs Fenêtre demandée (celle du graphique)
|
||||
* @param {number} endMs
|
||||
* @param {number} [stepMs=3600000] Pas d'échantillonnage (stepForRange)
|
||||
* @param {string|null} [modelOverride=null]
|
||||
* @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, extendBeyondLastLab = false) {
|
||||
if (startMs >= endMs) return { points: [], anchoredLabs: 0, lastAnchorMs: null };
|
||||
|
||||
const estrogenTreatments = treatments.filter((t) => t.type === 'ESTRADIOL');
|
||||
if (estrogenTreatments.length === 0) return { points: [], anchoredLabs: 0, lastAnchorMs: null };
|
||||
|
||||
// Prédictions BRUTES : scaleFactor forcé à 1 (le ρ du lab EST le facteur)
|
||||
const unscaled = estrogenTreatments.map((t) => ({ ...t, scaleFactor: 1.0 }));
|
||||
|
||||
// ── 1) Timeline des ANCRAGES (labs croissants + garde #61) ────────────────
|
||||
const anchors = []; // (t_lab, ratio)
|
||||
let maxPredictionSoFar = 0.0;
|
||||
const e2Labs = labs
|
||||
.filter((l) => String(l.marker).toUpperCase() === 'E2')
|
||||
.slice()
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
for (const lab of e2Labs) {
|
||||
const predicted = e2At(unscaled, doseLogs, lab.timestamp, modelOverride, null);
|
||||
if (!labIsSignificant(predicted, maxPredictionSoFar)) continue;
|
||||
anchors.push([lab.timestamp, lab.value / predicted]);
|
||||
if (predicted > maxPredictionSoFar) maxPredictionSoFar = predicted;
|
||||
}
|
||||
if (anchors.length < 2) return { points: [], anchoredLabs: anchors.length, lastAnchorMs: null };
|
||||
|
||||
const firstLabMs = anchors[0][0];
|
||||
const lastLabMs = anchors[anchors.length - 1][0];
|
||||
|
||||
// ── 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 t1 = extendBeyondLastLab
|
||||
? 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) ───────
|
||||
const points = [];
|
||||
for (let t = t0; t <= t1; t += stepMs) {
|
||||
const m = e2At(unscaled, doseLogs, t, modelOverride, null);
|
||||
const rho = ratioAt(anchors, t);
|
||||
points.push({ timestamp: t, e2: m * rho, t: 0.0 });
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* ρ(t) : interpolation LOG-LINÉAIRE entre ancrages consécutifs
|
||||
* (`anchors` trié croissant). Garanties : ρ(t_k) exactement, continuité
|
||||
* multiplicative, monotone ; doublons de timestamp → le 2ᵉ ratio gagne
|
||||
* (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} tMs
|
||||
* @returns {number}
|
||||
*/
|
||||
export function ratioAt(anchors, tMs) {
|
||||
if (tMs <= anchors[0][0]) return anchors[0][1];
|
||||
if (tMs >= anchors[anchors.length - 1][0]) return anchors[anchors.length - 1][1];
|
||||
|
||||
for (let i = 0; i < anchors.length - 1; i++) {
|
||||
const [t0, r0] = anchors[i];
|
||||
const [t1, r1] = anchors[i + 1];
|
||||
if (tMs >= t0 && tMs <= t1) {
|
||||
const span = t1 - t0;
|
||||
if (span <= 0) return r1; // doublon de timestamp : 2ᵉ ratio gagne
|
||||
const f = (tMs - t0) / span;
|
||||
return Math.exp(Math.log(r0) + f * (Math.log(r1) - Math.log(r0)));
|
||||
}
|
||||
}
|
||||
return anchors[anchors.length - 1][1]; // inatteignable — garde
|
||||
}
|
||||
@ -54,20 +54,14 @@ function median(values) {
|
||||
return (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0;
|
||||
}
|
||||
|
||||
/** Arrondi « facteur » à 2 décimales (v1.9.2 : Math.round — miroir
|
||||
* medianRounded Kotlin v1.8.2 ; la troncature historique (x*100|0)/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 « facteur » à 2 décimales, troncature (miroir du Kotlin (x*100|0)/100). */
|
||||
function round2(x) {
|
||||
return Math.trunc(x * 100) / 100;
|
||||
}
|
||||
|
||||
/** Arrondi « k T » à 3 décimales (v1.9.2 : Math.round, cf round2). */
|
||||
export function round3(x) {
|
||||
return Math.round(x * 1000) / 1000;
|
||||
/** Arrondi « k T » à 3 décimales, troncature. */
|
||||
function round3(x) {
|
||||
return Math.trunc(x * 1000) / 1000;
|
||||
}
|
||||
|
||||
/** Dernier élément (par timestamp) d'une liste non vide ; null si vide. */
|
||||
|
||||
@ -8,19 +8,15 @@
|
||||
*
|
||||
* Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la
|
||||
* réponse normalisée (pg/mL par mg) ; les contributions se superposent.
|
||||
* Trois modèles ANALYTIQUES superposables (l'ODS a quitté le runtime en
|
||||
* 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) ;
|
||||
* Trois modèles superposables :
|
||||
* - **Estrannaise (ESE)** = tables horaires de l'asset (ODS) ;
|
||||
* - **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).
|
||||
* Gel/patch/oral → modèle de Bateman paramétrable.
|
||||
*
|
||||
* ⚠️ INVARIANTS à préserver (doc Android §14 — bugs à ne pas réintroduire) :
|
||||
* - `computeKa` : bisection DANS LE BON SENS (#19) ;
|
||||
* - 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 ;
|
||||
* - tables ESE : modèle strict + extrapolation terminale ≥ 1 % du pic (#20/#21) ;
|
||||
* - `convertTToNgMl` appliquée à la calibration ET au rendu (#23) ;
|
||||
* - `generateForecastDoses` : créneaux passés SAUTÉS (#35).
|
||||
*
|
||||
@ -30,7 +26,7 @@
|
||||
|
||||
import * as TransfemScienceModels from './transfem-science-models.js';
|
||||
import * as WhsahModels from './whsah-models.js';
|
||||
import { sample as estrannaiseSample, hasModel as estrannaiseHasModel, model as estrannaiseModel } from './estrannaise-models.js';
|
||||
import * as PKProfileStore from './pk-profile-store.js';
|
||||
|
||||
/** Une heure en ms. */
|
||||
export const HOUR_MS = 3600000;
|
||||
@ -59,7 +55,6 @@ export class TConfig {
|
||||
export const Esters = {
|
||||
NONE: 'NONE', EV: 'EV', EU: 'EU', EEN: 'EEN',
|
||||
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`). */
|
||||
@ -145,7 +140,7 @@ export function doseEster(treatment, dose) {
|
||||
* 1. traitement à PROFIL PK (injection + ester ≠ NONE) :
|
||||
* - TFS + ester couvert par le V3C → TransfemScienceModels ;
|
||||
* - WHSAH + ester couvert par le fit Mona → WhsahModels ;
|
||||
* - ESE → forme close estrannaise.js (v1.9.0, analytique) ; ester sans modèle → 0 ;
|
||||
* - sinon → tables ODS via PKProfileStore (ester sans modèle → 0) ;
|
||||
* 2. sinon → Bateman (gel / patch / oral / custom).
|
||||
*
|
||||
* `modelOverride` force ESE/TFS/WHS pour CE calcul (le graphique dessine les
|
||||
@ -178,10 +173,7 @@ export function concentrationOfDose(treatment, dose, queryTimeMs, bateman = null
|
||||
if (mod === PKModels.WHSAH && WhsahModels.hasModel(ester)) {
|
||||
return WhsahModels.sample(ester, 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)
|
||||
return PKProfileStore.sample(ester, mod, dtH) * mg;
|
||||
}
|
||||
}
|
||||
|
||||
@ -215,14 +207,7 @@ export function cutoffHours(treatment) {
|
||||
} else if (mod === PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) {
|
||||
profileH = TransfemScienceModels.model(treatment.esterType).terminalHalfLifeDays * 24.0 * 10.0;
|
||||
} else {
|
||||
// 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;
|
||||
profileH = PKProfileStore.profileLength(treatment.esterType, mod);
|
||||
}
|
||||
}
|
||||
const batemanH = usesProfileModel(treatment) ? 0.0 : 30.0 * treatment.eliminationHalfLifeHours;
|
||||
@ -252,53 +237,19 @@ export function cutoffHours(treatment) {
|
||||
* @param {Object<string,number>|null} [scalePerEster=null]
|
||||
* @returns {number} pg/mL
|
||||
*/
|
||||
/**
|
||||
* 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();
|
||||
export function e2At(treatments, doseLogs, tMs, modelOverride = null, scalePerEster = null) {
|
||||
const batemanCache = new Map();
|
||||
let total = 0.0;
|
||||
for (const treatment of treatments) {
|
||||
if (treatment.type !== 'ESTRADIOL') continue; // AA/progestatifs → 0 en E2
|
||||
cutoffByTreatment.set(treatment.id, cutoffHours(treatment));
|
||||
if (!usesProfileModel(treatment)) {
|
||||
if (!batemanCache.has(treatment.id)) {
|
||||
batemanCache.set(treatment.id, batemanParams(treatment));
|
||||
}
|
||||
}
|
||||
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;
|
||||
for (const dose of doseLogs) {
|
||||
if (dose.treatmentId !== treatment.id || dose.timestamp > tMs) continue;
|
||||
const dtH = (tMs - dose.timestamp) / 3600000.0;
|
||||
if (dtH > cutoff) continue;
|
||||
const c = concentrationOfDose(treatment, dose, tMs, ctx.batemanCache.get(treatment.id), modelOverride);
|
||||
if (dtH > cutoffHours(treatment)) continue;
|
||||
const c = concentrationOfDose(treatment, dose, tMs, batemanCache.get(treatment.id), modelOverride);
|
||||
if (c > 0.0) {
|
||||
const scale = (scalePerEster && scalePerEster[doseEster(treatment, dose)]) ?? treatment.scaleFactor;
|
||||
total += c * scale;
|
||||
@ -308,11 +259,6 @@ export function e2AtCtx(ctx, tMs, modelOverride = null, scalePerEster = null) {
|
||||
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) :
|
||||
* `T = floor + (base − floor) / (1 + k·E2)` en ng/mL.
|
||||
@ -423,29 +369,12 @@ export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConf
|
||||
const earliestDose = doseLogs.reduce((m, d) => Math.min(m, d.timestamp), Infinity);
|
||||
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 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]);
|
||||
}
|
||||
relevantTreatments.forEach((tr) => batemanCache.set(tr.id, batemanParams(tr)));
|
||||
|
||||
// Doses triées pour suivre l'« ester actif » le long de la grille (k de T)
|
||||
const sortedEstrogenDoses = doseLogs
|
||||
.filter((d) => treatmentById.has(d.treatmentId))
|
||||
.filter((d) => relevantTreatments.some((tr) => tr.id === d.treatmentId))
|
||||
.slice()
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
let doseCursor = 0;
|
||||
@ -458,15 +387,16 @@ export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConf
|
||||
// Avance le curseur : la dose à t définit l'ester actif
|
||||
while (doseCursor < sortedEstrogenDoses.length && sortedEstrogenDoses[doseCursor].timestamp <= t) {
|
||||
const d = sortedEstrogenDoses[doseCursor];
|
||||
activeEster = doseEster(treatmentById.get(d.treatmentId), d);
|
||||
const tr = relevantTreatments.find((x) => x.id === d.treatmentId);
|
||||
activeEster = doseEster(tr, d);
|
||||
doseCursor++;
|
||||
}
|
||||
const k = (activeEster && tKPerEster && tKPerEster[activeEster]) ?? tConfig.k;
|
||||
for (const treatment of relevantTreatments) {
|
||||
const cutoff = cutoffByTreatment.get(treatment.id);
|
||||
const cutoff = cutoffHours(treatment);
|
||||
const p = batemanCache.get(treatment.id);
|
||||
for (const dose of dosesByTreatment.get(treatment.id) ?? []) {
|
||||
if (dose.timestamp > t) continue;
|
||||
for (const dose of doseLogs) {
|
||||
if (dose.treatmentId !== treatment.id || dose.timestamp > t) continue;
|
||||
const dtH = (t - dose.timestamp) / 3600000.0;
|
||||
if (dtH > cutoff) continue;
|
||||
const c = concentrationOfDose(treatment, dose, t, p, modelOverride);
|
||||
@ -512,14 +442,6 @@ export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConf
|
||||
export function generateForecastDoses(treatment, allDoseLogs, toMs, nowMs = Date.now()) {
|
||||
const intervalDays = treatment.forecastIntervalDays;
|
||||
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);
|
||||
if (intervalMs <= 0) return [];
|
||||
|
||||
|
||||
@ -1,12 +1,6 @@
|
||||
/**
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* 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`).
|
||||
*
|
||||
* L'asset contient les tables horaires Estrannaise extraites du tableur
|
||||
@ -143,8 +137,24 @@ export function sample(ester, mod, dtHours) {
|
||||
// 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) —
|
||||
// extrapolation EXPONENTIELLE, donc jamais croissante.
|
||||
const [j, rate] = terminalDecayParameters(arr) ?? [0, 0.0];
|
||||
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 0.0;
|
||||
let j = lastIdx;
|
||||
while (j > 0 && arr[j] < peakV * 0.01) j--;
|
||||
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));
|
||||
}
|
||||
|
||||
@ -155,62 +165,6 @@ export function sample(ester, mod, dtHours) {
|
||||
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é). */
|
||||
export function isInitialized() {
|
||||
return profiles !== null;
|
||||
|
||||
@ -49,13 +49,6 @@ export const all = [
|
||||
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_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 ---
|
||||
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),
|
||||
|
||||
108
js/ui/app.js
108
js/ui/app.js
@ -3,7 +3,7 @@
|
||||
* HormoneTrack Web — shell applicatif (portage de HormoneTrackApp.kt +
|
||||
* MainActivity.kt + HormoneTrackRoot.kt).
|
||||
*
|
||||
* - attend l'initialisation de l'asset MCMC (nuage d'incertitude ESE, v1.9.0) ;
|
||||
* - attend l'initialisation de PKProfileStore (fetch de l'asset) ;
|
||||
* - routing par hash (#home/#chart/#doses/#labs/#treatments/#settings/
|
||||
* #treatment-edit/{id}) — permet la navigation headless et le lien profond ;
|
||||
* - NavigationBar 5 onglets (barre masquée sur settings/éditeur, miroir
|
||||
@ -20,7 +20,7 @@
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import * as EstrannaiseModels from '../pk/estrannaise-models.js';
|
||||
import * as PKProfileStore from '../pk/pk-profile-store.js';
|
||||
import { store } from '../data/store.js';
|
||||
import * as AppLog from '../util/app-log.js';
|
||||
import { isVersionNewer, sectionsSince } from '../util/changelog.js';
|
||||
@ -55,21 +55,9 @@ async function main() {
|
||||
renderRoute(); // affiche l'écran courant avec le marqueur de chargement
|
||||
|
||||
// 4) Asset PK (équivalent de PKProfileStore.init(context) dans
|
||||
// HormoneTrackApp.onCreate).
|
||||
// v1.9.0 : l'asset MCMC (nuage ESE, 48 Ko) remplace le fetch des tables
|
||||
// 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');
|
||||
});
|
||||
// HormoneTrackApp.onCreate) — les courbes ne sont calculées qu'après ;
|
||||
// le re-rendu ci-dessous remplace le « Chargement… » par les données.
|
||||
await PKProfileStore.init();
|
||||
|
||||
// 5) Langue au démarrage (miroir MainActivity : setApplicationLocales)
|
||||
document.documentElement.lang = resolveLocale(store.getSettings().language);
|
||||
@ -106,8 +94,6 @@ let contentEl = null;
|
||||
let bannerSlot = null;
|
||||
/** Dernière route rendue (le tick minute ne re-rend que si inchangée). */
|
||||
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. */
|
||||
let demoMode = false;
|
||||
|
||||
@ -155,28 +141,19 @@ function currentRoute() {
|
||||
function renderRoute() {
|
||||
if (!contentEl) return;
|
||||
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;
|
||||
|
||||
// Version exposée à l'E2E (data-version) : les asserts se branchent sur
|
||||
// CETTE valeur au lieu d'une string codée en dur à bump à chaque release
|
||||
// — l'oubli a produit le bug v1.9.3 (version annoncée fausse).
|
||||
document.body.dataset.version = WEB_VERSION;
|
||||
// Marqueur E2E : l'app est prête — utilisé par scripts/e2e.mjs pour
|
||||
// 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).
|
||||
if (booted) document.body.setAttribute('data-ready', '1');
|
||||
// Écran de chargement tant que l'asset PK n'est pas chargé (les écrans
|
||||
// afficheraient « aucune donnée » à tort — les profils valent 0 avant init).
|
||||
if (!PKProfileStore.isInitialized()) {
|
||||
contentEl.textContent = '';
|
||||
contentEl.appendChild(el('p', { class: 'hint', style: 'padding:24px 0;' }, t('loading')));
|
||||
return;
|
||||
}
|
||||
|
||||
// Marqueur E2E : l'app est prête (asset chargé, écran rendu) — utilisé par
|
||||
// scripts/e2e.mjs pour attendre la fin du rendu (plus de course au screenshot).
|
||||
document.body.setAttribute('data-ready', '1');
|
||||
// Onglet actif
|
||||
document.querySelectorAll('.tabbar button').forEach((b) => {
|
||||
b.classList.toggle('active', b.getAttribute('data-route') === route);
|
||||
@ -207,7 +184,7 @@ function renderRoute() {
|
||||
renderHome(contentEl, { nowMs, openChart: () => { location.hash = '#chart'; } });
|
||||
break;
|
||||
case route === 'chart':
|
||||
renderChart(contentEl, { nowMs }, { preserveState: preserveChartState });
|
||||
renderChart(contentEl, { nowMs });
|
||||
break;
|
||||
case route === 'doses':
|
||||
renderDoses(contentEl);
|
||||
@ -359,58 +336,13 @@ function maybeLoadDemoData() {
|
||||
esterType: null,
|
||||
});
|
||||
}
|
||||
// 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.
|
||||
// Labs E2/T plausibles (1 prise de sang toutes les 2 semaines)
|
||||
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) => {
|
||||
const ts = now - (11 - i * 2) * 7 * DAY;
|
||||
const isLastPair = i === labs.length - 1;
|
||||
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,
|
||||
});
|
||||
store.upsertLabResult({ marker: 'E2', value: e2, unit: 'pg/mL', timestamp: ts, notes: null });
|
||||
store.upsertLabResult({ marker: 'T', value: Math.round(tVal * 100), unit: 'ng/dL', timestamp: ts, notes: 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)');
|
||||
}
|
||||
|
||||
|
||||
@ -36,18 +36,10 @@ export const COLORS = {
|
||||
grid: '#cac4d0',
|
||||
label: '#6b7280',
|
||||
nowLine: '#2fa48d',
|
||||
chartLabTrajectory: '#c2185b', // v1.5.0 — Tracé labs (rose foncé)
|
||||
forecastMarker: 'rgba(47,164,141,0.85)',
|
||||
realDoseMarker: 'rgba(73,69,79,0.55)',
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Une série objet : { points, e2Style:{color,dashed}, tStyle|null, showExtrema }
|
||||
* v1.5.0 : `showExtrema=false` → hors pics/creux (la série « Tracé labs »
|
||||
* épouse l'interp ρ, pas la physiologie du modèle).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Dessine le graphique dans un canvas.
|
||||
*
|
||||
@ -75,40 +67,31 @@ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses
|
||||
const allPoints = series.flatMap((s) => s.points);
|
||||
if (allPoints.length < 2) return;
|
||||
|
||||
// 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).
|
||||
// Marges (px) — miroir padLeft/padRight/padTop/padBottom (42/42/12/26 dp)
|
||||
const padLeft = 42;
|
||||
const padRight = 42;
|
||||
const padTop = 26;
|
||||
const padTop = 12;
|
||||
const padBottom = 26;
|
||||
const w = cssW - padLeft - padRight;
|
||||
const h = cssH - padTop - padBottom;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
// v1.9.5 (audit) : t0/t1/e2Max/tMax en UNE passe (RangeError possible au-delà
|
||||
// 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;
|
||||
}
|
||||
const t0 = Math.min(...allPoints.map((p) => p.timestamp));
|
||||
const t1 = Math.max(...allPoints.map((p) => p.timestamp));
|
||||
if (t1 <= t0) return;
|
||||
|
||||
// Labs T convertis en ng/mL AVANT l'échelle (fix #23)
|
||||
const convertedTLabs = tLabs.map((lab) => ({ ...lab, value: convertTToNgMl(lab.value, lab.unit) }));
|
||||
if (options.showLabs) {
|
||||
for (const l of e2Labs) if (l.value > e2DataMax) e2DataMax = l.value;
|
||||
for (const l of convertedTLabs) if (l.value > tDataMax) tDataMax = l.value;
|
||||
}
|
||||
|
||||
const e2DataMax = Math.max(
|
||||
Math.max(...allPoints.map((p) => p.e2)),
|
||||
options.showLabs && e2Labs.length ? Math.max(...e2Labs.map((l) => l.value)) : 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 xOf = (ms) => padLeft + (w * (ms - t0)) / (t1 - t0);
|
||||
@ -129,16 +112,11 @@ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses
|
||||
ctx.font = '10px system-ui, sans-serif';
|
||||
ctx.textAlign = 'right';
|
||||
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++) {
|
||||
ctx.fillText(formatValue((e2Max * i) / 4), padLeft - 6, padTop + h * (1 - i / 4));
|
||||
}
|
||||
if (options.showT && series.some((s) => s.tStyle)) {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('ng/mL', padLeft + w + 6, padTop - 6);
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
ctx.fillText(formatValue((tMax * i) / 4), padLeft + w + 6, padTop + h * (1 - i / 4));
|
||||
}
|
||||
@ -217,7 +195,7 @@ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses
|
||||
}
|
||||
|
||||
// Pics/creux (v1.2.3/v1.2.6) : triangles + valeur estimée
|
||||
if (options.showExtrema && (s.showExtrema !== false)) {
|
||||
if (options.showExtrema) {
|
||||
for (const ex of detectExtrema(s.points, (p) => p.e2, 2.0)) {
|
||||
drawExtremum(ctx, xOf(ex.timestamp), yE2(ex.value), ex, s.e2Style.color);
|
||||
}
|
||||
|
||||
211
js/ui/chart.js
211
js/ui/chart.js
@ -19,11 +19,6 @@
|
||||
* - « 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
|
||||
* 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
|
||||
* (piège Android #62/§11).
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -31,7 +26,6 @@
|
||||
|
||||
import {
|
||||
computeCurve, generateForecastDoses, autoCalibrated, TConfig,
|
||||
computeLabAnchoredCurve, computeEstrannaiseCloud, DEFAULT_NB_CURVES,
|
||||
} from '../pk/index.js';
|
||||
import {
|
||||
stepForRange, defaultModelToggles, panDeltaHours, clampPanHours,
|
||||
@ -48,29 +42,6 @@ const HOUR_MS = 3600000;
|
||||
const MIN_RANGE_H = 6;
|
||||
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). */
|
||||
function seriesStyles(model) {
|
||||
if (model === 'ESE') return { e2: { color: COLORS.chartE2, dashed: false }, t: { color: COLORS.chartT, dashed: false } };
|
||||
@ -80,19 +51,10 @@ function seriesStyles(model) {
|
||||
|
||||
/**
|
||||
* Rend l'écran Graphiques dans [container].
|
||||
*
|
||||
* @param {HTMLElement} container
|
||||
* @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, { preserveState = false } = {}) {
|
||||
export function renderChart(container, ctx) {
|
||||
const treatments = store.getTreatments(); // TOUS (actifs ET inactifs, §6.bis)
|
||||
const doseLogs = store.getDoseLogs();
|
||||
const labResults = store.getLabResults();
|
||||
@ -102,39 +64,19 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
const chartTimeZone = settings.chartTimezone || null;
|
||||
|
||||
// ── État d'interaction (miroir des mutableStateOf Compose) ────────────────
|
||||
// v1.6.1 : réutilisé entre les re-rendus du même écran (cf bloc
|
||||
// « État d'interaction » au-dessus — sans ça, le tick minute de app.js
|
||||
// réinitialisait toutes les options toutes les 60 s).
|
||||
if (!preserveState || !chartUiState) {
|
||||
chartUiState = {
|
||||
rangeHours: 24,
|
||||
panHours: 0,
|
||||
showT: true,
|
||||
showLabs: true,
|
||||
showEse: true,
|
||||
showTfs: true,
|
||||
showWhs: 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;
|
||||
const state = {
|
||||
rangeHours: 24,
|
||||
panHours: 0,
|
||||
showT: true,
|
||||
showLabs: true,
|
||||
showEse: true,
|
||||
showTfs: true,
|
||||
showWhs: false,
|
||||
showForecast: false,
|
||||
showExtrema: false,
|
||||
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
|
||||
togglesInitialized: false,
|
||||
};
|
||||
|
||||
// ── Colonne racine ───────────────────────────────────────────────────────
|
||||
const col = el('div', {});
|
||||
@ -171,10 +113,7 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
}
|
||||
|
||||
// ── 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
|
||||
.filter((tr) => tr.isActive)
|
||||
.map((tr) => tr.forecastIntervalDays)
|
||||
.filter((x) => x !== null && x !== undefined)
|
||||
.reduce((m, x) => Math.max(m, x), 0) || null;
|
||||
@ -262,65 +201,12 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
|
||||
// Rangée 3 : toggles modèles superposables
|
||||
modelRow.textContent = '';
|
||||
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_ese'), state.showEse, () => { state.showEse = !state.showEse; 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(); }));
|
||||
// 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(); }));
|
||||
// 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é ─────────────────────────────────────
|
||||
const curves = [];
|
||||
// ── v1.5.0 : courbe « Tracé labs » ───────────────────────────────────────
|
||||
// Indépendante des toggles modèles (modèle STOCKÉ — sémantique Home) et
|
||||
// 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) {
|
||||
const labCurve = computeLabAnchoredCurve(
|
||||
treatments, doseLogs, labResults, startMs, endMs, step, null,
|
||||
state.showLabTrackExtend,
|
||||
);
|
||||
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) {
|
||||
// Home utilise le modèle stocké ; le graphique calibre chaque modèle
|
||||
// avec SA prédiction (fix #60). Référence = auto du modèle TFS (les
|
||||
@ -336,6 +222,7 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
const allDoses = doseLogs.concat(forecastDoses);
|
||||
|
||||
// Pas d'échantillonnage adaptatif au zoom (v1.2.9)
|
||||
const step = stepForRange(state.rangeHours);
|
||||
const addCurve = (model) => {
|
||||
const autoM = autoByModel.get(model);
|
||||
curves.push([model, computeCurve(
|
||||
@ -354,20 +241,12 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
|
||||
const allPoints = curves.flatMap(([, pts]) => pts);
|
||||
|
||||
// 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.
|
||||
// Labs filtrés sur la fenêtre ; le toggle T masque AUSSI les labs T
|
||||
let a = 0;
|
||||
let b = 0;
|
||||
if (allPoints.length >= 2) {
|
||||
a = allPoints[0].timestamp;
|
||||
b = a;
|
||||
for (const p of allPoints) {
|
||||
if (p.timestamp < a) a = p.timestamp;
|
||||
if (p.timestamp > b) b = p.timestamp;
|
||||
}
|
||||
a = Math.min(...allPoints.map((p) => p.timestamp));
|
||||
b = Math.max(...allPoints.map((p) => p.timestamp));
|
||||
}
|
||||
const e2Labs = labResults.filter((l) => String(l.marker).toUpperCase() === 'E2' && l.timestamp >= a && l.timestamp <= b);
|
||||
const tLabs = state.showT
|
||||
@ -390,31 +269,6 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
drawChart(
|
||||
canvas,
|
||||
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') {
|
||||
// v1.5.0 : Tracé labs = E2 rose foncé pointillé, PAS de courbe T
|
||||
// (série E2-only) et hors pics/creux (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);
|
||||
return { points: pts, e2Style: st.e2, tStyle: st.t };
|
||||
}),
|
||||
@ -437,10 +291,6 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
|
||||
// Légende dynamique (miroir du bloc Compose)
|
||||
for (const [model] of curves) {
|
||||
// ⚠️ Les clés 'LAB' (v1.5.0) et 'LABX' (v1.6.0) sont SKIPPÉES
|
||||
// (légendes dédiées plus bas, fix #63 Android-miroir : sinon le
|
||||
// catchall imprime la légende TFS en doublon)
|
||||
if (model === 'LAB' || model === 'LABX' || model === 'CLOUD') continue;
|
||||
const st = seriesStyles(model);
|
||||
if (model === 'ESE') {
|
||||
legend.appendChild(legendLine(t('legend_ese_e2'), st.e2.color));
|
||||
@ -453,32 +303,9 @@ export function renderChart(container, ctx, { preserveState = false } = {}) {
|
||||
if (state.showT) legend.appendChild(legendLine(t('legend_tfs_t'), st.t.color));
|
||||
}
|
||||
}
|
||||
// Légende modèles : la clé 'LAB' est SKIPPÉE (la boucle for-of ne
|
||||
// gère que ESE/TFS/WHS — pas de catchall pour LAB, sinon légende
|
||||
// dupliquée : miroir de la leçon #63 Android)
|
||||
if (state.showLabs) legend.appendChild(legendLine(t('legend_labs'), COLORS.labDot));
|
||||
if (state.showForecast && horizonHours !== null) legend.appendChild(legendLine(t('legend_forecast_dose'), COLORS.nowLine));
|
||||
if (state.showExtrema) legend.appendChild(el('div', { class: 'legend-muted' }, t('legend_extrema')));
|
||||
// v1.5.0 : légende dédiée « Tracé labs » (génère rien si la série vide)
|
||||
if (state.showLabTrack && curves.some(([m, pts]) => m === 'LAB' && pts.length >= 2)) {
|
||||
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')));
|
||||
}
|
||||
|
||||
|
||||
@ -7,8 +7,7 @@
|
||||
* - create (traitements ACTIFS proposés) / edit (TOUS les traitements —
|
||||
* 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
|
||||
* suit le MODÈLE du traitement via choicesForModel (v1.9.4 : TFS=7,
|
||||
* WHSAH=6, ESE analytique=6).
|
||||
* suit le MODÈLE du traitement via choicesForModel (TFS=7, WHS=6, ESE=3).
|
||||
*
|
||||
* 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.
|
||||
|
||||
@ -15,9 +15,8 @@
|
||||
*/
|
||||
|
||||
import { store } from '../data/store.js';
|
||||
import { routeGlyph, routeLabelKey } from '../data/models.js';
|
||||
import { t } from '../util/i18n.js';
|
||||
import { formatDose, formatTime, formatLongDate, formatISODate, HrtDuration, labMarkersForDose } from '../util/format.js';
|
||||
import { formatDose, formatTime, formatLongDate, formatISODate, HrtDuration } from '../util/format.js';
|
||||
import { el, assistChip, confirmDialog, fab } from './components.js';
|
||||
import { openDoseDialog } from './dialogs.js';
|
||||
|
||||
@ -92,17 +91,7 @@ export function renderDoses(container) {
|
||||
col.appendChild(el('div', { class: 'group-header' }, dateKey));
|
||||
for (const log of logs) {
|
||||
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 }) });
|
||||
// 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' });
|
||||
main.appendChild(el('div', { class: 'title' },
|
||||
`${tr ? tr.name : '?'} · ${formatDose(log.doseAmount)} ${tr ? tr.doseUnit : ''}`));
|
||||
@ -118,28 +107,12 @@ export function renderDoses(container) {
|
||||
if (log.notes && log.notes.trim() !== '') sub.appendChild(el('span', {}, log.notes));
|
||||
main.appendChild(sub);
|
||||
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') }, '🗑');
|
||||
del.addEventListener('click', (e) => {
|
||||
e.stopPropagation(); // ne pas déclencher l'édition
|
||||
confirmDialog(t('delete'), t('confirm_delete'), () => store.deleteDoseLog(log.id));
|
||||
});
|
||||
side.appendChild(del);
|
||||
item.appendChild(side);
|
||||
item.appendChild(del);
|
||||
col.appendChild(item);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,12 +18,12 @@
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
|
||||
import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore, nextBloodDrawRecommendation, troughTargetStatus } from '../pk/index.js';
|
||||
import { computeCurve, autoCalibrated, nextReminderFireFor, Alerts, TConfig, pointHoursBefore } from '../pk/index.js';
|
||||
import { store } from '../data/store.js';
|
||||
import { t } from '../util/i18n.js';
|
||||
import { formatDose, formatValue, formatDateTime, formatWeekdayTime, nextDoseLineText } from '../util/format.js';
|
||||
import { formatDose, formatValue, formatTime, formatWeekdayTime, HrtDuration } from '../util/format.js';
|
||||
import { el } from './components.js';
|
||||
import { drawChart, COLORS } from './chart-canvas.js';
|
||||
import { drawChart } from './chart-canvas.js';
|
||||
import { openDoseDialog } from './dialogs.js';
|
||||
|
||||
const HOUR_MS = 3600000;
|
||||
@ -109,14 +109,7 @@ export function renderHome(container, ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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).
|
||||
// ── Carte « prochaine dose » (grille Posologie via le moteur, fix #52) ───
|
||||
const fires = [];
|
||||
for (const tr of treatments) {
|
||||
const fire = nextReminderFireFor(tr, doseLogs, nowMs);
|
||||
@ -124,55 +117,27 @@ export function renderHome(container, ctx) {
|
||||
}
|
||||
if (fires.length) {
|
||||
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' });
|
||||
card.appendChild(el('div', { class: 'label' }, t(fires.length === 1 ? 'next_dose' : 'next_doses')));
|
||||
fires.forEach(([treatment, next], idx) => {
|
||||
card.appendChild(el('div', { style: idx === 0 ? 'font-weight:600;' : '' },
|
||||
nextDoseLineText(nowMs, next, treatment.name)));
|
||||
});
|
||||
card.appendChild(el('div', { class: 'label' }, t('next_dose')));
|
||||
if (h >= 24) {
|
||||
// v1.4.1 : au-delà de 24 h, afficher en JOURS + jour de semaine
|
||||
const [days, remH] = HrtDuration.daysAndHours(h);
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 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) ────────
|
||||
const quickCard = el('div', { class: 'card variant' });
|
||||
quickCard.appendChild(el('h3', {}, t('logged_today')));
|
||||
@ -203,12 +168,12 @@ export function renderHome(container, ctx) {
|
||||
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
|
||||
requestAnimationFrame(() => {
|
||||
drawChart(canvas, [{ points: curve, e2Style: { color: COLORS.chartE2, dashed: false }, tStyle: { color: COLORS.chartT, dashed: false } }],
|
||||
drawChart(canvas, [{ points: curve, e2Style: { color: '#4f5bd5', dashed: false }, tStyle: { color: '#d6589e', dashed: false } }],
|
||||
e2Labs, tLabs, { showT: true, showLabs: true, showExtrema: false, nowMs }, [], [], null);
|
||||
});
|
||||
}
|
||||
chartCard.appendChild(el('div', { class: 'legend-text', style: `color:${COLORS.chartE2};` }, t('home_legend_e2')));
|
||||
chartCard.appendChild(el('div', { class: 'legend-text', style: `color:${COLORS.chartT};` }, t('home_legend_t')));
|
||||
chartCard.appendChild(el('div', { class: 'legend-text', style: 'color:#4f5bd5;' }, t('home_legend_e2')));
|
||||
chartCard.appendChild(el('div', { class: 'legend-text', style: 'color:#d6589e;' }, t('home_legend_t')));
|
||||
chartCard.appendChild(el('div', { class: 'legend-muted' }, t('open_charts_hint')));
|
||||
col.appendChild(chartCard);
|
||||
|
||||
|
||||
@ -14,8 +14,7 @@
|
||||
|
||||
import { store } from '../data/store.js';
|
||||
import { t } from '../util/i18n.js';
|
||||
import { formatLabValue, formatDateTime, formatDose, labNotesForDisplay } from '../util/format.js';
|
||||
import { nextBloodDrawRecommendation, shouldSuggestPosology, troughTargetStatus, autoCalibrated } from '../pk/index.js';
|
||||
import { formatLabValue, formatDateTime } from '../util/format.js';
|
||||
import { el, confirmDialog, fab, showDialog } from './components.js';
|
||||
import { openLabDialog } from './dialogs.js';
|
||||
|
||||
@ -56,62 +55,6 @@ export function renderLabs(container) {
|
||||
const col = el('div', {});
|
||||
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) {
|
||||
col.appendChild(el('p', {}, t('no_data')));
|
||||
container.appendChild(fab(() => openLabDialog({ existing: null })));
|
||||
@ -127,14 +70,8 @@ export function renderLabs(container) {
|
||||
const main = el('div', { class: 'main' });
|
||||
main.appendChild(el('div', { class: 'title' }, headline));
|
||||
main.appendChild(el('div', { class: 'sub' }, formatDateTime(ts)));
|
||||
// v1.7.1 : TOUTES les notes distinctes du groupe (préfixées du marqueur
|
||||
// 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));
|
||||
}
|
||||
const firstNotes = group.map((g) => g.notes).find((n) => n && n.trim() !== '');
|
||||
if (firstNotes) main.appendChild(el('div', { class: 'sub' }, firstNotes));
|
||||
item.appendChild(main);
|
||||
|
||||
// Tap → sélecteur E2/T si paire, édition directe sinon
|
||||
|
||||
@ -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`).
|
||||
* v1.4.10 = portage initial de l'Android v1.4.10.
|
||||
*/
|
||||
export const WEB_VERSION = '1.13.0';
|
||||
export const WEB_VERSION = '1.4.10';
|
||||
|
||||
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
|
||||
function parseNumOrNull(text) {
|
||||
@ -185,46 +185,6 @@ export function renderSettings(container) {
|
||||
}, t('save')));
|
||||
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) ───────────────────────────────────────
|
||||
const tzCard = el('div', { class: 'card' });
|
||||
tzCard.appendChild(el('h3', {}, t('timezone_section')));
|
||||
@ -381,23 +341,13 @@ export function renderSettings(container) {
|
||||
store.setTConfig(data.tConfig);
|
||||
// ⚠️ Paramètres du backup (null = backup v1 → ne rien toucher)
|
||||
if (data.settings) {
|
||||
const patch = {
|
||||
store.setSettings({
|
||||
autoCalibrate: data.settings.autoCalibrate ?? false,
|
||||
alertE2High: data.settings.alertE2High ?? null,
|
||||
alertE2Low: data.settings.alertE2Low ?? null,
|
||||
alertTHigh: data.settings.alertTHigh ?? 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) {
|
||||
store.setSettings({ language: data.settings.language });
|
||||
}
|
||||
|
||||
@ -2,13 +2,8 @@
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Éditeur de traitement (portage de `ui/screens/TreatmentEditorScreen.kt`).
|
||||
*
|
||||
* - presets (PKPresets, 25 entrées) pré-remplissent tout (création) —
|
||||
* 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 ») ;
|
||||
* - presets (PKPresets, 22 entrées) pré-remplissent tout (création) ;
|
||||
* - champs conditionnels : ester + modèle PK si injection, Bateman sinon ;
|
||||
* - carte Calibration : facteur manuel + bouton « Calibrer avec les
|
||||
* analyses » (computeScaleFactor — médiane lab ÷ prédiction, garde #61) ;
|
||||
* hint quand l'auto-calibration écrase le facteur à l'affichage (v1.4.9) ;
|
||||
@ -27,7 +22,7 @@
|
||||
*/
|
||||
|
||||
import { store } from '../data/store.js';
|
||||
import { newTreatment, isInjectionRoute, typeLabelKey, routeLabelKey, modelLabelKey, choicesForModel } from '../data/models.js';
|
||||
import { newTreatment, isInjectionRoute, typeLabelKey, routeLabelKey, modelLabelKey } from '../data/models.js';
|
||||
import { all as allPresets } from '../pk/presets.js';
|
||||
import { computeScaleFactor } from '../pk/pk-calibration.js';
|
||||
import { t } from '../util/i18n.js';
|
||||
@ -129,25 +124,14 @@ export function renderTreatmentEditor(container, ctx) {
|
||||
}));
|
||||
|
||||
if (isInjectionRoute(st.route)) {
|
||||
// Ester : liste = couverture RÉELLE du modèle choisi (v1.9.4 —
|
||||
// miroir Android Esters.choicesForModel) : ESE offre ses 6 esters
|
||||
// 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 }));
|
||||
// Ester : liste simple (miroir Android — EV/EU/EEn dans l'éditeur ;
|
||||
// les 7 esters sont proposables dans l'OVERRIDE par dose du DoseDialog)
|
||||
const esterOptions = ['EV', 'EU', 'EEN'].map((x) => ({ value: x, label: x }));
|
||||
form.appendChild(dropdown(t('ester'), st.ester, esterOptions, (v) => { st.ester = v; }));
|
||||
|
||||
// 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).
|
||||
// Modèle PK : 3 choix (v1.4.6 — WHSAH ajouté)
|
||||
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;
|
||||
if (!choicesForModel(v).includes(st.ester)) st.ester = choicesForModel(v)[0];
|
||||
rerenderForm();
|
||||
}));
|
||||
form.appendChild(dropdown(t('pk_model'), st.model, modelOptions, (v) => { st.model = v; }));
|
||||
} else {
|
||||
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' } }));
|
||||
|
||||
@ -5,15 +5,11 @@
|
||||
* Cartes (nom, voie, dose, chips ester·modèle / Tmax / ×scale / ⏰ rappel,
|
||||
* badge inactif) → tap = éditeur. ⚠️ Le chip modèle passe par modelLabelKey
|
||||
* (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 { modelLabelKey, routeLabelKey, treatmentFlags, treatmentsForDisplay } from '../data/models.js';
|
||||
import { modelLabelKey, routeLabelKey, treatmentFlags } from '../data/models.js';
|
||||
import { t } from '../util/i18n.js';
|
||||
import { formatDose } from '../util/format.js';
|
||||
import { el, assistChip, fab } from './components.js';
|
||||
@ -24,10 +20,7 @@ import { el, assistChip, fab } from './components.js';
|
||||
* @param {object} ctx { openEditor: (id:number)=>void, openNew: ()=>void }
|
||||
*/
|
||||
export function renderTreatments(container, ctx) {
|
||||
// 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 treatments = store.getTreatments();
|
||||
|
||||
const col = el('div', {});
|
||||
container.appendChild(col);
|
||||
@ -36,18 +29,9 @@ export function renderTreatments(container, ctx) {
|
||||
col.appendChild(el('p', {}, t('no_data')));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) => {
|
||||
for (const tr of treatments) {
|
||||
const flags = treatmentFlags(tr); // miroir des getters Kotlin
|
||||
const card = el('div', {
|
||||
class: 'card',
|
||||
style: `cursor:pointer;${dimmed ? 'opacity:0.55;' : ''}`,
|
||||
onclick: () => ctx.openEditor(tr.id),
|
||||
});
|
||||
const card = el('div', { class: 'card', style: 'cursor:pointer;', onclick: () => ctx.openEditor(tr.id) });
|
||||
|
||||
const titleRow = el('div', { class: 'row' });
|
||||
titleRow.appendChild(el('span', { style: 'font-weight:600;flex:1;' }, tr.name));
|
||||
@ -71,19 +55,7 @@ export function renderTreatments(container, ctx) {
|
||||
chipRow.appendChild(assistChip(`⏰ ${hh}:${mm}`));
|
||||
}
|
||||
card.appendChild(chipRow);
|
||||
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));
|
||||
col.appendChild(card);
|
||||
}
|
||||
|
||||
container.appendChild(fab(ctx.openNew));
|
||||
|
||||
@ -113,33 +113,6 @@ export function formatLongDate(ms, timeZone = null) {
|
||||
}), 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
|
||||
* inclus, cf v1.4.1).
|
||||
@ -199,73 +172,3 @@ export function inputValuesToMs(dateISO, timeHM) {
|
||||
const ms = new Date(y, mo - 1, d, h, mi, 0, 0).getTime();
|
||||
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 };
|
||||
}
|
||||
|
||||
@ -42,7 +42,6 @@ const EN = {
|
||||
no_data_short: 'No data — log an injection to see estimates',
|
||||
delta_6h: '{0} {1} pg/mL vs 6 h ago',
|
||||
next_dose: 'Next dose',
|
||||
next_doses: 'Upcoming doses',
|
||||
next_dose_days: '{0} d {1} h · {2} ({3})',
|
||||
logged_today: 'Quick log',
|
||||
home_chart_title: 'Last 24 h',
|
||||
@ -64,20 +63,8 @@ const EN = {
|
||||
// Labs
|
||||
edit_which_lab: 'Which entry to edit?',
|
||||
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
|
||||
inactive: 'inactive',
|
||||
inactive_section: 'Inactive treatments',
|
||||
pk_absorption: 'Tmax',
|
||||
pk_halflife: 'Half-life (h)',
|
||||
pk_bioavail: 'Bioavailability (0–1)',
|
||||
@ -194,24 +181,6 @@ const EN = {
|
||||
show_labs: 'Labs',
|
||||
show_forecast: 'Forecast',
|
||||
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',
|
||||
// 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',
|
||||
legend_ese_e2: 'E2 — Estrannaise (blue)',
|
||||
legend_ese_t: 'T — Estrannaise (pink)',
|
||||
@ -222,9 +191,6 @@ const EN = {
|
||||
legend_labs: '● labs (orange) — circle = E2, square = T',
|
||||
legend_forecast_dose: 'Dashed line + ▼ = projected dose',
|
||||
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)',
|
||||
// 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).',
|
||||
// Rappels (bannière + notification)
|
||||
reminder_notif_title: 'HormoneTrack — dose reminder',
|
||||
@ -265,7 +231,6 @@ const FR = {
|
||||
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',
|
||||
next_dose: 'Prochaine dose',
|
||||
next_doses: 'Prochaines doses',
|
||||
next_dose_days: '{0} j {1} h · {2} ({3})',
|
||||
logged_today: 'Log rapide',
|
||||
home_chart_title: 'Dernières 24 h',
|
||||
@ -284,19 +249,7 @@ const FR = {
|
||||
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 ?',
|
||||
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_section: 'Traitements inactifs',
|
||||
pk_absorption: 'Tmax',
|
||||
pk_halflife: 'Demi-vie (h)',
|
||||
pk_bioavail: 'Biodisponibilité (0–1)',
|
||||
@ -404,22 +357,6 @@ const FR = {
|
||||
show_labs: 'Labs',
|
||||
show_forecast: 'Prévision',
|
||||
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',
|
||||
// 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',
|
||||
legend_ese_e2: 'E2 — Estrannaise (bleu)',
|
||||
legend_ese_t: 'T — Estrannaise (rose)',
|
||||
@ -430,9 +367,6 @@ const FR = {
|
||||
legend_labs: '● labs (orange) — cercle = E2, carré = T',
|
||||
legend_forecast_dose: 'Ligne pointillée + ▼ = dose projetée',
|
||||
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)',
|
||||
// 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).',
|
||||
reminder_notif_title: 'HormoneTrack — rappel de dose',
|
||||
reminder_notif_body: '{0} · {1}{2} — prévu à {3}',
|
||||
@ -447,9 +381,6 @@ const FR = {
|
||||
/** Presets (même libellé que les strings Android). */
|
||||
const EN_PRESETS = {
|
||||
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_een_ese: 'EEn injection — Estrannaise',
|
||||
preset_ev_tfs: 'EV injection — Transfem Science',
|
||||
@ -475,9 +406,6 @@ const EN_PRESETS = {
|
||||
|
||||
const FR_PRESETS = {
|
||||
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_een_ese: 'Injection EEn — Estrannaise',
|
||||
preset_ev_tfs: 'Injection EV — Transfem Science',
|
||||
|
||||
58
nginx.conf
58
nginx.conf
@ -1,58 +0,0 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Config nginx du site HormoneTrack Web (conteneur — remplace le default.conf).
|
||||
#
|
||||
# La partie « serveur statique » est triviale ; les choix qui comptent :
|
||||
#
|
||||
# 1. **No-cache systématique** — raison : l'app est une SPA versionnée par un
|
||||
# jeu de fichiers (index.html + modules ES + asset PK) qui doivent rester
|
||||
# cohérents entre eux. Un cache client périmé AU MOMENT d'une mise à jour
|
||||
# d'image = code JS ancien + changelog nouveau = état incohérent. Le site
|
||||
# complet ≈ 600 Ko : le coût du re-téléchargement est négligeable sur un
|
||||
# LAN/VPN, et la fraîcheur après mise à jour est garantie.
|
||||
#
|
||||
# 2. **gzip** — l'asset pk_profiles.json (550 Ko de floats) se compresse
|
||||
# ~70 % : prend le seul fichier lourd en charge.
|
||||
#
|
||||
# 3. **En-têtes de sécurité minimaux** (site statique sans API, sans cookie,
|
||||
# sans iframe légitime).
|
||||
#
|
||||
# Ce fichier est copié dans /etc/nginx/conf.d/default.conf (server block) par
|
||||
# le Dockerfile — le http{} parent de l'image inclut déjà mime.types (.js
|
||||
# servi en application/javascript : requis pour les modules ES).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
server {
|
||||
listen 8080;
|
||||
listen [::]:8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# gzip : l'asset PK (550 Ko) se compresse ~4× — le seul fichier lourd
|
||||
gzip on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 5;
|
||||
gzip_types application/json application/javascript text/css text/markdown text/plain;
|
||||
|
||||
# ── Headers appliqués à toutes les réponses ──────────────────────────────
|
||||
# No-cache « always » : une mise à jour d'image est prise en compte au
|
||||
# rechargement suivant, sans qu'aucun client conserve du code périmé.
|
||||
add_header Cache-Control "no-cache, must-revalidate" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
|
||||
# ── Routing ─────────────────────────────────────────────────────────────
|
||||
# SPA à fichier unique : pas de réécriture (le routing est par HASH
|
||||
# `#chart` etc., jamais par chemin) — un simple fallback 404 suffit.
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
# Défensif : ne jamais servir les fichiers cachés (.gitignore,
|
||||
# .dockerignore — ils sont normalement exclus par .dockerignore, la
|
||||
# règle reste en filet si quelqu'un build depuis une copie non propre).
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hormonetrack-web",
|
||||
"version": "1.5.0",
|
||||
"version": "1.4.10",
|
||||
"description": "HormoneTrack — version web (100% locale, localStorage). Portage navigateur de l'app Android.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# check.sh — vérifications de l'app web (pré-commit / pré-push / pré-release).
|
||||
# check.sh — vérifications de l'app web (pré-commit / pré-push).
|
||||
#
|
||||
# 1. node --check sur chaque module ES (syntaxe) — attrape les typos avant
|
||||
# même de charger l'app dans un navigateur ;
|
||||
@ -12,26 +12,12 @@
|
||||
# install firefox, cf docs/DEVELOPPEMENT.md §Tests) ;
|
||||
# 6. smoke HTTP (--with-serve : serveur + curl des ressources clés).
|
||||
#
|
||||
# Mode RELEASE (--release) : ajoute les vérifications OBLIGATOIRES avant une
|
||||
# release (cf docs/DEVELOPPEMENT.md §10) —
|
||||
# 7. cohérence de la version : WEB_VERSION == section `## [X.Y.Z]` de
|
||||
# docs/CHANGELOG.md == tag annoté `vX.Y.Z` existant (le bump de version
|
||||
# jamais commité côté Android, bug #46, ne doit pas se reproduire ici) ;
|
||||
# 8. arbre git propre (tout commité avant publication) ;
|
||||
# 9. test conteneur RÉEL (scripts/container-test.sh — build/run durci/
|
||||
# headers/gzip/MIME/E2E contre le conteneur) si un daemon Docker est
|
||||
# joignable, ignoré sinon (à lancer sur l'hôte qui a Docker).
|
||||
#
|
||||
# Usage : bash scripts/check.sh [--with-serve] [--release]
|
||||
# Usage : bash scripts/check.sh [--with-serve]
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.." # racine du dépôt
|
||||
|
||||
fail=0
|
||||
RELEASE=false
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == "--release" ]] && RELEASE=1
|
||||
done
|
||||
|
||||
echo "── 1. Syntaxe des modules (node --check) ──"
|
||||
while IFS= read -r f; do
|
||||
@ -63,44 +49,6 @@ if ! node scripts/i18n-check.mjs; then
|
||||
fail=1
|
||||
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 ──"
|
||||
if ! node --test tests/ 2>&1 | tail -6; then
|
||||
fail=1
|
||||
@ -133,47 +81,6 @@ if [[ "${1:-}" == "--with-serve" ]]; then
|
||||
wait $SRV 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [[ "$RELEASE" == "1" ]]; then
|
||||
echo "── 7. Cohérence de la version (release) ──"
|
||||
ver=$(node --input-type=module -e "
|
||||
import { WEB_VERSION } from './js/ui/settings.js';
|
||||
process.stdout.write(WEB_VERSION);")
|
||||
verGrep=$(echo "$ver" | sed 's/\./\\./g') # 1.4.10 → 1\.4\.10 pour grep -E
|
||||
if grep -qE "^## \\[$verGrep\\]" docs/CHANGELOG.md; then
|
||||
echo " WEB_VERSION = $ver ; section [$ver] présente dans docs/CHANGELOG.md"
|
||||
else
|
||||
echo "✗ WEB_VERSION ($ver) n'a pas de section correspondante dans docs/CHANGELOG.md"
|
||||
fail=1
|
||||
fi
|
||||
# Le bump de version jamais commité (bug Android #46) ne doit pas se
|
||||
# reproduire : la release est faite SUR un tag qui existe.
|
||||
if git rev-parse -q --verify "refs/tags/v$ver" >/dev/null 2>&1; then
|
||||
echo " tag v$ver présent"
|
||||
else
|
||||
echo "✗ tag v$ver ABSENT — créer et pousser le tag avant la release (leçon Android #46)"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
echo "── 8. Arbre git propre avant publication ──"
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "✗ working tree sale — tout committer avant la release"
|
||||
git status --short | head -5
|
||||
fail=1
|
||||
else
|
||||
echo " working tree propre"
|
||||
fi
|
||||
|
||||
echo "── 9. Test conteneur RÉEL (si daemon Docker joignable) ──"
|
||||
if docker info --format x >/dev/null 2>&1; then
|
||||
if ! bash scripts/container-test.sh 8975; then
|
||||
fail=1
|
||||
fi
|
||||
else
|
||||
echo " (ignoré : aucun daemon Docker ici — lancer sur l'hôte cible :"
|
||||
echo " bash scripts/container-test.sh ; cf docs/DEVELOPPEMENT.md §10)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $fail -eq 0 ]]; then
|
||||
echo "✓ CHECK OK"
|
||||
else
|
||||
|
||||
@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# container-test.sh — validation RÉELLE de l'image Docker HormoneTrack Web.
|
||||
#
|
||||
# C'est le test qui doit passer AVANT toute release web déployable en
|
||||
# conteneur (cf docs/DEVELOPPEMENT.md §10) : il attrape ce qu'aucun test
|
||||
# statique ne voit — MIME des modules ES (requis par les navigateurs),
|
||||
# headers réellement émis, gzip appliqué, fichiers exclus non servis,
|
||||
# healthcheck, et le comportement de l'app COMPLÈTE servie par le conteneur
|
||||
# (via l'E2E playwright branclé sur le conteneur).
|
||||
#
|
||||
# Prérequis : docker (daemon) ; node + playwright pour l'E2E final (skippé
|
||||
# sinon — cf check.sh pour l'installation).
|
||||
#
|
||||
# Usage : bash scripts/container-test.sh [port] [platform]
|
||||
# [port] défaut 8975
|
||||
# [platform] platform Docker optionnelle (ex. linux/amd64) — permet de
|
||||
# tester l'image pour L'HÔTE CIBLE depuis un Mac arm64 : sans
|
||||
# elle, on build+run l'arch native (arm64) ; avec platform,
|
||||
# buildx produit l'image pour CETTE platform (build natif —
|
||||
# le Dockerfile ne contient AUCUN RUN, cross-compile sans
|
||||
# émulation de build) et le run passe par l'émulation de
|
||||
# l'hôte (Rosetta/qemu). ⚠️ Le bug réel du 8 sept. —
|
||||
# « exec /docker-entrypoint.sh: exec format error » sur le
|
||||
# NAS amd64 — venait d'une image arm64 uniquement : C'EST CE
|
||||
# TEST cross-arch qui l'eût attrapé.
|
||||
#
|
||||
# ⚠️ Un SEUL invocation fait tout (leçon Android #43 : les invocations
|
||||
# rapprochées se remplacent mutuellement) et l'échec est BRUYANT (exit 1
|
||||
# à la première vérification fausse — jamais de « probablement bon »).
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.." # racine du dépôt
|
||||
|
||||
PORT="${1:-8975}"
|
||||
PLATFORM="${2:-}"
|
||||
HOST_PORT="127.0.0.1:${PORT}"
|
||||
# Suffixe de tag par platevorme pour ne jamais mélanger les images
|
||||
TAG_SUFFIX="smoke"
|
||||
PLATFORM_ARG=()
|
||||
if [[ -n "${PLATFORM:-}" ]]; then
|
||||
TAG_SUFFIX="smoke-${PLATFORM##*/}" # linux/amd64 → smoke-amd64
|
||||
PLATFORM_ARG=(--platform "$PLATFORM")
|
||||
fi
|
||||
IMAGE="hormonetrack-web:${TAG_SUFFIX}"
|
||||
CONTAINER="hrt-web-${TAG_SUFFIX}"
|
||||
ROOM=0 # compteur d'échecs
|
||||
|
||||
fail() { echo " ✗ $1"; ROOM=$((ROOM + 1)); }
|
||||
ok() { echo " ✓ $1"; }
|
||||
|
||||
cleanup() {
|
||||
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "── 1. Build de l'image${PLATFORM:+ [${PLATFORM}]} ──"
|
||||
docker build "${PLATFORM_ARG[@]}" -t "$IMAGE" . 2>&1 | tail -2
|
||||
|
||||
echo "── 2. Run (durcissement compose : read_only + no-new-privileges) ──"
|
||||
# On teste l'image dans la configuration DURCIE de docker-compose.yml —
|
||||
# si ces options ne passent pas ici, elles n'auraient pas mieux passé en
|
||||
# prod. ⚠️ tmpfs /tmp requis : l'image nginx-unprivileged écrit ses
|
||||
# temporaires (proxy_temp…) dans /tmp — en read-only pur, nginx émerge
|
||||
# « mkdir /tmp/proxy_temp failed » (constaté au premier container-test).
|
||||
docker run -d --rm --name "$CONTAINER" \
|
||||
-p "${HOST_PORT}:8080" \
|
||||
"${PLATFORM_ARG[@]}" \
|
||||
--read-only \
|
||||
--tmpfs /var/cache/nginx --tmpfs /run --tmpfs /tmp \
|
||||
--security-opt no-new-privileges \
|
||||
"$IMAGE" >/dev/null
|
||||
|
||||
echo "── 3. Healthcheck (statut docker healthy) ──"
|
||||
state=""
|
||||
for _ in $(seq 1 30); do
|
||||
state=$(docker inspect --format '{{.State.Health.Status}}' "$CONTAINER" 2>/dev/null || echo "")
|
||||
if [[ "$state" == "healthy" ]]; then ok "healthcheck : healthy"; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[[ "$state" == "healthy" ]] || fail "healthcheck jamais healthy (état: '${state:-inconnu}')"
|
||||
|
||||
echo "── 4. Endpoints clés (HTTP 200) ──"
|
||||
# Les MEMES chemins que le smoke HTTP de check.sh — le conteneur doit les
|
||||
# servir TOUS (l'oubli de docs/ casserait le dialog « Nouveautés »).
|
||||
for path in / /css/style.css /assets/pk_profiles.json /js/ui/app.js /docs/CHANGELOG.md; do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://${HOST_PORT}${path}")
|
||||
if [[ "$code" == "200" ]]; then ok "200 ${path}"; else fail "${path} → HTTP ${code}"; fi
|
||||
done
|
||||
|
||||
echo "── 5. Headers (no-cache + sécurité) ──"
|
||||
hdr=$(curl -s -D - -o /dev/null "http://${HOST_PORT}/")
|
||||
for want in "no-cache" "nosniff" "DENY" "no-referrer"; do
|
||||
if echo "$hdr" | grep -qi "$want"; then ok "header présent : $want"; else fail "header manquant : $want"; fi
|
||||
done
|
||||
|
||||
echo "── 5. gzip effectif sur l'asset PK (550 Ko → ~150 Ko) ──"
|
||||
enc=$(curl -s -H 'Accept-Encoding: gzip' -o /dev/null -w '%{header_json}' \
|
||||
"http://${HOST_PORT}/assets/pk_profiles.json")
|
||||
# ⚠️ curl %header_json produit des clés MINUSCULES (convention JSON) —
|
||||
# chercher « content-encoding » pas « Content-Encoding ».
|
||||
if echo "$enc" | grep -qi 'content-encoding.*gzip'; then ok "gzip appliqué"; else fail "pas de gzip sur l'asset — ${enc}"; fi
|
||||
|
||||
echo "── 6. MIME des modules ES (requête STRICTE des navigateurs) ──"
|
||||
# Le piège réel du portage : un .js servi en text/html → « disallowed MIME
|
||||
# type » et l'app ne charge PAS (cf §11 #6). On vérifie le CType réel.
|
||||
mime=$(curl -s -o /dev/null -w '%{content_type}' "http://${HOST_PORT}/js/ui/app.js")
|
||||
if echo "$mime" | grep -qi 'javascript'; then ok "module ES : ${mime}"; else fail "CType module ES = ${mime} (javascript requis)"; fi
|
||||
|
||||
echo "── 7. Fichiers cachés NON servis (défensif) ──"
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' "http://${HOST_PORT}/.gitignore")
|
||||
if [[ "$code" == "403" || "$code" == "404" ]]; then ok "fichier caché non servi (${code})"; else fail "fichier caché servi → HTTP ${code}"; fi
|
||||
|
||||
echo "── 8. Le conteneur ne contient AUCUNE donnée de dev ──"
|
||||
# Audit de contenu : ce qui est dans l'image (garde double de .dockerignore)
|
||||
for excl in package-lock.json node_modules; do
|
||||
if docker exec "$CONTAINER" find /usr/share/nginx/html -name "$excl" 2>/dev/null | grep -q .; then
|
||||
fail "$excl trouvé dans l'image"
|
||||
else
|
||||
ok "image propre : pas de ${excl}"
|
||||
fi
|
||||
done
|
||||
# docs/ DOIT être présent (dialog changelog)
|
||||
if docker exec "$CONTAINER" test -f /usr/share/nginx/html/docs/CHANGELOG.md; then
|
||||
ok "docs/CHANGELOG.md embarqué (dialog « Nouveautés »)"
|
||||
else
|
||||
fail "docs/CHANGELOG.md ABSENT de l'image — le dialog « Nouveautés » planerait"
|
||||
fi
|
||||
|
||||
echo "── 9. E2E navigateur complet contre le conteneur (si playwright dispo) ──"
|
||||
if [[ -d node_modules/playwright-core && -d "$HOME/Library/Caches/ms-playwright" ]]; then
|
||||
if HRT_E2E_BASE="http://${HOST_PORT}" node scripts/e2e.mjs; then
|
||||
ok "E2E complet contre le conteneur"
|
||||
else
|
||||
fail "E2E contre le conteneur"
|
||||
fi
|
||||
else
|
||||
echo " (ignoré : playwright non installé — npm i && npx playwright install firefox)"
|
||||
fi
|
||||
|
||||
if [[ $ROOM -eq 0 ]]; then
|
||||
echo "✓ CONTAINER TEST OK"
|
||||
else
|
||||
echo "✗ CONTAINER TEST : ${ROOM} échec(s)"
|
||||
exit 1
|
||||
fi
|
||||
187
scripts/e2e.mjs
187
scripts/e2e.mjs
@ -40,23 +40,8 @@ function ok(condition, label, detail = '') {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le serveur statique de dev et attend qu'il réponde.
|
||||
*
|
||||
* ⚠️ MODE CONTENEUR : si la variable d'environnement HRT_E2E_BASE est définie
|
||||
* (ex. HRT_E2E_BASE=http://127.0.0.1:8080), le serveur local n'est PAS
|
||||
* démarré — l'E2E pilote l'app SÉRVIE PAR CE BASE. C'est ce qui permet de
|
||||
* valider l'image Docker avec ce même test (cf docs/DEVELOPPEMENT.md §10
|
||||
* « Valider le conteneur ») : build + run sur l'hôte Docker, puis
|
||||
* `HRT_E2E_BASE=http://… node scripts/e2e.mjs`.
|
||||
*
|
||||
* @returns {process|null} le process serveur (à tuer à la fin), null en mode externe
|
||||
*/
|
||||
/** Démarre le serveur statique de dev et attend qu'il réponde. */
|
||||
async function startServer() {
|
||||
if (process.env.HRT_E2E_BASE) {
|
||||
console.log(`serveur EXTERNE (HRT_E2E_BASE) : ${process.env.HRT_E2E_BASE}`);
|
||||
return null; // rien à démarrer, rien à tuer
|
||||
}
|
||||
const proc = spawn('python3', ['scripts/serve.py', '--port', String(PORT)], {
|
||||
cwd: WEB_ROOT,
|
||||
stdio: 'ignore',
|
||||
@ -75,10 +60,7 @@ async function startServer() {
|
||||
async function main() {
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
const server = await startServer();
|
||||
// BASE peut avoir été surchargée par HRT_E2E_BASE (mode conteneur) — les
|
||||
// navigations ci-dessous ciblent quel que soit le serveur qui répond.
|
||||
const base = process.env.HRT_E2E_BASE || BASE;
|
||||
console.log(`cible E2E : ${base}`);
|
||||
console.log(`serveur : ${BASE}`);
|
||||
|
||||
const browser = await firefox.launch({
|
||||
headless: true,
|
||||
@ -108,7 +90,7 @@ async function main() {
|
||||
try {
|
||||
// ── 1. Accueil avec données de démo ─────────────────────────────────────
|
||||
console.log('── Accueil (?demo=1) ──');
|
||||
await page.goto(`${base}/?demo=1`, { waitUntil: 'load' });
|
||||
await page.goto(`${BASE}/?demo=1`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('body[data-ready="1"]', { timeout: 15000 });
|
||||
await page.waitForSelector('.card', { timeout: 15000 });
|
||||
const bodyText = await page.textContent('main.content');
|
||||
@ -131,11 +113,7 @@ async function main() {
|
||||
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
|
||||
if (dialog) {
|
||||
const dlgText = await dialog.textContent();
|
||||
// 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');
|
||||
ok(dlgText.includes('Version 1.4.10'), 'dialog : section de la version courante');
|
||||
const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
|
||||
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
|
||||
await page.click('.dialog .actions button');
|
||||
@ -148,7 +126,7 @@ async function main() {
|
||||
|
||||
// ── 2. Graphiques : le Canvas peint de vraies courbes ───────────────────
|
||||
console.log('── Graphiques (#chart) ──');
|
||||
await page.goto(`${base}/#chart`, { waitUntil: 'load' });
|
||||
await page.goto(`${BASE}/#chart`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('canvas.chart', { timeout: 15000 });
|
||||
await page.waitForTimeout(600); // requestAnimationFrame du dessin
|
||||
const canvasInfo = await page.evaluate(() => {
|
||||
@ -171,135 +149,9 @@ async function main() {
|
||||
ok(legendText.includes('Glisser horizontalement'), 'hint panoramique');
|
||||
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 ────────────────────────────
|
||||
console.log('── Navigation ──');
|
||||
await page.goto(`${base}/#doses`, { waitUntil: 'load' });
|
||||
await page.goto(`${BASE}/#doses`, { waitUntil: 'load' });
|
||||
await page.waitForSelector('body[data-ready="1"]');
|
||||
await page.waitForTimeout(300);
|
||||
let text = await page.textContent('main.content');
|
||||
@ -307,38 +159,22 @@ async function main() {
|
||||
ok(text.includes('EEn — démo'), 'Doses : la dose de démo est listée');
|
||||
ok(text.includes('7.0 j depuis la précédente'), 'Doses : intervalle entre doses');
|
||||
|
||||
await page.goto(`${base}/#labs`, { waitUntil: 'load' });
|
||||
await page.goto(`${BASE}/#labs`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(300);
|
||||
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)');
|
||||
// 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);
|
||||
text = await page.textContent('main.content');
|
||||
ok(text.includes('EEn — démo'), 'Traitements : carte du traitement démo');
|
||||
ok(text.includes('Transfem Science'), 'Traitements : chip modèle (modelLabelKey)');
|
||||
|
||||
await page.goto(`${base}/#settings`, { waitUntil: 'load' });
|
||||
await page.goto(`${BASE}/#settings`, { waitUntil: 'load' });
|
||||
await page.waitForTimeout(300);
|
||||
text = await page.textContent('main.content');
|
||||
ok(text.includes('Langue'), 'Paramètres : carte langue');
|
||||
ok(text.includes(`Version ${await page.getAttribute('body', 'data-version')} (web)`), 'Paramètres : version affichée');
|
||||
ok(text.includes('Version 1.4.10 (web)'), 'Paramètres : version affichée');
|
||||
ok(text.includes('100 % local'), 'Paramètres : note vie privée');
|
||||
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });
|
||||
|
||||
@ -349,8 +185,7 @@ async function main() {
|
||||
consoleErrors.slice(0, 3).join(' | '));
|
||||
} finally {
|
||||
await browser.close();
|
||||
// Mode conteneur : pas de serveur local démarré → rien à tuer (cf startServer)
|
||||
server?.kill();
|
||||
server.kill();
|
||||
}
|
||||
|
||||
if (failures === 0) {
|
||||
|
||||
@ -1,205 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publie la release Gitea d'une version WEB (attache l'archive zip du site,
|
||||
VÉRIFIÉE PAR TÉLÉCHARGEMENT) — en UNE SEULE INVOCATION.
|
||||
|
||||
Portage du script Android `publish-release.py` (dépôt HormoneTrack),
|
||||
adapté au web :
|
||||
- UN SEUL artifact : `HormoneTrack-web-vX.Y.Z.zip` = déploiement
|
||||
statique sans git (index.html + css/ + js/ + assets/ + docs/ +
|
||||
LICENSE/README — le contenu runtime, cf .dockerignore) ;
|
||||
- l'archive est construite PAR `git archive` AU NIVEAU DU TAG : le zip
|
||||
correspond EXACTEMENT à ce qui est taggué (aucune divergence possible
|
||||
working tree ↔ release) ;
|
||||
- le corps de release = section `## [X.Y.Z]` de docs/CHANGELOG.md
|
||||
(identique à l'Android — c'est le même parseur, piège #31 : tag « vX »
|
||||
mais CHANGELOG sans v) ;
|
||||
- mêmes gardes que l'Android (leçons #37/#43) : purge des assets avant
|
||||
upload, vérification nom+taille par TÉLÉCHARGEMENT immédiat,
|
||||
re-vérification finale, échec BRUYANT.
|
||||
|
||||
⚠️ Note de parité : les APK Android mentionnent le tag du dépôt Android ; ce
|
||||
script ne doit être lancé QUE sur les tags du dépôt web (versions alignées).
|
||||
|
||||
Usage :
|
||||
python3 scripts/publish-release.py [instance] vX.Y.Z
|
||||
|
||||
Auth : trousseau macOS (security find-internet-password -s <hôte> -w),
|
||||
scope write:repository (suffit pour releases + assets).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
# Instances connues — miroir du script Android (mêmes noms courts)
|
||||
INSTANCES = {
|
||||
"cloudyfy": {"url": "https://gitea.cloudyfy.fr", "owner": "Siphonight",
|
||||
"host": "gitea.cloudyfy.fr"},
|
||||
"farewell": {"url": "https://gitea.farewell.dev", "owner": "Siphonight",
|
||||
"host": "gitea.farewell.dev"},
|
||||
}
|
||||
|
||||
REPO_NAME = "HormoneTrack-web"
|
||||
CHANGELOG = "docs/CHANGELOG.md"
|
||||
|
||||
|
||||
def gitea_token(host: str) -> str:
|
||||
out = subprocess.run(
|
||||
["security", "find-internet-password", "-s", host, "-w"],
|
||||
capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
if not out:
|
||||
sys.exit(f"Token Gitea introuvable dans le trousseau pour {host}\n"
|
||||
f" security add-internet-password -s {host} -a Siphonight -w <TOKEN> -U")
|
||||
return out
|
||||
|
||||
|
||||
def request(url: str, method: str = "GET", data=None, token: str = "") -> dict:
|
||||
req = urllib.request.Request(url, method=method)
|
||||
req.add_header("Authorization", f"token {token}")
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.data = json.dumps(data).encode()
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
payload = resp.read().decode()
|
||||
return json.loads(payload) if payload.strip() else {}
|
||||
|
||||
|
||||
def _delivered_size(base: str, token: str, url: str) -> int:
|
||||
"""Taille RÉELLEMENT délivrée — le listing API peut mentir (leçon Android
|
||||
#37/#43 : on ne fait jamais confiance au listing, on télécharge)."""
|
||||
r = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(r, timeout=300) as resp:
|
||||
return len(resp.read())
|
||||
|
||||
|
||||
def _upload_asset(base: str, token: str, release_id: int, name: str, path: str) -> dict:
|
||||
boundary = uuid.uuid4().hex
|
||||
head = (f"--{boundary}\r\n"
|
||||
f"Content-Disposition: form-data; name=\"attachment\"; filename=\"{name}\"\r\n"
|
||||
f"Content-Type: application/zip\r\n\r\n")
|
||||
tail = f"\r\n--{boundary}--\r\n"
|
||||
with open(path, "rb") as f:
|
||||
req = urllib.request.Request(
|
||||
f"{base}/releases/{release_id}/assets?name={name}", method="POST",
|
||||
headers={"Authorization": f"token {token}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}"})
|
||||
req.data = head.encode() + f.read() + tail.encode()
|
||||
with urllib.request.urlopen(req, timeout=300) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def changelog_section(version: str) -> str:
|
||||
"""Corps de release = section `## [X.Y.Z]` de docs/CHANGELOG.md.
|
||||
⚠️ Même piège Android #31 : le tag porte le « v », la CHANGELOG non."""
|
||||
num = version.removeprefix("v")
|
||||
text = open(CHANGELOG, encoding="utf-8").read()
|
||||
m = re.search(rf"## \[{re.escape(num)}\][^\n]*\n(.*?)(?=\n## \[|\Z)", text, re.S)
|
||||
if not m:
|
||||
sys.exit(f"Section [{num}] introuvable dans {CHANGELOG}")
|
||||
return m.group(1).strip()
|
||||
|
||||
|
||||
def verify_asset(base: str, token: str, release_id: int, asset_id: int,
|
||||
name: str, expected: int) -> bool:
|
||||
"""Vérifie par TÉLÉCHARGEMENT que l'asset a le bon nom et la bonne taille
|
||||
(le listing API a déjà menti côté Android — on ne le croit pas)."""
|
||||
release = request(f"{base}/releases/{release_id}", token=token)
|
||||
current = next((a for a in release["assets"] if a["id"] == asset_id), None) \
|
||||
if asset_id else next((a for a in release["assets"] if a["name"] == name), None)
|
||||
if current is None or current["name"] != name:
|
||||
return False
|
||||
if abs(current["size"] - expected) > 1000:
|
||||
return False
|
||||
delivered = _delivered_size(base, token, current["browser_download_url"])
|
||||
return delivered == expected
|
||||
|
||||
|
||||
def build_zip(tag: str) -> str:
|
||||
"""Construit l'archive de déploiement PAR GIT ARCHIVE au niveau du TAG —
|
||||
le zip publié = EXACTEMENT ce que le tag contient (jamais le working
|
||||
tree, qui peut être en avance). Exclusions : outils dev (tests/scripts)
|
||||
+ fichiers du conteneur — le même contenu que l'image Docker (cf
|
||||
.dockerignore) ; pour Docker, l'utilisatrice build depuis le dépôt.
|
||||
"""
|
||||
out = f"/tmp/HormoneTrack-web-{tag}.zip"
|
||||
paths = ["index.html", "css", "js", "assets", "docs", "LICENSE", "README.md"]
|
||||
subprocess.run(
|
||||
["git", "archive", "--format=zip", f"-o{out}", tag] + paths, check=True)
|
||||
print(f" zip construit depuis le tag : {out} ({os.path.getsize(out) // 1024} Ko)")
|
||||
return out
|
||||
|
||||
|
||||
def publish_zip(base: str, token: str, release_id: int, tag: str, zip_path: str) -> None:
|
||||
"""Purge + upload du zip + vérification IMMÉDIATE par téléchargement,
|
||||
puis re-vérification FINALE — le pattern Android qui marche
|
||||
(les uploads rapprochés se remplacent mutuellement, lesson #43 :
|
||||
tout est fait dans UNE invocation)."""
|
||||
name = f"HormoneTrack-web-{tag}.zip"
|
||||
|
||||
# 1) Purge de tout asset existant (idempotent : relancer le script est sûr)
|
||||
release = request(f"{base}/releases/{release_id}", token=token)
|
||||
for a in release["assets"]:
|
||||
urllib.request.urlopen(urllib.request.Request(
|
||||
f"{base}/releases/{release_id}/assets/{a['id']}", method="DELETE",
|
||||
headers={"Authorization": f"token {token}"}), timeout=60)
|
||||
print(f" purge : {a['name']}")
|
||||
|
||||
# 2) Upload + vérification immédiate par téléchargement
|
||||
name = f"HormoneTrack-web-{tag}.zip"
|
||||
expected = os.path.getsize(zip_path)
|
||||
uploaded = _upload_asset(base, token, release_id, name, zip_path)
|
||||
if not verify_asset(base, token, release_id, uploaded["id"], name, expected):
|
||||
sys.exit(f"✗ {name} : échec de vérification après upload")
|
||||
print(f" ✓ {name} ({expected // 1024} Ko)")
|
||||
|
||||
# 3) Re-vérification FINALE (la race se voyait seulement ici côté Android)
|
||||
if not verify_asset(base, token, release_id, 0, name, expected):
|
||||
sys.exit(f"✗ {name} : re-vérification finale échouée")
|
||||
print(f" ✓ zip vérifié par téléchargement")
|
||||
|
||||
|
||||
def main():
|
||||
args = sys.argv[1:]
|
||||
instance = "cloudyfy"
|
||||
if args and not args[0].startswith("v"):
|
||||
instance = args.pop(0)
|
||||
if instance not in INSTANCES:
|
||||
sys.exit(f"Instance inconnue : {instance}")
|
||||
if not args:
|
||||
sys.exit(f"Usage: publish-release.py [cloudyfy|farewell] vX.Y.Z")
|
||||
tag = args[0]
|
||||
|
||||
conf = INSTANCES[instance]
|
||||
base = f"{conf['url']}/api/v1/repos/{conf['owner']}/{REPO_NAME}"
|
||||
token = gitea_token(conf["host"])
|
||||
body = changelog_section(tag)
|
||||
zip_path = build_zip(tag)
|
||||
|
||||
# Release : crée ou récupère + PATCH corps (idempotent)
|
||||
try:
|
||||
release = request(f"{base}/releases/tags/{tag}", token=token)
|
||||
request(f"{base}/releases/{release['id']}", "PATCH", token=token,
|
||||
data={"tag_name": tag, "name": tag, "body": body,
|
||||
"draft": False, "prerelease": False})
|
||||
release_id = release["id"]
|
||||
print(f"[{instance}] release {tag} existante (id {release_id}) — corps mis à jour")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404:
|
||||
raise
|
||||
release = request(f"{base}/releases", "POST", token=token, data={
|
||||
"tag_name": tag, "name": tag, "body": body,
|
||||
"draft": False, "prerelease": False})
|
||||
release_id = release["id"]
|
||||
print(f"[{instance}] release {tag} créée (id {release_id})")
|
||||
|
||||
publish_zip(base, token, release_id, tag, zip_path)
|
||||
print(f"[{instance}] Release {tag} complète ✓")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -46,11 +46,9 @@ describe('format BackupData v2 (compatibilité Android)', () => {
|
||||
assert.equal(parsed.labResults.length, 1);
|
||||
assert.deepEqual(parsed.tConfig, tConfig);
|
||||
// champs plats UserSettings exactement comme Gson
|
||||
// (v1.13.0 : la cible de creux voyage aussi — champs optionnels)
|
||||
assert.deepEqual(parsed.settings, {
|
||||
language: 'fr', autoCalibrate: true,
|
||||
alertE2High: 400, alertE2Low: 50, alertTHigh: 1.2, alertTLow: null,
|
||||
troughTargetLow: null, troughTargetHigh: null,
|
||||
});
|
||||
// fidélité des entités (champs Gson/Kotlin)
|
||||
assert.equal(parsed.treatments[0].calendarEventId, null);
|
||||
|
||||
@ -1,186 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@ -1,92 +0,0 @@
|
||||
/**
|
||||
* 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})`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,96 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@ -1,45 +0,0 @@
|
||||
/**
|
||||
* 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', '')]), []);
|
||||
});
|
||||
});
|
||||
@ -1,159 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@ -1,259 +0,0 @@
|
||||
/**
|
||||
* Tests « Tracé labs » web (miroir de `LabTrajectoryModelTest.kt` Android)
|
||||
* — mêmes garanties épinglées : passage exact, ρ log-linéaire, garde #61,
|
||||
* 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 assert from 'node:assert/strict';
|
||||
import {
|
||||
initProfiles, makeTreatment, makeDose, makeLab, HOUR_MS, DAY_MS, assertClose,
|
||||
} from './helpers.js';
|
||||
import { e2At, cutoffHours } from '../js/pk/pk-engine.js';
|
||||
import {
|
||||
computeLabAnchoredCurve, ratioAt, extensionHorizonEndMs,
|
||||
} from '../js/pk/lab-trajectory-model.js';
|
||||
/** Traitement injectable EEn/TFS 5 mg, une dose à t=0, scaleFactor paramétrable. */
|
||||
function treatment(scaleFactor = 1.0) {
|
||||
return makeTreatment({ scaleFactor });
|
||||
}
|
||||
|
||||
function doses() {
|
||||
return [makeDose(0, 5)];
|
||||
}
|
||||
|
||||
const lab = (value, ts) => makeLab('E2', value, 'pg/mL', ts);
|
||||
const tLab = (ts) => makeLab('T', 0.35, 'ng/mL', ts);
|
||||
|
||||
describe('LabTrajectoryModel web (miroir Android v1.5.0)', () => {
|
||||
before(() => initProfiles());
|
||||
|
||||
test('passe exactement sur chaque lab', () => {
|
||||
const t2 = 2 * DAY_MS;
|
||||
const t7 = 7 * DAY_MS;
|
||||
const labs = [lab(300, t2), lab(260, t7)];
|
||||
const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
|
||||
assert.equal(curve.anchoredLabs, 2);
|
||||
assertClose(curve.points.find((p) => p.timestamp === t2).e2, 300, 1e-6, 'ancrage lab 1');
|
||||
assertClose(curve.points.find((p) => p.timestamp === t7).e2, 260, 1e-6, 'ancrage lab 2');
|
||||
});
|
||||
|
||||
test('rho interpole en log entre deux labs (math exacte)', () => {
|
||||
const t2 = 2 * DAY_MS;
|
||||
const t7 = 7 * DAY_MS;
|
||||
const tr = treatment();
|
||||
// Labs DATA-DRIVEN (miroir du fix du test Kotlin : M(2 d) ≠ le pic de référence)
|
||||
const m2 = e2At([tr], doses(), t2, null, null);
|
||||
const m7 = e2At([tr], doses(), t7, null, null);
|
||||
const labs = [lab(0.9 * m2, t2), lab(1.2 * m7, t7)];
|
||||
|
||||
// 1) La fonction PURE ratioAt interpole exactement
|
||||
const pure = [[t2, 0.9], [t7, 1.2]];
|
||||
const expectedMid = Math.exp((Math.log(0.9) + Math.log(1.2)) / 2.0);
|
||||
assertClose(ratioAt(pure, t2), 0.9, 1e-12);
|
||||
assertClose(ratioAt(pure, t7), 1.2, 1e-12);
|
||||
assertClose(ratioAt(pure, (t2 + t7) / 2), expectedMid, 1e-9);
|
||||
|
||||
// 2) La courbe restitue le même ρ
|
||||
const curve = computeLabAnchoredCurve([tr], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
|
||||
const mid = curve.points[Math.trunc(curve.points.length / 2)];
|
||||
const m = e2At([{ ...tr, scaleFactor: 1.0 }], doses(), mid.timestamp, null, null);
|
||||
assertClose(mid.e2 / m, expectedMid, 1e-9);
|
||||
});
|
||||
|
||||
test('un seul lab significatif donne une courbe vide', () => {
|
||||
const curve = computeLabAnchoredCurve([treatment()], doses(),
|
||||
[lab(200, 3 * DAY_MS)], 0, 30 * DAY_MS, HOUR_MS);
|
||||
assert.equal(curve.points.length, 0);
|
||||
assert.equal(curve.anchoredLabs, 1, 'le lab compte mais ne suffit pas');
|
||||
});
|
||||
|
||||
test('garde 61 - un lab hors fenêtre d action n est pas un ancrage', () => {
|
||||
const t2 = 2 * DAY_MS;
|
||||
const t7 = 7 * DAY_MS;
|
||||
const curve = computeLabAnchoredCurve([treatment()], doses(),
|
||||
[lab(300, t2), lab(260, t7), lab(3000, 60 * DAY_MS)], 0, 90 * DAY_MS, HOUR_MS);
|
||||
assert.equal(curve.anchoredLabs, 2, 'le lab tardif est rejeté');
|
||||
assert.ok(curve.points.every((p) => p.e2 <= 600), 'aucun point ne suit la valeur absurde');
|
||||
assert.ok(curve.points.every((p) => p.timestamp <= t7), 'fenêtre bornée au dernier lab significatif');
|
||||
});
|
||||
|
||||
test('labs avant la première dose ne produisent aucun ancrage', () => {
|
||||
const curve = computeLabAnchoredCurve([treatment()], doses(),
|
||||
[lab(200, -10 * DAY_MS), lab(210, -5 * DAY_MS)], -15 * DAY_MS, 0, HOUR_MS);
|
||||
assert.equal(curve.points.length, 0);
|
||||
assert.equal(curve.anchoredLabs, 0);
|
||||
});
|
||||
|
||||
test('la fenêtre du tracé est bornée par les labs et par la demande', () => {
|
||||
const t2 = 2 * DAY_MS;
|
||||
const t7 = 7 * DAY_MS;
|
||||
const labs = [lab(160, t2), lab(140, t7)];
|
||||
const wide = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 60 * DAY_MS, HOUR_MS);
|
||||
assert.ok(wide.points.length > 0);
|
||||
assert.ok(wide.points.every((p) => p.timestamp >= t2 && p.timestamp <= t7));
|
||||
const narrow = computeLabAnchoredCurve([treatment()], doses(), labs, 3 * DAY_MS, 5 * DAY_MS, HOUR_MS);
|
||||
assert.ok(narrow.points.length > 0);
|
||||
assert.ok(narrow.points.every((p) => p.timestamp >= 3 * DAY_MS && p.timestamp <= 5 * DAY_MS));
|
||||
});
|
||||
|
||||
test('indépendant du scaleFactor stocké (sinon double correction)', () => {
|
||||
const labs = [lab(300, 2 * DAY_MS), lab(260, 7 * DAY_MS)];
|
||||
const a = computeLabAnchoredCurve([treatment(1.0)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
|
||||
const b = computeLabAnchoredCurve([treatment(0.55)], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
|
||||
assert.deepEqual(a.points, b.points, 'M diffère mais ρ compense exactement');
|
||||
});
|
||||
|
||||
test('modelOverride - chaque modèle ancre avec sa prédiction (sémantique 60)', () => {
|
||||
// ESE vs WHS (ESE≈TFS quasi identiques par construction — seuil doc §8)
|
||||
const trEv = { ...treatment(), esterType: 'EV', route: 'INJECTION_IM' };
|
||||
const labs = [lab(320, 2 * DAY_MS), lab(240, 7 * DAY_MS)];
|
||||
const ese = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'ESE');
|
||||
const whs = computeLabAnchoredCurve([trEv], doses(), labs, 0, 30 * DAY_MS, HOUR_MS, 'WHS');
|
||||
assertClose(ese.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6);
|
||||
assertClose(whs.points.find((p) => p.timestamp === 2 * DAY_MS).e2, 320, 1e-6);
|
||||
const midEse = ese.points[Math.trunc(ese.points.length / 2)].e2;
|
||||
const midWhs = whs.points[Math.trunc(whs.points.length / 2)].e2;
|
||||
const relDiff = Math.abs(midEse - midWhs) / Math.max(Math.abs(midEse), Math.abs(midWhs));
|
||||
assert.ok(relDiff > 0.02, `empreinte intermédiaire ESE=${midEse} vs WHS=${midWhs}`);
|
||||
});
|
||||
|
||||
test('les labs T sont ignorés (E2 uniquement)', () => {
|
||||
const labs = [lab(300, 2 * DAY_MS), tLab(3 * DAY_MS), lab(240, 7 * DAY_MS)];
|
||||
const curve = computeLabAnchoredCurve([treatment()], doses(), labs, 0, 30 * DAY_MS, HOUR_MS);
|
||||
assert.equal(curve.anchoredLabs, 2);
|
||||
});
|
||||
|
||||
test('doublons de timestamp - le deuxième ratio gagne', () => {
|
||||
const t = 5 * DAY_MS;
|
||||
const anchors = [[t, 1.0], [t, 2.0], [10 * DAY_MS, 3.0]];
|
||||
assertClose(ratioAt(anchors, 10 * DAY_MS), 3.0, 1e-12);
|
||||
const expected = Math.exp(Math.log(2) + (7 - 5) / 5 * (Math.log(3) - Math.log(2)));
|
||||
assertClose(ratioAt(anchors, 7 * DAY_MS), expected, 1e-9);
|
||||
});
|
||||
|
||||
test('ratioAt - gardes defensives aux bornes', () => {
|
||||
const anchors = [[0, 1.0], [10, 2.0]];
|
||||
assertClose(ratioAt(anchors, -5), 1.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);
|
||||
});
|
||||
});
|
||||
@ -14,7 +14,7 @@ import { nextReminderFireFor, nextReminderFireMs } from '../js/pk/pk-reminders.j
|
||||
import { detectExtrema } from '../js/pk/pk-extrema.js';
|
||||
import { LevelPoint } from '../js/pk/pk-engine.js';
|
||||
import { isVersionNewer, sectionsSince } from '../js/util/changelog.js';
|
||||
import { HrtDuration, nextDoseLineText } from '../js/util/format.js';
|
||||
import { HrtDuration } from '../js/util/format.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é). */
|
||||
@ -212,30 +212,3 @@ describe('HrtDuration (temps sous THS)', () => {
|
||||
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)`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
/**
|
||||
* 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'));
|
||||
});
|
||||
});
|
||||
@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@ -1,66 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user