Harden sync and security; add IQ Bible, templates, password reset, backups
Build and Push Docker Image / build-and-push (push) Successful in 7m14s
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:
1 parent
6bb392933a
commit
f274bc9c8a
67 files changed
+4734
-1569
No files matched your search
+11
-4
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
},
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user