Cause (divergence de portage, bug remonté « options reset sans changer de
menu ») : renderRoute() re-crée l'écran ENTIER à chaque tick minute (60 s),
mutation du store ou resize — et l'état des options était une variable
LOCALE de renderChart, retombant aux défauts à chaque re-création.
L'Android est immunisé (remember { mutableStateOf } survit aux
recompositions).
Fix (miroir Compose) :
- chart.js : état d'interaction au NIVEAU MODULE (chartUiState) ;
renderChart(container, ctx, { preserveState }) réutilise l'état quand
preserveState=true, le recrée aux défauts sinon ;
- app.js : preserveChartState = (route === 'chart' && lastRoute === 'chart')
comparé AVANT la mise à jour de lastRoute — tick/store/resize préservent
l'état, la navigation le réinitialise (miroir du reset d'un remember
Android quitté).
Épinglé par l'E2E : resize (même code path que le tick) → chip Tracé labs
+ légende restent en place ; navigation → retour aux défauts.
Docs : §4 « État d'interaction persistant », §8 scénario E2E, §11 leçon
#15 (tout état d'UI doit survivre au re-rendu global), CHANGELOG [1.6.1]
(fix web-only : parité fonctionnelle = Android 1.6.0), README.
46 KiB
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 : sondocs/DEVELOPPEMENT.md). Guide utilisateur : ../README.md.
Table des matières
- Contexte & objectifs du portage
- Stack & décisions structurantes
- Deux dépôts séparés : HormoneTrack (APK) ↔ HormoneTrack-web
- Architecture & correspondance fichier par fichier
- Persistance (localStorage) & schéma de données
- Compatibilité des backups avec l'Android
- Fuseaux horaires (Intl)
- Tests — processus complet
- Workflow de développement
- Processus de push & releases (sync Android)
- Bugs potentiels évités pendant le portage
- Limites connues & non-portés
- 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 :
- 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).
- Compatibilité backup bidirectionnelle (schéma
BackupDatav2, champs Gson/Kotlin identiques). - 100 % local : tout dans le navigateur (localStorage), le serveur ne sert que des fichiers statiques.
- 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.)
- 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é)
- 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.
- 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.
- 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 dedata/,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
autoCalibratedpar 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.nowMsest 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 desrememberqui SURVIVENT aux recompositions. Le web fait donc vivre l'état AU NIVEAU MODULE (chartUiStatedans chart.js) et le shell signale les re-rendus du même écran viapreserveState: true(tick/store/resize → préservé ; navigation → défauts, miroir du reset d'unrememberquitté). 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.jsne 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). calendarEventIdexiste dans les objets (fidélité du schéma) mais reste TOUJOURSnullcô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(miroirUserSettings.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
tConfiget les réglages du backup sont restaurés comme sur l'Android. tests/backup.test.jsimporte les vrais exports Android viahelpers.localTestDataDir()(dépôt Android voisin, skip si absent — comportementAssumeidentique 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(...)(viaIntl.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 :
Intlrenvoiehour: '24'avech24et pash23— toujourshourCycle: 'h23';- un formatter avec SEULEMENT
minutene 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 × ρ_lastexacte 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
- Le code visé doit être dans
js/pk/(pur) ou testable via le store (backend Map) — l'UI est couverte par l'E2E. - Réutiliser
tests/helpers.js(makeTreatment/makeDose/makeLab,initProfiles,assertClose). - ⚠️ Les fixtures ont des defaults identiques aux entités Kotlin :
id: 0= « pas en base » (unid: 1par défaut a faussé trois tests pendant le portage — cf §11). - 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.pysur 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
Prolongerest DÉSACTIVÉ tant queTracé labsest 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é labsactivé, déclenche un re-render viadispatchEvent(new Event('resize'))(MÊME code path que le tick minute et la mutation du store : tous trois appellentrenderRoute— 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 durememberAndroid 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é parrenderRouteune fois l'asset chargé) — attendre ce sélecteur, jamais unsleepnu. - ⚠️ Le seed de langue (
addInitScript) est conditionnel : l'écraser à chaque navigation effaceraitchangelogSeenVersionet ferait réapparaître le dialog à chaquepage.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/@returnset, 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.mjsbloque sinon) — leçon Android §12. - Champs de modèles : ne JAMAIS renommer un champ de
data/models.jssans vérifierBackupManager.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)
- Android (doc Android §16 checklist complète) : bump
versionName/versionCode, tests + lint verts, sectiondocs/CHANGELOG.md, commit, tag annotévX.Y.Z. - Web (cette doc) :
a. porter la/les features si pas déjà fait (commits par couche) ;
b.
WEB_VERSION = "X.Y.Z"dansjs/ui/settings.js= même numéro que l'Android + section## [X.Y.Z]en tête dedocs/CHANGELOG.md(le dialog « Nouveautés » la lira) ; c.bash scripts/check.shvert (E2E inclus) ; d. commitvX.Y.Z — résumé du portage+ tag annotévX.Y.Z. - Push des deux dépôts :
⚠️ Ne JAMAIS comparer les versions en chaînes (piège #38 Android) — tout script futur passe par une comparaison numérique.# 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 - Releases Gitea :
- Android :
publish-release.pysur 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 = sectiondocs/CHANGELOG.md) et attache l'archiveHormoneTrack-web-vX.Y.Z.zipconstruite PARgit archiveAU 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). Idemfarewellune fois le repo créé.
- Android :
- 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)
bash scripts/check.shvert (E2E inclus si installé) ;- 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-datavide si le dossier local existe) ; - 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 :
- 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 - Multi-arch depuis le Mac (push vers un registry — Docker Hub, GHCR,
ou le registry interne du NAS) :
(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.)docker buildx build --platform linux/amd64,linux/arm64 -t <user>/hormonetrack-web:latest --push .
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) :
- Propriétés dérivées Kotlin = fonctions JS :
usesProfileModelest un getter en Kotlin mais n'existe pas sur les objets JSON plats → le moteur retombait silencieusement en Bateman (courbes ÷100). Fix :usesProfileModel(treatment)danspk-engine.js. Toute propriété dérivée d'entité (isInjection, …) doit être recalculée, jamais lue. - Fixtures de test :
makeTreatmentavecid: 1par défaut (au lieu de 0 = « pas en base ») faisait échouer auto-increment/CASCADE. Defaults des helpers == valeurs d'insertion Kotlin. - 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.
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).-0:Math.trunc/négation produisent-0;assert.equal(-0, 0)échoue en strict — normaliser dans les helpers (panDeltaHours,clampPanHours).- Imports relatifs :
app.jsvit dansjs/ui/— un import écrit comme depuisjs/donne des 404 MIME silencieuses (le module root ne charge PAS, aucune erreur fatale visible). D'où le checknode --check+ l'E2E « aucune erreur console ». - Exports du barrel :
pk/index.jsdoit ré-exporter tout ce que les écrans importent nommément (pointHoursBefore, …) — une omission = SyntaxError au chargement. L'E2E l'attrape (page vide). - Assignation à une variable non déclarée en ESM (strict) = crash du
module entier (
lastRoutedéclaré dansmain()mais assigné dansrenderRoute()) → remonter l'état partagé au niveau module. - Closures de dialog :
const dlg = showDialog({ actions: [… dlg.close()] })— oublier leconstdonne une ReferenceError AU CLIC (pas au chargement), invisible sans test d'interaction. L'E2E clique maintenant les actions. - Seed de test conditionnel : un
addInitScriptnon conditionnel écrasesettingsà chaque navigation (re-lecture du changelog à chaque goto). Cf §8. getSettings()fusionne les défauts : un stockage partiel ne doit jamais exposer des champsundefined(les gardes!== nulldeviennent fausses autrement).- Miroir du fix #63 Android (v1.5.0, étendu v1.6.0) : les clés
'LAB'ET'LABX'(Tracé labs ancré / prolongé) dans la listecurvessont 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). - Imports ESM = noms de modules EXACTS (v1.5.0) :
labIsSignificantvit danspk-calibration.js(paspk-engine.js) — un import du mauvais module = SyntaxError AU CHARGEMENT, l'app entière ne démarre pas ; attrapé parnode --check+ l'E2E « aucune erreur console ». - 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). - 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, etrenderRoutetransmet « même écran re-rendu » (preserveState) pour le préserver — miroir derememberCompose (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 ;
calendarEventIdreste null. - 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
- 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).
- Import en mode fusion (détection de doublons) — miroir de l'idée Android §20.
- Tooltip au survol du graphique (valeur + date au point).
- Export CSV (miroir Android roadmap).
- 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.