Skip to content

Commit c6773ae

Browse files
feat: add fuzzy search with fuse.js (#3829)
## Summary - Add fuse.js for typo-tolerant fuzzy matching ("scater" → "scatter", "heatmp" → "heatmap") - Enable extended search for multi-word AND queries ("hi k" → "histogram-kde") - Search spec titles in addition to spec_id - Show spec title as tooltip in dropdown for spec category - Add "fuzzy" divider label between exact and fuzzy matches in dropdown ## Configuration - FUZZY_THRESHOLD: 0.3 (max 30% dissimilarity) - EXACT_THRESHOLD: 0.1 (score < 0.1 = exact match) - minMatchCharLength: 1 (single characters allowed) ## Test plan - [ ] Search "scater" → finds "scatter-*" specs - [ ] Search "heatmp" → finds "heatmap-*" specs - [ ] Search "hi k" → finds "histogram-kde" - [ ] Exact matches appear above "fuzzy" divider - [ ] Spec tooltips show full title on hover Closes #3828 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eab0148 commit c6773ae

File tree

17 files changed

+840
-54
lines changed

17 files changed

+840
-54
lines changed

.github/workflows/ci-tests.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,3 +139,74 @@ jobs:
139139
- name: Skip notice
140140
if: steps.check.outputs.should_test == 'false'
141141
run: echo "::notice::Tests skipped - no testable Python changes (only plots/ or non-Python files)"
142+
143+
test-frontend:
144+
name: Run Frontend Tests
145+
runs-on: ubuntu-latest
146+
permissions:
147+
contents: read
148+
149+
steps:
150+
- uses: actions/checkout@v6
151+
with:
152+
fetch-depth: 0
153+
154+
- name: Check for frontend changes
155+
id: check
156+
run: |
157+
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
158+
BASE_SHA="${{ github.event.pull_request.base.sha }}"
159+
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
160+
CHANGED_FILES=$(git diff --name-only $BASE_SHA $HEAD_SHA)
161+
else
162+
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "")
163+
fi
164+
165+
echo "Changed files:"
166+
echo "$CHANGED_FILES"
167+
168+
# Check for frontend changes
169+
FRONTEND_CHANGES=$(echo "$CHANGED_FILES" | grep '^app/' || true)
170+
171+
# Force run on workflow_dispatch with force_run=true
172+
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.force_run }}" == "true" ]]; then
173+
echo "Manual trigger with force_run=true, will run frontend tests"
174+
echo "should_test=true" >> $GITHUB_OUTPUT
175+
elif [[ -n "$FRONTEND_CHANGES" ]]; then
176+
echo "Found frontend changes, will run tests"
177+
echo "should_test=true" >> $GITHUB_OUTPUT
178+
else
179+
echo "No frontend changes, skipping frontend tests"
180+
echo "should_test=false" >> $GITHUB_OUTPUT
181+
fi
182+
183+
- name: Set up Node.js
184+
if: steps.check.outputs.should_test == 'true'
185+
uses: actions/setup-node@v4
186+
with:
187+
node-version: '20'
188+
cache: 'yarn'
189+
cache-dependency-path: app/yarn.lock
190+
191+
- name: Install dependencies
192+
if: steps.check.outputs.should_test == 'true'
193+
working-directory: app
194+
run: yarn install --frozen-lockfile
195+
196+
- name: Run frontend tests with coverage
197+
if: steps.check.outputs.should_test == 'true'
198+
working-directory: app
199+
run: yarn test --coverage
200+
201+
- name: Upload frontend coverage to Codecov
202+
if: steps.check.outputs.should_test == 'true'
203+
uses: codecov/codecov-action@v5
204+
with:
205+
token: ${{ secrets.CODECOV_TOKEN }}
206+
files: ./app/coverage/coverage-final.json
207+
flags: frontend
208+
fail_ci_if_error: false
209+
210+
- name: Skip notice
211+
if: steps.check.outputs.should_test == 'false'
212+
run: echo "::notice::Frontend tests skipped - no app/ changes"

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ dist/
216216
*.tsbuildinfo
217217
.yarn/
218218
.pnp.*
219+
app/coverage/
219220

220221
# OS-specific files
221222
.DS_Store

