/** * ───────────────────────────────────────────────────────────────────────────── * Rappels + alertes — côté WEB (portage adapté de reminder/ReminderManager.kt * + AlertNotifier.kt + AlertWorker.kt). * * ⚠️ DIFFÉRENCES STRUCTURELLES vs Android (documentées, pas des bugs) : * - Android : AlarmManager exact + reprogrammation par les receivers, même * app fermée. Web : AUCUN scheduler système — une boucle `setInterval` * vérifie le prochain déclenchement via le MÊME moteur * (nextReminderFireFor) tant que la page est ouverte. Onglet fermé = pas * de notification (limite documentée). * - Android : WorkManager périodique 15 min pour les seuils d'alerte. Web : * la même boucle vérifie les seuils toutes les 5 min (anti-spam par état * persisté identique : Alerts.encodeState / shouldNotify). * ───────────────────────────────────────────────────────────────────────────── */ import { nextReminderFireMs, nextReminderFireFor, levelAt, autoCalibrated, TConfig, Alerts } from '../pk/index.js'; import { store } from '../data/store.js'; import { t } from '../util/i18n.js'; import { formatDose, formatTime } from '../util/format.js'; import * as AppLog from '../util/app-log.js'; /** Période de vérification des rappels (ms) — la grille du moteur est à la minute. */ const REMINDER_CHECK_MS = 30000; /** Période de vérification des seuils d'alerte (ms) — 5 min (Android : 15 min). */ const ALERT_CHECK_MS = 300000; /** Rappels déjà notifiés (id de traitement → timestamp notifié) pour éviter les doublons. */ const notifiedReminders = new Map(); /** Timer pour un rappel imminent (< 1 min) — permet un déclenchement précis. */ let imminentTimer = null; /** * Démarre la boucle de vérification (appelée une fois au démarrage de l'app). * Un seul timer de polling + un timer « imminent » re-programmable. */ export function startReminderLoop(onOpenLogDose) { AppLog.log('rappels', 'boucle web démarrée (rappels 30 s, seuils 5 min)'); setInterval(() => checkReminders(onOpenLogDose), REMINDER_CHECK_MS); setInterval(checkAlertsNow, ALERT_CHECK_MS); // Premier passage immédiat setTimeout(() => checkReminders(onOpenLogDose), 2000); } /** * Vérifie le prochain rappel ; si dû (dans la minute), déclenche la * notification navigateur + une bannière in-app avec actions. * * @param {(treatmentId:number)=>void} onOpenLogDose Ouvre le dialog de log pré-rempli */ function checkReminders(onOpenLogDose) { try { const treatments = store.getTreatments(); const doseLogs = store.getDoseLogs(); const nowMs = Date.now(); // Le MÊME moteur que la carte « Prochaine dose » (grille Posologie, #52) const nextMs = nextReminderFireMs(treatments, doseLogs, nowMs); if (nextMs === null) return; // Trouve le(s) traitement(s) concerné(s) — le min de nextReminderFireFor for (const tr of treatments) { const fire = nextReminderFireFor(tr, doseLogs, nowMs); if (fire === null) continue; const due = fire - nowMs; const key = `${tr.id}`; // Dû à moins de 30 s (ou déjà passé de peu) ET pas déjà notifié pour ce créneau if (due <= REMINDER_CHECK_MS && notifiedReminders.get(key) !== fire) { notifiedReminders.set(key, fire); fireReminder(tr, fire, onOpenLogDose); } } // Timer précis si le rappel est imminent (< 1 min) : re-vérifie à l'heure if (imminentTimer) clearTimeout(imminentTimer); const delta = nextMs - Date.now(); if (delta > 0 && delta < 60000) { imminentTimer = setTimeout(() => checkReminders(onOpenLogDose), Math.max(delta, 1000)); } } catch (e) { // La boucle ne doit JAMAIS tuer l'app (miroir AppLog fail-safe) AppLog.log('rappels', `ERREUR boucle : ${e.message}`); } } /** * Déclenche un rappel : notification navigateur (si permission) + bannière * in-app avec « Loguer maintenant » / « Reporter 1 h » (miroir des actions * de la notification Android : Pris / Reporter 1 h — « Pris » ouvre le * dialog pré-rempli plutôt que d'insérer aveuglément). */ function fireReminder(treatment, fireMs, onOpenLogDose) { const title = t('reminder_notif_title'); const body = t('reminder_notif_body', treatment.name, formatDose(treatment.doseAmount), treatment.doseUnit, formatTime(fireMs)); AppLog.log('rappels', `rappel traitement #${treatment.id} (${treatment.name}) à ${formatTime(fireMs)}`); // 1) Notification navigateur (graceful : permission peut être absente) try { if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { const notif = new Notification(title, { body, tag: `hormonetrack-reminder-${treatment.id}` }); notif.onclick = () => { window.focus(); onOpenLogDose(treatment.id); notif.close(); }; } } catch (e) { AppLog.log('rappels', `notification impossible : ${e.message}`); } // 2) Bannière in-app (fonctionne même sans permission — l'utilisateur voit) showReminderBanner(treatment, onOpenLogDose); } /** Bannière in-app persistante (jusqu'à action) avec les 2 actions. */ function showReminderBanner(treatment, onOpenLogDose) { const id = `reminder-banner-${treatment.id}`; if (document.getElementById(id)) return; // déjà affichée const banner = document.createElement('div'); banner.id = id; banner.className = 'reminder-banner'; banner.innerHTML = ''; banner.appendChild(document.createTextNode( `⏰ ${treatment.name} · ${formatDose(treatment.doseAmount)}${treatment.doseUnit}`, )); const logBtn = document.createElement('button'); logBtn.className = 'btn tonal'; logBtn.textContent = t('reminder_log_now'); logBtn.addEventListener('click', () => { banner.remove(); onOpenLogDose(treatment.id); }); const snoozeBtn = document.createElement('button'); snoozeBtn.className = 'btn text'; snoozeBtn.textContent = t('reminder_snooze'); snoozeBtn.addEventListener('click', () => { banner.remove(); // « Reporter 1 h » (miroir scheduleSnooze) : mémorise un délai, la boucle // re-notifiera dans 1 h via une entrée dans notifiedReminders décalée setTimeout(() => fireReminder(treatment, Date.now() + 3600000 - 1, onOpenLogDose), 3600000); AppLog.log('rappels', `report 1 h : traitement #${treatment.id}`); }); banner.appendChild(logBtn); banner.appendChild(snoozeBtn); const main = document.getElementById('banner-slot'); if (main) main.appendChild(banner); } /** * Vérifie les seuils d'alerte MAINTENANT (au save des seuils + toutes les * 5 min) — portage direct d'AlertNotifier.checkNow / AlertWorker : * - niveau calculé par le MÊME calibration que la carte accueil ; * - anti-spam par état persisté (Alerts.encodeState / shouldNotify). */ export function checkAlertsNow() { try { const settings = store.getSettings(); const thresholds = new Alerts.Thresholds( settings.alertE2High, settings.alertE2Low, settings.alertTHigh, settings.alertTLow, ); const tConfigRaw = store.getTConfig(); const tConfig = new TConfig(tConfigRaw.base, tConfigRaw.floor, tConfigRaw.k); // Même calibration que la carte accueil (auto-cal incluse si active) let scalePerEster = null; let tKPerEster = null; if (settings.autoCalibrate) { const auto = autoCalibrated(store.getTreatments(), store.getDoseLogs(), store.getLabResults(), tConfig, null); scalePerEster = auto.esterScales; tKPerEster = auto.tKPerEster; } const level = levelAt(store.getTreatments(), store.getDoseLogs(), Date.now(), tConfig, tKPerEster, scalePerEster); const alerts = Alerts.evaluateAll(level.e2, level.t, thresholds); const current = Alerts.parseState(Alerts.encodeState(alerts)); // map propre const lastNotified = Alerts.parseState(settings.alertNotifiedState); if (Alerts.shouldNotify(current, lastNotified)) { // Nouveau franchissement (ou changement H↔L) → notification AppLog.log('alertes', `notification : ${Alerts.encodeState(alerts)}`); try { if (typeof Notification !== 'undefined' && Notification.permission === 'granted') { const lines = alerts.map((a) => (a.level === 'HIGH' ? t('alert_high', a.marker, formatDose(a.value), a.unit, formatDose(a.limit)) : t('alert_low', a.marker, formatDose(a.value), a.unit, formatDose(a.limit)))); new Notification(t('alert_card_title'), { body: lines.join('\n'), tag: 'hormonetrack-alerts', }); } } catch (e) { AppLog.log('alertes', `notification impossible : ${e.message}`); } store.setSettings({ alertNotifiedState: Alerts.encodeState(alerts) }); } else if (Object.keys(current).length === 0 && settings.alertNotifiedState) { // Retour à la normale : pas de notif, mais état effacé (ré-arme) AppLog.log('alertes', 'retour à la normale'); store.setSettings({ alertNotifiedState: null }); } else { AppLog.log('alertes', `franchissement inchangé (${Alerts.encodeState(alerts) || 'aucun'})`); } } catch (e) { AppLog.log('alertes', `ERREUR : ${e.message}`); } }