Files
study-bible-project/src/hooks/usePrefsSync.js
T
nmemmertandClaude Sonnet 5.5 d13f55faf7 Add Outline stage, parallel reading, offline saves, backup, sync-conflict handling and more
Study
- New Outline stage (hierarchical I./A./1. points, linked to chunks, in all exports);
  the chunk stage is now "Chunks"
- Home cards: passages shown as ranges, tags capped with "+N more"

Reader
- Side-by-side translations and a Compare tab per verse
- Typeface, line spacing, verse-number settings; text and column scale with the screen
- Highlights/bookmarks sheet with named colours (synced); BSB footnote spacing fix
- Cmd-K palette (passages, studies, notes, marks), keyboard shortcuts and "?" help

Flow and safety
- Toasts with Undo replace most confirm()/alert()
- Back/Forward move between pages
- Server refuses saves over a newer copy (409); keep-mine / use-other banner;
  opening a study no longer re-stamps it
- Latest chapter request wins when flipping quickly

Offline and data
- Save books, plan days or a whole BSB/NET for offline
- Service worker fixes: helloao /api/ URLs were skipped and responses cloned too late
- Settings: download/restore a full backup

Accessibility
- Sheets trap focus and close on Escape, skip link, focus ring, reduced motion,
  theme contrast raised to 4.5:1 (with a test)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:08:08 -04:00

115 lines
5.5 KiB
JavaScript

// 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', 'planTranslation', 'startPage', 'position', 'colorLabels'];
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, planTranslation, startPage, 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.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, 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) {
if (valuesRef.current[key] === undefined) continue; // nothing on this device to compare or upload
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 };
}