diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
index 55906ed..b897e3f 100644
--- a/src/pages/HomePage.jsx
+++ b/src/pages/HomePage.jsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
+import { filterStudies, isTagQuery, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx';
import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
@@ -50,7 +51,9 @@ export default function HomePage() {
const [homeSort, setHomeSort] = useState(() => {
try { return localStorage.getItem('homeSort') || 'recent'; } catch { return 'recent'; }
}); // 'recent' | 'title' | 'passage'
- const [homeTagFilter, setHomeTagFilter] = useState('');
+ const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
+ const [showAllTags, setShowAllTags] = useState(false);
+ const toggleTag = (tag) => setHomeTags((cur) => (cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag]));
const [homeView, setHomeView] = useState(() => {
try { return localStorage.getItem('homeView') === 'list' ? 'list' : 'grid'; } catch { return 'grid'; }
}); // 'grid' | 'list'
@@ -70,7 +73,7 @@ export default function HomePage() {
useEffect(() => {
clearTimeout(fullTextTimerRef.current);
const q = homeSearch.trim().toLowerCase();
- if (q.length < 3) { setHomeFullTextResults(null); return; }
+ if (q.length < 3 || isTagQuery(q)) { setHomeFullTextResults(null); return; }
fullTextTimerRef.current = window.setTimeout(() => {
const index = loadProjectIndex();
const results = [];
@@ -98,15 +101,9 @@ export default function HomePage() {
}, 300);
}, [homeSearch]);
- const allTags = Array.from(new Set(projectIndex.flatMap((entry) => entry.tags ?? []))).sort((a, b) => a.localeCompare(b));
- const visible = projectIndex
- .slice()
- .filter((entry) => {
- if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false;
- const q = homeSearch.trim().toLowerCase();
- if (!q) return true;
- return entry.title?.toLowerCase().includes(q) || entry.chapterSummary?.toLowerCase().includes(q);
- })
+ const tagList = tagCounts(projectIndex);
+ const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
+ const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags })
.sort((a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
@@ -160,7 +157,7 @@ export default function HomePage() {
) : (
<>
+ No studies match{homeTags.length ? ` ${homeTags.map((t) => `“${t}”`).join(' + ')}` : ''}{homeSearch.trim() ? ` “${homeSearch.trim()}”` : ''}.{' '}
+ { setHomeTags([]); setHomeSearch(''); }} className="text-[var(--r-accent)] hover:underline">Clear filters
+
+ )}
+
{/* Full-text note search results */}
{homeFullTextResults !== null && (
@@ -241,7 +256,10 @@ export default function HomePage() {
{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) => {tag} )}
+ {entry.tags.slice(0, 3).map((tag) => (
+ toggleTag(tag)} aria-pressed={homeTags.includes(tag)} title={`Show studies tagged “${tag}”`}
+ className={`rounded-full border px-2 py-0.5 text-[11px] ${homeTags.includes(tag) ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>{tag}
+ ))}
{entry.tags.length > 3 && +{entry.tags.length - 3} more }
)}
diff --git a/src/test/App.home.test.jsx b/src/test/App.home.test.jsx
index ca2a6d4..7c2545a 100644
--- a/src/test/App.home.test.jsx
+++ b/src/test/App.home.test.jsx
@@ -27,6 +27,45 @@ describe('Home page', () => {
localStorage.setItem('bible-study-index', JSON.stringify(index));
}
+ test('studies can be found by tag: chips, clicking a tag on a card, and #tag search', async () => {
+ const add = (id, title, tags) => {
+ seedProject({ id, title });
+ const index = JSON.parse(localStorage.getItem('bible-study-index'));
+ index.find((e) => e.id === id).tags = tags;
+ localStorage.setItem('bible-study-index', JSON.stringify(index));
+ };
+ add('p1', 'Titus Notes', ['hope', 'leadership']);
+ add('p2', 'New Birth', ['grace', 'hope']);
+ add('p3', 'Grace Alone', []);
+ const user = userEvent.setup();
+ await renderApp();
+ const titles = () => screen.queryAllByRole('heading', { level: 2 }).map((h) => h.textContent).filter((t) => ['Titus Notes', 'New Birth', 'Grace Alone'].includes(t)).sort();
+ const bar = screen.getByRole('group', { name: /filter by tag/i });
+ expect(titles()).toHaveLength(3);
+
+ await user.click(within(bar).getByRole('button', { name: /^hope/ }));
+ expect(titles()).toEqual(['New Birth', 'Titus Notes']);
+ await user.click(within(bar).getByRole('button', { name: /^grace/ })); // both chips: a study needs both
+ expect(titles()).toEqual(['New Birth']);
+ await user.click(within(bar).getByRole('button', { name: 'Clear' }));
+ expect(titles()).toHaveLength(3);
+
+ await user.click(screen.getAllByRole('button', { name: 'leadership' }).find((b) => b.closest('h2, div.rounded-2xl')?.textContent.includes('Titus Notes'))); // a tag on a card
+ expect(titles()).toEqual(['Titus Notes']);
+ await user.click(within(bar).getByRole('button', { name: 'Clear' }));
+
+ await user.type(screen.getByPlaceholderText(/search projects/i), '#grace'); // tags only: not the study named "Grace Alone"
+ expect(titles()).toEqual(['New Birth']);
+ await user.clear(screen.getByPlaceholderText(/search projects/i));
+ await user.type(screen.getByPlaceholderText(/search projects/i), 'lead'); // plain words match tags too
+ expect(titles()).toEqual(['Titus Notes']);
+ await user.clear(screen.getByPlaceholderText(/search projects/i));
+ await user.type(screen.getByPlaceholderText(/search projects/i), '#nothing');
+ expect(await screen.findByRole('status')).toHaveTextContent(/No studies match/);
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }));
+ expect(titles()).toHaveLength(3);
+ });
+
test('studies can be shown as a grid or a list, and the choice is remembered', async () => {
seedProject();
seedProject({ id: 'p2', title: 'Second Study' });
diff --git a/src/utils/studyFilter.js b/src/utils/studyFilter.js
new file mode 100644
index 0000000..47019de
--- /dev/null
+++ b/src/utils/studyFilter.js
@@ -0,0 +1,25 @@
+// Searching and filtering the studies on the dashboard. The search box matches a study's title, its
+// passages and its tags; starting it with "#" searches tags only ("#faith"). Chosen tag chips narrow
+// the list to studies that have every one of them.
+
+export const isTagQuery = (query) => String(query ?? '').trim().startsWith('#');
+
+// -> [{ tag, count }], most-used first, then alphabetical
+export function tagCounts(index) {
+ const counts = new Map();
+ for (const entry of index) for (const tag of new Set(entry.tags ?? [])) counts.set(tag, (counts.get(tag) ?? 0) + 1);
+ return [...counts].map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
+}
+
+export function filterStudies(index, { query = '', tags = [] } = {}) {
+ const raw = String(query).trim().toLowerCase();
+ const tagOnly = raw.startsWith('#');
+ const q = tagOnly ? raw.slice(1).trim() : raw;
+ return index.filter((entry) => {
+ const own = (entry.tags ?? []).map((t) => t.toLowerCase());
+ if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false;
+ if (!q) return true;
+ if (own.some((t) => t.includes(q))) return true;
+ return !tagOnly && ((entry.title ?? '').toLowerCase().includes(q) || (entry.chapterSummary ?? '').toLowerCase().includes(q));
+ });
+}
diff --git a/src/utils/studyFilter.test.js b/src/utils/studyFilter.test.js
new file mode 100644
index 0000000..3e62e7b
--- /dev/null
+++ b/src/utils/studyFilter.test.js
@@ -0,0 +1,35 @@
+import { test, expect } from 'vitest';
+import { filterStudies, tagCounts, isTagQuery } from './studyFilter.js';
+
+const index = [
+ { id: 'a', title: 'Titus Notes', chapterSummary: 'Titus 1', tags: ['hope', 'leadership'] },
+ { id: 'b', title: 'New Birth', chapterSummary: 'John 3', tags: ['grace', 'hope'] },
+ { id: 'c', title: 'Grace Alone', chapterSummary: 'Romans 3', tags: [] },
+];
+const ids = (list) => list.map((e) => e.id);
+
+test('the search box finds a study by title, passage or tag', () => {
+ expect(ids(filterStudies(index, { query: 'titus' }))).toEqual(['a']);
+ expect(ids(filterStudies(index, { query: 'romans' }))).toEqual(['c']);
+ expect(ids(filterStudies(index, { query: 'lead' }))).toEqual(['a']); // a tag
+ expect(ids(filterStudies(index, { query: 'grace' }))).toEqual(['b', 'c']); // a tag on one, a title on the other
+});
+
+test('starting with # searches tags only', () => {
+ expect(isTagQuery(' #grace')).toBe(true);
+ expect(ids(filterStudies(index, { query: '#grace' }))).toEqual(['b']); // not the study named "Grace Alone"
+ expect(ids(filterStudies(index, { query: '#HOP' }))).toEqual(['a', 'b']);
+ expect(ids(filterStudies(index, { query: '#' }))).toEqual(['a', 'b', 'c']);
+ expect(ids(filterStudies(index, { query: '#nothing' }))).toEqual([]);
+});
+
+test('chosen tags must all be present, and combine with the search', () => {
+ expect(ids(filterStudies(index, { tags: ['hope'] }))).toEqual(['a', 'b']);
+ expect(ids(filterStudies(index, { tags: ['hope', 'grace'] }))).toEqual(['b']);
+ expect(ids(filterStudies(index, { tags: ['hope'], query: 'john' }))).toEqual(['b']);
+ expect(ids(filterStudies(index, { tags: ['Hope'] }))).toEqual(['a', 'b']); // case does not matter
+});
+
+test('tags are counted, most used first', () => {
+ expect(tagCounts(index)).toEqual([{ tag: 'hope', count: 2 }, { tag: 'grace', count: 1 }, { tag: 'leadership', count: 1 }]);
+});