diff --git a/src/hooks/useReadingPlans.js b/src/hooks/useReadingPlans.js index ceda561..02c8b48 100644 --- a/src/hooks/useReadingPlans.js +++ b/src/hooks/useReadingPlans.js @@ -1,9 +1,10 @@ // 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 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'; +// 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, useRef, useState } from 'react'; import { saveRemoteReadingProgress } from '../syncService.js'; import { localDateString, registerCustomPlans } from '../utils/readingPlans.js'; @@ -23,34 +24,63 @@ function loadLocal() { } } -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()}`); +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 [state, setState] = useState(loadLocal); + const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' + const [syncError, setSyncError] = useState(''); + const isFirstRender = useRef(true); + 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); - const updateState = useCallback((updater) => { - setState((prev) => { - const next = updater(prev); - persist(next); - return next; + // 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 (isFirstRender.current) { + isFirstRender.current = false; + return; + } + 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) => { - updateState((prev) => ({ ...prev, progress: updater(prev.progress) })); - }, [updateState]); + setState((prev) => ({ ...prev, progress: updater(prev.progress) })); + }, []); const updateCustomPlans = useCallback((updater) => { - updateState((prev) => ({ ...prev, customPlans: updater(prev.customPlans) })); - }, [updateState]); + setState((prev) => ({ ...prev, customPlans: updater(prev.customPlans) })); + }, []); const startPlan = useCallback((planId) => { updateProgress((prev) => { @@ -68,7 +98,7 @@ export function useReadingPlans() { }, [updateProgress]); const toggleDay = useCallback((progressId, dayIndex, totalDays) => { - updateState((prev) => { + setState((prev) => { const target = prev.progress.find((p) => p.id === progressId); if (!target) return prev; const wasAlready = target.completedDays.includes(dayIndex); @@ -87,7 +117,7 @@ export function useReadingPlans() { : prev.activity; return { ...prev, progress, activity }; }); - }, [updateState]); + }, []); const restartPlan = useCallback((progressId) => { updateProgress((prev) => prev.map((p) => ( @@ -160,14 +190,31 @@ export function useReadingPlans() { customPlans: Array.isArray(serverData.customPlans) ? serverData.customPlans : [], activity: Array.isArray(serverData.activity) ? serverData.activity : [], }; - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {} + skipNextSyncRef.current = true; setState(next); }, []); + // 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 { planProgress: state.progress, customPlans: state.customPlans, readingActivity: state.activity, + readingPlanSyncStatus: syncStatus, + readingPlanSyncError: syncError, + retryReadingPlanSync, startPlan, toggleDay, restartPlan, diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 98a971d..aa0c463 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -24,6 +24,9 @@ export default function HomePage() { listenReadingPlanDay, studyReadingPlanDay, studyBuildBusy, + readingPlanSyncStatus, + readingPlanSyncError, + retryReadingPlanSync, audioBook, setAudioBook, audioNarrator, setAudioNarrator, audioState, @@ -158,6 +161,20 @@ export default function HomePage() { )} + {readingPlanSyncStatus === 'error' && ( +
+ ⚠️ Couldn't sync your reading plans to the server — changes here are only saved on this device until this is fixed. +
+ +