From b14dbb52cc8a5d08887db35f064a7063bdc7587c Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:18:09 -0400 Subject: [PATCH] Surface reading plan sync failures instead of failing silently Reading plan sync was truly fire-and-forget: a failed PUT to /api/reading-plans left the change stuck on that device with no indication anything was wrong, which is exactly what "doesn't sync across devices" looks like from the outside. Moved the sync call out of the setState updater and into a proper effect keyed on state, so success/failure can be tracked. Both Home and Reading Plans now show a red banner with the actual error (HTTP status or network error) and a Retry button when a sync fails, mirroring how study project sync already reports errors. Verified the full cycle locally: normal sync, a killed-server failure showing the banner, and Retry recovering once the server's back. Co-Authored-By: Claude Sonnet 5 --- src/hooks/useReadingPlans.js | 89 ++++++++++++++++++++++++++-------- src/pages/HomePage.jsx | 17 +++++++ src/pages/ReadingPlansPage.jsx | 18 +++++++ 3 files changed, 103 insertions(+), 21 deletions(-) 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. +

+ +
+ )} {/* Reading plans summary — shown regardless of whether there are any study projects yet */}
+ {readingPlanSyncStatus === 'error' && ( +
+ + ⚠️ Couldn't sync your reading plans to the server — changes here are only saved on this device until this is fixed. + {readingPlanSyncError && {readingPlanSyncError}} + + +
+ )} {activeProgress.length > 0 && (