/** * ───────────────────────────────────────────────────────────────────────────── * 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();