diff --git a/TaskTracker.Web/src/components/FilterBar.tsx b/TaskTracker.Web/src/components/FilterBar.tsx
new file mode 100644
index 0000000..c1d6127
--- /dev/null
+++ b/TaskTracker.Web/src/components/FilterBar.tsx
@@ -0,0 +1,137 @@
+import { useMemo } from 'react'
+import { X, ListTree } from 'lucide-react'
+import type { WorkTask } from '../types/index.ts'
+import { CATEGORY_COLORS } from '../lib/constants.ts'
+
+export interface Filters {
+ categories: string[]
+ hasSubtasks: boolean
+}
+
+export const EMPTY_FILTERS: Filters = { categories: [], hasSubtasks: false }
+
+interface FilterBarProps {
+ tasks: WorkTask[]
+ filters: Filters
+ onFiltersChange: (filters: Filters) => void
+}
+
+export function applyFilters(tasks: WorkTask[], filters: Filters): WorkTask[] {
+ let result = tasks
+
+ if (filters.categories.length > 0) {
+ result = result.filter((t) => filters.categories.includes(t.category ?? 'Unknown'))
+ }
+
+ if (filters.hasSubtasks) {
+ result = result.filter((t) => t.subTasks && t.subTasks.length > 0)
+ }
+
+ return result
+}
+
+export default function FilterBar({ tasks, filters, onFiltersChange }: FilterBarProps) {
+ // Derive unique categories from tasks + CATEGORY_COLORS keys
+ const allCategories = useMemo(() => {
+ const fromTasks = new Set(tasks.map((t) => t.category ?? 'Unknown'))
+ const fromConfig = new Set(Object.keys(CATEGORY_COLORS))
+ const merged = new Set([...fromConfig, ...fromTasks])
+ return Array.from(merged).sort()
+ }, [tasks])
+
+ const hasActiveFilters = filters.categories.length > 0 || filters.hasSubtasks
+
+ const toggleCategory = (category: string) => {
+ const active = filters.categories
+ const next = active.includes(category)
+ ? active.filter((c) => c !== category)
+ : [...active, category]
+ onFiltersChange({ ...filters, categories: next })
+ }
+
+ const toggleHasSubtasks = () => {
+ onFiltersChange({ ...filters, hasSubtasks: !filters.hasSubtasks })
+ }
+
+ const clearAll = () => {
+ onFiltersChange(EMPTY_FILTERS)
+ }
+
+ return (
+
+ {/* "All" chip */}
+
+
+ {/* Divider */}
+
+
+ {/* Category chips */}
+ {allCategories.map((cat) => {
+ const isActive = filters.categories.includes(cat)
+ const color = CATEGORY_COLORS[cat] ?? CATEGORY_COLORS['Unknown']
+
+ return (
+
+ )
+ })}
+
+ {/* Divider */}
+
+
+ {/* Has subtasks chip */}
+
+
+ )
+}
diff --git a/TaskTracker.Web/src/components/KanbanBoard.tsx b/TaskTracker.Web/src/components/KanbanBoard.tsx
index e03aebb..c261b16 100644
--- a/TaskTracker.Web/src/components/KanbanBoard.tsx
+++ b/TaskTracker.Web/src/components/KanbanBoard.tsx
@@ -11,7 +11,6 @@ import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
import { useState } from 'react'
import { Loader2 } from 'lucide-react'
import {
- useTasks,
useStartTask,
usePauseTask,
useResumeTask,
@@ -24,11 +23,12 @@ import KanbanColumn from './KanbanColumn.tsx'
import TaskCard from './TaskCard.tsx'
interface KanbanBoardProps {
+ tasks: WorkTask[]
+ isLoading: boolean
onTaskClick: (id: number) => void
}
-export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) {
- const { data: tasks, isLoading } = useTasks()
+export default function KanbanBoard({ tasks, isLoading, onTaskClick }: KanbanBoardProps) {
const startTask = useStartTask()
const pauseTask = usePauseTask()
const resumeTask = useResumeTask()
@@ -42,7 +42,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) {
// Filter to top-level tasks only and group by status
const columns = useMemo(() => {
- const topLevel = (tasks ?? []).filter((t) => t.parentTaskId === null)
+ const topLevel = tasks.filter((t) => t.parentTaskId === null)
return COLUMN_CONFIG.map((col) => ({
...col,
tasks: topLevel.filter((t) => t.status === col.status),
@@ -52,7 +52,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) {
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const draggedId = Number(event.active.id)
- const task = (tasks ?? []).find((t) => t.id === draggedId) ?? null
+ const task = tasks.find((t) => t.id === draggedId) ?? null
setActiveTask(task)
},
[tasks]
@@ -66,7 +66,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) {
if (!over) return
const taskId = Number(active.id)
- const task = (tasks ?? []).find((t) => t.id === taskId)
+ const task = tasks.find((t) => t.id === taskId)
if (!task) return
// Determine target status from the droppable column ID
@@ -78,7 +78,7 @@ export default function KanbanBoard({ onTaskClick }: KanbanBoardProps) {
} else {
// Dropped over another card - find which column it belongs to
const overTaskId = Number(over.id)
- const overTask = (tasks ?? []).find((t) => t.id === overTaskId)
+ const overTask = tasks.find((t) => t.id === overTaskId)
if (overTask) {
targetStatus = overTask.status
}
diff --git a/TaskTracker.Web/src/components/Layout.tsx b/TaskTracker.Web/src/components/Layout.tsx
index f846fa3..f64fbd0 100644
--- a/TaskTracker.Web/src/components/Layout.tsx
+++ b/TaskTracker.Web/src/components/Layout.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react'
-import { NavLink, Outlet } from 'react-router-dom'
+import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import { LayoutGrid, BarChart3, Link, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
+import SearchBar from './SearchBar.tsx'
const navItems = [
{ to: '/board', label: 'Board', icon: LayoutGrid },
@@ -10,6 +11,7 @@ const navItems = [
export default function Layout() {
const [collapsed, setCollapsed] = useState(false)
+ const navigate = useNavigate()
return (
@@ -54,7 +56,7 @@ export default function Layout() {
{/* Top bar */}
TaskTracker
-
+ navigate(`/board?task=${taskId}`)} />
{/* Content */}
diff --git a/TaskTracker.Web/src/components/SearchBar.tsx b/TaskTracker.Web/src/components/SearchBar.tsx
new file mode 100644
index 0000000..461e3e5
--- /dev/null
+++ b/TaskTracker.Web/src/components/SearchBar.tsx
@@ -0,0 +1,179 @@
+import { useState, useRef, useEffect, useCallback } from 'react'
+import { Search } from 'lucide-react'
+import { useTasks } from '../api/tasks.ts'
+import { CATEGORY_COLORS, COLUMN_CONFIG } from '../lib/constants.ts'
+import type { WorkTask } from '../types/index.ts'
+
+interface SearchBarProps {
+ onSelect: (taskId: number) => void
+}
+
+export default function SearchBar({ onSelect }: SearchBarProps) {
+ const { data: tasks } = useTasks()
+ const [query, setQuery] = useState('')
+ const [debouncedQuery, setDebouncedQuery] = useState('')
+ const [isOpen, setIsOpen] = useState(false)
+ const [selectedIndex, setSelectedIndex] = useState(0)
+ const containerRef = useRef
(null)
+ const inputRef = useRef(null)
+ const timerRef = useRef | null>(null)
+
+ // Debounce the query by 200ms
+ useEffect(() => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ timerRef.current = setTimeout(() => {
+ setDebouncedQuery(query)
+ }, 200)
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }
+ }, [query])
+
+ // Filter tasks based on debounced query
+ const results: WorkTask[] = (() => {
+ if (!debouncedQuery.trim() || !tasks) return []
+ const q = debouncedQuery.toLowerCase()
+ return tasks
+ .filter(
+ (t) =>
+ t.title.toLowerCase().includes(q) ||
+ (t.description && t.description.toLowerCase().includes(q))
+ )
+ .slice(0, 8)
+ })()
+
+ // Open/close dropdown based on results
+ useEffect(() => {
+ setIsOpen(results.length > 0)
+ setSelectedIndex(0)
+ }, [results.length])
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ function handleClickOutside(e: MouseEvent) {
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
+ setIsOpen(false)
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => document.removeEventListener('mousedown', handleClickOutside)
+ }, [])
+
+ const handleSelect = useCallback(
+ (taskId: number) => {
+ onSelect(taskId)
+ setQuery('')
+ setDebouncedQuery('')
+ setIsOpen(false)
+ inputRef.current?.blur()
+ },
+ [onSelect]
+ )
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!isOpen) return
+
+ switch (e.key) {
+ case 'ArrowDown':
+ e.preventDefault()
+ setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1))
+ break
+ case 'ArrowUp':
+ e.preventDefault()
+ setSelectedIndex((prev) => Math.max(prev - 1, 0))
+ break
+ case 'Enter':
+ e.preventDefault()
+ if (results[selectedIndex]) {
+ handleSelect(results[selectedIndex].id)
+ }
+ break
+ case 'Escape':
+ e.preventDefault()
+ setIsOpen(false)
+ inputRef.current?.blur()
+ break
+ }
+ },
+ [isOpen, results, selectedIndex, handleSelect]
+ )
+
+ const getStatusLabel = (status: number) => {
+ const col = COLUMN_CONFIG.find((c) => c.status === status)
+ return col ? col.label : 'Unknown'
+ }
+
+ const getStatusColor = (status: number) => {
+ const col = COLUMN_CONFIG.find((c) => c.status === status)
+ return col ? col.color : '#64748b'
+ }
+
+ return (
+
+
+
+ setQuery(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onFocus={() => {
+ if (results.length > 0) setIsOpen(true)
+ }}
+ placeholder="Search tasks..."
+ className="w-full h-8 pl-9 pr-3 rounded-full bg-[#1a1d27] text-white text-sm placeholder-[#94a3b8] border border-white/5 focus:border-indigo-500/50 focus:outline-none transition-colors"
+ />
+
+
+ {isOpen && results.length > 0 && (
+
+ {results.map((task, index) => {
+ const categoryColor =
+ CATEGORY_COLORS[task.category ?? 'Unknown'] ?? CATEGORY_COLORS['Unknown']
+ const statusColor = getStatusColor(task.status)
+ const statusLabel = getStatusLabel(task.status)
+
+ return (
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/TaskTracker.Web/src/pages/Board.tsx b/TaskTracker.Web/src/pages/Board.tsx
index 6bea738..fc5d07e 100644
--- a/TaskTracker.Web/src/pages/Board.tsx
+++ b/TaskTracker.Web/src/pages/Board.tsx
@@ -1,13 +1,46 @@
-import { useState } from 'react'
+import { useState, useEffect, useMemo } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { useTasks } from '../api/tasks.ts'
import KanbanBoard from '../components/KanbanBoard.tsx'
import TaskDetailPanel from '../components/TaskDetailPanel.tsx'
+import FilterBar, { applyFilters, EMPTY_FILTERS } from '../components/FilterBar.tsx'
+import type { Filters } from '../components/FilterBar.tsx'
export default function Board() {
const [selectedTaskId, setSelectedTaskId] = useState(null)
+ const [searchParams, setSearchParams] = useSearchParams()
+ const [filters, setFilters] = useState(EMPTY_FILTERS)
+ const { data: tasks, isLoading } = useTasks()
+
+ // Read ?task= search param on mount or when it changes
+ useEffect(() => {
+ const taskParam = searchParams.get('task')
+ if (taskParam) {
+ const id = Number(taskParam)
+ if (!isNaN(id)) {
+ setSelectedTaskId(id)
+ }
+ // Clean up the search param
+ setSearchParams({}, { replace: true })
+ }
+ }, [searchParams, setSearchParams])
+
+ // Apply filters to tasks
+ const filteredTasks = useMemo(() => {
+ if (!tasks) return []
+ return applyFilters(tasks, filters)
+ }, [tasks, filters])
return (
-
-
setSelectedTaskId(id)} />
+
+
+
+ setSelectedTaskId(id)}
+ />
+
{selectedTaskId !== null && (
setSelectedTaskId(null)} />
)}