diff --git a/server/db.js b/server/db.js index 65e0104..3f049a6 100644 --- a/server/db.js +++ b/server/db.js @@ -63,6 +63,17 @@ export function initDb() { db.exec('ALTER TABLE users ADD COLUMN podcast_name TEXT'); } + // Older databases predate ntfy.sh daily reading reminders. + if (!userCols.some((c) => c.name === 'ntfy_url')) { + db.exec(` + ALTER TABLE users ADD COLUMN ntfy_url TEXT; + ALTER TABLE users ADD COLUMN ntfy_topic TEXT; + ALTER TABLE users ADD COLUMN reminder_time TEXT; + ALTER TABLE users ADD COLUMN reminder_enabled INTEGER NOT NULL DEFAULT 0; + ALTER TABLE users ADD COLUMN last_reminder_date 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'); @@ -125,6 +136,7 @@ function parseUserRow(row) { return { ...row, totpEnabled: !!row.totpEnabled, + reminderEnabled: !!row.reminderEnabled, backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [], }; } @@ -132,7 +144,9 @@ function parseUserRow(row) { const USER_SELECT = ` SELECT id, email, password_hash AS passwordHash, created_at AS createdAt, 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, + reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate FROM users `; @@ -163,6 +177,33 @@ export function setPodcastName(userId, podcastName) { db.prepare('UPDATE users SET podcast_name = ? WHERE id = ?').run(podcastName || null, userId); } +// --------------------------------------------------------------------------- +// Daily reading reminders (sent via ntfy.sh — see server/reminders.js) +// --------------------------------------------------------------------------- + +/** Saves a user's ntfy reminder settings ({ ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }). */ +export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime, reminderEnabled }) { + db.prepare(` + UPDATE users SET ntfy_url = ?, ntfy_topic = ?, reminder_time = ?, reminder_enabled = ? + WHERE id = ? + `).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, 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 + FROM users + WHERE reminder_enabled = 1 AND ntfy_topic IS NOT NULL AND reminder_time IS NOT NULL + `).all(); +} + +/** Marks that a user's reminder was sent today, so it isn't sent twice. */ +export function setLastReminderDate(userId, dateString) { + db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId); +} + /** Rewrites the remaining backup-code hashes after one is used (single-use codes). */ export function setBackupCodeHashes(userId, backupCodeHashes) { db.prepare('UPDATE users SET backup_codes = ? WHERE id = ?').run(JSON.stringify(backupCodeHashes), userId); diff --git a/server/index.js b/server/index.js index c83d6c8..1b39a69 100644 --- a/server/index.js +++ b/server/index.js @@ -14,6 +14,7 @@ import { setUserPassword, destroyAllSessionsForUser, getAllReaderInk, setReaderInkPage, getReadingProgress, setReadingProgress, + setReminderSettings, } from './db.js'; import { SqliteSessionStore } from './sessionStore.js'; import { @@ -21,6 +22,17 @@ import { generateTotpSecret, totpKeyUri, verifyTotpToken, generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword, } from './auth.js'; +import { startReminderScheduler } from './reminders.js'; + +// Shape returned to the client for the signed-in user (login, MFA verify, /auth/me). +function userPayload(user) { + return { + id: user.id, email: user.email, totpEnabled: user.totpEnabled, podcastName: user.podcastName ?? null, + isAdmin: isAdminEmail(user.email), + reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null, + reminderTime: user.reminderTime ?? null, + }; +} const __dirname = dirname(fileURLToPath(import.meta.url)); const app = express(); @@ -108,7 +120,10 @@ app.post('/api/auth/register', async (req, res) => { req.session.regenerate((err) => { if (err) return res.status(500).json({ error: 'Could not create session.' }); req.session.userId = user.id; - res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email) }); + res.json({ + id: user.id, email: user.email, totpEnabled: false, podcastName: null, isAdmin: isAdminEmail(user.email), + reminderEnabled: false, ntfyUrl: null, ntfyTopic: null, reminderTime: null, + }); }); } catch (err) { console.error('POST /api/auth/register error:', err); @@ -137,7 +152,7 @@ app.post('/api/auth/login', async (req, res) => { return res.json({ mfaRequired: true }); } req.session.userId = user.id; - res.json({ id: user.id, email: user.email, totpEnabled: false, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) }); + res.json(userPayload(user)); }); } catch (err) { console.error('POST /api/auth/login error:', err); @@ -176,7 +191,7 @@ app.post('/api/auth/mfa/verify', async (req, res) => { req.session.regenerate((err) => { if (err) return res.status(500).json({ error: 'Could not create session.' }); req.session.userId = user.id; - res.json({ id: user.id, email: user.email, totpEnabled: true, podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email) }); + res.json(userPayload(user)); }); } catch (err) { console.error('POST /api/auth/mfa/verify error:', err); @@ -194,10 +209,7 @@ app.post('/api/auth/logout', (req, res) => { app.get('/api/auth/me', (req, res) => { const user = req.session?.userId ? getUserById(req.session.userId) : null; if (!user) return res.status(401).json({ error: 'Not signed in.' }); - res.json({ - id: user.id, email: user.email, totpEnabled: user.totpEnabled, - podcastName: user.podcastName ?? null, isAdmin: isAdminEmail(user.email), - }); + res.json(userPayload(user)); }); // --------------------------------------------------------------------------- @@ -214,6 +226,44 @@ app.patch('/api/auth/profile', requireAuth, (req, res) => { } }); +// --------------------------------------------------------------------------- +// PATCH /api/auth/reminder-settings — daily reading reminder via ntfy.sh +// --------------------------------------------------------------------------- +app.patch('/api/auth/reminder-settings', requireAuth, (req, res) => { + try { + const reminderEnabled = !!req.body?.reminderEnabled; + const ntfyTopic = String(req.body?.ntfyTopic ?? '').trim(); + const ntfyUrl = String(req.body?.ntfyUrl ?? '').trim(); + const reminderTime = String(req.body?.reminderTime ?? '').trim(); + + if (ntfyTopic && !/^[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://.' }); + } + if (reminderEnabled) { + if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to enable reminders.' }); + if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(reminderTime)) { + return res.status(400).json({ error: 'Reminder time must be in HH:MM format.' }); + } + } + + setReminderSettings(req.session.userId, { + ntfyUrl: ntfyUrl || null, + ntfyTopic: ntfyTopic || null, + reminderTime: reminderTime || null, + reminderEnabled, + }); + res.json({ + reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null, + }); + } catch (err) { + console.error('PATCH /api/auth/reminder-settings error:', err); + res.status(500).json({ error: 'Failed to save reminder settings.' }); + } +}); + // --------------------------------------------------------------------------- // POST /api/auth/change-password — self-service password change (Account Settings) // --------------------------------------------------------------------------- @@ -575,6 +625,7 @@ if (isProd) { // Start // --------------------------------------------------------------------------- initDb(); +startReminderScheduler(); app.listen(PORT, () => { console.log(`Bible Study API running on http://localhost:${PORT}`); if (isProd) { diff --git a/server/reminders.js b/server/reminders.js new file mode 100644 index 0000000..7c6d447 --- /dev/null +++ b/server/reminders.js @@ -0,0 +1,52 @@ +// 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'; + +function todayDateString(d = new Date()) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function currentHHMM(d = new Date()) { + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +async function sendNtfyReminder(user) { + 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!", + }); + 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, + ); + + for (const user of due) { + 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); + } 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); +} diff --git a/src/hooks/useReadingPlans.js b/src/hooks/useReadingPlans.js index a48668a..ceda561 100644 --- a/src/hooks/useReadingPlans.js +++ b/src/hooks/useReadingPlans.js @@ -1,21 +1,25 @@ // Tracks which reading plans (see utils/readingPlans.js) a user has started, -// which days they've checked off, which plans they've finished, and any plans -// they've created themselves. Saved locally right away and synced to the -// server in the background — same "local wins immediately, server wins at -// sign-in" pattern as reader ink. +// which days they've checked off, which plans they've finished, any plans +// they've created themselves, and a cross-plan activity log (for streaks). +// Saved locally right away and synced to the server in the background — same +// "local wins immediately, server wins at sign-in" pattern as reader ink. import { useCallback, useState } from 'react'; import { saveRemoteReadingProgress } from '../syncService.js'; -import { registerCustomPlans } from '../utils/readingPlans.js'; +import { localDateString, registerCustomPlans } from '../utils/readingPlans.js'; const STORAGE_KEY = 'readingPlanProgress'; function loadLocal() { try { const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); - if (Array.isArray(raw)) return { progress: raw, customPlans: [] }; // pre-custom-plans shape - return { progress: raw?.progress ?? [], customPlans: raw?.customPlans ?? [] }; + if (Array.isArray(raw)) return { progress: raw, customPlans: [], activity: [] }; // pre-custom-plans shape + return { + progress: raw?.progress ?? [], + customPlans: raw?.customPlans ?? [], + activity: raw?.activity ?? [], + }; } catch { - return { progress: [], customPlans: [] }; + return { progress: [], customPlans: [], activity: [] }; } } @@ -32,21 +36,21 @@ export function useReadingPlans() { // this user's custom plans before any consumer renders this commit. registerCustomPlans(state.customPlans); - const updateProgress = useCallback((updater) => { + const updateState = useCallback((updater) => { setState((prev) => { - const next = { ...prev, progress: updater(prev.progress) }; + const next = updater(prev); persist(next); return next; }); }, []); + const updateProgress = useCallback((updater) => { + updateState((prev) => ({ ...prev, progress: updater(prev.progress) })); + }, [updateState]); + const updateCustomPlans = useCallback((updater) => { - setState((prev) => { - const next = { ...prev, customPlans: updater(prev.customPlans) }; - persist(next); - return next; - }); - }, []); + updateState((prev) => ({ ...prev, customPlans: updater(prev.customPlans) })); + }, [updateState]); const startPlan = useCallback((planId) => { updateProgress((prev) => { @@ -64,16 +68,26 @@ export function useReadingPlans() { }, [updateProgress]); const toggleDay = useCallback((progressId, dayIndex, totalDays) => { - updateProgress((prev) => prev.map((p) => { - if (p.id !== progressId) return p; - const already = p.completedDays.includes(dayIndex); - const completedDays = already - ? p.completedDays.filter((d) => d !== dayIndex) - : [...p.completedDays, dayIndex].sort((a, b) => a - b); - const completedAt = completedDays.length >= totalDays ? (p.completedAt ?? Date.now()) : null; - return { ...p, completedDays, completedAt, lastActivityAt: Date.now() }; - })); - }, [updateProgress]); + updateState((prev) => { + const target = prev.progress.find((p) => p.id === progressId); + if (!target) return prev; + const wasAlready = target.completedDays.includes(dayIndex); + const progress = prev.progress.map((p) => { + if (p.id !== progressId) return p; + const completedDays = wasAlready + ? p.completedDays.filter((d) => d !== dayIndex) + : [...p.completedDays, dayIndex].sort((a, b) => a - b); + const completedAt = completedDays.length >= totalDays ? (p.completedAt ?? Date.now()) : null; + return { ...p, completedDays, completedAt, lastActivityAt: Date.now() }; + }); + // Only marking a day *done* extends the streak; unchecking one doesn't erase it. + const today = localDateString(); + const activity = !wasAlready && !prev.activity.includes(today) + ? [...prev.activity, today] + : prev.activity; + return { ...prev, progress, activity }; + }); + }, [updateState]); const restartPlan = useCallback((progressId) => { updateProgress((prev) => prev.map((p) => ( @@ -87,8 +101,18 @@ export function useReadingPlans() { updateProgress((prev) => prev.filter((p) => p.id !== progressId)); }, [updateProgress]); - // { title, description, days, books: [abbrev, ...], image?: dataUrl } -> new custom plan id - const addCustomPlan = useCallback(({ title, description, days, books, image }) => { + // Resets the pace clock so a plan that's fallen behind schedule stops being + // flagged as "behind" — doesn't touch which days are actually marked done. + const recalibratePace = useCallback((progressId) => { + updateProgress((prev) => prev.map((p) => ( + p.id === progressId + ? { ...p, startedAt: Date.now() - p.completedDays.length * 86400000, lastActivityAt: Date.now() } + : p + ))); + }, [updateProgress]); + + // { title, description, days, books: [abbrev, ...], chapterRanges?, image?: dataUrl } -> new custom plan id + const addCustomPlan = useCallback(({ title, description, days, books, chapterRanges, image }) => { const id = `custom-${makeId()}`; updateCustomPlans((prev) => [ { @@ -100,6 +124,7 @@ export function useReadingPlans() { image: image || undefined, days: Math.max(1, Math.round(days)), books, + chapterRanges: chapterRanges && Object.keys(chapterRanges).length > 0 ? chapterRanges : undefined, custom: true, createdAt: Date.now(), }, @@ -113,10 +138,19 @@ export function useReadingPlans() { updateProgress((prev) => prev.filter((p) => p.planId !== planId)); }, [updateCustomPlans, updateProgress]); - // Patches an existing custom plan (used for setting/changing/removing its cover image). - const updateCustomPlan = useCallback((planId, patch) => { + // Patches an existing custom plan. Pass resetProgress: true when the edit + // changes its books/days (day indices from before the edit no longer mean + // anything), which restarts any in-progress runs of it from day 1. + const updateCustomPlan = useCallback((planId, patch, { resetProgress = false } = {}) => { updateCustomPlans((prev) => prev.map((p) => (p.id === planId ? { ...p, ...patch } : p))); - }, [updateCustomPlans]); + if (resetProgress) { + updateProgress((prev) => prev.map((p) => ( + p.planId === planId + ? { ...p, completedDays: [], completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() } + : p + ))); + } + }, [updateCustomPlans, updateProgress]); // Server wins over whatever's local at sign-in. const mergeServerReadingProgress = useCallback((serverData) => { @@ -124,6 +158,7 @@ export function useReadingPlans() { const next = { progress: Array.isArray(serverData.progress) ? serverData.progress : [], customPlans: Array.isArray(serverData.customPlans) ? serverData.customPlans : [], + activity: Array.isArray(serverData.activity) ? serverData.activity : [], }; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {} setState(next); @@ -132,10 +167,12 @@ export function useReadingPlans() { return { planProgress: state.progress, customPlans: state.customPlans, + readingActivity: state.activity, startPlan, toggleDay, restartPlan, removePlanProgress, + recalibratePace, addCustomPlan, removeCustomPlan, updateCustomPlan, diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 80f805e..4625a9e 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -9,7 +9,7 @@ import { loadProjectIndex, } from '../utils/bibleUtils.jsx'; import { loadProjectById } from '../utils/projectStorage.js'; -import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex } from '../utils/readingPlans.js'; +import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js'; export default function HomePage() { const { @@ -17,6 +17,8 @@ export default function HomePage() { staleLocalProjects, projectIndex, planProgress, + readingActivity, + toggleDay, openReadingPlans, readReadingPlanDay, audioBook, setAudioBook, @@ -153,6 +155,15 @@ export default function HomePage() { )} + {/* Reading plans summary — shown regardless of whether there are any study projects yet */} + + {projectIndex.length === 0 ? (

