Reading plans: integrated into the Reader, redesigned, with per-plan translation
Build and Push Docker Image / build-and-push (push) Failing after 19s
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:
1 parent
c11b6a60bf
commit
b1bc1c8ea4
17 files changed
+1205
-863
No files matched your search
+116
-48
@@ -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
|
||||
|
||||
Reference in new issue
Block a user