115 lines
4.6 KiB
JavaScript
115 lines
4.6 KiB
JavaScript
// 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);
|
|
}
|