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:
1 parent
f1026dc194
commit
600b3d3ab9
21 files changed
+1166
-159
No files matched your search
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user