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

160 lines
6.7 KiB
JavaScript

/**
* ─────────────────────────────────────────────────────────────────────────────
* Sauvegarde JSON (portage de `data/backup/BackupManager.kt`).
*
* ⚠️ COMPATIBILITÉ BIDIRECTIONNELLE avec les exports de l'app Android :
* format BackupData v2 —
*
* {
* "version": 2,
* "exportedAt": 1749...,
* "treatments": [ Treatment... (champs Gson/Kotlin, cf data/models.js) ],
* "doseLogs": [ DoseLog... ],
* "labResults": [ LabResult... ],
* "tConfig": { "base": 6.0, "floor": 0.2, "k": 0.19 },
* "settings": { "language": ..., "autoCalibrate": ...,
* "alertE2High": ..., "alertE2Low": ...,
* "alertTHigh": ..., "alertTLow": ... } ← optionnel (v2)
* }
*
* RÉTROCOMPATIBILITÉ : les backups v1 (SANS `settings`) restent parsables
* (settings = null) et importables — l'import ne vérifie pas strictement la
* version (miroir du comportement Gson côté Android). `changelog_seen_version`
* est volontairement EXCLU des settings d'export (pas une donnée utile à
* restaurer — cf BackupManager.kt).
*
* PUR (DOM-free) → testable en Node (web/tests/backup.test.js, y compris
* round-trip avec un vrai export Android si local-test-data/ est présent).
* ─────────────────────────────────────────────────────────────────────────────
*/
/**
* Construit le JSON de sauvegarde (BackupData v2, mêmes noms de champs que
* Gson).
*
* @param {object} collections { treatments, doseLogs, labResults } (store.exportAll)
* @param {object} tConfig { base, floor, k }
* @param {object|null} [userSettings=null] Réglages à embarquer (v2) —
* SEULS les champs du schéma UserSettings Android sont sérialisés (champs
* plats), jamais les clés web extra (chartTimezone, alertNotifiedState…) :
* un backup importé dans l'app Android ne doit pas contenir de clés
* inconnues (Gson les ignorerait, mais autant rester stricts).
* @returns {string} JSON
*/
export function buildBackupJson(collections, tConfig, userSettings = null) {
const data = {
version: 2,
exportedAt: Date.now(),
treatments: collections.treatments,
doseLogs: collections.doseLogs,
labResults: collections.labResults,
tConfig: {
base: tConfig.base,
floor: tConfig.floor,
k: tConfig.k,
},
};
if (userSettings) {
// Champs PLATS volontairement (miroir UserSettings.kt — Gson lit par
// réflexion côté Android : pas de nested inconnu)
data.settings = {
language: userSettings.language ?? null,
autoCalibrate: userSettings.autoCalibrate ?? null,
alertE2High: userSettings.alertE2High ?? null,
alertE2Low: userSettings.alertE2Low ?? null,
alertTHigh: userSettings.alertTHigh ?? null,
alertTLow: userSettings.alertTLow ?? null,
// v1.13.0 : cible de creux (champs optionnels — rétrocompatibles)
troughTargetLow: userSettings.troughTargetLow ?? null,
troughTargetHigh: userSettings.troughTargetHigh ?? null,
};
}
return JSON.stringify(data);
}
/**
* Parse un backup (Android v1 ou v2, ou export web) en structure validée.
*
* Les listes manquantes deviennent vides, les types numériques sont
* normalisés (Gson accepte des entiers pour des Double) — l'import ne
* vérifie pas strictement la version, comme côté Android.
*
* @param {string} json
* @returns {{version:number, exportedAt:number|null,
* treatments:object[], doseLogs:object[], labResults:object[],
* tConfig:{base:number,floor:number,k:number},
* settings:object|null}}
* @throws {Error} si le JSON est illisible ou n'a pas la forme d'un backup
*/
export function parseBackupJson(json) {
let raw;
try {
raw = JSON.parse(json);
} catch (e) {
throw new Error(`JSON illisible : ${e.message}`);
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('Le fichier ne contient pas un backup HormoneTrack');
}
if (!('treatments' in raw) && !('doseLogs' in raw) && !('labResults' in raw)) {
throw new Error('Aucune donnée de backup reconnue (treatments/doseLogs/labResults absents)');
}
return {
version: typeof raw.version === 'number' ? raw.version : 1,
exportedAt: typeof raw.exportedAt === 'number' ? raw.exportedAt : null,
treatments: Array.isArray(raw.treatments) ? raw.treatments : [],
doseLogs: Array.isArray(raw.doseLogs) ? raw.doseLogs : [],
labResults: Array.isArray(raw.labResults) ? raw.labResults : [],
// Défauts TConfig.kt si absent (backup tronqué) — comme Gson
// reconstruirait TConfig() par défaut
tConfig: {
base: numOrNull(raw.tConfig && raw.tConfig.base) ?? 6.0,
floor: numOrNull(raw.tConfig && raw.tConfig.floor) ?? 0.2,
k: numOrNull(raw.tConfig && raw.tConfig.k) ?? 0.19,
},
// settings absent = backup v1 → null (l'appelant ne touche pas aux réglages)
settings: raw.settings && typeof raw.settings === 'object' ? raw.settings : null,
};
}
/** @private Convertit en nombre si possible, sinon null. */
function numOrNull(v) {
const n = typeof v === 'string' ? Number(v) : v;
return typeof n === 'number' && Number.isFinite(n) ? n : null;
}
/**
* Nom de fichier d'export (miroir d'ExportFileNames.backupFileName Android) :
* `hormonetrack-backup-YYYYMMDD.json` — pattern SANS heure.
*
* Le helper Android existait parce qu'un pattern horaire passé à LocalDate
* crashait l'app (#48) ; côté web, `new Date()` porte tout — le helper reste
* centralisé par discipline (une seule source pour le nom).
*
* @param {Date} [now=new Date()]
* @returns {string}
*/
export function backupFileName(now = new Date()) {
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
return `hormonetrack-backup-${y}${m}${d}.json`;
}
/**
* Nom de fichier d'export des logs de diagnostic (miroir
* ExportFileNames.diagnosticLogFileName) : `hormonetrack-logs-YYYYMMDD-HHmm.txt`
* — pattern AVEC heure (le fichier Android documente pourquoi : bug #48).
*
* @param {Date} [now=new Date()]
* @returns {string}
*/
export function diagnosticLogFileName(now = new Date()) {
const y = now.getFullYear();
const mo = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const h = String(now.getHours()).padStart(2, '0');
const mi = String(now.getMinutes()).padStart(2, '0');
return `hormonetrack-logs-${y}${mo}${d}-${h}${mi}.txt`;
}