- predictedE2 : valeur brute au creux × facteur du ester (auto-calibration
ON requise — sinon rien, une valeur brute serait trompeuse) ; rendu
coloré face à la cible dans labs.js + home.js.
- pooledRegimeDoses : les doses de tous les traitements partageant (ester
effectif, mg) forment UNE séquence — le re-parenting d'historique
(« 6d-old ») ne redémarre plus la stabilisation. Garde-fous : ester ≠
jamais poolé, dose ≠ exclue.
- Cible de creux E2 (opt-in) : store + carte settings + backup
(rétrocompatible — un backup ancien n'efface pas la cible locale) +
i18n FR/EN. ⚠️ DISTINCT des seuils d'alerte : jamais de notification.
- 15 tests (pooling ×3, statut cible, round-trip backup) ; leçon E2E :
bump WEB_VERSION sans section CHANGELOG = dialog vide.
199 tests + E2E verts (check.sh). WEB_VERSION alignée sur 1.13.0.
309 lines
11 KiB
JavaScript
309 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,
|
|
troughTargetLow: null, // cible de creux (v1.13.0, opt-in — référence de lab, JAMAIS de notification)
|
|
troughTargetHigh: 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();
|