No projects yet

@@ -241,13 +252,6 @@ export default function HomePage() {
)} - {/* Reading plans summary */} - -
!p.completedAt); + const streak = computeStreak(readingActivity); + + const streakBadge = streak.current > 0 && ( + + 🔥 {streak.current} day{streak.current !== 1 ? 's' : ''} + + ); if (active.length === 0) { return (
-

Reading Plans

-

Read through the Bible, the Gospels, Psalms, and more — one day at a time.

+
+

Reading Plans

+ {streakBadge} +
+

Read through the Bible, the Gospels, Psalms, and more — one day at a time.

+
+
+ {rows.map(({ progress, plan, pct, dayIndex, refs }) => ( +
+
+ {refs ? ( + toggleDay(progress.id, dayIndex, plan.days)} + className="h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" + /> + ) : ( + ✓ + )} + {plan.icon ?? '📖'} +
+

{plan.title}

+

+ {refs ? formatDayReadings(refs) : `${progress.completedDays.length} / ${plan.days} days`}

-
- {nextRefs && ( - - )} + {refs && ( -
+ )}
-
-
+
+
-

{pct}%

- ); - })} + ))} +
); } diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index 50150ee..9ed681c 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -78,6 +78,23 @@ export default function ReaderPage() { .filter(Boolean); }, [planProgress, readerBookAbbrev, readerChapter]); + // Auto-marks matching plan days as read after a dwell period on the chapter, + // so you don't have to remember to tap "Mark read" yourself. Resets whenever + // you navigate to a different book/chapter; a manual unmark isn't re-marked. + const planMatchesRef = useRef(planMatches); + planMatchesRef.current = planMatches; + const [autoMarkedNotice, setAutoMarkedNotice] = useState(''); + useEffect(() => { + const timer = window.setTimeout(() => { + const justMarked = planMatchesRef.current.filter((m) => !m.done); + if (justMarked.length === 0) return; + justMarked.forEach((m) => toggleDay(m.progressId, m.dayIndex, m.plan.days)); + setAutoMarkedNotice(`✓ Marked read — ${justMarked.map((m) => m.plan.title).join(', ')}`); + window.setTimeout(() => setAutoMarkedNotice(''), 4000); + }, 20000); + return () => window.clearTimeout(timer); + }, [readerBookAbbrev, readerChapter, toggleDay]); + const [readerDrawMode, setReaderDrawMode] = useState(false); const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1'); const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES; @@ -354,6 +371,12 @@ export default function ReaderPage() {
+ {autoMarkedNotice && ( +
+ {autoMarkedNotice} +
+ )} + {/* Tool strip: font size · cross-refs · search */}
Text size diff --git a/src/pages/ReadingPlansPage.jsx b/src/pages/ReadingPlansPage.jsx index 6d86953..ba266f0 100644 --- a/src/pages/ReadingPlansPage.jsx +++ b/src/pages/ReadingPlansPage.jsx @@ -10,6 +10,9 @@ import { firstRefOfDay, nextUnreadDayIndex, themeForCategory, + computeStreak, + daysBehindPace, + bookChapterCount, ALL_BOOKS, OT_BOOKS, NT_BOOKS, @@ -23,21 +26,25 @@ export default function ReadingPlansPage() { goHome, planProgress, customPlans, + readingActivity, startPlan, toggleDay, restartPlan, removePlanProgress, + recalibratePace, addCustomPlan, removeCustomPlan, updateCustomPlan, readReadingPlanDay, } = useApp(); - const [showBuilder, setShowBuilder] = useState(false); + const [builderMode, setBuilderMode] = useState(null); // null | 'new' | a custom plan id to edit const activeProgress = planProgress.filter((p) => !p.completedAt); const completedProgress = planProgress .filter((p) => p.completedAt) .sort((a, b) => b.completedAt - a.completedAt); + const streak = computeStreak(readingActivity); + const editingPlan = builderMode && builderMode !== 'new' ? customPlans.find((p) => p.id === builderMode) : null; const categoryOrder = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical']; const builtinCategories = categoryOrder.filter((c) => READING_PLANS.some((p) => p.category === c)); @@ -49,6 +56,12 @@ export default function ReadingPlansPage() {

Bible Study Project

Reading Plans

+ {streak.current > 0 && ( +

+ 🔥 {streak.current} day{streak.current !== 1 ? 's' : ''} in a row + {streak.longest > streak.current && ` · best: ${streak.longest}`} +

+ )}
@@ -85,21 +103,40 @@ export default function ReadingPlansPage() {

My Plans

- {!showBuilder && ( + {builderMode === null && ( )}
- {showBuilder && ( + {builderMode !== null && (
{ addCustomPlan(input); setShowBuilder(false); }} - onCancel={() => setShowBuilder(false)} + key={builderMode} + initialValues={editingPlan} + onSubmit={(values) => { + if (editingPlan) { + const booksChanged = JSON.stringify(values.books) !== JSON.stringify(editingPlan.books); + const daysChanged = values.days !== editingPlan.days; + const rangesChanged = JSON.stringify(values.chapterRanges ?? {}) !== JSON.stringify(editingPlan.chapterRanges ?? {}); + updateCustomPlan(editingPlan.id, { + title: values.title.trim(), + description: values.description.trim(), + days: Math.max(1, Math.round(values.days)), + books: values.books, + chapterRanges: values.chapterRanges && Object.keys(values.chapterRanges).length > 0 ? values.chapterRanges : undefined, + image: values.image || undefined, + }, { resetProgress: booksChanged || daysChanged || rangesChanged }); + } else { + addCustomPlan(values); + } + setBuilderMode(null); + }} + onCancel={() => setBuilderMode(null)} />
)} @@ -111,12 +148,13 @@ export default function ReadingPlansPage() { plan={plan} progressForPlan={planProgress.filter((p) => p.planId === plan.id)} onStart={startPlan} + onEdit={(id) => setBuilderMode(id)} onDelete={(id) => { if (window.confirm('Delete this plan? Its progress history will be removed too.')) removeCustomPlan(id); }} onImageChange={(id, image) => updateCustomPlan(id, { image })} /> ))}
- ) : !showBuilder && ( + ) : builderMode === null && (

You haven't created any plans yet — try "+ New Plan".

)} @@ -180,6 +218,54 @@ export default function ReadingPlansPage() { ); } +// A single combined "what's left to read today, across every active plan" list — +// the fast-glance view; ActivePlanCard below still has full detail/expand/restart. +function TodayPanel({ activeProgress, onRead, onToggleDay }) { + const rows = activeProgress + .map((progress) => { + const plan = getPlan(progress.planId); + if (!plan) return null; + const schedule = getPlanSchedule(progress.planId); + const dayIndex = nextUnreadDayIndex(plan, progress.completedDays); + if (dayIndex == null) return null; + const refs = schedule[dayIndex]; + return { progress, plan, dayIndex, refs, ref: firstRefOfDay(refs) }; + }) + .filter(Boolean); + + if (rows.length === 0) return null; + + return ( +
+

