// Tracks which reading plans (see utils/readingPlans.js) a user has started, // which days they've checked off, which plans they've finished, any plans // they've created themselves, and a cross-plan activity log (for streaks). // Saved locally right away; synced to the server via an effect that watches // `state` (see below) so sync success/failure can actually be tracked and // shown, instead of being a fire-and-forget call buried inside setState. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { getRemoteReadingProgress, saveRemoteReadingProgress } from '../syncService.js'; import { useToast } from '../components/Toasts.jsx'; import { mergeReadingPlans } from '../utils/backup.js'; import { clampPlanDays, localDateString, normalizeCustomPlan, 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: [], activity: [] }; // pre-custom-plans shape return { progress: raw?.progress ?? [], customPlans: raw?.customPlans ?? [], activity: raw?.activity ?? [], }; } catch { return { progress: [], customPlans: [], activity: [] }; } } const makeId = () => (crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`); function formatSyncError(result) { const message = result.error || 'Network error'; if (!result.status || message.startsWith('HTTP')) return message; return `HTTP ${result.status}: ${message}`; } export function useReadingPlans() { const { toast } = useToast(); const [state, setState] = useState(loadLocal); const stateRef = useRef(state); // the latest state, for taking a copy just before something is removed stateRef.current = state; const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' const [syncError, setSyncError] = useState(''); // The exact object loadLocal() produced at mount. Compared by reference (not // a boolean "first render" flag) so React 18 StrictMode's dev-only double // effect invocation can't defeat the skip-on-load guard below — both // invocations see the same unchanged `state` reference either way. const initialStateRef = useRef(state); const skipNextSyncRef = useRef(false); // set right before a server merge, so we don't PUT straight back what we just GOT // Keeps the readingPlans.js lookup (getPlan/getPlanSchedule) in sync with // this user's custom plans before any consumer renders this commit. registerCustomPlans(state.customPlans); // What the rest of the app sees: plans with more days than chapters are clamped. const customPlans = useMemo(() => state.customPlans.map(normalizeCustomPlan), [state.customPlans]); // Persist locally + sync to the server whenever state changes. Runs as a // real effect (not inside the setState updater) so the network call has a // trustworthy `state` value and its result can update sync status safely. useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch {} if (state === initialStateRef.current) { return; // nothing's actually changed since load — don't sync just because we mounted } if (skipNextSyncRef.current) { skipNextSyncRef.current = false; return; } let cancelled = false; setSyncStatus('syncing'); saveRemoteReadingProgress(state).then((result) => { if (cancelled) return; if (result.ok) { setSyncStatus('synced'); setSyncError(''); } else { setSyncStatus('error'); setSyncError(formatSyncError(result)); console.error('Reading plan sync failed:', result.status, result.error); } }); return () => { cancelled = true; }; }, [state]); const updateProgress = useCallback((updater) => { setState((prev) => ({ ...prev, progress: updater(prev.progress) })); }, []); const updateCustomPlans = useCallback((updater) => { setState((prev) => ({ ...prev, customPlans: updater(prev.customPlans) })); }, []); const startPlan = useCallback((planId, translation) => { 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(), 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]); // 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; let newlyDone = false; const progress = prev.progress.map((p) => { if (p.id !== progressId) return p; 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 = 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]); // Restarting and removing are immediate, with an "Undo" toast that puts the plan back exactly as it was. const restartPlan = useCallback((progressId) => { const before = stateRef.current.progress.find((p) => p.id === progressId); updateProgress((prev) => prev.map((p) => ( p.id === progressId ? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() } : p ))); if (before) toast('Plan restarted from day 1', { action: { label: 'Undo', onClick: () => updateProgress((prev) => prev.map((p) => (p.id === progressId ? before : p))) } }); }, [updateProgress, toast]); const removePlanProgress = useCallback((progressId) => { const index = stateRef.current.progress.findIndex((p) => p.id === progressId); const before = stateRef.current.progress[index]; updateProgress((prev) => prev.filter((p) => p.id !== progressId)); if (before) { toast('Plan removed', { action: { label: 'Undo', onClick: () => updateProgress((prev) => (prev.some((p) => p.id === progressId) ? prev : [...prev.slice(0, index), before, ...prev.slice(index)])), }, }); } }, [updateProgress, toast]); // Resets the pace clock so a plan that's fallen behind schedule stops being // flagged as "behind" — doesn't touch which days are actually marked done. const recalibratePace = useCallback((progressId) => { updateProgress((prev) => prev.map((p) => ( p.id === progressId ? { ...p, startedAt: Date.now() - p.completedDays.length * 86400000, lastActivityAt: Date.now() } : p ))); }, [updateProgress]); // { title, description, days, books: [abbrev, ...], chapterRanges?, image?: dataUrl } -> new custom plan id const addCustomPlan = useCallback(({ title, description, days, books, chapterRanges, image }) => { const id = `custom-${makeId()}`; updateCustomPlans((prev) => [ { id, title: title.trim(), description: (description ?? '').trim(), category: 'My Plans', icon: '✨', image: image || undefined, days: clampPlanDays(books, chapterRanges, days), books, chapterRanges: chapterRanges && Object.keys(chapterRanges).length > 0 ? chapterRanges : undefined, custom: true, createdAt: Date.now(), }, ...prev, ]); return id; }, [updateCustomPlans]); const removeCustomPlan = useCallback((planId) => { const plan = stateRef.current.customPlans.find((p) => p.id === planId); const runs = stateRef.current.progress.filter((p) => p.planId === planId); updateCustomPlans((prev) => prev.filter((p) => p.id !== planId)); updateProgress((prev) => prev.filter((p) => p.planId !== planId)); if (plan) { toast(`Deleted “${plan.title}”`, { action: { label: 'Undo', onClick: () => { updateCustomPlans((prev) => (prev.some((p) => p.id === planId) ? prev : [plan, ...prev])); updateProgress((prev) => [...runs.filter((r) => !prev.some((p) => p.id === r.id)), ...prev]); }, }, }); } }, [updateCustomPlans, updateProgress, toast]); // Patches an existing custom plan. Pass resetProgress: true when the edit // changes its books/days (day indices from before the edit no longer mean // anything), which restarts any in-progress runs of it from day 1. const updateCustomPlan = useCallback((planId, patch, { resetProgress = false } = {}) => { updateCustomPlans((prev) => prev.map((p) => (p.id === planId ? { ...p, ...patch } : p))); if (resetProgress) { updateProgress((prev) => prev.map((p) => ( p.planId === planId ? { ...p, completedDays: [], chaptersRead: {}, completedAt: null, startedAt: Date.now(), lastActivityAt: Date.now() } : p ))); } }, [updateCustomPlans, updateProgress]); // Plans from a backup file are added to what is here (nothing is removed). const importReadingPlans = useCallback((incoming) => { setState((prev) => ({ ...prev, ...mergeReadingPlans(prev, incoming) })); }, []); // Server wins over whatever's local at sign-in. const mergeServerReadingProgress = useCallback((serverData) => { if (!serverData || typeof serverData !== 'object' || Array.isArray(serverData)) { console.warn('Reading plan pull: server returned unexpected data', serverData); return; } const next = { progress: Array.isArray(serverData.progress) ? serverData.progress : [], customPlans: Array.isArray(serverData.customPlans) ? serverData.customPlans : [], activity: Array.isArray(serverData.activity) ? serverData.activity : [], }; // Temporary diagnostic — remove once cross-device sync is confirmed fixed. console.log( `Reading plans pulled from server: ${next.progress.length} in progress, ${next.customPlans.length} custom plan(s).`, next, ); skipNextSyncRef.current = true; setState(next); }, []); // Fetches the server's copy at sign-in. Unlike the old inline version of // this call, a failed GET is now visible (same error banner as a failed // push) instead of silently leaving you on stale/empty local state — which // looks identical to "you have no reading plans" with zero indication why. const pullServerReadingProgress = useCallback(async () => { const result = await getRemoteReadingProgress(); if (result.ok) { mergeServerReadingProgress(result.data); } else { setSyncStatus('error'); setSyncError(formatSyncError(result)); console.error('Reading plan pull failed:', result.status, result.error); } }, [mergeServerReadingProgress]); // Manual retry after a failed sync (surfaced next to the sync error). const retryReadingPlanSync = useCallback(() => { setSyncStatus('syncing'); saveRemoteReadingProgress(state).then((result) => { if (result.ok) { setSyncStatus('synced'); setSyncError(''); } else { setSyncStatus('error'); setSyncError(formatSyncError(result)); } }); }, [state]); return { readingPlansState: state, importReadingPlans, planProgress: state.progress, customPlans, readingActivity: state.activity, readingPlanSyncStatus: syncStatus, readingPlanSyncError: syncError, retryReadingPlanSync, startPlan, toggleDay, toggleChapterRead, setPlanTranslation, restartPlan, removePlanProgress, recalibratePace, addCustomPlan, removeCustomPlan, updateCustomPlan, mergeServerReadingProgress, pullServerReadingProgress, }; }