Reading plans: integrated into the Reader, redesigned, with per-plan translation
Build and Push Docker Image / build-and-push (push) Failing after 19s

Reader: plan strip with a dot per chapter, plan sheet (day picker, chapter
ticks, translation, streak, other plans), end-of-day card that steps through
the day and rolls into the next, and chapter-level auto-marking.
Each plan remembers its own translation (BSB, ESV, NLT, CSB, NASB, NET) and the
Reader follows it while a plan is open. Plans home redesigned in the reader's
themes with a Continue card, in-progress plans, a filterable catalog, and
sheets for details and the custom-plan builder.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 09:47:51 -04:00
1 parent c11b6a60bf
commit b1bc1c8ea4
17 files changed
+1205 -863

No files matched your search

+116 -48
View File
@@ -3,7 +3,7 @@
import { useState, useEffect, useRef, useMemo, useDeferredValue } from 'react';
import { useApp } from '../context/AppContext.js';
import { bookOptions } from '../utils/bibleUtils.jsx';
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings } from '../utils/readingPlans.js';
import { getPlan, getPlanSchedule, findDayIndexForRef, formatDayReadings, nextUnreadDayIndex, computeStreak } from '../utils/readingPlans.js';
import DrawCanvas from './DrawCanvas.jsx';
import { READER_TRANSLATIONS } from '../hooks/useBibleReader.js';
import { useBibleSearch } from '../hooks/useBibleSearch.js';
@@ -17,6 +17,8 @@ import SearchSheet from '../components/reader/SearchSheet.jsx';
import BookmarksSheet from '../components/reader/BookmarksSheet.jsx';
import AudioSheet from '../components/reader/AudioSheet.jsx';
import VerseSheet from '../components/reader/VerseSheet.jsx';
import PlanSheet from '../components/reader/PlanSheet.jsx';
import PlanFooter from '../components/reader/PlanFooter.jsx';
import { FocusIcon } from '../components/reader/icons.jsx';
const NO_STROKES = []; // stable fallback so DrawCanvas doesn't redraw every render
@@ -68,6 +70,12 @@ export default function ReaderPage() {
removeTextHighlight,
planProgress,
toggleDay,
toggleChapterRead,
readingActivity,
readerPlan, setReaderPlan,
openPlanReading,
studyReadingPlanDay,
studyBuildBusy,
} = useApp();
const readerBook = bookOptions.find((b) => b.abbrev === readerBookAbbrev);
@@ -83,39 +91,67 @@ export default function ReaderPage() {
useEffect(() => { localStorage.setItem('reader-wide', readerWideLayout ? '1' : '0'); }, [readerWideLayout]);
// 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 schedule = getPlanSchedule(p.planId);
const dayIndex = findDayIndexForRef(schedule, readerBookAbbrev, readerChapter);
if (dayIndex === -1) return null;
const refs = schedule[dayIndex];
const position = refs.findIndex((r) => r.book === readerBookAbbrev && r.chapter === readerChapter) + 1;
return { progressId: p.id, plan, dayIndex, refs, position, total: refs.length, done: p.completedDays.includes(dayIndex), nextDayRefs: schedule[dayIndex + 1] ?? null };
})
.filter(Boolean);
}, [planProgress, readerBookAbbrev, readerChapter]);
const chapterKey = `${readerBookAbbrev}.${readerChapter}`;
const label = (ref) => formatDayReadings([ref]);
// 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;
// Everything the plan UI needs to know about one plan-day, relative to the chapter on screen.
const planInfo = (progress, dayIndex) => {
const plan = progress && getPlan(progress.planId);
if (!plan) return null;
const schedule = getPlanSchedule(progress.planId);
const refs = schedule[dayIndex] ?? [];
const keys = refs.map((r) => `${r.book}.${r.chapter}`);
const done = progress.completedDays.includes(dayIndex);
const readSet = new Set(done ? keys : progress.chaptersRead?.[dayIndex] ?? []);
return {
progress, plan, dayIndex, refs, keys, done, readSet,
position: keys.indexOf(chapterKey) + 1, // 0 = the chapter on screen isn't part of this day
total: refs.length,
nextDayRefs: schedule[dayIndex + 1] ?? null,
translation: progress.translation,
};
};
// The plan day open in the reader (set when you read from a plan) ...
const ctxInfo = useMemo(() => {
if (!readerPlan) return null;
return planInfo(planProgress.find((p) => p.id === readerPlan.progressId), readerPlan.dayIndex);
}, [planProgress, readerPlan, chapterKey]); // eslint-disable-line react-hooks/exhaustive-deps
// ... and any other active plans whose schedule includes the chapter on screen.
const otherMatches = useMemo(() => planProgress
.filter((p) => !p.completedAt && p.id !== readerPlan?.progressId)
.map((p) => {
const plan = getPlan(p.planId);
const dayIndex = plan ? findDayIndexForRef(getPlanSchedule(p.planId), readerBookAbbrev, readerChapter) : -1;
return dayIndex === -1 ? null : planInfo(p, dayIndex);
})
.filter(Boolean), [planProgress, readerPlan, chapterKey]); // eslint-disable-line react-hooks/exhaustive-deps
const stripInfo = ctxInfo ?? otherMatches[0] ?? null;
const otherPlans = useMemo(() => planProgress
.filter((p) => !p.completedAt && p.id !== readerPlan?.progressId)
.map((p) => {
const plan = getPlan(p.planId);
return plan ? { progress: p, plan, dayIndex: nextUnreadDayIndex(plan, p.completedDays) ?? 0 } : null;
})
.filter(Boolean), [planProgress, readerPlan]);
// After a dwell period on a plan chapter it's ticked off automatically, so you don't
// have to remember to. Resets whenever you change chapter; ticks are never removed here.
const autoMarkRef = useRef([]);
autoMarkRef.current = [ctxInfo?.position > 0 ? ctxInfo : null, ...otherMatches].filter(Boolean);
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);
const fresh = autoMarkRef.current.filter((m) => !m.readSet.has(chapterKey));
if (fresh.length === 0) return;
fresh.forEach((m) => toggleChapterRead(m.progress.id, m.dayIndex, chapterKey, m.keys, m.plan.days, true));
setAutoMarkedNotice(`Marked read: ${label({ book: readerBookAbbrev, chapter: readerChapter })}`);
window.setTimeout(() => setAutoMarkedNotice(''), 3500);
}, 20000);
return () => window.clearTimeout(timer);
}, [readerBookAbbrev, readerChapter, toggleDay]);
}, [chapterKey, toggleChapterRead]); // eslint-disable-line react-hooks/exhaustive-deps
const goTo = (book, chapter) => {
setReaderBookAbbrev(book);
@@ -124,24 +160,26 @@ export default function ReaderPage() {
setReaderCrossRefs(null);
window.scrollTo({ top: 0 });
};
const goToRef = (r) => goTo(r.book, r.chapter);
// Bottom-bar action tied to the first plan that includes this chapter: step through
// the day's chapters, then mark the day read and roll into the next day.
const primary = useMemo(() => {
const m = planMatches[0];
if (!m) return null;
const label = (ref) => formatDayReadings([ref]);
if (m.position < m.total) {
const next = m.refs[m.position];
return { label: `Next: ${label(next)}`, done: true, onClick: () => goTo(next.book, next.chapter) };
}
const markDone = () => { if (!m.done) toggleDay(m.progressId, m.dayIndex, m.plan.days); };
if (m.nextDayRefs?.length) {
const first = m.nextDayRefs[0];
return { label: m.done ? `Day ${m.dayIndex + 2}: ${label(first)}` : `Done · Day ${m.dayIndex + 2}`, done: m.done, onClick: () => { markDone(); goTo(first.book, first.chapter); } };
}
return m.done ? null : { label: 'Finish plan', done: false, onClick: markDone };
}, [planMatches]); // eslint-disable-line react-hooks/exhaustive-deps
// Opens a day of the context plan on its first unread chapter.
const goDay = (progressId, dayIndex) => {
const info = planInfo(planProgress.find((p) => p.id === progressId), dayIndex);
if (!info) return;
setReaderPlan({ progressId, dayIndex });
const target = info.refs.find((r) => !info.readSet.has(`${r.book}.${r.chapter}`)) ?? info.refs[0];
if (target) goToRef(target);
};
// Ticks the chapter on screen; finishing a day's last chapter completes it. If earlier
// chapters were skipped, the explicit "mark read" buttons still complete the whole day.
const readCurrent = (info) => {
if (info.readSet.has(chapterKey) && info.keys.every((k) => info.readSet.has(k))) return;
const complete = info.keys.every((k) => k === chapterKey || info.readSet.has(k));
if (complete) toggleChapterRead(info.progress.id, info.dayIndex, chapterKey, info.keys, info.plan.days, true);
else if (!info.done && info.position === info.total) toggleDay(info.progress.id, info.dayIndex, info.plan.days);
else toggleChapterRead(info.progress.id, info.dayIndex, chapterKey, info.keys, info.plan.days, true);
};
// Whole-Bible search. BSB and NET are indexed in the browser (lowercased once, not per
// keystroke, with typing kept responsive via useDeferredValue); ESV, NLT, CSB and NASB
@@ -307,8 +345,15 @@ export default function ReaderPage() {
</button>
)}
{chromeVisible && <PlanStrip matches={planMatches} notice={autoMarkedNotice}
onToggle={(m) => toggleDay(m.progressId, m.dayIndex, m.plan.days)} />}
{chromeVisible && (
<PlanStrip
info={stripInfo} translation={readerTranslation} inPlan={!!ctxInfo} notice={autoMarkedNotice}
onOpen={() => openSheet('plan')}
onJoin={() => setReaderPlan({ progressId: stripInfo.progress.id, dayIndex: stripInfo.dayIndex })}
onDot={goToRef}
onBackToDay={() => goDay(ctxInfo.progress.id, ctxInfo.dayIndex)}
/>
)}
<div className={paneOpen ? 'lg:pr-[24rem]' : ''}>
{!readerDrawMode && (
@@ -332,6 +377,14 @@ export default function ReaderPage() {
crossRefs={readerCrossRefs}
showCrossRefs={readerShowCrossRefs}
onSelectionEnd={handleSelectionEnd}
footer={(
<PlanFooter
info={ctxInfo} label={label}
onNext={() => { readCurrent(ctxInfo); goToRef(ctxInfo.refs[ctxInfo.position]); }}
onNextDay={() => { readCurrent(ctxInfo); goDay(ctxInfo.progress.id, ctxInfo.dayIndex + 1); }}
onFinish={() => { readCurrent(ctxInfo); setAutoMarkedNotice(`Day ${ctxInfo.dayIndex + 1} marked read`); window.setTimeout(() => setAutoMarkedNotice(''), 3500); }}
/>
)}
/>
)}
@@ -371,7 +424,7 @@ export default function ReaderPage() {
onCrossRefs={() => { setReaderShowCrossRefs((v) => !v); if (!readerShowCrossRefs) loadReaderCrossRefs(readerBookAbbrev, readerChapter); }}
crossRefsOn={readerShowCrossRefs}
onFocus={() => { setChromeVisible(false); setSheet(null); }}
primary={primary}
primary={null}
/>
)}
@@ -395,10 +448,25 @@ export default function ReaderPage() {
/>
)}
<PlanSheet
open={sheet === 'plan'} onClose={closeSheet}
info={ctxInfo} translation={readerTranslation} currentKey={chapterKey} streak={computeStreak(readingActivity)}
otherPlans={otherPlans} studyBusy={studyBuildBusy}
onDay={(d) => goDay(ctxInfo.progress.id, ctxInfo.dayIndex + d)}
onToggleChapter={(key) => toggleChapterRead(ctxInfo.progress.id, ctxInfo.dayIndex, key, ctxInfo.keys, ctxInfo.plan.days)}
onGoChapter={(r) => { goToRef(r); setSheet(null); }}
onTranslation={setReaderTranslation}
onToggleDay={() => toggleDay(ctxInfo.progress.id, ctxInfo.dayIndex, ctxInfo.plan.days)}
onSwitchPlan={(o) => { setSheet(null); openPlanReading(o.progress.id, o.dayIndex); }}
onListen={() => setSheet('audio')}
onStudy={() => studyReadingPlanDay(ctxInfo.refs, ctxInfo.plan.title)}
onLeave={() => { setReaderPlan(null); setSheet(null); }}
/>
<NavigatorSheet
open={sheet === 'nav'} onClose={closeSheet}
bookAbbrev={readerBookAbbrev} chapter={readerChapter}
translation={readerTranslation} onTranslation={setReaderTranslation}
translationNote={ctxInfo ? `Reading “${ctxInfo.plan.title}”: this sets the plan’s translation.` : ''}
onGo={goTo}
/>
<SettingsSheet