/** * ───────────────────────────────────────────────────────────────────────────── * HormoneTrack Web — shell applicatif (portage de HormoneTrackApp.kt + * MainActivity.kt + HormoneTrackRoot.kt). * * - attend l'initialisation de PKProfileStore (fetch de l'asset) ; * - 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 PKProfileStore from '../pk/pk-profile-store.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. await PKProfileStore.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; // Écran de chargement tant que l'asset PK n'est pas chargé (les écrans // afficheraient « aucune donnée » à tort — les profils valent 0 avant init). if (!PKProfileStore.isInitialized()) { contentEl.textContent = ''; contentEl.appendChild(el('p', { class: 'hint', style: 'padding:24px 0;' }, t('loading'))); return; } // 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; //