Skip to content

Commit 6eaf0ce

Browse files
feat(tasks): implement fuzzy search using Fuse.js (#217)
Replaces the previous exact-match `.includes()` search with a more flexible and powerful fuzzy search. Users can now find tasks even with partial matches or typos, improving workflow efficiency and overall usability. Key changes: - Integrated `Fuse.js` to perform fuzzy matching across `description`, `project`, and `tags`. - Added a dual-state search pattern (`searchInput` + `debouncedTerm`) to keep typing responsive while debouncing the expensive search operation. - Consolidated all filters (projects, tags, status) and fuzzy search into a single "Master Filter" `useEffect`, ensuring all filters interact correctly. - Updated Jest tests using `jest.useFakeTimers()` and `act` to validate the debounced fuzzy search, including typo-tolerant queries like "fiace". Fixes: #162
1 parent 7eaf791 commit 6eaf0ce

File tree

4 files changed

+86
-28
lines changed

4 files changed

+86
-28
lines changed

frontend/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
"framer-motion": "^11.3.8",
130130
"fs.realpath": "^1.0.0",
131131
"function-bind": "^1.1.2",
132+
"fuse.js": "^7.1.0",
132133
"gensync": "^1.0.0-beta.2",
133134
"get-caller-file": "^2.0.5",
134135
"get-nonce": "^1.0.1",

frontend/src/components/HomeComponents/Tasks/Tasks.tsx

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useState, useCallback } from 'react';
22
import { Task } from '../../utils/types';
33
import { ReportsView } from './ReportsView';
4+
import Fuse from 'fuse.js';
45
import {
56
Table,
67
TableBody,
@@ -128,6 +129,7 @@ export const Tasks = (
128129
const [isEditingEndDate, setIsEditingEndDate] = useState(false);
129130
const [editedEndDate, setEditedEndDate] = useState('');
130131
const [searchTerm, setSearchTerm] = useState('');
132+
const [debouncedTerm, setDebouncedTerm] = useState('');
131133
const [lastSyncTime, setLastSyncTime] = useState<number | null>(null);
132134

133135
const isOverdue = (due?: string) => {
@@ -153,25 +155,7 @@ export const Tasks = (
153155

154156
// Debounced search handler
155157
const debouncedSearch = debounce((value: string) => {
156-
if (!value) {
157-
setTempTasks(
158-
selectedProjects.length === 0 &&
159-
selectedStatuses.length === 0 &&
160-
selectedTags.length === 0
161-
? tasks
162-
: tempTasks
163-
);
164-
return;
165-
}
166-
const lowerValue = value.toLowerCase();
167-
const filtered = tasks.filter(
168-
(task) =>
169-
task.description.toLowerCase().includes(lowerValue) ||
170-
(task.project && task.project.toLowerCase().includes(lowerValue)) ||
171-
(task.tags &&
172-
task.tags.some((tag) => tag.toLowerCase().includes(lowerValue)))
173-
);
174-
setTempTasks(sortWithOverdueOnTop(filtered));
158+
setDebouncedTerm(value);
175159
setCurrentPage(1);
176160
}, 300);
177161

@@ -586,6 +570,7 @@ export const Tasks = (
586570
});
587571
};
588572

573+
// Master filter
589574
useEffect(() => {
590575
let filteredTasks = [...tasks];
591576

@@ -596,7 +581,7 @@ export const Tasks = (
596581
);
597582
}
598583

599-
//Status filter
584+
// Status filter
600585
if (selectedStatuses.length > 0) {
601586
filteredTasks = filteredTasks.filter((task) =>
602587
selectedStatuses.includes(task.status)
@@ -611,11 +596,25 @@ export const Tasks = (
611596
);
612597
}
613598

614-
filteredTasks = sortWithOverdueOnTop(filteredTasks);
599+
// Fuzzy search
600+
if (debouncedTerm.trim() !== '') {
601+
const fuseOptions = {
602+
keys: ['description', 'project', 'tags'],
603+
threshold: 0.4,
604+
ignoreLocation: true,
605+
includeScore: false,
606+
};
615607

616-
// Sort + set
608+
const fuse = new Fuse(filteredTasks, fuseOptions);
609+
const results = fuse.search(debouncedTerm);
610+
611+
filteredTasks = results.map((r) => r.item);
612+
}
613+
614+
// Keep overdue tasks always on top
615+
filteredTasks = sortWithOverdueOnTop(filteredTasks);
617616
setTempTasks(filteredTasks);
618-
}, [selectedProjects, selectedTags, selectedStatuses, tasks]);
617+
}, [selectedProjects, selectedTags, selectedStatuses, tasks, debouncedTerm]);
619618

