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

+58 -13
View File
@@ -85,7 +85,7 @@ export function useReadingPlans() {
setState((prev) => ({ ...prev, customPlans: updater(prev.customPlans) }));
}, []);
const startPlan = useCallback((planId) => {
const startPlan = useCallback((planId, translation) => {
updateProgress((prev) => {
if (prev.some((p) => p.planId === planId && !p.completedAt)) return prev;
const record = {
@@ -95,37 +95,80 @@ export function useReadingPlans() {
completedDays: [],
completedAt: null,
lastActivityAt: Date.now(),
translation: translation || undefined, // which Bible this plan is read in (undefined = the reader's default)
chaptersRead: {}, // dayIndex -> ['JHN.8', ...] chapters finished within that day
};
return [record, ...prev];
});
}, [updateProgress]);
const toggleDay = useCallback((progressId, dayIndex, totalDays) => {
// Applies a change to one plan's progress record and keeps the cross-plan
// activity log in step: finishing any day (for the first time today) extends the streak.
const patchProgress = useCallback((progressId, patcher, totalDays) => {
setState((prev) => {
const target = prev.progress.find((p) => p.id === progressId);
if (!target) return prev;
const wasAlready = target.completedDays.includes(dayIndex);
let newlyDone = false;
const progress = prev.progress.map((p) => {
if (p.id !== progressId) return p;
const completedDays = wasAlready
? 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() };
const next = patcher(p);
newlyDone = next.completedDays.length > p.completedDays.length;
const completedAt = totalDays != null && next.completedDays.length >= totalDays ? (p.completedAt ?? Date.now()) : (totalDays != null ? null : p.completedAt);
return { ...next, completedAt, lastActivityAt: Date.now() };
});
// Only marking a day *done* extends the streak; unchecking one doesn't erase it.
const today = localDateString();
const activity = !wasAlready && !prev.activity.includes(today)
? [...prev.activity, today]
: prev.activity;
const activity = newlyDone && !prev.activity.includes(today) ? [...prev.activity, today] : prev.activity;
return { ...prev, progress, activity };
});
}, []);
const withoutDay = (chaptersRead, dayIndex) => {
const { [dayIndex]: _removed, ...rest } = chaptersRead ?? {};
return rest;
};
// Marks a whole day done/undone. Undoing also clears that day's per-chapter ticks.
const toggleDay = useCallback((progressId, dayIndex, totalDays) => {
patchProgress(progressId, (p) => {
const wasAlready = p.completedDays.includes(dayIndex);
return {
...p,
completedDays: wasAlready
? p.completedDays.filter((d) => d !== dayIndex)
: [...p.completedDays, dayIndex].sort((a, b) => a - b),
chaptersRead: wasAlready ? withoutDay(p.chaptersRead, dayIndex) : p.chaptersRead,
};
}, totalDays);
}, [patchProgress]);
// Ticks (or unticks) one chapter of a day. `dayKeys` are all of that day's chapter
// keys ("JHN.8"); once every one is ticked the day completes by itself, and
// unticking a chapter of a finished day reopens it. `force` = true/false sets it
// instead of toggling (used by the auto-mark so it can't accidentally untick).
const toggleChapterRead = useCallback((progressId, dayIndex, chapterKey, dayKeys, totalDays, force) => {
patchProgress(progressId, (p) => {
const read = new Set((p.chaptersRead ?? {})[dayIndex] ?? []);
const want = force ?? !read.has(chapterKey);
if (want) read.add(chapterKey); else read.delete(chapterKey);
const allRead = dayKeys.every((k) => read.has(k));
const isDone = p.completedDays.includes(dayIndex);
let completedDays = p.completedDays;
if (allRead && !isDone) completedDays = [...completedDays, dayIndex].sort((a, b) => a - b);
if (!allRead && isDone) completedDays = completedDays.filter((d) => d !== dayIndex);
return { ...p, completedDays, chaptersRead: { ...(p.chaptersRead ?? {}), [dayIndex]: [...read] } };
}, totalDays);
}, [patchProgress]);
// Which Bible a plan is read in (any translation the reader offers).
const setPlanTranslation = useCallback((progressId, translation) => {
updateProgress((prev) => prev.map((p) => (p.id === progressId ? { ...p, translation } : p)));
}, [updateProgress]);
const restartPlan = useCallback((progressId) => {
updateProgress((prev) => prev.map((p) => (
p.id === progressId
? { ...p, completedDays: [], completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
: p
)));
}, [updateProgress]);
@@ -179,7 +222,7 @@ export function useReadingPlans() {
if (resetProgress) {
updateProgress((prev) => prev.map((p) => (
p.planId === planId
? { ...p, completedDays: [], completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
: p
)));
}
@@ -243,6 +286,8 @@ export function useReadingPlans() {
retryReadingPlanSync,
startPlan,
toggleDay,
toggleChapterRead,
setPlanTranslation,
restartPlan,
removePlanProgress,
recalibratePace,