Skip to content

Commit a18e9f2

Browse files
Merge branch 'add-fuzzy-search-to-tasks-view-arlf'
2 parents 04f8946 + 3b035a6 commit a18e9f2

4 files changed

Lines changed: 84 additions & 46 deletions

File tree

src/components/command-palette/command-registry.ts

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ReactNode } from 'react'
2+
import { fuzzyScore } from '@/lib/fuzzy-search'
23

34
export interface Command {
45
id: string
@@ -10,45 +11,6 @@ export interface Command {
1011
icon?: ReactNode
1112
}
1213

13-
/**
14-
* Simple fuzzy search scoring
15-
* Returns a score > 0 if the query matches, 0 otherwise
16-
* Higher score = better match
17-
*/
18-
function fuzzyScore(text: string, query: string): number {
19-
const lowerText = text.toLowerCase()
20-
const lowerQuery = query.toLowerCase()
21-
22-
// Exact match
23-
if (lowerText === lowerQuery) return 100
24-
25-
// Starts with
26-
if (lowerText.startsWith(lowerQuery)) return 80
27-
28-
// Contains
29-
if (lowerText.includes(lowerQuery)) return 60
30-
31-
// Fuzzy character match
32-
let textIndex = 0
33-
let queryIndex = 0
34-
let score = 0
35-
36-
while (textIndex < lowerText.length && queryIndex < lowerQuery.length) {
37-
if (lowerText[textIndex] === lowerQuery[queryIndex]) {
38-
score += 1
39-
queryIndex++
40-
}
41-
textIndex++
42-
}
43-
44-
// Only count as a match if all query characters were found
45-
if (queryIndex === lowerQuery.length) {
46-
return score
47-
}
48-
49-
return 0
50-
}
51-
5214
/**
5315
* Search commands by query
5416
*/

src/components/kanban/kanban-board.tsx

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DragProvider, useDrag } from './drag-context'
77
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
88
import { useTasks, useUpdateTaskStatus } from '@/hooks/use-tasks'
99
import { cn } from '@/lib/utils'
10+
import { fuzzyScore } from '@/lib/fuzzy-search'
1011
import type { TaskStatus } from '@/types'
1112

