Preferences in Settings and a home page the user chooses
Build and Push Docker Image / build-and-push (push) Failing after 20s

Settings gets a Preferences section: theme, default Bible, the Bible new reading
plans start in, verse layout, commentary, text size, reading width, audio
narrator (the last three stay per device; text size and narrator are now
remembered). A Home page setting (My Studies, Bible Reader or Reading Plans)
decides where the app opens and where every Home button goes; already being
there means My Studies. Back arrows are labelled Home. Tests cover every page
leading back.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:55:07 -04:00
1 parent 760c1e568b
commit 8c4f54e033
15 files changed
+401 -23

No files matched your search

+10 -1
View File
@@ -5,7 +5,16 @@ import { bookOptions, CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
export function useAudio(readerBookAbbrev, readerChapter, readerTranslation = 'BSB') {
const [audioBook, setAudioBook] = useState(bookOptions[0].abbrev);
const [audioNarrator, setAudioNarrator] = useState('souer');
const [audioNarrator, setAudioNarratorState] = useState(() => {
try {
const saved = localStorage.getItem('audio-narrator');
return ['david', 'hays', 'souer'].includes(saved) ? saved : 'souer';
} catch { return 'souer'; }
});
const setAudioNarrator = (id) => {
setAudioNarratorState(id);
try { localStorage.setItem('audio-narrator', id); } catch { /* storage unavailable */ }
};
const [audioState, setAudioState] = useState({ status: 'idle', chapter: 0, total: 0 });
const [readerAudioState, setReaderAudioState] = useState({ status: 'idle', chapter: 0 });
const [audioProgress, setAudioProgress] = useState({ currentTime: 0, duration: 0 });
+46 -1
View File
@@ -73,7 +73,46 @@ export function useBibleReader(currentPage, translationOverride = null) {
const [readerInterlinearLoading, setReaderInterlinearLoading] = useState(false);
const [readerSelectedVerse, setReaderSelectedVerse] = useState(null); // verse number with open interlinear panel
const _readerInterlinearCacheRef = useRef({});
const [readerFontSize, setReaderFontSize] = useState(1); // em multiplier: 0.875 | 1 | 1.125 | 1.25
// Text size and reading width are per-device (a phone and a tablet want different ones)
const [readerFontSize, setReaderFontSizeState] = useState(() => {
try {
const n = Number(localStorage.getItem('reader-font-size'));
return [0.875, 1, 1.125, 1.25, 1.5].includes(n) ? n : 1;
} catch { return 1; }
}); // em multiplier: 0.875 | 1 | 1.125 | 1.25 | 1.5
const setReaderFontSize = (n) => {
setReaderFontSizeState(n);
try { localStorage.setItem('reader-font-size', String(n)); } catch {}
};
const [readerWide, setReaderWideState] = useState(() => {
try { return localStorage.getItem('reader-wide') === '1'; } catch { return false; }
});
const setReaderWide = (on) => {
setReaderWideState(!!on);
try { localStorage.setItem('reader-wide', on ? '1' : '0'); } catch {}
};
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
const [startPagePref, setStartPagePrefState] = useState(() => {
try {
const saved = localStorage.getItem('start-page');
return ['studies', 'reader', 'plans'].includes(saved) ? saved : 'studies';
} catch { return 'studies'; }
});
const setStartPagePref = (id) => {
setStartPagePrefState(id);
try { localStorage.setItem('start-page', id); } catch {}
};
// Which Bible a newly started reading plan begins in: 'same' = whatever the default Bible is
const [planTranslationPref, setPlanTranslationPrefState] = useState(() => {
try {
const saved = localStorage.getItem('plan-translation');
return saved === 'same' || READER_TRANSLATIONS.some((t) => t.id === saved) ? saved : 'same';
} catch { return 'same'; }
});
const setPlanTranslationPref = (id) => {
setPlanTranslationPrefState(id);
try { localStorage.setItem('plan-translation', id); } catch {}
};
const [readerBookmarks, setReaderBookmarks] = useState(() => {
try { return JSON.parse(localStorage.getItem('reader-bookmarks') || '{}'); } catch { return {}; }
});
@@ -397,12 +436,15 @@ export function useBibleReader(currentPage, translationOverride = null) {
const prefsSync = usePrefsSync({
values: {
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
planTranslation: planTranslationPref, startPage: startPagePref,
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 === 'startPage') { setStartPagePrefState(v); try { localStorage.setItem('start-page', v); } catch {} }
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', 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); }
},
@@ -424,6 +466,9 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerInterlinear,
readerSelectedVerse, setReaderSelectedVerse,
readerFontSize, setReaderFontSize,
readerWide, setReaderWide,
planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks,
readerBookmarksPanelOpen, setReaderBookmarksPanelOpen,
readerCrossRefs, setReaderCrossRefs,
+3 -3
View File
@@ -5,7 +5,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'position'];
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', '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;
@@ -16,7 +16,7 @@ const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* storage un
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 } }
// 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 }) {
@@ -58,7 +58,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
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
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, push]); // eslint-disable-line react-hooks/exhaustive-deps
const pullServerPrefs = useCallback(async () => {
enabledRef.current = true;
+2 -2
View File
@@ -19,7 +19,7 @@ function setup({ canApplyPosition = () => true, initial = {} } = {}) {
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 },
values: { theme, translation: 'BSB', layout, commentarySource: 'matthew-henry', planTranslation: 'same', startPage: 'studies', position },
apply: (key, v) => { if (key === 'theme') setTheme(v); else if (key === 'layout') setLayout(v); else if (key === 'position') setPosition(v); },
canApplyPosition,
});
@@ -45,7 +45,7 @@ test('a device with no history takes the account\'s settings; a first device upl
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']);
expect(Object.keys(remote.puts[0]).sort()).toEqual(['commentarySource', 'layout', 'planTranslation', 'position', 'startPage', 'translation']);
});
test('reading position is not moved under you while the Reader is open', async () => {