Version history, notes on highlights and bookmarks, "you've studied this", prayer journal
Build and Push Docker Image / build-and-push (push) Successful in 5m13s

- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of
  editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept)
- Notes on highlights and bookmarks: synced, merged without loss, in exports and backups
- Reader verse panel lists your studies and chunks that cover the verse, and opens them
- Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups
  and the account export; daily prayer reminder through ntfy
- TODO.md lists the ideas not built yet

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:45:32 -04:00
1 parent 54cb1c217d
commit 207bfcb919
45 files changed
+1669 -75

No files matched your search

+34 -4
View File
@@ -130,6 +130,10 @@ export function useBibleReader(currentPage, translationOverride = null) {
const [readerBookmarks, setReaderBookmarks] = useState(() => {
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
});
// A short note on a bookmark: { "JHN-3-16": "text" }. (A note on a highlight lives on the highlight itself.)
const [readerBookmarkNotes, setReaderBookmarkNotes] = useState(() => {
try { const v = JSON.parse(localStorage.getItem('reader-bookmark-notes') || '{}'); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; } catch { return {}; }
});
const [readerBookmarksPanelOpen, setReaderBookmarksPanelOpen] = useState(false);
const [readerJumpVerse, setReaderJumpVerse] = useState(null); // verse number to scroll to + flash after a jump
const [readerCrossRefs, setReaderCrossRefs] = useState(null);
@@ -451,6 +455,29 @@ export function useBibleReader(currentPage, translationOverride = null) {
});
};
const setBookmarkNote = (verseKey, note) => {
setReaderBookmarkNotes((prev) => {
const text = String(note ?? '').slice(0, 500);
const next = { ...prev };
if (text.trim()) next[verseKey] = text; else delete next[verseKey];
try { localStorage.setItem('reader-bookmark-notes', JSON.stringify(next)); } catch {}
return next;
});
};
const setHighlightNote = (id, note) => {
setReaderTextHighlights((prev) => {
const text = String(note ?? '').slice(0, 500);
const next = prev.map((h) => {
if (h.id !== id) return h;
const { note: _old, ...rest } = h;
return text.trim() ? { ...rest, note: text } : rest;
});
try { localStorage.setItem('readerTextHighlights', JSON.stringify(next)); } catch {}
return next;
});
};
// Puts a removed highlight back exactly as it was (used by "Undo").
const restoreTextHighlight = (highlight) => {
setReaderTextHighlights((prev) => {
@@ -495,13 +522,15 @@ export function useBibleReader(currentPage, translationOverride = null) {
// Bookmarks, highlights, colour names and ink from a backup file are added to what is here (nothing is
// removed, and where both have something the copy already here wins).
const importReaderMarks = ({ bookmarks, highlights, colorLabels, ink }) => {
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights }));
const importReaderMarks = ({ bookmarks, bookmarkNotes, highlights, colorLabels, ink }) => {
const merged = mergeMarks(normalizeMarks({ bookmarks, highlights, bookmarkNotes }), normalizeMarks({ bookmarks: readerBookmarks, highlights: readerTextHighlights, bookmarkNotes: readerBookmarkNotes }));
setReaderBookmarks(merged.bookmarks);
setReaderTextHighlights(merged.highlights);
setReaderBookmarkNotes(merged.bookmarkNotes);
try {
localStorage.setItem('reader-bookmarks', JSON.stringify(merged.bookmarks));
localStorage.setItem('readerTextHighlights', JSON.stringify(merged.highlights));
localStorage.setItem('reader-bookmark-notes', JSON.stringify(merged.bookmarkNotes));
} catch {}
if (colorLabels && Object.keys(colorLabels).length > 0) {
setReaderColorLabelsState((prev) => {
@@ -540,8 +569,8 @@ 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,
bookmarks: readerBookmarks, highlights: readerTextHighlights, bookmarkNotes: readerBookmarkNotes,
setBookmarks: setReaderBookmarks, setHighlights: setReaderTextHighlights, setBookmarkNotes: setReaderBookmarkNotes,
});
// Theme, default translation, layout, commentary and reading position follow the account too.
@@ -590,6 +619,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks,
readerBookmarkNotes, setBookmarkNote, setHighlightNote,
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
+74
View File
@@ -0,0 +1,74 @@
// The prayer journal on this device, kept in step with your account. Every change is saved here at once and
// sent to the account a moment later; signing in merges the two (the newer version of each prayer wins), so a prayer
// added on your phone appears on your computer. Signing out clears this device's copy.
import { useCallback, useEffect, useRef, useState } from 'react';
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
import { markAnswered, markDeleted, mergePrayers, newPrayer, prayersEqual, reopen, touch, togglePrayedToday } from '../utils/prayers.js';
import { localDateString } from '../utils/readingPlans.js';
const KEY = 'prayers';
const PUSH_DELAY_MS = 1500;
const load = () => { try { const v = JSON.parse(localStorage.getItem(KEY) || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } };
const save = (list) => { try { localStorage.setItem(KEY, JSON.stringify(list)); } catch { /* storage unavailable */ } };
export function usePrayers() {
const [prayers, setPrayersState] = useState(load);
const ref = useRef(prayers);
ref.current = prayers;
const enabledRef = useRef(false);
const timerRef = useRef(null);
const push = useCallback(async () => {
window.clearTimeout(timerRef.current);
if (!enabledRef.current) return;
const result = await saveRemotePrayers(ref.current);
if (result.ok) {
const merged = mergePrayers(ref.current, result.data?.prayers);
if (!prayersEqual(merged, ref.current)) { ref.current = merged; save(merged); setPrayersState(merged); }
}
}, []);
const set = useCallback((next) => {
ref.current = next;
save(next);
setPrayersState(next);
if (enabledRef.current) { window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); }
}, [push]);
const replace = (id, fn) => set(ref.current.map((p) => (p.id === id ? fn(p) : p)));
const addPrayer = (fields) => { const p = newPrayer(fields); set([p, ...ref.current]); return p; };
const editPrayer = (id, patch) => replace(id, (p) => touch(p, patch));
const togglePrayed = (id) => replace(id, (p) => togglePrayedToday(p, localDateString()));
const answerPrayer = (id, note) => replace(id, (p) => markAnswered(p, note));
const reopenPrayer = (id) => replace(id, (p) => reopen(p));
// Deleting leaves a marker so the other devices drop it too; the returned function puts it back
const deletePrayer = (id) => {
const before = ref.current.find((p) => p.id === id);
if (!before) return () => {};
set(ref.current.map((p) => (p.id === id ? markDeleted(p) : p)));
return () => set(ref.current.map((p) => (p.id === id ? touch(before, {}) : p)));
};
// Prayers from a backup file are added; a prayer already here keeps whichever copy is newer
const importPrayers = (list) => set(mergePrayers(ref.current, list));
const pull = useCallback(async () => {
enabledRef.current = true;
const result = await getRemotePrayers();
if (!result.ok) return;
const merged = mergePrayers(ref.current, result.data?.prayers);
if (!prayersEqual(merged, ref.current)) { ref.current = merged; save(merged); setPrayersState(merged); }
if (!prayersEqual(merged, result.data?.prayers ?? [])) await push();
}, [push]);
const stop = useCallback(() => {
enabledRef.current = false;
window.clearTimeout(timerRef.current);
try { localStorage.removeItem(KEY); } catch { /* storage unavailable */ }
ref.current = [];
setPrayersState([]);
}, []);
useEffect(() => () => window.clearTimeout(timerRef.current), []);
return { prayers, addPrayer, editPrayer, togglePrayed, answerPrayer, reopenPrayer, deletePrayer, importPrayers, pullPrayers: pull, stopPrayers: stop };
}
+57
View File
@@ -0,0 +1,57 @@
import { renderHook, act } from '@testing-library/react';
import { vi, test, expect, beforeEach } from 'vitest';
vi.mock('../syncService.js', () => ({ getRemotePrayers: vi.fn(), saveRemotePrayers: vi.fn() }));
import { usePrayers } from './usePrayers.js';
import { getRemotePrayers, saveRemotePrayers } from '../syncService.js';
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
getRemotePrayers.mockResolvedValue({ ok: true, data: { prayers: [] } });
saveRemotePrayers.mockImplementation(async (list) => ({ ok: true, data: { prayers: list } }));
});
test('prayers are added, changed, answered and kept on this device', () => {
const { result } = renderHook(() => usePrayers());
let id;
act(() => { id = result.current.addPrayer({ text: 'Peace at home', forWhom: 'Family' }).id; });
expect(result.current.prayers[0]).toMatchObject({ text: 'Peace at home', forWhom: 'Family' });
act(() => result.current.editPrayer(id, { text: 'Peace and joy at home' }));
act(() => result.current.togglePrayed(id));
expect(result.current.prayers[0].prayedOn).toHaveLength(1);
act(() => result.current.answerPrayer(id, 'Yes'));
expect(result.current.prayers[0]).toMatchObject({ text: 'Peace and joy at home', answerNote: 'Yes' });
expect(result.current.prayers[0].answeredAt).toBeGreaterThan(0);
act(() => result.current.reopenPrayer(id));
expect(result.current.prayers[0].answeredAt).toBeNull();
expect(JSON.parse(localStorage.getItem('prayers'))[0].text).toBe('Peace and joy at home');
});
test('a deleted prayer can be taken back', () => {
const { result } = renderHook(() => usePrayers());
let id; let undo;
act(() => { id = result.current.addPrayer({ text: 'Keep me' }).id; });
act(() => { undo = result.current.deletePrayer(id); });
expect(result.current.prayers[0].deleted).toBe(true);
act(() => undo());
expect(result.current.prayers[0]).toMatchObject({ id, text: 'Keep me' });
expect(result.current.prayers[0].deleted).toBeUndefined();
});
test('signing in merges with the account and sends back what it lacked', async () => {
localStorage.setItem('prayers', JSON.stringify([{ id: 'local', text: 'Only here', forWhom: '', verses: [], prayedOn: [], createdAt: 1, updatedAt: 5, answeredAt: null, answerNote: '' }]));
getRemotePrayers.mockResolvedValue({ ok: true, data: { prayers: [{ id: 'remote', text: 'Only there', forWhom: '', verses: [], prayedOn: [], createdAt: 1, updatedAt: 6, answeredAt: null, answerNote: '' }] } });
const { result } = renderHook(() => usePrayers());
await act(async () => { await result.current.pullPrayers(); });
expect(result.current.prayers.map((p) => p.id).sort()).toEqual(['local', 'remote']);
expect(saveRemotePrayers).toHaveBeenCalled();
});
test('signing out clears the journal from this device', async () => {
const { result } = renderHook(() => usePrayers());
act(() => { result.current.addPrayer({ text: 'Private' }); });
act(() => result.current.stopPrayers());
expect(result.current.prayers).toEqual([]);
expect(localStorage.getItem('prayers')).toBeNull();
});
+9
View File
@@ -171,6 +171,14 @@ export function useProjectLibrary({
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;
@@ -276,6 +284,7 @@ export function useProjectLibrary({
staleLocalProjects,
autoRestoredCount,
resumeProject,
openStudyChunk,
renameProjectInStorage,
setProjectSeries,
deleteProject,
+11 -9
View File
@@ -6,7 +6,7 @@ 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 KEYS = { bookmarks: 'reader-bookmarks', highlights: 'readerTextHighlights', bookmarkNotes: 'reader-bookmark-notes', syncedAt: 'reader-marks-synced-at', dirty: 'reader-marks-dirty' };
const PUSH_DELAY_MS = 1500;
const FOCUS_PULL_MIN_MS = 20_000;
@@ -14,10 +14,10 @@ const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return nu
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 }) {
export function useReaderMarksSync({ bookmarks, highlights, bookmarkNotes = {}, setBookmarks, setHighlights, setBookmarkNotes = () => {} }) {
const enabledRef = useRef(false);
const latestRef = useRef({ bookmarks, highlights });
latestRef.current = { bookmarks, highlights };
const latestRef = useRef({ bookmarks, highlights, bookmarkNotes });
latestRef.current = { bookmarks, highlights, bookmarkNotes };
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);
@@ -31,13 +31,13 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
if (!enabledRef.current) return;
if (pushingRef.current) { await pushingRef.current; }
const snapshot = latestRef.current;
pushingRef.current = saveRemoteMarks({ bookmarks: snapshot.bookmarks, highlights: snapshot.highlights });
pushingRef.current = saveRemoteMarks({ bookmarks: snapshot.bookmarks, highlights: snapshot.highlights, bookmarkNotes: snapshot.bookmarkNotes });
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);
if (latestRef.current.bookmarks === snapshot.bookmarks && latestRef.current.highlights === snapshot.highlights && latestRef.current.bookmarkNotes === snapshot.bookmarkNotes) setDirty(false);
else timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}, []);
@@ -50,7 +50,7 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
}
}, [bookmarks, highlights, push]);
}, [bookmarks, highlights, bookmarkNotes, push]);
// Puts `next` on screen and in localStorage without treating it as a local edit.
const apply = useCallback((next) => {
@@ -58,9 +58,11 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
skipRef.current = true;
lsSet(KEYS.bookmarks, JSON.stringify(next.bookmarks));
lsSet(KEYS.highlights, JSON.stringify(next.highlights));
lsSet(KEYS.bookmarkNotes, JSON.stringify(next.bookmarkNotes ?? {}));
setBookmarks(next.bookmarks);
setHighlights(next.highlights);
}, [setBookmarks, setHighlights]);
setBookmarkNotes(next.bookmarkNotes ?? {});
}, [setBookmarks, setHighlights, setBookmarkNotes]);
// Sign-in (and tab focus): reconcile this device with the server's copy.
const pullServerMarks = useCallback(async () => {
@@ -103,7 +105,7 @@ export function useReaderMarksSync({ bookmarks, highlights, setBookmarks, setHig
const stopMarksSync = useCallback(() => {
enabledRef.current = false;
window.clearTimeout(timerRef.current);
[KEYS.bookmarks, KEYS.highlights, KEYS.syncedAt, KEYS.dirty].forEach(lsDel);
[KEYS.bookmarks, KEYS.highlights, KEYS.bookmarkNotes, KEYS.syncedAt, KEYS.dirty].forEach(lsDel);
apply(emptyMarks());
}, [apply]);