diff --git a/.kiro/specs/ultimate-lists-app/tasks.md b/.kiro/specs/ultimate-lists-app/tasks.md
index 9d32e6e..14c2eae 100644
--- a/.kiro/specs/ultimate-lists-app/tasks.md
+++ b/.kiro/specs/ultimate-lists-app/tasks.md
@@ -120,7 +120,7 @@
- **Property 14: Overdue task identification**
- **Validates: Requirements 5.5**
-- [ ] 8. Add search and filter UI components
+- [-] 8. Add search and filter UI components
- Create SearchBar component with debounced input
- Build FilterPanel component with controls for priority, completion, tags, and due date
- Add sort controls (field and direction)
diff --git a/app/globals.css b/app/globals.css
index bd6213e..8136de0 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,3 +1,12 @@
@tailwind base;
@tailwind components;
-@tailwind utilities;
\ No newline at end of file
+@tailwind utilities;
+
+/* Search highlight styles */
+mark {
+ background-color: #fef08a;
+ color: inherit;
+ padding: 0 2px;
+ border-radius: 2px;
+ font-weight: 500;
+}
\ No newline at end of file
diff --git a/app/list/[id]/page.tsx b/app/list/[id]/page.tsx
index 92fda00..381bb93 100644
--- a/app/list/[id]/page.tsx
+++ b/app/list/[id]/page.tsx
@@ -1,9 +1,9 @@
import { getListWithItems } from '@/lib/actions'
import { notFound } from 'next/navigation'
import Link from 'next/link'
-import { ArrowLeft, Plus } from 'lucide-react'
-import ItemCard from '@/components/ItemCard'
+import { ArrowLeft } from 'lucide-react'
import InlineItemCreate from '@/components/InlineItemCreate'
+import ListDetailView from '@/components/ListDetailView'
export default async function ListDetailPage({ params }: { params: { id: string } }) {
const list = await getListWithItems(params.id)
@@ -13,7 +13,6 @@ export default async function ListDetailPage({ params }: { params: { id: string
}
const completedItems = list.items.filter(item => item.completed)
- const incompleteItems = list.items.filter(item => !item.completed)
return (
@@ -45,44 +44,8 @@ export default async function ListDetailPage({ params }: { params: { id: string
- {/* Items List */}
-
- {/* Incomplete Items */}
- {incompleteItems.length > 0 && (
-
-
- To Do ({incompleteItems.length})
-
-
- {incompleteItems.map((item) => (
-
- ))}
-
-
- )}
-
- {/* Completed Items */}
- {completedItems.length > 0 && (
-
-
- Completed ({completedItems.length})
-
-
- {completedItems.map((item) => (
-
- ))}
-
-
- )}
-
- {/* Empty State */}
- {list.items.length === 0 && (
-
-
No tasks yet
-
Add your first task to get started
-
- )}
-
+ {/* Search, Filter, and Items List */}
+
)
}
diff --git a/app/page.tsx b/app/page.tsx
index eadc93f..253fd80 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,6 +1,7 @@
import { getLists } from '@/lib/actions'
import ListCard from '@/components/ListCard'
import NewListButton from '@/components/NewListButton'
+import GlobalSearch from '@/components/GlobalSearch'
export default async function Home() {
const lists = await getLists()
@@ -12,6 +13,11 @@ export default async function Home() {
+ {/* Global Search */}
+
+
+
+
{lists.map((list) => (
diff --git a/components/FilterPanel.tsx b/components/FilterPanel.tsx
new file mode 100644
index 0000000..7b79fde
--- /dev/null
+++ b/components/FilterPanel.tsx
@@ -0,0 +1,295 @@
+'use client'
+
+import { useState } from 'react'
+import { Filter, X, ChevronDown, ChevronUp } from 'lucide-react'
+
+export interface FilterState {
+ priority: 'low' | 'medium' | 'high' | null
+ completed: boolean | null
+ tags: string[]
+ dueDateRange: { start: Date | null; end: Date | null }
+ sortBy: 'createdAt' | 'dueDate' | 'priority' | 'title'
+ sortOrder: 'asc' | 'desc'
+}
+
+interface FilterPanelProps {
+ filters: FilterState
+ onFiltersChange: (filters: FilterState) => void
+ availableTags?: string[]
+ showListScope?: boolean
+}
+
+export default function FilterPanel({
+ filters,
+ onFiltersChange,
+ availableTags = [],
+ showListScope = false
+}: FilterPanelProps) {
+ const [isExpanded, setIsExpanded] = useState(false)
+
+ const hasActiveFilters =
+ filters.priority !== null ||
+ filters.completed !== null ||
+ filters.tags.length > 0 ||
+ filters.dueDateRange.start !== null ||
+ filters.dueDateRange.end !== null
+
+ const handlePriorityChange = (priority: 'low' | 'medium' | 'high' | null) => {
+ onFiltersChange({ ...filters, priority })
+ }
+
+ const handleCompletedChange = (completed: boolean | null) => {
+ onFiltersChange({ ...filters, completed })
+ }
+
+ const handleTagToggle = (tag: string) => {
+ const newTags = filters.tags.includes(tag)
+ ? filters.tags.filter(t => t !== tag)
+ : [...filters.tags, tag]
+ onFiltersChange({ ...filters, tags: newTags })
+ }
+
+ const handleDueDateStartChange = (date: string) => {
+ const start = date ? new Date(date) : null
+ onFiltersChange({
+ ...filters,
+ dueDateRange: { ...filters.dueDateRange, start }
+ })
+ }
+
+ const handleDueDateEndChange = (date: string) => {
+ const end = date ? new Date(date) : null
+ onFiltersChange({
+ ...filters,
+ dueDateRange: { ...filters.dueDateRange, end }
+ })
+ }
+
+ const handleSortByChange = (sortBy: FilterState['sortBy']) => {
+ onFiltersChange({ ...filters, sortBy })
+ }
+
+ const handleSortOrderToggle = () => {
+ onFiltersChange({
+ ...filters,
+ sortOrder: filters.sortOrder === 'asc' ? 'desc' : 'asc'
+ })
+ }
+
+ const handleClearFilters = () => {
+ onFiltersChange({
+ priority: null,
+ completed: null,
+ tags: [],
+ dueDateRange: { start: null, end: null },
+ sortBy: 'createdAt',
+ sortOrder: 'desc'
+ })
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Filter Controls */}
+ {isExpanded && (
+
+ {/* Sort Controls */}
+
+
+
+
+
+
+
+
+ {/* Priority Filter */}
+
+
+
+
+
+
+
+
+
+
+ {/* Completion Status Filter */}
+
+
+
+
+
+
+
+
+
+ {/* Tags Filter */}
+ {availableTags.length > 0 && (
+
+
+
+ {availableTags.map((tag) => (
+
+ ))}
+
+
+ )}
+
+ {/* Due Date Range Filter */}
+
+
+ {/* Clear Filters Button */}
+ {hasActiveFilters && (
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/components/GlobalSearch.tsx b/components/GlobalSearch.tsx
new file mode 100644
index 0000000..d2f2572
--- /dev/null
+++ b/components/GlobalSearch.tsx
@@ -0,0 +1,71 @@
+'use client'
+
+import { useState, useCallback } from 'react'
+import SearchBar from './SearchBar'
+import SearchResults from './SearchResults'
+import { searchItems } from '@/lib/actions'
+
+interface ItemWithList {
+ id: string
+ title: string
+ description: string | null
+ completed: boolean
+ priority: string
+ dueDate: Date | null
+ tags: any
+ notes: string | null
+ list: {
+ id: string
+ title: string
+ color: string
+ }
+}
+
+export default function GlobalSearch() {
+ const [searchQuery, setSearchQuery] = useState('')
+ const [searchResults, setSearchResults] = useState
([])
+ const [isSearching, setIsSearching] = useState(false)
+
+ const handleSearch = useCallback(async (query: string) => {
+ setSearchQuery(query)
+
+ if (!query.trim()) {
+ setSearchResults([])
+ setIsSearching(false)
+ return
+ }
+
+ setIsSearching(true)
+ try {
+ const results = await searchItems(query)
+ setSearchResults(results as ItemWithList[])
+ } catch (error) {
+ console.error('Search error:', error)
+ setSearchResults([])
+ } finally {
+ setIsSearching(false)
+ }
+ }, [])
+
+ const handleClose = useCallback(() => {
+ setSearchQuery('')
+ setSearchResults([])
+ }, [])
+
+ return (
+
+
+
+ {searchQuery.trim() && (
+
+ )}
+
+ )
+}
diff --git a/components/ListDetailView.tsx b/components/ListDetailView.tsx
new file mode 100644
index 0000000..4ed74cf
--- /dev/null
+++ b/components/ListDetailView.tsx
@@ -0,0 +1,178 @@
+'use client'
+
+import { useState, useCallback, useMemo } from 'react'
+import ItemCard from './ItemCard'
+import SearchBar from './SearchBar'
+import FilterPanel, { FilterState } from './FilterPanel'
+import SearchResults from './SearchResults'
+import { searchItems, getItemsByFilter } from '@/lib/actions'
+
+interface Item {
+ id: string
+ title: string
+ description: string | null
+ completed: boolean
+ priority: string
+ dueDate: Date | null
+ tags: any
+ notes: string | null
+ createdAt: Date
+ updatedAt: Date
+ listId: string
+}
+
+interface ItemWithList extends Item {
+ list: {
+ id: string
+ title: string
+ color: string
+ }
+}
+
+interface ListDetailViewProps {
+ listId: string
+ initialItems: Item[]
+}
+
+export default function ListDetailView({ listId, initialItems }: ListDetailViewProps) {
+ const [searchQuery, setSearchQuery] = useState('')
+ const [searchResults, setSearchResults] = useState([])
+ const [isSearching, setIsSearching] = useState(false)
+
+ const [filters, setFilters] = useState({
+ priority: null,
+ completed: null,
+ tags: [],
+ dueDateRange: { start: null, end: null },
+ sortBy: 'createdAt',
+ sortOrder: 'desc'
+ })
+
+ const [filteredItems, setFilteredItems] = useState- (initialItems)
+
+ // Extract all unique tags from items
+ const availableTags = useMemo(() => {
+ const tagSet = new Set()
+ initialItems.forEach(item => {
+ const tags = Array.isArray(item.tags) ? item.tags : []
+ tags.forEach(tag => {
+ if (typeof tag === 'string') {
+ tagSet.add(tag)
+ }
+ })
+ })
+ return Array.from(tagSet).sort()
+ }, [initialItems])
+
+ // Handle search
+ const handleSearch = useCallback(async (query: string) => {
+ setSearchQuery(query)
+
+ if (!query.trim()) {
+ setSearchResults([])
+ setIsSearching(false)
+ return
+ }
+
+ setIsSearching(true)
+ try {
+ const results = await searchItems(query, listId)
+ setSearchResults(results as ItemWithList[])
+ } catch (error) {
+ console.error('Search error:', error)
+ setSearchResults([])
+ }
+ }, [listId])
+
+ // Handle filter changes
+ const handleFiltersChange = useCallback(async (newFilters: FilterState) => {
+ setFilters(newFilters)
+
+ try {
+ const results = await getItemsByFilter(listId, {
+ priority: newFilters.priority,
+ completed: newFilters.completed,
+ tags: newFilters.tags,
+ dueDateRange: newFilters.dueDateRange,
+ sortBy: newFilters.sortBy,
+ sortOrder: newFilters.sortOrder
+ })
+ setFilteredItems(results as Item[])
+ } catch (error) {
+ console.error('Filter error:', error)
+ setFilteredItems(initialItems)
+ }
+ }, [listId, initialItems])
+
+ // Determine which items to display
+ const displayItems = searchQuery.trim() ? searchResults : filteredItems
+ const completedItems = displayItems.filter(item => item.completed)
+ const incompleteItems = displayItems.filter(item => !item.completed)
+
+ return (
+
+ {/* Search Bar */}
+
+
+ {/* Filter Panel */}
+
+
+ {/* Results */}
+ {searchQuery.trim() ? (
+
+ ) : (
+
+ {/* Incomplete Items */}
+ {incompleteItems.length > 0 && (
+
+
+ To Do ({incompleteItems.length})
+
+
+ {incompleteItems.map((item) => (
+
+ ))}
+
+
+ )}
+
+ {/* Completed Items */}
+ {completedItems.length > 0 && (
+
+
+ Completed ({completedItems.length})
+
+
+ {completedItems.map((item) => (
+
+ ))}
+
+
+ )}
+
+ {/* Empty State */}
+ {displayItems.length === 0 && (
+
+
No tasks found
+
+ {filters.priority || filters.completed !== null || filters.tags.length > 0
+ ? 'Try adjusting your filters'
+ : 'Add your first task to get started'}
+
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/components/SearchBar.tsx b/components/SearchBar.tsx
new file mode 100644
index 0000000..bae1850
--- /dev/null
+++ b/components/SearchBar.tsx
@@ -0,0 +1,61 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import { Search, X } from 'lucide-react'
+
+interface SearchBarProps {
+ onSearch: (query: string) => void
+ placeholder?: string
+ debounceMs?: number
+ initialValue?: string
+}
+
+export default function SearchBar({
+ onSearch,
+ placeholder = 'Search tasks...',
+ debounceMs = 300,
+ initialValue = ''
+}: SearchBarProps) {
+ const [query, setQuery] = useState(initialValue)
+
+ // Debounced search effect
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ onSearch(query)
+ }, debounceMs)
+
+ return () => clearTimeout(timer)
+ }, [query, debounceMs, onSearch])
+
+ const handleClear = useCallback(() => {
+ setQuery('')
+ onSearch('')
+ }, [onSearch])
+
+ return (
+
+
+
+ setQuery(e.target.value)}
+ placeholder={placeholder}
+ className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
+ />
+ {query && (
+
+ )}
+
+
+ )
+}
diff --git a/components/SearchResults.tsx b/components/SearchResults.tsx
new file mode 100644
index 0000000..92f903c
--- /dev/null
+++ b/components/SearchResults.tsx
@@ -0,0 +1,155 @@
+'use client'
+
+import { highlightText } from '@/lib/utils'
+import Link from 'next/link'
+import { Calendar, Tag, AlertCircle } from 'lucide-react'
+
+interface SearchResultsProps {
+ results: Array<{
+ id: string
+ title: string
+ description: string | null
+ completed: boolean
+ priority: string
+ dueDate: Date | null
+ tags: any
+ notes: string | null
+ list: {
+ id: string
+ title: string
+ color: string
+ }
+ }>
+ query: string
+ onClose?: () => void
+}
+
+export default function SearchResults({ results, query, onClose }: SearchResultsProps) {
+ if (!query.trim()) {
+ return null
+ }
+
+ if (results.length === 0) {
+ return (
+
+
No tasks found matching "{query}"
+
Try a different search term
+
+ )
+ }
+
+ const priorityColors = {
+ low: 'bg-green-100 text-green-700 border-green-200',
+ medium: 'bg-yellow-100 text-yellow-700 border-yellow-200',
+ high: 'bg-red-100 text-red-700 border-red-200',
+ }
+
+ return (
+
+
+
+ Found {results.length} {results.length === 1 ? 'task' : 'tasks'}
+
+
+
+ {results.map((item) => {
+ const tags = Array.isArray(item.tags) ? item.tags : []
+ const isOverdue = item.dueDate && new Date(item.dueDate) < new Date() && !item.completed
+ const priorityColor = priorityColors[item.priority as keyof typeof priorityColors] || priorityColors.medium
+
+ return (
+
+ {/* List Badge */}
+
+
+ {/* Title with highlighting */}
+
+
+ {/* Description with highlighting */}
+ {item.description && (
+
+ )}
+
+ {/* Notes with highlighting (if matches) */}
+ {item.notes && item.notes.toLowerCase().includes(query.toLowerCase()) && (
+
+ )}
+
+ {/* Metadata */}
+
+ {/* Priority Badge */}
+
+
+ {item.priority.charAt(0).toUpperCase() + item.priority.slice(1)}
+
+
+ {/* Due Date */}
+ {item.dueDate && (
+
+
+ {new Date(item.dueDate).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ })}
+ {isOverdue && ' (Overdue)'}
+
+ )}
+
+ {/* Tags with highlighting */}
+ {tags.length > 0 && (
+
+
+
+ {tags.length > 2 && (
+
+ {` +${tags.length - 2}`}
+
+ )}
+
+ )}
+
+
+ )
+ })}
+
+ )
+}