diff --git a/public/plan-covers/README.txt b/public/plan-covers/README.txt new file mode 100644 index 0000000..ce9d48d --- /dev/null +++ b/public/plan-covers/README.txt @@ -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. diff --git a/public/plan-covers/bible-90.jpg b/public/plan-covers/bible-90.jpg new file mode 100644 index 0000000..e340649 Binary files /dev/null and b/public/plan-covers/bible-90.jpg differ diff --git a/public/plan-covers/bible-year.jpg b/public/plan-covers/bible-year.jpg new file mode 100644 index 0000000..22a667f Binary files /dev/null and b/public/plan-covers/bible-year.jpg differ diff --git a/public/plan-covers/gospels-30.jpg b/public/plan-covers/gospels-30.jpg new file mode 100644 index 0000000..8cfc51b Binary files /dev/null and b/public/plan-covers/gospels-30.jpg differ diff --git a/public/plan-covers/nt-30.jpg b/public/plan-covers/nt-30.jpg new file mode 100644 index 0000000..b7f6b76 Binary files /dev/null and b/public/plan-covers/nt-30.jpg differ diff --git a/public/plan-covers/nt-90.jpg b/public/plan-covers/nt-90.jpg new file mode 100644 index 0000000..daa68ee Binary files /dev/null and b/public/plan-covers/nt-90.jpg differ diff --git a/public/plan-covers/pentateuch-60.jpg b/public/plan-covers/pentateuch-60.jpg new file mode 100644 index 0000000..2878389 Binary files /dev/null and b/public/plan-covers/pentateuch-60.jpg differ diff --git a/public/plan-covers/proverbs-31.jpg b/public/plan-covers/proverbs-31.jpg new file mode 100644 index 0000000..d027f2c Binary files /dev/null and b/public/plan-covers/proverbs-31.jpg differ diff --git a/public/plan-covers/psalms-30.jpg b/public/plan-covers/psalms-30.jpg new file mode 100644 index 0000000..8bbe71a Binary files /dev/null and b/public/plan-covers/psalms-30.jpg differ diff --git a/public/plan-covers/wisdom-40.jpg b/public/plan-covers/wisdom-40.jpg new file mode 100644 index 0000000..65e63ec Binary files /dev/null and b/public/plan-covers/wisdom-40.jpg differ diff --git a/server/db.js b/server/db.js index a8af867..65e0104 100644 --- a/server/db.js +++ b/server/db.js @@ -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}`); } @@ -182,6 +192,30 @@ export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) { `).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) * to the given user. Intended to run once, right after the first account is created. diff --git a/server/index.js b/server/index.js index d55c285..c83d6c8 100644 --- a/server/index.js +++ b/server/index.js @@ -13,6 +13,7 @@ import { adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject, setUserPassword, destroyAllSessionsForUser, getAllReaderInk, setReaderInkPage, + getReadingProgress, setReadingProgress, } from './db.js'; import { SqliteSessionStore } from './sessionStore.js'; 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 // --------------------------------------------------------------------------- diff --git a/src/App.jsx b/src/App.jsx index 5600038..43f369a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -4,10 +4,10 @@ import { useAudio } from './hooks/useAudio.js'; import { useAuth } from './hooks/useAuth.js'; import { useBibleReader } from './hooks/useBibleReader.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 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 { 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 StudyPage = lazy(() => import('./pages/StudyPage.jsx')); const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx')); +const ReadingPlansPage = lazy(() => import('./pages/ReadingPlansPage.jsx')); // --------------------------------------------------------------------------- // App component @@ -28,7 +29,7 @@ const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx')); const App = () => { 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 [projectIndex, setProjectIndex] = useState([]); const [saveStatus, setSaveStatus] = useState(''); @@ -36,7 +37,7 @@ const App = () => { const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' const reader = useBibleReader(currentPage); const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter); - const readingPlan = useReadingPlan(); + const readingPlans = useReadingPlans(); const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({ onSignIn: (user) => { setProjectIndex(switchStorageUser(user.id)); @@ -45,6 +46,10 @@ const App = () => { .then((r) => r.ok ? r.json() : null) .then((serverInk) => { if (serverInk) reader.mergeServerInk(serverInk); }) .catch(() => {}); + // Same for reading plan progress + getRemoteReadingProgress().then((result) => { + if (result.ok) readingPlans.mergeServerReadingProgress(result.data); + }); }, onSignOut: () => { setProjectIndex(switchStorageUser(null)); @@ -110,6 +115,14 @@ const App = () => { // Navigation 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 = () => { setProject(null); setCurrentPage('setup'); @@ -155,8 +168,10 @@ const App = () => { // Home projectIndex, ...library, - ...readingPlan, + ...readingPlans, openBibleReader, + openReadingPlans, + readReadingPlanDay, openImportProject, openNewProject, // Audio (book player on Home + chapter player in the Reader) @@ -238,6 +253,7 @@ function AppRouter() { if (currentPage === 'admin') return ; if (currentPage === 'settings') return ; + if (currentPage === 'reading-plans') return ; if (currentPage === 'reader') return ; if (currentPage === 'import') return ; if (currentPage === 'study') return ; diff --git a/src/App.test.jsx b/src/App.test.jsx index 1b3924e..800d498 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -580,13 +580,16 @@ describe('Home page', () => { expect(await screen.findByDisplayValue('Title')).toBeInTheDocument(); }); - test('a reading plan can be started from Home', async () => { - seedProject(); // the plan card shows once there's at least one project + test('a reading plan can be started from the Reading Plans page', async () => { + seedProject(); // the nav button shows once there's at least one project const user = userEvent.setup(); await renderApp(); - await user.click(screen.getByRole('button', { name: /set goal/i })); - await user.click(screen.getByRole('button', { name: /start plan/i })); - await waitFor(() => expect(JSON.parse(localStorage.getItem('readingPlan'))?.bookAbbrev).toBe('GEN')); + await user.click(screen.getByRole('button', { name: /reading plans/i })); + await user.click((await screen.findAllByRole('button', { name: /^start plan$/i }))[0]); + await waitFor(() => { + const stored = JSON.parse(localStorage.getItem('readingPlanProgress')); + expect(stored?.progress?.[0]?.planId).toBeTruthy(); + }); }); }); diff --git a/src/hooks/useReadingPlan.js b/src/hooks/useReadingPlan.js deleted file mode 100644 index edd110d..0000000 --- a/src/hooks/useReadingPlan.js +++ /dev/null @@ -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 }; -} diff --git a/src/hooks/useReadingPlans.js b/src/hooks/useReadingPlans.js new file mode 100644 index 0000000..a48668a --- /dev/null +++ b/src/hooks/useReadingPlans.js @@ -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, + }; +} diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 62b93b6..80f805e 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -4,21 +4,21 @@ import AuthStatus from '../components/AuthStatus.jsx'; import AudioPlayer from '../components/AudioPlayer.jsx'; import { bookOptions, - CHAPTER_COUNTS, formatChunkReference, formatRelativeDate, loadProjectIndex, } from '../utils/bibleUtils.jsx'; import { loadProjectById } from '../utils/projectStorage.js'; +import { getPlan, getPlanSchedule, formatDayReadings, nextUnreadDayIndex } from '../utils/readingPlans.js'; export default function HomePage() { const { autoRestoredCount, staleLocalProjects, projectIndex, - readingPlan, - createReadingPlan, - clearReadingPlan, + planProgress, + openReadingPlans, + readReadingPlanDay, audioBook, setAudioBook, audioNarrator, setAudioNarrator, audioState, @@ -99,6 +99,14 @@ export default function HomePage() { > 📖 Read Bible + - - +
+
+

