// static/js/calendar/reminders.js // // Browser-notification poller for calendar reminder notes. Self-contained: // module-private `_notifFired` Set tracks which note IDs we've already // notified, persisted to localStorage. Polls `/api/notes?label=calendar` // every 60 seconds and fires a Notification + toast for any note whose // `due_date` is in the past but within the staleness window. // // `start()` kicks off the poll loop + permission request. Call once from // the calendar's entry module. import uiModule from '../ui.js'; const API_BASE = window.location.origin; let _notifFired = new Set(JSON.parse(localStorage.getItem('cal-notif-fired') || '[]')); // Compute a fresh, system-clock-accurate notification body. Tries the // note's `event_dtstart` first (set by _createEventReminder); falls back // to scrubbing stale time tokens out of items[0].text so legacy // reminders don't show "in 29 min" at 9pm. function _formatReminderBody(note) { const dtstartRaw = note.event_dtstart || note.eventDtstart || null; if (dtstartRaw) { const start = new Date(dtstartRaw); if (!isNaN(start.getTime())) { const now = new Date(); const mins = Math.round((start - now) / 60000); const when = start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); let when2 = ''; const sameDay = start.toDateString() === now.toDateString(); if (!sameDay) when2 = ' ' + start.toLocaleDateString([], { month: 'short', day: 'numeric' }); if (mins >= 1 && mins <= 60) return `Starts in ${mins} min (${when}${when2})`; if (mins === 0) return `Starting now (${when}${when2})`; if (mins > 60) { const h = Math.round(mins / 60); return `Starts in ${h} hour${h === 1 ? '' : 's'} (${when}${when2})`; } if (mins >= -60) return `Started ${Math.abs(mins)} min ago (${when}${when2})`; return `Was scheduled for ${when}${when2}`; } } // Legacy notes (no event_dtstart). Scrub stale relative-time strings. let body = (note.items || []).map(i => i.text).join('\n') || note.content || ''; body = body.replace(/\bin\s+\d+\s*(min|minute|hour|hr|day)s?\b/gi, '').trim(); body = body.replace(/\(\s*\d{1,2}:\d{2}\s*\)/g, '').trim(); body = body.replace(/\s{2,}/g, ' '); return body; } // Only fire a reminder if `due` was within this many minutes BEFORE now. // Stops a fresh browser (empty `cal-notif-fired` localStorage) from spamming // every 2-week-old reminder on first poll. Anything older is silently // marked fired so it doesn't keep getting picked up. const _REMINDER_STALENESS_MIN = 5; async function _pollReminders() { try { const res = await fetch(`${API_BASE}/api/notes?label=calendar`, { credentials: 'same-origin' }); if (!res.ok) return; const notes = await res.json(); const now = new Date(); const stalenessMs = _REMINDER_STALENESS_MIN * 60 * 1000; for (const note of notes) { if (!note.due_date || _notifFired.has(note.id)) continue; const due = new Date(note.due_date); if (isNaN(due)) continue; if (due > now) continue; // not yet due const ageMs = now - due; if (ageMs > stalenessMs) { // Too old to fire — mark as seen so we don't recheck every minute. _notifFired.add(note.id); continue; } _notifFired.add(note.id); const body = _formatReminderBody(note); fetch(`${API_BASE}/api/notes/fire-reminder`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ note_id: note.id, title: note.title || 'Calendar Reminder', body, }), }).catch(() => {}); if ('Notification' in window && Notification.permission === 'granted') { new Notification(note.title || 'Calendar Reminder', { body, icon: '/static/favicon.png', tag: `cal-remind-${note.id}`, }); } if (uiModule.showToast) uiModule.showToast((note.title || 'Calendar Reminder') + (body ? ' — ' + body : '')); } // Persist fired set (keep last 200) const arr = [..._notifFired].slice(-200); localStorage.setItem('cal-notif-fired', JSON.stringify(arr)); } catch (_) {} } let _started = false; // Idempotent: safe to call multiple times. Kicks off permission request // and the 60s poll loop on first call. export function startReminderPoll() { if (_started) return; _started = true; if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } _pollReminders(); setInterval(_pollReminders, 60000); }