Add reading plans: catalog, progress tracking, and custom plans

Replaces the old single-book reading goal with a full Reading Plans page:
9 built-in plans (Whole Bible, OT/NT, topical) with cover photos and
color-themed cards, day-by-day checklists synced to the server per user,
inline "mark today read" in the Bible reader, and user-created plans with
their own book picker and an optional uploaded cover photo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-23 12:51:28 -04:00
1 parent f1026dc194
commit 600b3d3ab9
21 files changed
+1166 -159

No files matched your search

-35
View File
@@ -1,35 +0,0 @@
// A simple "read this book in N weeks" plan, stored per browser.
import { useState } from 'react';
import { CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
export function useReadingPlan() {
const [readingPlan, setReadingPlan] = useState(() => {
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
});
const createReadingPlan = (bookAbbrev, bookName, weeks) => {
const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1;
const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] };
setReadingPlan(plan);
try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {}
};
const markChapterRead = (chapter) => {
setReadingPlan((prev) => {
if (!prev) return prev;
const chaptersRead = prev.chaptersRead.includes(chapter)
? prev.chaptersRead.filter((c) => c !== chapter)
: [...prev.chaptersRead, chapter].sort((a, b) => a - b);
const next = { ...prev, chaptersRead };
try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {}
return next;
});
};
const clearReadingPlan = () => {
setReadingPlan(null);
try { localStorage.removeItem('readingPlan'); } catch {}
};
return { readingPlan, createReadingPlan, markChapterRead, clearReadingPlan };
}
+144
View File
@@ -0,0 +1,144 @@
// Tracks which reading plans (see utils/readingPlans.js) a user has started,
// which days they've checked off, which plans they've finished, and any plans
// they've created themselves. Saved locally right away and synced to the
// server in the background — same "local wins immediately, server wins at
// sign-in" pattern as reader ink.
import { useCallback, useState } from 'react';
import { saveRemoteReadingProgress } from '../syncService.js';
import { registerCustomPlans } from '../utils/readingPlans.js';
const STORAGE_KEY = 'readingPlanProgress';
function loadLocal() {
try {
const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
if (Array.isArray(raw)) return { progress: raw, customPlans: [] }; // pre-custom-plans shape
return { progress: raw?.progress ?? [], customPlans: raw?.customPlans ?? [] };
} catch {
return { progress: [], customPlans: [] };
}
}
function persist(next) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
saveRemoteReadingProgress(next); // fire-and-forget; errors are silent, local copy is already safe
}
const makeId = () => (crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`);
export function useReadingPlans() {
const [state, setState] = useState(loadLocal);
// Keeps the readingPlans.js lookup (getPlan/getPlanSchedule) in sync with
// this user's custom plans before any consumer renders this commit.
registerCustomPlans(state.customPlans);
const updateProgress = useCallback((updater) => {
setState((prev) => {
const next = { ...prev, progress: updater(prev.progress) };
persist(next);
return next;
});
}, []);
const updateCustomPlans = useCallback((updater) => {
setState((prev) => {
const next = { ...prev, customPlans: updater(prev.customPlans) };
persist(next);
return next;
});
}, []);
const startPlan = useCallback((planId) => {
updateProgress((prev) => {
if (prev.some((p) => p.planId === planId && !p.completedAt)) return prev;
const record = {
id: makeId(),
planId,
startedAt: Date.now(),
completedDays: [],
completedAt: null,
lastActivityAt: Date.now(),
};
return [record, ...prev];
});
}, [updateProgress]);
const toggleDay = useCallback((progressId, dayIndex, totalDays) => {
updateProgress((prev) => prev.map((p) => {
if (p.id !== progressId) return p;
const already = p.completedDays.includes(dayIndex);
const completedDays = already
? 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() };
}));
}, [updateProgress]);
const restartPlan = useCallback((progressId) => {
updateProgress((prev) => prev.map((p) => (
p.id === progressId
? { ...p, completedDays: [], completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() }
: p
)));
}, [updateProgress]);
const removePlanProgress = useCallback((progressId) => {
updateProgress((prev) => prev.filter((p) => p.id !== progressId));
}, [updateProgress]);
// { title, description, days, books: [abbrev, ...], image?: dataUrl } -> new custom plan id
const addCustomPlan = useCallback(({ title, description, days, books, image }) => {
const id = `custom-${makeId()}`;
updateCustomPlans((prev) => [
{
id,
title: title.trim(),
description: (description ?? '').trim(),
category: 'My Plans',
icon: '✨',
image: image || undefined,
days: Math.max(1, Math.round(days)),
books,
custom: true,
createdAt: Date.now(),
},
...prev,
]);
return id;
}, [updateCustomPlans]);
const removeCustomPlan = useCallback((planId) => {
updateCustomPlans((prev) => prev.filter((p) => p.id !== planId));
updateProgress((prev) => prev.filter((p) => p.planId !== planId));
}, [updateCustomPlans, updateProgress]);
// Patches an existing custom plan (used for setting/changing/removing its cover image).
const updateCustomPlan = useCallback((planId, patch) => {
updateCustomPlans((prev) => prev.map((p) => (p.id === planId ? { ...p, ...patch } : p)));
}, [updateCustomPlans]);
// Server wins over whatever's local at sign-in.
const mergeServerReadingProgress = useCallback((serverData) => {
if (!serverData || typeof serverData !== 'object' || Array.isArray(serverData)) return;
const next = {
progress: Array.isArray(serverData.progress) ? serverData.progress : [],
customPlans: Array.isArray(serverData.customPlans) ? serverData.customPlans : [],
};
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
setState(next);
}, []);
return {
planProgress: state.progress,
customPlans: state.customPlans,
startPlan,
toggleDay,
restartPlan,
removePlanProgress,
addCustomPlan,
removeCustomPlan,
updateCustomPlan,
mergeServerReadingProgress,
};
}