From f96c6a99d11347f5504b12692531160f558ba28e Mon Sep 17 00:00:00 2001 From: A Charlwood Date: Fri, 13 Feb 2026 01:20:08 +0000 Subject: [PATCH] Task 13: Implement fuzzy search with fuse.js - Installed fuse.js for fuzzy search functionality - Created src/lib/search.ts with buildSearchIndex and groupResultsBySection functions - Search index includes all consultations, medications, problems, investigations, and documents - Updated ClinicalSidebar to use fuse.js instead of simple filter - Search results grouped by section (Experience, Skills, Achievements, Projects, Education) - Section headers show icon and count - Each result shows title and highlight text (truncated) - Clicking a result navigates to the section and expands the matching item - Minimum 2 characters required for search - Top 10 results displayed - Clean dropdown styling with hover states - Integrates with AccessibilityContext to set expandedItem Co-Authored-By: Claude Sonnet 4.5 --- package-lock.json | 10 +++ package.json | 1 + src/components/ClinicalSidebar.tsx | 97 +++++++++++++++++++------- src/lib/search.ts | 106 +++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 25 deletions(-) create mode 100644 src/lib/search.ts diff --git a/package-lock.json b/package-lock.json index 09d93f3..af6cd9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "framer-motion": "^11.15.0", + "fuse.js": "^7.1.0", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -2668,6 +2669,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", diff --git a/package.json b/package.json index a990f95..ccb3ff9 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "framer-motion": "^11.15.0", + "fuse.js": "^7.1.0", "lucide-react": "^0.468.0", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/src/components/ClinicalSidebar.tsx b/src/components/ClinicalSidebar.tsx index ba97481..652b0b3 100644 --- a/src/components/ClinicalSidebar.tsx +++ b/src/components/ClinicalSidebar.tsx @@ -12,6 +12,8 @@ import { } from 'lucide-react' import type { ViewId } from '../types/pmr' import { useAccessibility } from '../contexts/AccessibilityContext' +import { buildSearchIndex, groupResultsBySection, type SearchResult } from '../lib/search' +import type { FuseResult } from 'fuse.js' interface NavItem { id: ViewId @@ -50,7 +52,10 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }: const [focusedIndex, setFocusedIndex] = useState(null) const [hoveredItem, setHoveredItem] = useState(null) const navButtonRefs = useRef<(HTMLButtonElement | null)[]>([]) - const { focusAfterLoginRef } = useAccessibility() + const { focusAfterLoginRef, setExpandedItem } = useAccessibility() + + // Build search index once on mount + const searchIndex = useMemo(() => buildSearchIndex(), []) const handleNavClick = useCallback( (view: ViewId) => { @@ -159,13 +164,33 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }: searchInput?.focus() } - const filteredItems = useMemo(() => { - if (!searchQuery.trim()) return [] - const query = searchQuery.toLowerCase() - return navItems.filter(item => - item.label.toLowerCase().includes(query) - ) - }, [searchQuery]) + // Fuzzy search with fuse.js + const searchResults = useMemo(() => { + if (!searchQuery.trim() || searchQuery.length < 2) return [] + const results = searchIndex.search(searchQuery) + return results.slice(0, 10) // Limit to top 10 results + }, [searchQuery, searchIndex]) + + // Group results by section for organized display + const groupedResults = useMemo(() => { + if (searchResults.length === 0) return new Map() + return groupResultsBySection(searchResults) + }, [searchResults]) + + const handleSearchResultClick = useCallback( + (result: FuseResult) => { + // Navigate to the section + onViewChange(result.item.section) + window.location.hash = result.item.section + + // Expand the matching item + setExpandedItem(result.item.id) + + // Clear search + setSearchQuery('') + }, + [onViewChange, setExpandedItem] + ) // ── Tablet: 56px icon-only sidebar ── if (isTablet) { @@ -279,23 +304,45 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }: )} - {/* Search results dropdown */} - {searchQuery && filteredItems.length > 0 && ( -
- {filteredItems.map(item => ( - - ))} + {/* Search results dropdown — grouped by section */} + {searchQuery.trim().length >= 2 && groupedResults.size > 0 && ( +
+ {Array.from(groupedResults.entries()).map(([sectionLabel, results]) => { + // Find section icon + const navItem = navItems.find(item => item.label === sectionLabel) + return ( +
+ {/* Section header */} +
+
+ {navItem && {navItem.icon}} + + {sectionLabel} + + + ({results.length}) + +
+
+ {/* Results for this section */} + {results.map((result: FuseResult) => ( + + ))} +
+ ) + })}
)}
diff --git a/src/lib/search.ts b/src/lib/search.ts new file mode 100644 index 0000000..8762042 --- /dev/null +++ b/src/lib/search.ts @@ -0,0 +1,106 @@ +import Fuse, { type FuseResult } from 'fuse.js' +import type { ViewId } from '@/types/pmr' + +// Import all data sources +import { consultations } from '@/data/consultations' +import { medications } from '@/data/medications' +import { problems } from '@/data/problems' +import { investigations } from '@/data/investigations' +import { documents } from '@/data/documents' + +export interface SearchResult { + id: string + title: string + section: ViewId + sectionLabel: string + highlight: string + score?: number +} + +// Build a unified search index from all PMR content +export function buildSearchIndex(): Fuse { + const searchableItems: SearchResult[] = [] + + // Index consultations (Experience) + consultations.forEach(consultation => { + searchableItems.push({ + id: consultation.id, + title: consultation.role, + section: 'consultations', + sectionLabel: 'Experience', + highlight: `${consultation.role} at ${consultation.organization} — ${consultation.history}`, + }) + }) + + // Index medications (Skills) + medications.forEach(medication => { + searchableItems.push({ + id: medication.id, + title: medication.name, + section: 'medications', + sectionLabel: 'Skills', + highlight: `${medication.name} — ${medication.frequency} use since ${medication.startYear}`, + }) + }) + + // Index problems (Achievements) + problems.forEach(problem => { + searchableItems.push({ + id: problem.id, + title: problem.description, + section: 'problems', + sectionLabel: 'Achievements', + highlight: `[${problem.code}] ${problem.description} — ${problem.narrative}`, + }) + }) + + // Index investigations (Projects) + investigations.forEach(investigation => { + searchableItems.push({ + id: investigation.id, + title: investigation.name, + section: 'investigations', + sectionLabel: 'Projects', + highlight: `${investigation.name} — ${investigation.methodology}`, + }) + }) + + // Index documents (Education) + documents.forEach(document => { + searchableItems.push({ + id: document.id, + title: document.title, + section: 'documents', + sectionLabel: 'Education', + highlight: `${document.title} from ${document.source} (${document.date})`, + }) + }) + + // Fuse.js configuration for fuzzy search + const fuseOptions = { + keys: [ + { name: 'title', weight: 2 }, // Primary match on title + { name: 'highlight', weight: 1 }, // Secondary match on full text + ], + threshold: 0.3, // 0 = exact match, 1 = match anything + includeScore: true, + minMatchCharLength: 2, + } + + return new Fuse(searchableItems, fuseOptions) +} + +// Group search results by section +export function groupResultsBySection(results: FuseResult[]): Map[]> { + const grouped = new Map[]>() + + results.forEach(result => { + const sectionLabel = result.item.sectionLabel + if (!grouped.has(sectionLabel)) { + grouped.set(sectionLabel, []) + } + grouped.get(sectionLabel)!.push(result) + }) + + return grouped +}