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. // Older databases predate shareable read-only links.
if (!projectCols.some((c) => c.name === 'share_token')) { if (!projectCols.some((c) => c.name === 'share_token')) {
db.exec('ALTER TABLE projects ADD COLUMN share_token TEXT'); 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, totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
podcast_name AS podcastName, podcast_name AS podcastName,
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime, 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 FROM users
`; `;
@@ -182,18 +187,18 @@ export function setPodcastName(userId, podcastName) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Saves a user's ntfy reminder settings ({ ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }). */ /** 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(` 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 = ? 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. */ /** Every account with reminders turned on and a topic configured — checked once a minute. */
export function getUsersWithRemindersEnabled() { export function getUsersWithRemindersEnabled() {
return db.prepare(` return db.prepare(`
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime, 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 FROM users
WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL
`).all(); `).all();
+30 -1
View File
@@ -22,7 +22,7 @@ import {
generateTotpSecret, totpKeyUri, verifyTotpToken, generateTotpSecret, totpKeyUri, verifyTotpToken,
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword, generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
} from './auth.js'; } from './auth.js';
import { startReminderScheduler } from './reminders.js'; import { startReminderScheduler, sendTestReminder, isValidTimeZone } from './reminders.js';
import { registerBibleRoutes } from './bibleApi.js'; import { registerBibleRoutes } from './bibleApi.js';
// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me). // 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 ntfyTopic = String(req.body?.ntfyTopic ?? '').trim();
const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim(); const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim();
const reminderTime = String(req.body?.reminderTime ?? '').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)) { if (ntfyTopic && !/^[A-Za-z0-9_-]{1,64}$/.test(ntfyTopic)) {
return res.status(400).json({ error: 'Topic can only contain letters, numbers, "-" and "_".' }); 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, ntfyTopic: ntfyTopic || null,
reminderTime: reminderTime || null, reminderTime: reminderTime || null,
reminderEnabled, reminderEnabled,
reminderTz,
}); });
res.json({ res.json({
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null, 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 // Read-only share links
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+51 -20
View File
@@ -4,40 +4,71 @@
// No API keys or push-subscription plumbing needed. // No API keys or push-subscription plumbing needed.
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js'; import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js';
function todayDateString(d = new Date()) { // A reminder still goes out if the server was briefly down (or restarting) at the
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; // 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()) { // The calendar date ("YYYY-MM-DD") and minutes-since-midnight in a timezone; the
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; // 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 base = (user.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '');
const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`; const url = `${base}/${encodeURIComponent(user.ntfyTopic)}`;
const res = await fetch(url, { const headers = { Title: title, Tags: tags };
method: 'POST', if (process.env.APP_URL) headers.Click = process.env.APP_URL; // tapping the notification opens the app
headers: { Title: 'Reading Plan Reminder', Tags: 'open_book' }, let res;
body: "Don't forget today's reading!", 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}`); if (!res.ok) throw new Error(`ntfy responded ${res.status}`);
} }
async function checkReminders() { export function reminderBody(progress) {
const now = new Date(); const active = (Array.isArray(progress?.progress) ? progress.progress : []).filter((p) => !p.completedAt).length;
const hhmm = currentHHMM(now); return active > 1 ? `Don't forget today's reading! You have ${active} plans in progress.` : "Don't forget today's reading!";
const today = todayDateString(now); }
const due = getUsersWithRemindersEnabled().filter(
(u) => u.reminderTime === hhmm && u.lastReminderDate !== today,
);
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 { try {
const progress = getReadingProgress(user.id); const progress = getReadingProgress(user.id);
const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt); const hasActivePlan = Array.isArray(progress?.progress) && progress.progress.some((p) => !p.completedAt);
if (!hasActivePlan) continue; // nothing to nudge them about if (!hasActivePlan) continue; // nothing to nudge them about
await sendNtfyReminder(user); await sendNtfy(user, { title: 'Reading Plan Reminder', body: reminderBody(progress) });
setLastReminderDate(user.id, today); setLastReminderDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) { } catch (err) {
console.error(`Reading reminder failed for user ${user.id}:`, err.message); 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/);
});
+8 -5
View File
@@ -1,5 +1,5 @@
import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { getPlanSchedule } from './utils/readingPlans.js'; import { getPlanSchedule, nearestReadingDay } from './utils/readingPlans.js';
import { AppContext, useApp } from './context/AppContext.js'; import { AppContext, useApp } from './context/AppContext.js';
import { useAudio } from './hooks/useAudio.js'; import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js'; import { useAuth } from './hooks/useAuth.js';
@@ -140,11 +140,14 @@ const App = () => {
const openPlanReading = (progressId, dayIndex, ref) => { const openPlanReading = (progressId, dayIndex, ref) => {
const progress = readingPlans.planProgress.find((p) => p.id === progressId); const progress = readingPlans.planProgress.find((p) => p.id === progressId);
if (!progress) return; if (!progress) return;
const refs = getPlanSchedule(progress.planId)[dayIndex] ?? []; const schedule = getPlanSchedule(progress.planId);
const done = progress.completedDays.includes(dayIndex); // A day with nothing scheduled would leave the Reader where it was — use the nearest real day.
const read = new Set(done ? [] : progress.chaptersRead?.[dayIndex] ?? []); const day = schedule[dayIndex]?.length ? dayIndex : Math.max(0, nearestReadingDay(schedule, dayIndex));
const refs = schedule[day] ?? [];
const done = progress.completedDays.includes(day);
const read = new Set(done ? [] : progress.chaptersRead?.[day] ?? []);
const target = ref ?? refs.find((r) => !read.has(`${r.book}.${r.chapter}`)) ?? refs[0]; const target = ref ?? refs.find((r) => !read.has(`${r.book}.${r.chapter}`)) ?? refs[0];
setReaderPlan({ progressId, dayIndex }); setReaderPlan({ progressId, dayIndex: day });
if (target) { if (target) {
reader.setReaderBookAbbrev(target.book); reader.setReaderBookAbbrev(target.book);
reader.setReaderChapter(target.chapter); reader.setReaderChapter(target.chapter);
+16
View File
@@ -950,3 +950,19 @@ describe('Study layout', () => {
expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument(); expect(within(screen.getByRole('region', { name: 'Scripture' })).queryByRole('textbox')).not.toBeInTheDocument();
}); });
}); });
describe('Reading plan with more days than chapters', () => {
test('Continue reading opens the plan on a real chapter instead of stranding the Reader', async () => {
localStorage.setItem('readingPlanProgress', JSON.stringify({
customPlans: [{ id: 'custom-luke', title: 'Luke', days: 90, books: ['LUK'], custom: true }],
progress: [{ id: 'rp1', planId: 'custom-luke', startedAt: Date.now(), completedDays: [0, 1], chaptersRead: {}, completedAt: null, lastActivityAt: Date.now() }],
activity: [],
}));
const user = userEvent.setup();
await renderApp();
await user.click(await screen.findByRole('button', { name: /continue reading/i }));
expect(await screen.findByText(/Luke 3 · BSB/)).toBeInTheDocument();
expect(screen.getAllByText(/Luke · Day 3/).length).toBeGreaterThan(0);
expect(screen.queryByText(/back to today.s reading/i)).not.toBeInTheDocument();
});
});
+8 -3
View File
@@ -3,7 +3,7 @@
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx'; import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx';
import { import {
bookChapterCount, ALL_BOOKS, OT_BOOKS, NT_BOOKS, GOSPELS, PENTATEUCH, WISDOM_BOOKS, bookChapterCount, clampPlanDays, ALL_BOOKS, OT_BOOKS, NT_BOOKS, GOSPELS, PENTATEUCH, WISDOM_BOOKS,
} from '../../utils/readingPlans.js'; } from '../../utils/readingPlans.js';
// Downscales a picked image file to a reasonable cover-photo size and returns // Downscales a picked image file to a reasonable cover-photo size and returns
@@ -201,8 +201,13 @@ export default function CustomPlanBuilder({ initialValues, onSubmit, onCancel })
</div> </div>
<p className="mt-2 text-xs text-[var(--r-sub)]"> <p className="mt-2 text-xs text-[var(--r-sub)]">
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters {books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / days).toFixed(1)} ch/day`} {days > 0 && totalChapters > 0 && ` · ~${(totalChapters / Math.min(days, totalChapters)).toFixed(1)} ch/day`}
</p> </p>
{totalChapters > 0 && Number(days) > totalChapters && (
<p className="mt-1 text-xs text-[var(--r-accent)]">
That’s more days than chapters, so the plan will be {totalChapters} days, one chapter a day.
</p>
)}
{isEditing && ( {isEditing && (
books.join() !== initialValues.books.join() books.join() !== initialValues.books.join()
|| Number(days) !== initialValues.days || Number(days) !== initialValues.days
@@ -218,7 +223,7 @@ export default function CustomPlanBuilder({ initialValues, onSubmit, onCancel })
<button <button
type="button" type="button"
disabled={!canCreate} disabled={!canCreate}
onClick={() => onSubmit({ title, description, days, books, chapterRanges, image })} onClick={() => onSubmit({ title, description, days: clampPlanDays(books, chapterRanges, days), books, chapterRanges, image })}
className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-semibold text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-semibold text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
> >
{isEditing ? 'Save changes' : 'Create plan'} {isEditing ? 'Save changes' : 'Create plan'}
+15 -6
View File
@@ -5,7 +5,7 @@ import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent'; const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings }) { export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) {
return ( return (
<div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans"> <div className="sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2"> <div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
@@ -14,11 +14,20 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5"> className="mx-auto flex min-w-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--r-text)] hover:bg-black/5">
<span className="truncate">{title}</span><ChevronDown /> <span className="truncate">{title}</span><ChevronDown />
</button> </button>
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search"><SearchIcon /></button> {onDoneDrawing ? (
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks"> <button type="button" onClick={onDoneDrawing}
<BookmarkIcon filled={bookmarkCount > 0} /> className="mr-1 flex items-center gap-1.5 rounded-full bg-[var(--r-accent)] px-4 py-1.5 text-xs font-medium text-[var(--r-on)]">
</button> <CheckIcon />Done drawing
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button> </button>
) : (
<>
<button type="button" onClick={onSearch} className={iconBtn} aria-label="Search"><SearchIcon /></button>
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
<BookmarkIcon filled={bookmarkCount > 0} />
</button>
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
</>
)}
</div> </div>
</div> </div>
); );
+6 -4
View File
@@ -4,9 +4,9 @@
// Saved locally right away; synced to the server via an effect that watches // Saved locally right away; synced to the server via an effect that watches
// `state` (see below) so sync success/failure can actually be tracked and // `state` (see below) so sync success/failure can actually be tracked and
// shown, instead of being a fire-and-forget call buried inside setState. // shown, instead of being a fire-and-forget call buried inside setState.
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getRemoteReadingProgress, saveRemoteReadingProgress } from '../syncService.js'; import { getRemoteReadingProgress, saveRemoteReadingProgress } from '../syncService.js';
import { localDateString, registerCustomPlans } from '../utils/readingPlans.js'; import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js';
const STORAGE_KEY = 'readingPlanProgress'; const STORAGE_KEY = 'readingPlanProgress';
@@ -46,6 +46,8 @@ export function useReadingPlans() {
// Keeps the readingPlans.js lookup (getPlan/getPlanSchedule) in sync with // Keeps the readingPlans.js lookup (getPlan/getPlanSchedule) in sync with
// this user's custom plans before any consumer renders this commit. // this user's custom plans before any consumer renders this commit.
registerCustomPlans(state.customPlans); registerCustomPlans(state.customPlans);
// What the rest of the app sees: plans with more days than chapters are clamped.
const customPlans = useMemo(() => state.customPlans.map(normalizeCustomPlan), [state.customPlans]);
// Persist locally + sync to the server whenever state changes. Runs as a // Persist locally + sync to the server whenever state changes. Runs as a
// real effect (not inside the setState updater) so the network call has a // real effect (not inside the setState updater) so the network call has a
@@ -198,7 +200,7 @@ export function useReadingPlans() {
category: 'My Plans', category: 'My Plans',
icon: '✨', icon: '✨',
image: image || undefined, image: image || undefined,
days: Math.max(1, Math.round(days)), days: clampPlanDays(books, chapterRanges, days),
books, books,
chapterRanges: chapterRanges && Object.keys(chapterRanges).length > 0 ? chapterRanges : undefined, chapterRanges: chapterRanges && Object.keys(chapterRanges).length > 0 ? chapterRanges : undefined,
custom: true, custom: true,
@@ -279,7 +281,7 @@ export function useReadingPlans() {
return { return {
planProgress: state.progress, planProgress: state.progress,
customPlans: state.customPlans, customPlans,
readingActivity: state.activity, readingActivity: state.activity,
readingPlanSyncStatus: syncStatus, readingPlanSyncStatus: syncStatus,
readingPlanSyncError: syncError, readingPlanSyncError: syncError,
+10 -5
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react'; import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { bookOptions } from '../utils/bibleUtils.jsx'; import { bookOptions } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js'; import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak, nearestReadingDay } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx'; import DrawCanvas from './DrawCanvas.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js'; import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
import { useBibleSearch } from '../hooks/useBibleSearch.js'; import { useBibleSearch } from '../hooks/useBibleSearch.js';
@@ -162,11 +162,15 @@ export default function ReaderPage() {
}; };
const goToRef = (r) => goTo(r.book, r.chapter); const goToRef = (r) => goTo(r.book, r.chapter);
// Opens a day of the context plan on its first unread chapter. // Opens a day of the context plan on its first unread chapter (or the nearest day that has one).
const goDay = (progressId, dayIndex) => { const goDay = (progressId, dayIndex) => {
const info = planInfo(planProgress.find((p) => p.id === progressId), dayIndex); const progress = planProgress.find((p) => p.id === progressId);
if (!progress) return;
const schedule = getPlanSchedule(progress.planId);
const day = schedule[dayIndex]?.length ? dayIndex : nearestReadingDay(schedule, dayIndex);
const info = day >= 0 ? planInfo(progress, day) : null;
if (!info) return; if (!info) return;
setReaderPlan({ progressId, dayIndex }); setReaderPlan({ progressId, dayIndex: day });
const target = info.refs.find((r) => !info.readSet.has(`${r.book}.${r.chapter}`)) ?? info.refs[0]; const target = info.refs.find((r) => !info.readSet.has(`${r.book}.${r.chapter}`)) ?? info.refs[0];
if (target) goToRef(target); if (target) goToRef(target);
}; };
@@ -232,7 +236,7 @@ export default function ReaderPage() {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
if (e.key === 'ArrowLeft') readerGoToPreviousChapter(); if (e.key === 'ArrowLeft') readerGoToPreviousChapter();
else if (e.key === 'ArrowRight') readerGoToNextChapter(); else if (e.key === 'ArrowRight') readerGoToNextChapter();
else if (e.key === 'Escape') { if (sheet) closeSheet(); else setReaderSelectedVerse(null); } else if (e.key === 'Escape') { if (sheet) closeSheet(); else if (readerDrawMode) setReaderDrawMode(false); else setReaderSelectedVerse(null); }
}; };
window.addEventListener('keydown', handleKey); window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey); return () => window.removeEventListener('keydown', handleKey);
@@ -336,6 +340,7 @@ export default function ReaderPage() {
onBookmarks={() => openSheet('bookmarks')} onBookmarks={() => openSheet('bookmarks')}
bookmarkCount={bookmarkEntries.length} bookmarkCount={bookmarkEntries.length}
onSettings={() => openSheet('settings')} onSettings={() => openSheet('settings')}
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
/> />
)} )}
{!chromeVisible && ( {!chromeVisible && (
+31 -8
View File
@@ -8,6 +8,7 @@ import {
changePassword, changePassword,
updateProfile, updateProfile,
updateReminderSettings, updateReminderSettings,
sendReminderTest,
} from '../syncService.js'; } from '../syncService.js';
import { exportJournalDocx, printJournalPdf } from '../utils/journalExport.js'; import { exportJournalDocx, printJournalPdf } from '../utils/journalExport.js';
@@ -120,11 +121,20 @@ export default function SettingsPage() {
window.setTimeout(() => setPodcastNameSaved(false), 2500); window.setTimeout(() => setPodcastNameSaved(false), 2500);
}; };
const [reminderTest, setReminderTest] = useState({ busy: false, ok: false, message: '' });
const testReminder = async () => {
setReminderTest({ busy: true, ok: false, message: '' });
const result = await sendReminderTest({ ntfyTopic: reminderForm.ntfyTopic, ntfyUrl: reminderForm.ntfyUrl });
setReminderTest(result.ok
? { busy: false, ok: true, message: 'Sent. Check your ntfy app or the topic page in a browser.' }
: { busy: false, ok: false, message: result.error ?? 'Could not send the test.' });
};
const submitReminderSettings = async (e) => { const submitReminderSettings = async (e) => {
e.preventDefault(); e.preventDefault();
setReminderError(''); setReminderError('');
setReminderBusy(true); setReminderBusy(true);
const result = await updateReminderSettings(reminderForm); const result = await updateReminderSettings({ ...reminderForm, reminderTz: Intl.DateTimeFormat().resolvedOptions().timeZone });
setReminderBusy(false); setReminderBusy(false);
if (!result.ok) { if (!result.ok) {
setReminderError(result.error ?? 'Failed to save reminder settings.'); setReminderError(result.error ?? 'Failed to save reminder settings.');
@@ -334,13 +344,26 @@ export default function SettingsPage() {
</label> </label>
{reminderError && <p className="text-sm text-[var(--r-danger,#b42318)]">{reminderError}</p>} {reminderError && <p className="text-sm text-[var(--r-danger,#b42318)]">{reminderError}</p>}
{reminderSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>} {reminderSaved && <p className="text-sm text-[var(--r-accent)]">Saved.</p>}
<button {reminderTest.message && (
type="submit" <p role="status" className={`text-sm ${reminderTest.ok ? 'text-[var(--r-accent)]' : 'text-[var(--r-danger,#b42318)]'}`}>{reminderTest.message}</p>
disabled={reminderBusy} )}
className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50" <div className="flex flex-wrap items-center gap-3">
> <button
{reminderBusy ? 'Saving…' : 'Save'} type="submit"
</button> disabled={reminderBusy}
className="rounded-full bg-[var(--r-accent)] px-5 py-2 text-sm font-medium text-[var(--r-on)] transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{reminderBusy ? 'Saving…' : 'Save'}
</button>
<button
type="button"
onClick={testReminder}
disabled={reminderTest.busy || !reminderForm.ntfyTopic.trim()}
className="rounded-full border border-[var(--r-line)] px-5 py-2 text-sm font-medium transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50"
>
{reminderTest.busy ? 'Sending…' : 'Send test notification'}
</button>
</div>
</form> </form>
</section> </section>
+5
View File
@@ -159,6 +159,11 @@ export async function changePassword(currentPassword, newPassword) {
return request('POST', '/auth/change-password', { currentPassword, newPassword }); return request('POST', '/auth/change-password', { currentPassword, newPassword });
} }
/** Sends a test notification to the given ntfy topic/server (or the saved ones). */
export async function sendReminderTest(settings) {
return request('POST', '/auth/reminder-test', settings);
}
/** Sets the daily reading reminder sent via ntfy.sh ({ reminderEnabled, ntfyUrl, ntfyTopic, reminderTime }). */ /** Sets the daily reading reminder sent via ntfy.sh ({ reminderEnabled, ntfyUrl, ntfyTopic, reminderTime }). */
export async function updateReminderSettings(settings) { export async function updateReminderSettings(settings) {
return request('PATCH', '/auth/reminder-settings', settings); return request('PATCH', '/auth/reminder-settings', settings);
+21 -1
View File
@@ -180,6 +180,26 @@ export function getPlan(planId) {
return PLAN_BY_ID[planId] ?? null; return PLAN_BY_ID[planId] ?? null;
} }
// A plan can't have more days than it has chapters (the extra days would be empty, with
// nothing to read). Existing custom plans with too many days heal to one chapter a day.
export function clampPlanDays(books, chapterRanges, days) {
const total = (books ?? []).reduce((sum, b) => sum + bookChapterCount(b, chapterRanges), 0);
const wanted = Math.max(1, Math.round(Number(days) || 1));
return total > 0 ? Math.min(wanted, total) : wanted;
}
export function normalizeCustomPlan(plan) {
const days = clampPlanDays(plan.books, plan.chapterRanges, plan.days);
return days === plan.days ? plan : { ...plan, days };
}
// Nearest day at or after `from` that has something to read, else the nearest one before it.
export function nearestReadingDay(schedule, from) {
for (let i = Math.max(0, from); i < schedule.length; i += 1) if (schedule[i]?.length) return i;
for (let i = Math.min(from, schedule.length) - 1; i >= 0; i -= 1) if (schedule[i]?.length) return i;
return -1;
}
// Registers/updates/removes user-created plans in the lookup used by getPlan/getPlanSchedule. // Registers/updates/removes user-created plans in the lookup used by getPlan/getPlanSchedule.
// Called by useReadingPlans() whenever the custom-plans list changes. // Called by useReadingPlans() whenever the custom-plans list changes.
export function registerCustomPlans(customPlans) { export function registerCustomPlans(customPlans) {
@@ -190,7 +210,7 @@ export function registerCustomPlans(customPlans) {
} }
} }
for (const cp of customPlans) { for (const cp of customPlans) {
PLAN_BY_ID[cp.id] = { category: 'My Plans', icon: '✨', ...cp, custom: true }; PLAN_BY_ID[cp.id] = { category: 'My Plans', icon: '✨', ...normalizeCustomPlan(cp), custom: true };
scheduleCache.delete(cp.id); scheduleCache.delete(cp.id);
} }
} }
+38
View File
@@ -0,0 +1,38 @@
import { test, expect } from 'vitest';
import {
clampPlanDays, getPlan, getPlanSchedule, nearestReadingDay, normalizeCustomPlan, registerCustomPlans,
} from './readingPlans.js';
const luke = (days) => ({ id: `custom-luke-${days}`, title: 'Luke', days, books: ['LUK'], custom: true });
test('a plan can never have more days than chapters', () => {
expect(clampPlanDays(['LUK'], undefined, 90)).toBe(24);
expect(clampPlanDays(['LUK'], undefined, 24)).toBe(24);
expect(clampPlanDays(['LUK'], undefined, 10)).toBe(10);
expect(clampPlanDays(['LUK'], { LUK: { start: 1, end: 5 } }, 30)).toBe(5); // honours chapter ranges
expect(clampPlanDays(['LUK'], undefined, 0)).toBe(1);
});
test('normalizeCustomPlan leaves a fine plan alone and heals one with too many days', () => {
const fine = luke(12);
expect(normalizeCustomPlan(fine)).toBe(fine);
expect(normalizeCustomPlan(luke(90)).days).toBe(24);
});
test('a 90-day Luke plan registers as 24 days with a chapter on every day (no empty days)', () => {
registerCustomPlans([luke(90)]);
const plan = getPlan('custom-luke-90');
expect(plan.days).toBe(24);
const schedule = getPlanSchedule('custom-luke-90');
expect(schedule).toHaveLength(24);
expect(schedule.every((day) => day.length === 1)).toBe(true);
expect(schedule[2]).toEqual([{ book: 'LUK', chapter: 3 }]); // "Day 3" opens Luke 3
});
test('nearestReadingDay skips empty days, looking forward first and then back', () => {
const schedule = [[{ book: 'LUK', chapter: 1 }], [], [], [{ book: 'LUK', chapter: 2 }], []];
expect(nearestReadingDay(schedule, 1)).toBe(3);
expect(nearestReadingDay(schedule, 3)).toBe(3);
expect(nearestReadingDay(schedule, 4)).toBe(3);
expect(nearestReadingDay([[], []], 0)).toBe(-1);
});