Add reading plans: catalog, progress tracking, and custom plans

Replaces the old single-book reading goal with a full Reading Plans page:
9 built-in plans (Whole Bible, OT/NT, topical) with cover photos and
color-themed cards, day-by-day checklists synced to the server per user,
inline "mark today read" in the Bible reader, and user-created plans with
their own book picker and an optional uploaded cover photo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-23 12:51:28 -04:00
1 parent f1026dc194
commit 600b3d3ab9
21 files changed
+1166 -159

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
Drop a cover photo here for each built-in reading plan, named exactly:
bible-year.jpg Whole Bible in a Year
bible-90.jpg Whole Bible in 90 Days
nt-90.jpg New Testament in 90 Days
nt-30.jpg New Testament in 30 Days
gospels-30.jpg The Gospels in 30 Days
psalms-30.jpg Psalms in 30 Days
proverbs-31.jpg Proverbs in 31 Days
pentateuch-60.jpg The Pentateuch in 60 Days
wisdom-40.jpg Wisdom Literature in 40 Days
Landscape orientation, ~1200px wide, works best (cards crop to a 16:9 box).
Any plan without a matching file just shows its colorful gradient + icon —
nothing breaks if some are missing.
See src/utils/readingPlans.js -> PIXABAY_SEARCH_LINKS for a Pixabay search
link per plan to help pick one.
Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

