# 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 132 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) │ ├── 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` | **132 tests** : noyau PK, calibration, backup, store, helpers, alertes, rappels, changelog, tracé labs (v1.5.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 = minimum de la courbe prévisionnelle entre 2 injections, 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 dépôt farewell reste en attente de la création du repo (one-shot côté Gitea). ### 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` une fois le repo créé. 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://: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 /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 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.*