Siphonight 174928bb6b v1.9.0 : modèle Estrannaise ANALYTIQUE (abandon de l'ODS) + nuage d'incertitude MCMC
Miroir fidèle du Kotlin (doc Android §7.12) :
- estrannaise-models.js : forme close 3C de estrannaise.js (paramètres
  publiés EV/EU/EEn/EC/EB/EUCS), posterior MCMC (313 échantillons/ester,
  asset mcmc_samples.json ≈ 48 Ko fetché au démarrage — remplace le fetch
  des tables ODS, 550 Ko), t½ terminale analytique, mapping en objets
  {d,k1,k2,k3} (miroir du Param Kotlin).
- estrannaise-cloud.js : nuage d'incertitude — 32 courbes du posterior
  (échelonnées, déterministes) superposant les doses des porteurs ESE ;
  exclusif ESE (TFS/WHSAH sans posterior publié), gardes (sans dose,
  nbCurves<2, MCMC non chargé → vide).
- pk-engine.js : dispatch ESE → forme close (plus de fallback PKProfileStore
  au runtime) ; cutoffHours ESE → 10 × t½ analytique ; lab-timing.js t½ ESE
  analytique ; app.js fetch MCMC remplace le fetch des tables ODS
  (démarrage plus rapide).
- chart.js/chart-canvas.js : chip « Nuage » (off, activable si ESE affiché,
  coupure auto si ESE off), clé 'CLOUD' skippée des légendes (leçon #63),
  nuage dessiné en alpha faible, légende dédiée ; seed démo + traitement
  EV/ESE (2 doses) pour démontrer le nuage ; choicesForModel ESE=6.
- Tests : estrannaise-models.test.js (6 : fidélité RMS/pics, dégénérés,
  MCMC 313×6, t½ analytique, gardes) + estrannaise-cloud.test.js (5 :
  32 courbes à dispersion réelle, fenêtre, exclusivité ESE, gardes)
  → 166 verts + E2E (chip/légende/exclusivité nuage) + check --release.
- WEB_VERSION 1.9.0 (versions sync Android). CHANGELOG [1.9.0] web.
2026-09-19 20:45:53 +02:00

408 lines
16 KiB
JavaScript

/**
* ─────────────────────────────────────────────────────────────────────────────
* HormoneTrack Web — shell applicatif (portage de HormoneTrackApp.kt +
* MainActivity.kt + HormoneTrackRoot.kt).
*
* - attend l'initialisation de l'asset MCMC (nuage d'incertitude ESE, v1.9.0) ;
* - routing par hash (#home/#chart/#doses/#labs/#treatments/#settings/
* #treatment-edit/{id}) — permet la navigation headless et le lien profond ;
* - NavigationBar 5 onglets (barre masquée sur settings/éditeur, miroir
* Android) ;
* - dialog « Nouveautés » (changelog.md fetché, version vue persistée) ;
* - boucle de rappels (notifications navigateur) ;
* - re-rendu global sur mutation du store (pub/sub) + tick minute (les
* estimations « maintenant » vivent) ;
* - hook de TEST ?demo=1 : charge un jeu de données de démonstration si le
* store est vide (voir scripts de smoke-test + doc de dev §Tests).
*
* ⚠️ 100 % LOCAL : aucun envoi de données. Le seul réseau est le fetch
* (lecture) de l'asset PK et du changelog, servis en statique.
* ─────────────────────────────────────────────────────────────────────────────
*/
import * as EstrannaiseModels from '../pk/estrannaise-models.js';
import { store } from '../data/store.js';
import * as AppLog from '../util/app-log.js';
import { isVersionNewer, sectionsSince } from '../util/changelog.js';
import { t, resolveLocale } from '../util/i18n.js';
import { el, showDialog } from './components.js';
import { renderHome } from './home.js';
import { renderChart } from './chart.js';
import { renderDoses } from './doses.js';
import { renderLabs } from './labs.js';
import { renderTreatments } from './treatments.js';
import { renderTreatmentEditor } from './treatment-editor.js';
import { renderSettings, WEB_VERSION } from './settings.js';
import { startReminderLoop } from './reminders.js';
import { openDoseDialog } from './dialogs.js';
/** Point d'entrée. */
async function main() {
// 1) Backend de stockage (localStorage) + chargement du journal
store.setBackend(window.localStorage);
AppLog.init();
// 2) Hook de test ?demo=1 : données de démo si le store est vide
if (new URLSearchParams(window.location.search).get('demo') === '1') {
demoMode = true;
maybeLoadDemoData();
}
// 3) Shell rendu SYNCHRONIQUEMENT (avant le fetch de l'asset) : l'utilisateur
// voit immédiatement la structure + « Chargement… » au lieu d'une page
// blanche — et les captures d'écran smoke-test ne sont plus vides.
buildShell();
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.
// 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();
// 5) Langue au démarrage (miroir MainActivity : setApplicationLocales)
document.documentElement.lang = resolveLocale(store.getSettings().language);
// 6) Boucle de rappels (notifications navigateur)
startReminderLoop((treatmentId) => {
location.hash = '#home';
// petit délai pour laisser le re-render se faire avant le dialog
setTimeout(() => openDoseDialog({
treatments: store.getTreatments().filter((x) => x.isActive),
preselected: store.getTreatments().find((x) => x.id === treatmentId) || null,
existing: null,
}), 60);
});
// 7) Dialog « Nouveautés » après mise à jour (v1.3.0 Android)
await maybeShowWhatsNew();
// 8) Re-rendu global : mutations du store + tick minute + navigation hash
store.subscribe(() => renderRoute());
setInterval(() => {
if (lastRoute === currentRoute()) renderRoute(); // rafraîchit « now »
}, 60000);
window.addEventListener('hashchange', () => renderRoute());
window.addEventListener('resize', () => renderRoute()); // le canvas suit la largeur
AppLog.log('app', `démarrage web v${WEB_VERSION}`);
renderRoute();
}
// ── Shell ────────────────────────────────────────────────────────────────────
let contentEl = null;
let bannerSlot = null;
/** Dernière route rendue (le tick minute ne re-rend que si inchangée). */
let lastRoute = null;
/** Mode démo (?demo=1) : bannière affichée au-dessus du contenu. */
let demoMode = false;
/** Construit topbar + bandeau trans + main + tabbar (une seule fois). */
function buildShell() {
const topbar = el('div', { class: 'topbar' }, [
el('h1', {}, t('app_name')),
el('button', { class: 'btn text', onclick: () => { location.hash = '#settings'; } }, '⚙'),
]);
document.body.appendChild(topbar);
document.body.appendChild(el('div', { class: 'trans-banner' }));
bannerSlot = el('div', { id: 'banner-slot', style: 'max-width:720px;margin:0 auto;padding:0 16px;' });
document.body.appendChild(bannerSlot);
contentEl = el('main', { class: 'content' });
document.body.appendChild(contentEl);
const tabs = [
['home', '🏠', t('nav_home')],
['chart', '📈', t('nav_chart')],
['doses', '💉', t('nav_doses')],
['labs', '🧪', t('nav_labs')],
['treatments', '💊', t('nav_treatments')],
];
const tabbar = el('nav', { class: 'tabbar' });
for (const [route, icon, label] of tabs) {
const btn = el('button', { 'data-route': route, onclick: () => { location.hash = `#${route}`; } }, [
el('span', { class: 'ico' }, icon),
el('span', {}, label),
]);
tabbar.appendChild(btn);
}
document.body.appendChild(tabbar);
}
/** Route courante depuis le hash (défaut #home). */
function currentRoute() {
const h = (window.location.hash || '#home').replace(/^#/, '');
return h === '' ? 'home' : h;
}
/**
* Rend la route courante dans contentEl — re-création complète de l'écran
* (le DOM est jetable, l'état vit dans le store / le hash).
*/
function renderRoute() {
if (!contentEl) return;
const route = currentRoute();
// (v1.6.1) Re-rendu du MÊME écran graphique (tick minute, mutation du
// store, resize — ce re-rendu ne vient PAS de la navigation) ? Le chart
// doit alors PRÉSERVER son état d'interaction (chips, zoom, pan,
// Tracé labs…) : miroir Compose où `remember` survit aux recompositions.
// Sans ça, le tick minute de 60 s réinitialisait toutes les options du
// graphique pendant que l'utilisateur le regarde (bug remonté : « les
// options se reset alors qu'on n'a pas changé de menu »). À l'ENTRÉE
// (navigation depuis un autre onglet) : preserveState = false → défauts,
// miroir du reset Android quand on quitte l'écran. ⚠️ comparé AVANT la
// mise à jour de lastRoute ci-dessous.
const preserveChartState = route === 'chart' && lastRoute === 'chart';
lastRoute = route;
// 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');
// Onglet actif
document.querySelectorAll('.tabbar button').forEach((b) => {
b.classList.toggle('active', b.getAttribute('data-route') === route);
});
// Barre masquée sur settings + éditeur (miroir HormoneTrackRoot)
document.querySelector('.tabbar').style.display
= (route === 'settings' || route.startsWith('treatment-edit')) ? 'none' : 'flex';
// Re-traduit la topbar (changement de langue)
const h1 = document.querySelector('.topbar h1');
if (h1) h1.textContent = t('app_name');
document.documentElement.lang = resolveLocale(store.getSettings().language);
// Bannières de rappel : nettoyées au changement d'écran (elles se recréent
// via la boucle si toujours pertinentes)
if (bannerSlot) bannerSlot.textContent = '';
contentEl.textContent = '';
// Bannière mode démo (hook de test ?demo=1) : explicite pour ne jamais
// confondre des données de démo avec de vraies données de santé
if (demoMode) {
contentEl.appendChild(el('div', { class: 'demo-banner' }, t('demo_banner')));
}
const nowMs = Date.now();
switch (true) {
case route === 'home':
renderHome(contentEl, { nowMs, openChart: () => { location.hash = '#chart'; } });
break;
case route === 'chart':
renderChart(contentEl, { nowMs }, { preserveState: preserveChartState });
break;
case route === 'doses':
renderDoses(contentEl);
break;
case route === 'labs':
renderLabs(contentEl);
break;
case route === 'treatments':
renderTreatments(contentEl, {
openEditor: (id) => { location.hash = `#treatment-edit/${id}`; },
openNew: () => { location.hash = '#treatment-edit/0'; },
});
break;
case route === 'settings':
renderSettings(contentEl);
break;
case route.startsWith('treatment-edit/'):
renderTreatmentEditor(contentEl, {
treatmentId: Number(route.split('/')[1] || 0),
onDone: () => { location.hash = '#treatments'; },
});
break;
default:
location.hash = '#home';
}
}
// ── Dialog « Nouveautés » ────────────────────────────────────────────────────
/**
* Rendu MINIMAL de markdown pour le dialog « Nouveautés » : titres, listes,
* gras, code inline — suffisant pour le format de docs/CHANGELOG.md (pas un
* parseur général, volontairement : aucune dépendance, surface réduite).
*
* @param {string} markdown
* @returns {HTMLElement}
*/
function renderMarkdown(md) {
const container = el('div', { class: 'md-body', style: 'font-size:0.88rem;' });
let list = null; // <ul> courant (regroupe les lignes « - » consécutives)
const inline = (text) => {
// **gras** et `code` — split simple par regex, échappé par le DOM (textContent)
const frag = document.createDocumentFragment();
for (const part of text.split(/(\*\*[^*]+\*\*|`[^`]+`)/g)) {
if (part.startsWith('**') && part.endsWith('**')) {
frag.appendChild(el('strong', {}, part.slice(2, -2)));
} else if (part.startsWith('`') && part.endsWith('`') && part.length > 2) {
frag.appendChild(el('code', { style: 'background:var(--surface-variant);border-radius:4px;padding:1px 4px;font-size:0.8em;' }, part.slice(1, -1)));
} else if (part) {
frag.appendChild(document.createTextNode(part));
}
}
return frag;
};
for (const rawLine of md.split('\n')) {
const line = rawLine.trimEnd();
if (line.startsWith('### ')) {
list = null;
container.appendChild(el('h4', {}, line.slice(4)));
} else if (line.startsWith('## ')) {
list = null;
container.appendChild(el('h3', { style: 'margin:10px 0 4px;' }, line.slice(3)));
} else if (line.startsWith('- ')) {
if (!list) list = el('ul', { style: 'margin:4px 0;padding-left:20px;' });
list.appendChild(el('li', {}, inline(line.slice(2))));
container.appendChild(list); // (ré-append le même nœud : pas d'effet)
} else if (line === '') {
list = null;
} else {
list = null;
container.appendChild(el('p', { style: 'margin:4px 0;' }, inline(line)));
}
}
return container;
}
/**
* Au démarrage : si WEB_VERSION est plus récente que la dernière vue
* (DataStore `changelogSeenVersion` côté Android), affiche les sections
* CHANGELOG concernées — la version vue est mémorisée AVANT l'affichage
* (bug Android #42 : si l'app est fermée pendant le dialog, ne pas
* re-montrer les mêmes nouveautés).
*/
async function maybeShowWhatsNew() {
let markdown = '';
try {
const resp = await fetch('docs/CHANGELOG.md');
if (resp.ok) markdown = await resp.text();
} catch (e) {
// Pas grave : pas de dialog (app servie file:// ou changelog absent)
AppLog.log('changelog', `indisponible : ${e.message}`);
return;
}
if (!markdown) return;
const seen = store.getSettings().changelogSeenVersion;
if (!isVersionNewer(WEB_VERSION, seen)) return;
store.setSettings({ changelogSeenVersion: WEB_VERSION }); // mémorisé AVANT
const sections = sectionsSince(markdown, seen, WEB_VERSION);
if (sections === '') return;
const dlg = showDialog({
title: t('whats_new_title'),
body: renderMarkdown(sections),
actions: [{ label: t('whats_new_close'), className: 'btn', onClick: () => dlg.close() }],
});
}
// ── Hook de démo/test (?demo=1) ──────────────────────────────────────────────
/**
* Charge un jeu de données de DÉMONSTRATION si le store est vide. Utilisé
* par les smoke-tests headless (et pour découvrir l'app). Jamais automatique
* sans le paramètre d'URL, jamais sur un store déjà rempli.
*/
function maybeLoadDemoData() {
if (store.getTreatments().length > 0 || store.getDoseLogs().length > 0) return;
const now = Date.now();
const HOUR = 3600000;
const DAY = 24 * HOUR;
const trId = store.upsertTreatment({
name: 'EEn — démo',
type: 'ESTRADIOL',
route: 'INJECTION_SUBCUT',
doseAmount: 5,
doseUnit: 'mg',
isActive: true,
notes: null,
esterType: 'EEN',
pkModel: 'TFS',
absorptionHours: 156,
eliminationHalfLifeHours: 110,
bioavailabilityFraction: 1,
scaleFactor: 1.0,
forecastIntervalDays: 7,
reminderHour: 18,
reminderMinute: 0,
reminderEnabled: true,
calendarEventId: null,
createdAt: now - 90 * DAY,
});
// 12 injections hebdomadaires passées (à 18 h)
for (let k = 12; k >= 1; k--) {
store.upsertDoseLog({
treatmentId: trId,
timestamp: now - k * 7 * DAY,
doseAmount: 5,
notes: null,
esterType: null,
});
}
// Labs E2/T plausibles (1 prise de sang toutes les 2 semaines).
// v1.7.1 : la DERNIÈRE paire porte des notes DISTINCTES (E2 ≠ T) — cas
// réel du bug « une seule note sur deux s'affichait » (fix labNotesForDisplay)
// et fixture du scénario E2E correspondant.
const labs = [[280, 0.35], [300, 0.31], [260, 0.4], [290, 0.33], [275, 0.36], [305, 0.3]];
labs.forEach(([e2, tVal], i) => {
const ts = now - (11 - i * 2) * 7 * DAY;
const isLastPair = i === labs.length - 1;
store.upsertLabResult({
marker: 'E2', value: e2, unit: 'pg/mL', timestamp: ts,
notes: isLastPair ? 'Prise de sang après des maux de tête.' : null,
});
store.upsertLabResult({
marker: 'T', value: Math.round(tVal * 100), unit: 'ng/dL', timestamp: ts,
notes: isLastPair ? 'DHT : 12 ng/dL' : null,
});
});
// v1.9.0 : un 2ᵉ traitement E2 à modèle ESE (EV) avec 2 doses — permet
// de démontrer/tester le nuage d'incertitude ESE et la couverture
// multi-esters sans toucher au traitement EEn (TFS) principal.
const evId = store.upsertTreatment({
name: 'EV — Estrannaise (démo)',
type: 'ESTRADIOL',
route: 'INJECTION_IM',
doseAmount: 4.0,
doseUnit: 'mg',
isActive: true,
notes: null,
esterType: 'EV',
pkModel: 'ESE',
absorptionHours: 45,
eliminationHalfLifeHours: 70,
bioavailabilityFraction: 1,
scaleFactor: 1.0,
forecastIntervalDays: null,
reminderHour: null,
reminderMinute: null,
reminderEnabled: false,
calendarEventId: null,
createdAt: now - 60 * DAY,
});
// doses vieilles de 10-24 j : contribution résiduelle au présent (la
// courbe EV/SE fait partie du mini-chart sans saturer le niveau actuel)
for (const k of [24, 17, 10]) {
store.upsertDoseLog({
treatmentId: evId,
timestamp: now - k * DAY,
doseAmount: 4.0,
notes: null,
esterType: null,
});
}
AppLog.log('demo', 'données de démonstration chargées (?demo=1)');
}
main().catch((e) => {
// Erreur de démarrage : l'afficher lisible (asset absent, serveur mal configuré…)
document.body.innerHTML = `<div style="padding:24px;font-family:system-ui;">
<h1>HormoneTrack</h1>
<p><strong>Erreur de démarrage :</strong> ${e.message}</p>
<p>L'app doit être servie en HTTP statique (voir web/README.md —
<code>python3 web/scripts/serve.py</code>), pas ouverte en file://.</p>
</div>`;
});