Siphonight 902d521094 v1.13.0 (web) : reco prédictive — valeur E2 au creux, régime poolé, cible de creux — miroir Android
- 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.
2026-10-07 09:58:52 +02:00

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();