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

307 lines
11 KiB
JavaScript

/**
* ─────────────────────────────────────────────────────────────────────────────
* Store — persistance LOCALE (portage de la couche Room + DataStore Android).
*
* ⚠️ 100 % LOCAL : toutes les données vivent dans le localStorage du
* navigateur, sous des clés préfixées `hormonetrack.` — AUCUN envoi réseau
* (l'app ne fait qu'un fetch de son asset PK et de son changelog, en lecture).
*
* Architecture :
* - un backend de stockage INJECTABLE (localStorage en production, Map en
* Node pour les tests) — cf `WebStore.setBackend()` ;
* - un pub/sub simple (`subscribe`) : les écrans se re-rendent sur mutation ;
* - IDs auto-incrémentés par table (miroir de Room autoGenerate) ; à
* l'import, les IDs du backup sont conservés (FK dose→traitement) et les
* compteurs recalculés (max + 1).
*
* Clés localStorage (miroir des tables Room / clés DataStore Android) :
* - hormonetrack.treatments / .doseLogs / .labResults : tableaux JSON
* - hormonetrack.tConfig : {base, floor, k}
* - hormonetrack.settings : {language, autoCalibrate, alertE2High/Low,
* alertTHigh/Low, chartTimezone, changelogSeenVersion, alertNotifiedState}
* - hormonetrack.debugLog : journal de diagnostic (texte, cf AppLog)
* ─────────────────────────────────────────────────────────────────────────────
*/
/** Préfixe de toutes les clés (évite les collisions dans un localStorage partagé). */
const PREFIX = 'hormonetrack.';
/** Backend par défaut : window.localStorage (production navigateur). */
function defaultBackend() {
if (typeof window !== 'undefined' && window.localStorage) return window.localStorage;
throw new Error('Aucun backend de stockage (localStorage indisponible) — utiliser setBackend() en Node');
}
/**
* Store applicatif. Une seule instance est exportée (`store`) — les tests
* utilisent `setBackend(new Map())` pour isoler.
*/
class WebStore {
constructor() {
this.backend = null;
this.listeners = new Set();
}
/** Injecte un backend (localStorage-like ou Map — Node : new Map()). */
setBackend(backend) {
if (backend && typeof backend.getItem !== 'function' && typeof backend.get === 'function') {
// Adaptateur Map → API localStorage (utilisé par les tests Node)
const map = backend;
this.backend = {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => map.set(k, v),
removeItem: (k) => map.delete(k),
};
} else {
this.backend = backend;
}
}
/** @private */
b() {
if (!this.backend) this.backend = defaultBackend();
return this.backend;
}
// ── primitives JSON ──────────────────────────────────────────────────────
/** @private */
read(key, fallback) {
const raw = this.b().getItem(PREFIX + key);
if (raw === null || raw === undefined || raw === '') return fallback;
try {
return JSON.parse(raw);
} catch (e) {
// Donnée corrompue : ne JAMAIS bloquer l'app au démarrage (même
// philosophie qu'AppLog.init côté Android — on repart sur le défaut).
return fallback;
}
}
/** @private */
write(key, value) {
this.b().setItem(PREFIX + key, JSON.stringify(value));
}
/** @private */
remove(key) {
this.b().removeItem(PREFIX + key);
}
// ── pub/sub ──────────────────────────────────────────────────────────────
/** Abonne un écouteur aux mutations ; retourne la fonction de désabonnement. */
subscribe(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
/** Notifie les écrans après une mutation (batch : un appel par action). */
emit() {
for (const l of [...this.listeners]) {
try {
l();
} catch (e) {
// Un écran qui plante ne doit pas casser les autres ni la donnée
console.error('store listener error', e);
}
}
}
// ── traitements ──────────────────────────────────────────────────────────
getTreatments() {
return this.read('treatments', []);
}
/**
* Insère (id=0) ou met à jour (id>0) un traitement. Retourne l'id réellement
* stocké (nouveau si insertion).
*/
upsertTreatment(treatment) {
const all = this.read('treatments', []);
if (!treatment.id) {
const nextId = all.reduce((m, t) => Math.max(m, t.id), 0) + 1;
const tr = { ...treatment, id: nextId };
all.push(tr);
this.write('treatments', all);
this.emit();
return nextId;
}
const idx = all.findIndex((t) => t.id === treatment.id);
if (idx >= 0) all[idx] = { ...treatment };
else all.push({ ...treatment });
this.write('treatments', all);
this.emit();
return treatment.id;
}
deleteTreatment(id) {
// FK CASCADE : les doses du traitement partent avec lui (miroir Room)
this.write('treatments', this.read('treatments', []).filter((t) => t.id !== id));
this.write('doseLogs', this.read('doseLogs', []).filter((d) => d.treatmentId !== id));
this.emit();
}
// ── doses ────────────────────────────────────────────────────────────────
getDoseLogs() {
return this.read('doseLogs', []);
}
upsertDoseLog(dose) {
const all = this.read('doseLogs', []);
if (!dose.id) {
const nextId = all.reduce((m, d) => Math.max(m, d.id), 0) + 1;
const d = { ...dose, id: nextId };
all.push(d);
this.write('doseLogs', all);
this.emit();
return nextId;
}
const idx = all.findIndex((x) => x.id === dose.id);
if (idx >= 0) all[idx] = { ...dose };
else all.push({ ...dose });
this.write('doseLogs', all);
this.emit();
return dose.id;
}
deleteDoseLog(id) {
this.write('doseLogs', this.read('doseLogs', []).filter((d) => d.id !== id));
this.emit();
}
// ── labs ─────────────────────────────────────────────────────────────────
getLabResults() {
return this.read('labResults', []);
}
upsertLabResult(lab) {
const all = this.read('labResults', []);
if (!lab.id) {
const nextId = all.reduce((m, l) => Math.max(m, l.id), 0) + 1;
const l = { ...lab, id: nextId };
all.push(l);
this.write('labResults', all);
this.emit();
return nextId;
}
const idx = all.findIndex((x) => x.id === lab.id);
if (idx >= 0) all[idx] = { ...lab };
else all.push({ ...lab });
this.write('labResults', all);
this.emit();
return lab.id;
}
deleteLabResult(id) {
this.write('labResults', this.read('labResults', []).filter((l) => l.id !== id));
this.emit();
}
// ── TConfig (DataStore Android) ──────────────────────────────────────────
getTConfig() {
const c = this.read('tConfig', null);
// Défauts identiques à TConfig.kt (base 6.0, floor 0.2, k 0.19)
return c || { base: 6.0, floor: 0.2, k: 0.19 };
}
setTConfig(config) {
this.write('tConfig', config);
this.emit();
}
// ── réglages utilisateur (DataStore Android → un objet JSON) ──────────────
getSettings() {
// Fusion avec les défauts : un stockage partiel (ex. seed de test avec
// {language:'fr'} seul) ne doit jamais exposer des champs undefined.
return {
language: 'system', // "system" | "fr" | "en"
autoCalibrate: false, // option désactivée par défaut (contrôle utilisateur)
alertE2High: null, // seuils d'alerte : null = pas de limite (opt-in)
alertE2Low: null,
alertTHigh: null,
alertTLow: null,
chartTimezone: null, // fuseau de LECTURE du graphique (null = auto)
changelogSeenVersion: null, // dialog « Nouveautés »
alertNotifiedState: null, // anti-spam des notifications d'alerte
...this.read('settings', {}),
};
}
/** Fusion partielle (setSettings({ language: 'fr' })) + notification. */
setSettings(patch) {
this.write('settings', { ...this.getSettings(), ...patch });
this.emit();
}
// ── journal de diagnostic (AppLog) ───────────────────────────────────────
getDebugLog() {
return this.read('debugLog', '');
}
setDebugLog(text) {
this.write('debugLog', text);
}
// ── import/export ────────────────────────────────────────────────────────
/**
* Import en mode ÉCRASEMENT (miroir BackupManager.importJson v1.2.6) :
* les données actuelles sont effacées AVANT l'insertion — sinon les IDs du
* backup (conservés pour les FK dose→traitement) entraient en conflit.
* Ordre d'effacement : enfants (doses, labs) puis parents (traitements).
*
* @param {object} data BackupData parsé (data/backup.js)
* @returns {{treatments:number, doseLogs:number, labResults:number}}
*/
importOverwrite(data) {
this.write('doseLogs', []);
this.write('labResults', []);
this.write('treatments', []);
for (const tr of data.treatments) this.upsertTreatment(tr);
for (const d of data.doseLogs) this.upsertDoseLog(d);
for (const l of data.labResults) this.upsertLabResult(l);
// Un seul emit final (importOverwrite est atomique pour l'UI)
this.emit();
return {
treatments: data.treatments.length,
doseLogs: data.doseLogs.length,
labResults: data.labResults.length,
};
}
/**
* Exporte tout (miroir BackupManager.exportJson — sérialisation dans
* data/backup.js, cette fonction fournit juste les collections).
*/
exportAll() {
return {
treatments: this.getTreatments(),
doseLogs: this.getDoseLogs(),
labResults: this.getLabResults(),
tConfig: this.getTConfig(),
settings: this.getSettings(),
};
}
/** Efface TOUTES les données locales (danger : confirmation côté UI). */
clearAll() {
this.remove('treatments');
this.remove('doseLogs');
this.remove('labResults');
this.remove('tConfig');
this.remove('settings');
this.remove('debugLog');
this.emit();
}
}
/** Instance unique de l'app. */
export const store = new WebStore();