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)
426 lines
17 KiB
JavaScript
426 lines
17 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).
|
|
// 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.
|
|
// 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);
|
|
|
|
// 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;
|
|
/** 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;
|
|
|
|
/** 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;
|
|
|
|
// 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);
|
|
});
|
|
// 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>`;
|
|
});
|