Siphonight 491efef8eb v1.4.10 — portage initial de l'app Android v1.4.10
Portage navigateur complet de l'app Android HormoneTrack v1.4.10 :
- moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C,
  WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android
  préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés
- calibration par période d'ester et par modèle affiché (#60/#61)
- UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire,
  prévision, pics/creux, fuseau configurable), rappels web
  (Notification API), seuils d'alerte, i18n FR/EN
- sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1)
- 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie
- processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur,
  smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG)
- dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions
  alignées sur l'Android porté, changelogs indépendants

Non porté (impossible dans un navigateur, documenté §12) : agenda
récurrent, notifications onglet fermé, montre.
2026-09-08 14:34:58 +02:00

358 lines
14 KiB
JavaScript

/**
* ─────────────────────────────────────────────────────────────────────────────
* 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();
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 });
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)
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;
store.upsertLabResult({ marker: 'E2', value: e2, unit: 'pg/mL', timestamp: ts, notes: null });
store.upsertLabResult({ marker: 'T', value: Math.round(tVal * 100), unit: 'ng/dL', timestamp: ts, notes: 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>`;
});