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
@@ -21,15 +21,18 @@ export const READER_TRANSLATIONS = [
|
||||
|
||||
const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
|
||||
|
||||
export function useBibleReader(currentPage) {
|
||||
// `translationOverride` lets a reading plan pin the Bible it is read in while it is
|
||||
// open; otherwise the reader's own (remembered) translation is used.
|
||||
export function useBibleReader(currentPage, translationOverride = null) {
|
||||
const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
|
||||
const [readerChapter, setReaderChapter] = useState(1);
|
||||
const [readerTranslation, setReaderTranslationState] = useState(() => {
|
||||
const [baseTranslation, setReaderTranslationState] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('reader-translation');
|
||||
return READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'BSB';
|
||||
} catch { return 'BSB'; }
|
||||
});
|
||||
const readerTranslation = translationOverride ?? baseTranslation;
|
||||
const [readerCopyright, setReaderCopyright] = useState('');
|
||||
const [readerTheme, setReaderThemeState] = useState(() => {
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../syncService.js', () => ({
|
||||
getRemoteReadingProgress: vi.fn(async () => ({ ok: true, data: {} })),
|
||||
saveRemoteReadingProgress: vi.fn(async () => ({ ok: true })),
|
||||
}));
|
||||
|
||||
import { useReadingPlans } from './useReadingPlans.js';
|
||||
|
||||
beforeEach(() => { localStorage.clear(); });
|
||||
|
||||
const start = (translation) => {
|
||||
const hook = renderHook(() => useReadingPlans());
|
||||
act(() => hook.result.current.startPlan('gospels-90', translation));
|
||||
return hook;
|
||||
};
|
||||
const rec = (hook) => hook.result.current.planProgress[0];
|
||||
|
||||
test('a started plan remembers its translation', () => {
|
||||
const hook = start('ESV');
|
||||
expect(rec(hook).translation).toBe('ESV');
|
||||
act(() => hook.result.current.setPlanTranslation(rec(hook).id, 'NLT'));
|
||||
expect(rec(hook).translation).toBe('NLT');
|
||||
});
|
||||
|
||||
test('ticking every chapter of a day completes it, and unticking reopens it', () => {
|
||||
const hook = start('BSB');
|
||||
const id = rec(hook).id;
|
||||
const keys = ['JHN.8', 'JHN.9'];
|
||||
act(() => hook.result.current.toggleChapterRead(id, 11, 'JHN.8', keys, 90));
|
||||
expect(rec(hook).completedDays).toEqual([]);
|
||||
expect(rec(hook).chaptersRead[11]).toEqual(['JHN.8']);
|
||||
act(() => hook.result.current.toggleChapterRead(id, 11, 'JHN.9', keys, 90));
|
||||
expect(rec(hook).completedDays).toEqual([11]);
|
||||
expect(hook.result.current.readingActivity.length).toBe(1); // streak extended
|
||||
act(() => hook.result.current.toggleChapterRead(id, 11, 'JHN.9', keys, 90));
|
||||
expect(rec(hook).completedDays).toEqual([]);
|
||||
});
|
||||
|
||||
test('force=true never unticks a chapter that is already read', () => {
|
||||
const hook = start('BSB');
|
||||
const id = rec(hook).id;
|
||||
act(() => hook.result.current.toggleChapterRead(id, 0, 'JHN.1', ['JHN.1', 'JHN.2'], 90, true));
|
||||
act(() => hook.result.current.toggleChapterRead(id, 0, 'JHN.1', ['JHN.1', 'JHN.2'], 90, true));
|
||||
expect(rec(hook).chaptersRead[0]).toEqual(['JHN.1']);
|
||||
});
|
||||
|
||||
test('unmarking a whole day clears its chapter ticks', () => {
|
||||
const hook = start('BSB');
|
||||
const id = rec(hook).id;
|
||||
act(() => hook.result.current.toggleDay(id, 3, 90));
|
||||
expect(rec(hook).completedDays).toEqual([3]);
|
||||
act(() => hook.result.current.toggleChapterRead(id, 3, 'JHN.4', ['JHN.4'], 90, true));
|
||||
act(() => hook.result.current.toggleDay(id, 3, 90));
|
||||
expect(rec(hook).completedDays).toEqual([]);
|
||||
expect(rec(hook).chaptersRead[3]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('finishing the last day completes the plan', () => {
|
||||
const hook = start('BSB');
|
||||
const id = rec(hook).id;
|
||||
act(() => { for (let d = 0; d < 90; d += 1) hook.result.current.toggleDay(id, d, 90); });
|
||||
expect(rec(hook).completedAt).toBeTruthy();
|
||||
act(() => hook.result.current.toggleDay(id, 5, 90));
|
||||
expect(rec(hook).completedAt).toBeNull();
|
||||
});
|
||||
Reference in new issue
Block a user