From a7e00a76b580f4e1acd63545c62322f3e71863af Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:52:46 -0400 Subject: [PATCH] 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 --- src/App.jsx | 183 +++------------------------------ src/App.test.jsx | 74 +++++++++++++ src/hooks/useProjectLibrary.js | 115 +++++++++++++++++++++ src/hooks/useReadingPlan.js | 35 +++++++ src/pages/HomePage.jsx | 62 +++++++++-- 5 files changed, 291 insertions(+), 178 deletions(-) create mode 100644 src/hooks/useProjectLibrary.js create mode 100644 src/hooks/useReadingPlan.js diff --git a/src/App.jsx b/src/App.jsx index c19a216..199c2bc 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,6 +3,8 @@ import { AppContext, useApp } from './context/AppContext.js'; import { useAudio } from './hooks/useAudio.js'; import { useAuth } from './hooks/useAuth.js'; import { useBibleReader } from './hooks/useBibleReader.js'; +import { useProjectLibrary } from './hooks/useProjectLibrary.js'; +import { useReadingPlan } from './hooks/useReadingPlan.js'; import { loadNtGloss, loadNtConcordance, @@ -20,11 +22,9 @@ import { import HomePage from './pages/HomePage.jsx'; import { - CHAPTER_COUNTS, bookOptions, NT_BOOK_NUMBER, makeId, - formatChunkReference, getChunkVerseEntries, chunkSpansNextChapter, buildExportHtml, @@ -36,8 +36,6 @@ import { } from './utils/bibleUtils.jsx'; import { saveProjectToStorage, - deleteProjectFromStorage, - loadProjectById, migrateOldStorageKeys, buildChapterSummary, } from './utils/projectStorage.js'; @@ -52,9 +50,6 @@ import { import { saveRemoteProject, - deleteRemoteProject, - listRemoteProjects, - loadRemoteProject, getShareStatus, enableSharing, disableSharing, @@ -118,25 +113,14 @@ const App = () => { const [typedChunkBulk, setTypedChunkBulk] = useState(''); const saveTimerRef = useRef(null); 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 [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null); // suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] } const [suggestModal, setSuggestModal] = useState(null); 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 audio = useAudio(reader.readerBookAbbrev, reader.readerChapter); + const readingPlan = useReadingPlan(); const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({ onSignIn: (user) => { setProjectIndex(switchStorageUser(user.id)); @@ -151,6 +135,14 @@ const App = () => { goHome(); }, }); + const library = useProjectLibrary({ + authUser, + project, + setProject, + setProjectIndex, + setCurrentPage, + setActiveChapterIndex, + }); const [studyLayout, setStudyLayout] = useState(() => { const saved = localStorage.getItem('studyLayout'); return saved === 'stacked' ? 'stacked' : 'split'; // 'annotate' (Draw) was removed @@ -171,38 +163,6 @@ const App = () => { localStorage.setItem('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 [collapsedSections, setCollapsedSections] = useState({}); const [commentarySource, setCommentarySource] = useState('matthew-henry'); @@ -274,44 +234,6 @@ const App = () => { }); }, [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(() => { if (!titleEdited) { 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 = () => { if (!project) return; const guide = buildPronunciationGuide(project); @@ -1512,47 +1410,6 @@ const App = () => { 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 = () => { setProjectIndex(loadProjectIndex()); setCurrentPage('home'); @@ -1773,25 +1630,11 @@ const deleteProject = (id) => { signIn, // Home projectIndex, - autoRestoredCount, - staleLocalProjects, - homeSearch, setHomeSearch, - homeSort, setHomeSort, - homeTagFilter, setHomeTagFilter, - homeFullTextResults, - readingPlan, - createReadingPlan, - markChapterRead, - clearReadingPlan, - renamingId, setRenamingId, - renameValue, setRenameValue, + ...library, + ...readingPlan, openBibleReader, openImportProject, openNewProject, - pullLatestFromServer, - resumeProject, - renameProjectInStorage, - deleteProject, // Audio (book player on Home + chapter player in the Reader) ...audio, // Reader diff --git a/src/App.test.jsx b/src/App.test.jsx index e8fd97a..6162b78 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest'; import App from './App'; import { resetLexiconCaches } from './utils/wordStudy.js'; +import { switchStorageUser } from './utils/bibleUtils.jsx'; // --------------------------------------------------------------------------- // Shared mock data @@ -51,6 +52,7 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {} beforeEach(() => { localStorage.clear(); resetLexiconCaches(); + switchStorageUser(null); // a signed-in test leaves the storage namespace switched vi.stubGlobal('fetch', buildFetchMock()); vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock'); vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); @@ -512,3 +514,75 @@ describe('Import page', () => { 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')); + }); +}); diff --git a/src/hooks/useProjectLibrary.js b/src/hooks/useProjectLibrary.js new file mode 100644 index 0000000..39bf8b8 --- /dev/null +++ b/src/hooks/useProjectLibrary.js @@ -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, + }; +} diff --git a/src/hooks/useReadingPlan.js b/src/hooks/useReadingPlan.js new file mode 100644 index 0000000..edd110d --- /dev/null +++ b/src/hooks/useReadingPlan.js @@ -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 }; +} diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 1fe283e..8af607e 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,6 +1,13 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; 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() { const { @@ -8,18 +15,12 @@ export default function HomePage() { autoRestoredCount, staleLocalProjects, projectIndex, - homeSearch, setHomeSearch, - homeSort, setHomeSort, - homeTagFilter, setHomeTagFilter, - homeFullTextResults, readingPlan, createReadingPlan, clearReadingPlan, audioBook, setAudioBook, audioNarrator, setAudioNarrator, audioState, - renamingId, setRenamingId, - renameValue, setRenameValue, openBibleReader, openImportProject, openNewProject, @@ -31,6 +32,51 @@ export default function HomePage() { handleStopBookAudio, handleToggleBookAudioPause, } = 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 (