v1.6.1 : fix — les options du graphique se réinitialisaient toutes seules

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.
This commit is contained in:
Siphonight 2026-09-15 12:49:11 +02:00
parent ed623b786b
commit 524f122f15
7 changed files with 176 additions and 28 deletions

View File

@ -17,8 +17,10 @@
> Fie-toi toujours à tes analyses de sang et aux consignes de ton > Fie-toi toujours à tes analyses de sang et aux consignes de ton
> endocrinologue. > endocrinologue.
- **Statut** : web v1.6.0 — portage de l'Android v1.6.0 · **140 tests verts** · - **Statut** : web v1.6.1 — parité fonctionnelle avec l'Android v1.6.0
E2E navigateur ✅ · conteneur testé en configuration durcie ✅ · lint/i18n ✅ (+ fix web-only : options du graphique préservées entre re-rendus) ·
**140 tests verts** · E2E navigateur ✅ · conteneur testé en
configuration durcie ✅ · lint/i18n ✅
- **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md) - **Journal des versions web** : [docs/CHANGELOG.md](docs/CHANGELOG.md)
- **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases) - **Releases** : [gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases](https://gitea.cloudyfy.fr/Siphonight/HormoneTrack-web/releases)
(zip de déploiement statique vérifié par téléchargement) (zip de déploiement statique vérifié par téléchargement)

View File

@ -5,6 +5,31 @@
> section la plus récente EN PREMIER ; ce fichier est lu par le dialog > section la plus récente EN PREMIER ; ce fichier est lu par le dialog
> « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`). > « Nouveautés » de l'app web (fetch `docs/CHANGELOG.md`).
## [1.6.1] — 2026-09-13
### Corrigé — les options du graphique se réinitialisaient « toutes seules »
- **Bug remonté** : sur l'écran Graphiques, les options sélectionnées
(modèles affichés, Tracé labs, Prolonger, zoom, panoramique…) revenaient
d'elles-mêmes aux défauts « alors qu'on n'a pas changé de menu ».
- **Cause** (divergence de portage) : le shell web re-crée l'écran ENTIER
à chaque **tick minute** (60 s), **mutation du store** ou **resize** —
et l'état des options vivait dans une variable LOCALE de la fonction de
rendu, donc retombait aux défauts à chaque re-crétion. Côté Android,
l'équivalent vit dans des `remember { mutableStateOf }` qui survivent à
toutes les recompositions : le bug est structurellement impossible côté
APK — c'était un piège du portage, pas de la logique.
- **Fix** : l'état d'interaction du graphique vit au niveau MODULE et le
shell signale les re-rendus du **même** écran (`preserveState: true`)
→ options préservées pendant qu'on regarde le graphique ; à l'entrée
depuis la navigation (quitter puis revenir), retour aux défauts —
comportement identique à l'Android.
- **Épinglé par l'E2E** : re-render déclenché (resize — même code path
que le tick) → chip et légende toujours en place ; navigation → reset.
> Note de parité : fix **web-only** (pas de changement Android) — les
> fonctionnalités portées restent celles de l'Android v1.6.0.
## [1.6.0] — 2026-09-12 ## [1.6.0] — 2026-09-12
### « Tracé labs » prolongé au-delà du dernier lab — portage de l'Android v1.6.0 ### « Tracé labs » prolongé au-delà du dernier lab — portage de l'Android v1.6.0

View File

@ -272,6 +272,16 @@ HormoneTrack-web/ (dépôt séparé)
- **« now » mémoïsé sur un tick minute** (fix #56 transposé) : `ctx.nowMs` - **« 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 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. 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 - **Le graphique est purement déclaratif** : pan/zoom vivent dans
`chart.js` (parent), `chart-canvas.js` ne fait que dessiner une fenêtre `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). donnée — même séparation que Compose (piège #62/§11 Android).
@ -455,6 +465,13 @@ launch) — d'où le build dédié dans le cache Playwright, hors dépôt.
ancrée, la légende prolongée et l'**AVERTISSEMENT simulation** (sans ancrée, la légende prolongée et l'**AVERTISSEMENT simulation** (sans
garantie, labs potentiellement erronés) — puis désactive et vérifie garantie, labs potentiellement erronés) — puis désactive et vérifie
qu'ils disparaissent ; 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 ; - vérifie l'**absence d'erreur console/page** sur toute la session ;
- captures dans `/tmp/hrt-web-shots/` (inspection visuelle). - captures dans `/tmp/hrt-web-shots/` (inspection visuelle).
- ⚠️ Course au screenshot évitée par le marqueur `body[data-ready="1"]` - ⚠️ Course au screenshot évitée par le marqueur `body[data-ready="1"]`
@ -710,6 +727,17 @@ Trouvés pendant l'écriture (à ne pas réintroduire) :
d'où la garde `check.sh` §3.bis : quand le dépôt Android voisin 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 existe, les symboles clés des features partagées sont vérifiés des
DEUX côtés (moteur + chip + avertissement). 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 ## 12. Limites connues & non-portés

