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']);
+});