From be4d26b3e5786af38c1dfccecdff75f8a03af4e4 Mon Sep 17 00:00:00 2001 From: Siphonight Date: Sun, 20 Sep 2026 12:55:51 +0200 Subject: [PATCH] =?UTF-8?q?v1.9.5=20:=20revue=20de=20code=20=E2=80=94=20ga?= =?UTF-8?q?rde=20cutoffHours=20ESE,=20portage=20optimisations=20v1.8.2,=20?= =?UTF-8?q?perf=20canvas,=20docs=20align=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrigé (audit) : - cutoffHours : garde hasModel pour ESE (parité Kotlin) — un backup importé ESE + ester non couvert levait TypeError → écran vide - min/max en une passe : les spreads Math.min(...allPoints) levaient RangeError au-delà de ~1e5 arguments (Nuage × zoom max) et allouaient par frame - init MCMC non bloquante : data-ready garantit désormais « asset chargé » Perf (portage v1.8.2 Android, fin de la dérive de miroir) : - prepareE2Context/e2AtCtx : doses groupées, cutoffs précalculés, Bateman paresseux ; computeCurve ne recalcule plus cutoffHours par point - résultat identique (175 tests verts) E2E : version lue via data-version (anti-bug v1.9.3) Docs/commentaires : pk-engine (ESE analytique), pk-calibration (JSDoc arrondi), dialogs (ESE=6), lab-timing (import mort, JSDoc orpheline), home (COLORS), pk-profile-store (test-only), pk_profiles.json exclu du zip (export-ignore, −548 Ko) --- .gitattributes | 4 ++ README.md | 2 +- docs/CHANGELOG.md | 40 ++++++++++++ docs/DEVELOPPEMENT.md | 4 +- js/pk/estrannaise-cloud.js | 3 +- js/pk/lab-timing.js | 23 ++++--- js/pk/pk-calibration.js | 6 +- js/pk/pk-engine.js | 104 ++++++++++++++++++++++++++------ js/pk/pk-profile-store.js | 6 ++ js/ui/app.js | 32 +++++++--- js/ui/chart-canvas.js | 28 +++++---- js/ui/chart.js | 16 +++-- js/ui/dialogs.js | 3 +- js/ui/home.js | 8 +-- js/ui/settings.js | 2 +- scripts/e2e.mjs | 8 ++- tests/estrannaise-cloud.test.js | 16 ++--- 17 files changed, 228 insertions(+), 77 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..865cdb7 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# v1.9.5 (audit) : les tables ODS ne sont plus fetchées au runtime (analytique +# v1.9.0) — l_asset ne survit que pour le TEST de fidélité RMS (miroir Android +# src/test/assets). Exclu du zip de release (git archive) : −548 Ko livrés. +assets/pk_profiles.json export-ignore diff --git a/README.md b/README.md index 0878614..acfefa7 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ > Fie-toi toujours à tes analyses de sang et aux consignes de ton > endocrinologue. -- **Statut** : web v1.9.4 — parité fonctionnelle avec l'Android v1.9.4 +- **Statut** : web v1.9.5 — parité fonctionnelle avec l'Android v1.9.5 (modèle Estrannaise analytique + nuage d'incertitude MCMC exclusif ESE ; presets des 6 esters injectables ESE ; l'auto-backup journalier Android reste structurellement non porté, cf §12 — l'export manuel couvre la diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2bf3fd3..fad4117 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,46 @@ > 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.9.5] — 2026-09-20 + +### Corrigé — revue de code post-v1.9.4 (audit lecture-seule des deux dépôts) + +- **Garde de `cutoffHours` pour ESE** (parité du Kotlin) : la branche ESE ne + vérifiait pas `hasModel(ester)` — un traitement IMPORTÉ (le backup accepte + toute string de pkModel/ester) avec un ester non couvert (ex. ECS/PEP en + ESE) levait `TypeError` et laissait un écran vide. Retombe maintenant sur + la garde Bateman (≥ 24 h), comme TFS/WHS le faisaient déjà. +- **min/max en UNE passe** : les spreads `Math.min(...allPoints)` (≈ 5 par + frame) lèvent `RangeError` au-delà de ~10⁵ arguments — atteignable avec + le Nuage actif × zoom max (≈ 230 000 points) — et allouaient un tableau + intermédiaire par appel. Remplacés par une boucle (chart.js + + chart-canvas.js). +- **Init MCMC non bloquante** : le démarrage n'attend plus le fetch de + l'asset (seul le nuage lit le posterior, qui renvoie `[]` proprement) ; + l'init re-rend à la fin et pose `data-ready` — la garantie E2E + « data-ready ⇒ asset chargé » est maintenant RÉELLE (le commentaire + antérieur était faux : le marqueur était posé avant le fetch). +- **Portage des optimisations Android v1.8.2** (la dérive de miroir était + réelle) : contexte moteur pré-calculé (`prepareE2Context`/`e2AtCtx` — + doses groupées, cutoffs par traitement, Bateman paresseux), + `computeCurve` ne recalcule plus `cutoffHours` PAR POINT × traitement ni + le scan complet de doseLogs. Résultats identiques (175 tests verts). +- **E2E version paramétrée** : les asserts lisent `data-version` posé par + l'app au lieu de strings codées en dur à bump à chaque release (l'oubli a + produit le bug v1.9.3). + +### Documenté — miroir nettoyé + +- En-tête de `pk-engine.js` à jour (ESE analytique v1.9.0, plus de tables + ODS) ; JSDoc « troncature » de `pk-calibration.js` alignée sur + `Math.round` (fix v1.9.2) ; `dialogs.js` (ESE=6) ; import mort et JSDoc + orpheline de `lab-timing.js` retirés ; couleurs de l'accueil via + `COLORS` (source unique avec le canvas). +- **`pk_profiles.json` exclu du zip de release** (`.gitattributes` + export-ignore, −548 Ko livrés) : plus fetché au runtime depuis v1.9.0 — + l'asset ne survit que pour le test de fidélité RMS (miroir Android), + `pk-profile-store.js` documenté test-only. + ## [1.9.4] — 2026-09-19 ### Ajouté — les presets du modèle ESE couvrent ses 6 esters injectables diff --git a/docs/DEVELOPPEMENT.md b/docs/DEVELOPPEMENT.md index 26a2d26..94d97a1 100644 --- a/docs/DEVELOPPEMENT.md +++ b/docs/DEVELOPPEMENT.md @@ -60,7 +60,7 @@ Règles du portage, fixées au départ : | 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 | +| 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 175 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())`) | @@ -405,7 +405,7 @@ Pièges rencontrés : |---|---|---|---| | 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` | +| Unitaires | `node --test` | **175 tests** : noyau PK (ESE analytique/TFS/WHS), calibration, backup, store, helpers, alertes, rappels, changelog, tracé labs, estrannaise-models/cloud, rounding, presets (v1.9.4) | `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` | diff --git a/js/pk/estrannaise-cloud.js b/js/pk/estrannaise-cloud.js index 82cfb88..c0e2120 100644 --- a/js/pk/estrannaise-cloud.js +++ b/js/pk/estrannaise-cloud.js @@ -51,14 +51,13 @@ export const MIN_HORIZON_HOURS = 6; * @param {number} startMs fenêtre demandée (celle du graphique) * @param {number} endMs * @param {number} stepMs pas de la grille (stepForRange) - * @param {number} nowMs « maintenant » * @param {number} [nbCurves=32] * @returns {Array>} M courbes — * **vide** si rien n'est calculable : aucun traitement E2 actif à profil * ESE couvert, aucune dose, ou MCMC non chargé. */ export function compute( - treatments, doseLogs, startMs, endMs, stepMs, nowMs, + treatments, doseLogs, startMs, endMs, stepMs, nbCurves = DEFAULT_NB_CURVES, /** * Calibration à appliquer au nuage (v1.9.1 — fix « le nuage ne s'active diff --git a/js/pk/lab-timing.js b/js/pk/lab-timing.js index ce88404..c43661f 100644 --- a/js/pk/lab-timing.js +++ b/js/pk/lab-timing.js @@ -16,8 +16,8 @@ * - traitement porteur : ESTRADIOL + injectable + ACTIF + Posologie * (`forecastIntervalDays > 0`) — sinon null (la page Analyses affiche * alors l'invite « renseigne une Posologie ») ; - * - t½ terminale : analytique TFS/WHSAH (`terminalHalfLifeDays`), lue dans - * la TABLE pour Estrannaise (`pkProfileStore.terminalHalfLifeDays`) ; + * - t½ terminale : ANALYTIQUE pour les trois modèles (`terminalHalfLifeDays` + * — v1.9.0 : Estrannaise analytique aussi, plus aucune lecture de table) ; * - début du régime courant = 1ʳᵉ dose du traitement actif — PROXY assumé : * l'app ne trace pas l'historique des éditions de Posologie/dose ; * - creux = minimum local de la courbe E2 PRÉVISIONNELLE (doses réelles + @@ -38,7 +38,6 @@ import { computeCurve, generateForecastDoses, TConfig, usesProfileModel, doseEst import * as EstrannaiseModels from './estrannaise-models.js'; import { model as tfsModel } from './transfem-science-models.js'; import { model as whsahModel } from './whsah-models.js'; -import * as pkProfileStore from './pk-profile-store.js'; /** Règle de stabilisation : 5 demi-vies terminales (≈ 97 % de l'équilibre). */ export const STABILIZATION_HALF_LIVES = 5; @@ -144,15 +143,6 @@ export function nextBloodDrawRecommendation(treatments, doseLogs, labs, nowMs = return null; // stabilisation hors horizon (ester ultra-long) → carte cachée } -/** - * Invite d'AFFICHAGE (v1.8.0, demande explicite) : existe-t-il un traitement - * E2 injectable ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la - * renseigner pour recevoir des recommandations. Ne s'affiche QUE si aucune - * recommandation n'est calculable. - * - * @param {object[]} treatments - * @returns {boolean} - */ /** * DÉBUT DU RÉGIME COURANT (v1.8.1 — correction du proxy v1.8.0, miroir du * Kotlin). Critique remontée : « changé d'ester, de dosage ET de posologie, @@ -200,6 +190,15 @@ function computeRegimeStartMs(carrier, myDoses) { return regimeStart; } +/** + * Invite d'AFFICHAGE (v1.8.0, demande explicite) : existe-t-il un traitement + * E2 injectable ACTIF SANS Posologie ? Si oui, la page Analyses suggère de la + * renseigner pour recevoir des recommandations. Ne s'affiche QUE si aucune + * recommandation n'est calculable. + * + * @param {object[]} treatments + * @returns {boolean} + */ export function shouldSuggestPosology(treatments) { return treatments.some((t) => t.type === 'ESTRADIOL' diff --git a/js/pk/pk-calibration.js b/js/pk/pk-calibration.js index 2bde451..8fbcb54 100644 --- a/js/pk/pk-calibration.js +++ b/js/pk/pk-calibration.js @@ -54,7 +54,9 @@ function median(values) { return (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0; } -/** Arrondi « facteur » à 2 décimales, troncature (miroir du Kotlin (x*100|0)/100). */ +/** Arrondi « facteur » à 2 décimales (v1.9.2 : Math.round — miroir + * medianRounded Kotlin v1.8.2 ; la troncature historique (x*100|0)/100 + * faussait les échelles d'un cran, cf le test rounding.test.js). */ export function round2(x) { // v1.9.2 : ARRONDI (Math.round) au lieu de troncature — miroir du fix // Android v1.8.2 (medianRounded) : la troncature au dernier bit flottant @@ -63,7 +65,7 @@ export function round2(x) { return Math.round(x * 100) / 100; } -/** Arrondi « k T » à 3 décimales, troncature. */ +/** Arrondi « k T » à 3 décimales (v1.9.2 : Math.round, cf round2). */ export function round3(x) { return Math.round(x * 1000) / 1000; } diff --git a/js/pk/pk-engine.js b/js/pk/pk-engine.js index 843603d..14e0147 100644 --- a/js/pk/pk-engine.js +++ b/js/pk/pk-engine.js @@ -8,15 +8,19 @@ * * Chaque injection contribue `dose_mg × profil(dt)` où `profil` est la * réponse normalisée (pg/mL par mg) ; les contributions se superposent. - * Trois modèles superposables : - * - **Estrannaise (ESE)** = tables horaires de l'asset (ODS) ; + * Trois modèles ANALYTIQUES superposables (l'ODS a quitté le runtime en + * v1.9.0 — les tables ne survivent que dans le test de fidélité RMS) : + * - **Estrannaise (ESE)** = forme close 3C de estrannaise.js (6 esters + * injectables, posterior MCMC pour le nuage, cf estrannaise-models.js) ; * - **Transfem Science (TFS)** = forme close V3C de la méta-analyse (7 esters) ; * - **WHSAH (WHS)** = fit « license-free » de Mona (6 esters, PEP non couvert). * Gel/patch/oral → modèle de Bateman paramétrable. * * ⚠️ INVARIANTS à préserver (doc Android §14 — bugs à ne pas réintroduire) : * - `computeKa` : bisection DANS LE BON SENS (#19) ; - * - tables ESE : modèle strict + extrapolation terminale ≥ 1 % du pic (#20/#21) ; + * - modèles de PROFIL stricts : un ester NON couvert par le modèle du + * traitement → 0 (jamais un autre modèle) — #20/#21, cf + * `choicesForModel` (models.js) qui n'offre que les esters tracés ; * - `convertTToNgMl` appliquée à la calibration ET au rendu (#23) ; * - `generateForecastDoses` : créneaux passés SAUTÉS (#35). * @@ -211,9 +215,14 @@ export function cutoffHours(treatment) { } else if (mod === PKModels.TRANSFEM_SCIENCE && TransfemScienceModels.hasModel(treatment.esterType)) { profileH = TransfemScienceModels.model(treatment.esterType).terminalHalfLifeDays * 24.0 * 10.0; } else { - // v1.9.0 : ESE analytique → t½ terminale analytique (comme TFS/WHS), - // plus la longueur de table ODS - profileH = 10 * 24 * estrannaiseModel(treatment.esterType).terminalHalfLifeDays; + // v1.9.0 : ESE analytique → t½ terminale analytique (comme TFS/WHS). + // v1.9.5 (audit, parité du Kotlin) : garde hasModel — un traitement + // IMPORTÉ peut porter n'importe quelle string de pkModel/ester + // (schéma de backup permissif) : ESE + ester non couvert (ex. ECS/PEP) + // ne doit PAS lever TypeError mais retomber sur la garde Bateman ≥ 24 h. + profileH = estrannaiseHasModel(treatment.esterType) + ? 10 * 24 * estrannaiseModel(treatment.esterType).terminalHalfLifeDays + : 0.0; } } const batemanH = usesProfileModel(treatment) ? 0.0 : 30.0 * treatment.eliminationHalfLifeHours; @@ -243,19 +252,53 @@ export function cutoffHours(treatment) { * @param {Object|null} [scalePerEster=null] * @returns {number} pg/mL */ -export function e2At(treatments, doseLogs, tMs, modelOverride = null, scalePerEster = null) { +/** + * Contexte PRÉ-CALCULÉ d'une série d'appels e2At (v1.9.5 — miroir de + * `E2Context` Kotlin, audit) : doses groupées par traitement, cutoff + * précalculé, Bateman PARESSEUX (inutile pour les traitements à PROFIL — + * `concentrationOfDose` ne lit le paramètre que dans la branche Bateman, + * inatteignable quand usesProfileModel). Le chemin chaud du tracé labs + * appelle e2At par point de grille : préparer ce contexte UNE fois évite + * le re-grouping × 720 points × N traitements. L'ordre de sommation est + * PRÉSERVÉ (groupBy conserve l'ordre relatif des doses) → résultats + * bit-identiques à l'ancienne forme. + * + * @param {object[]} treatments + * @param {object[]} doseLogs + * @returns {{treatments: object[], dosesByTreatment: Map, cutoffByTreatment: Map, batemanCache: Map}} + */ +export function prepareE2Context(treatments, doseLogs) { + const dosesByTreatment = new Map(); + for (const dose of doseLogs) { + const list = dosesByTreatment.get(dose.treatmentId); + if (list) list.push(dose); + else dosesByTreatment.set(dose.treatmentId, [dose]); + } + const cutoffByTreatment = new Map(); const batemanCache = new Map(); - let total = 0.0; for (const treatment of treatments) { if (treatment.type !== 'ESTRADIOL') continue; // AA/progestatifs → 0 en E2 - if (!batemanCache.has(treatment.id)) { + cutoffByTreatment.set(treatment.id, cutoffHours(treatment)); + if (!usesProfileModel(treatment)) { batemanCache.set(treatment.id, batemanParams(treatment)); } - for (const dose of doseLogs) { - if (dose.treatmentId !== treatment.id || dose.timestamp > tMs) continue; + } + return { treatments, dosesByTreatment, cutoffByTreatment, batemanCache }; +} + +/** e2At sur contexte pré-calculé (voir prepareE2Context) — la forme WORKHORSE. */ +export function e2AtCtx(ctx, tMs, modelOverride = null, scalePerEster = null) { + let total = 0.0; + for (const treatment of ctx.treatments) { + if (treatment.type !== 'ESTRADIOL') continue; // AA/progestatifs → 0 en E2 + const doses = ctx.dosesByTreatment.get(treatment.id); + if (!doses) continue; + const cutoff = ctx.cutoffByTreatment.get(treatment.id); + for (const dose of doses) { + if (dose.timestamp > tMs) continue; const dtH = (tMs - dose.timestamp) / 3600000.0; - if (dtH > cutoffHours(treatment)) continue; - const c = concentrationOfDose(treatment, dose, tMs, batemanCache.get(treatment.id), modelOverride); + if (dtH > cutoff) continue; + const c = concentrationOfDose(treatment, dose, tMs, ctx.batemanCache.get(treatment.id), modelOverride); if (c > 0.0) { const scale = (scalePerEster && scalePerEster[doseEster(treatment, dose)]) ?? treatment.scaleFactor; total += c * scale; @@ -265,6 +308,11 @@ export function e2At(treatments, doseLogs, tMs, modelOverride = null, scalePerEs return total; } +/** e2At à appel unique : prépare le contexte puis délègue. */ +export function e2At(treatments, doseLogs, tMs, modelOverride = null, scalePerEster = null) { + return e2AtCtx(prepareE2Context(treatments, doseLogs), tMs, modelOverride, scalePerEster); +} + /** * Modèle T EMPIRIQUE (non publié — étiqueté « estimation » partout) : * `T = floor + (base − floor) / (1 + k·E2)` en ng/mL. @@ -375,12 +423,29 @@ export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConf const earliestDose = doseLogs.reduce((m, d) => Math.min(m, d.timestamp), Infinity); const searchStart = Math.max(startMs, earliestDose); + // Pré-calculs PAR TRAITEMENT (v1.9.5 — portage de l'optimisation Kotlin + // v1.8.2 : cutoffHours était recalculé PAR POINT × traitement, le scan + // doseLogs complet par point, et Bateman calculé même pour les profils + // qui ne le lisent jamais) — ordre de sommation PRÉSERVÉ → résultats + // identiques (épinglés par les 172 tests). + const cutoffByTreatment = new Map(); const batemanCache = new Map(); - relevantTreatments.forEach((tr) => batemanCache.set(tr.id, batemanParams(tr))); + const treatmentById = new Map(); + relevantTreatments.forEach((tr) => { + cutoffByTreatment.set(tr.id, cutoffHours(tr)); + treatmentById.set(tr.id, tr); + if (!usesProfileModel(tr)) batemanCache.set(tr.id, batemanParams(tr)); + }); + const dosesByTreatment = new Map(); + for (const dose of doseLogs) { + const list = dosesByTreatment.get(dose.treatmentId); + if (list) list.push(dose); + else dosesByTreatment.set(dose.treatmentId, [dose]); + } // Doses triées pour suivre l'« ester actif » le long de la grille (k de T) const sortedEstrogenDoses = doseLogs - .filter((d) => relevantTreatments.some((tr) => tr.id === d.treatmentId)) + .filter((d) => treatmentById.has(d.treatmentId)) .slice() .sort((a, b) => a.timestamp - b.timestamp); let doseCursor = 0; @@ -393,16 +458,15 @@ export function computeCurve(treatments, doseLogs, startMs, endMs, stepMs, tConf // Avance le curseur : la dose à t définit l'ester actif while (doseCursor < sortedEstrogenDoses.length && sortedEstrogenDoses[doseCursor].timestamp <= t) { const d = sortedEstrogenDoses[doseCursor]; - const tr = relevantTreatments.find((x) => x.id === d.treatmentId); - activeEster = doseEster(tr, d); + activeEster = doseEster(treatmentById.get(d.treatmentId), d); doseCursor++; } const k = (activeEster && tKPerEster && tKPerEster[activeEster]) ?? tConfig.k; for (const treatment of relevantTreatments) { - const cutoff = cutoffHours(treatment); + const cutoff = cutoffByTreatment.get(treatment.id); const p = batemanCache.get(treatment.id); - for (const dose of doseLogs) { - if (dose.treatmentId !== treatment.id || dose.timestamp > t) continue; + for (const dose of dosesByTreatment.get(treatment.id) ?? []) { + if (dose.timestamp > t) continue; const dtH = (t - dose.timestamp) / 3600000.0; if (dtH > cutoff) continue; const c = concentrationOfDose(treatment, dose, t, p, modelOverride); diff --git a/js/pk/pk-profile-store.js b/js/pk/pk-profile-store.js index 3aa7fc8..4af2023 100644 --- a/js/pk/pk-profile-store.js +++ b/js/pk/pk-profile-store.js @@ -1,6 +1,12 @@ /** * ───────────────────────────────────────────────────────────────────────────── * PKProfileStore — chargeur de l'asset `pk_profiles.json` + échantillonnage + * + * ⚠️ HORS RUNTIME WEB depuis v1.9.0 (modèle Estrannaise analytique, + * estrannaise-models.js) : ce module n'est plus importé par js/ui — il ne + * survit que pour le TEST de fidélité RMS (miroir de PKProfileStore.kt, + * test-only côté Android aussi). L'asset est exclu du zip de release + * (.gitattributes export-ignore). * (portage web de `pk/PKProfileStore.kt`). * * L'asset contient les tables horaires Estrannaise extraites du tableur diff --git a/js/ui/app.js b/js/ui/app.js index feabb5d..93be06a 100644 --- a/js/ui/app.js +++ b/js/ui/app.js @@ -55,11 +55,21 @@ async function main() { renderRoute(); // affiche l'écran courant avec le marqueur de chargement // 4) Asset PK (équivalent de PKProfileStore.init(context) dans - // HormoneTrackApp.onCreate) — les courbes ne sont calculées qu'après ; - // le re-rendu ci-dessous remplace le « Chargement… » par les données. + // HormoneTrackApp.onCreate). // v1.9.0 : l'asset MCMC (nuage ESE, 48 Ko) remplace le fetch des tables - // ODS (550 Ko) — démarrage plus rapide, runtime 100 % sources analytiques - await EstrannaiseModels.init(); + // ODS (550 Ko) — démarrage plus rapide, runtime 100 % sources analytiques. + // v1.9.5 (audit) : l'init n'est PLUS BLOQUANTE — seul le nuage lit le + // posterior (compute renvoie [] proprement tant qu'il n'est pas chargé) : + // l'abonnement au store, la boucle de rappels et le dialog « Nouveautés » + // démarrent sans attendre le fetch. L'init re-rend à la fin (le + // « Chargement… » cède la place aux données) et pose data-ready — + // garantie E2E « data-ready ⇒ asset chargé » (le commentaire antérieur + // était faux : le marqueur était posé avant le fetch). + EstrannaiseModels.init().then(() => { + booted = true; + renderRoute(); + document.body.setAttribute('data-ready', '1'); + }); // 5) Langue au démarrage (miroir MainActivity : setApplicationLocales) document.documentElement.lang = resolveLocale(store.getSettings().language); @@ -96,6 +106,8 @@ let contentEl = null; let bannerSlot = null; /** Dernière route rendue (le tick minute ne re-rend que si inchangée). */ let lastRoute = null; +/** Asset MCMC chargé (fin du démarrage) — conditionne le marqueur data-ready. */ +let booted = false; /** Mode démo (?demo=1) : bannière affichée au-dessus du contenu. */ let demoMode = false; @@ -156,9 +168,15 @@ function renderRoute() { const preserveChartState = route === 'chart' && lastRoute === 'chart'; lastRoute = route; - // Marqueur E2E : l'app est prête (asset chargé, écran rendu) — utilisé par - // scripts/e2e.mjs pour attendre la fin du rendu (plus de course au screenshot). - document.body.setAttribute('data-ready', '1'); + // Version exposée à l'E2E (data-version) : les asserts se branchent sur + // CETTE valeur au lieu d'une string codée en dur à bump à chaque release + // — l'oubli a produit le bug v1.9.3 (version annoncée fausse). + document.body.dataset.version = WEB_VERSION; + // Marqueur E2E : l'app est prête — utilisé par scripts/e2e.mjs pour + // attendre la fin du rendu (plus de course au screenshot). Au DÉMARRAGE + // (booted=false), c'est la fin de l'init MCMC qui le pose : data-ready + // garantit « asset chargé, écran rendu » (v1.9.5, audit). + if (booted) document.body.setAttribute('data-ready', '1'); // Onglet actif document.querySelectorAll('.tabbar button').forEach((b) => { b.classList.toggle('active', b.getAttribute('data-route') === route); diff --git a/js/ui/chart-canvas.js b/js/ui/chart-canvas.js index ccca522..b9a312d 100644 --- a/js/ui/chart-canvas.js +++ b/js/ui/chart-canvas.js @@ -87,22 +87,28 @@ export function drawChart(canvas, series, e2Labs, tLabs, options, forecastDoses const h = cssH - padTop - padBottom; if (w <= 0 || h <= 0) return; - const t0 = Math.min(...allPoints.map((p) => p.timestamp)); - const t1 = Math.max(...allPoints.map((p) => p.timestamp)); + // v1.9.5 (audit) : t0/t1/e2Max/tMax en UNE passe (RangeError possible au-delà + // de ~10⁵ arguments de spread — atteignable avec le Nuage actif ; et 5 + // allocations intermédiaires par frame de pan évitées). + let t0 = Infinity; + let t1 = -Infinity; + let e2DataMax = 0; + let tDataMax = 0; + for (const p of allPoints) { + if (p.timestamp < t0) t0 = p.timestamp; + if (p.timestamp > t1) t1 = p.timestamp; + if (p.e2 > e2DataMax) e2DataMax = p.e2; + if (p.t > tDataMax) tDataMax = p.t; + } if (t1 <= t0) return; // Labs T convertis en ng/mL AVANT l'échelle (fix #23) const convertedTLabs = tLabs.map((lab) => ({ ...lab, value: convertTToNgMl(lab.value, lab.unit) })); - - const e2DataMax = Math.max( - Math.max(...allPoints.map((p) => p.e2)), - options.showLabs && e2Labs.length ? Math.max(...e2Labs.map((l) => l.value)) : 0, - ); + if (options.showLabs) { + for (const l of e2Labs) if (l.value > e2DataMax) e2DataMax = l.value; + for (const l of convertedTLabs) if (l.value > tDataMax) tDataMax = l.value; + } const e2Max = niceCeil(Math.max(e2DataMax, 50.0)); - const tDataMax = Math.max( - Math.max(...allPoints.map((p) => p.t)), - options.showLabs && convertedTLabs.length ? Math.max(...convertedTLabs.map((l) => l.value)) : 0, - ); const tMax = niceCeil(tDataMax * 1.1); const xOf = (ms) => padLeft + (w * (ms - t0)) / (t1 - t0); diff --git a/js/ui/chart.js b/js/ui/chart.js index 77994ed..3d8c4ac 100644 --- a/js/ui/chart.js +++ b/js/ui/chart.js @@ -313,7 +313,7 @@ export function renderChart(container, ctx, { preserveState = false } = {}) { // et dessinée en alpha faible. if (state.showCloud) { const cloud = computeEstrannaiseCloud( - treatments, doseLogs, startMs, endMs, step, nowMs, + treatments, doseLogs, startMs, endMs, step, DEFAULT_NB_CURVES, autoByModel.get('ESE')?.esterScales ?? null, ); if (cloud.length > 0) curves.push(['CLOUD', cloud.flat()]); @@ -351,12 +351,20 @@ export function renderChart(container, ctx, { preserveState = false } = {}) { const allPoints = curves.flatMap(([, pts]) => pts); - // Labs filtrés sur la fenêtre ; le toggle T masque AUSSI les labs T + // Labs filtrés sur la fenêtre ; le toggle T masque AUSSI les labs T. + // v1.9.5 (audit) : min/max en UNE passe (boucle) — les spreads + // `Math.min(...arr)` lèvent RangeError au-delà de ~10⁵ arguments + // (atteignable : Nuage actif × zoom max ≈ 230 000 points) et + // allouent un tableau intermédiaire par appel × frame de pan. let a = 0; let b = 0; if (allPoints.length >= 2) { - a = Math.min(...allPoints.map((p) => p.timestamp)); - b = Math.max(...allPoints.map((p) => p.timestamp)); + a = allPoints[0].timestamp; + b = a; + for (const p of allPoints) { + if (p.timestamp < a) a = p.timestamp; + if (p.timestamp > b) b = p.timestamp; + } } const e2Labs = labResults.filter((l) => String(l.marker).toUpperCase() === 'E2' && l.timestamp >= a && l.timestamp <= b); const tLabs = state.showT diff --git a/js/ui/dialogs.js b/js/ui/dialogs.js index 2a2ba7c..485c5b4 100644 --- a/js/ui/dialogs.js +++ b/js/ui/dialogs.js @@ -7,7 +7,8 @@ * - create (traitements ACTIFS proposés) / edit (TOUS les traitements — * la dose éditée reste rattachable à son traitement même inactivé, §6.bis) ; * - override d'ESTER par injection (comme le tableur d'origine) : la liste - * suit le MODÈLE du traitement via choicesForModel (TFS=7, WHS=6, ESE=3). + * suit le MODÈLE du traitement via choicesForModel (v1.9.4 : TFS=7, + * WHSAH=6, ESE analytique=6). * * LabDialog (v1.2.2) : prise de sang E2 + T en UNE entrée (chacune * optionnelle) → 1 ou 2 LabResult au même timestamp ; unités T suggérées. diff --git a/js/ui/home.js b/js/ui/home.js index 42bdc3c..2584654 100644 --- a/js/ui/home.js +++ b/js/ui/home.js @@ -23,7 +23,7 @@ import { store } from '../data/store.js'; import { t } from '../util/i18n.js'; import { formatDose, formatValue, formatTime, formatWeekdayTime, formatDateTime, HrtDuration } from '../util/format.js'; import { el } from './components.js'; -import { drawChart } from './chart-canvas.js'; +import { drawChart, COLORS } from './chart-canvas.js'; import { openDoseDialog } from './dialogs.js'; const HOUR_MS = 3600000; @@ -188,12 +188,12 @@ export function renderHome(container, ctx) { const tLabs = labResults.filter((l) => String(l.marker).toUpperCase() === 'T' && l.timestamp >= a && l.timestamp <= b); // Dessin différé : le canvas doit être dans le DOM pour connaître sa taille requestAnimationFrame(() => { - drawChart(canvas, [{ points: curve, e2Style: { color: '#4f5bd5', dashed: false }, tStyle: { color: '#d6589e', dashed: false } }], + drawChart(canvas, [{ points: curve, e2Style: { color: COLORS.chartE2, dashed: false }, tStyle: { color: COLORS.chartT, dashed: false } }], e2Labs, tLabs, { showT: true, showLabs: true, showExtrema: false, nowMs }, [], [], null); }); } - chartCard.appendChild(el('div', { class: 'legend-text', style: 'color:#4f5bd5;' }, t('home_legend_e2'))); - chartCard.appendChild(el('div', { class: 'legend-text', style: 'color:#d6589e;' }, t('home_legend_t'))); + chartCard.appendChild(el('div', { class: 'legend-text', style: `color:${COLORS.chartE2};` }, t('home_legend_e2'))); + chartCard.appendChild(el('div', { class: 'legend-text', style: `color:${COLORS.chartT};` }, t('home_legend_t'))); chartCard.appendChild(el('div', { class: 'legend-muted' }, t('open_charts_hint'))); col.appendChild(chartCard); diff --git a/js/ui/settings.js b/js/ui/settings.js index 187a6e3..c0f6d45 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.9.4'; +export const WEB_VERSION = '1.9.5'; /** 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 1414540..663d8d4 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -131,7 +131,11 @@ 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.9.4'), 'dialog : section de la version courante'); + // v1.9.5 : la version attendue vient de l'APP (data-version posée par + // app.js) — plus aucune string codée en dur à bump à chaque release + // (l'oubli a produit le bug v1.9.3 : version annoncée fausse). + const appVersion = await page.getAttribute('body', 'data-version'); + ok(dlgText.includes(`Version ${appVersion}`), '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'); @@ -334,7 +338,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.9.4 (web)'), 'Paramètres : version affichée'); + ok(text.includes(`Version ${await page.getAttribute('body', 'data-version')} (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') }); diff --git a/tests/estrannaise-cloud.test.js b/tests/estrannaise-cloud.test.js index e93aeba..18dc22f 100644 --- a/tests/estrannaise-cloud.test.js +++ b/tests/estrannaise-cloud.test.js @@ -34,7 +34,7 @@ describe('EstrannaiseCloud web (miroir Android v1.9.0)', () => { }); test('32 courbes avec dispersion réelle entre elles', () => { - const cloud = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW); + const cloud = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000); assert.equal(cloud.length, 32); assert.ok(cloud.every((c) => c.length >= 2)); const peaks = [...new Set(cloud.map((c) => Math.max(...c.map((p) => p.e2))))]; @@ -44,7 +44,7 @@ describe('EstrannaiseCloud web (miroir Android v1.9.0)', () => { test('le nuage reste dans la fenêtre demandée', () => { const start = NOW - 10 * DAY_MS; const end = NOW; - const cloud = compute([ese()], doses(), start, end, 3_600_000, NOW); + const cloud = compute([ese()], doses(), start, end, 3_600_000); assert.ok(cloud.every((c) => c.every((p) => p.timestamp >= start && p.timestamp <= end))); }); @@ -54,7 +54,7 @@ describe('EstrannaiseCloud web (miroir Android v1.9.0)', () => { // Estrannaise, donc le nuage les couvre aussi (l\'exclusivité ESE est // portée par le CHIP, pas par le filtre des doses) const tfs = { ...ese(), pkModel: 'TFS' }; - const cloud = compute([tfs], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW); + const cloud = compute([tfs], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000); assert.equal(cloud.length, 32); assert.ok(cloud.every((c) => c.length >= 2)); const oral = makeTreatment({ @@ -63,23 +63,23 @@ describe('EstrannaiseCloud web (miroir Android v1.9.0)', () => { forecastIntervalDays: 2.0, isActive: true, }); // L'oral Bateman reste hors nuage (pas d'ester échantillonnable) - assert.equal(compute([oral], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW).length, 0); + assert.equal(compute([oral], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000).length, 0); }); test('aucune dose sur le porteur → nuage vide', () => { - assert.equal(compute([ese()], [], NOW - 30 * DAY_MS, NOW, 3_600_000, NOW).length, 0); + assert.equal(compute([ese()], [], NOW - 30 * DAY_MS, NOW, 3_600_000).length, 0); }); test('nbCurves < 2 → nuage vide', () => { - assert.equal(compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW, 1).length, 0); + assert.equal(compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, 1).length, 0); }); test('le nuage suit la calibration (fix v1.9.1, miroir Android)', () => { // scalePerEster ×2 → nuage calibré = nuage brut ×2 (le nuage ENTOURE // la courbe ESE calibrée au lieu de flotter à une autre échelle) const scaled = { EEN: 2.0 }; - const calibrated = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW, 32, scaled); - const raw = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, NOW); + const calibrated = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000, 32, scaled); + const raw = compute([ese()], doses(), NOW - 30 * DAY_MS, NOW, 3_600_000); assert.equal(calibrated.length, raw.length); assert.ok(raw[raw.length - 1].length >= 2); const rawLast = raw[raw.length - 1][raw[raw.length - 1].length - 1].e2;