import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { createRoute, useNavigate } from "@tanstack/react-router";
import { Route as appRoute } from "../_app";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api, useApiQuery } from "@/lib/api";
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
import { useRealtime } from "@/hooks/use-realtime";
import { useOpenCreateDialog } from "@/hooks/use-open-create-dialog";
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Plus, GripVertical, Pencil, Trash2, Calendar, ListTodo, Layout as LayoutIcon, Search, MoreHorizontal, Bookmark, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
import { PRIORITY } from "@/lib/status-colors";
import type { Task, State, StateGroup, PaginatedResponse } from "@/lib/types";
import { cn } from "@/lib/utils";
import { parseTaskInput } from "@/lib/nlp";
import { useSavedViewsStore } from "@/lib/stores/use-saved-views-store";
import { RecurrencePicker } from "@/components/tasks/recurrence-picker";
import { BulkActionBar } from "@/components/tasks/bulk-action-bar";
import { showUndoToast } from "@/lib/undo/use-undo-toast";
const STATE_GROUP_COLUMNS: { id: StateGroup; label: string; colorClass: string }[] = [
{ id: "backlog", label: "Backlog", colorClass: "bg-slate-400" },
{ id: "unstarted", label: "Unstarted", colorClass: "bg-slate-500" },
{ id: "started", label: "Started", colorClass: "bg-blue-500" },
{ id: "completed", label: "Completed", colorClass: "bg-green-500" },
{ id: "cancelled", label: "Cancelled", colorClass: "bg-red-500" },
];
function SortableTaskCard({ task, stateName, stateColor, onClick, onEdit }: { task: Task; stateName?: string; stateColor?: string | null; onClick: () => void; onEdit?: () => void }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
{task.title}
{stateName && (
{stateName}
)}
{task.dueDate && (
{new Date(task.dueDate).toLocaleDateString()}
)}
{PRIORITY[task.priority]?.label ?? task.priority}
{task.tags?.slice(0, 2).map((tag) => (
{tag.name}
))}
{onEdit && (
{ e.stopPropagation(); onEdit(); }}
aria-label={"Edit " + task.title}
>
)}
);
}
function ColumnDroppable({ id, className, children }: { id: string; className?: string; children: React.ReactNode }) {
const { setNodeRef, isOver } = useDroppable({ id });
return (
{children}
);
}
function TaskForm({ task, onClose, projectId }: { task?: Task; onClose: () => void; projectId?: string | null }) {
const queryClient = useQueryClient();
const activeDomainId = useApiDomain();
const [title, setTitle] = useState(task?.title || "");
const [description, setDescription] = useState(task?.description || "");
const [priority, setPriority] = useState(task?.priority || "medium");
const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : "");
const [recurrenceRule, setRecurrenceRule] = useState(task?.recurrenceRule || "");
const [customFieldValues, setCustomFieldValues] = useState>(() => ({ ...(task?.customFields ?? {}) }));
const parsed = !task ? parseTaskInput(title) : null;
const effectiveProjectId = task?.projectId || projectId;
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", effectiveProjectId || ""],
"/states?projectId=" + effectiveProjectId,
{ enabled: !!effectiveProjectId }
);
const projectStates = statesData?.items || [];
const [selectedStateId, setSelectedStateId] = useState(task?.stateId || "");
const createMutation = useMutation({
mutationFn: (data: any) => api.post("/tasks", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
onClose();
},
});
const updateMutation = useMutation({
mutationFn: (data: any) => api.patch("/tasks/" + task!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
onClose();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
let finalTitle = title.trim();
let finalDueDate = dueDate ? new Date(dueDate).toISOString() : null;
let finalPriority = priority;
let tagNames: string[] = [];
if (!task) {
const p = parseTaskInput(title);
finalTitle = p.title;
if (p.dueDate && !dueDate) finalDueDate = p.dueDate;
if (p.priority) finalPriority = p.priority;
tagNames = p.tags;
}
const data: any = { title: finalTitle, description: description || null, priority: finalPriority, tagNames };
if (selectedStateId) data.stateId = selectedStateId || null;
if (finalDueDate) data.dueDate = finalDueDate;
if (recurrenceRule) data.recurrenceRule = recurrenceRule;
const customFields = { ...customFieldValues };
if (Object.keys(customFields).length > 0) data.customFields = customFields;
if (task) {
updateMutation.mutate(data);
} else {
createMutation.mutate({ ...data, ...(activeDomainId ? { domain: activeDomainId } : {}) });
}
};
return (
);
}
function TasksPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [view, setView] = useState<"board" | "list">("board");
const [search, setSearch] = useState("");
const [selectedStateId, setSelectedStateId] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [selectedTask, setSelectedTask] = useState(null);
const [panelOpen, setPanelOpen] = useState(false);
const [activeId, setActiveId] = useState(null);
const [selectedRowIndex, setSelectedRowIndex] = useState(-1);
const [editingTaskId, setEditingTaskId] = useState(null);
const [selectedTaskIds, setSelectedTaskIds] = useState>(new Set());
const [showKbdHint, setShowKbdHint] = useState(true);
const [editTitle, setEditTitle] = useState("");
useRealtime({ enabled: true });
useOpenCreateDialog("task", () => setCreateOpen(true));
const activeDomainId = useApiDomain();
const { data: projectsData } = useApiQuery>(
["projects", activeDomainId],
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
);
const projects = projectsData?.items || [];
const [filterProjectId, setFilterProjectId] = useState("");
const [saveViewOpen, setSaveViewOpen] = useState(false);
const [viewName, setViewName] = useState("");
const savedViews = useSavedViewsStore((s) => s.views);
const addSavedView = useSavedViewsStore((s) => s.addView);
const removeSavedView = useSavedViewsStore((s) => s.removeView);
const { data: statesData } = useApiQuery<{ items: State[] }>(
["states", filterProjectId],
"/states?projectId=" + filterProjectId,
{ enabled: !!filterProjectId }
);
const projectStates = statesData?.items || [];
const stateGroupOf = useMemo(() => {
const map = new Map();
for (const s of projectStates) map.set(s.id, s.group);
return map;
}, [projectStates]);
const stateById = useMemo(() => {
const map = new Map();
for (const s of projectStates) map.set(s.id, s);
return map;
}, [projectStates]);
const taskQueryParams = () =>
new URLSearchParams({
limit: "200",
...(activeDomainId ? { domain: activeDomainId } : {}),
...(search ? { search } : {}),
...(filterProjectId ? { project_id: filterProjectId } : {}),
...(selectedStateId ? { state_id: selectedStateId } : {}),
}).toString();
const { data: tasksData, isLoading, isError, refetch } = useApiQuery>(
["tasks", activeDomainId, search, filterProjectId, selectedStateId],
"/tasks?" + taskQueryParams()
);
const tasks = tasksData?.items || [];
const hasMoreTasks = tasks.length < (tasksData?.totalItems || 0);
const [loadingMoreTasks, setLoadingMoreTasks] = useState(false);
// Keyboard navigation for list view
useEffect(() => {
if (view !== "list") return;
const handler = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable ||
target.closest('[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"]')
) {
return;
}
setShowKbdHint(false);
const hasSelection = selectedTaskIds.size > 0;
const currentIdx = selectedRowIndex >= 0 ? selectedRowIndex : 0;
switch (e.key) {
case "j":
if (!e.shiftKey) {
e.preventDefault();
setSelectedRowIndex((prev) => {
const next = Math.min(prev + 1, tasks.length - 1);
const row = document.querySelector(`[data-row-index="${next}"]`);
row?.scrollIntoView({ block: "nearest", behavior: "smooth" });
return next;
});
}
break;
case "k":
if (!e.shiftKey) {
e.preventDefault();
setSelectedRowIndex((prev) => {
const next = Math.max(prev - 1, 0);
const row = document.querySelector(`[data-row-index="${next}"]`);
row?.scrollIntoView({ block: "nearest", behavior: "smooth" });
return next;
});
}
break;
case "J":
if (e.shiftKey) {
e.preventDefault();
if (tasks.length > 1) {
const newOrder = tasks.map((t) => t.id);
const idx = currentIdx;
const nextIdx = Math.min(idx + 1, tasks.length - 1);
if (idx !== nextIdx) {
[newOrder[idx], newOrder[nextIdx]] = [newOrder[nextIdx], newOrder[idx]];
reorderMutation.mutate({ orderedIds: newOrder });
setSelectedRowIndex(nextIdx);
}
}
}
break;
case "K":
if (e.shiftKey) {
e.preventDefault();
if (tasks.length > 1) {
const newOrder = tasks.map((t) => t.id);
const idx = currentIdx;
const nextIdx = Math.max(idx - 1, 0);
if (idx !== nextIdx) {
[newOrder[idx], newOrder[nextIdx]] = [newOrder[nextIdx], newOrder[idx]];
reorderMutation.mutate({ orderedIds: newOrder });
setSelectedRowIndex(nextIdx);
}
}
}
break;
case "Enter":
if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) {
e.preventDefault();
openTaskPanel(tasks[selectedRowIndex]);
}
break;
case "e":
if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) {
e.preventDefault();
const task = tasks[selectedRowIndex];
setEditingTaskId(task.id);
setEditTitle(task.title);
}
break;
case "x":
if (selectedRowIndex >= 0 && selectedRowIndex < tasks.length) {
e.preventDefault();
const task = tasks[selectedRowIndex];
const newStatus = task.status === "done" ? "todo" : "done";
stateUpdateMutation.mutate({ taskId: task.id, stateId: task.stateId || "" });
api.patch(`/tasks/${task.id}`, { status: newStatus }).then(() => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
});
}
break;
case "Escape":
if (hasSelection) {
setSelectedTaskIds(new Set());
} else {
setSelectedRowIndex(-1);
}
break;
}
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [view, tasks, selectedRowIndex, selectedTaskIds]);
// Hide kbd hint after 5 seconds
useEffect(() => {
if (!showKbdHint) return;
const timer = setTimeout(() => setShowKbdHint(false), 5000);
return () => clearTimeout(timer);
}, [showKbdHint]);
const loadMoreTasks = async () => {
if (!hasMoreTasks || loadingMoreTasks) return;
setLoadingMoreTasks(true);
try {
const next = await api.get>(
"/tasks?" + new URLSearchParams({ ...Object.fromEntries(new URLSearchParams(taskQueryParams())), offset: String(tasks.length) }).toString()
);
queryClient.setQueryData>(["tasks", activeDomainId, search, filterProjectId, selectedStateId], (old) => {
if (!old) return old;
const seen = new Set(old.items.map((t) => t.id));
return { ...old, items: [...old.items, ...next.items.filter((t) => !seen.has(t.id))] };
});
} finally {
setLoadingMoreTasks(false);
}
};
const stateUpdateMutation = useMutation({
mutationFn: ({ taskId, stateId }: { taskId: string; stateId: string }) =>
api.patch("/tasks/" + taskId, { stateId }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
const reorderMutation = useMutation({
mutationFn: ({ orderedIds }: { orderedIds: string[] }) =>
api.post("/tasks/reorder", { orderedIds }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
onError: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
// Bulk action handlers
const handleBulkComplete = useCallback(() => {
const ids = Array.from(selectedTaskIds);
Promise.all(ids.map((id) => api.patch(`/tasks/${id}`, { status: "done" }))).then(() => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
setSelectedTaskIds(new Set());
toast.success(`${ids.length} tasks marked as done`);
});
}, [selectedTaskIds, queryClient]);
const handleBulkUpdate = useCallback((updates: Partial) => {
const ids = Array.from(selectedTaskIds);
Promise.all(ids.map((id) => api.patch(`/tasks/${id}`, updates))).then(() => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
setSelectedTaskIds(new Set());
toast.success(`${ids.length} tasks updated`);
});
}, [selectedTaskIds, queryClient]);
const handleBulkDelete = useCallback(() => {
const ids = Array.from(selectedTaskIds);
Promise.all(ids.map((id) => api.delete(`/tasks/${id}`))).then(() => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
setSelectedTaskIds(new Set());
toast.success(`${ids.length} tasks deleted`);
});
}, [selectedTaskIds, queryClient]);
const handleSaveEdit = useCallback(() => {
if (editingTaskId && editTitle.trim()) {
api.patch(`/tasks/${editingTaskId}`, { title: editTitle.trim() }).then(() => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
setEditingTaskId(null);
setEditTitle("");
});
}
}, [editingTaskId, editTitle, queryClient]);
const toggleAll = useCallback(() => {
if (selectedTaskIds.size === tasks.length) {
setSelectedTaskIds(new Set());
} else {
setSelectedTaskIds(new Set(tasks.map((t) => t.id)));
}
}, [selectedTaskIds, tasks]);
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete("/tasks/" + id),
onSuccess: (_, deletedId) => {
const task = tasks.find((t) => t.id === deletedId);
if (task) {
showUndoToast(
"task",
deletedId,
{
title: task.title,
priority: task.priority,
dueDate: task.dueDate,
description: task.description,
domain: task.domainId || undefined,
},
queryClient,
["tasks"],
);
}
queryClient.invalidateQueries({ queryKey: ["tasks"] });
setPanelOpen(false);
},
});
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor)
);
const taskGroupOf = useCallback(
(task: Task): StateGroup => {
if (task.stateId) {
const group = stateGroupOf.get(task.stateId);
if (group) return group;
}
return "unstarted";
},
[stateGroupOf]
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (!over) return;
const taskId = active.id as string;
const overId = over.id as string;
const draggedTask = tasks.find((t) => t.id === taskId);
if (!draggedTask) return;
const columnTasks = (group: StateGroup) =>
tasks
.filter((t) => taskGroupOf(t) === group)
.sort((a, b) => a.order - b.order);
let targetGroup: StateGroup;
let insertIndex: number;
if (STATE_GROUP_COLUMNS.some((c) => c.id === overId)) {
targetGroup = overId as StateGroup;
insertIndex = -1;
} else {
const overTask = tasks.find((t) => t.id === overId);
if (!overTask) return;
targetGroup = taskGroupOf(overTask);
const overIndex = columnTasks(targetGroup).findIndex((t) => t.id === overId);
insertIndex = overIndex === -1 ? -1 : overIndex;
}
const targetIds = columnTasks(targetGroup)
.map((t) => t.id)
.filter((id) => id !== taskId);
if (insertIndex === -1) {
targetIds.push(taskId);
} else {
targetIds.splice(Math.min(insertIndex, targetIds.length), 0, taskId);
}
const currentIds = columnTasks(targetGroup).map((t) => t.id);
const unchanged =
currentIds.length === targetIds.length &&
currentIds.every((id, i) => id === targetIds[i]);
if (unchanged) return;
const groupChanged = taskGroupOf(draggedTask) !== targetGroup;
const orderById = new Map(targetIds.map((id, i) => [id, i]));
queryClient.setQueryData>(["tasks", activeDomainId, search, filterProjectId, selectedStateId], (old) => {
if (!old) return old;
return {
...old,
items: old.items.map((t) => {
const order = orderById.get(t.id);
return order !== undefined ? { ...t, order } : t;
}),
};
});
if (groupChanged && draggedTask.projectId) {
const firstStateInGroup = projectStates.find((s) => s.group === targetGroup && s.projectId === draggedTask.projectId);
if (firstStateInGroup) {
stateUpdateMutation.mutate({ taskId, stateId: firstStateInGroup.id });
}
}
reorderMutation.mutate({ orderedIds: targetIds });
};
const openTaskDetail = (task: Task) => {
navigate({ to: "/tasks/$id", params: { id: task.id } });
};
const openTaskPanel = (task: Task) => {
setSelectedTask(task);
setPanelOpen(true);
};
const columns = useMemo(() => {
return STATE_GROUP_COLUMNS.map((col) => ({
...col,
tasks: tasks
.filter((t) => taskGroupOf(t) === col.id)
.sort((a, b) => a.order - b.order),
}));
}, [tasks, taskGroupOf]);
return (
Tasks
setView(v as "board" | "list")}>
New Task
New Task
setCreateOpen(false)} projectId={filterProjectId || undefined} />
setSearch(e.target.value)} className="pl-8" />
All projects
{projects.map((p) => (
{p.name}
))}
{filterProjectId && projectStates.length > 0 && (
All states
{projectStates.map((s) => (
{s.name}
))}
)}
{/* Saved views */}
{savedViews.length > 0 && (
{
const view = savedViews.find((v) => v.id === id);
if (view) {
setSearch(view.filters.search);
setFilterProjectId(view.filters.projectId);
setSelectedStateId(view.filters.stateId);
}
}}
>
{savedViews.map((v) => (
{v.name}
{
e.stopPropagation();
removeSavedView(v.id);
}}
>
))}
)}
Save View
Save Current Filters
{isLoading ? (
) : isError ? (
refetch()} />
) : view === "board" ? (
{columns.map((col) => (
{col.label}
{col.tasks.length}
t.id)} strategy={verticalListSortingStrategy}>
{col.tasks.map((task) => {
const st = task.stateId ? stateById.get(task.stateId) : undefined;
return (
openTaskDetail(task)}
onEdit={() => openTaskPanel(task)}
/>
);
})}
{col.tasks.length === 0 && (
No tasks
)}
))}
{activeId ? Moving...
: null}
) : (
{view === "list" && showKbdHint && (
j /k navigate
Enter open
e edit
x toggle
)}
{selectedTaskIds.size > 0 && (
setSelectedTaskIds(new Set())}
onComplete={handleBulkComplete}
onBatchUpdate={handleBulkUpdate}
onBatchDelete={handleBulkDelete}
/>
)}
)}
{hasMoreTasks && (
{loadingMoreTasks ? "Loading..." : "Load more tasks"}
)}
{selectedTask && (
setPanelOpen(false)} />
Delete Task
Delete Task
Are you sure you want to delete "{selectedTask.title}"? This action cannot be undone.
Cancel
deleteMutation.mutate(selectedTask.id)} className="bg-destructive text-destructive-foreground">Delete
)}
);
}
export const Route = createRoute({
getParentRoute: () => appRoute,
path: "/tasks",
component: TasksPage,
});