HormoneTrack-web/js/ui/settings.js
Siphonight ff4a1374db v1.9.7 (web) : tolérance ± 24 h de l'écart inter-doses — miroir Android
La règle de stabilisation comparait l'écart inter-doses EXACTEMENT à
l'écart précédent : un log 30 min plus tard cassait le régime et
repoussait la stabilisation de 5 × t½ à chaque injection (creux fuyant).

Fix : chaque écart doit rester dans l'intervalle de Posologie ± 24 h
(GAP_TOLERANCE_MS, fenêtre vs l'intervalle THÉORIQUE) — des logs à 6,8 j
puis 7,2 j ne se déstabilisent plus en cascade ; un vrai changement de
créneau reste hors fenêtre.

Tests : « interval change » ré-épinglé (+21 j) + 2 tests de tolérance
miroirs (12 verts lab-timing). WEB_VERSION 1.9.7 + CHANGELOG/README.
2026-09-21 22:20:21 +02:00

385 lines
18 KiB
JavaScript

/**
* ─────────────────────────────────────────────────────────────────────────────
* Écran Paramètres (portage de `ui/screens/SettingsScreen.kt`).
*
* - langue (Système / Français / English) ;
* - calibration automatique (UNE option — E2 par période d'ester + T, fix
* v1.2.1 ; désactivée par défaut) ;
* - réglages T manuels (base/floor/k + bouton « Calibrer avec les analyses »
* ponctuel — computeTConfigCalibration) ;
* - seuils d'alerte E2/T (opt-in, validation haut > bas, feedback rouge) ;
* - fuseau de LECTURE du graphique (ID IANA, vide = auto — v1.4.5) ;
* - notifications (permission navigateur — rappels web, cf ui/reminders.js) ;
* - Export/Import JSON (compatibles Android, mode ÉCRASEMENT à l'import) ;
* - logs de diagnostic (export .txt / effacement) ;
* - à propos (version, lien releases, crédits modèles, vie privée).
* ─────────────────────────────────────────────────────────────────────────────
*/
import { store } from '../data/store.js';
import * as Alerts from '../pk/alerts.js';
import { computeTConfigCalibration, TConfig } from '../pk/index.js';
import { buildBackupJson, parseBackupJson, backupFileName, diagnosticLogFileName } from '../data/backup.js';
import { t } from '../util/i18n.js';
import { resolveLocale } from '../util/i18n.js';
import * as AppLog from '../util/app-log.js';
import { el, showDialog, field, switchRow, filterChip, toast } from './components.js';
/**
* Version de l'app web — ⚠️ ALIGNÉE sur la version Android portée (cf
* web/docs/DEVELOPPEMENT.md §3) : chaque release est publiée en parallèle
* (APK + web au MÊME numéro, tags appariés `vX.Y.Z` + `vX.Y.Z-web`).
* v1.4.10 = portage initial de l'Android v1.4.10.
*/
export const WEB_VERSION = '1.9.7';
/** Parse un champ numérique FR (virgule → point) ; null si vide/invalide. */
function parseNumOrNull(text) {
const s = String(text).trim().replace(',', '.');
if (s === '') return null;
const v = Number(s);
return Number.isFinite(v) ? v : null;
}
/**
* Déclenche un téléchargement côté navigateur (équivalent SAF CreateDocument
* Android : l'utilisateur choisit où enregistrer — ici via le dossier de
* téléchargement).
*
* @param {string} filename
* @param {string} content
* @param {string} mimeType
* @returns {boolean} true si le téléchargement a été initié
*/
function downloadFile(filename, content, mimeType) {
try {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
// libère l'URL object après un délai (le navigateur a initié le download)
setTimeout(() => URL.revokeObjectURL(url), 5000);
return true;
} catch (e) {
AppLog.log('export', `ERREUR : ${e.message}`);
return false;
}
}
/**
* Rend l'écran Paramètres.
* @param {HTMLElement} container
*/
export function renderSettings(container) {
const col = el('div', {});
container.appendChild(col);
function rerender() {
col.textContent = '';
const settings = store.getSettings();
const tConfigRaw = store.getTConfig();
const tConfig = new TConfig(tConfigRaw.base, tConfigRaw.floor, tConfigRaw.k);
// ── Langue ─────────────────────────────────────────────────────────────
const langCard = el('div', { class: 'card' });
langCard.appendChild(el('h3', {}, t('language')));
const langRow = el('div', { class: 'chip-row' });
langRow.appendChild(filterChip(t('language_system'), settings.language === 'system', () => { store.setSettings({ language: 'system' }); }));
langRow.appendChild(filterChip('Français', settings.language === 'fr', () => { store.setSettings({ language: 'fr' }); }));
langRow.appendChild(filterChip('English', settings.language === 'en', () => { store.setSettings({ language: 'en' }); }));
langCard.appendChild(langRow);
col.appendChild(langCard);
// ── Calibration automatique (UNE option, désactivée par défaut) ─────────
const autoCard = el('div', { class: 'card' });
autoCard.appendChild(switchRow(t('auto_calibrate'), settings.autoCalibrate, (v) => store.setSettings({ autoCalibrate: v })));
autoCard.appendChild(el('div', { class: 'hint' }, t('auto_calibrate_hint')));
col.appendChild(autoCard);
// ── Estimation T (réglages manuels) ────────────────────────────────────
const tCard = el('div', { class: 'card' });
tCard.appendChild(el('h3', {}, t('t_model_title')));
tCard.appendChild(el('div', { class: 'hint' }, t('t_model_hint')));
const tRow = el('div', { class: 'row' });
const baseField = field({ label: t('t_base'), type: 'number', value: String(tConfig.base), attrs: { step: 'any', inputmode: 'decimal' } });
const floorField = field({ label: t('t_floor'), type: 'number', value: String(tConfig.floor), attrs: { step: 'any', inputmode: 'decimal' } });
const kField = field({ label: 'k', type: 'number', value: String(tConfig.k), attrs: { step: 'any', inputmode: 'decimal' } });
tRow.appendChild(baseField);
tRow.appendChild(floorField);
tRow.appendChild(kField);
tCard.appendChild(tRow);
const tBtnRow = el('div', { class: 'row' });
tBtnRow.appendChild(el('button', {
class: 'btn',
onclick: () => {
const base = parseNumOrNull(baseField.input.value);
const floor = parseNumOrNull(floorField.input.value);
const k = parseNumOrNull(kField.input.value);
if (base === null || floor === null || k === null) return;
store.setTConfig({ base, floor, k });
toast(t('saved'));
},
}, t('save')));
tBtnRow.appendChild(el('button', {
class: 'btn outlined',
onclick: () => {
// Calibration T GLOBALE (k unique) — la même que le bouton Android
const labs = store.getLabResults().filter((l) => String(l.marker).toUpperCase() === 'T');
const calibrated = computeTConfigCalibration(labs, store.getTreatments(), store.getDoseLogs(), tConfig);
if (calibrated) {
store.setTConfig({ base: calibrated.base, floor: calibrated.floor, k: calibrated.k });
toast(t('t_calibrated'));
}
},
}, t('calibrate_from_labs')));
tCard.appendChild(tBtnRow);
col.appendChild(tCard);
// ── Seuils d'alerte (v1.4.2) ───────────────────────────────────────────
const alertCard = el('div', { class: 'card' });
alertCard.appendChild(el('h3', {}, t('alert_section')));
alertCard.appendChild(el('div', { class: 'hint' }, t('alert_hint')));
const e2Row = el('div', { class: 'row' });
const e2HighField = field({ label: t('alert_e2_high'), type: 'number', value: settings.alertE2High === null ? '' : String(settings.alertE2High), attrs: { step: 'any', inputmode: 'decimal' } });
const e2LowField = field({ label: t('alert_e2_low'), type: 'number', value: settings.alertE2Low === null ? '' : String(settings.alertE2Low), attrs: { step: 'any', inputmode: 'decimal' } });
e2Row.appendChild(e2HighField);
e2Row.appendChild(e2LowField);
alertCard.appendChild(e2Row);
const tRow2 = el('div', { class: 'row' });
const tHighField = field({ label: t('alert_t_high'), type: 'number', value: settings.alertTHigh === null ? '' : String(settings.alertTHigh), attrs: { step: 'any', inputmode: 'decimal' } });
const tLowField = field({ label: t('alert_t_low'), type: 'number', value: settings.alertTLow === null ? '' : String(settings.alertTLow), attrs: { step: 'any', inputmode: 'decimal' } });
tRow2.appendChild(tHighField);
tRow2.appendChild(tLowField);
alertCard.appendChild(tRow2);
const alertError = el('div', { class: 'error-text', style: 'display:none;' });
alertCard.appendChild(alertError);
alertCard.appendChild(el('button', {
class: 'btn',
onclick: () => {
const e2High = parseNumOrNull(e2HighField.input.value);
const e2Low = parseNumOrNull(e2LowField.input.value);
const tHigh = parseNumOrNull(tHighField.input.value);
const tLow = parseNumOrNull(tLowField.input.value);
// Saisie invalide (texte non numérique) ou incohérence haut ≤ bas
const fields = [e2HighField.input.value, e2LowField.input.value, tHighField.input.value, tLowField.input.value];
const invalidSyntax = fields.some((v) => v.trim() !== '' && parseNumOrNull(v) === null);
const thresholds = new Alerts.Thresholds(e2High, e2Low, tHigh, tLow);
if (invalidSyntax || !thresholds.isCoherent()) {
alertError.textContent = t('alert_invalid');
alertError.style.display = '';
} else {
alertError.style.display = 'none';
store.setSettings({
alertE2High: e2High, alertE2Low: e2Low, alertTHigh: tHigh, alertTLow: tLow,
});
// Check IMMÉDIAT (miroir AlertNotifier.checkNow — si le seuil est
// déjà franchi, la notification part sans attendre le cycle)
import('./reminders.js').then((m) => m.checkAlertsNow());
toast(t('saved'));
}
},
}, t('save')));
col.appendChild(alertCard);
// ── Fuseau du graphique (v1.4.5) ───────────────────────────────────────
const tzCard = el('div', { class: 'card' });
tzCard.appendChild(el('h3', {}, t('timezone_section')));
tzCard.appendChild(el('div', { class: 'hint' }, t('timezone_hint')));
const tzField = field({ label: t('timezone_field'), value: settings.chartTimezone || '', placeholder: t('timezone_auto') });
tzCard.appendChild(tzField);
const tzError = el('div', { class: 'error-text', style: 'display:none;' });
tzCard.appendChild(tzError);
const tzBtnRow = el('div', { class: 'row' });
tzBtnRow.appendChild(el('button', {
class: 'btn',
onclick: () => {
const id = tzField.input.value.trim();
// Validation : vide = auto ; sinon l'ID doit être un fuseau IANA connu
const valid = id === '' || isValidTimeZone(id);
if (!valid) {
tzError.textContent = t('timezone_invalid');
tzError.style.display = '';
} else {
tzError.style.display = 'none';
store.setSettings({ chartTimezone: id === '' ? null : id });
toast(t('saved'));
}
},
}, t('save')));
tzBtnRow.appendChild(el('button', {
class: 'btn outlined',
onclick: () => {
tzField.input.value = '';
tzError.style.display = 'none';
store.setSettings({ chartTimezone: null });
toast(t('saved'));
},
}, t('timezone_auto')));
tzCard.appendChild(tzBtnRow);
col.appendChild(tzCard);
// ── Notifications (rappels web) ────────────────────────────────────────
const notifCard = el('div', { class: 'card' });
notifCard.appendChild(el('h3', {}, t('reminders_section')));
notifCard.appendChild(el('div', { class: 'hint' }, t('reminders_note')));
if (typeof Notification === 'undefined') {
notifCard.appendChild(el('div', { class: 'hint' }, t('notif_denied')));
} else if (Notification.permission === 'granted') {
notifCard.appendChild(el('div', {}, t('notif_granted')));
} else if (Notification.permission === 'denied') {
notifCard.appendChild(el('div', { class: 'hint' }, t('notif_denied')));
} else {
notifCard.appendChild(el('button', {
class: 'btn tonal',
onclick: () => { Notification.requestPermission(); },
}, t('notif_grant')));
}
col.appendChild(notifCard);
// ── Backup JSON (compatible Android) ───────────────────────────────────
const backupCard = el('div', { class: 'card' });
backupCard.appendChild(el('h3', {}, t('backup_section')));
backupCard.appendChild(el('div', { class: 'hint' }, t('backup_hint')));
const backupRow = el('div', { class: 'row' });
backupRow.appendChild(el('button', {
class: 'btn',
onclick: () => {
// v1.4.2 : le backup embarque aussi les PARAMÈTRES utilisateur
// (langue, auto-cal, seuils — seuls les champs UserSettings Android)
const collections = store.exportAll();
const json = buildBackupJson(collections, collections.tConfig, {
language: settings.language,
autoCalibrate: settings.autoCalibrate,
alertE2High: settings.alertE2High,
alertE2Low: settings.alertE2Low,
alertTHigh: settings.alertTHigh,
alertTLow: settings.alertTLow,
});
const ok = downloadFile(backupFileName(), json, 'application/json');
AppLog.log('backup', `export : ok=${ok}`);
toast(ok ? t('export_ok') : t('export_fail'));
},
}, t('export_json')));
backupRow.appendChild(el('button', {
class: 'btn outlined',
onclick: () => {
const input = el('input', { type: 'file', accept: 'application/json,.json' });
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const json = String(reader.result);
showImportConfirm(json);
};
reader.onerror = () => toast(t('import_fail'));
reader.readAsText(file);
});
input.click();
},
}, t('import_json')));
backupCard.appendChild(backupRow);
col.appendChild(backupCard);
// ── Logs de diagnostic (v1.3.1) ────────────────────────────────────────
const logsCard = el('div', { class: 'card' });
logsCard.appendChild(el('h3', {}, t('logs_section')));
logsCard.appendChild(el('div', { class: 'hint' }, t('logs_count', AppLog.count())));
const logsRow = el('div', { class: 'row' });
logsRow.appendChild(el('button', {
class: 'btn outlined',
onclick: () => {
// ⚠️ Exporter APRÈS avoir journalisé le début (miroir Android #45)
const logs = AppLog.all();
AppLog.log('logs', `export démarré (${logs.length} caractères)`);
const ok = downloadFile(diagnosticLogFileName(), AppLog.all(), 'text/plain');
AppLog.log('logs', `export terminé : ok=${ok}`);
toast(ok ? t('logs_export_ok') : t('logs_export_fail'));
},
}, t('logs_export')));
logsRow.appendChild(el('button', {
class: 'btn text',
onclick: () => { AppLog.clear(); toast(t('saved')); },
}, t('logs_clear')));
logsCard.appendChild(logsRow);
col.appendChild(logsCard);
// ── À propos ───────────────────────────────────────────────────────────
const aboutCard = el('div', { class: 'card' });
aboutCard.appendChild(el('h3', {}, t('about_title')));
aboutCard.appendChild(el('div', {}, t('settings_version', WEB_VERSION)));
aboutCard.appendChild(el('div', { class: 'hint' }, t('privacy_note')));
aboutCard.appendChild(el('a', { href: 'https://gitea.cloudyfy.fr/Siphonight/HormoneTrack/releases', target: '_blank', rel: 'noopener' }, t('releases_link')));
aboutCard.appendChild(el('div', { class: 'hint' }, t('disclaimer')));
aboutCard.appendChild(el('div', { class: 'hint' }, t('models_credit')));
col.appendChild(aboutCard);
}
/**
* Confirmation d'import (miroir showImportConfirm) : écrasement complet,
* restauration tConfig + réglages + re-programmation des rappels.
*/
function showImportConfirm(json) {
const dlg = showDialog({
title: t('import_title'),
body: el('p', {}, t('import_warning')),
actions: [
{ label: t('cancel'), className: 'btn text', onClick: () => dlg.close() },
{
label: t('import_confirm'),
className: 'btn danger',
onClick: () => {
dlg.close();
try {
const data = parseBackupJson(json);
const result = store.importOverwrite(data);
// Restauration tConfig (miroir BackupManager.importJson)
store.setTConfig(data.tConfig);
// ⚠️ Paramètres du backup (null = backup v1 → ne rien toucher)
if (data.settings) {
store.setSettings({
autoCalibrate: data.settings.autoCalibrate ?? false,
alertE2High: data.settings.alertE2High ?? null,
alertE2Low: data.settings.alertE2Low ?? null,
alertTHigh: data.settings.alertTHigh ?? null,
alertTLow: data.settings.alertTLow ?? null,
});
if (data.settings.language) {
store.setSettings({ language: data.settings.language });
}
}
// Re-programmation des rappels (la boucle relit le store à chaque cycle)
AppLog.log('backup',
`import ok : ${result.treatments} traitements, ${result.doseLogs} doses, ${result.labResults} analyses`);
toast(t('import_ok', result.treatments, result.doseLogs, result.labResults));
} catch (e) {
AppLog.log('backup', `ERREUR import : ${e.message}`);
toast(t('import_fail'));
}
rerender();
},
},
],
});
}
/** Validation d'un ID de fuseau IANA (miroir de la validation Android). */
function isValidTimeZone(id) {
try {
new Intl.DateTimeFormat('en-US', { timeZone: id });
return true;
} catch (e) {
return false;
}
}
rerender();
// Le changement de langue doit re-traduire TOUTE l'app : écouteur géré par
// app.js (subscribe → re-render de la route courante).
}