v1.9.5 : revue de code — garde cutoffHours ESE, portage optimisations v1.8.2, perf canvas, docs alignées

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)
This commit is contained in:
Siphonight 2026-09-20 12:55:51 +02:00
parent f79a180e36
commit d86dfb0b6d
17 changed files with 228 additions and 77 deletions

4
.gitattributes vendored Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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` |

View File

@ -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<Array<{timestamp:number,e2:number,t:number}>>} 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

View File

@ -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'

View File

@ -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;
}

View File

@ -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<string,number>|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);

View File

@ -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

View File

@ -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);

View File

@ -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);

View File

@ -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

View File

@ -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.

View File

@ -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);

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`).
* 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) {

View File

@ -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') });

View File

@ -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;