From 843056b96373cdfe9eadcc67af50156f84a9e33d Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:55:33 -0400 Subject: [PATCH] Studies: Finished & archived section with Archive/Restore Co-Authored-By: Claude Sonnet 5.5 --- src/hooks/useProjectLibrary.js | 18 ++- src/hooks/useProjectLibrary.test.js | 14 +++ src/pages/HomePage.jsx | 169 +++++++++++++++++----------- src/utils/projectStorage.js | 1 + src/utils/studyFilter.js | 4 +- src/utils/studyFilter.test.js | 8 ++ 6 files changed, 140 insertions(+), 74 deletions(-) diff --git a/src/hooks/useProjectLibrary.js b/src/hooks/useProjectLibrary.js index c754bd7..778bbd7 100644 --- a/src/hooks/useProjectLibrary.js +++ b/src/hooks/useProjectLibrary.js @@ -200,17 +200,16 @@ export function useProjectLibrary({ } }; - // Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name). - const setProjectSeries = (id, series) => { + // Changes one top-level field of a stored study (from Home, without opening it) and syncs it. + const patchStoredStudy = (id, patch) => { const loaded = loadProjectById(id); if (!loaded) return; - const name = String(series ?? '').trim().slice(0, 80); - if ((loaded.series ?? '') === name) return; - const updated = { ...loaded, series: name, lastEdited: Date.now() }; + if (Object.entries(patch).every(([k, v]) => (loaded[k] ?? (typeof v === 'boolean' ? false : '')) === v)) return; + const updated = { ...loaded, ...patch, lastEdited: Date.now() }; saveProjectToStorage(updated); unsyncedStudies.add(id); setProjectIndex(loadProjectIndex()); - if (project?.id === id) setProject((current) => (current ? { ...current, series: name } : current)); + if (project?.id === id) setProject((current) => (current ? { ...current, ...patch } : current)); saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => { if (result.ok) { const serverEdited = result.data?.lastEdited ?? updated.lastEdited; @@ -221,6 +220,12 @@ export function useProjectLibrary({ }); }; + // Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name). + const setProjectSeries = (id, series) => patchStoredStudy(id, { series: String(series ?? '').trim().slice(0, 80) }); + + // Moves a study to (or back from) the "Finished & archived" section on Home. + const setProjectArchived = (id, archived) => patchStoredStudy(id, { archived: !!archived }); + // Changes one chunk of a stored study (from Home, without opening it) and syncs it. `change` takes the chunk, returns the new one. const updateStoredChunk = (id, chunkId, change) => { const loaded = loadProjectById(id); @@ -318,6 +323,7 @@ export function useProjectLibrary({ openStudyChunk, renameProjectInStorage, setProjectSeries, + setProjectArchived, saveChunkRecall, saveChunkPatch, deleteProject, diff --git a/src/hooks/useProjectLibrary.test.js b/src/hooks/useProjectLibrary.test.js index 662329b..771d89a 100644 --- a/src/hooks/useProjectLibrary.test.js +++ b/src/hooks/useProjectLibrary.test.js @@ -105,3 +105,17 @@ test('studies only on the server are pulled down', async () => { setup(); await waitFor(() => expect(loadProjectById('far')?.title).toBe('From elsewhere')); }); + +test('archiving a study keeps it, flags it in the index, syncs it, and restoring clears the flag', async () => { + saveProjectToStorage(study('s1')); + const hook = setup(); + act(() => hook.result.current.setProjectArchived('s1', true)); + expect(loadProjectById('s1').archived).toBe(true); + expect(loadProjectIndex().find((e) => e.id === 's1').archived).toBe(true); + await waitFor(() => expect(sync.saveRemoteProject).toHaveBeenCalledWith(expect.objectContaining({ id: 's1', archived: true }), expect.anything())); + sync.saveRemoteProject.mockClear(); + act(() => hook.result.current.setProjectArchived('s1', true)); // already archived: nothing to do + expect(sync.saveRemoteProject).not.toHaveBeenCalled(); + act(() => hook.result.current.setProjectArchived('s1', false)); + expect(loadProjectIndex().find((e) => e.id === 's1').archived).toBe(false); +}); diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 720bb43..e135f04 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -50,6 +50,7 @@ export default function HomePage() { resumeProject, renameProjectInStorage, setProjectSeries, + setProjectArchived, deleteProject, handlePlayBookAudio, handleStopBookAudio, @@ -68,6 +69,7 @@ export default function HomePage() { }); // 'recent' | 'title' | 'passage' const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them const [showAllTags, setShowAllTags] = useState(false); + const [showArchived, setShowArchived] = useState(false); // the "Finished & archived" section is folded away until opened const [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name const [seriesEditingId, setSeriesEditingId] = useState(null); const [seriesValue, setSeriesValue] = useState(''); @@ -119,21 +121,90 @@ export default function HomePage() { }, 300); }, [homeSearch]); - const tagList = tagCounts(projectIndex); + // Finished studies are set aside in their own section; the tag and series filters follow the active ones + const activeIndex = projectIndex.filter((e) => !e.archived); + const tagList = tagCounts(activeIndex); const shownTags = showAllTags ? tagList : tagList.slice(0, 12); - const seriesNames = seriesList(projectIndex); - const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter }) - .sort((a, b) => { - if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? ''); - if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? ''); - return (b.lastEdited ?? 0) - (a.lastEdited ?? 0); - }); + const seriesNames = seriesList(activeIndex); + const byHomeSort = (a, b) => { + if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? ''); + if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? ''); + return (b.lastEdited ?? 0) - (a.lastEdited ?? 0); + }; + const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter }).sort(byHomeSort); + const archivedStudies = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, archived: true }).sort(byHomeSort); + const archivedCount = projectIndex.length - activeIndex.length; + const filtering = !!homeSearch.trim() || homeTags.length > 0 || seriesFilter !== ''; + const archivedOpen = showArchived || ((!!homeSearch.trim() || homeTags.length > 0) && archivedStudies.length > 0); // With series in use (and no single series chosen) the studies are grouped under their series name const sections = seriesFilter === '' && seriesNames.length > 0 ? groupBySeries(visible) : [{ name: '', items: visible, flat: true }]; const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5'; const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm'; + const renderEntry = (entry) => ( +
+ {homeView === 'list' + ? + : } +
+ {renamingId === entry.id ? ( +
+ setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); } + else if (e.key === 'Escape') setRenamingId(null); + }} + className={`${fieldCls} flex-1`} /> + + +
+ ) : ( +
+

{entry.title}

+ {sharedStudies?.[entry.id] !== undefined && ( + Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''} + )} + + +
+ )} + {seriesEditingId === entry.id && ( +
+ setSeriesValue(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }} + className={`${fieldCls} flex-1`} /> + + +
+ )} + {entry.chapterSummary &&

{compactPassages(entry.chapterSummary)}

} + {entry.tags?.length > 0 && ( + // A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them) +
3 ? entry.tags.join(', ') : undefined}> + {entry.tags.slice(0, 3).map((tag) => ( + + ))} + {entry.tags.length > 3 && +{entry.tags.length - 3} more} +
+ )} +

