Files
study-bible-project/src/hooks/useReadingPlans.js
T
nmemmertandClaude Sonnet 5.5 d13f55faf7 Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:08:08 -04:00

341 lines
14 KiB
JavaScript

// 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,
};
}