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>
341 lines
14 KiB
JavaScript
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,
|
|
};
|
|
}
|