diff --git a/server/prefs.js b/server/prefs.js
index 6bc514b..dfe7f90 100644
--- a/server/prefs.js
+++ b/server/prefs.js
@@ -5,6 +5,8 @@ const ENUMS = {
theme: ['paper', 'sepia', 'dark'],
translation: ['BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'],
layout: ['flow', 'verses'],
+ startPage: ['studies', 'reader', 'plans'], // where the app opens and where Home buttons go
+ planTranslation: ['same', 'BSB', 'ESV', 'NLT', 'CSB', 'NASB', 'NET'], // which Bible new reading plans start in
commentarySource: ['matthew-henry', 'john-gill', 'jamieson-fausset-brown', 'tyndale', 'adam-clarke', 'john-calvin', 'keil-delitzsch'],
};
const DAY_MS = 86_400_000;
diff --git a/server/prefs.test.js b/server/prefs.test.js
index 3f3f87e..ebb3704 100644
--- a/server/prefs.test.js
+++ b/server/prefs.test.js
@@ -14,6 +14,11 @@ test('only known preferences with valid values are accepted', () => {
expect(cleanPrefValue('translation', 'KJV')).toBeUndefined();
expect(cleanPrefValue('layout', 'flow')).toBe('flow');
expect(cleanPrefValue('commentarySource', 'tyndale')).toBe('tyndale');
+ expect(cleanPrefValue('startPage', 'reader')).toBe('reader');
+ expect(cleanPrefValue('startPage', 'admin')).toBeUndefined();
+ expect(cleanPrefValue('planTranslation', 'same')).toBe('same');
+ expect(cleanPrefValue('planTranslation', 'NASB')).toBe('NASB');
+ expect(cleanPrefValue('planTranslation', 'KJV')).toBeUndefined();
expect(cleanPrefValue('fontSize', 2)).toBeUndefined(); // not a synced preference
});
diff --git a/src/App.jsx b/src/App.jsx
index b55f004..3153bd7 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -71,7 +71,7 @@ const App = () => {
reader.stopMarksSync();
reader.stopPrefsSync();
setProjectIndex(switchStorageUser(null));
- goHome();
+ goStudies();
},
});
const library = useProjectLibrary({
@@ -174,7 +174,7 @@ const App = () => {
// player) so this works even without opening the reader first.
const listenReadingPlanDay = (bookAbbrev, chapter) => {
audio.handlePlayBookAudioFrom(bookAbbrev, chapter);
- goHome();
+ goStudies();
};
// Builds a new chunk-builder project seeded with a reading-plan day's
@@ -228,12 +228,34 @@ const App = () => {
setCurrentPage('import');
};
- const goHome = () => {
+ // The My Studies page.
+ const goStudies = () => {
setProjectIndex(loadProjectIndex());
setCurrentPage('home');
setProject(null);
};
+ // "Home": the page the user chose in Settings (My Studies, the Bible Reader, or Reading
+ // Plans). If they're already on it, Home means My Studies, so there is always somewhere to go.
+ const START_PAGES = { studies: 'home', reader: 'reader', plans: 'reading-plans' };
+ const goHome = () => {
+ const target = START_PAGES[reader.startPagePref] ?? 'home';
+ if (target === 'home' || target === currentPage) { goStudies(); return; }
+ setProjectIndex(loadProjectIndex());
+ setProject(null);
+ setCurrentPage(target);
+ };
+
+ // Open the app on the chosen home page (once, as soon as we know who is signed in).
+ const openedStartPageRef = useRef(false);
+ useEffect(() => {
+ if (authUser === undefined || openedStartPageRef.current) return;
+ openedStartPageRef.current = true;
+ const target = START_PAGES[reader.startPagePref] ?? 'home';
+ if (target !== 'home') setCurrentPage(target);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [authUser]);
+
// Manual retry after a failed background sync (shown in the project header).
const retrySync = async () => {
if (!project) return;
@@ -251,6 +273,7 @@ const App = () => {
// Navigation
currentPage, setCurrentPage,
goHome,
+ goStudies,
authServerDown,
// Auth (login/2FA/settings form state lives in AuthPage and SettingsPage)
authUser, setAuthUser,
diff --git a/src/App.test.jsx b/src/App.test.jsx
index 4eab0d5..6f2e967 100644
--- a/src/App.test.jsx
+++ b/src/App.test.jsx
@@ -509,6 +509,17 @@ describe('Admin page', () => {
expect(fetchMock.mock.calls.some(([url, o = {}]) => url === '/api/admin/users/u2' && o.method === 'DELETE')).toBe(true);
});
+ test('the Admin page leads back to the dashboard', async () => {
+ stubAdminApi(admin);
+ const u = userEvent.setup();
+ render();
+ await openAccountMenu(u);
+ await u.click(await screen.findByRole('menuitem', { name: /admin/i }));
+ await screen.findByText('student@example.com');
+ await u.click(screen.getByRole('button', { name: /^back$/i }));
+ expect(await screen.findByText('My Studies')).toBeInTheDocument();
+ });
+
test('non-admins get no Admin button', async () => {
stubAdminApi({ ...admin, isAdmin: false });
render();
@@ -593,7 +604,7 @@ describe('Home page', () => {
await renderApp();
await user.selectOptions(screen.getByDisplayValue('Last edited'), 'title');
await user.click(screen.getByRole('button', { name: /read bible/i }));
- await user.click(await screen.findByRole('button', { name: /back to studies/i }));
+ await user.click(await screen.findByRole('button', { name: /back to home/i }));
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
});
@@ -619,7 +630,7 @@ describe('Home page', () => {
expect(screen.getByText('ESV')).toBeInTheDocument(); // plan strip shows the plan's translation
expect(fetchMock.mock.calls.some(([u]) => String(u).startsWith('/api/bible/ESV/MAT/1'))).toBe(true);
// Leaving the reader ends the plan context: the reader's own translation applies again
- await user.click(screen.getByRole('button', { name: /back to studies/i }));
+ await user.click(screen.getByRole('button', { name: /back to home/i }));
await user.click(screen.getByRole('button', { name: /read bible/i }));
expect(await screen.findByText(/Matthew 1 · BSB/)).toBeInTheDocument();
});
@@ -1128,6 +1139,77 @@ describe('Preference sync', () => {
await waitFor(() => expect(localStorage.getItem('reader-commentary')).toBe('tyndale'));
});
+ async function openSettings(u) {
+ await u.click(await screen.findByRole('button', { name: /account menu/i }));
+ await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
+ return screen.findByRole('region', { name: 'Preferences' });
+ }
+
+ test('Settings shows your preferences, and choosing a default Bible saves it and syncs it', async () => {
+ const server = stubPrefsServer({ translation: { v: 'BSB', at: 10 } });
+ const u = userEvent.setup();
+ await renderApp();
+ await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
+ server.puts.length = 0;
+ const prefs = within(await openSettings(u));
+ expect(prefs.getByLabelText('Default Bible')).toHaveValue('BSB');
+ await u.selectOptions(prefs.getByLabelText('Default Bible'), 'ESV');
+ expect(localStorage.getItem('reader-translation')).toBe('ESV');
+ await waitFor(() => expect(server.puts).toHaveLength(1), { timeout: 4000 });
+ expect(server.puts[0].translation.v).toBe('ESV');
+ });
+
+ test('theme, verse layout, commentary and new-plan Bible can all be set from Settings', async () => {
+ stubPrefsServer();
+ const u = userEvent.setup();
+ await renderApp();
+ const prefs = within(await openSettings(u));
+ await u.click(within(prefs.getByRole('group', { name: 'Theme' })).getByRole('button', { name: 'Dark' }));
+ await u.click(within(prefs.getByRole('group', { name: 'Verse layout' })).getByRole('button', { name: 'One per line' }));
+ await u.selectOptions(prefs.getByLabelText('Commentary'), 'tyndale');
+ await u.selectOptions(prefs.getByLabelText('New reading plans'), 'NLT');
+ expect(localStorage.getItem('reader-theme')).toBe('dark');
+ expect(localStorage.getItem('reader-layout')).toBe('verses');
+ expect(localStorage.getItem('reader-commentary')).toBe('tyndale');
+ expect(localStorage.getItem('plan-translation')).toBe('NLT');
+ });
+
+ test('text size, reading width and narrator are remembered on this device (not synced)', async () => {
+ const server = stubPrefsServer();
+ const u = userEvent.setup();
+ await renderApp();
+ await waitFor(() => expect(server.puts.length).toBeGreaterThan(0));
+ server.puts.length = 0;
+ const prefs = within(await openSettings(u));
+ await u.click(within(prefs.getByRole('group', { name: 'Text size' })).getByRole('button', { name: 'XL' }));
+ await u.click(within(prefs.getByRole('group', { name: 'Reading width' })).getByRole('button', { name: 'Two columns' }));
+ await u.selectOptions(prefs.getByLabelText('Audio narrator'), 'hays');
+ expect(localStorage.getItem('reader-font-size')).toBe('1.25');
+ expect(localStorage.getItem('reader-wide')).toBe('1');
+ expect(localStorage.getItem('audio-narrator')).toBe('hays');
+ await new Promise((r) => setTimeout(r, 1500));
+ expect(server.puts).toHaveLength(0); // per-device settings stay off the account
+ });
+
+ test('Settings leads back to the dashboard', async () => {
+ stubPrefsServer();
+ const u = userEvent.setup();
+ await renderApp();
+ await openSettings(u);
+ await u.click(screen.getByRole('button', { name: /bible study home/i }));
+ expect(await screen.findByText('My Studies')).toBeInTheDocument();
+ });
+
+ test('a new reading plan starts in the Bible chosen for new plans', async () => {
+ localStorage.setItem('plan-translation', 'CSB');
+ stubPrefsServer({ planTranslation: { v: 'CSB', at: 10 } });
+ const u = userEvent.setup();
+ await renderApp();
+ await u.click(screen.getByRole('button', { name: /reading plans/i }));
+ await u.click(await screen.findByRole('button', { name: /The Gospels in 30 Days/ }));
+ expect(screen.getByRole('combobox')).toHaveValue('CSB');
+ });
+
test('signing out forgets the sync timestamps', async () => {
stubPrefsServer({ theme: { v: 'dark', at: 900 } });
const u = userEvent.setup();
@@ -1138,3 +1220,134 @@ describe('Preference sync', () => {
await waitFor(() => expect(localStorage.getItem('prefs-meta')).toBeNull());
});
});
+
+// ---------------------------------------------------------------------------
+// Every page has a way back to the dashboard
+// ---------------------------------------------------------------------------
+describe('Back to the dashboard', () => {
+ const home = /back to home|bible study home/i;
+ const dashboard = async () => expect(await screen.findByText('My Studies')).toBeInTheDocument();
+
+ test.each([
+ ['the Reader', async (u) => { await u.click(screen.getByRole('button', { name: /read bible/i })); await screen.findByText(/Genesis 1 · BSB/); }],
+ ['Reading Plans', async (u) => { await u.click(screen.getByRole('button', { name: /reading plans/i })); await screen.findByText(/browse plans/i); }],
+ ['Import', async (u) => { await u.click(screen.getByRole('button', { name: /import session list/i })); await screen.findByRole('heading', { name: /import session list/i }); }],
+ ])('%s', async (_name, open) => {
+ const u = userEvent.setup();
+ await renderApp();
+ await open(u);
+ await u.click(screen.getByRole('button', { name: home }));
+ await dashboard();
+ });
+
+ test.each(['Outline', 'Study', 'Script', 'Export'])('the %s stage of a study', async (stage) => {
+ const u = await loadChapter();
+ addChunk('Paul, a servant', 'In hope of eternal');
+ await screen.findByText('1 created');
+ await u.click(screen.getByRole('button', { name: new RegExp(`^${stage}$`, 'i') }));
+ await screen.findByRole('navigation', { name: /study stages/i });
+ await u.click(screen.getByRole('button', { name: home }));
+ await dashboard();
+ });
+
+ test('the Pronunciation guide', async () => {
+ const u = await loadChapter();
+ addChunk('Paul, a servant', 'Paul, a servant');
+ await screen.findByText('1 created');
+ await u.click(screen.getByRole('button', { name: /^script$/i }));
+ await u.click(await screen.findByRole('button', { name: /pronunciation guide/i }));
+ await screen.findByRole('heading', { name: /pronunciation guide/i });
+ await u.click(screen.getByRole('button', { name: home }));
+ await dashboard();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The home page the user chooses
+// ---------------------------------------------------------------------------
+describe('Chosen home page', () => {
+ const homeBtn = /back to home|bible study home/i;
+
+ test('the app opens on the chosen page', async () => {
+ localStorage.setItem('start-page', 'reader');
+ render();
+ expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
+ expect(screen.queryByText('My Studies')).not.toBeInTheDocument();
+ });
+
+ test('by default the app opens on My Studies', async () => {
+ await renderApp();
+ expect(screen.getByText('My Studies')).toBeInTheDocument();
+ });
+
+ test('Home buttons go to the chosen page, and to My Studies when you are already on it', async () => {
+ localStorage.setItem('start-page', 'reader');
+ const u = userEvent.setup();
+ render();
+ await screen.findByText(/Genesis 1 · BSB/);
+ // already on the chosen page: Home means My Studies
+ await u.click(screen.getByRole('button', { name: homeBtn }));
+ expect(await screen.findByText('My Studies')).toBeInTheDocument();
+ // from another page, Home is the chosen page
+ await u.click(screen.getByRole('button', { name: /reading plans/i }));
+ await screen.findByText(/browse plans/i);
+ await u.click(screen.getByRole('button', { name: homeBtn }));
+ expect(await screen.findByText(/Genesis 1 · BSB/)).toBeInTheDocument();
+ });
+
+ test('the logo on a page with an app bar goes to the chosen page, and Studies always goes to My Studies', async () => {
+ localStorage.setItem('start-page', 'plans');
+ const u = userEvent.setup();
+ render();
+ await screen.findByText(/browse plans/i); // opened on Reading Plans
+ await u.click(screen.getByRole('button', { name: homeBtn })); // already there -> My Studies
+ await screen.findByText('My Studies');
+ await u.click(screen.getByRole('button', { name: /import session list/i }));
+ await screen.findByRole('heading', { name: /import session list/i });
+ await u.click(screen.getByRole('button', { name: /bible study home/i }));
+ expect(await screen.findByText(/browse plans/i)).toBeInTheDocument();
+ await u.click(screen.getByRole('button', { name: homeBtn })); // -> My Studies again
+ await u.click(screen.getByRole('button', { name: /import session list/i }));
+ await u.click(screen.getByRole('button', { name: /^studies$/i }));
+ expect(await screen.findByText('My Studies')).toBeInTheDocument();
+ });
+
+ test('leaving a study with a different home page closes the study', async () => {
+ localStorage.setItem('start-page', 'plans');
+ const u = userEvent.setup();
+ render();
+ await screen.findByText(/browse plans/i);
+ await u.click(screen.getByRole('button', { name: homeBtn })); // to My Studies
+ await u.click(screen.getAllByRole('button', { name: /new project/i })[0]);
+ await u.click(await screen.findByRole('button', { name: /load chapter/i }));
+ await screen.findByText(/Scripture & Chunks/i);
+ await u.click(screen.getByRole('button', { name: homeBtn }));
+ expect(await screen.findByText(/browse plans/i)).toBeInTheDocument();
+ });
+
+ test('Settings changes it, and it is saved for next time', async () => {
+ const user = { id: 'u1', email: 'reader@example.com', totpEnabled: false, podcastName: null, isAdmin: false };
+ const json = (status, data) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(data) });
+ const fallback = buildFetchMock();
+ vi.stubGlobal('fetch', vi.fn((url, opts = {}) => {
+ if (!url.startsWith('/api/')) return fallback(url, opts);
+ const route = `${opts.method ?? 'GET'} ${url}`;
+ if (route === 'GET /api/auth/me') return json(200, user);
+ if (route === 'GET /api/projects') return json(200, []);
+ if (route === 'GET /api/reader/ink') return json(200, {});
+ if (route === 'GET /api/reader/marks') return json(200, { bookmarks: {}, highlights: [], updatedAt: 0 });
+ if (route === 'GET /api/prefs') return json(200, { prefs: {} });
+ if (route === 'PUT /api/prefs') return json(200, { prefs: {} });
+ return json(404, { error: 'not mocked' });
+ }));
+ const u = userEvent.setup();
+ await renderApp();
+ await u.click(await screen.findByRole('button', { name: /account menu/i }));
+ await u.click(await screen.findByRole('menuitem', { name: /settings/i }));
+ const prefs = within(await screen.findByRole('region', { name: 'Preferences' }));
+ await u.click(within(prefs.getByRole('group', { name: 'Home page' })).getByRole('button', { name: 'Reading Plans' }));
+ expect(localStorage.getItem('start-page')).toBe('plans');
+ await u.click(screen.getByRole('button', { name: homeBtn })); // Settings back -> the new home page
+ expect(await screen.findByText(/browse plans/i)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/reader/ReaderBars.jsx b/src/components/reader/ReaderBars.jsx
index fb3f227..bc0e1c4 100644
--- a/src/components/reader/ReaderBars.jsx
+++ b/src/components/reader/ReaderBars.jsx
@@ -9,7 +9,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
return (
-
+