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.