HormoneTrack-web/js/ui/reminders.js
Siphonight 491efef8eb v1.4.10 — portage initial de l'app Android v1.4.10
Portage navigateur complet de l'app Android HormoneTrack v1.4.10 :
- moteur PK fidèle (Estrannaise tables ODS, Transfem Science V3C,
  WHSAH fit Mona, Bateman) — paramètres identiques, invariants Android
  préservés (fixes #19-#23, #35, #52-#62), 121 tests Node épinglés
- calibration par période d'ester et par modèle affiché (#60/#61)
- UI 6 écrans + éditeur, CurveChart Canvas (zoom/pan fractionnaire,
  prévision, pics/creux, fuseau configurable), rappels web
  (Notification API), seuils d'alerte, i18n FR/EN
- sauvegarde JSON v2 compatible Android bidirectionnelle (rétrocompat v1)
- 100 % local : localStorage, aucun serveur applicatif, aucune télémétrie
- processus : scripts/check.sh (syntaxe, i18n, tests, E2E navigateur,
  smoke HTTP), docs séparées (README + DEVELOPPEMENT + CHANGELOG)
- dépôt GIT SÉPARÉ de l'Android : historique 100 % propre, versions
  alignées sur l'Android porté, changelogs indépendants

Non porté (impossible dans un navigateur, documenté §12) : agenda
récurrent, notifications onglet fermé, montre.
2026-09-08 14:34:58 +02:00

210 lines
9.3 KiB
JavaScript

/**
* ─────────────────────────────────────────────────────────────────────────────
* 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}`);
}
}