HormoneTrack-web/docs/DEVELOPPEMENT.md
Siphonight d822af2dd2 v1.7.0 : unités des axes du graphique (portage Android) + note auto-backup
- chart-canvas.js : pg/mL (E2, axe gauche) / ng/mL (T, axe droit) au
  sommet des colonnes de labels — miroir exact du fix Android (padTop
  élargi 12 → 26 px). Universelles, PAS des clés i18n.
- WEB_VERSION 1.7.0 (versions sync Android) ; E2E assertions version.
- CHANGELOG [1.7.0] : unités portées ; auto-backup Android NON porté
  (structurellement impossible côté navigateur — cf §12 Limites :
  pas d'OPEN_DOCUMENT_TREE persisté + WorkManager ; l'export/import
  manuel couvre la même donnée).
- Doc : §12 limites, README statut. check.sh vert (140 tests + E2E +
  garde de parité §3.bis).
2026-09-16 19:53:41 +02:00

46 KiB
Raw Permalink Blame History

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, docs mère : son docs/DEVELOPPEMENT.md). Guide utilisateur : ../README.md.


Table des matières

  1. Contexte & objectifs du portage
  2. Stack & décisions structurantes
  3. Deux dépôts séparés : HormoneTrack (APK) ↔ HormoneTrack-web
  4. Architecture & correspondance fichier par fichier
  5. Persistance (localStorage) & schéma de données
  6. Compatibilité des backups avec l'Android
  7. Fuseaux horaires (Intl)
  8. Tests — processus complet
  9. Workflow de développement
  10. Processus de push & releases (sync Android)
  11. Bugs potentiels évités pendant le portage
  12. Limites connues & non-portés
  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 (22 presets, clés i18n)
│   │   ├── 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)
│   ├── 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 :

{
  "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)

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.
  • 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é).
  • 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

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 :
    # 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).

# ── 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) :

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) :
    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) :
    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.