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() { ) : ( <>
- setHomeSearch(e.target.value)} placeholder="Search projects by title or passage…" + setHomeSearch(e.target.value)} placeholder="Search projects by title, passage or #tag…" className={`${fieldCls} w-full max-w-sm`} /> - )}
+ {tagList.length > 0 && ( +
+ Tags + {shownTags.map(({ tag, count }) => { + const on = homeTags.includes(tag); + return ( + + ); + })} + {tagList.length > 12 && ( + + )} + {homeTags.length > 0 && } +
+ )} + + {visible.length === 0 && ( +

+ No studies match{homeTags.length ? ` ${homeTags.map((t) => `“${t}”`).join(' + ')}` : ''}{homeSearch.trim() ? ` “${homeSearch.trim()}”` : ''}.{' '} + +

+ )} + {/* 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) => ( + + ))} {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 }]); +});