{formatRelativeDate(entry.lastEdited)}

+ +
+
+ + {sharedStudies?.[entry.id] === undefined && ( + + )} + +
+
+ ); + return ( )} - {visible.length === 0 && ( + {visible.length === 0 && archivedStudies.length === 0 && (

No studies match{homeTags.length ? ` ${homeTags.map((t) => `“${t}”`).join(' + ')}` : ''}{homeSearch.trim() ? ` “${homeSearch.trim()}”` : ''}.{' '} @@ -273,67 +344,31 @@ export default function HomePage() { )}

- {section.items.map((entry) => ( -
- {homeView === 'list' - ? - : } -
- {renamingId === entry.id ? ( -
- setRenameValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); const t = renameValue.trim(); if (t) renameProjectInStorage(entry.id, t); setRenamingId(null); } - else if (e.key === 'Escape') setRenamingId(null); - }} - className={`${fieldCls} flex-1`} /> - - -
- ) : ( -
-

{entry.title}

- {sharedStudies?.[entry.id] !== undefined && ( - Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''} - )} - - -
- )} - {seriesEditingId === entry.id && ( -
- setSeriesValue(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }} - className={`${fieldCls} flex-1`} /> - - -
- )} - {entry.chapterSummary &&

{compactPassages(entry.chapterSummary)}

} - {entry.tags?.length > 0 && ( - // A few tags and a "+N" for the rest (hover for all; the Tag filter above finds any of them) -
3 ? entry.tags.join(', ') : undefined}> - {entry.tags.slice(0, 3).map((tag) => ( - - ))} - {entry.tags.length > 3 && +{entry.tags.length - 3} more} -
- )} -

