diff --git a/README.md b/README.md index 0220020..c5bf5da 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,10 @@ > Fie-toi toujours à tes analyses de sang et aux consignes de ton > endocrinologue. -- **Statut** : web v1.6.0 — portage de l'Android v1.6.0 · **140 tests verts** · - E2E navigateur ✅ · conteneur testé en configuration durcie ✅ · lint/i18n ✅ +- **Statut** : web v1.6.1 — parité fonctionnelle avec l'Android v1.6.0 + (+ 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) - **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) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 85f6687..b8935ef 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,31 @@ > section la plus récente EN PREMIER ; ce fichier est lu par le dialog > « 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 ### « Tracé labs » prolongé au-delà du dernier lab — portage de l'Android v1.6.0 diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index c0124f5..421bed9 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -272,6 +272,16 @@ HormoneTrack-web/ (dépôt séparé) - **« 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). @@ -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 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"]` @@ -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 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 diff --git a/js/ui/app.js b/js/ui/app.js index 1491ae5..ef82af7 100644 --- a/js/ui/app.js +++ b/js/ui/app.js @@ -141,6 +141,17 @@ function currentRoute() { function renderRoute() { if (!contentEl) return; 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; // É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'; } }); break; case route === 'chart': - renderChart(contentEl, { nowMs }); + renderChart(contentEl, { nowMs }, { preserveState: preserveChartState }); break; case route === 'doses': renderDoses(contentEl); diff --git a/js/ui/chart.js b/js/ui/chart.js index f9ac70b..160fd84 100644 --- a/js/ui/chart.js +++ b/js/ui/chart.js @@ -19,6 +19,11 @@ * - « 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 * 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 * (piège Android #62/§11). * ───────────────────────────────────────────────────────────────────────────── @@ -43,6 +48,29 @@ const HOUR_MS = 3600000; const MIN_RANGE_H = 6; 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). */ function seriesStyles(model) { 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]. + * * @param {HTMLElement} container * @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 doseLogs = store.getDoseLogs(); const labResults = store.getLabResults(); @@ -65,27 +102,33 @@ export function renderChart(container, ctx) { const chartTimeZone = settings.chartTimezone || null; // ── État d'interaction (miroir des mutableStateOf Compose) ──────────────── - const state = { - rangeHours: 24, - panHours: 0, - showT: true, - showLabs: true, - showEse: true, - showTfs: true, - showWhs: false, - showForecast: false, - showExtrema: false, - showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0) - // v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du - // dernier lab constant × modèle, bornée par l'extinction du modèle — - // cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la - // partie prolongée est une ESTIMATION non ancrée, elle doit être - // 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, - }; + // v1.6.1 : réutilisé entre les re-rendus du même écran (cf bloc + // « État d'interaction » au-dessus — sans ça, le tick minute de app.js + // réinitialisait toutes les options toutes les 60 s). + if (!preserveState || !chartUiState) { + chartUiState = { + rangeHours: 24, + panHours: 0, + showT: true, + showLabs: true, + showEse: true, + showTfs: true, + showWhs: false, + showForecast: false, + showExtrema: false, + showLabTrack: false, // v1.5.0 — courbe ancrée sur les labs, OFF par défaut (choix v1.5.0) + // v1.6.0 : PROLONGATION du tracé labs au-delà du dernier lab (ρ du + // dernier lab constant × modèle, bornée par l'extinction du modèle — + // cf LabTrajectoryModel.extensionHorizonEndMs). OFF par défaut : la + // partie prolongée est une ESTIMATION non ancrée, elle doit être + // 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 ─────────────────────────────────────────────────────── const col = el('div', {}); diff --git a/js/ui/settings.js b/js/ui/settings.js index 709d416..bfbfce2 100644 --- a/js/ui/settings.js +++ b/js/ui/settings.js @@ -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`). * 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. */ function parseNumOrNull(text) { diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index d8f9019..a1baa56 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -131,7 +131,7 @@ async function main() { ok(dialog !== null, 'dialog « Nouveautés » au premier lancement (markdown rendu)'); if (dialog) { 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'); ok(hasRenderedMd !== null, 'dialog : markdown RENDU (titres/gras), pas de brut'); await page.click('.dialog .actions button'); @@ -218,6 +218,45 @@ async function main() { 'désactivation → légende prolongée + avertissement disparaissent'); 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 ──────────────────────────── console.log('── Navigation ──'); await page.goto(`${base}/#doses`, { waitUntil: 'load' }); @@ -243,7 +282,7 @@ async function main() { await page.waitForTimeout(300); text = await page.textContent('main.content'); 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'); await page.screenshot({ path: join(SHOTS, 'e2e-settings.png') });