From 297f2c7e84d627f75e3e9d332cc631a928d2c77a Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:34:46 -0400 Subject: [PATCH] Fix plan days with no chapters, add a way out of ink mode, harden ntfy reminders 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 --- server/db.js | 15 ++-- server/index.js | 31 +++++++- server/reminders.js | 71 +++++++++++++----- server/reminders.test.js | 86 ++++++++++++++++++++++ src/App.jsx | 13 ++-- src/App.test.jsx | 16 ++++ src/components/plans/CustomPlanBuilder.jsx | 11 ++- src/components/reader/ReaderBars.jsx | 21 ++++-- src/hooks/useReadingPlans.js | 10 ++- src/pages/ReaderPage.jsx | 15 ++-- src/pages/SettingsPage.jsx | 39 ++++++++-- src/syncService.js | 5 ++ src/utils/readingPlans.js | 22 +++++- src/utils/readingPlans.test.js | 38 ++++++++++ 14 files changed, 335 insertions(+), 58 deletions(-) create mode 100644 server/reminders.test.js create mode 100644 src/utils/readingPlans.test.js diff --git a/server/db.js b/server/db.js index 3f049a6..5b88a34 100644 --- a/server/db.js +++ b/server/db.js @@ -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(); diff --git a/server/index.js b/server/index.js index 4d37d06..0b83b18 100644 --- a/server/index.js +++ b/server/index.js @@ -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 // --------------------------------------------------------------------------- diff --git a/server/reminders.js b/server/reminders.js index 7c6d447..8380c15 100644 --- a/server/reminders.js +++ b/server/reminders.js @@ -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); } diff --git a/server/reminders.test.js b/server/reminders.test.js new file mode 100644 index 0000000..94e28f1 --- /dev/null +++ b/server/reminders.test.js @@ -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 / 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/); +}); diff --git a/src/App.jsx b/src/App.jsx index e00e5f1..7e8cf13 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,5 @@ 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 { useAudio } from './hooks/useAudio.js'; import { useAuth } from './hooks/useAuth.js'; @@ -140,11 +140,14 @@ const App = () => { const openPlanReading = (progressId, dayIndex, ref) => { const progress = readingPlans.planProgress.find((p) => p.id === progressId); if (!progress) return; - const refs = getPlanSchedule(progress.planId)[dayIndex] ?? []; - const done = progress.completedDays.includes(dayIndex); - const read = new Set(done ? [] : progress.chaptersRead?.[dayIndex] ?? []); + const schedule = getPlanSchedule(progress.planId); + // A day with nothing scheduled would leave the Reader where it was — use the nearest real day. + 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]; - setReaderPlan({ progressId, dayIndex }); + setReaderPlan({ progressId, dayIndex: day }); if (target) { reader.setReaderBookAbbrev(target.book); reader.setReaderChapter(target.chapter); diff --git a/src/App.test.jsx b/src/App.test.jsx index f9eae9b..31debf7 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -950,3 +950,19 @@ describe('Study layout', () => { 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(); + }); +}); diff --git a/src/components/plans/CustomPlanBuilder.jsx b/src/components/plans/CustomPlanBuilder.jsx index 58c8612..cfe6e3a 100644 --- a/src/components/plans/CustomPlanBuilder.jsx +++ b/src/components/plans/CustomPlanBuilder.jsx @@ -3,7 +3,7 @@ import { useRef, useState } from 'react'; import { bookOptions, CHAPTER_COUNTS } from '../../utils/bibleUtils.jsx'; 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'; // Downscales a picked image file to a reasonable cover-photo size and returns @@ -201,8 +201,13 @@ export default function CustomPlanBuilder({ initialValues, onSubmit, onCancel })

