// Daily "don't forget to read" reminders, sent via ntfy.sh (https://ntfy.sh) — // a free pub/sub push service: POST a message to a topic URL, anyone // subscribed to that topic (via the ntfy app or a browser) gets notified. // No API keys or push-subscription plumbing needed. import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js'; // A reminder still goes out if the server was briefly down (or restarting) at the // exact minute — as long as it's within this window after the chosen time. const GRACE_MINUTES = 180; const SEND_TIMEOUT_MS = 10_000; export function isValidTimeZone(tz) { if (!tz) return false; try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; } } // The calendar date ("YYYY-MM-DD") and minutes-since-midnight in a timezone; the // server's own timezone when none (or an invalid one) is saved. export function zonedNow(date = new Date(), tz) { const timeZone = isValidTimeZone(tz) ? tz : undefined; const parts = Object.fromEntries( new Intl.DateTimeFormat('en-CA', { timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).formatToParts(date).map((p) => [p.type, p.value]), ); return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute) }; } // Is this user's reminder due right now (and not already sent today)? export function isDue(user, now = new Date()) { const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(user.reminderTime ?? ''); if (!match) return false; const target = Number(match[1]) * 60 + Number(match[2]); const local = zonedNow(now, user.reminderTz); if (user.lastReminderDate === local.date) return false; return local.minutes >= target && local.minutes < target + GRACE_MINUTES; } // POSTs one notification to the user's ntfy topic. Throws with a readable message on failure. export async function sendNtfy(user, { title, body, tags = 'open_book' }) { const base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, ''); const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`; const headers = { Title: title, Tags: tags }; if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app let res; try { res = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(SEND_TIMEOUT_MS) }); } catch (err) { throw new Error(err.name === 'TimeoutError' ? `Timed out reaching ${base}` : `Could not reach ${base} (${err.cause?.code ?? err.message})`); } if (!res.ok) throw new Error(`ntfy responded ${res.status}`); } export function reminderBody(progress) { const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length; return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!"; } /** Sends a one-off notification so the user can confirm their topic/server work. */ export function sendTestReminder(user) { return sendNtfy(user, { title: 'Reading reminders are working', body: 'This is a test from Bible Study Project. Your daily reminder will arrive like this.', tags: 'white_check_mark' }); } export async function checkReminders(now = new Date()) { for (const user of getUsersWithRemindersEnabled().filter((u) => isDue(u, now))) { try { const progress = getReadingProgress(user.id); const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt); if (!hasActivePlan) continue; // nothing to nudge them about await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) }); setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date); } catch (err) { console.error(`Reading reminder failed for user ${user.id}:`, err.message); } } } /** Checks once a minute whether any user's reminder time has arrived. */ export function startReminderScheduler() { setInterval(() => { checkReminders().catch((err) => console.error('Reminder scheduler error:', err)); }, 60 * 1000); }