+34
View File
@@ -81,6 +81,16 @@ export function initDb() {
); );
`); `);
// Reading plan progress — one row per user, holding the whole list of
// in-progress/completed plans as JSON (small; no need to normalize).
db.exec(`
CREATE TABLE IF NOT EXISTS reading_progress (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
console.log(`SQLite database ready at ${DB_PATH}`); console.log(`SQLite database ready at ${DB_PATH}`);
} }
@@ -182,6 +192,30 @@ export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
`).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now()); `).run(userId, bookAbbrev, Number(chapter), JSON.stringify(strokes), Date.now());
} }
// ---------------------------------------------------------------------------
// Reading plan progress — cross-device sync for started/completed reading plans
// ---------------------------------------------------------------------------
/** Returns the user's reading-plan data ({ progress, customPlans }, parsed JSON), or an empty shell. */
export function getReadingProgress(userId) {
const row = db.prepare('SELECT data FROM reading_progress WHERE user_id = ?').get(userId);
if (!row) return { progress: [], customPlans: [] };
try {
return JSON.parse(row.data);
} catch {
return { progress: [], customPlans: [] };
}
}
/** Overwrites the user's whole reading-plan progress list. */
export function setReadingProgress(userId, progress) {
db.prepare(`
INSERT INTO reading_progress (user_id, data, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(userId, JSON.stringify(progress), Date.now());
}
/** /**
* Assigns any pre-existing, unowned projects (from before multi-user support) * Assigns any pre-existing, unowned projects (from before multi-user support)
* to the given user. Intended to run once, right after the first account is created. * to the given user. Intended to run once, right after the first account is created.
+30
View File
@@ -13,6 +13,7 @@ import {
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject, adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
setUserPassword, destroyAllSessionsForUser, setUserPassword, destroyAllSessionsForUser,
getAllReaderInk, setReaderInkPage, getAllReaderInk, setReaderInkPage,
getReadingProgress, setReadingProgress,
} from './db.js'; } from './db.js';
import { SqliteSessionStore } from './sessionStore.js'; import { SqliteSessionStore } from './sessionStore.js';
import { import {
@@ -394,6 +395,35 @@ app.put('/api/reader/ink/:book/:chapter', requireAuth, (req, res) => {
} }
}); });
// ---------------------------------------------------------------------------
// Reading plan progress — cross-device sync for started/completed reading plans
// ---------------------------------------------------------------------------
// GET /api/reading-plans — the current user's whole progress list
app.get('/api/reading-plans', requireAuth, (req, res) => {
try {
res.json(getReadingProgress(req.session.userId));
} catch (err) {
console.error('GET /api/reading-plans error:', err);
res.status(500).json({ error: 'Failed to load reading plan progress.' });
}
});
// PUT /api/reading-plans — overwrite the current user's whole progress list
// (an object: { progress: [...], customPlans: [...] })
app.put('/api/reading-plans', requireAuth, (req, res) => {
try {
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
return res.status(400).json({ error: 'Body must be an object.' });
}
setReadingProgress(req.session.userId, req.body);
res.json({ ok: true });
} catch (err) {
console.error('PUT /api/reading-plans error:', err);
res.status(500).json({ error: 'Failed to save reading plan progress.' });
}
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Read-only share links // Read-only share links
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+21 -5
View File
@@ -4,10 +4,10 @@ import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js'; import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js'; import { useBibleReader } from './hooks/useBibleReader.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js'; import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useReadingPlan } from './hooks/useReadingPlan.js'; import { useReadingPlans } from './hooks/useReadingPlans.js';
import { useSharedView } from './hooks/useSharedView.js'; import { useSharedView } from './hooks/useSharedView.js';
import HomePage from './pages/HomePage.jsx'; import HomePage from './pages/HomePage.jsx';
import { saveRemoteProject } from './syncService.js'; import { getRemoteReadingProgress, saveRemoteProject } from './syncService.js';
import { buildExportHtml, loadProjectIndex, switchStorageUser } from './utils/bibleUtils.jsx'; import { buildExportHtml, loadProjectIndex, switchStorageUser } from './utils/bibleUtils.jsx';
import { migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js'; import { migrateOldStorageKeys, saveProjectToStorage } from './utils/projectStorage.js';
@@ -21,6 +21,7 @@ const SettingsPage = lazy(() => import('./pages/SettingsPage.jsx'));
const SetupPage = lazy(() => import('./pages/SetupPage.jsx')); const SetupPage = lazy(() => import('./pages/SetupPage.jsx'));
const StudyPage = lazy(() => import('./pages/StudyPage.jsx')); const StudyPage = lazy(() => import('./pages/StudyPage.jsx'));
const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx')); const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx'));
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// App component // App component
@@ -28,7 +29,7 @@ const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx'));
const App = () => { const App = () => {
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
// 'home' | 'setup' | 'study' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' // 'home' | 'setup' | 'study' | 'reader' | 'import' | 'pronunciation' | 'settings' | 'admin' | 'reading-plans'
const [currentPage, setCurrentPage] = useState('home'); const [currentPage, setCurrentPage] = useState('home');
const [projectIndex, setProjectIndex] = useState([]); const [projectIndex, setProjectIndex] = useState([]);
const [saveStatus, setSaveStatus] = useState(''); const [saveStatus, setSaveStatus] = useState('');
@@ -36,7 +37,7 @@ const App = () => {
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
const reader = useBibleReader(currentPage); const reader = useBibleReader(currentPage);
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter); const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
const readingPlan = useReadingPlan(); const readingPlans = useReadingPlans();
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({ const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
onSignIn: (user) => { onSignIn: (user) => {
setProjectIndex(switchStorageUser(user.id)); setProjectIndex(switchStorageUser(user.id));
@@ -45,6 +46,10 @@ const App = () => {
.then((r) => r.ok ? r.json() : null) .then((r) => r.ok ? r.json() : null)
.then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); }) .then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); })
.catch(() => {}); .catch(() => {});
// Same for reading plan progress
getRemoteReadingProgress().then((result) => {
if (result.ok) readingPlans.mergeServerReadingProgress(result.data);
});
}, },
onSignOut: () => { onSignOut: () => {
setProjectIndex(switchStorageUser(null)); setProjectIndex(switchStorageUser(null));
@@ -110,6 +115,14 @@ const App = () => {
// Navigation // Navigation
const openBibleReader = () => setCurrentPage('reader'); const openBibleReader = () => setCurrentPage('reader');
const openReadingPlans = () => setCurrentPage('reading-plans');
// Jumps straight into a specific plan day's first chapter and opens the reader.
const readReadingPlanDay = (bookAbbrev, chapter) => {
reader.jumpToReaderVerse(bookAbbrev, chapter, 1);
setCurrentPage('reader');
};
const openNewProject = () => { const openNewProject = () => {
setProject(null); setProject(null);
setCurrentPage('setup'); setCurrentPage('setup');
@@ -155,8 +168,10 @@ const App = () => {
// Home // Home
projectIndex, projectIndex,
...library, ...library,
...readingPlan, ...readingPlans,
openBibleReader, openBibleReader,
openReadingPlans,
readReadingPlanDay,
openImportProject, openImportProject,
openNewProject, openNewProject,
// Audio (book player on Home + chapter player in the Reader) // Audio (book player on Home + chapter player in the Reader)
@@ -238,6 +253,7 @@ function AppRouter() {
if (currentPage === 'admin') return <AdminPage />; if (currentPage === 'admin') return <AdminPage />;
if (currentPage === 'settings') return <SettingsPage />; if (currentPage === 'settings') return <SettingsPage />;
if (currentPage === 'reading-plans') return <ReadingPlansPage />;
if (currentPage === 'reader') return <ReaderPage />; if (currentPage === 'reader') return <ReaderPage />;
if (currentPage === 'import') return <ImportPage />; if (currentPage === 'import') return <ImportPage />;
if (currentPage === 'study') return <StudyPage />; if (currentPage === 'study') return <StudyPage />;
+8 -5
View File
@@ -580,13 +580,16 @@ describe('Home page', () => {
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument(); expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
}); });
test('a reading plan can be started from Home', async () => { test('a reading plan can be started from the Reading Plans page', async () => {
seedProject(); // the plan card shows once there's at least one project seedProject(); // the nav button shows once there's at least one project
const user = userEvent.setup(); const user = userEvent.setup();
await renderApp(); await renderApp();
await user.click(screen.getByRole('button', { name: /set goal/i })); await user.click(screen.getByRole('button', { name: /reading plans/i }));
await user.click(screen.getByRole('button', { name: /start plan/i })); await user.click((await screen.findAllByRole('button', { name: /^start plan$/i }))[0]);
await waitFor(() => expect(JSON.parse(localStorage.getItem('readingPlan'))?.bookAbbrev).toBe('GEN')); await waitFor(() => {
const stored = JSON.parse(localStorage.getItem('readingPlanProgress'));
expect(stored?.progress?.[0]?.planId).toBeTruthy();
});
}); });
}); });
-35
View File
@@ -1,35 +0,0 @@
// A simple "read this book in N weeks" plan, stored per browser.
import { useState } from 'react';
import { CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
export function useReadingPlan() {
const [readingPlan, setReadingPlan] = useState(() => {
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
});
const createReadingPlan = (bookAbbrev, bookName, weeks) => {
const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1;
const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] };
setReadingPlan(plan);
try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {}
};
const markChapterRead = (chapter) => {
setReadingPlan((prev) => {
if (!prev) return prev;
const chaptersRead = prev.chaptersRead.includes(chapter)
? prev.chaptersRead.filter((c) => c !== chapter)
: [...prev.chaptersRead, chapter].sort((a, b) => a - b);
const next = { ...prev, chaptersRead };
try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {}
return next;
});
};
const clearReadingPlan = () => {
setReadingPlan(null);
try { localStorage.removeItem('readingPlan'); } catch {}
};
return { readingPlan, createReadingPlan, markChapterRead, clearReadingPlan };
}
+144
View File
@@ -0,0 +1,144 @@
// 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.
import { useCallback, useState } from 'react';
import { saveRemoteReadingProgress } from '../syncService.js';
import { 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 ?? [] };
} catch {
return { progress: [], customPlans: [] };
}
}
function persist(next) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
saveRemoteReadingProgress(next); // fire-and-forget; errors are silent, local copy is already safe
}
const makeId = () => (crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`);
export function useReadingPlans() {
const [state, setState] = useState(loadLocal);
// Keeps the readingPlans.js lookup (getPlan/getPlanSchedule) in sync with
// this user's custom plans before any consumer renders this commit.
registerCustomPlans(state.customPlans);
const updateProgress = useCallback((updater) => {
setState((prev) => {
const next = { ...prev, progress: updater(prev.progress) };
persist(next);
return next;
});
}, []);
const updateCustomPlans = useCallback((updater) => {
setState((prev) => {
const next = { ...prev, customPlans: updater(prev.customPlans) };
persist(next);
return next;
});
}, []);
const startPlan = useCallback((planId) => {
updateProgress((prev) => {
if (prev.some((p) => p.planId === planId && !p.completedAt)) return prev;
const record = {
id: makeId(),
planId,
startedAt: Date.now(),
completedDays: [],
completedAt: null,
lastActivityAt: Date.now(),
};
return [record, ...prev];
});
}, [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]);
const restartPlan = useCallback((progressId) => {
updateProgress((prev) => prev.map((p) => (
p.id === progressId
? { ...p, completedDays: [], completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
: p
)));
}, [updateProgress]);
const removePlanProgress = useCallback((progressId) => {
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 }) => {
const id = `custom-${makeId()}`;
updateCustomPlans((prev) => [
{
id,
title: title.trim(),
description: (description ?? '').trim(),
category: 'My Plans',
icon: '✨',
image: image || undefined,
days: Math.max(1, Math.round(days)),
books,
custom: true,
createdAt: Date.now(),
},
...prev,
]);
return id;
}, [updateCustomPlans]);
const removeCustomPlan = useCallback((planId) => {
updateCustomPlans((prev) => prev.filter((p) => p.id !== planId));
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) => {
updateCustomPlans((prev) => prev.map((p) => (p.id === planId ? { ...p, ...patch } : p)));
}, [updateCustomPlans]);
// Server wins over whatever's local at sign-in.
const mergeServerReadingProgress = useCallback((serverData) => {
if (!serverData || typeof serverData !== 'object' || Array.isArray(serverData)) return;
const next = {
progress: Array.isArray(serverData.progress) ? serverData.progress : [],
customPlans: Array.isArray(serverData.customPlans) ? serverData.customPlans : [],
};
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
setState(next);
}, []);
return {
planProgress: state.progress,
customPlans: state.customPlans,
startPlan,
toggleDay,
restartPlan,
removePlanProgress,
addCustomPlan,
removeCustomPlan,
updateCustomPlan,
mergeServerReadingProgress,
};
}
+76 -99
View File
@@ -4,21 +4,21 @@ import AuthStatus from '../components/AuthStatus.jsx';
import AudioPlayer from '../components/AudioPlayer.jsx'; import AudioPlayer from '../components/AudioPlayer.jsx';
import { import {
bookOptions, bookOptions,
CHAPTER_COUNTS,
formatChunkReference, formatChunkReference,
formatRelativeDate, formatRelativeDate,
loadProjectIndex, loadProjectIndex,
} from '../utils/bibleUtils.jsx'; } from '../utils/bibleUtils.jsx';
import { loadProjectById } from '../utils/projectStorage.js'; import { loadProjectById } from '../utils/projectStorage.js';
import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex } from '../utils/readingPlans.js';
export default function HomePage() { export default function HomePage() {
const { const {
autoRestoredCount, autoRestoredCount,
staleLocalProjects, staleLocalProjects,
projectIndex, projectIndex,
readingPlan, planProgress,
createReadingPlan, openReadingPlans,
clearReadingPlan, readReadingPlanDay,
audioBook, setAudioBook, audioBook, setAudioBook,
audioNarrator, setAudioNarrator, audioNarrator, setAudioNarrator,
audioState, audioState,
@@ -99,6 +99,14 @@ export default function HomePage() {
> >
📖 Read Bible 📖 Read Bible
</button> </button>
<button
type="button"
onClick={openReadingPlans}
className="rounded-xl border border-slate-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700"
>
<span className="hidden sm:inline">📅 Reading Plans</span>
<span className="sm:hidden">📅 Plans</span>
</button>
<button <button
type="button" type="button"
onClick={openImportProject} onClick={openImportProject}
@@ -233,8 +241,12 @@ export default function HomePage() {
</div> </div>
)} )}
{/* Reading plan card */} {/* Reading plans summary */}
<ReadingPlanCard readingPlan={readingPlan} createReadingPlan={createReadingPlan} clearReadingPlan={clearReadingPlan} openBibleReader={openBibleReader} /> <ReadingPlansSummaryCard
planProgress={planProgress}
openReadingPlans={openReadingPlans}
readReadingPlanDay={readReadingPlanDay}
/>
<div className="mb-4"> <div className="mb-4">
<AudioPlayer <AudioPlayer
@@ -394,108 +406,73 @@ export default function HomePage() {
); );
} }
function ReadingPlanCard({ readingPlan, createReadingPlan, clearReadingPlan, openBibleReader }) { function ReadingPlansSummaryCard({ planProgress, openReadingPlans, readReadingPlanDay }) {
const [showForm, setShowForm] = useState(false); const active = planProgress.filter((p) => !p.completedAt);
const [planBook, setPlanBook] = useState(bookOptions[0].abbrev);
const [planWeeks, setPlanWeeks] = useState(4);
if (readingPlan) { if (active.length === 0) {
const pct = readingPlan.totalChapters > 0
? Math.round((readingPlan.chaptersRead.length / readingPlan.totalChapters) * 100)
: 0;
const daysLeft = Math.max(0, Math.ceil((readingPlan.targetDate - Date.now()) / 86400000));
const chapLeft = readingPlan.totalChapters - readingPlan.chaptersRead.length;
const paceNeeded = daysLeft > 0 ? (chapLeft / daysLeft).toFixed(1) : '—';
return ( return (
<div className="mb-4 rounded-3xl border border-emerald-200 bg-emerald-50 p-5 shadow-panel"> <div className="mb-4 flex items-center justify-between gap-3 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel">
<div className="flex items-start justify-between gap-3"> <div>
<div> <p className="text-sm font-semibold text-slate-700">Reading Plans</p>
<p className="text-xs font-semibold uppercase tracking-widest text-emerald-600">Reading Plan</p> <p className="text-xs text-slate-500">Read through the Bible, the Gospels, Psalms, and more — one day at a time.</p>
<h3 className="mt-1 text-base font-semibold text-slate-900">{readingPlan.bookName}</h3>
<p className="mt-0.5 text-sm text-slate-600">
{readingPlan.chaptersRead.length} / {readingPlan.totalChapters} chapters · {daysLeft} day{daysLeft !== 1 ? 's' : ''} left · {paceNeeded} ch/day needed
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={openBibleReader}
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
>
Read
</button>
<button
type="button"
onClick={() => { if (window.confirm('Clear reading plan?')) clearReadingPlan(); }}
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
>
Clear
</button>
</div>
</div> </div>
<div className="mt-3 h-2.5 w-full overflow-hidden rounded-full bg-emerald-200"> <button
<div className="h-full rounded-full bg-emerald-500 transition-all" style={{ width: `${pct}%` }} /> type="button"
</div> onClick={openReadingPlans}
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p> className="shrink-0 rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
{readingPlan.chaptersRead.length > 0 && ( >
<p className="mt-1 text-xs text-slate-500"> Browse plans
Read: ch. {readingPlan.chaptersRead.slice(0, 12).join(', ')}{readingPlan.chaptersRead.length > 12 ? '…' : ''} </button>
</p>
)}
</div> </div>
); );
} }
return ( return (
<div className="mb-4 rounded-3xl border border-slate-200 bg-white p-5 shadow-panel"> <div className="mb-4 space-y-3">
{showForm ? ( {active.map((progress) => {
<div className="flex flex-wrap items-center gap-3"> const plan = getPlan(progress.planId);
<span className="text-sm font-semibold text-slate-700">Read</span> if (!plan) return null;
<select const schedule = getPlanSchedule(progress.planId);
value={planBook} const pct = plan.days > 0 ? Math.round((progress.completedDays.length / plan.days) * 100) : 0;
onChange={(e) => setPlanBook(e.target.value)} const nextDay = nextUnreadDayIndex(plan, progress.completedDays);
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" const nextRefs = nextDay != null ? schedule[nextDay] : null;
> return (
{bookOptions.map((b) => ( <div key={progress.id} className="rounded-3xl border border-emerald-200 bg-emerald-50 p-5 shadow-panel">
<option key={b.abbrev} value={b.abbrev}>{b.name} ({CHAPTER_COUNTS[b.abbrev] ?? '?'} ch)</option> <div className="flex items-start justify-between gap-3">
))} <div>
</select> <p className="text-xs font-semibold uppercase tracking-widest text-emerald-600">Reading Plan</p>
<span className="text-sm text-slate-600">in</span> <h3 className="mt-1 text-base font-semibold text-slate-900">{plan.title}</h3>
<select <p className="mt-0.5 text-sm text-slate-600">
value={planWeeks} {progress.completedDays.length} / {plan.days} days
onChange={(e) => setPlanWeeks(Number(e.target.value))} {nextRefs && <> · Next: {formatDayReadings(nextRefs)}</>}
className="rounded-xl border border-slate-300 bg-slate-50 px-2 py-1.5 text-sm text-slate-900 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" </p>
> </div>
{[1,2,3,4,6,8,12,16,26,52].map((w) => <option key={w} value={w}>{w} week{w !== 1 ? 's' : ''}</option>)} <div className="flex items-center gap-2">
</select> {nextRefs && (
<button <button
type="button" type="button"
onClick={() => { onClick={() => readReadingPlanDay(nextRefs[0].book, nextRefs[0].chapter)}
const book = bookOptions.find((b) => b.abbrev === planBook); className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
createReadingPlan(planBook, book?.name ?? planBook, planWeeks); >
setShowForm(false); Read
}} </button>
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500" )}
> <button
Start plan type="button"
</button> onClick={openReadingPlans}
<button type="button" onClick={() => setShowForm(false)} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button> className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
</div> >
) : ( View
<div className="flex items-center justify-between gap-3"> </button>
<div> </div>
<p className="text-sm font-semibold text-slate-700">Reading Plan</p> </div>
<p className="text-xs text-slate-500">Set a goal to read through a book, track chapters as you go.</p> <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>
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
</div> </div>
<button );
type="button" })}
onClick={() => setShowForm(true)}
className="shrink-0 rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-700 hover:bg-slate-50"
>
Set goal
</button>
</div>
)}
</div> </div>
); );
} }
+29 -15
View File
@@ -4,6 +4,7 @@ import { formatCrossRef, speakOriginalWord } from '../utils/wordStudy.js';
import AuthStatus from '../components/AuthStatus.jsx'; import AuthStatus from '../components/AuthStatus.jsx';
import AudioPlayer from '../components/AudioPlayer.jsx'; import AudioPlayer from '../components/AudioPlayer.jsx';
import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx'; import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx'; import DrawCanvas from './DrawCanvas.jsx';
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
@@ -57,11 +58,26 @@ export default function ReaderPage() {
readerTextHighlights, readerTextHighlights,
addTextHighlight, addTextHighlight,
removeTextHighlight, removeTextHighlight,
readingPlan, planProgress,
markChapterRead, toggleDay,
} = useApp(); } = useApp();
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev); const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
// Active reading plans whose schedule includes the chapter currently on screen.
const planMatches = useMemo(() => {
return planProgress
.filter((p) => !p.completedAt)
.map((p) => {
const plan = getPlan(p.planId);
if (!plan) return null;
const dayIndex = findDayIndexForRef(getPlanSchedule(p.planId), readerBookAbbrev, readerChapter);
if (dayIndex === -1) return null;
return { progressId: p.id, plan, dayIndex, done: p.completedDays.includes(dayIndex) };
})
.filter(Boolean);
}, [planProgress, readerBookAbbrev, readerChapter]);
const [readerDrawMode, setReaderDrawMode] = useState(false); const [readerDrawMode, setReaderDrawMode] = useState(false);
const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1'); const [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1');
const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES; const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES;
@@ -316,19 +332,17 @@ export default function ReaderPage() {
</select> </select>
</label> </label>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
{readingPlan && readingPlan.bookAbbrev === readerBookAbbrev && (() => { {planMatches.map(({ progressId, plan, dayIndex, done }) => (
const done = readingPlan.chaptersRead.includes(readerChapter); <button
return ( key={progressId}
<button type="button"
type="button" onClick={() => toggleDay(progressId, dayIndex, plan.days)}
onClick={() => markChapterRead(readerChapter)} className={`rounded-xl px-3 py-1.5 text-sm font-semibold transition ${done ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-50'}`}
className={`rounded-xl px-3 py-1.5 text-sm font-semibold transition ${done ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : 'border border-emerald-300 bg-white text-emerald-700 hover:bg-emerald-50'}`} title={done ? `Click to unmark Day ${dayIndex + 1} of "${plan.title}"` : `Mark Day ${dayIndex + 1} of "${plan.title}" as read`}
title={done ? 'Click to unmark' : 'Mark this chapter as read in your reading plan'} >
> {done ? `✓ ${plan.title}` : `Mark read — ${plan.title}`}
{done ? '✓ Read' : 'Mark as read'} </button>
</button> ))}
);
})()}
<button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1} <button type="button" onClick={readerGoToPreviousChapter} disabled={readerChapter <= 1}
className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"> className="rounded-xl border border-slate-300 bg-white px-4 py-1.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40">
‹ Prev ‹ Prev
+557
View File
@@ -0,0 +1,557 @@
import { useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import AuthStatus from '../components/AuthStatus.jsx';
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.jsx';
import {
READING_PLANS,
getPlan,
getPlanSchedule,
formatDayReadings,
firstRefOfDay,
nextUnreadDayIndex,
themeForCategory,
ALL_BOOKS,
OT_BOOKS,
NT_BOOKS,
GOSPELS,
PENTATEUCH,
WISDOM_BOOKS,
} from '../utils/readingPlans.js';
export default function ReadingPlansPage() {
const {
goHome,
planProgress,
customPlans,
startPlan,
toggleDay,
restartPlan,
removePlanProgress,
addCustomPlan,
removeCustomPlan,
updateCustomPlan,
readReadingPlanDay,
} = useApp();
const [showBuilder, setShowBuilder] = useState(false);
const activeProgress = planProgress.filter((p) => !p.completedAt);
const completedProgress = planProgress
.filter((p) => p.completedAt)
.sort((a, b) => b.completedAt - a.completedAt);
const categoryOrder = ['Whole Bible', 'Old Testament', 'New Testament', 'Topical'];
const builtinCategories = categoryOrder.filter((c) => READING_PLANS.some((p) => p.category === c));
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">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-5 sm:px-6 lg:px-8">
<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>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={goHome}
className="rounded-xl border border-slate-500 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-700"
>
← Back
</button>
<AuthStatus />
</div>
</div>
</header>
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
{activeProgress.length > 0 && (
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900">Continue Reading</h2>
<div className="space-y-4">
{activeProgress.map((progress) => (
<ActivePlanCard
key={progress.id}
progress={progress}
onRead={readReadingPlanDay}
onToggleDay={toggleDay}
onRestart={restartPlan}
onRemove={removePlanProgress}
/>
))}
</div>
</section>
)}
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900">My Plans</h2>
{!showBuilder && (
<button
type="button"
onClick={() => setShowBuilder(true)}
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 && (
<div className="mb-4">
<CustomPlanBuilder
onCreate={(input) => { addCustomPlan(input); setShowBuilder(false); }}
onCancel={() => setShowBuilder(false)}
/>
</div>
)}
{customPlans.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{customPlans.map((plan) => (
<PlanCatalogCard
key={plan.id}
plan={plan}
progressForPlan={planProgress.filter((p) => p.planId === plan.id)}
onStart={startPlan}
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 && (
<p className="text-sm text-slate-500">You haven't created any plans yet — try "+ New Plan".</p>
)}
</section>
{builtinCategories.map((category) => (
<section key={category}>
<h2 className="mb-3 text-lg font-semibold text-slate-900">{category}</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{READING_PLANS.filter((p) => p.category === category).map((plan) => (
<PlanCatalogCard
key={plan.id}
plan={plan}
progressForPlan={planProgress.filter((p) => p.planId === plan.id)}
onStart={startPlan}
/>
))}
</div>
</section>
))}
{completedProgress.length > 0 && (
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900">Completed</h2>
<div className="space-y-2">
{completedProgress.map((progress) => {
const plan = getPlan(progress.planId);
if (!plan) return null;
return (
<div
key={progress.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white px-5 py-3 shadow-panel"
>
<div>
<p className="text-sm font-semibold text-slate-900">{plan.icon ?? '✓'} {plan.title}</p>
<p className="text-xs text-slate-500">Completed {formatRelativeDate(progress.completedAt)}</p>
</div>
<div className="flex items-center gap-3 text-sm">
<button
type="button"
onClick={() => restartPlan(progress.id)}
className="font-semibold text-slate-700 hover:underline"
>
Start again
</button>
<button
type="button"
onClick={() => { if (window.confirm('Remove this from your history?')) removePlanProgress(progress.id); }}
className="text-rose-500 hover:text-rose-700"
>
Remove
</button>
</div>
</div>
);
})}
</div>
</section>
)}
</main>
</div>
);
}
function PlanCover({ plan }) {
const [imgFailed, setImgFailed] = useState(false);
const theme = themeForCategory(plan.category);
const showImage = !!plan.image && !imgFailed;
return (
<div className={`relative flex h-32 items-center justify-center overflow-hidden bg-gradient-to-br ${theme.gradient}`}>
{showImage && (
<img
src={plan.image}
alt=""
onError={() => setImgFailed(true)}
style={{ objectPosition: plan.imageFocus ?? 'center' }}
className="absolute inset-0 h-full w-full object-cover"
/>
)}
{!showImage && <span className="text-4xl drop-shadow">{plan.icon ?? '📖'}</span>}
</div>
);
}
function ActivePlanCard({ progress, onRead, onToggleDay, onRestart, onRemove }) {
const [expanded, setExpanded] = useState(false);
const plan = getPlan(progress.planId);
if (!plan) return null;
const theme = themeForCategory(plan.category);
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;
const nextFirstRef = nextRefs ? firstRefOfDay(nextRefs) : null;
return (
<div className="overflow-hidden rounded-3xl border border-emerald-200 bg-emerald-50 shadow-panel">
<div className={`h-1.5 w-full bg-gradient-to-r ${theme.gradient}`} />
<div className="p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className={`text-xs font-semibold uppercase tracking-widest ${theme.text}`}>{plan.category}</p>
<h3 className="mt-1 text-base font-semibold text-slate-900">{plan.icon ?? '📖'} {plan.title}</h3>
<p className="mt-0.5 text-sm text-slate-600">
{progress.completedDays.length} / {plan.days} days
{nextRefs && <> · Next: Day {nextDay + 1} — {formatDayReadings(nextRefs)}</>}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{nextFirstRef && (
<button
type="button"
onClick={() => onRead(nextFirstRef.book, nextFirstRef.chapter)}
className="rounded-xl bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-500"
>
Read
</button>
)}
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="rounded-xl border border-emerald-300 px-3 py-1.5 text-sm text-emerald-700 hover:bg-emerald-100"
>
{expanded ? 'Hide days' : 'View days'}
</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>
<p className="mt-1 text-right text-xs text-emerald-700">{pct}%</p>
{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) => {
const done = progress.completedDays.includes(i);
const ref = firstRefOfDay(dayRefs);
return (
<div key={i} className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-slate-50">
<input
type="checkbox"
checked={done}
onChange={() => onToggleDay(progress.id, i, plan.days)}
className="h-4 w-4 shrink-0 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
/>
<span className={done ? 'flex-1 text-slate-400 line-through' : 'flex-1 text-slate-700'}>
Day {i + 1} — {formatDayReadings(dayRefs)}
</span>
<button
type="button"
onClick={() => onRead(ref.book, ref.chapter)}
className="shrink-0 text-xs font-semibold text-sky-600 hover:underline"
>
Read
</button>
</div>
);
})}
</div>
)}
<div className="mt-3 flex items-center gap-4 text-xs">
<button
type="button"
onClick={() => { if (window.confirm('Restart this plan from day 1?')) onRestart(progress.id); }}
className="font-semibold text-slate-500 hover:text-slate-700"
>
Restart
</button>
<button
type="button"
onClick={() => { if (window.confirm('Remove this plan?')) onRemove(progress.id); }}
className="font-semibold text-rose-500 hover:text-rose-700"
>
Remove
</button>
</div>
</div>
</div>
);
}
function PlanCatalogCard({ plan, progressForPlan, onStart, onDelete, onImageChange }) {
const active = progressForPlan.find((p) => !p.completedAt);
const completedCount = progressForPlan.filter((p) => p.completedAt).length;
const theme = themeForCategory(plan.category);
const fileInputRef = useRef(null);
const [imageError, setImageError] = useState('');
const pickImage = async (e) => {
const file = e.target.files?.[0];
e.target.value = ''; // lets the same file be re-picked later
if (!file) return;
try {
setImageError('');
onImageChange(plan.id, await resizeImageToDataUrl(file));
} catch {
setImageError("Couldn't use that image.");
}
};
return (
<div className="group overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-panel transition hover:shadow-lg">
<PlanCover plan={plan} />
<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>
<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">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${theme.chip}`}>{plan.days} days</span>
{completedCount > 0 && <span className="text-xs font-semibold text-emerald-600">✓ Completed {completedCount}×</span>}
</div>
{onImageChange && (
<div className="flex items-center gap-3 text-xs">
<button type="button" onClick={() => fileInputRef.current?.click()} className="font-semibold text-slate-500 hover:text-slate-700">
{plan.image ? 'Change cover' : '+ Add cover photo'}
</button>
{plan.image && (
<button type="button" onClick={() => onImageChange(plan.id, null)} className="text-slate-400 hover:text-rose-500">
Remove
</button>
)}
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
</div>
)}
{imageError && <p className="text-xs text-rose-500">{imageError}</p>}
<button
type="button"
disabled={!!active}
onClick={() => onStart(plan.id)}
className="mt-1 rounded-xl bg-slate-900 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50"
>
{active ? 'In progress' : completedCount > 0 ? 'Start again' : 'Start plan'}
</button>
</div>
</div>
);
}
// Downscales a picked image file to a reasonable cover-photo size and returns
// a data URL — no server upload endpoint needed, it's stored right on the plan.
function resizeImageToDataUrl(file, maxWidth = 960, quality = 0.82) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Could not read file.'));
reader.onload = () => {
const img = new Image();
img.onerror = () => reject(new Error('Could not decode image.'));
img.onload = () => {
const scale = Math.min(1, maxWidth / img.width);
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.src = reader.result;
};
reader.readAsDataURL(file);
});
}
const BOOK_PRESETS = [
['All', ALL_BOOKS],
['None', []],
['Old Testament', OT_BOOKS],
['New Testament', NT_BOOKS],
['Gospels', GOSPELS],
['Pentateuch', PENTATEUCH],
['Psalms', ['PSA']],
['Proverbs', ['PRO']],
['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);
const [imageError, setImageError] = useState('');
const fileInputRef = useRef(null);
const toggleBook = (abbrev) => {
setBooks((prev) => (prev.includes(abbrev) ? prev.filter((b) => b !== abbrev) : [...prev, abbrev]));
};
const pickImage = async (e) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
try {
setImageError('');
setImage(await resizeImageToDataUrl(file));
} catch {
setImageError("Couldn't use that image.");
}
};
const totalChapters = books.reduce((sum, b) => sum + (CHAPTER_COUNTS[b] ?? 0), 0);
const canCreate = title.trim().length > 0 && books.length > 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>
<div className="grid gap-3 sm:grid-cols-2">
<label className="text-sm text-slate-600">
Title
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Advent Readings"
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
/>
</label>
<label className="text-sm text-slate-600">
Days
<input
type="number"
min="1"
max="365"
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
/>
</label>
</div>
<label className="mt-3 block text-sm text-slate-600">
Description <span className="text-slate-400">(optional)</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-rose-500 focus:outline-none focus:ring-2 focus:ring-rose-200"
/>
</label>
<div className="mt-3">
<span className="text-sm text-slate-600">Cover photo <span className="text-slate-400">(optional)</span></span>
<div className="mt-1 flex items-center gap-3">
{image ? (
<img src={image} alt="" className="h-16 w-28 rounded-lg object-cover" />
) : (
<div className="flex h-16 w-28 items-center justify-center rounded-lg bg-slate-100 text-2xl">🖼️</div>
)}
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="rounded-xl border border-slate-300 px-3 py-1.5 text-sm font-semibold text-slate-600 hover:bg-slate-50"
>
{image ? 'Change photo' : 'Choose photo'}
</button>
{image && (
<button type="button" onClick={() => setImage(null)} className="text-sm text-slate-400 hover:text-rose-500">
Remove
</button>
)}
<input ref={fileInputRef} type="file" accept="image/*" onChange={pickImage} className="hidden" />
</div>
{imageError && <p className="mt-1 text-xs text-rose-500">{imageError}</p>}
</div>
<div className="mt-4">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-700">Books</span>
{BOOK_PRESETS.map(([label, list]) => (
<button
key={label}
type="button"
onClick={() => setBooks(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>
<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">
<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>
))}
</div>
<div className="max-h-48 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>
))}
</div>
</div>
<p className="mt-2 text-xs text-slate-400">
{books.length} book{books.length !== 1 ? 's' : ''} selected · {totalChapters} chapters
{days > 0 && totalChapters > 0 && ` · ~${(totalChapters / days).toFixed(1)} ch/day`}
</p>
</div>
<div className="mt-4 flex items-center gap-3">
<button
type="button"
disabled={!canCreate}
onClick={() => onCreate({ title, description, days, books, 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
</button>
<button type="button" onClick={onCancel} className="text-sm text-slate-400 hover:text-slate-600">Cancel</button>
</div>
</div>
);
}
+14
View File
@@ -65,6 +65,20 @@ export async function deleteRemoteProject(id) {
return request('DELETE', `/projects/${id}`); return request('DELETE', `/projects/${id}`);
} }
// ---------------------------------------------------------------------------
// Reading plan progress
// ---------------------------------------------------------------------------
/** Returns the signed-in user's whole reading-plan progress list. */
export async function getRemoteReadingProgress() {
return request('GET', '/reading-plans');
}
/** Overwrites the signed-in user's whole reading-plan progress list. */
export async function saveRemoteReadingProgress(progress) {
return request('PUT', '/reading-plans', progress);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Read-only share links // Read-only share links
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+235
View File
@@ -0,0 +1,235 @@
// Reading plan catalog — pure data + helpers. No React, no storage; progress
// tracking (and custom plan storage) lives in useReadingPlans.js.
import { bookOptions, CHAPTER_COUNTS, NT_BOOK_NUMBER } from './bibleUtils.jsx';
const BOOK_NAME_BY_ABBREV = Object.fromEntries(bookOptions.map((b) => [b.abbrev, b.name]));
export const ALL_BOOKS = bookOptions.map((b) => b.abbrev);
export const NT_BOOKS = ALL_BOOKS.filter((a) => NT_BOOK_NUMBER[a]);
export const OT_BOOKS = ALL_BOOKS.filter((a) => !NT_BOOK_NUMBER[a]);
export const GOSPELS = ['MAT', 'MRK', 'LUK', 'JHN'];
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) {
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 });
}
return list;
}
// Splits a flat chapter list into `days` nearly-equal, contiguous slices.
function distributeIntoDays(chapters, days) {
const total = chapters.length;
const schedule = [];
for (let d = 0; d < days; d += 1) {
const start = Math.round((d * total) / days);
const end = Math.round(((d + 1) * total) / days);
schedule.push(chapters.slice(start, end));
}
return schedule;
}
// Per-category color theme used for card accents/gradients when a plan has no cover image yet.
export const CATEGORY_THEME = {
'Whole Bible': { gradient: 'from-indigo-500 to-violet-600', text: 'text-indigo-600', chip: 'bg-indigo-100 text-indigo-700' },
'New Testament': { gradient: 'from-sky-500 to-blue-600', text: 'text-sky-600', chip: 'bg-sky-100 text-sky-700' },
'Old Testament': { gradient: 'from-amber-500 to-orange-600', text: 'text-amber-600', chip: 'bg-amber-100 text-amber-700' },
Topical: { gradient: 'from-emerald-500 to-teal-600', text: 'text-emerald-600', chip: 'bg-emerald-100 text-emerald-700' },
'My Plans': { gradient: 'from-rose-500 to-pink-600', text: 'text-rose-600', chip: 'bg-rose-100 text-rose-700' },
};
const DEFAULT_THEME = { gradient: 'from-slate-500 to-slate-700', text: 'text-slate-600', chip: 'bg-slate-100 text-slate-700' };
export function themeForCategory(category) {
return CATEGORY_THEME[category] ?? DEFAULT_THEME;
}
// Static catalog. `image` points at an optional cover photo the user can drop into
// public/plan-covers/ — see PIXABAY_SEARCH_LINKS below for where to find one. Cards
// fall back to a colorful gradient + icon when the file isn't there.
export const READING_PLANS = [
{
id: 'bible-year',
title: 'Whole Bible in a Year',
description: 'Read straight through Genesis to Revelation at a steady, sustainable pace.',
category: 'Whole Bible',
icon: '📖',
image: '/plan-covers/bible-year.jpg',
days: 365,
books: ALL_BOOKS,
},
{
id: 'bible-90',
title: 'Whole Bible in 90 Days',
description: 'An intensive read of the entire Bible, cover to cover.',
category: 'Whole Bible',
icon: '⏳',
image: '/plan-covers/bible-90.jpg',
days: 90,
books: ALL_BOOKS,
},
{
id: 'nt-90',
title: 'New Testament in 90 Days',
description: 'Matthew through Revelation in three months.',
category: 'New Testament',
icon: '✝️',
image: '/plan-covers/nt-90.jpg',
days: 90,
books: NT_BOOKS,
},
{
id: 'nt-30',
title: 'New Testament in 30 Days',
description: 'A fast-paced trip through the whole New Testament.',
category: 'New Testament',
icon: '⚡',
image: '/plan-covers/nt-30.jpg',
imageFocus: '50% 35%',
days: 30,
books: NT_BOOKS,
},
{
id: 'gospels-30',
title: 'The Gospels in 30 Days',
description: 'Matthew, Mark, Luke, and John — the life of Jesus in a month.',
category: 'Topical',
icon: '🕊️',
image: '/plan-covers/gospels-30.jpg',
days: 30,
books: GOSPELS,
},
{
id: 'psalms-30',
title: 'Psalms in 30 Days',
description: 'Pray and read through all 150 Psalms in a month.',
category: 'Topical',
icon: '🎵',
image: '/plan-covers/psalms-30.jpg',
days: 30,
books: ['PSA'],
},
{
id: 'proverbs-31',
title: 'Proverbs in 31 Days',
description: 'One chapter a day — a natural fit for any month.',
category: 'Topical',
icon: '💡',
image: '/plan-covers/proverbs-31.jpg',
days: 31,
books: ['PRO'],
},
{
id: 'pentateuch-60',
title: 'The Pentateuch in 60 Days',
description: 'Genesis through Deuteronomy — the foundation of the Old Testament.',
category: 'Old Testament',
icon: '🏜️',
image: '/plan-covers/pentateuch-60.jpg',
days: 60,
books: PENTATEUCH,
},
{
id: 'wisdom-40',
title: 'Wisdom Literature in 40 Days',
description: 'Job, Psalms, Proverbs, Ecclesiastes, and Song of Songs.',
category: 'Topical',
icon: '🦉',
image: '/plan-covers/wisdom-40.jpg',
imageFocus: '50% 32%',
days: 40,
books: WISDOM_BOOKS,
},
];
// Pixabay *search* pages (not specific photos — nothing here is a guessed asset URL)
// for each built-in plan, so a cover photo can be picked and dropped into
// public/plan-covers/<id>.jpg. Landscape, ~1200px wide, works best.
export const PIXABAY_SEARCH_LINKS = {
'bible-year': 'https://pixabay.com/images/search/open%20bible/',
'bible-90': 'https://pixabay.com/images/search/bible%20pages/',
'nt-90': 'https://pixabay.com/images/search/cross%20sunrise/',
'nt-30': 'https://pixabay.com/images/search/cross/',
'gospels-30': 'https://pixabay.com/images/search/jesus/',
'psalms-30': 'https://pixabay.com/images/search/harp/',
'proverbs-31': 'https://pixabay.com/images/search/candle%20light/',
'pentateuch-60': 'https://pixabay.com/images/search/desert%20mountain/',
'wisdom-40': 'https://pixabay.com/images/search/owl%20book/',
};
const PLAN_BY_ID = Object.fromEntries(READING_PLANS.map((p) => [p.id, p]));
const scheduleCache = new Map();
export function getPlan(planId) {
return PLAN_BY_ID[planId] ?? null;
}
// Registers/updates/removes user-created plans in the lookup used by getPlan/getPlanSchedule.
// Called by useReadingPlans() whenever the custom-plans list changes.
export function registerCustomPlans(customPlans) {
for (const key of Object.keys(PLAN_BY_ID)) {
if (PLAN_BY_ID[key].custom && !customPlans.some((cp) => cp.id === key)) {
delete PLAN_BY_ID[key];
scheduleCache.delete(key);
}
}
for (const cp of customPlans) {
PLAN_BY_ID[cp.id] = { category: 'My Plans', icon: '✨', ...cp, custom: true };
scheduleCache.delete(cp.id);
}
}
// Returns an array of `plan.days` entries, each an array of { book, chapter } refs.
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);
scheduleCache.set(planId, schedule);
return schedule;
}
// Groups consecutive same-book chapters and renders "Genesis 1–3; Exodus 1" style text.
export function formatDayReadings(dayRefs) {
if (!dayRefs || dayRefs.length === 0) return '';
const groups = [];
for (const ref of dayRefs) {
const last = groups[groups.length - 1];
if (last && last.book === ref.book && ref.chapter === last.end + 1) {
last.end = ref.chapter;
} else {
groups.push({ book: ref.book, start: ref.chapter, end: ref.chapter });
}
}
return groups
.map((g) => {
const name = BOOK_NAME_BY_ABBREV[g.book] ?? g.book;
return g.start === g.end ? `${name} ${g.start}` : `${name} ${g.start}–${g.end}`;
})
.join('; ');
}
// The first chapter reference of a day's reading — where "Read" should jump to.
export function firstRefOfDay(dayRefs) {
return dayRefs && dayRefs.length > 0 ? dayRefs[0] : null;
}
// Index (0-based) of the first day not yet marked complete, or null if the plan is finished.
export function nextUnreadDayIndex(plan, completedDays) {
for (let i = 0; i < plan.days; i += 1) {
if (!completedDays.includes(i)) return i;
}
return null;
}
// Index (0-based) of the day whose reading includes this exact book/chapter, or -1.
// Used by the reader to offer "mark today's plan reading done" inline.
export function findDayIndexForRef(schedule, bookAbbrev, chapter) {
for (let i = 0; i < schedule.length; i += 1) {
if (schedule[i].some((r) => r.book === bookAbbrev && r.chapter === chapter)) return i;
}
return -1;
}