1213
const COLUMNS: TaskStatus[] = [
@@ -59,19 +60,45 @@ function MobileDropZone({ status }: { status: TaskStatus }) {
5960

6061
interface KanbanBoardProps {
6162
repoFilter?: string | null
63+
searchQuery?: string
6264
}
6365

64-
function KanbanBoardInner({ repoFilter }: KanbanBoardProps) {
66+
function KanbanBoardInner({ repoFilter, searchQuery }: KanbanBoardProps) {
6567
const { data: allTasks = [], isLoading } = useTasks()
6668
const updateStatus = useUpdateTaskStatus()
6769
const { activeTask } = useDrag()
6870
const [activeTab, setActiveTab] = useState<TaskStatus>('IN_PROGRESS')
6971

70-
// Filter tasks by repo if filter is set
72+
// Filter tasks by repo and search query, sort by latest first
7173
const tasks = useMemo(() => {
72-
if (!repoFilter) return allTasks
73-
return allTasks.filter((t) => t.repoName === repoFilter)
74-
}, [allTasks, repoFilter])
74+
let filtered = allTasks
75+
if (repoFilter) {
76+
filtered = filtered.filter((t) => t.repoName === repoFilter)
77+
}
78+
if (searchQuery?.trim()) {
79+
// When searching, sort by fuzzy score
80+
filtered = filtered
81+
.map((t) => ({
82+
task: t,
83+
score: Math.max(
84+
fuzzyScore(t.title, searchQuery),
85+
fuzzyScore(t.description || '', searchQuery),
86+
fuzzyScore(t.branch || '', searchQuery),
87+
fuzzyScore(t.linearTicketId || '', searchQuery),
88+
fuzzyScore(t.prUrl || '', searchQuery)
89+
),
90+
}))
91+
.filter(({ score }) => score > 0)
92+
.sort((a, b) => b.score - a.score)
93+
.map(({ task }) => task)
94+
} else {
95+
// Default sort: newest first
96+
filtered = [...filtered].sort((a, b) =>
97+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
98+
)
99+
}
100+
return filtered
101+
}, [allTasks, repoFilter, searchQuery])
75102

76103
// Task counts for tabs
77104
const taskCounts = useMemo(() => {

src/lib/fuzzy-search.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Simple fuzzy search scoring
3+
* Returns a score > 0 if the query matches, 0 otherwise
4+
* Higher score = better match
5+
*/
6+
export function fuzzyScore(text: string, query: string): number {
7+
const lowerText = text.toLowerCase()
8+
const lowerQuery = query.toLowerCase()
9+
10+
// Exact match
11+
if (lowerText === lowerQuery) return 100
12+
13+
// Starts with
14+
if (lowerText.startsWith(lowerQuery)) return 80
15+
16+
// Contains
17+
if (lowerText.includes(lowerQuery)) return 60
18+
19+
// Fuzzy character match
20+
let textIndex = 0
21+
let queryIndex = 0
22+
let score = 0
23+
24+
while (textIndex < lowerText.length && queryIndex < lowerQuery.length) {
25+
if (lowerText[textIndex] === lowerQuery[queryIndex]) {
26+
score += 1
27+
queryIndex++
28+
}
29+
textIndex++
30+
}
31+
32+
// Only count as a match if all query characters were found
33+
if (queryIndex === lowerQuery.length) {
34+
return score
35+
}
36+
37+
return 0
38+
}

src/routes/tasks/index.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { KanbanBoard } from '@/components/kanban/kanban-board'
44
import { SelectionProvider, useSelection } from '@/components/kanban/selection-context'
55
import { Button } from '@/components/ui/button'
66
import { HugeiconsIcon } from '@hugeicons/react'
7-
import { Delete02Icon, Cancel01Icon, CheckListIcon, FilterIcon } from '@hugeicons/core-free-icons'
7+
import { Delete02Icon, Cancel01Icon, CheckListIcon, FilterIcon, Search01Icon } from '@hugeicons/core-free-icons'
88
import {
99
AlertDialog,
1010
AlertDialogAction,
@@ -23,6 +23,7 @@ import {
2323
SelectTrigger,
2424
SelectValue,
2525
} from '@/components/ui/select'
26+
import { Input } from '@/components/ui/input'
2627
import { useBulkDeleteTasks, useTasks } from '@/hooks/use-tasks'
2728

2829
export const Route = createFileRoute('/tasks/')({
@@ -42,6 +43,7 @@ function KanbanViewContent() {
4243
const bulkDelete = useBulkDeleteTasks()
4344
const { data: tasks = [] } = useTasks()
4445
const [repoFilter, setRepoFilter] = useState<string | null>(null)
46+
const [searchQuery, setSearchQuery] = useState('')
4547

4648
// Unique repo names for filtering
4749
const repoNames = useMemo(() => {
@@ -105,6 +107,15 @@ function KanbanViewContent() {
105107
<>
106108
<h1 className="text-sm font-medium max-sm:hidden">Tasks</h1>
107109
<div className="flex items-center gap-2">
110+
<div className="relative">
111+
<HugeiconsIcon icon={Search01Icon} size={12} strokeWidth={2} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
112+
<Input
113+
value={searchQuery}
114+
onChange={(e) => setSearchQuery(e.target.value)}
115+
placeholder="Filter tasks..."
116+
className="w-40 pl-6"
117+
/>
118+
</div>
108119
<Select
109120
value={repoFilter ?? ''}
110121
onValueChange={(v) => setRepoFilter(v || null)}
@@ -133,7 +144,7 @@ function KanbanViewContent() {
133144
)}
134145
</div>
135146
<div className="flex-1 overflow-hidden">
136-
<KanbanBoard repoFilter={repoFilter} />
147+
<KanbanBoard repoFilter={repoFilter} searchQuery={searchQuery} />
137148
</div>
138149
</div>
139150
)

0 commit comments

Comments
 (0)