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.
307 lines
11 KiB
JavaScript
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();
|