- lab-timing.js : computeRegimeStartMs — le régime courant = séquence terminale de doses à (ester effectif, dose, écart inter-doses) constants (écart comparé EXACTEMENT) : tout changement récent de dose/ester/ posologie réinitialise la stabilisation. L'ancien proxy (1ʳᵉ dose du traitement) disait « stabilisée depuis février » malgré des changements récents (remontée v1.8.0). L'encart porte l'ESTER EFFECTIF de la dernière dose (override compris). - home.js : encart « Prochaine prise de sang (suggestion) » sur l'ACCUEIL (demande) — creux daté + créneau associé + mention de stabilisation seulement si le régime n'était pas déjà stable. - 3 nouveaux tests miroirs (155 verts) : dose/intervalle/ester → reset épinglé. WEB_VERSION 1.8.1. CHANGELOG [1.8.1], README.
385 lines
18 KiB
JavaScript
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.8.1';
|
|
|
|
/** 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).
|
|
}
|