'use client'; import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { Calendar, MoreHorizontal, Pencil, Trash2, ArrowUpDown, ArrowUp, ArrowDown, Search, X, } from 'lucide-react'; import { toast } from 'sonner'; import { TaskDetailPanel } from './task-detail-panel'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { useRealtimeContext } from '@/components/realtime-provider'; interface Task { id: string; title: string; description?: string | null; status: 'todo' | 'in_progress' | 'done' | 'cancelled'; priority: 'low' | 'medium' | 'high' | 'urgent'; domainId: string; dueDate?: string | null; projectId?: string | null; order: number; tags: { id: string; name: string; color: string | null }[]; } const priorityColors: Record = { urgent: 'destructive', high: 'default', medium: 'secondary', low: 'secondary', }; const statusOptions = ['todo', 'in_progress', 'done', 'cancelled'] as const; const priorityOptions = ['low', 'medium', 'high', 'urgent'] as const; const sortableColumns = [ { key: 'title', label: 'Task' }, { key: 'status', label: 'Status' }, { key: 'priority', label: 'Priority' }, { key: 'due_date', label: 'Due Date' }, ] as const; function useDebounce(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debounced; } function MultiSelectFilter({ label, options, selected, onChange, }: { label: string; options: readonly string[]; selected: string[]; onChange: (values: string[]) => void; }) { const [open, setOpen] = useState(false); return (
{options.map((opt) => { const checked = selected.includes(opt); return ( ); })}
{selected.length > 0 && ( )}
); } export function TasksListView({ domainId, onRefresh, }: { domainId: string; onRefresh?: () => void; }) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const [tasks, setTasks] = useState([]); const [totalCount, setTotalCount] = useState(0); const [offset, setOffset] = useState(0); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const lastErrorRef = useRef(null); const [selectedTask, setSelectedTask] = useState(null); const [deleteId, setDeleteId] = useState(null); const [deleting, setDeleting] = useState(false); const [domainMap, setDomainMap] = useState>(new Map()); const LIMIT = 50; const { subscribe } = useRealtimeContext(); // Filter/sort state from URL params const statusFilter = searchParams.get('status')?.split(',').filter(Boolean) || []; const priorityFilter = searchParams.get('priority')?.split(',').filter(Boolean) || []; const domainFilter = searchParams.get('domain') || ''; const searchQuery = searchParams.get('search') || ''; const sort = searchParams.get('sort') || 'order'; const order = searchParams.get('order') || 'asc'; // Local search input (debounced) const [searchInput, setSearchInput] = useState(searchQuery); const debouncedSearch = useDebounce(searchInput, 300); // Bulk selection const [selectedIds, setSelectedIds] = useState>(new Set()); const [bulkStatusOpen, setBulkStatusOpen] = useState(false); const [bulkPriorityOpen, setBulkPriorityOpen] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false); // Sync debounced search to URL useEffect(() => { if (debouncedSearch !== searchQuery) { const params = new URLSearchParams(searchParams.toString()); if (debouncedSearch) { params.set('search', debouncedSearch); } else { params.delete('search'); } router.replace(`${pathname}?${params.toString()}`, { scroll: false }); } }, [debouncedSearch, searchQuery, searchParams, pathname, router]); const updateURLParam = useCallback( (key: string, value: string | null) => { const params = new URLSearchParams(searchParams.toString()); if (value) { params.set(key, value); } else { params.delete(key); } router.replace(`${pathname}?${params.toString()}`, { scroll: false }); }, [searchParams, pathname, router] ); const fetchTasks = useCallback(async (appendOffset?: number) => { if (!domainId) return; if (appendOffset === undefined) setLoading(true); else setLoadingMore(true); try { const currentOffset = appendOffset ?? 0; const params = new URLSearchParams({ sort, order, limit: String(LIMIT), offset: String(currentOffset) }); if (statusFilter.length > 0) params.set('status', statusFilter.join(',')); if (priorityFilter.length > 0) params.set('priority', priorityFilter.join(',')); if (searchQuery) params.set('search', searchQuery); const response = await fetch(`/api/domains/${domainId}/tasks?${params.toString()}`); if (!response.ok) throw new Error('Unable to load tasks'); const data = await response.json(); if (appendOffset !== undefined) { setTasks(prev => [...prev, ...(data.items || [])]); } else { setTasks(data.items || []); setOffset(0); } setTotalCount(data.totalItems || 0); // Successful fetch — reset the dedup tracker so the next error // class toasts fresh instead of being suppressed. lastErrorRef.current = null; } catch (error) { console.error('Failed to fetch tasks:', error); // Only toast once per unique error message to prevent infinite spam // when realtime subscriptions re-trigger fetchTasks() on every event. const message = error instanceof Error ? error.message : 'Unable to load tasks'; if (message !== lastErrorRef.current) { lastErrorRef.current = message; if (message.includes('429') || message.toLowerCase().includes('rate')) { toast.error('Rate limited — slowing down'); } else { toast.error('Unable to load tasks'); } } } finally { setLoading(false); setLoadingMore(false); } }, [domainId, sort, order, statusFilter, priorityFilter, searchQuery]); function loadMore() { const newOffset = offset + LIMIT; setOffset(newOffset); fetchTasks(newOffset); } const fetchDomains = useCallback(async () => { try { const res = await fetch('/api/domains?sort=sort_order'); if (res.ok) { const data = await res.json(); const map = new Map(); for (const d of data.items || []) map.set(d.id, d.name); setDomainMap(map); } } catch {} }, []); useEffect(() => { if (!domainId) return; setLoading(true); fetchTasks(); fetchDomains(); }, [domainId, fetchTasks, fetchDomains]); // Subscribe to realtime updates useEffect(() => { if (!domainId) return; // Debounce realtime-triggered refetches so a burst of events does // not cause a flood of fetchTasks() calls + toasts. let timer: ReturnType | null = null; const debouncedRefetch = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => { fetchTasks(); onRefresh?.(); }, 750); }; debouncedRefetch.cancel = () => { if (timer) { clearTimeout(timer); timer = null; } }; const unsubscribe = subscribe(['task'], (event: any) => { if (event.type === 'task') { debouncedRefetch(); } }); return () => { debouncedRefetch.cancel(); unsubscribe; }; }, [domainId, subscribe, fetchTasks, onRefresh]); // Clear selection when tasks change useEffect(() => { setSelectedIds(new Set()); }, [tasks]); async function toggleTaskComplete(task: Task) { const newStatus = task.status === 'done' ? 'todo' : 'done'; try { const response = await fetch(`/api/domains/${domainId}/tasks/${task.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: newStatus }), }); if (!response.ok) throw new Error('Unable to update task'); await fetchTasks(); toast.success( `Marked "${task.title}" as ${newStatus === 'done' ? 'complete' : 'incomplete'}` ); } catch (error) { console.error('Failed to toggle task:', error); toast.error(`Unable to update "${task.title}"`); } } function toggleSort(column: string) { if (sort === column) { updateURLParam('order', order === 'asc' ? 'desc' : 'asc'); } else { updateURLParam('sort', column); updateURLParam('order', 'asc'); } } function getSortIcon(column: string) { if (sort !== column) return ; return order === 'asc' ? ( ) : ( ); } // Bulk actions const allSelected = tasks.length > 0 && selectedIds.size === tasks.length; function toggleSelectAll() { if (allSelected) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(tasks.map((t) => t.id))); } } function toggleSelect(id: string) { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } async function handleBulkStatus(newStatus: string) { if (selectedIds.size === 0) return; setBulkStatusOpen(false); try { const res = await fetch(`/api/domains/${domainId}/tasks/bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: Array.from(selectedIds), updates: { status: newStatus }, }), }); if (!res.ok) throw new Error(); toast.success(`Marked ${selectedIds.size} tasks as ${newStatus.replace('_', ' ')}`); setSelectedIds(new Set()); fetchTasks(); } catch { toast.error('Unable to update tasks'); } } async function handleBulkPriority(newPriority: string) { if (selectedIds.size === 0) return; setBulkPriorityOpen(false); try { const res = await fetch(`/api/domains/${domainId}/tasks/bulk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: Array.from(selectedIds), updates: { priority: newPriority }, }), }); if (!res.ok) throw new Error(); toast.success(`Set priority for ${selectedIds.size} tasks`); setSelectedIds(new Set()); fetchTasks(); } catch { toast.error('Unable to update tasks'); } } async function handleBulkDelete() { if (selectedIds.size === 0) return; setBulkDeleting(true); try { const res = await fetch(`/api/domains/${domainId}/tasks/bulk`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: Array.from(selectedIds) }), }); if (!res.ok) throw new Error(); toast.success(`Deleted ${selectedIds.size} tasks`); setSelectedIds(new Set()); fetchTasks(); } catch { toast.error('Unable to delete tasks'); } finally { setBulkDeleting(false); } } if (loading) { return

