Build and Push Docker Image / build-and-push (push) Failing after 19s
Plans can no longer have more days than chapters (existing ones heal), and opening a plan never strands the Reader on an empty day. The Reader shows Done drawing while inking (Escape also exits). Reminders use the user's own timezone, send within a 3 hour grace window, and Settings has a Send test notification button. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
84 lines
4.1 KiB
JavaScript
84 lines
4.1 KiB
JavaScript
// 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);
|
|
}
|