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 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 01:20:08 +00:00
parent 7461a83b9d
commit f96c6a99d1
4 changed files with 189 additions and 25 deletions
+10
View File
@@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"fuse.js": "^7.1.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
@@ -2668,6 +2669,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+1
View File
@@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"fuse.js": "^7.1.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
+66 -19
View File
@@ -12,6 +12,8 @@ import {
} from 'lucide-react' } from 'lucide-react'
import type { ViewId } from '../types/pmr' import type { ViewId } from '../types/pmr'
import { useAccessibility } from '../contexts/AccessibilityContext' import { useAccessibility } from '../contexts/AccessibilityContext'
import { buildSearchIndex, groupResultsBySection, type SearchResult } from '../lib/search'
import type { FuseResult } from 'fuse.js'
interface NavItem { interface NavItem {
id: ViewId id: ViewId
@@ -50,7 +52,10 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
const [focusedIndex, setFocusedIndex] = useState<number | null>(null) const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
const [hoveredItem, setHoveredItem] = useState<ViewId | null>(null) const [hoveredItem, setHoveredItem] = useState<ViewId | null>(null)
const navButtonRefs = useRef<(HTMLButtonElement | 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( const handleNavClick = useCallback(
(view: ViewId) => { (view: ViewId) => {
@@ -159,13 +164,33 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
searchInput?.focus() searchInput?.focus()
} }
const filteredItems = useMemo(() => { // Fuzzy search with fuse.js
if (!searchQuery.trim()) return [] const searchResults = useMemo(() => {
const query = searchQuery.toLowerCase() if (!searchQuery.trim() || searchQuery.length < 2) return []
return navItems.filter(item => const results = searchIndex.search(searchQuery)
item.label.toLowerCase().includes(query) 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<SearchResult>) => {
// 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]
) )
}, [searchQuery])
// ── Tablet: 56px icon-only sidebar ── // ── Tablet: 56px icon-only sidebar ──
if (isTablet) { if (isTablet) {
@@ -279,24 +304,46 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
<X size={14} /> <X size={14} />
</button> </button>
)} )}
{/* Search results dropdown */} {/* Search results dropdown — grouped by section */}
{searchQuery && filteredItems.length > 0 && ( {searchQuery.trim().length >= 2 && groupedResults.size > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-pmr-sidebar border border-white/10 rounded overflow-hidden z-50"> <div className="absolute top-full left-0 right-0 mt-1 bg-pmr-sidebar border border-white/10 rounded overflow-hidden z-50 max-h-[400px] overflow-y-auto shadow-lg">
{filteredItems.map(item => ( {Array.from(groupedResults.entries()).map(([sectionLabel, results]) => {
// Find section icon
const navItem = navItems.find(item => item.label === sectionLabel)
return (
<div key={sectionLabel}>
{/* Section header */}
<div className="px-3 py-1.5 bg-white/[0.05] border-b border-white/10">
<div className="flex items-center gap-2">
{navItem && <span className="text-white/40">{navItem.icon}</span>}
<span className="font-ui text-xs font-semibold uppercase tracking-wide text-white/50">
{sectionLabel}
</span>
<span className="font-ui text-xs text-white/30">
({results.length})
</span>
</div>
</div>
{/* Results for this section */}
{results.map((result: FuseResult<SearchResult>) => (
<button <button
key={item.id} key={result.item.id}
type="button" type="button"
onClick={() => { onClick={() => handleSearchResultClick(result)}
handleNavClick(item.id) className="w-full px-3 py-2.5 text-left hover:bg-white/[0.10] transition-colors border-b border-white/5 last:border-b-0"
setSearchQuery('')
}}
className="w-full flex items-center gap-3 px-3 py-2.5 text-left hover:bg-white/[0.10] transition-colors"
> >
<span className="text-white/60">{item.icon}</span> <div className="font-ui text-sm text-white leading-snug">
<span className="font-ui text-sm">{item.label}</span> {result.item.title}
</div>
<div className="font-ui text-xs text-white/50 mt-0.5 line-clamp-1">
{result.item.highlight}
</div>
</button> </button>
))} ))}
</div> </div>
)
})}
</div>
)} )}
</div> </div>
</div> </div>
+106
View File
@@ -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<SearchResult> {
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<SearchResult>[]): Map<string, FuseResult<SearchResult>[]> {
const grouped = new Map<string, FuseResult<SearchResult>[]>()
results.forEach(result => {
const sectionLabel = result.item.sectionLabel
if (!grouped.has(sectionLabel)) {
grouped.set(sectionLabel, [])
}
grouped.get(sectionLabel)!.push(result)
})
return grouped
}