HormoneTrack-web/docs/DEVELOPPEMENT.md

838 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Documentation de développement — HormoneTrack **Web**
> Doc de référence pour toute session (humaine ou IA) travaillant sur la
> version web : architecture, correspondances avec l'app Android, format du
> backup, tests, déploiement, pièges. Le code est commenté au même niveau que
> la doc — cette page explique le POURQUOI et le LIEN à l'Android.
>
> Projet : `~/projects/HormoneTrack-web` — **dépôt GIT SÉPARÉ** de l'app
> Android (décision v1.4.10, cf §3) ; l'app Android vit dans
> `~/projects/HormoneTrack`
> ([gitea.cloudyfy.fr/Siphonight/HormoneTrack](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack),
> docs mère : son `docs/DEVELOPPEMENT.md`).
> Guide utilisateur : [../README.md](../README.md).
---
## Table des matières
1. [Contexte & objectifs du portage](#1-contexte--objectifs-du-portage)
2. [Stack & décisions structurantes](#2-stack--décisions-structurantes)
3. [Deux dépôts séparés : HormoneTrack (APK) ↔ HormoneTrack-web](#3-deux-dépôts-séparés--hormonetrack-apk--hormonetrack-web)
4. [Architecture & correspondance fichier par fichier](#4-architecture--correspondance-fichier-par-fichier)
5. [Persistance (localStorage) & schéma de données](#5-persistance-localstorage--schéma-de-données)
6. [Compatibilité des backups avec l'Android](#6-compatibilité-des-backups-avec-landroid)
7. [Fuseaux horaires (Intl)](#7-fuseaux-horaires-intl)
8. [Tests — processus complet](#8-tests--processus-complet)
9. [Workflow de développement](#9-workflow-de-développement)
10. [Processus de push & releases (sync Android)](#10-processus-de-push--releases-sync-android)
11. [Bugs potentiels évités pendant le portage](#11-bugs-potentiels-évités-pendant-le-portage)
12. [Limites connues & non-portés](#12-limites-connues--non-portés)
13. [Idées d'évolution](#13-idées-dévolution)
---
## 1. Contexte & objectifs du portage
L'utilisatrice suit son THS sur l'app Android (cf doc mère). La version web
existe pour :
- **disponibilité** : consulter ses courbes depuis n'importe quel appareil
(ordinateur, iPad) sans installation ;
- **portabilité des données** : les backups JSON de l'app Android
s'importent tels quels (et réciproquement) — pas de verrou.
Règles du portage, fixées au départ :
1. **Fidélité du moteur PK** : mêmes maths, mêmes paramètres, mêmes pins de
tests que l'Android (les courbes doivent être INDISTINGUABLES).
2. **Compatibilité backup bidirectionnelle** (schéma `BackupData` v2, champs
Gson/Kotlin identiques).
3. **100 % local** : tout dans le navigateur (localStorage), le serveur ne
sert que des fichiers statiques.
4. **Aucune dépendance applicative** : pas de framework, pas de build, pas
de node_modules à l'exécution — le dépôt est un site statique déployable
tel quel. (Les devDependencies npm ne servent QU'aux tests E2E.)
5. **Dépôt GIT SÉPARÉ** de l'app Android (décision v1.4.10, cf §3) —
changelogs, docs, historique, tags et releases totalement indépendants.
## 2. Stack & décisions structurantes
| 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 194 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())`) |
| i18n | dictionnaires JS (`util/i18n.js`), langue persistée | miroir values/strings.xml ; `system` suit `navigator.language` |
| Fuseaux | `Intl.DateTimeFormat` (natif) | remplace `java.util.Calendar/TimeZone` — cf §7 |
| Tests | `node --test` (runner natif Node ≥ 20) + E2E playwright | zéro dépendance pour les tests unitaires ; l'E2E utilise le build Firefox de Playwright |
| Asset PK | **copie** de l'asset Android (`pk_profiles.json`) | mêmes tables que l'app Android — `tests/pk-profiles.test.js` vérifie les 6 profils et les pics ODS ; TOUTE retouche se fait dans les DEUX dépôts (règle §3) |
⚠️ **Ne pas introduire de bundler/build sans décision inverse documentée** :
le déploiement « copier un dossier » est une propriété voulue du projet.
## 3. Deux dépôts séparés : HormoneTrack (APK) ↔ HormoneTrack-web
**Décision (8 sept. 2026)** : le portage vit dans son PROPRE dépôt git,
avec ses propres remotes, historique, tags et releases — les deux apps ne
partagent plus qu'une CONVENTION de version.
### Pourquoi (motifs qui ont tranché)
1. **Confidentialité** : l'historique du dépôt Android contient une dette
connue et jugée (fragments de labs dans les révisions v1.1.0→v1.2.3, cf
§8.bis de la doc Android) — non réexportable sans re-décision. Le dépôt
web **naît avec un historique 100 % propre** : il peut devenir public un
jour sans AUCUNE re-décision, pendant que la dette reste confinée à
l'Android.
2. **Releases indépendantes** : « pas de release web pour l'instant » =
ne rien faire côté web. Les releases APK ne traînent jamais un artifact
web et inversement.
3. **Simplicité** : plus de conventions de préfixes (`web:`) ni de tags
appariés à maintenir dans un historique unique.
### Ce que ça implique (règles de croisement)
| Aspect | Règle |
|---|---|
| **Version** | `WEB_VERSION` = version ANDROID portée, **alignée à chaque release** (web v1.4.10 = portage complet de l'Android v1.4.10, sauf impossibilités structurelles documentées §12) |
| **Tags** | les DEUX dépôts tagguent `vX.Y.Z` au même rythme (dans HormoneTrack-web, `v1.4.10` = l'état web correspondant à l'Android v1.4.10 — aucun risque d'ambiguïté, dépôts séparés) |
| **Changelogs** | TOTALEMENT SÉPARÉS : `docs/CHANGELOG.md` (web, lu par le dialog « Nouveautés ») vs `docs/CHANGELOG.md` Android (source des releases Gitea APK) — jamais fusionnés, jamais synchronisés automatiquement |
| **Asset PK** (`pk_profiles.json`) | COPIE dans chaque dépôt. ⚠️ Toute retouche (re-fit, nouvel ester) se fait dans les DEUX dépôts avec les DEUX suites de tests (pins identiques des pics ODS) — une divergence ferait échouer la CI de l'un ou l'autre |
| **Fix de moteur cross-cutting** | le même fix se fait dans les DEUX dépôts (même test épinglé, même n° de bug) — cf §11 pour l'exemple du portage |
| **Données de test réelles** (`local-test-data/`) | vivent dans le dépôt ANDROID (gitigné, jamais versionnées). Le web ne les duplique PAS : `tests/helpers.localTestDataDir()` les cherche dans le dépôt Android voisin (layout standard `~/projects/HormoneTrack*`), skip des tests si absent |
| **Commits** | convention Android conservée : commits par couche (noyau / UI / docs), release = commit dédié |
### Layout disque attendu
```
~/projects/
├── HormoneTrack/ dépôt Android (existant) — héberge local-test-data/
│ └── local-test-data/ exports réels, gitigné, JAMAIS versionnés
└── HormoneTrack-web/ dépôt web (ce projet)
```
Les tests web cherchent les exports dans le dépôt voisin : si tu clones
HormoneTrack-web ailleurs, les 22 tests de régression data-driven sont
simplement SKIPPÉS (comportement Assume identique à l'Android) — ou
dépose les exports dans un `local-test-data/` local au dépôt web (gitigné).
### Remotes du dépôt web (même double-instance que l'Android)
- `origin` → `https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web` (privé, HTTPS + trousseau)
- `farewell` → `git@farewell:Siphonight/HormoneTrack-web.git` (SSH, alias existant)
⚠️ La CRÉATION des repos sur les deux instances Gitea nécessite le scope
`write:user` (le token `write:repository` ne suffit PAS, et le
push-to-create est désactivé sur farewell) — cf §10.
## 4. Architecture & correspondance fichier par fichier
Pas de DI, pas de reactive framework : le store pub/sub notifie les écrans,
qui se re-rendent entièrement (le DOM est jetable, l'état vit dans le store
et le hash). Le routing est par **hash** (`#home`, `#chart`, `#doses`,
`#labs`, `#treatments`, `#settings`, `#treatment-edit/{id}`) — navigable
headless, lien profond possible.
```
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/
│ ├── pk/ ── NOYAU PUR (aucun DOM/storage) — testable Node ──
│ │ ├── pk-engine.js PharmacokineticEngine.kt (1/4) : Bateman,
│ │ │ contribution d'une dose, e2At/computeCurve,
│ │ │ modèle T, convertTToNgMl, prévision
│ │ ├── pk-calibration.js engine (2/4) : échelles par ester/période/
│ │ │ modèle (#60), garde labIsSignificant (#61),
│ │ │ autoCalibrated, k T, calibration manuelle
│ │ ├── pk-reminders.js engine (3/4) : nextReminderFireFor (grille
│ │ │ Posologie, fix #52)
│ │ ├── pk-extrema.js engine (4/4) : detectExtrema (pics/creux)
│ │ ├── pk-profile-store.js PKProfileStore.kt : asset + échantillonnage,
│ │ │ lookup insensible à la casse (#22), modèle
│ │ │ strict, extrapolation terminale (#20)
│ │ ├── 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,
│ │ │ shouldNotify (anti-spam)
│ │ ├── presets.js PKPresets.kt (25 presets — v1.9.4 :
│ │ │ les 6 esters injectables ESE)
│ │ ├── chart-helpers.kt fonctions pures de CurveChart.kt :
│ │ │ panDeltaHours (#62), xLabelTicks (#55),
│ │ │ pointHoursBefore (#53), clampPanHours (#54),
│ │ │ niceCeil, stepForRange, forecast*,
│ │ │ defaultModelToggles (#58) + helpers Intl
│ │ └── index.js barrel (point d'import des écrans/tests)
│ ├── data/
│ │ ├── models.js Treatment/DoseLog/LabResult (CHAMPS GSON
│ │ │ IDENTIQUES — ne jamais renommer, cf §6),
│ │ │ enums, helpers (choicesForModel, labels)
│ │ ├── store.js persistance localStorage + pub/sub + import
│ │ │ écrasement (miroir Room + BackupManager)
│ │ └── backup.js BackupData v2 : build/parse compat Android,
│ │ noms de fichiers d'export (ExportFileNames)
│ ├── util/
│ │ ├── i18n.js dictionnaires FR/EN + t() (+ presets)
│ │ ├── format.js formatDose/formatLabValue/formatValue,
│ │ │ HrtDuration, formatage Intl des dates
│ │ ├── changelog.js ChangelogHelper.kt (isVersionNewer,
│ │ │ sectionsSince — comparaison numérique #38)
│ │ └── app-log.js AppLog.kt : journal 500 lignes, fail-safe
│ └── ui/ ── ÉCRANS (DOM uniquement ici) ──
│ ├── app.js HormoneTrackApp+MainActivity+Root : shell,
│ │ routing hash, « Nouveautés » (markdown
│ │ rendu), hook ?demo=1, boucle ticks
│ ├── components.js el(), showDialog/toast, fields, switch,
│ │ chips, FAB, dateTimeField (M3 → natifs)
│ ├── dialogs.js DoseDialog.kt (override ester par modèle)
│ │ + LabDialog.kt (E2+T une entrée)
│ ├── home.js HomeScreen.kt (niveau actuel #53, alertes,
│ │ prochaine dose, log rapide, mini-chart)
│ ├── chart.js ChartScreen.kt : fenêtre/pan/zoom (fixes
│ │ #54/#56/#62), toggles, calibration par
│ │ modèle (#60), prévision
│ ├── chart-canvas.js CurveChart.kt : rendu Canvas multi-séries,
│ │ labs orange, marqueurs, extrema
│ ├── doses.js DosesScreen.kt (groupes jour, Δ jours,
│ │ temps sous THS — 3 placeholders, bug #47)
│ ├── labs.js LabsScreen.kt (groupement, suppression
│ │ par prise entière, sélecteur E2/T)
│ ├── treatments.js TreatmentsScreen.kt (cartes, modelLabelKey #59)
│ ├── treatment-editor.js TreatmentEditorScreen.kt (presets,
│ │ calibration, Posologie, rappel, actif)
│ ├── settings.js SettingsScreen.kt (langue, auto-cal, T,
│ │ seuils, fuseau, notifs, backup, logs, à propos)
│ └── reminders.js ReminderManager+AlertNotifier (web) :
│ boucle 30 s rappels + 5 min seuils,
│ Notification API + bannière in-app
├── docs/
│ ├── DEVELOPPEMENT.md cette doc
│ └── 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)
│ ├── 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)
├── tests/ ── node --test (miroirs des suites Kotlin) ──
│ ├── helpers.js fixtures (makeTreatment/makeDose/makeLab, asset)
│ ├── pk-profiles.test.js ← PKProfileStoreTest.kt (pics ODS, casse #22…)
│ ├── tfs-models.test.js ← TransfemScienceModelsTest.kt (article ±2 %)
│ ├── whsah-models.test.js ← WhsahModelsTest.kt (Mona ±2 %)
│ ├── 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)
│ ├── 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)
│ ├── alerts.test.js ← AlertsTest.kt (codec, anti-spam)
│ ├── misc.test.js ← ReminderSchedule/Extrema/Changelog/HrtDuration
│ └── store.test.js CRUD, IDs auto, CASCADE, pub/sub, corruption
└── package.json scripts npm + devDependencies (E2E seulement)
```
### Points clés de l'architecture
- **Le noyau `js/pk/` est PUR** : aucune importation de `data/`, `ui/` ou
DOM → testé en Node exactement comme le Kotlin l'est en JVM. C'est la
garantie que les courbes web == courbes Android.
- **Le store est le seul point de persistance** : les écrans ne touchent
jamais localStorage directement ; les mutations passent par
`store.*` qui notifie les abonnés.
- **`isActive` = drapeau administratif** (Android §6.bis, appliqué tel quel) :
la saisie (chips, dropdown création) filtre les actifs ; la simulation,
la calibration et les rappels du moteur reçoivent TOUS les traitements.
- **Calibration par modèle affiché** (fix #60) : l'écran Graphiques calcule
une `autoCalibrated` par modèle (ESE/TFS/WHS) ; l'accueil utilise le
modèle stocké — comme l'Android.
- **« 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).
## 5. Persistance (localStorage) & schéma de données
Clés (préfixe `hormonetrack.`) — miroir des tables Room / clés DataStore :
| Clé | Contenu | Équivalent Android |
|---|---|---|
| `treatments` | tableau JSON d'objets Treatment (champs = entités Room) | table `treatments` |
| `doseLogs` | idem DoseLog (FK `treatmentId`, CASCADE au delete) | table `dose_logs` |
| `labResults` | idem LabResult | table `lab_results` |
| `tConfig` | `{base, floor, k}` (défauts 6.0 / 0.2 / 0.19) | DataStore `t_base/t_floor/t_k` |
| `settings` | `{language, autoCalibrate, alertE2High/Low, alertTHigh/Low, chartTimezone, changelogSeenVersion, alertNotifiedState}` (fusion avec défauts à la lecture) | DataStore (plusieurs clés) |
| `debugLog` | texte, `\n` (buffer 500 lignes) | `filesDir/debug-log.txt` |
Règles :
- **IDs auto-incrémentés par table** (max + 1) — miroir Room
`autoGenerate` ; à l'import, les IDs du backup sont conservés (FK
dose→traitement) et les compteurs repartent de max+1.
- **Import = écrasement** (miroir v1.2.6) : effacement enfants→parents puis
restauration, un seul `emit()` final.
- **Donnée corrompue ≠ crash** : `read()` retombe sur le défaut (même
philosophie qu'AppLog.init Android).
- `calendarEventId` existe dans les objets (fidélité du schéma) mais reste
TOUJOURS `null` côté web.
## 6. Compatibilité des backups avec l'Android
Format `BackupData` v2 — **verrouillé par des tests des deux côtés** :
```json
{
"version": 2,
"exportedAt": 1749000000000,
"treatments": [ { "id": 1, "name": "…", "type": "ESTRADIOL", "route": "INJECTION_SUBCUT",
"doseAmount": 5, "doseUnit": "mg", "isActive": true, "notes": null,
"esterType": "EEN", "pkModel": "TFS",
"absorptionHours": 156, "eliminationHalfLifeHours": 110,
"bioavailabilityFraction": 1, "scaleFactor": 1,
"forecastIntervalDays": 7, "reminderHour": 18, "reminderMinute": 0,
"reminderEnabled": true, "calendarEventId": null, "createdAt": … } ],
"doseLogs": [ { "id": 1, "treatmentId": 1, "timestamp": …, "doseAmount": 5,
"notes": null, "esterType": null } ],
"labResults": [ { "id": 1, "marker": "E2", "value": 300, "unit": "pg/mL",
"timestamp": …, "notes": null } ],
"tConfig": { "base": 6.0, "floor": 0.2, "k": 0.19 },
"settings": { "language": "fr", "autoCalibrate": false,
"alertE2High": null, "alertE2Low": null,
"alertTHigh": null, "alertTLow": null }
}
```
- **Champs PLATS** dans `settings` (miroir `UserSettings.kt` — Gson lit par
réflexion côté Android). ⚠️ Les clés web extra (`chartTimezone`,
`alertNotifiedState`, `changelogSeenVersion`) sont **exclues** de
l'export (testé) — un backup importé dans l'Android ne doit rien contenir
d'inconnu.
- **Rétrocompat v1** : backup sans `settings` → `settings = null` →
l'import ne touche pas aux réglages (comportement Gson identique).
- **L'import web est un écrasement** (dialog d'avertissement, bouton
« Effacer & restaurer ») ; le `tConfig` et les réglages du backup sont
restaurés comme sur l'Android.
- `tests/backup.test.js` importe les **vrais exports Android** via
`helpers.localTestDataDir()` (dépôt Android voisin, skip si absent —
comportement `Assume` identique au Kotlin) — garde de confidentialité
identique : ces fichiers restent HORS dépôts, et les assertions sont
data-driven (aucune valeur réelle en dur).
**Règle de maintenance** : tout changement de schéma (côté Android OU web)
doit (a) passer par les deux suites de tests backup, (b) rester
rétrocompatible, (c) être documenté dans les DEUX docs de dev.
## 7. Fuseaux horaires (Intl)
L'Android utilise `Calendar`/`TimeZone` ; le web utilise `Intl` :
- `zonedParts(ms, tz)` = `Calendar.getInstance(tz).get(...)` (via
`Intl.DateTimeFormat.formatToParts`) ;
- `zonedTimeToMs(parts, tz)` = construction inverse avec **deux passes de
correction DST** (mesure de l'offset à l'instant naïf, puis à l'instant
corrigé — point fixe) ;
- `xLabelTicks` (fix #55) : les pas ≥ 24 h sont alignés sur **minuit LOCAL
du fuseau de lecture** (Paramètres → « Fuseau du graphique », vide =
navigateur), avancés de N jours CALENDAIRES (DST-safe, testé sur la
traversée de fin mars) ; les pas horaires sur des heures rondes locales.
Pièges rencontrés :
- `Intl` renvoie `hour: '24'` avec `h24` et pas `h23` — toujours
`hourCycle: 'h23'` ;
- un formatter avec SEULEMENT `minute` ne pads pas à 2 chiffres partout —
tester sur `(hour, minute)` ensemble ;
- `-0` : `Math.trunc(-0.8) === -0` — `assert.equal(-0, 0)` échoue en strict ;
les helpers normalisent (`clampPanHours`, `panDeltaHours`).
## 8. Tests — processus complet
### Panorama
| Niveau | Outil | Couverture | Commande |
|---|---|---|---|
| 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` | **194 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) | `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` |
La commande unique avant tout push : **`bash scripts/check.sh`** (tout
exécuter, E2E inclus s'il est installé).
### Installation (une fois)
```bash
cd web
npm i # devDependencies (playwright) — l'APP n'en a aucune
npx playwright install firefox # le build Firefox piloté (~90 Mo, cache ~/.cache)
```
⚠️ Le **Firefox système** ne convient PAS : depuis ~Firefox 150, le
protocole Juggler de Playwright n'y est plus fonctionnel (exit immédiat au
launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
### Ce que chaque suite épinglera TOUJOURS
- **pk-profiles** : les 6 profils × 8001 points, pics == valeurs ODS
(61,12 / 3,44 / 31,35 / 58,96 / 10,11 / 31,97), casse `EEn` (#22),
modèle strict (#21), extrapolation ≥ 1 % du pic (#20).
- **tfs-models** : pics/t½ des Tableaux 9–10 ±2 %, équilibre EV 5 mg/7 j =
Figure 11, PEP, EU ultra-rapide. *Toute retouche de
transfem-science-models.js passe par là.*
- **whsah-models** : fidélité Mona ±2 %, PEP non couvert, EEn J+1 ×3.
- **pk-engine** : pic Bateman ≈ Tmax (bisection #19), dispatch 3 modèles,
override par dose, §6.bis (inactif simulé), prévision (#35), T monotone.
- **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** — l'ancien minimum de la fenêtre entière tombait
juste après l'injection précédente pour les esters à montée lente comme
l'EEn ; pour EV le point pré-injection EST le minimum de fenêtre),
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),
pointHoursBefore (#53), clamps (#54), toggles (#58).
- **alerts / misc / store** : codec strict, anti-spam, grille Posologie
(#52), extrema, comparaison numérique de versions (#38), résilience du
store (donnée corrompue → défauts).
### Écrire un nouveau test
1. Le code visé doit être dans `js/pk/` (pur) ou testable via le store
(backend Map) — l'UI est couverte par l'E2E.
2. Réutiliser `tests/helpers.js` (`makeTreatment/makeDose/makeLab`,
`initProfiles`, `assertClose`).
3. ⚠️ Les fixtures ont des **defaults identiques aux entités Kotlin** :
`id: 0` = « pas en base » (un `id: 1` par défaut a faussé trois tests
pendant le portage — cf §11).
4. Les scénarios de labs/calibration se posent **data-driven** :
`lab = e2At(...) × facteur` — ne jamais recalculer la prédiction à la
main (l'accumulation est celle du moteur).
### E2E (scripts/e2e.mjs)
- Démarre `serve.py` sur un port libre en argument, lance le Firefox
Playwright headless, navigue avec `?demo=1` (données fictives banniérées,
jamais sur un store non vide) ;
- 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"]`
(posé par `renderRoute` une fois l'asset chargé) — attendre ce sélecteur,
jamais un `sleep` nu.
- ⚠️ Le seed de langue (`addInitScript`) est **conditionnel** : l'écraser à
chaque navigation effacerait `changelogSeenVersion` et ferait réapparaître
le dialog à chaque `page.goto` (une goto = reboot complet de la SPA).
## 9. Workflow de développement
```bash
cd web
python3 scripts/serve.py # → http://127.0.0.1:8970 (no-cache : F5 = code à jour)
# … développement …
bash scripts/check.sh # AVANT chaque commit (syntaxe + i18n + tests + E2E)
```
Règles de code (appliquées partout, à conserver) :
- **Tout fichier commence par un bloc KDoc** qui dit QUOI il porte (fichier
Kotlin d'origine), POURQUOI il existe et les invariants à préserver.
- **Toute fonction non triviale a un JSDoc** avec `@param`/`@returns` et,
quand c'est un fix Android transposé, le numéro du bug (#NN).
- **Le noyau `js/pk/` ne connaît ni le DOM ni le storage** — si une feature
exige l'inverse, c'est le design qu'il faut revoir.
- **Strings** : passer par `t('clé')`, dans les DEUX dictionnaires
(`i18n-check.mjs` bloque sinon) — leçon Android §12.
- **Champs de modèles** : ne JAMAIS renommer un champ de
`data/models.js` sans vérifier `BackupManager.kt` (compat Gson, cf §6).
- **Tout fix de bug** : test qui l'épingle d'abord (rouge), puis fix, puis
§11 de cette doc + `docs/CHANGELOG.md`.
## 10. Processus de push & releases (sync Android)
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).
### Remotes
```
origin https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web (privé, HTTPS + trousseau)
farewell git@farewell:Siphonight/HormoneTrack-web.git (SSH, alias existant)
```
⚠️ **Création des repos** (one-shot, faite par l'utilisatrice — le token
`write:repository` ne permet PAS de créer un repo via API, et le
push-to-create est désactivé sur farewell) : créer `HormoneTrack-web` sur
les DEUX instances (cloudyfy + farewell), privé, puis configurer les
remotes ci-dessus. (Historique : Android a fait la même opération au
push initial v1.2.3, cf doc Android §16.)
### Checklist de release SYNC (Android + web, une seule session)
1. **Android** (doc Android §16 checklist complète) : bump `versionName`/
`versionCode`, tests + lint verts, section `docs/CHANGELOG.md`, commit,
tag annoté `vX.Y.Z`.
2. **Web** (cette doc) :
a. porter la/les features si pas déjà fait (commits par couche) ;
b. `WEB_VERSION = "X.Y.Z"` dans `js/ui/settings.js` **= même numéro que
l'Android** + section `## [X.Y.Z]` en tête de `docs/CHANGELOG.md`
(le dialog « Nouveautés » la lira) ;
c. `bash scripts/check.sh` vert (E2E inclus) ;
d. commit `vX.Y.Z — résumé du portage` + tag annoté `vX.Y.Z`.
3. **Push des deux dépôts** :
```bash
# Android
git push origin main --tags && git push farewell main --tags
# Web
git push origin main && git push origin vX.Y.Z
git push farewell main && git push farewell vX.Y.Z
```
⚠️ Ne JAMAIS comparer les versions en chaînes (piège #38 Android) —
tout script futur passe par une comparaison numérique.
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.
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.
### Avant CHAQUE push (release ou non)
1. `bash scripts/check.sh` **vert** (E2E inclus si installé) ;
2. les données de test réelles restent **hors des DEUX dépôts**
(`git check-ignore -v local-test-data/…` côté Android ; le web ne
doit contenir AUCUN export réel — `git log --all -- local-test-data`
vide si le dossier local existe) ;
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)
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).
## 11. Bugs potentiels évités pendant le portage
Trouvés pendant l'écriture (à ne pas réintroduire) :
1. **Propriétés dérivées Kotlin = fonctions JS** : `usesProfileModel` est un
*getter* en Kotlin mais n'existe pas sur les objets JSON plats → le
moteur retombait silencieusement en Bateman (courbes ÷100). Fix :
`usesProfileModel(treatment)` dans `pk-engine.js`. Toute propriété
dérivée d'entité (`isInjection`, …) doit être recalculée, jamais lue.
2. **Fixtures de test** : `makeTreatment` avec `id: 1` par défaut (au lieu
de 0 = « pas en base ») faisait échouer auto-increment/CASCADE. Defaults
des helpers == valeurs d'insertion Kotlin.
3. **Extrapolation terminale** : première version linéaire (pente) au lieu
du **taux logarithmique** du Kotlin — queues croissantes sur EU (plancher
0,01/0,00). Portage fidèle exigé, tests EU/EV des deux régimes.
4. **`zonedTimeToMs`** : la 2ᵉ passe de correction DST doit mesurer
l'offset à l'instant CORRIGÉ (`naive − offset(instant)`) — une première
version référençait le mauvais instant (ticks à minuit UTC+2 = 02:00).
5. **`-0`** : `Math.trunc`/négation produisent `-0` ; `assert.equal(-0, 0)`
échoue en strict — normaliser dans les helpers (`panDeltaHours`,
`clampPanHours`).
6. **Imports relatifs** : `app.js` vit dans `js/ui/` — un import écrit comme
depuis `js/` donne des 404 MIME silencieuses (le module root ne charge
PAS, aucune erreur fatale visible). D'où le check `node --check` +
l'E2E « aucune erreur console ».
7. **Exports du barrel** : `pk/index.js` doit ré-exporter tout ce que les
écrans importent nommément (`pointHoursBefore`, …) — une omission =
SyntaxError au chargement. L'E2E l'attrape (page vide).
8. **Assignation à une variable non déclarée** en ESM (strict) = crash du
module entier (`lastRoute` déclaré dans `main()` mais assigné dans
`renderRoute()`) → remonter l'état partagé au niveau module.
9. **Closures de dialog** : `const dlg = showDialog({ actions: [… dlg.close()] })`
— oublier le `const` donne une ReferenceError AU CLIC (pas au chargement),
invisible sans test d'interaction. L'E2E clique maintenant les actions.
10. **Seed de test conditionnel** : un `addInitScript` non conditionnel
écrase `settings` à chaque navigation (re-lecture du changelog à chaque
goto). Cf §8.
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
Structurels (plateforme), pas des bugs :
- **Rappels** : uniquement page ouverte (boucle 30 s + Notification API).
Aucun équivalent d'AlarmManager — un service worker + push exigerait un
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
système — l'historique des doses reste en epoch ms (aucun effet sur les
données, contrairement à un LocalDateTime stocké).
- **localStorage ≈ 5 Mo** : largement suffisant (années de doses/labs) ;
en cas de quota dépassé, AppLog avalera l'erreur (fail-safe) et il faudra
un export/import de nettoyage.
## 13. Idées d'évolution
1. **Service worker de cache offline** (l'app serait installable en PWA) —
sans push notifications, uniquement cache statique ; ne casse pas « 100 %
local » (aucun serveur de données).
2. Import en mode **fusion** (détection de doublons) — miroir de l'idée
Android §20.
3. Tooltip au survol du graphique (valeur + date au point).
4. Export CSV (miroir Android roadmap).
5. Tests E2E des gestes (drag du pan via `mouse.drag`) — l'équivalent du
pinch tactile reste hors de portée headless (comme l'émulateur Android,
§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,
backup compatible bidirectionnel, historique propre (aucune donnée de
santée, aucune dette de confidentialité), versions alignées sur
l'Android.*