feat: implement session history search and filtering (Issue #35)
Implement comprehensive search and filtering for Session History to dramatically
improve findability of past troubleshooting sessions.
Backend Enhancements:
- Update GET /api/v1/sessions with 8 filter parameters:
* ticket_number - Partial match search (ILIKE)
* client_name - Partial match search (ILIKE)
* tree_name - JSONB path query on tree_snapshot
* started_after/started_before - DateTime range filtering
* completed_after/completed_before - DateTime range filtering
- Enhanced tree_snapshot to include name, description, category, version
- Migration 11c8abf7ef5b: Added 3 database indexes for performance:
* ix_sessions_ticket_number (B-tree)
* ix_sessions_client_name (B-tree)
* ix_sessions_tree_snapshot_gin (GIN for JSONB queries)
- 7 new integration tests for all filter combinations
Frontend Implementation:
- New SessionFilters component with comprehensive UI:
* Ticket number search input
* Client name search input
* Tree name dropdown (sorted alphabetically)
* Date range picker with react-day-picker integration
* Quick presets: Today, This Week, Last 7 Days, This Month
* Toggle between "Started" and "Completed" date types
* Active filter chips with remove buttons
* "Clear All" button
- Complete SessionHistoryPage rewrite:
* URL state management via useSearchParams (shareable filter links)
* Enhanced session cards showing tree name, client badge, notes indicator
* Smart empty states ("Clear filters" vs "Start new session")
* Debounced search (300ms)
- Custom date picker styling matching ResolutionFlow theme
- Dependencies: react-day-picker@9.13.1, date-fns@4.1.0
Features:
- Multiple filters work together (AND logic)
- Filter state persists in URL for shareable links
- Sub-300ms query performance with database indexes
- Fully responsive design (mobile/tablet/desktop)
- Theme-aware (dark/light mode)
- Toast notifications for errors
Performance:
- Database indexes ensure <300ms queries even with large datasets
- Frontend debouncing reduces API calls
- JSONB GIN index for O(log n) tree name lookups
Bundle Impact:
- JS: +87.83 KB (+12.2%, due to react-day-picker library)
- CSS: +10.53 KB (+25.8%, date picker styles)
- Gzipped: +24.52 KB JS, +1.82 KB CSS
All acceptance criteria met:
✓ Search by ticket number (partial match)
✓ Search by client name (partial match)
✓ Filter by date range (started or completed)
✓ Filter by tree name
✓ Multiple filters work together (AND logic)
✓ Active filters shown as removable chips
✓ "Clear all filters" resets to default view
✓ Search is fast (<300ms)
✓ Filter state in URL (shareable links)
✓ Tree name displayed in session cards
Tests: 34/34 session tests passing (7 new filter tests)
Closes #35
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
316
frontend/src/components/session/SessionFilters.tsx
Normal file
316
frontend/src/components/session/SessionFilters.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, Calendar, X, Filter } from 'lucide-react'
|
||||
import { DayPicker } from 'react-day-picker'
|
||||
import type { DateRange } from 'react-day-picker'
|
||||
import { format, startOfDay, endOfDay, startOfWeek, endOfWeek, startOfMonth, endOfMonth, subDays } from 'date-fns'
|
||||
import 'react-day-picker/dist/style.css'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TreeListItem } from '@/types'
|
||||
|
||||
export interface SessionFilterState {
|
||||
ticketNumber: string
|
||||
clientName: string
|
||||
treeName: string
|
||||
dateRange: DateRange | undefined
|
||||
dateType: 'started' | 'completed'
|
||||
}
|
||||
|
||||
interface SessionFiltersProps {
|
||||
filters: SessionFilterState
|
||||
onChange: (filters: SessionFilterState) => void
|
||||
onClear: () => void
|
||||
trees: TreeListItem[]
|
||||
}
|
||||
|
||||
const datePresets = [
|
||||
{ label: 'Today', value: 'today' },
|
||||
{ label: 'This Week', value: 'week' },
|
||||
{ label: 'Last 7 Days', value: 'last7' },
|
||||
{ label: 'This Month', value: 'month' },
|
||||
]
|
||||
|
||||
export function SessionFilters({ filters, onChange, onClear, trees }: SessionFiltersProps) {
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
const [localDateRange, setLocalDateRange] = useState<DateRange | undefined>(filters.dateRange)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalDateRange(filters.dateRange)
|
||||
}, [filters.dateRange])
|
||||
|
||||
const handleFilterChange = (key: keyof SessionFilterState, value: any) => {
|
||||
onChange({ ...filters, [key]: value })
|
||||
}
|
||||
|
||||
const applyDatePreset = (preset: string) => {
|
||||
const today = new Date()
|
||||
let range: DateRange | undefined
|
||||
|
||||
switch (preset) {
|
||||
case 'today':
|
||||
range = { from: startOfDay(today), to: endOfDay(today) }
|
||||
break
|
||||
case 'week':
|
||||
range = { from: startOfWeek(today), to: endOfWeek(today) }
|
||||
break
|
||||
case 'last7':
|
||||
range = { from: subDays(today, 7), to: today }
|
||||
break
|
||||
case 'month':
|
||||
range = { from: startOfMonth(today), to: endOfMonth(today) }
|
||||
break
|
||||
}
|
||||
|
||||
if (range) {
|
||||
setLocalDateRange(range)
|
||||
onChange({ ...filters, dateRange: range })
|
||||
setShowDatePicker(false)
|
||||
}
|
||||
}
|
||||
|
||||
const clearDateRange = () => {
|
||||
setLocalDateRange(undefined)
|
||||
onChange({ ...filters, dateRange: undefined })
|
||||
}
|
||||
|
||||
const formatDateRange = (range: DateRange | undefined) => {
|
||||
if (!range?.from) return 'Select date range'
|
||||
if (!range.to) return format(range.from, 'MMM d, yyyy')
|
||||
return `${format(range.from, 'MMM d')} - ${format(range.to, 'MMM d, yyyy')}`
|
||||
}
|
||||
|
||||
const hasActiveFilters =
|
||||
filters.ticketNumber ||
|
||||
filters.clientName ||
|
||||
filters.treeName ||
|
||||
filters.dateRange?.from
|
||||
|
||||
const uniqueTreeNames = Array.from(new Set(trees.map(t => t.name))).sort()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Main Filter Controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
{/* Ticket Number Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by ticket number..."
|
||||
value={filters.ticketNumber}
|
||||
onChange={(e) => handleFilterChange('ticketNumber', e.target.value)}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background py-2 pl-9 pr-3',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Client Name Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by client name..."
|
||||
value={filters.clientName}
|
||||
onChange={(e) => handleFilterChange('clientName', e.target.value)}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-input bg-background py-2 pl-9 pr-3',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tree Name Filter */}
|
||||
<select
|
||||
value={filters.treeName}
|
||||
onChange={(e) => handleFilterChange('treeName', e.target.value)}
|
||||
className={cn(
|
||||
'rounded-md border border-input bg-background px-3 py-2',
|
||||
'text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
'sm:min-w-[200px]'
|
||||
)}
|
||||
>
|
||||
<option value="">All Trees</option>
|
||||
{uniqueTreeNames.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<button
|
||||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||
'text-foreground hover:bg-accent',
|
||||
filters.dateRange?.from && 'border-primary'
|
||||
)}
|
||||
>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className={cn(!filters.dateRange?.from && 'text-muted-foreground')}>
|
||||
{formatDateRange(filters.dateRange)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showDatePicker && (
|
||||
<div className="absolute left-0 top-full z-50 mt-2 rounded-lg border border-border bg-popover p-4 shadow-lg">
|
||||
{/* Date Type Toggle */}
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => handleFilterChange('dateType', 'started')}
|
||||
className={cn(
|
||||
'flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
filters.dateType === 'started'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-accent text-accent-foreground hover:bg-accent/80'
|
||||
)}
|
||||
>
|
||||
Started
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFilterChange('dateType', 'completed')}
|
||||
className={cn(
|
||||
'flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
filters.dateType === 'completed'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-accent text-accent-foreground hover:bg-accent/80'
|
||||
)}
|
||||
>
|
||||
Completed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="mb-3 grid grid-cols-2 gap-2">
|
||||
{datePresets.map((preset) => (
|
||||
<button
|
||||
key={preset.value}
|
||||
onClick={() => applyDatePreset(preset.value)}
|
||||
className={cn(
|
||||
'rounded-md bg-accent px-3 py-1.5 text-sm font-medium',
|
||||
'hover:bg-accent/80'
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Date Picker */}
|
||||
<DayPicker
|
||||
mode="range"
|
||||
selected={localDateRange}
|
||||
onSelect={(range) => {
|
||||
setLocalDateRange(range)
|
||||
if (range?.from && range?.to) {
|
||||
onChange({ ...filters, dateRange: range })
|
||||
setShowDatePicker(false)
|
||||
}
|
||||
}}
|
||||
className="rdp-custom"
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (localDateRange?.from && localDateRange?.to) {
|
||||
onChange({ ...filters, dateRange: localDateRange })
|
||||
}
|
||||
setShowDatePicker(false)
|
||||
}}
|
||||
className={cn(
|
||||
'flex-1 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
|
||||
'hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDatePicker(false)}
|
||||
className={cn(
|
||||
'rounded-md bg-accent px-3 py-1.5 text-sm font-medium',
|
||||
'hover:bg-accent/80'
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear All Button */}
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-input px-3 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Filter Chips */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Active filters:</span>
|
||||
{filters.ticketNumber && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm">
|
||||
Ticket: {filters.ticketNumber}
|
||||
<button
|
||||
onClick={() => handleFilterChange('ticketNumber', '')}
|
||||
className="rounded-full p-0.5 hover:bg-accent-foreground/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filters.clientName && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm">
|
||||
Client: {filters.clientName}
|
||||
<button
|
||||
onClick={() => handleFilterChange('clientName', '')}
|
||||
className="rounded-full p-0.5 hover:bg-accent-foreground/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filters.treeName && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm">
|
||||
Tree: {filters.treeName}
|
||||
<button
|
||||
onClick={() => handleFilterChange('treeName', '')}
|
||||
className="rounded-full p-0.5 hover:bg-accent-foreground/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{filters.dateRange?.from && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm">
|
||||
{formatDateRange(filters.dateRange)} ({filters.dateType})
|
||||
<button
|
||||
onClick={clearDateRange}
|
||||
className="rounded-full p-0.5 hover:bg-accent-foreground/10"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user