Loading tasks...

; } return ( <> {/* Filter bar */}
updateURLParam('status', values.length > 0 ? values.join(',') : null) } /> updateURLParam('priority', values.length > 0 ? values.join(',') : null) } />
setSearchInput(e.target.value)} className="h-9 pl-8 pr-8" /> {searchInput && ( )}
{/* Bulk action bar */} {selectedIds.size > 0 && (
{selectedIds.size} selected
{statusOptions.map((s) => ( handleBulkStatus(s)}> {s.replace('_', ' ')} ))} {priorityOptions.map((p) => ( handleBulkPriority(p)}> {p} ))}
)} {tasks.length === 0 ? (

No tasks yet. Create your first task to get started!

) : (
{sortableColumns.map((col) => ( toggleSort(col.key)} > {col.label} {getSortIcon(col.key)} ))} Domain {tasks.map((task) => ( toggleSelect(task.id)} aria-label={`Select "${task.title}"`} /> {task.status.replace('_', ' ')} {task.priority} {task.dueDate && ( {new Date(task.dueDate).toLocaleDateString()} )} {domainMap.get(task.domainId) || task.domainId.slice(0, 8)} setSelectedTask(task)}> Edit setDeleteId(task.id)}> Delete ))}
Showing {tasks.length} of {totalCount} tasks {tasks.length < totalCount && ( )}
)} !o && setDeleteId(null)}> Delete task? This cannot be undone. Cancel { if (!deleteId || !domainId) return; setDeleting(true); try { const res = await fetch(`/api/domains/${domainId}/tasks/${deleteId}`, { method: 'DELETE' }); if (!res.ok) throw new Error(); toast.success('Task deleted'); setDeleteId(null); fetchTasks(); } catch { toast.error('Unable to delete task'); } finally { setDeleting(false); setDeleteId(null); } }} disabled={deleting} > {deleting ? 'Deleting...' : 'Delete'} {selectedTask && ( !open && setSelectedTask(null)} onUpdate={fetchTasks} /> )} ); }