📅 Today

+
+ {rows.map(({ progress, plan, dayIndex, refs, ref }) => ( +
+ onToggleDay(progress.id, dayIndex, plan.days)} + className="h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" + /> + {plan.icon ?? '📖'} +
+

{plan.title}

+

{formatDayReadings(refs)}

+
+ +
+ ))} +
+
+ ); +} + function PlanCover({ plan }) { const [imgFailed, setImgFailed] = useState(false); const theme = themeForCategory(plan.category); @@ -200,7 +286,7 @@ function PlanCover({ plan }) { ); } -function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove }) { +function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove, onRecalibrate }) { const [expanded, setExpanded] = useState(false); const plan = getPlan(progress.planId); if (!plan) return null; @@ -211,6 +297,7 @@ function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove }) const nextDay = nextUnreadDayIndex(plan, progress.completedDays); const nextRefs = nextDay != null ? schedule[nextDay] : null; const nextFirstRef = nextRefs ? firstRefOfDay(nextRefs) : null; + const behind = daysBehindPace(plan, progress); return (
@@ -250,6 +337,19 @@ function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove })

{pct}%

+ {behind >= 2 && ( +
+ 📅 {behind} days behind pace + +
+ )} + {expanded && (
{schedule.map((dayRefs, i) => { @@ -300,7 +400,7 @@ function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove }) ); } -function PlanCatalogCard({ plan, progressForPlan, onStart, onDelete, onImageChange }) { +function PlanCatalogCard({ plan, progressForPlan, onStart, onEdit, onDelete, onImageChange }) { const active = progressForPlan.find((p) => !p.completedAt); const completedCount = progressForPlan.filter((p) => p.completedAt).length; const theme = themeForCategory(plan.category); @@ -325,16 +425,23 @@ function PlanCatalogCard({ plan, progressForPlan, onStart, onDelete, onImageChan

{plan.title}

- {onDelete && ( - - )} +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +

{plan.description || 'A custom reading plan.'}

@@ -403,17 +510,42 @@ const BOOK_PRESETS = [ ['Wisdom', WISDOM_BOOKS], ]; -function CustomPlanBuilder({ onCreate, onCancel }) { - const [title, setTitle] = useState(''); - const [description, setDescription] = useState(''); - const [days, setDays] = useState(30); - const [books, setBooks] = useState([]); - const [image, setImage] = useState(null); +function CustomPlanBuilder({ initialValues, onSubmit, onCancel }) { + const [title, setTitle] = useState(initialValues?.title ?? ''); + const [description, setDescription] = useState(initialValues?.description ?? ''); + const [days, setDays] = useState(initialValues?.days ?? 30); + const [books, setBooks] = useState(initialValues?.books ?? []); + const [chapterRanges, setChapterRanges] = useState(initialValues?.chapterRanges ?? {}); + const [image, setImage] = useState(initialValues?.image ?? null); const [imageError, setImageError] = useState(''); const fileInputRef = useRef(null); + const isEditing = !!initialValues; const toggleBook = (abbrev) => { - setBooks((prev) => (prev.includes(abbrev) ? prev.filter((b) => b !== abbrev) : [...prev, abbrev])); + const isOn = books.includes(abbrev); + setBooks((prev) => (isOn ? prev.filter((b) => b !== abbrev) : [...prev, abbrev])); + if (isOn) { + setChapterRanges((prev) => { + if (!(abbrev in prev)) return prev; + const next = { ...prev }; + delete next[abbrev]; + return next; + }); + } + }; + + const setChapterRange = (abbrev, key, value) => { + const total = CHAPTER_COUNTS[abbrev] ?? 1; + const clamped = Math.min(total, Math.max(1, Math.round(value) || 1)); + setChapterRanges((prev) => ({ + ...prev, + [abbrev]: { start: prev[abbrev]?.start ?? 1, end: prev[abbrev]?.end ?? total, [key]: clamped }, + })); + }; + + const applyPreset = (list) => { + setBooks(list); + setChapterRanges({}); }; const pickImage = async (e) => { @@ -428,12 +560,12 @@ function CustomPlanBuilder({ onCreate, onCancel }) { } }; - const totalChapters = books.reduce((sum, b) => sum + (CHAPTER_COUNTS[b] ?? 0), 0); - const canCreate = title.trim().length > 0 && books.length > 0 && days >= 1; + const totalChapters = books.reduce((sum, b) => sum + bookChapterCount(b, chapterRanges), 0); + const canCreate = title.trim().length > 0 && totalChapters > 0 && days >= 1; return (
-

New reading plan

+

{isEditing ? 'Edit reading plan' : 'New reading plan'}

+

+ Check a book to include the whole thing, or narrow it to a chapter range with the fields that appear. +

-
+

Old Testament

{bookOptions.filter((b) => OT_BOOKS.includes(b.abbrev)).map((b) => ( - + toggleBook(b.abbrev)} + onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)} + /> ))}
-
+

New Testament

{bookOptions.filter((b) => NT_BOOKS.includes(b.abbrev)).map((b) => ( - + toggleBook(b.abbrev)} + onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)} + /> ))}
@@ -539,19 +672,68 @@ function CustomPlanBuilder({ onCreate, onCancel }) { {books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters {days > 0 && totalChapters > 0 && ` · ~${(totalChapters / days).toFixed(1)} ch/day`}

+ {isEditing && ( + books.join() !== initialValues.books.join() + || Number(days) !== initialValues.days + || JSON.stringify(chapterRanges) !== JSON.stringify(initialValues.chapterRanges ?? {}) + ) && ( +

+ Changing the books, chapters, or day count restarts progress for any run of this plan already in your list. +

+ )}
); } + +// One book row in the custom-plan builder: a checkbox, and — once checked — a +// "ch. X–Y" chapter-range pair that defaults to the whole book. +function BookRow({ book, checked, range, onToggle, onRangeChange }) { + const total = CHAPTER_COUNTS[book.abbrev] ?? 1; + return ( + + ); +} diff --git a/src/pages/SettingsPage.jsx b/src/pages/SettingsPage.jsx index 0f5e8f2..e602a93 100644 --- a/src/pages/SettingsPage.jsx +++ b/src/pages/SettingsPage.jsx @@ -7,6 +7,7 @@ import { disableMfa, changePassword, updateProfile, + updateReminderSettings, } from '../syncService.js'; export default function SettingsPage() { @@ -28,6 +29,15 @@ export default function SettingsPage() { const [changePasswordBusy, setChangePasswordBusy] = useState(false); const [changePasswordError, setChangePasswordError] = useState(''); const [changePasswordSaved, setChangePasswordSaved] = useState(false); + const [reminderForm, setReminderForm] = useState({ + reminderEnabled: authUser?.reminderEnabled ?? false, + ntfyUrl: authUser?.ntfyUrl ?? '', + ntfyTopic: authUser?.ntfyTopic ?? '', + reminderTime: authUser?.reminderTime ?? '07:00', + }); + const [reminderBusy, setReminderBusy] = useState(false); + const [reminderError, setReminderError] = useState(''); + const [reminderSaved, setReminderSaved] = useState(false); const startSetup = async () => { setMfaSetupError(''); @@ -107,6 +117,21 @@ export default function SettingsPage() { window.setTimeout(() => setPodcastNameSaved(false), 2500); }; + const submitReminderSettings = async (e) => { + e.preventDefault(); + setReminderError(''); + setReminderBusy(true); + const result = await updateReminderSettings(reminderForm); + setReminderBusy(false); + if (!result.ok) { + setReminderError(result.error ?? 'Failed to save reminder settings.'); + return; + } + setAuthUser((u) => ({ ...u, ...result.data })); + setReminderSaved(true); + window.setTimeout(() => setReminderSaved(false), 2500); + }; + return (
@@ -217,6 +242,71 @@ export default function SettingsPage() { {podcastNameSaved &&

Saved.

} +
+
+

Reading reminders

+

+ Get a daily nudge via{' '} + ntfy.sh + {' '}— a free push-notification service. Install the ntfy app (or open{' '} + {(reminderForm.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '')}/{reminderForm.ntfyTopic || ''} + {' '}in a browser) and subscribe to the topic below to receive it. +

+
+
+ +
+ + +
+ + {reminderError &&

{reminderError}

} + {reminderSaved &&

Saved.

} + +
+
+

Two-factor authentication

diff --git a/src/syncService.js b/src/syncService.js index cf6d98a..7f9c19f 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 }); } +/** 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); +} + // --------------------------------------------------------------------------- // Two-factor auth setup (Account Settings page) // --------------------------------------------------------------------------- diff --git a/src/utils/readingPlans.js b/src/utils/readingPlans.js index adb2b4c..0335a32 100644 --- a/src/utils/readingPlans.js +++ b/src/utils/readingPlans.js @@ -12,15 +12,28 @@ export const PENTATEUCH = ['GEN', 'EXO', 'LEV', 'NUM', 'DEU']; export const WISDOM_BOOKS = ['JOB', 'PSA', 'PRO', 'ECC', 'SNG']; // Flattens an ordered list of book abbreviations into one chapter-by-chapter list. -function chaptersFor(bookAbbrevs) { +// `chapterRanges` (optional) is { [bookAbbrev]: { start, end } } — a custom plan's +// per-book override to read only part of a book instead of the whole thing. +function chaptersFor(bookAbbrevs, chapterRanges) { const list = []; for (const book of bookAbbrevs) { const total = CHAPTER_COUNTS[book] ?? 0; - for (let chapter = 1; chapter <= total; chapter += 1) list.push({ book, chapter }); + const range = chapterRanges?.[book]; + const start = Math.max(1, range?.start ?? 1); + const end = Math.min(total, range?.end ?? total); + for (let chapter = start; chapter <= end; chapter += 1) list.push({ book, chapter }); } return list; } +// Chapter count a book contributes to a plan, honoring a chapter-range override. +export function bookChapterCount(bookAbbrev, chapterRanges) { + const total = CHAPTER_COUNTS[bookAbbrev] ?? 0; + const range = chapterRanges?.[bookAbbrev]; + if (!range) return total; + return Math.max(0, Math.min(total, range.end) - Math.max(1, range.start) + 1); +} + // Splits a flat chapter list into `days` nearly-equal, contiguous slices. function distributeIntoDays(chapters, days) { const total = chapters.length; @@ -187,7 +200,7 @@ export function getPlanSchedule(planId) { if (scheduleCache.has(planId)) return scheduleCache.get(planId); const plan = getPlan(planId); if (!plan) return []; - const schedule = distributeIntoDays(chaptersFor(plan.books), plan.days); + const schedule = distributeIntoDays(chaptersFor(plan.books, plan.chapterRanges), plan.days); scheduleCache.set(planId, schedule); return schedule; } @@ -233,3 +246,51 @@ export function findDayIndexForRef(schedule, bookAbbrev, chapter) { } return -1; } + +// --------------------------------------------------------------------------- +// Streaks & pace — driven by a plain list of 'YYYY-MM-DD' local-date strings +// (one per calendar day something was marked read, across all plans). +// --------------------------------------------------------------------------- + +export function localDateString(ts = Date.now()) { + const d = new Date(ts); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +// { current, longest } consecutive-calendar-day streak. "current" stays alive +// through today even if today hasn't been logged yet (so it doesn't drop to 0 +// the moment you wake up) but breaks once a full day is skipped. +export function computeStreak(activityDates) { + if (!activityDates || activityDates.length === 0) return { current: 0, longest: 0 }; + const daySet = new Set(activityDates); + const DAY_MS = 86400000; + + const sorted = Array.from(daySet).sort(); + let longest = 0; + let run = 0; + let prevTime = null; + for (const ds of sorted) { + const t = new Date(`${ds}T00:00:00`).getTime(); + run = prevTime != null && t - prevTime === DAY_MS ? run + 1 : 1; + longest = Math.max(longest, run); + prevTime = t; + } + + let current = 0; + const cursor = new Date(); + cursor.setHours(0, 0, 0, 0); + if (!daySet.has(localDateString(cursor.getTime()))) cursor.setDate(cursor.getDate() - 1); + while (daySet.has(localDateString(cursor.getTime()))) { + current += 1; + cursor.setDate(cursor.getDate() - 1); + } + return { current, longest }; +} + +// How many days a plan's pace has fallen behind, assuming one plan-day per +// calendar day since it was started. Purely informational — nothing enforces it. +export function daysBehindPace(plan, progress) { + const elapsedDays = Math.floor((Date.now() - progress.startedAt) / 86400000); + const expectedDayIndex = Math.min(plan.days - 1, Math.max(0, elapsedDays)); + return Math.max(0, expectedDayIndex - progress.completedDays.length); +}