View File

@ -141,6 +141,17 @@ function currentRoute() {
function renderRoute() { function renderRoute() {
if (!contentEl) return; if (!contentEl) return;
const route = currentRoute(); const route = currentRoute();
// (v1.6.1) Re-rendu du MÊME écran graphique (tick minute, mutation du
// store, resize — ce re-rendu ne vient PAS de la navigation) ? Le chart
// doit alors PRÉSERVER son état d'interaction (chips, zoom, pan,
// Tracé labs…) : miroir Compose où `remember` survit aux recompositions.
// Sans ça, le tick minute de 60 s réinitialisait toutes les options du
// graphique pendant que l'utilisateur le regarde (bug remonté : « les
// options se reset alors qu'on n'a pas changé de menu »). À l'ENTRÉE
// (navigation depuis un autre onglet) : preserveState = false → défauts,
// miroir du reset Android quand on quitte l'écran. ⚠️ comparé AVANT la
// mise à jour de lastRoute ci-dessous.
const preserveChartState = route === 'chart' && lastRoute === 'chart';
lastRoute = route; lastRoute = route;
// Écran de chargement tant que l'asset PK n'est pas chargé (les écrans // Écran de chargement tant que l'asset PK n'est pas chargé (les écrans
@ -184,7 +195,7 @@ function renderRoute() {
renderHome(contentEl, { nowMs, openChart: () => { location.hash = '#chart'; } }); renderHome(contentEl, { nowMs, openChart: () => { location.hash = '#chart'; } });
break; break;
case route === 'chart': case route === 'chart':
renderChart(contentEl, { nowMs }); renderChart(contentEl, { nowMs }, { preserveState: preserveChartState });
break; break;
case route === 'doses': case route === 'doses':
renderDoses(contentEl); renderDoses(contentEl);

View File

@ -19,6 +19,11 @@
* - « now » mémoïsé sur un tick minute (fix #56 : boucle de recomposition * - « now » mémoïsé sur un tick minute (fix #56 : boucle de recomposition
* côté Android — côté web, même discipline : endMs ne dérive pas à chaque * côté Android — côté web, même discipline : endMs ne dérive pas à chaque
* frame, il ne change qu'au tick ou sur interaction) ; * frame, il ne change qu'au tick ou sur interaction) ;
* - ÉTAT D'INTERACTION PERSISTANT entre re-rendus (v1.6.1) : le tick minute
* de app.js, une mutation du store ou un resize re-créent l'écran entier —
* l'état (chips, zoom, pan, Tracé labs, Prolonger…) est réutilisé quand
* on re-rend le même écran (miroir `remember` Compose) et réinitialisé
* à l'entrée depuis la navigation (miroir du reset Android) ;
* - PAS de scroll horizontal parent : le drag horizontal est réservé au pan * - PAS de scroll horizontal parent : le drag horizontal est réservé au pan
* (piège Android #62/§11). * (piège Android #62/§11).
* ───────────────────────────────────────────────────────────────────────────── * ─────────────────────────────────────────────────────────────────────────────
@ -43,6 +48,29 @@ const HOUR_MS = 3600000;
const MIN_RANGE_H = 6; const MIN_RANGE_H = 6;
const MAX_RANGE_H = 24 * 300; const MAX_RANGE_H = 24 * 300;
// ── État d'interaction du graphique (v1.6.1 — fix du bug « options qui se
// reset toutes seules ») ──────────────────────────────────────────────────────
// ⚠️ DIVERGENCE DE PORTAGE corrigée : côté Android, les options du graphique
// vivent dans des `remember { mutableStateOf }` — elles SURVIVENT à toutes
// les recompositions (tick minute, nouvelles données, resize) tant qu'on
// reste sur l'écran. Côté web, `renderRoute()` (app.js) re-crée l'écran
// ENTIER à chaque tick minute (60 s), mutation du store ou resize — et un
// `const state = {...}` local à renderChart retombait donc aux DÉFAUTS
// toutes les 60 s pendant qu'on regarde le graphique (remontée : « les
// options se réinitialisent sans qu'on change de menu »).
//
// FIX : l'état vit AU NIVEAU MODULE. `renderChart` le réutilise quand
// l'appelant signale un re-rendu DU MÊME écran (`preserveState: true` —
// miroir de `remember` qui survit aux recompositions) et le RECRÉE aux
// défauts à l'ENTRÉE sur l'écran (navigation depuis un autre onglet —
// miroir du reset d'un `remember` quitté, PAS un rememberSaveable :
// quitter Charts puis revenir réinitialise, comme l'Android).
//
// Un seul objet à la fois : le graphique est un écran unique, le DOM
// précédent est jeté à chaque re-rendu (les closures recaptent `state`,
// qui reste le MÊME objet d'un re-rendu à l'autre quand il est préservé).
let chartUiState = null;
/** Styles visuels des séries (miroir de la map du ChartScreen Android). */ /** Styles visuels des séries (miroir de la map du ChartScreen Android). */
function seriesStyles(model) { function seriesStyles(model) {
if (model === 'ESE') return { e2: { color: COLORS.chartE2, dashed: false }, t: { color: COLORS.chartT, dashed: false } }; if (model === 'ESE') return { e2: { color: COLORS.chartE2, dashed: false }, t: { color: COLORS.chartT, dashed: false } };
@ -52,10 +80,19 @@ function seriesStyles(model) {
/** /**
* Rend l'écran Graphiques dans [container]. * Rend l'écran Graphiques dans [container].
*
* @param {HTMLElement} container * @param {HTMLElement} container
* @param {object} ctx { nowMs } * @param {object} ctx { nowMs }
* @param {object} [opts]
* @param {boolean} [opts.preserveState=false] (v1.6.1) `true` quand on
* re-rend le MÊME écran graphique (tick minute, mutation du store,
* resize — cf app.js renderRoute) : l'état d'interaction [state]
* est réutilisé au lieu d'être recréé aux défauts (miroir Compose :
* `remember` survit aux recompositions). `false` = entrée sur
* l'écran (navigation) : état neuf aux défauts (miroir du reset
* d'un `remember` Android quand on quitte l'écran).
*/ */
export function renderChart(container, ctx) { export function renderChart(container, ctx, { preserveState = false } = {}) {
const treatments = store.getTreatments(); // TOUS (actifs ET inactifs, §6.bis) const treatments = store.getTreatments(); // TOUS (actifs ET inactifs, §6.bis)
const doseLogs = store.getDoseLogs(); const doseLogs = store.getDoseLogs();
const labResults = store.getLabResults(); const labResults = store.getLabResults();
@ -65,27 +102,33 @@ export function renderChart(container, ctx) {
const chartTimeZone = settings.chartTimezone || null; const chartTimeZone = settings.chartTimezone || null;
// ── État d'interaction (miroir des mutableStateOf Compose) ──────────────── // ── État d'interaction (miroir des mutableStateOf Compose) ────────────────
const state = { // v1.6.1 : réutilisé entre les re-rendus du même écran (cf bloc
rangeHours: 24, // « État d'interaction » au-dessus — sans ça, le tick minute de app.js
panHours: 0, // réinitialisait toutes les options toutes les 60 s).
showT: true, if (!preserveState || !chartUiState) {
showLabs: true, chartUiState = {
showEse: true, rangeHours: 24,
showTfs: true, panHours: 0,
showWhs: false, showT: true,
showForecast: false, showLabs: true,
showExtrema: false, showEse: true,
showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0) showTfs: true,
// v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du showWhs: false,
// dernier lab constant × modèle, bornée par l'extinction du modèle — showForecast: false,
// cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la showExtrema: false,
// partie prolongée est une ESTIMATION non ancrée, elle doit être showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0)
// demandée explicitement ; le chip n'est actif que si Lab track est ON // v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du
// (miroir ChartScreen.kt). // dernier lab constant × modèle, bornée par l'extinction du modèle —
showLabTrackExtend: false, // cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62) // partie prolongée est une ESTIMATION non ancrée, elle doit être
togglesInitialized: false, // demandée explicitement ; le chip n'est actif que si Lab track est ON
}; // (miroir ChartScreen.kt).
showLabTrackExtend: false,
panResidualHours: 0.0, // résiduel fractionnaire du pan (fix #62)
togglesInitialized: false,
};
}
const state = chartUiState;
// ── Colonne racine ─────────────────────────────────────────────────────── // ── Colonne racine ───────────────────────────────────────────────────────
const col = el('div', {}); const col = el('div', {});

View File

@ -31,7 +31,7 @@ import { el, showDialog, field, switchRow, filterChip, toast } from './component
* (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`). * (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`).
* v1.4.10 = portage initial de l'Android v1.4.10. * v1.4.10 = portage initial de l'Android v1.4.10.
*/ */
export const WEB_VERSION = '1.6.0'; export const WEB_VERSION = '1.6.1';
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */ /** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
function parseNumOrNull(text) { function parseNumOrNull(text) {

View File

@ -131,7 +131,7 @@ async function main() {
ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)'); ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)');
if (dialog) { if (dialog) {
const dlgText = await dialog.textContent(); const dlgText = await dialog.textContent();
ok(dlgText.includes('Version 1.6.0'), 'dialog : section de la version courante'); ok(dlgText.includes('Version 1.6.1'), 'dialog : section de la version courante');
const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong'); const hasRenderedMd = await dialog.$('.md-body h3, .md-body strong');
ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut'); ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut');
await page.click('.dialog .actions button'); await page.click('.dialog .actions button');
@ -218,6 +218,45 @@ async function main() {
'désactivation → légende prolongée + avertissement disparaissent'); 'désactivation → légende prolongée + avertissement disparaissent');
await page.screenshot({ path: join(SHOTS, 'e2e-chart-extend.png') }); await page.screenshot({ path: join(SHOTS, 'e2e-chart-extend.png') });
// ── 2.ter PERSISTANCE de l'état du graphique (v1.6.1) ───────────────────
// Bug remonté : les options se réinitialisaient « toutes seules » car le
// tick minute / une mutation du store / un resize re-créaient l'écran
// ENTIER (renderRoute) et le state local de renderChart retombait aux
// défauts. Fix : état au niveau module + preserveState du même écran.
// Ici on déclenche le re-render via RESIZE (même code path que le tick :
// store.subscribe et setInterval appellent tous renderRoute) — le tick
// réel de 60 s est trop lent pour un E2E.
console.log('── Persistance des options du graphique (#chart, v1.6.1) ──');
// « Tracé labs » est ON à ce stade (scénario 2.bis) — vérifions avant :
const selectedBefore = await page.evaluate(() =>
[...document.querySelectorAll('.chip')]
.some((x) => x.textContent.trim() === 'Tracé labs' && x.classList.contains('selected')));
ok(selectedBefore, 'pré-état : chip « Tracé labs » activé');
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
await page.waitForTimeout(500);
const stateAfterResize = await page.evaluate(() => ({
labTrackSelected: [...document.querySelectorAll('.chip')]
.some((x) => x.textContent.trim() === 'Tracé labs' && x.classList.contains('selected')),
legend: document.querySelector('main.content').textContent.includes('E2 ancrée sur tes labs'),
}));
ok(stateAfterResize.labTrackSelected,
'resize → le chip « Tracé labs » reste sélectionné (état préservé, bug v1.6.1 corrigé)');
ok(stateAfterResize.legend,
'resize → la légende « Tracé labs » reste affichée (le re-rendu n\'a pas tout remis à zéro)');
// Reset à l'ENTRÉE (navigation) : quitter puis revenir → défauts,
// miroir du reset d'un `remember` Android quand on quitte l'écran
await page.goto(`${base}/#home`, { waitUntil: 'load' });
await page.waitForSelector('body[data-ready="1"]');
await page.goto(`${base}/#chart`, { waitUntil: 'load' });
await page.waitForSelector('body[data-ready="1"]');
await page.waitForTimeout(400);
const stateAfterNav = await page.evaluate(() => ({
labTrackSelected: [...document.querySelectorAll('.chip')]
.some((x) => x.textContent.trim() === 'Tracé labs' && x.classList.contains('selected')),
}));
ok(stateAfterNav.labTrackSelected === false,
'navigation (quitter → revenir) → options retombées aux défauts (miroir Android)');
// ── 3. Navigation : les 5 onglets + settings ──────────────────────────── // ── 3. Navigation : les 5 onglets + settings ────────────────────────────
console.log('── Navigation ──'); console.log('── Navigation ──');
await page.goto(`${base}/#doses`, { waitUntil: 'load' }); await page.goto(`${base}/#doses`, { waitUntil: 'load' });
@ -243,7 +282,7 @@ async function main() {
await page.waitForTimeout(300); await page.waitForTimeout(300);
text = await page.textContent('main.content'); text = await page.textContent('main.content');
ok(text.includes('Langue'), 'Paramètres : carte langue'); ok(text.includes('Langue'), 'Paramètres : carte langue');
ok(text.includes('Version 1.6.0 (web)'), 'Paramètres : version affichée'); ok(text.includes('Version 1.6.1 (web)'), 'Paramètres : version affichée');
ok(text.includes('100 % local'), 'Paramètres : note vie privée'); ok(text.includes('100 % local'), 'Paramètres : note vie privée');
await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') }); await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });