Add streaks, today view, custom plan editing/chapter ranges, and ntfy reminders
Build and Push Docker Image / build-and-push (push) Failing after 21s

Reading plans now track a cross-plan activity log for streaks, surface a
combined "today's reading" view on both Home and the Reading Plans page,
and let custom plans be edited after creation (including narrowing a book
to a specific chapter range). The reader auto-marks a plan's day read after
a dwell period, active plans that fall behind their pace get a "catch me
up" banner, and a new Settings section wires up daily reading reminders
via ntfy.sh, checked once a minute by a small server-side scheduler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-23 13:16:27 -04:00
1 parent 600b3d3ab9
commit 1dd6aef6a5
10 files changed
+711 -141

No files matched your search

+42 -1
View File
@@ -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);
+58 -7
View File
@@ -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) {
+52
View File
@@ -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);
}
+68 -31
View File
@@ -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,
+75 -47
View File
@@ -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() {
</div>
</div>
)}
{/* Reading plans summary — shown regardless of whether there are any study projects yet */}
<ReadingPlansSummaryCard
planProgress={planProgress}
readingActivity={readingActivity}
openReadingPlans={openReadingPlans}
readReadingPlanDay={readReadingPlanDay}
toggleDay={toggleDay}
/>
{projectIndex.length === 0 ? (
<div className="mx-auto max-w-xl rounded-3xl border border-dashed border-slate-300 bg-white p-10 text-center shadow-panel">
<p className="text-lg font-semibold text-slate-700">No projects yet</p>
@@ -241,13 +252,6 @@ export default function HomePage() {
</div>
)}
{/* Reading plans summary */}
<ReadingPlansSummaryCard
planProgress={planProgress}
openReadingPlans={openReadingPlans}
readReadingPlanDay={readReadingPlanDay}
/>
<div className="mb-4">
<AudioPlayer
title="Listen to BSB Audio"
@@ -406,15 +410,25 @@ export default function HomePage() {
);
}
function ReadingPlansSummaryCard({ planProgress, openReadingPlans, readReadingPlanDay }) {
function ReadingPlansSummaryCard({ planProgress, readingActivity, openReadingPlans, readReadingPlanDay, toggleDay }) {
const active = planProgress.filter((p) => !p.completedAt);
const streak = computeStreak(readingActivity);
const streakBadge = streak.current > 0 && (
<span className="rounded-full bg-amber-100 px-2.5 py-1 text-xs font-semibold text-amber-700">
🔥 {streak.current} day{streak.current !== 1 ? 's' : ''}
</span>
);
if (active.length === 0) {
return (
<div className="mb-4 flex items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
<div>
<p className="text-sm font-semibold text-slate-700">Reading Plans</p>
<p className="text-xs text-slate-500">Read through the Bible, the Gospels, Psalms, and more — one day at a time.</p>
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-slate-700">Reading Plans</p>
{streakBadge}
</div>
<p className="mt-1 text-xs text-slate-500">Read through the Bible, the Gospels, Psalms, and more — one day at a time.</p>
</div>
<button
type="button"
@@ -427,52 +441,66 @@ function ReadingPlansSummaryCard({ planProgress, openReadingPlans, readReadingPl
);
}
const rows = active
.map((progress) => {
const plan = getPlan(progress.planId);
if (!plan) return null;
const schedule = getPlanSchedule(progress.planId);
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
const dayIndex = nextUnreadDayIndex(plan, progress.completedDays);
const refs = dayIndex != null ? schedule[dayIndex] : null;
return { progress, plan, pct, dayIndex, refs };
})
.filter(Boolean);
return (
<div className="mb-4 space-y-3">
{active.map((progress) => {
const plan = getPlan(progress.planId);
if (!plan) return null;
const schedule = getPlanSchedule(progress.planId);
const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
const nextDay = nextUnreadDayIndex(plan, progress.completedDays);
const nextRefs = nextDay != null ? schedule[nextDay] : null;
return (
<div key={progress.id} className="rounded-3xl border border-emerald-200 bg-emerald-50 p-5 shadow-panel">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600">Reading Plan</p>
<h3 className="mt-1 text-base font-semibold text-slate-900">{plan.title}</h3>
<p className="mt-0.5 text-sm text-slate-600">
{progress.completedDays.length} / {plan.days} days
{nextRefs && <> · Next: {formatDayReadings(nextRefs)}</>}
<div className="mb-4 rounded-3xl border border-indigo-200 bg-indigo-50 p-5 shadow-panel">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-900">📅 Today's Reading</h3>
{streakBadge}
</div>
<button type="button" onClick={openReadingPlans} className="text-xs font-semibold text-indigo-700 hover:underline">
View all plans →
</button>
</div>
<div className="space-y-1.5">
{rows.map(({ progress, plan, pct, dayIndex, refs }) => (
<div key={progress.id} className="rounded-2xl bg-white px-4 py-2.5 shadow-sm">
<div className="flex flex-wrap items-center gap-3">
{refs ? (
<input
type="checkbox"
checked={false}
onChange={() => toggleDay(progress.id, dayIndex, plan.days)}
className="h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
) : (
<span className="shrink-0 text-emerald-500">✓</span>
)}
<span className="shrink-0 text-lg leading-none">{plan.icon ?? '📖'}</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900">{plan.title}</p>
<p className="truncate text-xs text-slate-500">
{refs ? formatDayReadings(refs) : `${progress.completedDays.length} / ${plan.days} days`}
</p>
</div>
<div className="flex items-center gap-2">
{nextRefs && (
<button
type="button"
onClick={() => readReadingPlanDay(nextRefs[0].book, nextRefs[0].chapter)}
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
>
Read
</button>
)}
{refs && (
<button
type="button"
onClick={openReadingPlans}
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
onClick={() => readReadingPlanDay(refs[0].book, refs[0].chapter)}
className="shrink-0 rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
>
View
Read
</button>
</div>
)}
</div>
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-emerald-200">
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} />
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div className="h-full rounded-full bg-indigo-400 transition-all" style={{ width: `${pct}%` }} />
</div>
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
</div>
);
})}
))}
</div>
</div>
);
}
+23
View File
@@ -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() {
</div>
</div>
{autoMarkedNotice && (
<div className="mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-2 text-sm font-semibold text-emerald-700">
{autoMarkedNotice}
</div>
)}
{/* Tool strip: font size · cross-refs · search */}
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-3xl border border-slate-200 bg-white px-4 py-3 shadow-panel">
<span className="text-xs text-slate-500 mr-1">Text size</span>
+234 -52
View File
@@ -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() {
<div>
<p className="text-sm uppercase tracking-[0.24em] text-slate-300">Bible Study Project</p>
<h1 className="mt-2 text-2xl font-semibold">Reading Plans</h1>
{streak.current > 0 && (
<p className="mt-1 text-sm text-amber-300">
🔥 {streak.current} day{streak.current !== 1 ? 's' : ''} in a row
{streak.longest > streak.current && ` · best: ${streak.longest}`}
</p>
)}
</div>
<div className="flex items-center gap-3">
<button
@@ -64,6 +77,10 @@ export default function ReadingPlansPage() {
</header>
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
{activeProgress.length > 0 && (
<TodayPanel activeProgress={activeProgress} onRead={readReadingPlanDay} onToggleDay={toggleDay} />
)}
{activeProgress.length > 0 && (
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900">Continue Reading</h2>
@@ -76,6 +93,7 @@ export default function ReadingPlansPage() {
onToggleDay={toggleDay}
onRestart={restartPlan}
onRemove={removePlanProgress}
onRecalibrate={recalibratePace}
/>
))}
</div>
@@ -85,21 +103,40 @@ export default function ReadingPlansPage() {
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900">My Plans</h2>
{!showBuilder && (
{builderMode === null && (
<button
type="button"
onClick={() => setShowBuilder(true)}
onClick={() => setBuilderMode('new')}
className="rounded-xl bg-rose-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-rose-500"
>
+ New Plan
</button>
)}
</div>
{showBuilder && (
{builderMode !== null && (
<div className="mb-4">
<CustomPlanBuilder
onCreate={(input) => { 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)}
/>
</div>
)}
@@ -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 })}
/>
))}
</div>
) : !showBuilder && (
) : builderMode === null && (
<p className="text-sm text-slate-500">You haven't created any plans yet — try "+ New Plan".</p>
)}
</section>
@@ -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 (
<section className="rounded-3xl border border-indigo-200 bg-indigo-50 p-5 shadow-panel">
<h2 className="mb-3 text-lg font-semibold text-slate-900">📅 Today</h2>
<div className="space-y-1.5">
{rows.map(({ progress, plan, dayIndex, refs, ref }) => (
<div key={progress.id} className="flex flex-wrap items-center gap-3 rounded-2xl bg-white px-4 py-2.5 shadow-sm">
<input
type="checkbox"
checked={false}
onChange={() => onToggleDay(progress.id, dayIndex, plan.days)}
className="h-4 w-4 shrink-0 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
<span className="shrink-0 text-lg leading-none">{plan.icon ?? '📖'}</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900">{plan.title}</p>
<p className="truncate text-xs text-slate-500">{formatDayReadings(refs)}</p>
</div>
<button
type="button"
onClick={() => onRead(ref.book, ref.chapter)}
className="shrink-0 rounded-xl bg-indigo-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500"
>
Read
</button>
</div>
))}
</div>
</section>
);
}
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 (
<div className="overflow-hidden rounded-3xl border border-emerald-200 bg-emerald-50 shadow-panel">
@@ -250,6 +337,19 @@ function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove })
</div>
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
{behind >= 2 && (
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs">
<span className="text-amber-700">📅 {behind} days behind pace</span>
<button
type="button"
onClick={() => onRecalibrate(progress.id)}
className="font-semibold text-amber-800 hover:underline"
>
Catch me up
</button>
</div>
)}
{expanded && (
<div className="mt-3 max-h-72 overflow-y-auto rounded-2xl border border-emerald-100 bg-white p-2">
{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
<div className="flex flex-col gap-2 p-5">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-slate-900">{plan.title}</h3>
{onDelete && (
<button
type="button"
onClick={() => onDelete(plan.id)}
title="Delete plan"
className="shrink-0 text-slate-300 transition hover:text-rose-500"
>
✕
</button>
)}
<div className="flex shrink-0 items-center gap-2">
{onEdit && (
<button type="button" onClick={() => onEdit(plan.id)} title="Edit plan" className="text-slate-300 transition hover:text-slate-600">
✎
</button>
)}
{onDelete && (
<button
type="button"
onClick={() => onDelete(plan.id)}
title="Delete plan"
className="text-slate-300 transition hover:text-rose-500"
>
✕
</button>
)}
</div>
</div>
<p className="flex-1 text-xs text-slate-500">{plan.description || 'A custom reading plan.'}</p>
<div className="flex flex-wrap items-center gap-2">
@@ -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 (
<div className="rounded-2xl border border-rose-200 bg-white p-5 shadow-panel">
<h3 className="mb-3 text-sm font-semibold text-slate-900">New reading plan</h3>
<h3 className="mb-3 text-sm font-semibold text-slate-900">{isEditing ? 'Edit reading plan' : 'New reading plan'}</h3>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm text-slate-600">
Title
@@ -498,40 +630,41 @@ function CustomPlanBuilder({ onCreate, onCancel }) {
<button
key={label}
type="button"
onClick={() => setBooks(list)}
onClick={() => applyPreset(list)}
className="rounded-full border border-slate-300 px-2.5 py-1 text-xs font-semibold text-slate-600 hover:bg-slate-50"
>
{label}
</button>
))}
</div>
<p className="mb-2 text-xs text-slate-400">
Check a book to include the whole thing, or narrow it to a chapter range with the fields that appear.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="max-h-48 overflow-y-auto rounded-xl border border-slate-200 p-2">
<div className="max-h-56 overflow-y-auto rounded-xl border border-slate-200 p-2">
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-slate-400">Old Testament</p>
{bookOptions.filter((b) => OT_BOOKS.includes(b.abbrev)).map((b) => (
<label key={b.abbrev} className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-slate-50">
<input
type="checkbox"
checked={books.includes(b.abbrev)}
onChange={() => toggleBook(b.abbrev)}
className="h-4 w-4 rounded border-slate-300 text-rose-600 focus:ring-rose-500"
/>
{b.name}
</label>
<BookRow
key={b.abbrev}
book={b}
checked={books.includes(b.abbrev)}
range={chapterRanges[b.abbrev]}
onToggle={() => toggleBook(b.abbrev)}
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
/>
))}
</div>
<div className="max-h-48 overflow-y-auto rounded-xl border border-slate-200 p-2">
<div className="max-h-56 overflow-y-auto rounded-xl border border-slate-200 p-2">
<p className="mb-1 px-1 text-xs font-semibold uppercase tracking-wide text-slate-400">New Testament</p>
{bookOptions.filter((b) => NT_BOOKS.includes(b.abbrev)).map((b) => (
<label key={b.abbrev} className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-slate-50">
<input
type="checkbox"
checked={books.includes(b.abbrev)}
onChange={() => toggleBook(b.abbrev)}
className="h-4 w-4 rounded border-slate-300 text-rose-600 focus:ring-rose-500"
/>
{b.name}
</label>
<BookRow
key={b.abbrev}
book={b}
checked={books.includes(b.abbrev)}
range={chapterRanges[b.abbrev]}
onToggle={() => toggleBook(b.abbrev)}
onRangeChange={(key, value) => setChapterRange(b.abbrev, key, value)}
/>
))}
</div>
</div>
@@ -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`}
</p>
{isEditing && (
books.join() !== initialValues.books.join()
|| Number(days) !== initialValues.days
|| JSON.stringify(chapterRanges) !== JSON.stringify(initialValues.chapterRanges ?? {})
) && (
<p className="mt-2 text-xs text-amber-600">
Changing the books, chapters, or day count restarts progress for any run of this plan already in your list.
</p>
)}
</div>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
disabled={!canCreate}
onClick={() => onCreate({ title, description, days, books, image })}
onClick={() => onSubmit({ title, description, days, books, chapterRanges, image })}
className="rounded-xl bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-500 disabled:cursor-not-allowed disabled:opacity-50"
>
Create plan
{isEditing ? 'Save changes' : 'Create plan'}
</button>
<button type="button" onClick={onCancel} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
</div>
</div>
);
}
// 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 (
<label className="flex items-center gap-2 rounded-lg px-1 py-1 text-sm hover:bg-slate-50">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="h-4 w-4 shrink-0 rounded border-slate-300 text-rose-600 focus:ring-rose-500"
/>
<span className="flex-1">{book.name}</span>
{checked && total > 1 && (
<span className="flex shrink-0 items-center gap-1 text-xs text-slate-500" onClick={(e) => e.preventDefault()}>
ch.
<input
type="number"
min={1}
max={total}
value={range?.start ?? 1}
onChange={(e) => onRangeChange('start', e.target.value)}
className="w-11 rounded border border-slate-300 px-1 py-0.5 text-center"
/>
–
<input
type="number"
min={1}
max={total}
value={range?.end ?? total}
onChange={(e) => onRangeChange('end', e.target.value)}
className="w-11 rounded border border-slate-300 px-1 py-0.5 text-center"
/>
<span className="text-slate-400">/{total}</span>
</span>
)}
</label>
);
}
+90
View File
@@ -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 (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-slate-900 text-white shadow-sm">
@@ -217,6 +242,71 @@ export default function SettingsPage() {
{podcastNameSaved && <p className="text-sm text-emerald-600">Saved.</p>}
</section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Reading reminders</h2>
<p className="text-sm text-slate-500">
Get a daily nudge via{' '}
<a href="https://ntfy.sh" target="_blank" rel="noreferrer" className="text-sky-600 underline">ntfy.sh</a>
{' '}— a free push-notification service. Install the ntfy app (or open{' '}
<span className="font-mono text-xs">{(reminderForm.ntfyUrl || 'https://ntfy.sh').replace(/\/+$/, '')}/{reminderForm.ntfyTopic || '<topic>'}</span>
{' '}in a browser) and subscribe to the topic below to receive it.
</p>
</div>
<form onSubmit={submitReminderSettings} className="space-y-3">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input
type="checkbox"
checked={reminderForm.reminderEnabled}
onChange={(e) => setReminderForm((f) => ({ ...f, reminderEnabled: e.target.checked }))}
className="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
/>
Enable daily reading reminder
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block max-w-xs text-sm font-medium text-slate-700">
Topic
<input
type="text"
placeholder="e.g. nate-bible-reading-8f2"
value={reminderForm.ntfyTopic}
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyTopic: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
<span className="mt-1 block text-xs text-slate-400">Pick something unique — anyone who knows it can subscribe.</span>
</label>
<label className="block max-w-xs text-sm font-medium text-slate-700">
Reminder time
<input
type="time"
value={reminderForm.reminderTime}
onChange={(e) => setReminderForm((f) => ({ ...f, reminderTime: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
</div>
<label className="block max-w-xs text-sm font-medium text-slate-700">
ntfy server <span className="text-slate-400">(optional — defaults to ntfy.sh)</span>
<input
type="text"
placeholder="https://ntfy.sh"
value={reminderForm.ntfyUrl}
onChange={(e) => setReminderForm((f) => ({ ...f, ntfyUrl: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
/>
</label>
{reminderError && <p className="text-sm text-rose-600">{reminderError}</p>}
{reminderSaved && <p className="text-sm text-emerald-600">Saved.</p>}
<button
type="submit"
disabled={reminderBusy}
className="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-sky-400 disabled:cursor-not-allowed disabled:bg-slate-300"
>
{reminderBusy ? 'Saving…' : 'Save'}
</button>
</form>
</section>
<section className="rounded-3xl border border-slate-200 bg-white p-8 shadow-panel space-y-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">Two-factor authentication</h2>
+5
View File
@@ -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)
// ---------------------------------------------------------------------------
+64 -3
View File
@@ -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);
}