api/routers/plots.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ def _collect_all_images(all_specs: list) -> list[dict]:
379379
all_specs: List of Spec objects
380380
381381
Returns:
382-
List of image dicts with spec_id, library, quality, url, thumb, and html
382+
List of image dicts with spec_id, library, quality, url, thumb, html, and title
383383
"""
384384
all_images: list[dict] = []
385385
for spec_obj in all_specs:
@@ -395,6 +395,7 @@ def _collect_all_images(all_specs: list) -> list[dict]:
395395
"url": impl.preview_url,
396396
"thumb": impl.preview_thumb,
397397
"html": impl.preview_html,
398+
"title": spec_obj.title,
398399
}
399400
)
400401
return all_images
@@ -483,13 +484,17 @@ async def get_filtered_plots(request: Request, db: AsyncSession = Depends(requir
483484
counts = _calculate_contextual_counts(filtered_images, spec_id_to_tags, impl_lookup)
484485
or_counts = _calculate_or_counts(filter_groups, all_images, spec_id_to_tags, spec_lookup, impl_lookup)
485486

487+
# Build spec_id -> title mapping for search/tooltips
488+
spec_titles = {spec_id: data["spec"].title for spec_id, data in spec_lookup.items() if data["spec"].title}
489+
486490
# Build and cache response
487491
result = FilteredPlotsResponse(
488492
total=len(filtered_images),
489493
images=filtered_images,
490494
counts=counts,
491495
globalCounts=global_counts,
492496
orCounts=or_counts,
497+
specTitles=spec_titles,
493498
)
494499

495500
try:

api/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ class FilteredPlotsResponse(BaseModel):
9797
counts: dict
9898
globalCounts: dict
9999
orCounts: list[dict]
100+
specTitles: dict[str, str] = {} # Mapping spec_id -> title for search/tooltips
100101

101102

102103
class LibraryInfo(BaseModel):

app/package.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
"build": "tsc && vite build",
1010
"preview": "vite preview",
1111
"lint": "eslint src --ext ts,tsx",
12-
"type-check": "tsc --noEmit"
12+
"type-check": "tsc --noEmit",
13+
"test": "vitest run",
14+
"test:watch": "vitest"
1315
},
1416
"engines": {
1517
"node": ">=20"
@@ -20,6 +22,7 @@
2022
"@emotion/styled": "^11.14.1",
2123
"@mui/icons-material": "^7.3.7",
2224
"@mui/material": "^7.3.7",
25+
"fuse.js": "^7.1.0",
2326
"react": "^19.2.3",
2427
"react-dom": "^19.2.3",
2528
"react-helmet-async": "^2.0.5",
@@ -31,7 +34,9 @@
3134
"@types/react-dom": "^19.1.7",
3235
"@types/react-syntax-highlighter": "^15.5.13",
3336
"@vitejs/plugin-react-swc": "^4.0.0",
37+
"@vitest/coverage-v8": "^4.0.17",
3438
"typescript": "^5.9.2",
35-
"vite": "^7.3.1"
39+
"vite": "^7.3.1",
40+
"vitest": "^4.0.17"
3641
}
3742
}

app/src/components/FilterBar.tsx

Lines changed: 82 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,13 @@ import { useTheme } from '@mui/material/styles';
2121
import type { FilterCategory, ActiveFilters, FilterCounts } from '../types';
2222
import { FILTER_LABELS, FILTER_TOOLTIPS, FILTER_CATEGORIES } from '../types';
2323
import type { ImageSize } from '../constants';
24-
import { getAvailableValues, getAvailableValuesForGroup, getSearchResults } from '../utils';
24+
import { getAvailableValues, getAvailableValuesForGroup, getSearchResults, type SearchResult } from '../utils';
2525

2626
interface FilterBarProps {
2727
activeFilters: ActiveFilters;
2828
filterCounts: FilterCounts | null; // Contextual counts (for AND additions)
2929
orCounts: Record<string, number>[]; // Per-group counts for OR additions
30+
specTitles: Record<string, string>; // Mapping spec_id -> title for search/tooltips
3031
currentTotal: number; // Total number of filtered images
3132
displayedCount: number; // Currently displayed images
3233
randomAnimation: { index: number; phase: 'out' | 'in'; oldLabel?: string } | null;
@@ -44,6 +45,7 @@ export function FilterBar({
4445
activeFilters,
4546
filterCounts,
4647
orCounts,
48+
specTitles,
4749
currentTotal,
4850
displayedCount,
4951
randomAnimation,
@@ -208,8 +210,8 @@ export function FilterBar({
208210

209211
// Memoize search results to avoid recalculating on every render
210212
const searchResults = useMemo(
211-
() => getSearchResults(filterCounts, activeFilters, searchQuery, selectedCategory),
212-
[filterCounts, activeFilters, searchQuery, selectedCategory]
213+
() => getSearchResults(filterCounts, activeFilters, searchQuery, selectedCategory, specTitles),
214+
[filterCounts, activeFilters, searchQuery, selectedCategory, specTitles]
213215
);
214216

215217
// Track searches with no results (debounced, to discover missing specs)
@@ -699,38 +701,83 @@ export function FilterBar({
699701
]
700702
: []),
701703
...(searchResults.length > 0
702-
? searchResults.map(({ category, value, count }, idx) => (
703-
<MenuItem
704-
key={`${category}-${value}`}
705-
onClick={() => handleValueSelect(category, value)}
706-
selected={idx === highlightedIndex}
707-
sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace' }}
708-
>
709-
<ListItemText
710-
primary={value}
711-
secondary={!selectedCategory ? FILTER_LABELS[category] : undefined}
712-
primaryTypographyProps={{
713-
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
714-
fontSize: '0.85rem',
715-
}}
716-
secondaryTypographyProps={{
717-
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
718-
fontSize: '0.7rem',
719-
color: '#9ca3af',
720-
}}
721-
/>
722-
<Typography
723-
sx={{
724-
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
725-
fontSize: '0.75rem',
726-
color: '#9ca3af',
727-
ml: 2,
728-
}}
729-
>
730-
({count})
731-
</Typography>
732-
</MenuItem>
733-
))
704+
? (() => {
705+
// Split results into exact and fuzzy matches
706+
const exactResults = searchResults.filter((r) => r.matchType === 'exact');
707+
const fuzzyResults = searchResults.filter((r) => r.matchType === 'fuzzy');
708+
709+
const renderMenuItem = (result: SearchResult, idx: number) => {
710+
const { category, value, count } = result;
711+
const specTitle = category === 'spec' ? specTitles[value] : undefined;
712+
const menuItem = (
713+
<MenuItem
714+
key={`${category}-${value}`}
715+
onClick={() => handleValueSelect(category, value)}
716+
selected={idx === highlightedIndex}
717+
sx={{ fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace' }}
718+
>
719+
<ListItemText
720+
primary={value}
721+
secondary={!selectedCategory ? FILTER_LABELS[category] : undefined}
722+
primaryTypographyProps={{
723+
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
724+
fontSize: '0.85rem',
725+
}}
726+
secondaryTypographyProps={{
727+
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
728+
fontSize: '0.7rem',
729+
color: '#9ca3af',
730+
}}
731+
/>
732+
<Typography
733+
sx={{
734+
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
735+
fontSize: '0.75rem',
736+
color: '#9ca3af',
737+
ml: 2,
738+
}}
739+
>
740+
({count})
741+
</Typography>
742+
</MenuItem>
743+
);
744+
return specTitle ? (
745+
<Tooltip key={`${category}-${value}`} title={specTitle} placement="right" arrow>
746+
<span>{menuItem}</span>
747+
</Tooltip>
748+
) : (
749+
menuItem
750+
);
751+
};
752+
753+
const items: React.ReactNode[] = [];
754+
// Add exact matches
755+
exactResults.forEach((result, i) => {
756+
items.push(renderMenuItem(result, i));
757+
});
758+
// Add fuzzy label/divider if there are fuzzy results
759+
if (fuzzyResults.length > 0) {
760+
items.push(
761+
<Divider key="exact-fuzzy-divider" sx={{ my: 0.5 }}>
762+
<Typography
763+
sx={{
764+
fontSize: '0.65rem',
765+
color: '#9ca3af',
766+
fontFamily: '"MonoLisa", "MonoLisa Fallback", monospace',
767+
px: 1,
768+
}}
769+
>
770+
fuzzy
771+
</Typography>
772+
</Divider>
773+
);
774+
}
775+
// Add fuzzy matches
776+
fuzzyResults.forEach((result, i) => {
777+
items.push(renderMenuItem(result, exactResults.length + i));
778+
});
779+
return items;
780+
})()
734781
: [
735782
<MenuItem key="no-results" disabled>
736783
<Typography

app/src/hooks/useFilterFetch.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface FilterFetchState {
2525
filterCounts: FilterCounts | null;
2626
globalCounts: FilterCounts | null;
2727
orCounts: Record<string, number>[];
28+
specTitles: Record<string, string>;
2829
allImages: PlotImage[];
2930
displayedImages: PlotImage[];
3031
hasMore: boolean;
@@ -55,6 +56,7 @@ export function useFilterFetch({
5556
const [filterCounts, setFilterCounts] = useState<FilterCounts | null>(initialState.filterCounts ?? null);
5657
const [globalCounts, setGlobalCounts] = useState<FilterCounts | null>(initialState.globalCounts ?? null);
5758
const [orCounts, setOrCounts] = useState<Record<string, number>[]>(initialState.orCounts ?? []);
59+
const [specTitles, setSpecTitles] = useState<Record<string, string>>(initialState.specTitles ?? {});
5860
const [allImages, setAllImages] = useState<PlotImage[]>(initialState.allImages ?? []);
5961
const [displayedImages, setDisplayedImages] = useState<PlotImage[]>(initialState.displayedImages ?? []);
6062
const [hasMore, setHasMore] = useState(initialState.hasMore ?? false);
@@ -97,10 +99,11 @@ export function useFilterFetch({
9799

98100
if (abortController.signal.aborted) return;
99101

100-
// Update filter counts
102+
// Update filter counts and spec titles
101103
setFilterCounts(data.counts);
102104
setGlobalCounts(data.globalCounts || data.counts);
103105
setOrCounts(data.orCounts || []);
106+
setSpecTitles(data.specTitles || {});
104107

105108
// Shuffle images randomly on each load
106109
const shuffled = shuffleArray<PlotImage>(data.images || []);
@@ -128,6 +131,7 @@ export function useFilterFetch({
128131
filterCounts,
129132
globalCounts,
130133
orCounts,
134+
specTitles,
131135
allImages,
132136
displayedImages,
133137
hasMore,

app/src/hooks/useFilterState.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ interface UseFilterStateReturn {
3131
filterCounts: FilterCounts | null;
3232
globalCounts: FilterCounts | null;
3333
orCounts: Record<string, number>[];
34+
specTitles: Record<string, string>;
3435
allImages: PlotImage[];
3536
displayedImages: PlotImage[];
3637
hasMore: boolean;
@@ -86,6 +87,7 @@ export function useFilterState({
8687
filterCounts,
8788
globalCounts,
8889
orCounts,
90+
specTitles,
8991
allImages,
9092
displayedImages,
9193
hasMore,
@@ -234,6 +236,7 @@ export function useFilterState({
234236
filterCounts,
235237
globalCounts,
236238
orCounts,
239+
specTitles,
237240
allImages,
238241
displayedImages,
239242
hasMore,

app/src/pages/HomePage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function HomePage() {
3030
activeFilters,
3131
filterCounts,
3232
orCounts,
33+
specTitles,
3334
allImages,
3435
displayedImages,
3536
hasMore,
@@ -167,6 +168,7 @@ export function HomePage() {
167168
activeFilters={activeFilters}
168169
filterCounts={filterCounts}
169170
orCounts={orCounts}
171+
specTitles={specTitles}
170172
currentTotal={allImages.length}
171173
displayedCount={displayedImages.length}
172174
randomAnimation={randomAnimation}

app/src/types/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface PlotImage {
77
html?: string;
88
code?: string;
99
spec_id?: string;
10+
title?: string;
1011
}
1112

1213
// Filter system types
@@ -95,6 +96,7 @@ export interface FilteredPlotsResponse {
9596
counts: FilterCounts; // Contextual counts (for AND additions)
9697
globalCounts: FilterCounts; // Global counts (for reference)
9798
orCounts: Record<string, number>[]; // Per-group counts for OR additions
99+
specTitles: Record<string, string>; // Mapping spec_id -> title for search/tooltips
98100
}
99101

100102
export interface LibraryInfo {

0 commit comments

Comments
 (0)