Files
study-bible-project/src/hooks/useProjectLibrary.js
T
nmemmertandClaude Sonnet 5.5 0c8a632cff Add own-work tracking, Claude hand-off check, and weekly recall
- Long pastes into Observe/Interpret/Apply are tagged as pasted; notes show
  Mine / N pasted and an own-words share. Pastes follow text through merges.
- The "-> Claude" buttons on the Script stage ask first when a chunk lacks
  enough of the user's own (non-pasted) words.
- Weekly recall card on Home: write a passage from memory, compare with notes,
  self-rate; recalls are saved on the chunk and synced.
- Recall history on Home with an eight-week strip and the full list.

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

321 lines
14 KiB
JavaScript

// The signed-in user's saved projects: opening, renaming, deleting, and keeping
// this device's copies in step with the server.
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,
loadProjectById,
} from '../utils/projectStorage.js';
import {
saveRemoteProject,
deleteRemoteProject,
listRemoteProjects,
loadRemoteProject,
} from '../syncService.js';
export function useProjectLibrary({
authUser,
project,
setProject,
setProjectIndex,
setCurrentPage,
}) {
const { toast } = useToast();
// Deleted projects waiting out their "Undo" window before the server copy is removed too
const pendingDeletes = useRef(new Map()); // id -> timer
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
// Studies another account invited you to edit: id -> the owner's email (shown on the card)
const [sharedStudies, setSharedStudies] = useState({});
const sharedFrom = (list) => Object.fromEntries(list.filter((e) => e.shared).map((e) => [e.id, e.ownerEmail ?? '']));
// 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
// on the server but not on this device (e.g. a brand new browser) are pulled down
// automatically instead of requiring a manual "Restore" click per project.
useEffect(() => {
if (!authUser) {
setStaleLocalProjects([]);
return;
}
let cancelled = false;
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));
setSharedStudies(sharedFrom(serverList));
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 serverList) {
const local = localMap.get(e.id);
setSyncBase(e.id, local ? Math.min(e.lastEdited ?? 0, local.lastEdited ?? 0) : e.lastEdited);
}
// 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); });
if (cancelled) return;
const restoredCount = fetched.filter((r) => r.ok).length;
if (restoredCount > 0) {
setProjectIndex(loadProjectIndex());
setAutoRestoredCount(restoredCount);
window.setTimeout(() => setAutoRestoredCount(null), 4000);
}
}
const stale = serverList.filter((e) => {
const local = localMap.get(e.id);
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0) && !unsynced.has(e.id);
});
if (!cancelled) setStaleLocalProjects(stale);
pushPending();
});
return () => { cancelled = true; };
}, [authUser]);
// Another device (or a co-teacher) may have saved a study since you opened this page: look again when you come
// back to the tab, and every couple of minutes while it's open. A newer copy is offered, never forced on you.
const refreshStale = async () => {
const result = await listRemoteProjects();
if (!result.ok) return;
const local = new Map(loadProjectIndex().map((e) => [e.id, e]));
const unsynced = new Set(unsyncedStudies.list());
setSharedStudies(sharedFrom(result.data));
setStaleLocalProjects(result.data.filter((e) => {
const mine = local.get(e.id);
return mine && (e.lastEdited ?? 0) > (mine.lastEdited ?? 0) && !unsynced.has(e.id);
}));
};
useEffect(() => {
if (!authUser) return undefined;
let last = Date.now();
const maybe = () => { if (document.visibilityState === 'visible' && Date.now() - last > 30_000) { last = Date.now(); refreshStale(); } };
document.addEventListener('visibilitychange', maybe);
const timer = window.setInterval(maybe, 120_000);
return () => { document.removeEventListener('visibilitychange', maybe); window.clearInterval(timer); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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;
setProject(loaded);
const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks);
const hasSelectedChunk = loaded.selectedChunkId
&& loadedChunks.some((c) => c.id === loaded.selectedChunkId);
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
};
// Opens a study on one particular chunk (from the Reader's "In your studies").
const openStudyChunk = (id, chunkId) => {
const loaded = loadProjectById(id);
if (!loaded) return;
setProject({ ...loaded, selectedChunkId: chunkId });
setCurrentPage('study');
};
const renameProjectInStorage = (id, title) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const updated = { ...loaded, title, lastEdited: Date.now() };
saveProjectToStorage(updated);
setProjectIndex(loadProjectIndex());
unsyncedStudies.add(id);
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
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) {
setProject((current) => (current ? { ...current, title } : current));
}
};
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
const setProjectSeries = (id, series) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const name = String(series ?? '').trim().slice(0, 80);
if ((loaded.series ?? '') === name) return;
const updated = { ...loaded, series: name, lastEdited: Date.now() };
saveProjectToStorage(updated);
unsyncedStudies.add(id);
setProjectIndex(loadProjectIndex());
if (project?.id === id) setProject((current) => (current ? { ...current, series: name } : current));
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
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' });
});
};
// Saves a weekly recall (what you remembered, and how it went) on one chunk of a stored study.
const saveChunkRecall = (id, chunkId, recall) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const updated = {
...loaded,
lastEdited: Date.now(),
chapters: loaded.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, recalls: [...(c.recalls ?? []), recall] } : c)),
})),
};
saveProjectToStorage(updated);
unsyncedStudies.add(id);
setProjectIndex(loadProjectIndex());
if (project?.id === id) setProject(updated);
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
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' });
});
};
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
const finishDelete = (id) => {
if (!pendingDeletes.current.has(id)) return;
window.clearTimeout(pendingDeletes.current.get(id));
pendingDeletes.current.delete(id);
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);
window.addEventListener('pagehide', flush);
return () => window.removeEventListener('pagehide', flush);
}, []);
const deleteProject = (id) => {
const loaded = loadProjectById(id);
if (!loaded) return;
deleteProjectFromStorage(id);
clearSyncBase(id);
unsyncedStudies.remove(id);
pendingDeleteIds.add(id);
setProjectIndex(loadProjectIndex());
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
if (sharedStudies[id] !== undefined) {
// A study someone else owns: this only removes you from it (they keep it). A new invite is needed to rejoin.
toast(`You left “${loaded.title}”. Ask for a new invite if you want back in.`);
return;
}
toast(`Deleted “${loaded.title}”`, {
action: {
label: 'Undo',
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());
},
},
});
};
const pullLatestFromServer = async (id) => {
const result = await loadRemoteProject(id);
if (!result.ok) {
toast('Could not pull the latest version from the server.', { tone: 'error' });
return;
}
saveProjectToStorage(result.data);
setSyncBase(id, result.data.lastEdited);
setProjectIndex(loadProjectIndex());
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
};
return {
sharedStudies,
refreshLibrary: refreshStale,
staleLocalProjects,
autoRestoredCount,
resumeProject,
openStudyChunk,
renameProjectInStorage,
setProjectSeries,
saveChunkRecall,
deleteProject,
pullLatestFromServer,
};
}