Fix plan days with no chapters, add a way out of ink mode, harden ntfy reminders
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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:34:46 -04:00
1 parent ecb1c701fa
commit 297f2c7e84
14 files changed
+335 -58

No files matched your search

+10 -5
View File
@@ -74,6 +74,11 @@ export function initDb() {
`);
}
// Reminders fire at the user's own local time, so remember their timezone.
if (!userCols.some((c) => c.name === 'reminder_tz')) {
db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT');
}
// Older databases predate shareable read-only links.
if (!projectCols.some((c) => c.name === 'share_token')) {
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT');
@@ -146,7 +151,7 @@ const USER_SELECT = `
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
podcast_name AS podcastName,
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz
FROM users
`;
@@ -182,18 +187,18 @@ export function setPodcastName(userId, podcastName) {
// ---------------------------------------------------------------------------
/** Saves a user's ntfy reminder settings ({ ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }). */
export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }) {
export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, reminderEnabled, reminderTz }) {
db.prepare(`
UPDATE users SET ntfy_url = ?, ntfy_topic = ?, reminder_time = ?, reminder_enabled = ?
UPDATE users SET ntfy_url = ?, ntfy_topic = ?, reminder_time = ?, reminder_enabled = ?, reminder_tz = ?
WHERE id = ?
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, userId);
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId);
}
/** Every account with reminders turned on and a topic configured — checked once a minute. */
export function getUsersWithRemindersEnabled() {
return db.prepare(`
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
last_reminder_date AS lastReminderDate
last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz
FROM users
WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL
`).all();
+30 -1
View File
@@ -22,7 +22,7 @@ import {
generateTotpSecret, totpKeyUri, verifyTotpToken,
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
} from './auth.js';
import { startReminderScheduler } from './reminders.js';
import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './reminders.js';
import { registerBibleRoutes } from './bibleApi.js';
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me).
@@ -236,6 +236,7 @@ app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => {
const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
const reminderTime = String(req.body?.reminderTime ?? '').trim();
const reminderTz = isValidTimeZone(req.body?.reminderTz) ? String(req.body.reminderTz) : null;
if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
@@ -255,6 +256,7 @@ app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => {
ntfyTopic: ntfyTopic || null,
reminderTime: reminderTime || null,
reminderEnabled,
reminderTz,
});
res.json({
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
@@ -477,6 +479,33 @@ app.put('/api/reading-plans', requireAuth, (req, res) => {
}
});
// POST /api/auth/reminder-test — sends a test notification to the topic in the request
// body (so it can be tried before saving), or to the saved settings.
const _lastReminderTest = new Map(); // userId -> timestamp, to keep test sends from being spammed
app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
const userId = req.session.userId;
if (Date.now() - (_lastReminderTest.get(userId) ?? 0) < 5000) {
return res.status(429).json({ error: 'Wait a few seconds before sending another test.' });
}
_lastReminderTest.set(userId, Date.now());
const saved = getUserById(userId);
const ntfyTopic = String(req.body?.ntfyTopic ?? saved?.ntfyTopic ?? '').trim();
const ntfyUrl = String(req.body?.ntfyUrl ?? saved?.ntfyUrl ?? '').trim();
if (!ntfyTopic) return res.status(400).json({ error: 'Enter a topic first.' });
if (!/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' });
}
if (ntfyUrl && !/^https?:\/\/.{1,200}$/.test(ntfyUrl)) {
return res.status(400).json({ error: 'Server URL must start with http:// or https://.' });
}
try {
await sendTestReminder({ ntfyTopic, ntfyUrl });
res.json({ ok: true });
} catch (err) {
res.status(502).json({ error: err.message });
}
});
// ---------------------------------------------------------------------------
// Read-only share links
// ---------------------------------------------------------------------------
+51 -20
View File
@@ -4,40 +4,71 @@
// No API keys or push-subscription plumbing needed.
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
function todayDateString(d = new Date()) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
// 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; }
}
function currentHHMM(d = new Date()) {
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
// 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) };
}
async function sendNtfyReminder(user) {
// 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 res = await fetch(url, {
method: 'POST',
headers: { Title: 'Reading Plan Reminder', Tags: 'open_book' },
body: "Don't forget today's reading!",
});
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}`);
}
async function checkReminders() {
const now = new Date();
const hhmm = currentHHMM(now);
const today = todayDateString(now);
const due = getUsersWithRemindersEnabled().filter(
(u) => u.reminderTime === hhmm && u.lastReminderDate !== today,
);
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!";
}
for (const user of due) {
/** 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 sendNtfyReminder(user);
setLastReminderDate(user.id, today);
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);
}
+86
View File
@@ -0,0 +1,86 @@
// @vitest-environment node
import http from 'node:http';
import { test, expect, afterEach } from 'vitest';
import { isDue, isValidTimeZone, reminderBody, sendNtfy, sendTestReminder, zonedNow } from './reminders.js';
const at = (iso) => new Date(iso);
test('zonedNow reads the date and time in the user\'s timezone, not the server\'s', () => {
// 2026-09-30T03:30Z is 23:30 the day before in New York, and 12:30 the same day in Tokyo
const d = at('2026-09-30T03:30:00Z');
expect(zonedNow(d, 'America/New_York')).toEqual({ date: '2026-09-29', minutes: 23 * 60 + 30 });
expect(zonedNow(d, 'Asia/Tokyo')).toEqual({ date: '2026-09-30', minutes: 12 * 60 + 30 });
expect(zonedNow(d, 'Not/AZone')).toEqual(zonedNow(d)); // bad timezone falls back to the server's
});
test('timezone validation', () => {
expect(isValidTimeZone('America/Chicago')).toBe(true);
expect(isValidTimeZone('Mars/Base')).toBe(false);
expect(isValidTimeZone('')).toBe(false);
});
test('a reminder is due at its time in the user\'s timezone', () => {
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
expect(isDue(user, at('2026-09-29T11:59:00Z'))).toBe(false); // 06:59 in Chicago (CDT)
expect(isDue(user, at('2026-09-29T12:00:00Z'))).toBe(true); // 07:00
});
test('a late server still sends today\'s reminder, but only within the grace window', () => {
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: null };
expect(isDue(user, at('2026-09-29T12:40:00Z'))).toBe(true); // 07:40 — server was down at 07:00
expect(isDue(user, at('2026-09-29T15:30:00Z'))).toBe(false); // 10:30 — too late to be useful
});
test('it is not sent twice on the same local day', () => {
const user = { reminderTime: '07:00', reminderTz: 'America/Chicago', lastReminderDate: '2026-09-29' };
expect(isDue(user, at('2026-09-29T12:05:00Z'))).toBe(false);
expect(isDue({ ...user, lastReminderDate: '2026-09-28' }, at('2026-09-29T12:05:00Z'))).toBe(true);
});
test('a missing or malformed time is never due', () => {
expect(isDue({ reminderTime: null }, new Date())).toBe(false);
expect(isDue({ reminderTime: '7am' }, new Date())).toBe(false);
});
test('the message mentions how many plans are in progress', () => {
expect(reminderBody({ progress: [{ completedAt: null }] })).toBe("Don't forget today's reading!");
expect(reminderBody({ progress: [{ completedAt: null }, { completedAt: null }, { completedAt: 1 }] })).toContain('2 plans');
});
// --- delivery, against a local stand-in for an ntfy server ------------------------
let server;
afterEach(() => new Promise((resolve) => (server ? server.close(resolve) : resolve())));
const startNtfy = (handler) => new Promise((resolve) => {
const seen = [];
server = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => { seen.push({ method: req.method, url: req.url, headers: req.headers, body }); handler(res); });
});
server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, seen }));
});
test('sendNtfy POSTs the message to <server>/<topic> with a title', async () => {
const ntfy = await startNtfy((res) => res.end('{}'));
await sendNtfy({ ntfyUrl: `${ntfy.url}/`, ntfyTopic: 'my-topic_1' }, { title: 'Hello', body: 'Body text' });
expect(ntfy.seen).toHaveLength(1);
expect(ntfy.seen[0]).toMatchObject({ method: 'POST', url: '/my-topic_1', body: 'Body text' });
expect(ntfy.seen[0].headers.title).toBe('Hello');
});
test('the test notification reaches the topic', async () => {
const ntfy = await startNtfy((res) => res.end('{}'));
await sendTestReminder({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' });
expect(ntfy.seen[0].url).toBe('/abc');
expect(ntfy.seen[0].headers.title).toMatch(/working/i);
});
test('an ntfy error response becomes a readable failure', async () => {
const ntfy = await startNtfy((res) => { res.statusCode = 403; res.end('nope'); });
await expect(sendNtfy({ ntfyUrl: ntfy.url, ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow('ntfy responded 403');
});
test('an unreachable server says so instead of hanging', async () => {
await expect(sendNtfy({ ntfyUrl: 'http://127.0.0.1:9', ntfyTopic: 'abc' }, { title: 't', body: 'b' })).rejects.toThrow(/Could not reach/);
});