Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s

Sync and data safety
- Autosave flushes on leaving a study or the tab; storage-full errors are reported
- Unsynced studies, deletes and ink are queued and retried when back online
- Deletes propagate across devices (with a "Keep it" undo) and no longer resurrect
- Server assigns study versions (no client-clock conflicts); "Combine both"
  merges a conflicted study chunk by chunk
- Reading-plan progress made offline is merged, not overwritten; plans, ink and
  marks are cleared from the device on sign-out

Security
- Block SSRF through the ntfy reminder URL; failure-only rate limiting per IP and
  account; constant-time login; TOTP replay protection; input validation;
  SESSION_SECRET required in production; admin user delete fixed
- Forgot password: one-time code sent to the account's ntfy topic

Features
- IQ Bible proxy (route allow-list, text-only caching per its terms): more
  commentaries, lexicons, study aids, daily verse and devotional, plan calendar
  export, narrations, podcast background
- Study templates, reading-plan catch-up, account export, daily database snapshots

Tests and tooling
- ESLint config; App tests split into src/test; tests for sync queues, library
  hook, merge, templates, IQ Bible UI and reset form

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 08:13:36 -04:00
1 parent 6bb392933a
commit f274bc9c8a
67 files changed
+4734 -1569

No files matched your search

+11 -4
View File
@@ -8,7 +8,7 @@ export function useAudio(readerBookAbbrev, readerChapter, readerTranslation = 'B
const [audioNarrator, setAudioNarratorState] = useState(() => {
try {
const saved = localStorage.getItem('audio-narrator');
return ['david', 'hays', 'souer'].includes(saved) ? saved : 'souer';
return (['david', 'hays', 'souer'].includes(saved) || /^iq:[\w-]+$/.test(saved ?? '')) ? saved : 'souer';
} catch { return 'souer'; }
});
const setAudioNarrator = (id) => {
@@ -30,13 +30,20 @@ export function useAudio(readerBookAbbrev, readerChapter, readerTranslation = 'B
// recording; every other translation falls back to the BSB narrators.
const readerTranslationRef = useRef(readerTranslation);
readerTranslationRef.current = readerTranslation;
const readerAudioSource = () => (readerTranslationRef.current === 'ESV' ? 'ESV' : 'BSB');
const readerAudioSource = () => (audioNarratorRef.current?.startsWith('iq:') ? 'IQ' : readerTranslationRef.current === 'ESV' ? 'ESV' : 'BSB');
const playAudioChapter = async (abbrev, chapterNum, narrator) => {
const source = audioModeRef.current === 'reader' ? readerAudioSource() : 'BSB';
const iqId = narrator?.startsWith('iq:') ? narrator.slice(3) : null;
const source = iqId ? 'IQ' : audioModeRef.current === 'reader' ? readerAudioSource() : 'BSB';
let url;
let total;
if (source === 'ESV') {
if (iqId) {
// IQ Bible narration: the server mints a short-lived signed link (one monthly stream each)
const res = await fetch(`/api/iq/audio/${encodeURIComponent(iqId)}/${abbrev}/${chapterNum}`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load IQ Bible audio');
url = (await res.json()).file;
total = CHAPTER_COUNTS[abbrev] ?? chapterNum;
} else if (source === 'ESV') {
const res = await fetch(`/api/bible/ESV/audio/${abbrev}/${chapterNum}`, { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load ESV audio');
url = (await res.json()).url;
+50 -8
View File
@@ -373,6 +373,41 @@ export function useBibleReader(currentPage, translationOverride = null) {
});
};
// Pages whose ink the server hasn't accepted yet. Kept in localStorage so a failed save (offline,
// tab closed mid-save) is retried later instead of being lost.
const inkLatestRef = useRef(readerInkByPage);
inkLatestRef.current = readerInkByPage;
const inkDirtyKeys = () => { try { return JSON.parse(localStorage.getItem('readerInkDirty') ?? '[]'); } catch { return []; } };
const setInkDirty = (key, on) => {
const keys = new Set(inkDirtyKeys());
if (on) keys.add(key); else keys.delete(key);
try { localStorage.setItem('readerInkDirty', JSON.stringify([...keys])); } catch {}
};
const pushInkPage = useCallback(async (key, strokes) => {
const [bookAbbrev, chapter] = key.split('_');
try {
const res = await fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ strokes }),
});
if (res.ok || res.status === 400 || res.status === 413) setInkDirty(key, false); // 4xx won't ever succeed: don't retry
} catch { /* stays dirty; retried when back online or on the tab */ }
}, []);
const flushInk = useCallback(() => {
inkDirtyKeys().forEach((key) => {
const strokes = inkLatestRef.current[key];
if (Array.isArray(strokes)) pushInkPage(key, strokes); else setInkDirty(key, false);
});
}, [pushInkPage]);
useEffect(() => {
const onVisible = () => { if (document.visibilityState === 'hidden') { Object.keys(inkSaveTimerRef.current).forEach((k) => clearTimeout(inkSaveTimerRef.current[k])); } flushInk(); };
window.addEventListener('online', flushInk);
document.addEventListener('visibilitychange', onVisible);
return () => { window.removeEventListener('online', flushInk); document.removeEventListener('visibilitychange', onVisible); };
}, [flushInk]);
const updateReaderPageInk = useCallback((bookAbbrev, chapter, strokes) => {
const key = `${bookAbbrev}_${chapter}`;
setReaderInkByPage((prev) => {
@@ -380,16 +415,11 @@ export function useBibleReader(currentPage, translationOverride = null) {
try { localStorage.setItem('readerInkByPage', JSON.stringify(next)); } catch {}
return next;
});
setInkDirty(key, true);
// Debounce server sync — wait 1 s after last stroke before sending
clearTimeout(inkSaveTimerRef.current[key]);
inkSaveTimerRef.current[key] = setTimeout(() => {
fetch(`/api/reader/ink/${encodeURIComponent(bookAbbrev)}/${encodeURIComponent(chapter)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strokes }),
}).catch(() => {}); // silent — localStorage already saved locally
}, 1000);
}, []);
inkSaveTimerRef.current[key] = setTimeout(() => pushInkPage(key, strokes), 1000);
}, [pushInkPage]);
const cycleBookmarkColor = (verseKey) => {
setReaderBookmarks((prev) => {
@@ -479,11 +509,22 @@ export function useBibleReader(currentPage, translationOverride = null) {
// Merges reader ink loaded from the server at sign-in; server wins over local.
const mergeServerInk = useCallback((serverInk) => {
const dirty = new Set(inkDirtyKeys());
setReaderInkByPage((local) => {
// Server wins, except pages drawn on here that the server hasn't received yet.
const merged = { ...local, ...serverInk };
dirty.forEach((k) => { if (local[k]) merged[k] = local[k]; });
try { localStorage.setItem('readerInkByPage', JSON.stringify(merged)); } catch {}
return merged;
});
flushInk();
}, [flushInk]);
// Signing out clears this account's ink from the device.
const stopInkSync = useCallback(() => {
Object.keys(inkSaveTimerRef.current).forEach((k) => clearTimeout(inkSaveTimerRef.current[k]));
['readerInkByPage', 'readerInkDirty'].forEach((k) => { try { localStorage.removeItem(k); } catch {} });
setReaderInkByPage({});
}, []);
// Bookmarks and highlights follow the signed-in user across devices.
@@ -547,6 +588,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerInkByPage,
updateReaderPageInk,
mergeServerInk,
stopInkSync,
importReaderMarks,
readerTextHighlights,
...marksSync,
+12 -28
View File
@@ -4,9 +4,11 @@
import { useEffect, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { fetchStudyChapter } from '../utils/studyText.js';
import { templateStarter } from '../utils/studyTemplates.js';
// Every chunk starts blank; all note fields exist from the start.
export function blankChunk(start, end, spilloverEndVerse = null) {
export function blankChunk(start, end, spilloverEndVerse = null, templateId = 'blank') {
return {
id: makeId(),
startVerse: start,
@@ -22,6 +24,7 @@ export function blankChunk(start, end, spilloverEndVerse = null) {
episodeTitle: '',
finalScript: '',
tags: [],
...templateStarter(templateId),
};
}
@@ -81,7 +84,7 @@ export function uncoveredRuns(chunks, from, to) {
function initialSetupForm(project) {
const last = project?.chapters?.at(-1);
if (!last) {
return { translation: 'BSB', book: 'Titus', bookAbbrev: 'TIT', chapter: '1', title: 'Titus 1 Study' };
return { translation: 'BSB', book: 'Titus', bookAbbrev: 'TIT', chapter: '1', title: 'Titus 1 Study', template: 'blank' };
}
const next = Math.min(Number(last.chapter) + 1, CHAPTER_COUNTS[last.bookAbbrev] ?? Number(last.chapter));
return {
@@ -155,11 +158,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
setErrorMessage('');
setLoadingChapter(true);
try {
const response = await fetch(
`https://bible.helloao.org/api/${setup.translation}/${setup.bookAbbrev}/${setup.chapter}.json`,
);
if (!response.ok) throw new Error('Unable to load chapter.');
const data = await response.json();
const data = await fetchStudyChapter(setup.translation, setup.bookAbbrev, setup.chapter);
const verses = parseBibleChapter(data);
if (!data || !Array.isArray(verses) || verses.length === 0) {
throw new Error('Invalid Bible data returned.');
@@ -195,6 +194,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
id: makeId(),
title: setup.title,
translation: setup.translation,
templateId: setup.template ?? 'blank',
lastEdited: Date.now(),
selectedChunkId: null,
chapters: [
@@ -237,22 +237,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
);
if (overlap) return ch;
const newChunk = {
id: makeId(),
startVerse: start,
endVerse: end,
spilloverEndVerse,
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
generalNotes: '',
episodeNumber: '',
episodeTitle: '',
finalScript: '',
tags: [],
};
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateId);
return { ...ch, chunks: [...ch.chunks, newChunk] };
});
// Find the newly added chunk id
@@ -301,7 +286,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
skippedCount += 1;
return;
}
const newChunk = blankChunk(start, end, spilloverEndVerse);
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateId);
existing.add(key);
newChunks.push(newChunk);
addedCount += 1;
@@ -592,12 +577,12 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
if (i >= 0) {
const c = chunks[i];
chunks[i] = { ...c, endVerse: n, spilloverEndVerse: null };
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null));
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null, current.templateId));
return { ...ch, chunks };
}
if (chunks.some((c) => c.startVerse <= n && n <= c.endVerse)) return ch; // already a boundary
const start = Math.max(0, ...chunks.filter((c) => c.endVerse < n).map((c) => c.endVerse)) + 1;
const created = blankChunk(start, n);
const created = blankChunk(start, n, null, current.templateId);
selected = created.id;
const at = chunks.findIndex((c) => c.startVerse > n);
chunks.splice(at < 0 ? chunks.length : at, 0, created);
@@ -637,8 +622,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
try {
let starts = [];
try {
const res = await fetch(`https://bible.helloao.org/api/${project?.translation ?? 'BSB'}/${activeChapter.bookAbbrev}/${activeChapter.chapter}.json`);
if (res.ok) starts = headingStarts(await res.json());
starts = headingStarts(await fetchStudyChapter(project?.translation ?? 'BSB', activeChapter.bookAbbrev, activeChapter.chapter));
} catch { /* headings are a nicety — fall back to even chunks */ }
const usedHeadings = starts.length > 0;
const first = activeChapter.verses[0]?.number ?? 1;
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';
import { iqAvailable, iqGet } from '../utils/iqbible.js';
// Whether the server has an IQ Bible key. false until known, so IQ-only UI never flashes up.
export function useIqAvailable() {
const [on, setOn] = useState(false);
useEffect(() => {
let live = true;
iqAvailable().then((v) => { if (live) setOn(v); });
return () => { live = false; };
}, []);
return on;
}
// { status: 'idle' | 'loading' | 'ready' | 'empty' | 'error', data, error } for one IQ Bible call.
// A null path means "don't fetch".
export function useIq(path, query) {
const [state, setState] = useState({ status: path ? 'loading' : 'idle', data: null, error: '' });
const key = path ? `${path}?${JSON.stringify(query ?? {})}` : '';
useEffect(() => {
if (!path) { setState({ status: 'idle', data: null, error: '' }); return undefined; }
let live = true;
setState({ status: 'loading', data: null, error: '' });
iqGet(path, query).then((r) => {
if (!live) return;
if (r.ok) setState({ status: 'ready', data: r.data, error: '' });
else if (r.status === 404) setState({ status: 'empty', data: null, error: '' });
else setState({ status: 'error', data: null, error: r.error });
});
return () => { live = false; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return state;
}
+79 -6
View File
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react';
import { useToast } from '../components/Toasts.jsx';
import { clearSyncBase, getSyncBase, setSyncBase } from '../utils/syncBase.js';
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
import { knownOnServer, pendingDeletes as pendingDeleteIds, unsyncedStudies } from '../utils/syncQueue.js';
import {
saveProjectToStorage,
deleteProjectFromStorage,
@@ -29,6 +30,33 @@ export function useProjectLibrary({
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
// Studies saved here that the server hasn't accepted yet (saved offline, or the tab closed mid-sync).
// A study another device changed meanwhile is left alone; opening it offers the keep-mine/keep-theirs choice.
const pushPending = async () => {
for (const id of unsyncedStudies.list()) {
const local = loadProjectById(id);
if (!local) { unsyncedStudies.remove(id); continue; }
const result = await saveRemoteProject(local, { baseEdited: getSyncBase(id) });
if (result.ok) {
const serverEdited = result.data?.lastEdited ?? local.lastEdited;
setSyncBase(id, serverEdited);
unsyncedStudies.remove(id);
knownOnServer.add(id);
try { saveProjectToStorage({ ...local, lastEdited: serverEdited }); } catch { /* already saved */ }
} else if (result.status === 409) {
toast(`“${local.title}” changed on another device. Open it to choose which version to keep.`, { tone: 'error' });
}
}
setProjectIndex(loadProjectIndex());
};
const retryPendingDeletes = async () => {
for (const id of pendingDeleteIds.list()) {
if (pendingDeletes.current.has(id)) continue; // still inside its Undo window
const result = await deleteRemoteProject(id);
if (result.ok) { pendingDeleteIds.remove(id); knownOnServer.remove(id); }
}
};
// Once we know who's signed in, reconcile the local project index against the server.
// Runs on every authUser change (including logout -> different login) so a previous
// account's stale suggestions never linger after switching users. Projects that exist
@@ -43,15 +71,40 @@ export function useProjectLibrary({
const localIndex = loadProjectIndex();
listRemoteProjects().then(async (result) => {
if (!result.ok || cancelled) return;
// Deletes that never reached the server (offline, tab closed) go first, so those studies aren't pulled back.
// (the server's list above may still include them, so note which they are before they're cleared)
const gone = new Set(pendingDeleteIds.list());
await retryPendingDeletes();
const serverList = result.data.filter((e) => !gone.has(e.id));
const localMap = new Map(localIndex.map((e) => [e.id, e]));
// What this device has seen of each study on the server: a save made on top of anything older is
// checked against it, so a newer copy from another device is never overwritten silently.
for (const e of result.data) {
for (const e of serverList) {
const local = localMap.get(e.id);
setSyncBase(e.id, local ? Math.min(e.lastEdited ?? 0, local.lastEdited ?? 0) : e.lastEdited);
}
const missing = result.data.filter((e) => !localMap.has(e.id));
// A study this device knew the server had, that the server no longer has, was deleted somewhere else.
const onServer = new Set(serverList.map((e) => e.id));
const unsynced = new Set(unsyncedStudies.list());
const deletedElsewhere = localIndex.filter((e) => knownOnServer.has(e.id) && !onServer.has(e.id) && !unsynced.has(e.id) && !gone.has(e.id));
deletedElsewhere.forEach((e) => {
const copy = loadProjectById(e.id);
deleteProjectFromStorage(e.id);
clearSyncBase(e.id);
knownOnServer.remove(e.id);
localMap.delete(e.id);
toast(`“${e.title}” was deleted on another device`, {
action: {
label: 'Keep it',
onClick: () => { if (copy) { saveProjectToStorage(copy); unsyncedStudies.add(copy.id); setProjectIndex(loadProjectIndex()); pushPending(); } },
},
});
});
if (deletedElsewhere.length > 0) setProjectIndex(loadProjectIndex());
knownOnServer.replace(serverList.map((e) => e.id));
const missing = serverList.filter((e) => !localMap.has(e.id));
if (missing.length > 0) {
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
fetched.filter((r) => r.ok).forEach((r) => { saveProjectToStorage(r.data); setSyncBase(r.data.id, r.data.lastEdited); });
@@ -64,15 +117,23 @@ export function useProjectLibrary({
}
}
const stale = result.data.filter((e) => {
const stale = serverList.filter((e) => {
const local = localMap.get(e.id);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0) && !unsynced.has(e.id);
});
if (!cancelled) setStaleLocalProjects(stale);
pushPending();
});
return () => { cancelled = true; };
}, [authUser]);
// Back online: send what the server missed.
useEffect(() => {
const online = () => { if (authUser) { retryPendingDeletes(); pushPending(); } };
window.addEventListener('online', online);
return () => window.removeEventListener('online', online);
}, [authUser]);
const resumeProject = (id) => {
const loaded = loadProjectById(id);
if (!loaded) return;
@@ -89,8 +150,14 @@ export function useProjectLibrary({
const updated = { ...loaded, title, lastEdited: Date.now() };
saveProjectToStorage(updated);
setProjectIndex(loadProjectIndex());
unsyncedStudies.add(id);
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
if (result.ok) setSyncBase(id, updated.lastEdited);
if (result.ok) {
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
setSyncBase(id, serverEdited);
unsyncedStudies.remove(id);
try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ }
}
else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
});
if (project?.id === id) {
@@ -104,7 +171,9 @@ export function useProjectLibrary({
if (!pendingDeletes.current.has(id)) return;
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
deleteRemoteProject(id); // fire-and-forget
deleteRemoteProject(id).then((result) => { // stays on the pending list until the server confirms
if (result.ok) { pendingDeleteIds.remove(id); knownOnServer.remove(id); }
});
};
useEffect(() => {
const flush = () => [...pendingDeletes.current.keys()].forEach(finishDelete);
@@ -117,6 +186,8 @@ export function useProjectLibrary({
if (!loaded) return;
deleteProjectFromStorage(id);
clearSyncBase(id);
unsyncedStudies.remove(id);
pendingDeleteIds.add(id);
setProjectIndex(loadProjectIndex());
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
toast(`Deleted “${loaded.title}”`, {
@@ -125,6 +196,8 @@ export function useProjectLibrary({
onClick: () => {
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
pendingDeleteIds.remove(id);
unsyncedStudies.add(id); // re-sent in case the server copy was already removed
saveProjectToStorage(loaded);
setProjectIndex(loadProjectIndex());
},
+107
View File
@@ -0,0 +1,107 @@
import { renderHook, act, waitFor } from '@testing-library/react';
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
const toasts = [];
vi.mock('../components/Toasts.jsx', () => ({ useToast: () => ({ toast: (msg, opts) => toasts.push({ msg, opts }) }) }));
vi.mock('../syncService.js', () => ({
listRemoteProjects: vi.fn(),
loadRemoteProject: vi.fn(),
saveRemoteProject: vi.fn(),
deleteRemoteProject: vi.fn(),
}));
import { useProjectLibrary } from './useProjectLibrary.js';
import * as sync from '../syncService.js';
import { saveProjectToStorage, loadProjectById } from '../utils/projectStorage.js';
import { loadProjectIndex, switchStorageUser } from '../utils/bibleUtils.jsx';
import { knownOnServer, pendingDeletes, unsyncedStudies } from '../utils/syncQueue.js';
const study = (id, title = 'A study') => ({ id, title, lastEdited: 100, translation: 'BSB', chapters: [{ book: 'John', bookAbbrev: 'JHN', chapter: '3', verses: [], chunks: [] }] });
const setup = (authUser = { id: 'u1' }) => renderHook(() => useProjectLibrary({
authUser, project: null, setProject: vi.fn(), setProjectIndex: vi.fn(), setCurrentPage: vi.fn(),
}));
beforeEach(() => {
localStorage.clear();
switchStorageUser('u1');
toasts.length = 0;
vi.clearAllMocks();
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [] });
sync.deleteRemoteProject.mockResolvedValue({ ok: true });
sync.saveRemoteProject.mockResolvedValue({ ok: true, data: { lastEdited: 500 } });
});
afterEach(() => { vi.useRealTimers(); switchStorageUser(null); });
test('deleting removes the study here at once and asks the server only after the undo window', async () => {
vi.useFakeTimers();
saveProjectToStorage(study('s1'));
const hook = setup(null);
act(() => hook.result.current.deleteProject('s1'));
expect(loadProjectById('s1')).toBeNull();
expect(pendingDeletes.list()).toEqual(['s1']);
expect(sync.deleteRemoteProject).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(8100); });
expect(sync.deleteRemoteProject).toHaveBeenCalledWith('s1');
expect(pendingDeletes.list()).toEqual([]); // the server confirmed
});
test('a delete the server did not confirm stays queued to be retried', async () => {
vi.useFakeTimers();
sync.deleteRemoteProject.mockResolvedValue({ ok: false, status: null, error: 'Network error' });
saveProjectToStorage(study('s1'));
const hook = setup(null);
act(() => hook.result.current.deleteProject('s1'));
await act(async () => { await vi.advanceTimersByTimeAsync(8100); });
expect(pendingDeletes.list()).toEqual(['s1']);
});
test('undo puts the study back and cancels the server delete', async () => {
vi.useFakeTimers();
saveProjectToStorage(study('s1', 'Keep me'));
const hook = setup(null);
act(() => hook.result.current.deleteProject('s1'));
act(() => toasts.at(-1).opts.action.onClick());
expect(loadProjectById('s1').title).toBe('Keep me');
expect(pendingDeletes.list()).toEqual([]);
await act(async () => { await vi.advanceTimersByTimeAsync(9000); });
expect(sync.deleteRemoteProject).not.toHaveBeenCalled();
});
test('a study deleted on the server but still queued to delete is not pulled back down at sign-in', async () => {
pendingDeletes.add('gone');
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [{ id: 'gone', title: 'Gone', lastEdited: 5 }] });
setup();
await waitFor(() => expect(sync.deleteRemoteProject).toHaveBeenCalledWith('gone'));
expect(sync.loadRemoteProject).not.toHaveBeenCalled();
expect(loadProjectById('gone')).toBeNull();
});
test('a study another device deleted is removed here, with a way to keep it', async () => {
saveProjectToStorage(study('s2', 'Old study'));
knownOnServer.add('s2'); // the server had it when we last looked
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [] });
setup();
await waitFor(() => expect(loadProjectById('s2')).toBeNull());
const note = toasts.find((t) => t.msg.includes('Old study'));
expect(note.msg).toMatch(/deleted on another device/);
act(() => note.opts.action.onClick());
expect(loadProjectById('s2').title).toBe('Old study');
expect(unsyncedStudies.list()).toContain('s2');
});
test('a study never seen on the server (made offline) is sent up, not deleted', async () => {
saveProjectToStorage(study('s3', 'Made offline'));
unsyncedStudies.add('s3');
setup();
await waitFor(() => expect(sync.saveRemoteProject).toHaveBeenCalled());
expect(loadProjectById('s3').title).toBe('Made offline');
await waitFor(() => expect(unsyncedStudies.list()).toEqual([]));
expect(loadProjectIndex()).toHaveLength(1);
});
test('studies only on the server are pulled down', async () => {
sync.listRemoteProjects.mockResolvedValue({ ok: true, data: [{ id: 'far', title: 'From elsewhere', lastEdited: 9 }] });
sync.loadRemoteProject.mockResolvedValue({ ok: true, data: study('far', 'From elsewhere') });
setup();
await waitFor(() => expect(loadProjectById('far')?.title).toBe('From elsewhere'));
});
+1 -1
View File
@@ -75,7 +75,7 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
const dirty = lsGet(KEYS.dirty) === '1';
let next = local;
let needsPush = false;
let needsPush;
if (serverAt === 0) {
needsPush = !isEmptyMarks(local); // first time on this account: the server takes what's here
} else if (syncedAt === 0) {
+36 -6
View File
@@ -11,6 +11,8 @@ import { mergeReadingPlans } from '../utils/backup.js';
import { clampPlanDays, localDateString, normalizeCustomPlan, registerCustomPlans } from '../utils/readingPlans.js';
const STORAGE_KEY = 'readingPlanProgress';
const DIRTY_KEY = 'readingPlanDirty'; // set while this device has changes the server hasn't accepted yet
const lsDirty = (on) => { try { if (on === undefined) return localStorage.getItem(DIRTY_KEY) === '1'; if (on) localStorage.setItem(DIRTY_KEY, '1'); else localStorage.removeItem(DIRTY_KEY); } catch { /* storage unavailable */ } return false; };
function loadLocal() {
try {
@@ -69,10 +71,12 @@ export function useReadingPlans() {
}
let cancelled = false;
lsDirty(true);
setSyncStatus('syncing');
saveRemoteReadingProgress(state).then((result) => {
if (cancelled) return;
if (result.ok) {
lsDirty(false);
setSyncStatus('synced');
setSyncError('');
} else {
@@ -200,12 +204,14 @@ export function useReadingPlans() {
// 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) => {
const before = stateRef.current.progress.find((p) => p.id === progressId);
updateProgress((prev) => prev.map((p) => (
p.id === progressId
? { ...p, startedAt: Date.now() - p.completedDays.length * 86400000, lastActivityAt: Date.now() }
: p
)));
}, [updateProgress]);
if (before) toast('Caught up — your pace starts again from today', { action: { label: 'Undo', onClick: () => updateProgress((prev) => prev.map((p) => (p.id === progressId ? before : p))) } });
}, [updateProgress, toast]);
// { title, description, days, books: [abbrev, ...], chapterRanges?, image?: dataUrl } -> new custom plan id
const addCustomPlan = useCallback(({ title, description, days, books, chapterRanges, image }) => {
@@ -277,11 +283,12 @@ export function useReadingPlans() {
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,
);
// Changes this device made that never reached the server (offline, failed push) are merged in,
// not thrown away; otherwise the server's copy simply wins.
if (lsDirty()) {
setState((local) => ({ ...local, ...mergeReadingPlans(next, local) }));
return;
}
skipNextSyncRef.current = true;
setState(next);
}, []);
@@ -306,6 +313,7 @@ export function useReadingPlans() {
setSyncStatus('syncing');
saveRemoteReadingProgress(state).then((result) => {
if (result.ok) {
lsDirty(false);
setSyncStatus('synced');
setSyncError('');
} else {
@@ -315,7 +323,29 @@ export function useReadingPlans() {
});
}, [state]);
// Back online, or back on the tab: send anything the server missed.
useEffect(() => {
const retry = () => {
if (!lsDirty() || document.visibilityState === 'hidden') return;
saveRemoteReadingProgress(stateRef.current).then((result) => {
if (result.ok) { lsDirty(false); setSyncStatus('synced'); setSyncError(''); }
});
};
window.addEventListener('online', retry);
document.addEventListener('visibilitychange', retry);
return () => { window.removeEventListener('online', retry); document.removeEventListener('visibilitychange', retry); };
}, []);
// Signing out leaves nothing of this account's plans on the device for the next person.
const stopReadingPlansSync = useCallback(() => {
try { localStorage.removeItem(STORAGE_KEY); } catch { /* storage unavailable */ }
lsDirty(false);
skipNextSyncRef.current = true;
setState({ progress: [], customPlans: [], activity: [] });
}, []);
return {
stopReadingPlansSync,
readingPlansState: state,
importReadingPlans,
planProgress: state.progress,
+27
View File
@@ -65,3 +65,30 @@ test('finishing the last day completes the plan', () => {
act(() => hook.result.current.toggleDay(id, 5, 90));
expect(rec(hook).completedAt).toBeNull();
});
const progressRec = (id, extra = {}) => ({ id, planId: 'gospels-90', startedAt: 1, completedDays: [], completedAt: null, lastActivityAt: 1, chaptersRead: {}, ...extra });
test('progress this device made offline is merged with the server copy, not overwritten by it', () => {
localStorage.setItem('readingPlanProgress', JSON.stringify({ progress: [progressRec('mine', { completedDays: [0, 1], lastActivityAt: 50 })], customPlans: [], activity: ['2026-10-01'] }));
localStorage.setItem('readingPlanDirty', '1'); // changes the server never accepted
const hook = renderHook(() => useReadingPlans());
act(() => hook.result.current.mergeServerReadingProgress({ progress: [progressRec('theirs')], customPlans: [], activity: ['2026-10-02'] }));
expect(hook.result.current.planProgress.map((p) => p.id).sort()).toEqual(['mine', 'theirs']);
expect(hook.result.current.readingActivity).toEqual(['2026-10-01', '2026-10-02']);
expect(hook.result.current.planProgress.find((p) => p.id === 'mine').completedDays).toEqual([0, 1]);
});
test('with nothing unsaved, the server copy simply wins', () => {
localStorage.setItem('readingPlanProgress', JSON.stringify({ progress: [progressRec('old')], customPlans: [], activity: [] }));
const hook = renderHook(() => useReadingPlans());
act(() => hook.result.current.mergeServerReadingProgress({ progress: [progressRec('server')], customPlans: [], activity: [] }));
expect(hook.result.current.planProgress.map((p) => p.id)).toEqual(['server']);
});
test('signing out clears the plans from this device', () => {
const hook = start('BSB');
expect(hook.result.current.planProgress).toHaveLength(1);
act(() => hook.result.current.stopReadingPlansSync());
expect(hook.result.current.planProgress).toEqual([]);
expect(localStorage.getItem('readingPlanDirty')).toBeNull();
});