{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`}

+ {totalChapters > 0 && Number(days) > totalChapters && ( +

+ That’s more days than chapters, so the plan will be {totalChapters} days, one chapter a day. +

+ )} {isEditing && ( books.join() !== initialValues.books.join() || Number(days) !== initialValues.days @@ -218,7 +223,7 @@ export default function CustomPlanBuilder({ initialValues, onSubmit, onCancel }) - - - + {onDoneDrawing ? ( + + ) : ( + <> + + + + + )} ); diff --git a/src/hooks/useReadingPlans.js b/src/hooks/useReadingPlans.js index 9593972..b9172e0 100644 --- a/src/hooks/useReadingPlans.js +++ b/src/hooks/useReadingPlans.js @@ -4,9 +4,9 @@ // 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 // 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 { localDateString, registerCustomPlans } from '../utils/readingPlans.js'; +import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js'; const STORAGE_KEY = 'readingPlanProgress'; @@ -46,6 +46,8 @@ export function useReadingPlans() { // Keeps the readingPlans.js lookup (getPlan/getPlanSchedule) in sync with // this user's custom plans before any consumer renders this commit. 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 // real effect (not inside the setState updater) so the network call has a @@ -198,7 +200,7 @@ export function useReadingPlans() { category: 'My Plans', icon: '✨', image: image || undefined, - days: Math.max(1, Math.round(days)), + days: clampPlanDays(books, chapterRanges, days), books, chapterRanges: chapterRanges && Object.keys(chapterRanges).length > 0 ? chapterRanges : undefined, custom: true, @@ -279,7 +281,7 @@ export function useReadingPlans() { return { planProgress: state.progress, - customPlans: state.customPlans, + customPlans, readingActivity: state.activity, readingPlanSyncStatus: syncStatus, readingPlanSyncError: syncError, diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index 31d95a2..36572fd 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react'; import { useApp } from '../context/AppContext.js'; 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 { READER_TRANSLATIONS } from '../hooks/useBibleReader.js'; import { useBibleSearch } from '../hooks/useBibleSearch.js'; @@ -162,11 +162,15 @@ export default function ReaderPage() { }; 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 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; - setReaderPlan({ progressId, dayIndex }); + setReaderPlan({ progressId, dayIndex: day }); const target = info.refs.find((r) => !info.readSet.has(`${r.book}.${r.chapter}`)) ?? info.refs[0]; 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.key === 'ArrowLeft') readerGoToPreviousChapter(); 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); return () => window.removeEventListener('keydown', handleKey); @@ -336,6 +340,7 @@ export default function ReaderPage() { onBookmarks={() => openSheet('bookmarks')} bookmarkCount={bookmarkEntries.length} onSettings={() => openSheet('settings')} + onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined} /> )} {!chromeVisible && ( diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index cc1113f..0120a8e 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -8,6 +8,7 @@ import { changePassword, updateProfile, updateReminderSettings, + sendReminderTest, } from '../syncService.js'; import { exportJournalDocx, printJournalPdf } from '../utils/journalExport.js'; @@ -120,11 +121,20 @@ export default function SettingsPage() { 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) => { e.preventDefault(); setReminderError(''); setReminderBusy(true); - const result = await updateReminderSettings(reminderForm); + const result = await updateReminderSettings({ ...reminderForm, reminderTz: Intl.DateTimeFormat().resolvedOptions().timeZone }); setReminderBusy(false); if (!result.ok) { setReminderError(result.error ?? 'Failed to save reminder settings.'); @@ -334,13 +344,26 @@ export default function SettingsPage() { {reminderError &&

{reminderError}

} {reminderSaved &&

Saved.

} - + {reminderTest.message && ( +

{reminderTest.message}

+ )} +
+ + +
diff --git a/src/syncService.js b/src/syncService.js index 7f9c19f..3671fe8 100644 --- a/src/syncService.js +++ b/src/syncService.js @@ -159,6 +159,11 @@ export async function changePassword(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 }). */ export async function updateReminderSettings(settings) { return request('PATCH', '/auth/reminder-settings', settings); diff --git a/src/utils/readingPlans.js b/src/utils/readingPlans.js index 0335a32..74bab72 100644 --- a/src/utils/readingPlans.js +++ b/src/utils/readingPlans.js @@ -180,6 +180,26 @@ export function getPlan(planId) { 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. // Called by useReadingPlans() whenever the custom-plans list changes. export function registerCustomPlans(customPlans) { @@ -190,7 +210,7 @@ export function registerCustomPlans(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); } } diff --git a/src/utils/readingPlans.test.js b/src/utils/readingPlans.test.js new file mode 100644 index 0000000..97d04d8 --- /dev/null +++ b/src/utils/readingPlans.test.js @@ -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); +});