Sync bookmarks, highlights and display preferences to the account
Build and Push Docker Image / build-and-push (push) Failing after 19s

Bookmarks and text highlights are saved per user and merged across devices
(union on first contact, newer server copy when this device is clean). Theme,
default translation, verse layout, commentary source and reading position sync
too, each with its own timestamp so the newest change to each setting wins.
The Reader never moves under you while it is open. Signing out clears this
device's marks and forgets sync timestamps.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:45:23 -04:00
1 parent 297f2c7e84
commit 760c1e568b
20 files changed
+976 -18

No files matched your search

+53 -2
View File
@@ -3,6 +3,9 @@
// the same <audio> element.
import { useCallback, useEffect, useRef, useState } from 'react';
import { bookOptions, CHAPTER_COUNTS, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { useReaderMarksSync } from './useReaderMarksSync.js';
import { usePrefsSync } from './usePrefsSync.js';
import { COMMENTARIES } from './useVerseStudy.js';
import { BIBLE_IDB_TTL, getBibleIndexFromIDB, saveBibleIndexToIDB } from '../utils/bibleIndexCache.js';
export const READER_TRANSLATIONS = [
@@ -24,8 +27,26 @@ const BOOKMARK_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fecaca', '#e9d5ff'];
// `translationOverride` lets a reading plan pin the Bible it is read in while it is
// open; otherwise the reader's own (remembered) translation is used.
export function useBibleReader(currentPage, translationOverride = null) {
const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev);
const [readerChapter, setReaderChapter] = useState(1);
// Where you left off (saved on this device, and synced to the account)
const [savedPosition] = useState(() => {
try {
const p = JSON.parse(localStorage.getItem('reader-position') || 'null');
return p && CHAPTER_COUNTS[p.book] && Number.isInteger(p.chapter) && p.chapter >= 1 && p.chapter <= CHAPTER_COUNTS[p.book] ? p : null;
} catch { return null; }
});
const [readerBookAbbrev, setReaderBookAbbrev] = useState(savedPosition?.book ?? bookOptions[0].abbrev);
const [readerChapter, setReaderChapter] = useState(savedPosition?.chapter ?? 1);
const [readerCommentary, setReaderCommentaryState] = useState(() => {
try {
const saved = localStorage.getItem('reader-commentary');
return COMMENTARIES.some((c) => c.id === saved) ? saved : COMMENTARIES[0].id;
} catch { return COMMENTARIES[0].id; }
});
useEffect(() => {
try { localStorage.setItem('reader-position', JSON.stringify({ book: readerBookAbbrev, chapter: readerChapter })); } catch {}
}, [readerBookAbbrev, readerChapter]);
const pageRef = useRef(currentPage);
pageRef.current = currentPage;
const [baseTranslation, setReaderTranslationState] = useState(() => {
try {
const saved = localStorage.getItem('reader-translation');
@@ -138,6 +159,11 @@ export function useBibleReader(currentPage, translationOverride = null) {
try { localStorage.setItem('reader-layout', id); } catch {}
};
const setReaderCommentary = (id) => {
setReaderCommentaryState(id);
try { localStorage.setItem('reader-commentary', id); } catch {}
};
const setReaderTranslation = (id) => {
setReaderTranslationState(id);
try { localStorage.setItem('reader-translation', id); } catch {}
@@ -361,8 +387,31 @@ export function useBibleReader(currentPage, translationOverride = null) {
});
}, []);
// Bookmarks and highlights follow the signed-in user across devices.
const marksSync = useReaderMarksSync({
bookmarks: readerBookmarks, highlights: readerTextHighlights,
setBookmarks: setReaderBookmarks, setHighlights: setReaderTextHighlights,
});
// Theme, default translation, layout, commentary and reading position follow the account too.
const prefsSync = usePrefsSync({
values: {
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
position: { book: readerBookAbbrev, chapter: readerChapter },
},
apply: (key, v) => {
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
else if (key === 'translation') { setReaderTranslationState(v); try { localStorage.setItem('reader-translation', v); } catch {} }
else if (key === 'layout') { setReaderLayoutState(v); try { localStorage.setItem('reader-layout', v); } catch {} }
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
},
canApplyPosition: () => pageRef.current !== 'reader',
});
return {
readerBookAbbrev, setReaderBookAbbrev,
readerCommentary, setReaderCommentary,
readerChapter, setReaderChapter,
readerTranslation, setReaderTranslation,
readerCopyright,
@@ -387,6 +436,8 @@ export function useBibleReader(currentPage, translationOverride = null) {
updateReaderPageInk,
mergeServerInk,
readerTextHighlights,
...marksSync,
...prefsSync,
addTextHighlight,
removeTextHighlight,
bibleIndexStatusFor,
+113
View File
@@ -0,0 +1,113 @@
// Keeps a few display preferences (theme, default translation, verse layout, commentary
// source, and where you left off in the Reader) in step with the signed-in account.
// Every preference has its own timestamp, so the newest change to each one wins and two
// devices changing different settings never overwrite each other.
import { useCallback, useEffect, useRef } from 'react';
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'position'];
const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device }
const PUSH_DELAY_MS = 1000;
const FOCUS_PULL_MIN_MS = 30_000;
const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* storage unavailable */ } };
const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* storage unavailable */ } };
const readMeta = () => { try { return JSON.parse(lsGet(META_KEY) || '{}'); } catch { return {}; } };
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
// values: { theme, translation, layout, commentarySource, position: { book, chapter } }
// apply(key, value): puts a server value on screen and in localStorage
// canApplyPosition(): false while the Reader is open, so a background sync never moves the page under you
export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
const valuesRef = useRef(values);
valuesRef.current = values;
const prevRef = useRef(values);
const enabledRef = useRef(false);
const dirtyRef = useRef(new Set());
const timerRef = useRef(null);
const lastPullRef = useRef(0);
const applyRef = useRef(apply);
applyRef.current = apply;
const canApplyPositionRef = useRef(canApplyPosition);
canApplyPositionRef.current = canApplyPosition;
const push = useCallback(async () => {
window.clearTimeout(timerRef.current);
if (!enabledRef.current || dirtyRef.current.size === 0) return;
const keys = [...dirtyRef.current];
const meta = readMeta();
const prefs = Object.fromEntries(keys.map((k) => [k, { v: valuesRef.current[k], at: meta[k] ?? Date.now() }]));
const result = await saveRemotePrefs(prefs);
if (!result.ok) return; // stays dirty; retried on the next change or when the tab regains focus
keys.forEach((k) => { if (readMeta()[k] === prefs[k].at) dirtyRef.current.delete(k); });
if (dirtyRef.current.size > 0) timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}, []);
// Local changes: stamp them and save shortly.
useEffect(() => {
const prev = prevRef.current;
const changed = PREF_KEYS.filter((k) => !same(prev[k], values[k]));
prevRef.current = values;
if (changed.length === 0) return;
const meta = readMeta();
const now = Date.now();
changed.forEach((k) => { meta[k] = now; dirtyRef.current.add(k); });
lsSet(META_KEY, JSON.stringify(meta));
if (enabledRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}
}, [values.theme, values.translation, values.layout, values.commentarySource, values.position.book, values.position.chapter, push]); // eslint-disable-line react-hooks/exhaustive-deps
const pullServerPrefs = useCallback(async () => {
enabledRef.current = true;
lastPullRef.current = Date.now();
const result = await getRemotePrefs();
if (!result.ok) return;
const server = result.data?.prefs ?? {};
const meta = readMeta();
const now = Date.now();
for (const key of PREF_KEYS) {
const remote = server[key];
const localAt = meta[key] ?? 0;
if (remote && remote.at > localAt) {
if (key === 'position' && !canApplyPositionRef.current()) continue; // don't move the page mid-read
meta[key] = remote.at;
if (!same(valuesRef.current[key], remote.v)) {
prevRef.current = { ...prevRef.current, [key]: remote.v }; // so this isn't mistaken for a local edit
applyRef.current(key, remote.v);
}
dirtyRef.current.delete(key);
} else if (!remote || localAt > remote.at) {
if (!meta[key]) meta[key] = now; // first time on this account: what's on screen becomes the account's
dirtyRef.current.add(key);
}
}
lsSet(META_KEY, JSON.stringify(meta));
await push();
}, [push]);
// Signed out: stop syncing. Values stay as they are on this device; the timestamps are
// forgotten so the next account to sign in here starts from its own settings.
const stopPrefsSync = useCallback(() => {
enabledRef.current = false;
window.clearTimeout(timerRef.current);
dirtyRef.current.clear();
lsDel(META_KEY);
}, []);
useEffect(() => {
const onVisibility = () => {
if (!enabledRef.current) return;
if (document.visibilityState === 'hidden') { push(); return; }
if (Date.now() - lastPullRef.current > FOCUS_PULL_MIN_MS) pullServerPrefs();
};
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, [push, pullServerPrefs]);
useEffect(() => () => window.clearTimeout(timerRef.current), []);
return { pullServerPrefs, stopPrefsSync, flushPrefs: push };
}
+87
View File
@@ -0,0 +1,87 @@
import { renderHook, act } from '@testing-library/react';
import { useState } from 'react';
import { vi, test, expect, beforeEach } from 'vitest';
const remote = { prefs: {}, puts: [] };
vi.mock('../syncService.js', () => ({
getRemotePrefs: vi.fn(async () => ({ ok: true, data: { prefs: remote.prefs } })),
saveRemotePrefs: vi.fn(async (prefs) => { remote.puts.push(prefs); return { ok: true, data: { prefs } }; }),
}));
import { usePrefsSync } from './usePrefsSync.js';
beforeEach(() => { localStorage.clear(); remote.prefs = {}; remote.puts = []; });
// A stand-in for the Reader's state, wired the same way useBibleReader wires it.
function setup({ canApplyPosition = () => true, initial = {} } = {}) {
return renderHook(() => {
const [theme, setTheme] = useState(initial.theme ?? 'paper');
const [layout, setLayout] = useState(initial.layout ?? 'flow');
const [position, setPosition] = useState(initial.position ?? { book: 'GEN', chapter: 1 });
const sync = usePrefsSync({
values: { theme, translation: 'BSB', layout, commentarySource: 'matthew-henry', position },
apply: (key, v) => { if (key === 'theme') setTheme(v); else if (key === 'layout') setLayout(v); else if (key === 'position') setPosition(v); },
canApplyPosition,
});
return { theme, layout, position, setTheme, setLayout, setPosition, ...sync };
});
}
test('the server\'s newer value wins, an older one does not', async () => {
localStorage.setItem('prefs-meta', JSON.stringify({ theme: 500, layout: 900 }));
remote.prefs = { theme: { v: 'dark', at: 800 }, layout: { v: 'verses', at: 100 } };
const h = setup();
await act(async () => { await h.result.current.pullServerPrefs(); });
expect(h.result.current.theme).toBe('dark'); // server is newer
expect(h.result.current.layout).toBe('flow'); // this device changed it later
expect(remote.puts).toHaveLength(1);
expect(Object.keys(remote.puts[0])).toContain('layout'); // ...so the server hears about it
expect(Object.keys(remote.puts[0])).not.toContain('theme');
});
test('a device with no history takes the account\'s settings; a first device uploads its own', async () => {
remote.prefs = { theme: { v: 'sepia', at: 700 } };
const h = setup();
await act(async () => { await h.result.current.pullServerPrefs(); });
expect(h.result.current.theme).toBe('sepia');
// keys the account doesn't have yet are filled in from this device
expect(Object.keys(remote.puts[0]).sort()).toEqual(['commentarySource', 'layout', 'position', 'translation']);
});
test('reading position is not moved under you while the Reader is open', async () => {
remote.prefs = { position: { v: { book: 'JHN', chapter: 3 }, at: 900 }, theme: { v: 'dark', at: 900 } };
const h = setup({ canApplyPosition: () => false });
await act(async () => { await h.result.current.pullServerPrefs(); });
expect(h.result.current.position).toEqual({ book: 'GEN', chapter: 1 }); // stays put
expect(h.result.current.theme).toBe('dark'); // everything else still syncs
});
test('and it is applied when the Reader is not open', async () => {
remote.prefs = { position: { v: { book: 'JHN', chapter: 3 }, at: 900 } };
const h = setup({ canApplyPosition: () => true });
await act(async () => { await h.result.current.pullServerPrefs(); });
expect(h.result.current.position).toEqual({ book: 'JHN', chapter: 3 });
expect(remote.puts.every((p) => !('position' in p))).toBe(true); // applying it isn't mistaken for a local edit
});
test('a local change is stamped and pushed shortly after', async () => {
vi.useFakeTimers();
try {
const h = setup();
await act(async () => { await h.result.current.pullServerPrefs(); });
remote.puts = [];
act(() => h.result.current.setTheme('dark'));
await act(async () => { await vi.advanceTimersByTimeAsync(1200); });
expect(remote.puts).toHaveLength(1);
expect(remote.puts[0].theme.v).toBe('dark');
expect(JSON.parse(localStorage.getItem('prefs-meta')).theme).toBe(remote.puts[0].theme.at);
} finally { vi.useRealTimers(); }
});
test('stopping sync forgets the timestamps so the next account starts from its own settings', async () => {
const h = setup();
await act(async () => { await h.result.current.pullServerPrefs(); });
expect(localStorage.getItem('prefs-meta')).not.toBeNull();
act(() => h.result.current.stopPrefsSync());
expect(localStorage.getItem('prefs-meta')).toBeNull();
});
+124
View File
@@ -0,0 +1,124 @@
// Keeps the Reader's bookmarks and text highlights in step with the signed-in user's
// account, so they follow you between devices. Local edits save to localStorage as they
// always did; while signed in they are also pushed to the server a moment after the last
// change, and pulled when you sign in and whenever you come back to the tab.
import { useCallback, useEffect, useRef } from 'react';
import { getRemoteMarks, saveRemoteMarks } from '../syncService.js';
import { emptyMarks, isEmptyMarks, marksEqual, mergeMarks, normalizeMarks } from '../utils/readerMarks.js';
const KEYS = { bookmarks: 'reader-bookmarks', highlights: 'readerTextHighlights', syncedAt: 'reader-marks-synced-at', dirty: 'reader-marks-dirty' };
const PUSH_DELAY_MS = 1500;
const FOCUS_PULL_MIN_MS = 20_000;
const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* storage unavailable */ } };
const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* storage unavailable */ } };
export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHighlights }) {
const enabledRef = useRef(false);
const latestRef = useRef({ bookmarks, highlights });
latestRef.current = { bookmarks, highlights };
const timerRef = useRef(null);
const skipRef = useRef(false); // set while applying server data, so it isn't mistaken for a local edit
const firstRunRef = useRef(true);
const lastPullRef = useRef(0);
const pushingRef = useRef(null);
const setDirty = (dirty) => (dirty ? lsSet(KEYS.dirty, '1') : lsDel(KEYS.dirty));
const push = useCallback(async () => {
window.clearTimeout(timerRef.current);
if (!enabledRef.current) return;
if (pushingRef.current) { await pushingRef.current; }
const snapshot = latestRef.current;
pushingRef.current = saveRemoteMarks({ bookmarks: snapshot.bookmarks, highlights: snapshot.highlights });
const result = await pushingRef.current;
pushingRef.current = null;
if (!result.ok) return; // stays dirty; retried on the next change or when the tab regains focus
lsSet(KEYS.syncedAt, String(result.data.updatedAt));
// Only clean if nothing changed while the request was in flight
if (latestRef.current.bookmarks === snapshot.bookmarks && latestRef.current.highlights === snapshot.highlights) setDirty(false);
else timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}, []);
// Local edits: remember there's something to save, and save it shortly.
useEffect(() => {
if (firstRunRef.current) { firstRunRef.current = false; return; }
if (skipRef.current) { skipRef.current = false; return; }
setDirty(true);
if (enabledRef.current) {
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}
}, [bookmarks, highlights, push]);
// Puts `next` on screen and in localStorage without treating it as a local edit.
const apply = useCallback((next) => {
if (marksEqual(next, latestRef.current)) return;
skipRef.current = true;
lsSet(KEYS.bookmarks, JSON.stringify(next.bookmarks));
lsSet(KEYS.highlights, JSON.stringify(next.highlights));
setBookmarks(next.bookmarks);
setHighlights(next.highlights);
}, [setBookmarks, setHighlights]);
// Sign-in (and tab focus): reconcile this device with the server's copy.
const pullServerMarks = useCallback(async () => {
enabledRef.current = true;
lastPullRef.current = Date.now();
const result = await getRemoteMarks();
if (!result.ok) return;
const server = normalizeMarks(result.data);
const serverAt = Number(result.data?.updatedAt) || 0;
const local = normalizeMarks(latestRef.current);
const syncedAt = Number(lsGet(KEYS.syncedAt)) || 0;
const dirty = lsGet(KEYS.dirty) === '1';
let next = local;
let needsPush = false;
if (serverAt === 0) {
needsPush = !isEmptyMarks(local); // first time on this account: the server takes what's here
} else if (syncedAt === 0) {
next = mergeMarks(local, server); // first time this device meets the server's copy: keep both
needsPush = !marksEqual(next, server);
} else if (serverAt > syncedAt) {
next = dirty ? mergeMarks(local, server) : server; // another device saved since we last synced
needsPush = dirty && !marksEqual(next, server);
} else {
needsPush = dirty; // server has nothing newer than what we already have
}
apply(next);
if (serverAt > 0) lsSet(KEYS.syncedAt, String(serverAt));
if (needsPush) { setDirty(true); latestRef.current = next; await push(); } else if (serverAt >= syncedAt) setDirty(false);
}, [apply, push]);
// Save anything pending before signing out.
const flushMarks = useCallback(async () => {
if (enabledRef.current && lsGet(KEYS.dirty) === '1') await push();
}, [push]);
// Signed out: stop syncing and clear this device's copy, so the next person to sign in
// here starts clean (their marks come from their own account).
const stopMarksSync = useCallback(() => {
enabledRef.current = false;
window.clearTimeout(timerRef.current);
[KEYS.bookmarks, KEYS.highlights, KEYS.syncedAt, KEYS.dirty].forEach(lsDel);
apply(emptyMarks());
}, [apply]);
// Coming back to the tab (another device may have saved), or leaving it (save now).
useEffect(() => {
const onVisibility = () => {
if (!enabledRef.current) return;
if (document.visibilityState === 'hidden') { if (lsGet(KEYS.dirty) === '1') push(); return; }
if (Date.now() - lastPullRef.current > FOCUS_PULL_MIN_MS) pullServerMarks();
};
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, [push, pullServerMarks]);
useEffect(() => () => window.clearTimeout(timerRef.current), []);
return { pullServerMarks, flushMarks, stopMarksSync };
}
+6 -2
View File
@@ -5,8 +5,12 @@ import { useEffect, useState } from 'react';
const commentaryCache = {}; // "commentaryId/BOOK/chapter" -> data
const interlinearCache = {}; // BOOK -> whole-book interlinear data
export function useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections }) {
const [commentarySource, setCommentarySource] = useState('matthew-henry');
// `commentarySource` / `setCommentarySource` may be passed in to share the choice with the rest of
// the app (it's a synced preference); otherwise the hook keeps its own.
export function useStudyResources({ project, selectedChunk, selectedChunkChapterIndex, collapsedSections, commentarySource: sharedSource, setCommentarySource: setSharedSource }) {
const [ownSource, setOwnSource] = useState('matthew-henry');
const commentarySource = sharedSource ?? ownSource;
const setCommentarySource = setSharedSource ?? setOwnSource;
const [commentaryData, setCommentaryData] = useState(null); // { content: [...] } for current chapter
const [commentaryLoading, setCommentaryLoading] = useState(false);
const [commentaryError, setCommentaryError] = useState('');