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
+72 -1
View File
@@ -25,7 +25,9 @@ const ctx = {
handlePlayReaderAudio: noop, handleToggleReaderAudioPause: noop, handleStopBookAudio: noop,
handleSeekAudio: noop, handleSeekAudioTo: noop,
readerInkByPage: {}, updateReaderPageInk: noop, readerTextHighlights: [], addTextHighlight: noop, removeTextHighlight: noop,
planProgress: [], toggleDay: noop,
planProgress: [], toggleDay: vi.fn(), toggleChapterRead: vi.fn(), readingActivity: [],
readerPlan: null, setReaderPlan: vi.fn(), openPlanReading: vi.fn(), studyReadingPlanDay: vi.fn(), studyBuildBusy: false,
setReaderBookAbbrev: vi.fn(), setReaderChapter: vi.fn(),
};
vi.mock('../context/AppContext.js', () => ({ useApp: () => ctx }));
@@ -50,3 +52,72 @@ test('tapping a verse selects it', () => {
fireEvent.click(screen.getByText(/This man came to Jesus/));
expect(ctx.setReaderSelectedVerse).toHaveBeenCalledWith(2);
});
// --- reading plan integration -------------------------------------------------
import { getPlanSchedule, findDayIndexForRef } from '../utils/readingPlans.js';
const schedule = getPlanSchedule('gospels-30');
const dayIndex = findDayIndexForRef(schedule, 'JHN', 3);
const refs = schedule[dayIndex];
const keys = refs.map((r) => `${r.book}.${r.chapter}`);
const position = keys.indexOf('JHN.3') + 1;
const isLast = position === refs.length;
const withPlan = (over = {}) => {
ctx.planProgress = [{ id: 'p1', planId: 'gospels-30', startedAt: Date.now(), completedDays: [], chaptersRead: {}, completedAt: null, translation: 'ESV', ...over }];
ctx.readerPlan = { progressId: 'p1', dayIndex };
[ctx.setReaderPlan, ctx.toggleChapterRead, ctx.toggleDay, ctx.setReaderChapter, ctx.setReaderBookAbbrev].forEach((f) => f.mockClear());
ctx.setReaderTranslation = vi.fn();
};
const stripButton = () => screen.getAllByText(new RegExp(`Day ${dayIndex + 1} *$`))[0];
test('in a plan: strip shows the day, a dot per chapter, and the plan translation', () => {
withPlan();
render(<ReaderPage />);
expect(screen.getAllByLabelText(/Go to chapter/)).toHaveLength(refs.length);
expect(screen.getByText(`${position} of ${refs.length}`)).toBeTruthy();
});
test('end-of-day card ticks the current chapter and moves on', () => {
// every other chapter of the day already read, so finishing this one completes the day
withPlan({ chaptersRead: { [dayIndex]: keys.filter((k) => k !== 'JHN.3') } });
render(<ReaderPage />);
fireEvent.click(screen.getByText(isLast ? /Mark read · Day/ : /^Next: /));
expect(ctx.toggleChapterRead).toHaveBeenCalledWith('p1', dayIndex, 'JHN.3', keys, 30, true);
if (!isLast) expect(ctx.setReaderChapter).toHaveBeenCalledWith(refs[position].chapter);
});
test('"mark read" on the last chapter completes the whole day even if earlier chapters were skipped', () => {
const last = refs.at(-1);
expect(refs.length).toBeGreaterThan(1);
withPlan(); // nothing read yet
ctx.readerBookAbbrev = last.book;
ctx.readerChapter = last.chapter;
try {
render(<ReaderPage />);
fireEvent.click(screen.getByText(/Mark read · Day/));
expect(ctx.toggleDay).toHaveBeenCalledWith('p1', dayIndex, 30);
} finally {
ctx.readerBookAbbrev = 'JHN';
ctx.readerChapter = 3;
}
});
test('plan sheet lists the day, changes the plan translation, and leaves the plan', () => {
withPlan();
render(<ReaderPage />);
fireEvent.click(stripButton());
expect(screen.getByText('Mark the whole day read')).toBeTruthy();
fireEvent.change(screen.getByDisplayValue(/^ESV/), { target: { value: 'NLT' } });
expect(ctx.setReaderTranslation).toHaveBeenCalledWith('NLT');
fireEvent.click(screen.getByText(/Leave plan/));
expect(ctx.setReaderPlan).toHaveBeenCalledWith(null);
});
test('outside a plan but on a plan chapter: offers to open the plan', () => {
withPlan();
ctx.readerPlan = null;
render(<ReaderPage />);
fireEvent.click(screen.getByText('Open plan'));
expect(ctx.setReaderPlan).toHaveBeenCalledWith({ progressId: 'p1', dayIndex });
});
File diff suppressed because it is too large. Load diff
+71
View File
@@ -0,0 +1,71 @@
import { render, screen, fireEvent, within } from '@testing-library/react';
import { vi, test, expect, beforeEach } from 'vitest';
import { getPlanSchedule, formatDayReadings } from '../utils/readingPlans.js';
const fn = () => vi.fn();
const ctx = {};
vi.mock('../context/AppContext.js', () => ({ useApp: () => ctx }));
import ReadingPlansPage from './ReadingPlansPage.jsx';
const record = (over = {}) => ({
id: 'p1', planId: 'gospels-30', startedAt: Date.now(), completedDays: [0, 1], chaptersRead: {}, completedAt: null,
lastActivityAt: Date.now(), translation: 'NLT', ...over,
});
beforeEach(() => {
Object.assign(ctx, {
goHome: fn(), planProgress: [], customPlans: [], readingActivity: [],
startPlan: fn(), toggleDay: fn(), restartPlan: fn(), removePlanProgress: fn(), recalibratePace: fn(), setPlanTranslation: fn(),
addCustomPlan: fn(), removeCustomPlan: fn(), updateCustomPlan: fn(),
openPlanReading: fn(), studyReadingPlanDay: fn(), studyBuildBusy: false,
readingPlanSyncStatus: '', readingPlanSyncError: '', retryReadingPlanSync: fn(),
readerTheme: 'paper', setReaderTheme: fn(), readerTranslation: 'BSB',
});
});
test('with no plans, the catalog is shown and there is no Continue card', () => {
render(<ReadingPlansPage />);
expect(screen.queryByText('Continue reading')).toBeNull();
expect(screen.getByRole('button', { name: /The Gospels in 30 Days/ })).toBeTruthy();
});
test('Continue reading opens the plan at its next unread day, in the plan translation', () => {
ctx.planProgress = [record()];
render(<ReadingPlansPage />);
const day2 = getPlanSchedule('gospels-30')[2];
expect(screen.getAllByText(new RegExp(formatDayReadings(day2)))[0]).toBeTruthy();
expect(screen.getAllByText('NLT').length).toBeGreaterThan(0);
fireEvent.click(screen.getByText('Continue reading'));
expect(ctx.openPlanReading).toHaveBeenCalledWith('p1', 2);
});
test('starting a catalog plan passes the chosen translation', () => {
render(<ReadingPlansPage />);
fireEvent.click(screen.getByRole('button', { name: /The Gospels in 30 Days/ }));
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'CSB' } });
fireEvent.click(screen.getByRole('button', { name: 'Start plan' }));
expect(ctx.startPlan).toHaveBeenCalledWith('gospels-30', 'CSB');
});
test('every reader translation can be chosen for a plan', () => {
render(<ReadingPlansPage />);
fireEvent.click(screen.getByRole('button', { name: /The Gospels in 30 Days/ }));
const options = within(screen.getByRole('combobox')).getAllByRole('option').map((o) => o.value);
expect(options).toEqual(expect.arrayContaining(['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET']));
});
test('an in-progress plan: change its translation, jump into a specific day', () => {
ctx.planProgress = [record()];
render(<ReadingPlansPage />);
fireEvent.click(screen.getAllByRole('button', { name: /The Gospels in 30 Days/ })[0]);
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'ESV' } });
expect(ctx.setPlanTranslation).toHaveBeenCalledWith('p1', 'ESV');
fireEvent.click(screen.getAllByText('Read')[3]); // "Read" on Day 4
expect(ctx.openPlanReading).toHaveBeenCalledWith('p1', 3);
});
test('categories filter the catalog', () => {
render(<ReadingPlansPage />);
fireEvent.click(screen.getByRole('button', { name: 'Old Testament' }));
expect(screen.queryByRole('button', { name: /The Gospels in 30 Days/ })).toBeNull();
});