Surface reading plan sync failures instead of failing silently
Build and Push Docker Image / build-and-push (push) Successful in 4m34s

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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5 committed 2026-09-23 14:18:09 -04:00
1 parent 787edcbe73
commit b14dbb52cc
3 files changed
+103 -21

No files matched your search

+68 -21
View File
@@ -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,
+17
View File
@@ -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() {
</div>
</div>
)}
{readingPlanSyncStatus === 'error' && (
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-rose-200 bg-rose-50 p-4">
<p className="text-sm text-rose-700">
⚠️ Couldn't sync your reading plans to the server — changes here are only saved on this device until this is fixed.
</p>
<button
type="button"
onClick={retryReadingPlanSync}
className="shrink-0 rounded-xl border border-rose-300 px-3 py-1.5 text-sm font-semibold text-rose-700 hover:bg-rose-100"
>
Retry
</button>
</div>
)}
{/* Reading plans summary — shown regardless of whether there are any study projects yet */}
<ReadingPlansSummaryCard
planProgress={planProgress}
+18
View File
@@ -39,6 +39,9 @@ export default function ReadingPlansPage() {
listenReadingPlanDay,
studyReadingPlanDay,
studyBuildBusy,
readingPlanSyncStatus,
readingPlanSyncError,
retryReadingPlanSync,
} = useApp();
const [builderMode, setBuilderMode] = useState(null); // null | 'new' | a custom plan id to edit
@@ -80,6 +83,21 @@ export default function ReadingPlansPage() {
</header>
<main className="mx-auto max-w-5xl space-y-8 px-4 py-8 sm:px-6 lg:px-8">
{readingPlanSyncStatus === 'error' && (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm">
<span className="text-rose-700">
⚠️ Couldn't sync your reading plans to the server — changes here are only saved on this device until this is fixed.
{readingPlanSyncError && <span className="block text-xs text-rose-500">{readingPlanSyncError}</span>}
</span>
<button
type="button"
onClick={retryReadingPlanSync}
className="shrink-0 rounded-xl border border-rose-300 px-3 py-1.5 font-semibold text-rose-700 hover:bg-rose-100"
>
Retry
</button>
</div>
)}
{activeProgress.length > 0 && (
<TodayPanel
activeProgress={activeProgress}