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:
parent
f79a180e36
commit
d86dfb0b6d
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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` |
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
32
js/ui/app.js
32
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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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') });
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user