/** * ───────────────────────────────────────────────────────────────────────────── * Rappels (partie 3/4 du moteur) — portage de `PharmacokineticEngine.kt` * sections `nextReminderFireFor` / `nextReminderFireMs` (fix #52, v1.4.0). * * Le WEB ne peut pas programmer d'alarmes système : ces fonctions alimentent * la boucle de vérification de l'app (`ui/reminders.js`, toutes les 30 s * tant que la page est ouverte) et la carte « Prochaine dose » de l'accueil. * Limite documentée : pas de notification quand l'onglet est fermé. * * ⚠️ FIX #52 (v1.4.0) : si le traitement a une Posologie * (`forecastIntervalDays`) ET un historique de doses, le rappel suit la * GRILLE des prises (dernière dose réelle + k × intervalle, créneaux passés * sautés — même sémantique que generateForecastDoses) et sonne le jour du * créneau à l'heure choisie : une injection tous les samedis à 18 h → * rappel UNIQUEMENT le samedi à 18 h. Sinon (gel/oral, ou aucune dose pour * cadrer la grille) → comportement QUOTIDIEN historique (prochaine * occurrence HH:mm). * * PUR, testable en Node (web/tests/reminders.test.js). * ───────────────────────────────────────────────────────────────────────────── */ import { HOUR_MS } from './pk-engine.js'; /** Une journée en ms. */ const DAY_MS = 86400000; /** * Met une date sur HH:mm:00.000 du MÊME jour calendaire (heure LOCALE du * navigateur / du process Node — équivalent Calendar.set(HOUR_OF_DAY…)). * * @param {number} ms * @param {number} hour * @param {number} minute * @returns {number} epoch ms */ function atTimeSameDay(ms, hour, minute) { const d = new Date(ms); d.setHours(hour, minute, 0, 0); return d.getTime(); } /** * Prochain déclenchement de rappel pour UN traitement. * * @param {object} treatment (reminderEnabled, reminderHour/Minute, isActive, * forecastIntervalDays) * @param {object[]} doseLogs Toutes les doses (le traitement repère les siennes) * @param {number} nowMs * @returns {number|null} epoch ms du prochain déclenchement, null si désactivé */ export function nextReminderFireFor(treatment, doseLogs, nowMs) { if (!treatment.reminderEnabled || !treatment.isActive) return null; const h = treatment.reminderHour; const m = treatment.reminderMinute; if (h === null || h === undefined || m === null || m === undefined) return null; const intervalDays = treatment.forecastIntervalDays; const myDoses = doseLogs.filter( (d) => d.treatmentId === treatment.id && d.timestamp <= nowMs, ); if (intervalDays !== null && intervalDays !== undefined && intervalDays > 0.0 && myDoses.length > 0) { const intervalMs = Math.trunc(intervalDays * 24.0 * HOUR_MS); if (intervalMs > 0) { // Dernière dose réelle = ancre de la grille let slot = myDoses.reduce((acc, d) => (acc === null || d.timestamp > acc.timestamp ? d : acc), null).timestamp; // Le premier créneau STRICTEMENT futur (un créneau passé = oubli : // on avance au rythme configuré — cf generateForecastDoses, #35). while (slot <= nowMs) slot += intervalMs; // L'alarme sonne le jour du créneau, à l'heure de rappel choisie ; // si cette heure est déjà passée le jour du créneau → créneau suivant. let candidate = slot; for (;;) { const fireMs = atTimeSameDay(candidate, h, m); if (fireMs > nowMs) return fireMs; candidate += intervalMs; } } } // Fallback QUOTIDIEN (comportement historique) : prochaine occurrence HH:mm let fire = atTimeSameDay(nowMs, h, m); if (fire <= nowMs) fire += DAY_MS; return fire; } /** * Prochain rappel parmi tous les traitements (min). [doseLogs] active la * planification par Posologie (cf nextReminderFireFor) ; vide = fallback * quotidien pur (compatibilité avec les appels historiques). * * @param {object[]} treatments * @param {object[]} [doseLogs=[]] * @param {number} [nowMs=Date.now()] * @returns {number|null} */ export function nextReminderFireMs(treatments, doseLogs = [], nowMs = Date.now()) { let next = null; for (const tr of treatments) { const fire = nextReminderFireFor(tr, doseLogs, nowMs); if (fire !== null && (next === null || fire < next)) next = fire; } return next; }