/** * ───────────────────────────────────────────────────────────────────────────── * É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). }