{formatRelativeDate(entry.lastEdited)}

- -
-
- - -
-
- ))} + {section.items.map(renderEntry)}
))} + + {visible.length === 0 && archivedStudies.length > 0 && !filtering && ( +

Everything is finished or archived. Open the section below to bring a study back.

+ )} + + {archivedCount > 0 && ( +
+ + {archivedOpen && ( +
+ {archivedStudies.length === 0 + ?

No archived studies match.

+ : archivedStudies.map(renderEntry)} +
+ )} +
+ )} )} diff --git a/src/utils/projectStorage.js b/src/utils/projectStorage.js index c090d6b..285368b 100644 --- a/src/utils/projectStorage.js +++ b/src/utils/projectStorage.js @@ -94,6 +94,7 @@ export function saveProjectToStorage(project) { lastEdited: updated.lastEdited, chapterSummary: buildChapterSummary(updated), series: (updated.series ?? '').trim(), + archived: !!updated.archived, tags, }; if (existing >= 0) { diff --git a/src/utils/studyFilter.js b/src/utils/studyFilter.js index 76eb28c..7b356f3 100644 --- a/src/utils/studyFilter.js +++ b/src/utils/studyFilter.js @@ -30,11 +30,13 @@ export function groupBySeries(entries) { return groups.has('') ? [...named, { name: '', items: groups.get('') }] : named; } -export function filterStudies(index, { query = '', tags = [], series = '' } = {}) { +// archived: false (default) leaves out finished/archived studies, true keeps only them, null keeps all. +export function filterStudies(index, { query = '', tags = [], series = '', archived = false } = {}) { const raw = String(query).trim().toLowerCase(); const tagOnly = raw.startsWith('#'); const q = tagOnly ? raw.slice(1).trim() : raw; return index.filter((entry) => { + if (archived !== null && !!entry.archived !== archived) return false; if (series === '__none') { if ((entry.series ?? '').trim()) return false; } else if (series && (entry.series ?? '').trim() !== series) return false; const own = (entry.tags ?? []).map((t) => t.toLowerCase()); if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false; diff --git a/src/utils/studyFilter.test.js b/src/utils/studyFilter.test.js index 974796c..cc457f9 100644 --- a/src/utils/studyFilter.test.js +++ b/src/utils/studyFilter.test.js @@ -52,3 +52,11 @@ test('a series can be chosen as a filter, and the search finds studies by series expect(filterStudies(idx, { series: '__none' }).map((e) => e.id)).toEqual(['b']); expect(filterStudies(idx, { query: 'advent' }).map((e) => e.id)).toEqual(['c']); }); + +test('archived studies are left out unless asked for', () => { + const idx = [{ id: 'a', title: 'Live' }, { id: 'b', title: 'Done', archived: true }, { id: 'c', title: 'Done too', archived: true, tags: ['hope'] }]; + expect(filterStudies(idx).map((e) => e.id)).toEqual(['a']); + expect(filterStudies(idx, { archived: true }).map((e) => e.id)).toEqual(['b', 'c']); + expect(filterStudies(idx, { archived: true, tags: ['hope'] }).map((e) => e.id)).toEqual(['c']); + expect(filterStudies(idx, { archived: null }).map((e) => e.id)).toEqual(['a', 'b', 'c']); +});