Split Home: local UI state, useProjectLibrary, useReadingPlan
- Search, tag filter, full-text note search, and rename state live in HomePage. The sort choice is remembered in localStorage. - useProjectLibrary owns opening/renaming/deleting projects and the server reconcile (auto-restore + newer-on-server list). - useReadingPlan owns the reading plan shared by Home and Reader. - Tests: note search, rename, delete, sort memory, reading plan. Test setup now resets the storage namespace between tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8bb3b104c8
commit
a7e00a76b5
5 files changed
+291
-178
No files matched your search
+13
-170
@@ -3,6 +3,8 @@ import { AppContext, useApp } from './context/AppContext.js';
|
|||||||
import { useAudio } from './hooks/useAudio.js';
|
import { useAudio } from './hooks/useAudio.js';
|
||||||
import { useAuth } from './hooks/useAuth.js';
|
import { useAuth } from './hooks/useAuth.js';
|
||||||
import { useBibleReader } from './hooks/useBibleReader.js';
|
import { useBibleReader } from './hooks/useBibleReader.js';
|
||||||
|
import { useProjectLibrary } from './hooks/useProjectLibrary.js';
|
||||||
|
import { useReadingPlan } from './hooks/useReadingPlan.js';
|
||||||
import {
|
import {
|
||||||
loadNtGloss,
|
loadNtGloss,
|
||||||
loadNtConcordance,
|
loadNtConcordance,
|
||||||
@@ -20,11 +22,9 @@ import {
|
|||||||
import HomePage from './pages/HomePage.jsx';
|
import HomePage from './pages/HomePage.jsx';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CHAPTER_COUNTS,
|
|
||||||
bookOptions,
|
bookOptions,
|
||||||
NT_BOOK_NUMBER,
|
NT_BOOK_NUMBER,
|
||||||
makeId,
|
makeId,
|
||||||
formatChunkReference,
|
|
||||||
getChunkVerseEntries,
|
getChunkVerseEntries,
|
||||||
chunkSpansNextChapter,
|
chunkSpansNextChapter,
|
||||||
buildExportHtml,
|
buildExportHtml,
|
||||||
@@ -36,8 +36,6 @@ import {
|
|||||||
} from './utils/bibleUtils.jsx';
|
} from './utils/bibleUtils.jsx';
|
||||||
import {
|
import {
|
||||||
saveProjectToStorage,
|
saveProjectToStorage,
|
||||||
deleteProjectFromStorage,
|
|
||||||
loadProjectById,
|
|
||||||
migrateOldStorageKeys,
|
migrateOldStorageKeys,
|
||||||
buildChapterSummary,
|
buildChapterSummary,
|
||||||
} from './utils/projectStorage.js';
|
} from './utils/projectStorage.js';
|
||||||
@@ -52,9 +50,6 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
saveRemoteProject,
|
saveRemoteProject,
|
||||||
deleteRemoteProject,
|
|
||||||
listRemoteProjects,
|
|
||||||
loadRemoteProject,
|
|
||||||
getShareStatus,
|
getShareStatus,
|
||||||
enableSharing,
|
enableSharing,
|
||||||
disableSharing,
|
disableSharing,
|
||||||
@@ -118,25 +113,14 @@ const App = () => {
|
|||||||
const [typedChunkBulk, setTypedChunkBulk] = useState('');
|
const [typedChunkBulk, setTypedChunkBulk] = useState('');
|
||||||
const saveTimerRef = useRef(null);
|
const saveTimerRef = useRef(null);
|
||||||
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error'
|
||||||
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
|
|
||||||
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
|
|
||||||
const [suggestingGreekForChunkId, setSuggestingGreekForChunkId] = useState(null);
|
const [suggestingGreekForChunkId, setSuggestingGreekForChunkId] = useState(null);
|
||||||
const [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null);
|
const [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null);
|
||||||
// suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] }
|
// suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] }
|
||||||
const [suggestModal, setSuggestModal] = useState(null);
|
const [suggestModal, setSuggestModal] = useState(null);
|
||||||
const [suggestSelection, setSuggestSelection] = useState(new Set());
|
const [suggestSelection, setSuggestSelection] = useState(new Set());
|
||||||
const [homeSearch, setHomeSearch] = useState('');
|
|
||||||
const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage'
|
|
||||||
const [homeTagFilter, setHomeTagFilter] = useState('');
|
|
||||||
const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results
|
|
||||||
const fullTextTimerRef = useRef(null);
|
|
||||||
const [readingPlan, setReadingPlan] = useState(() => {
|
|
||||||
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
|
|
||||||
});
|
|
||||||
const [renamingId, setRenamingId] = useState(null);
|
|
||||||
const [renameValue, setRenameValue] = useState('');
|
|
||||||
const reader = useBibleReader(currentPage);
|
const reader = useBibleReader(currentPage);
|
||||||
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter);
|
||||||
|
const readingPlan = useReadingPlan();
|
||||||
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
|
const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({
|
||||||
onSignIn: (user) => {
|
onSignIn: (user) => {
|
||||||
setProjectIndex(switchStorageUser(user.id));
|
setProjectIndex(switchStorageUser(user.id));
|
||||||
@@ -151,6 +135,14 @@ const App = () => {
|
|||||||
goHome();
|
goHome();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const library = useProjectLibrary({
|
||||||
|
authUser,
|
||||||
|
project,
|
||||||
|
setProject,
|
||||||
|
setProjectIndex,
|
||||||
|
setCurrentPage,
|
||||||
|
setActiveChapterIndex,
|
||||||
|
});
|
||||||
const [studyLayout, setStudyLayout] = useState(() => {
|
const [studyLayout, setStudyLayout] = useState(() => {
|
||||||
const saved = localStorage.getItem('studyLayout');
|
const saved = localStorage.getItem('studyLayout');
|
||||||
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
|
return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed
|
||||||
@@ -171,38 +163,6 @@ const App = () => {
|
|||||||
localStorage.setItem('activeStudyTab', activeStudyTab);
|
localStorage.setItem('activeStudyTab', activeStudyTab);
|
||||||
}, [activeStudyTab]);
|
}, [activeStudyTab]);
|
||||||
|
|
||||||
// Full-text search across all project notes (debounced, synchronous localStorage read)
|
|
||||||
useEffect(() => {
|
|
||||||
clearTimeout(fullTextTimerRef.current);
|
|
||||||
const q = homeSearch.trim().toLowerCase();
|
|
||||||
if (q.length < 3) { setHomeFullTextResults(null); return; }
|
|
||||||
fullTextTimerRef.current = window.setTimeout(() => {
|
|
||||||
const index = loadProjectIndex();
|
|
||||||
const results = [];
|
|
||||||
for (const entry of index) {
|
|
||||||
const proj = loadProjectById(entry.id);
|
|
||||||
if (!proj) continue;
|
|
||||||
const matches = [];
|
|
||||||
for (const [chIdx, ch] of (proj.chapters ?? []).entries()) {
|
|
||||||
for (const chunk of (ch.chunks ?? [])) {
|
|
||||||
for (const field of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) {
|
|
||||||
const text = (chunk[field] ?? '').toLowerCase();
|
|
||||||
if (!text.includes(q)) continue;
|
|
||||||
const idx = text.indexOf(q);
|
|
||||||
const raw = chunk[field] ?? '';
|
|
||||||
const start = Math.max(0, idx - 40);
|
|
||||||
const snippet = (start > 0 ? '…' : '') + raw.slice(start, idx + q.length + 60) + (idx + q.length + 60 < raw.length ? '…' : '');
|
|
||||||
matches.push({ chunkId: chunk.id, ref: formatChunkReference(proj, chIdx, chunk, '–'), field, snippet });
|
|
||||||
break; // one match per chunk
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (matches.length > 0) results.push({ projectId: entry.id, projectTitle: entry.title, matches });
|
|
||||||
}
|
|
||||||
setHomeFullTextResults(results);
|
|
||||||
}, 300);
|
|
||||||
}, [homeSearch]);
|
|
||||||
|
|
||||||
const [verseSearch, setVerseSearch] = useState('');
|
const [verseSearch, setVerseSearch] = useState('');
|
||||||
const [collapsedSections, setCollapsedSections] = useState({});
|
const [collapsedSections, setCollapsedSections] = useState({});
|
||||||
const [commentarySource, setCommentarySource] = useState('matthew-henry');
|
const [commentarySource, setCommentarySource] = useState('matthew-henry');
|
||||||
@@ -274,44 +234,6 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
}, [project?.id, currentPage]);
|
}, [project?.id, currentPage]);
|
||||||
|
|
||||||
// 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;
|
|
||||||
const localMap = new Map(localIndex.map((e) => [e.id, e]));
|
|
||||||
|
|
||||||
const missing = result.data.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));
|
|
||||||
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 = result.data.filter((e) => {
|
|
||||||
const local = localMap.get(e.id);
|
|
||||||
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
|
|
||||||
});
|
|
||||||
if (!cancelled) setStaleLocalProjects(stale);
|
|
||||||
});
|
|
||||||
return () => { cancelled = true; };
|
|
||||||
}, [authUser]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!titleEdited) {
|
if (!titleEdited) {
|
||||||
setSetup((current) => ({
|
setSetup((current) => ({
|
||||||
@@ -1458,30 +1380,6 @@ const App = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const createReadingPlan = (bookAbbrev, bookName, weeks) => {
|
|
||||||
const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1;
|
|
||||||
const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] };
|
|
||||||
setReadingPlan(plan);
|
|
||||||
try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const markChapterRead = (chapter) => {
|
|
||||||
setReadingPlan((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
const chaptersRead = prev.chaptersRead.includes(chapter)
|
|
||||||
? prev.chaptersRead.filter((c) => c !== chapter)
|
|
||||||
: [...prev.chaptersRead, chapter].sort((a, b) => a - b);
|
|
||||||
const next = { ...prev, chaptersRead };
|
|
||||||
try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearReadingPlan = () => {
|
|
||||||
setReadingPlan(null);
|
|
||||||
try { localStorage.removeItem('readingPlan'); } catch {}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyPronunciationGuide = () => {
|
const copyPronunciationGuide = () => {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
const guide = buildPronunciationGuide(project);
|
const guide = buildPronunciationGuide(project);
|
||||||
@@ -1512,47 +1410,6 @@ const App = () => {
|
|||||||
setCurrentPage('import');
|
setCurrentPage('import');
|
||||||
};
|
};
|
||||||
|
|
||||||
const resumeProject = (id) => {
|
|
||||||
const loaded = loadProjectById(id);
|
|
||||||
if (!loaded) return;
|
|
||||||
setProject(loaded);
|
|
||||||
setActiveChapterIndex(0);
|
|
||||||
const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks);
|
|
||||||
const hasSelectedChunk = loaded.selectedChunkId
|
|
||||||
&& loadedChunks.some((c) => c.id === loaded.selectedChunkId);
|
|
||||||
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
|
|
||||||
};
|
|
||||||
|
|
||||||
const renameProjectInStorage = (id, title) => {
|
|
||||||
const loaded = loadProjectById(id);
|
|
||||||
if (!loaded) return;
|
|
||||||
const updated = { ...loaded, title, lastEdited: Date.now() };
|
|
||||||
saveProjectToStorage(updated);
|
|
||||||
setProjectIndex(loadProjectIndex());
|
|
||||||
saveRemoteProject(updated);
|
|
||||||
if (project?.id === id) {
|
|
||||||
setProject((current) => (current ? { ...current, title } : current));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteProject = (id) => {
|
|
||||||
if (!window.confirm('Delete this project? This cannot be undone.')) return;
|
|
||||||
deleteProjectFromStorage(id);
|
|
||||||
setProjectIndex(loadProjectIndex());
|
|
||||||
deleteRemoteProject(id); // fire-and-forget
|
|
||||||
};
|
|
||||||
|
|
||||||
const pullLatestFromServer = async (id) => {
|
|
||||||
const result = await loadRemoteProject(id);
|
|
||||||
if (!result.ok) {
|
|
||||||
alert('Could not pull latest version from server.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saveProjectToStorage(result.data);
|
|
||||||
setProjectIndex(loadProjectIndex());
|
|
||||||
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const goHome = () => {
|
const goHome = () => {
|
||||||
setProjectIndex(loadProjectIndex());
|
setProjectIndex(loadProjectIndex());
|
||||||
setCurrentPage('home');
|
setCurrentPage('home');
|
||||||
@@ -1773,25 +1630,11 @@ const deleteProject = (id) => {
|
|||||||
signIn,
|
signIn,
|
||||||
// Home
|
// Home
|
||||||
projectIndex,
|
projectIndex,
|
||||||
autoRestoredCount,
|
...library,
|
||||||
staleLocalProjects,
|
...readingPlan,
|
||||||
homeSearch, setHomeSearch,
|
|
||||||
homeSort, setHomeSort,
|
|
||||||
homeTagFilter, setHomeTagFilter,
|
|
||||||
homeFullTextResults,
|
|
||||||
readingPlan,
|
|
||||||
createReadingPlan,
|
|
||||||
markChapterRead,
|
|
||||||
clearReadingPlan,
|
|
||||||
renamingId, setRenamingId,
|
|
||||||
renameValue, setRenameValue,
|
|
||||||
openBibleReader,
|
openBibleReader,
|
||||||
openImportProject,
|
openImportProject,
|
||||||
openNewProject,
|
openNewProject,
|
||||||
pullLatestFromServer,
|
|
||||||
resumeProject,
|
|
||||||
renameProjectInStorage,
|
|
||||||
deleteProject,
|
|
||||||
// Audio (book player on Home + chapter player in the Reader)
|
// Audio (book player on Home + chapter player in the Reader)
|
||||||
...audio,
|
...audio,
|
||||||
// Reader
|
// Reader
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { resetLexiconCaches } from './utils/wordStudy.js';
|
import { resetLexiconCaches } from './utils/wordStudy.js';
|
||||||
|
import { switchStorageUser } from './utils/bibleUtils.jsx';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared mock data
|
// Shared mock data
|
||||||
@@ -51,6 +52,7 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
resetLexiconCaches();
|
resetLexiconCaches();
|
||||||
|
switchStorageUser(null); // a signed-in test leaves the storage namespace switched
|
||||||
vi.stubGlobal('fetch', buildFetchMock());
|
vi.stubGlobal('fetch', buildFetchMock());
|
||||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||||
@@ -512,3 +514,75 @@ describe('Import page', () => {
|
|||||||
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
|
expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Home page (search/sort/rename local to HomePage; library + reading plan hooks)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('Home page', () => {
|
||||||
|
// Tests run in local-only mode (the mock API is unreachable), which uses the
|
||||||
|
// pre-account localStorage keys.
|
||||||
|
function seedProject({ id = 'p1', title = 'Titus Notes', observation = 'Grace abounds to all.' } = {}) {
|
||||||
|
const project = {
|
||||||
|
id, title, translation: 'BSB', lastEdited: Date.now(), selectedChunkId: null,
|
||||||
|
chapters: [{
|
||||||
|
book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'Paul, a servant of God.' }],
|
||||||
|
chunks: [{ id: 'c1', startVerse: 1, endVerse: 1, observation, interpretation: '', application: '',
|
||||||
|
crossReferences: [], greekWords: [], tags: [] }],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const index = JSON.parse(localStorage.getItem('bible-study-index') || '[]');
|
||||||
|
index.push({ id, title, lastEdited: project.lastEdited, chapterSummary: 'Titus 1', tags: [] });
|
||||||
|
localStorage.setItem(`bible-study-project-${id}`, JSON.stringify(project));
|
||||||
|
localStorage.setItem('bible-study-index', JSON.stringify(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('searching 3+ characters finds matches inside study notes', async () => {
|
||||||
|
seedProject();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.type(screen.getByPlaceholderText(/search projects/i), 'grace');
|
||||||
|
expect(await screen.findByText(/notes matching "grace"/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renaming a project updates its title and saved index', async () => {
|
||||||
|
seedProject();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /rename/i }));
|
||||||
|
const input = screen.getByDisplayValue('Titus Notes');
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, 'Titus Deep Dive');
|
||||||
|
await user.click(screen.getByRole('button', { name: /^save$/i }));
|
||||||
|
expect(await screen.findByText('Titus Deep Dive')).toBeInTheDocument();
|
||||||
|
expect(JSON.parse(localStorage.getItem('bible-study-index'))[0].title).toBe('Titus Deep Dive');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a project removes it after confirmation', async () => {
|
||||||
|
seedProject();
|
||||||
|
window.confirm.mockReturnValue(true);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /^delete$/i }));
|
||||||
|
await waitFor(() => expect(screen.queryByText('Titus Notes')).not.toBeInTheDocument());
|
||||||
|
expect(localStorage.getItem('bible-study-project-p1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sort choice is remembered after leaving Home', async () => {
|
||||||
|
seedProject();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
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 }));
|
||||||
|
expect(await screen.findByDisplayValue('Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reading plan can be started from Home', async () => {
|
||||||
|
seedProject(); // the plan card shows once there's at least one project
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await renderApp();
|
||||||
|
await user.click(screen.getByRole('button', { name: /set goal/i }));
|
||||||
|
await user.click(screen.getByRole('button', { name: /start plan/i }));
|
||||||
|
await waitFor(() => expect(JSON.parse(localStorage.getItem('readingPlan'))?.bookAbbrev).toBe('GEN'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// The signed-in user's saved projects: opening, renaming, deleting, and keeping
|
||||||
|
// this device's copies in step with the server.
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
|
||||||
|
import {
|
||||||
|
saveProjectToStorage,
|
||||||
|
deleteProjectFromStorage,
|
||||||
|
loadProjectById,
|
||||||
|
} from '../utils/projectStorage.js';
|
||||||
|
import {
|
||||||
|
saveRemoteProject,
|
||||||
|
deleteRemoteProject,
|
||||||
|
listRemoteProjects,
|
||||||
|
loadRemoteProject,
|
||||||
|
} from '../syncService.js';
|
||||||
|
|
||||||
|
export function useProjectLibrary({
|
||||||
|
authUser,
|
||||||
|
project,
|
||||||
|
setProject,
|
||||||
|
setProjectIndex,
|
||||||
|
setCurrentPage,
|
||||||
|
setActiveChapterIndex,
|
||||||
|
}) {
|
||||||
|
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
|
||||||
|
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
const localMap = new Map(localIndex.map((e) => [e.id, e]));
|
||||||
|
|
||||||
|
const missing = result.data.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));
|
||||||
|
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 = result.data.filter((e) => {
|
||||||
|
const local = localMap.get(e.id);
|
||||||
|
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0);
|
||||||
|
});
|
||||||
|
if (!cancelled) setStaleLocalProjects(stale);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [authUser]);
|
||||||
|
|
||||||
|
const resumeProject = (id) => {
|
||||||
|
const loaded = loadProjectById(id);
|
||||||
|
if (!loaded) return;
|
||||||
|
setProject(loaded);
|
||||||
|
setActiveChapterIndex(0);
|
||||||
|
const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks);
|
||||||
|
const hasSelectedChunk = loaded.selectedChunkId
|
||||||
|
&& loadedChunks.some((c) => c.id === loaded.selectedChunkId);
|
||||||
|
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renameProjectInStorage = (id, title) => {
|
||||||
|
const loaded = loadProjectById(id);
|
||||||
|
if (!loaded) return;
|
||||||
|
const updated = { ...loaded, title, lastEdited: Date.now() };
|
||||||
|
saveProjectToStorage(updated);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
saveRemoteProject(updated);
|
||||||
|
if (project?.id === id) {
|
||||||
|
setProject((current) => (current ? { ...current, title } : current));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteProject = (id) => {
|
||||||
|
if (!window.confirm('Delete this project? This cannot be undone.')) return;
|
||||||
|
deleteProjectFromStorage(id);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
deleteRemoteProject(id); // fire-and-forget
|
||||||
|
};
|
||||||
|
|
||||||
|
const pullLatestFromServer = async (id) => {
|
||||||
|
const result = await loadRemoteProject(id);
|
||||||
|
if (!result.ok) {
|
||||||
|
alert('Could not pull latest version from server.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saveProjectToStorage(result.data);
|
||||||
|
setProjectIndex(loadProjectIndex());
|
||||||
|
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
staleLocalProjects,
|
||||||
|
autoRestoredCount,
|
||||||
|
resumeProject,
|
||||||
|
renameProjectInStorage,
|
||||||
|
deleteProject,
|
||||||
|
pullLatestFromServer,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// A simple "read this book in N weeks" plan, stored per browser.
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { CHAPTER_COUNTS } from '../utils/bibleUtils.jsx';
|
||||||
|
|
||||||
|
export function useReadingPlan() {
|
||||||
|
const [readingPlan, setReadingPlan] = useState(() => {
|
||||||
|
try { return JSON.parse(localStorage.getItem('readingPlan') || 'null'); } catch { return null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
const createReadingPlan = (bookAbbrev, bookName, weeks) => {
|
||||||
|
const totalChapters = CHAPTER_COUNTS[bookAbbrev] ?? 1;
|
||||||
|
const plan = { bookAbbrev, bookName, totalChapters, startDate: Date.now(), targetDate: Date.now() + weeks * 7 * 24 * 60 * 60 * 1000, chaptersRead: [] };
|
||||||
|
setReadingPlan(plan);
|
||||||
|
try { localStorage.setItem('readingPlan', JSON.stringify(plan)); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markChapterRead = (chapter) => {
|
||||||
|
setReadingPlan((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
const chaptersRead = prev.chaptersRead.includes(chapter)
|
||||||
|
? prev.chaptersRead.filter((c) => c !== chapter)
|
||||||
|
: [...prev.chaptersRead, chapter].sort((a, b) => a - b);
|
||||||
|
const next = { ...prev, chaptersRead };
|
||||||
|
try { localStorage.setItem('readingPlan', JSON.stringify(next)); } catch {}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearReadingPlan = () => {
|
||||||
|
setReadingPlan(null);
|
||||||
|
try { localStorage.removeItem('readingPlan'); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { readingPlan, createReadingPlan, markChapterRead, clearReadingPlan };
|
||||||
|
}
|
||||||
+54
-8
@@ -1,6 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { bookOptions, CHAPTER_COUNTS, formatRelativeDate } from '../utils/bibleUtils.jsx';
|
import {
|
||||||
|
bookOptions,
|
||||||
|
CHAPTER_COUNTS,
|
||||||
|
formatChunkReference,
|
||||||
|
formatRelativeDate,
|
||||||
|
loadProjectIndex,
|
||||||
|
} from '../utils/bibleUtils.jsx';
|
||||||
|
import { loadProjectById } from '../utils/projectStorage.js';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const {
|
const {
|
||||||
@@ -8,18 +15,12 @@ export default function HomePage() {
|
|||||||
autoRestoredCount,
|
autoRestoredCount,
|
||||||
staleLocalProjects,
|
staleLocalProjects,
|
||||||
projectIndex,
|
projectIndex,
|
||||||
homeSearch, setHomeSearch,
|
|
||||||
homeSort, setHomeSort,
|
|
||||||
homeTagFilter, setHomeTagFilter,
|
|
||||||
homeFullTextResults,
|
|
||||||
readingPlan,
|
readingPlan,
|
||||||
createReadingPlan,
|
createReadingPlan,
|
||||||
clearReadingPlan,
|
clearReadingPlan,
|
||||||
audioBook, setAudioBook,
|
audioBook, setAudioBook,
|
||||||
audioNarrator, setAudioNarrator,
|
audioNarrator, setAudioNarrator,
|
||||||
audioState,
|
audioState,
|
||||||
renamingId, setRenamingId,
|
|
||||||
renameValue, setRenameValue,
|
|
||||||
openBibleReader,
|
openBibleReader,
|
||||||
openImportProject,
|
openImportProject,
|
||||||
openNewProject,
|
openNewProject,
|
||||||
@@ -31,6 +32,51 @@ export default function HomePage() {
|
|||||||
handleStopBookAudio,
|
handleStopBookAudio,
|
||||||
handleToggleBookAudioPause,
|
handleToggleBookAudioPause,
|
||||||
} = useApp();
|
} = useApp();
|
||||||
|
const [homeSearch, setHomeSearch] = useState('');
|
||||||
|
const [homeSort, setHomeSort] = useState(() => {
|
||||||
|
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
|
||||||
|
}); // 'recent' | 'title' | 'passage'
|
||||||
|
const [homeTagFilter, setHomeTagFilter] = useState('');
|
||||||
|
const [homeFullTextResults, setHomeFullTextResults] = useState(null); // null=idle, []=no match, [...]= results
|
||||||
|
const fullTextTimerRef = useRef(null);
|
||||||
|
const [renamingId, setRenamingId] = useState(null);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem('homeSort', homeSort); } catch {}
|
||||||
|
}, [homeSort]);
|
||||||
|
|
||||||
|
// Full-text search across all project notes (debounced, synchronous localStorage read)
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimeout(fullTextTimerRef.current);
|
||||||
|
const q = homeSearch.trim().toLowerCase();
|
||||||
|
if (q.length < 3) { setHomeFullTextResults(null); return; }
|
||||||
|
fullTextTimerRef.current = window.setTimeout(() => {
|
||||||
|
const index = loadProjectIndex();
|
||||||
|
const results = [];
|
||||||
|
for (const entry of index) {
|
||||||
|
const proj = loadProjectById(entry.id);
|
||||||
|
if (!proj) continue;
|
||||||
|
const matches = [];
|
||||||
|
for (const [chIdx, ch] of (proj.chapters ?? []).entries()) {
|
||||||
|
for (const chunk of (ch.chunks ?? [])) {
|
||||||
|
for (const field of ['observation', 'interpretation', 'application', 'generalNotes', 'finalScript']) {
|
||||||
|
const text = (chunk[field] ?? '').toLowerCase();
|
||||||
|
if (!text.includes(q)) continue;
|
||||||
|
const idx = text.indexOf(q);
|
||||||
|
const raw = chunk[field] ?? '';
|
||||||
|
const start = Math.max(0, idx - 40);
|
||||||
|
const snippet = (start > 0 ? '…' : '') + raw.slice(start, idx + q.length + 60) + (idx + q.length + 60 < raw.length ? '…' : '');
|
||||||
|
matches.push({ chunkId: chunk.id, ref: formatChunkReference(proj, chIdx, chunk, '–'), field, snippet });
|
||||||
|
break; // one match per chunk
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (matches.length > 0) results.push({ projectId: entry.id, projectTitle: entry.title, matches });
|
||||||
|
}
|
||||||
|
setHomeFullTextResults(results);
|
||||||
|
}, 300);
|
||||||
|
}, [homeSearch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
<div className="min-h-screen bg-slate-50 text-slate-900">
|
||||||
|
|||||||
Reference in new issue
Block a user