Reading Plans

+

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

-
-
-
-

{pct}%

- {readingPlan.chaptersRead.length > 0 && ( -

- Read: ch. {readingPlan.chaptersRead.slice(0, 12).join(', ')}{readingPlan.chaptersRead.length > 12 ? '…' : ''} -

- )} +
); } return ( -
- {showForm ? ( -
- Read - - in - - - -
- ) : ( -
-
-

Reading Plan

-

Set a goal to read through a book, track chapters as you go.

+
+ {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 ( +
+
+
+

Reading Plan

+

{plan.title}

+

+ {progress.completedDays.length} / {plan.days} days + {nextRefs && <> · Next: {formatDayReadings(nextRefs)}} +

+
+
+ {nextRefs && ( + + )} + +
+
+
+
+
+

{pct}%

- -
- )} + ); + })}
); } diff --git a/src/pages/ReaderPage.jsx b/src/pages/ReaderPage.jsx index af9f083..50150ee 100644 --- a/src/pages/ReaderPage.jsx +++ b/src/pages/ReaderPage.jsx @@ -4,6 +4,7 @@ import { formatCrossRef, speakOriginalWord } from '../utils/wordStudy.js'; import AuthStatus from '../components/AuthStatus.jsx'; import AudioPlayer from '../components/AudioPlayer.jsx'; import { bookOptions, BookmarkIcon, CopyIcon } from '../utils/bibleUtils.jsx'; +import { getPlan, getPlanSchedule, findDayIndexForRef } from '../utils/readingPlans.js'; import DrawCanvas from './DrawCanvas.jsx'; const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render @@ -57,11 +58,26 @@ export default function ReaderPage() { readerTextHighlights, addTextHighlight, removeTextHighlight, - readingPlan, - markChapterRead, + planProgress, + toggleDay, } = useApp(); 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 [readerWideLayout, setReaderWideLayout] = useState(() => localStorage.getItem('reader-wide') === '1'); const readerPageInkStrokes = readerInkByPage?.[`${readerBookAbbrev}_${readerChapter}`] ?? NO_STROKES; @@ -316,19 +332,17 @@ export default function ReaderPage() {
- {readingPlan && readingPlan.bookAbbrev === readerBookAbbrev && (() => { - const done = readingPlan.chaptersRead.includes(readerChapter); - return ( - - ); - })()} + {planMatches.map(({ progressId, plan, dayIndex, done }) => ( + + ))} + +
+
+ + +
+ {activeProgress.length > 0 && ( +
+

Continue Reading

+
+ {activeProgress.map((progress) => ( + + ))} +
+
+ )} + +
+
+

My Plans

+ {!showBuilder && ( + + )} +
+ {showBuilder && ( +
+ { addCustomPlan(input); setShowBuilder(false); }} + onCancel={() => setShowBuilder(false)} + /> +
+ )} + {customPlans.length > 0 ? ( +
+ {customPlans.map((plan) => ( + 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 })} + /> + ))} +
+ ) : !showBuilder && ( +

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

+ )} +
+ + {builtinCategories.map((category) => ( +
+

{category}

+
+ {READING_PLANS.filter((p) => p.category === category).map((plan) => ( + p.planId === plan.id)} + onStart={startPlan} + /> + ))} +
+
+ ))} + + {completedProgress.length > 0 && ( +
+

Completed

+
+ {completedProgress.map((progress) => { + const plan = getPlan(progress.planId); + if (!plan) return null; + return ( +
+
+

{plan.icon ?? '✓'} {plan.title}

+

Completed {formatRelativeDate(progress.completedAt)}

+
+
+ + +
+
+ ); + })} +
+
+ )} +
+
+ ); +} + +function PlanCover({ plan }) { + const [imgFailed, setImgFailed] = useState(false); + const theme = themeForCategory(plan.category); + const showImage = !!plan.image && !imgFailed; + return ( +
+ {showImage && ( + setImgFailed(true)} + style={{ objectPosition: plan.imageFocus ?? 'center' }} + className="absolute inset-0 h-full w-full object-cover" + /> + )} + {!showImage && {plan.icon ?? '📖'}} +
+ ); +} + +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 ( +
+
+
+
+
+

{plan.category}

+

{plan.icon ?? '📖'} {plan.title}

+

+ {progress.completedDays.length} / {plan.days} days + {nextRefs && <> · Next: Day {nextDay + 1} — {formatDayReadings(nextRefs)}} +

+
+
+ {nextFirstRef && ( + + )} + +
+
+ +
+
+
+

{pct}%

+ + {expanded && ( +
+ {schedule.map((dayRefs, i) => { + const done = progress.completedDays.includes(i); + const ref = firstRefOfDay(dayRefs); + return ( +
+ onToggleDay(progress.id, i, plan.days)} + className="h-4 w-4 shrink-0 rounded border-slate-300 text-emerald-600 focus:ring-emerald-500" + /> + + Day {i + 1} — {formatDayReadings(dayRefs)} + + +
+ ); + })} +
+ )} + +
+ + +
+
+
+ ); +} + +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 ( +
+ +
+
+

{plan.title}

+ {onDelete && ( + + )} +
+

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

+
+ {plan.days} days + {completedCount > 0 && ✓ Completed {completedCount}×} +
+ {onImageChange && ( +
+ + {plan.image && ( + + )} + +
+ )} + {imageError &&

{imageError}

} + +
+
+ ); +} + +// 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 ( +
+

New reading plan

+
+ + +
+