620619
const handleEditTagsClick = (task: Task) => {
621620
setEditedTags(task.tags || []);

frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen, fireEvent, within } from '@testing-library/react';
1+
import { render, screen, fireEvent, act, within } from '@testing-library/react';
22
import { Tasks } from '../Tasks';
33

44
// Mock props for the Tasks component
@@ -48,17 +48,43 @@ jest.mock('../hooks', () => ({
4848
where: jest.fn(() => ({
4949
equals: jest.fn(() => ({
5050
// Mock 12 tasks to test pagination
51-
toArray: jest.fn().mockResolvedValue(
52-
Array.from({ length: 12 }, (_, i) => ({
51+
toArray: jest.fn().mockResolvedValue([
52+
...Array.from({ length: 12 }, (_, i) => ({
5353
id: i + 1,
5454
description: `Task ${i + 1}`,
5555
status: 'pending',
5656
project: i % 2 === 0 ? 'ProjectA' : 'ProjectB',
5757
tags: i % 3 === 0 ? ['tag1'] : ['tag2'],
5858
uuid: `uuid-${i + 1}`,
5959
due: i === 0 ? '20200101T120000Z' : undefined,
60-
}))
61-
),
60+
})),
61+
{
62+
id: 13,
63+
description:
64+
'Prepare quarterly financial analysis report for review',
65+
status: 'pending',
66+
project: 'Finance',
67+
tags: ['report', 'analysis'],
68+
uuid: 'uuid-corp-1',
69+
},
70+
{
71+
id: 14,
72+
description: 'Schedule client onboarding meeting with Sales team',
73+
status: 'pending',
74+
project: 'Sales',
75+
tags: ['meeting', 'client'],
76+
uuid: 'uuid-corp-2',
77+
},
78+
{
79+
id: 15,
80+
description:
81+
'Draft technical documentation for API integration module',
82+
status: 'pending',
83+
project: 'Engineering',
84+
tags: ['documentation', 'api'],
85+
uuid: 'uuid-corp-3',
86+
},
87+
]),
6288
})),
6389
})),
6490
},
@@ -275,4 +301,26 @@ describe('Tasks Component', () => {
275301
const overdueBadge = await screen.findByText('Overdue');
276302
expect(overdueBadge).toBeInTheDocument();
277303
});
304+
test('filters tasks with fuzzy search (handles typos)', async () => {
305+
jest.useFakeTimers();
306+
307+
render(<Tasks {...mockProps} />);
308+
expect(await screen.findByText('Task 12')).toBeInTheDocument();
309+
310+
const dropdown = screen.getByLabelText('Show:');
311+
fireEvent.change(dropdown, { target: { value: '50' } });
312+
313+
const searchBar = screen.getByPlaceholderText('Search tasks...');
314+
fireEvent.change(searchBar, { target: { value: 'fiace' } });
315+
316+
act(() => {
317+
jest.advanceTimersByTime(300);
318+
});
319+
320+
expect(await screen.findByText('Finance')).toBeInTheDocument();
321+
expect(screen.queryByText('Engineering')).not.toBeInTheDocument();
322+
expect(screen.queryByText('Sales')).not.toBeInTheDocument();
323+
324+
jest.useRealTimers();
325+
});
278326
});

0 commit comments

Comments
 (0)