"use client" import { useState, useEffect, useMemo, type ReactNode } from "react" import { DndContext, DragEndEvent, DragOverlay, DragOverEvent, DragStartEvent, PointerSensor, useDraggable, useDroppable, useSensor, useSensors, closestCorners, } from "@dnd-kit/core" import { CSS } from "@dnd-kit/utilities" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Textarea } from "@/components/ui/textarea" import { Label } from "@/components/ui/label" import { useTaskStore, Task, TaskType, TaskStatus, Priority } from "@/stores/useTaskStore" import { BacklogView } from "@/components/BacklogView" import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical } from "lucide-react" const typeColors: Record = { idea: "bg-purple-500", task: "bg-blue-500", bug: "bg-red-500", research: "bg-green-500", plan: "bg-amber-500", } const typeLabels: Record = { idea: "๐Ÿ’ก Idea", task: "๐Ÿ“‹ Task", bug: "๐Ÿ› Bug", research: "๐Ÿ”ฌ Research", plan: "๐Ÿ“ Plan", } const priorityColors: Record = { low: "text-slate-400", medium: "text-blue-400", high: "text-orange-400", urgent: "text-red-400", } const allStatuses: TaskStatus[] = ["open", "todo", "blocked", "in-progress", "review", "validate", "archived", "canceled", "done"] // Sprint board columns mapped to workflow statuses const sprintColumns: { key: string; label: string; statuses: TaskStatus[] }[] = [ { key: "todo", label: "To Do", statuses: ["open", "todo"] }, { key: "inprogress", label: "In Progress", statuses: ["blocked", "in-progress", "review", "validate"] }, { key: "done", label: "Done", statuses: ["archived", "canceled", "done"] }, ] const sprintColumnDropStatus: Record = { todo: "open", inprogress: "in-progress", done: "done", } const formatStatusLabel = (status: TaskStatus) => status === "todo" ? "To Do" : status .split("-") .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join(" ") function KanbanStatusDropTarget({ status, count, expanded = false, }: { status: TaskStatus count: number expanded?: boolean }) { const { isOver, setNodeRef } = useDroppable({ id: `kanban-status-${status}` }) return (
{formatStatusLabel(status)} ({count})
) } function KanbanDropColumn({ id, label, count, statusTargets, isDragging, isActiveDropColumn, children, }: { id: string label: string count: number statusTargets: Array<{ status: TaskStatus; count: number }> isDragging: boolean isActiveDropColumn: boolean children: ReactNode }) { const { isOver, setNodeRef } = useDroppable({ id }) return (

{label}

{count}
{statusTargets.map((target) => ( ))}
{isDragging && isActiveDropColumn ? (

Drop into an exact status:

{statusTargets.map((target) => ( ))}
) : ( children )}
) } function KanbanTaskCard({ task, taskTags, onOpen, onDelete, }: { task: Task taskTags: string[] onOpen: () => void onDelete: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useDraggable({ id: task.id, }) const style = { transform: CSS.Translate.toString(transform), transition, opacity: isDragging ? 0.6 : 1, } return (
{typeLabels[task.type]}

{task.title}

{task.description && (

{task.description}

)}
{task.priority} {task.comments && task.comments.length > 0 && ( {task.comments.length} )}
{task.dueDate && ( {new Date(task.dueDate).toLocaleDateString()} )}
{taskTags.length > 0 && (
{taskTags.map((tag) => ( {tag} ))}
)}
) } export default function Home() { console.log('>>> PAGE: Component rendering') const { projects, tasks, sprints, selectedProjectId, selectedTaskId, addTask, updateTask, deleteTask, selectTask, addComment, deleteComment, syncFromServer, isLoading, } = useTaskStore() const [newTaskOpen, setNewTaskOpen] = useState(false) const [newTask, setNewTask] = useState>({ title: "", description: "", type: "task", priority: "medium", status: "open", tags: [], }) const [newComment, setNewComment] = useState("") const [viewMode, setViewMode] = useState<'kanban' | 'backlog'>('kanban') const [editedTask, setEditedTask] = useState(null) const [newTaskLabelInput, setNewTaskLabelInput] = useState("") const [editedTaskLabelInput, setEditedTaskLabelInput] = useState("") const [activeKanbanTaskId, setActiveKanbanTaskId] = useState(null) const [dragOverKanbanColumnKey, setDragOverKanbanColumnKey] = useState(null) const getTags = (taskLike: { tags?: unknown }) => { if (!Array.isArray(taskLike.tags)) return [] as string[] return taskLike.tags.filter((tag): tag is string => typeof tag === "string" && tag.trim().length > 0) } const labelUsage = useMemo(() => { const counts = new Map() tasks.forEach((task) => { getTags(task).forEach((label) => { counts.set(label, (counts.get(label) || 0) + 1) }) }) return Array.from(counts.entries()).sort((a, b) => b[1] - a[1]) }, [tasks]) const allLabels = useMemo(() => labelUsage.map(([label]) => label), [labelUsage]) // Sync from server on mount useEffect(() => { console.log('>>> PAGE: useEffect for syncFromServer running') syncFromServer().then(() => { console.log('>>> PAGE: syncFromServer completed') }) }, [syncFromServer]) // Log when tasks change useEffect(() => { console.log('>>> PAGE: tasks changed, new count:', tasks.length) }, [tasks]) const selectedTask = tasks.find((t) => t.id === selectedTaskId) const editedTaskTags = editedTask ? getTags(editedTask) : [] useEffect(() => { if (selectedTask) { // eslint-disable-next-line react-hooks/set-state-in-effect setEditedTask({ ...selectedTask, tags: getTags(selectedTask) }) setEditedTaskLabelInput("") } }, [selectedTask]) // Get current active sprint (across all projects) const now = new Date() const currentSprint = sprints.find((s) => s.status === 'active' && new Date(s.startDate) <= now && new Date(s.endDate) >= now ) // Filter tasks to only show current sprint tasks in Kanban (from ALL projects) const sprintTasks = currentSprint ? tasks.filter((t) => t.sprintId === currentSprint.id) : [] const activeKanbanTask = activeKanbanTaskId ? sprintTasks.find((task) => task.id === activeKanbanTaskId) : null const toLabel = (raw: string) => raw.trim().replace(/^#/, "") const addUniqueLabel = (existing: string[], raw: string) => { const nextLabel = toLabel(raw) if (!nextLabel) return existing const alreadyExists = existing.some((label) => label.toLowerCase() === nextLabel.toLowerCase()) return alreadyExists ? existing : [...existing, nextLabel] } const removeLabel = (existing: string[], labelToRemove: string) => existing.filter((label) => label.toLowerCase() !== labelToRemove.toLowerCase()) const getColumnKeyForStatus = (status: TaskStatus) => sprintColumns.find((column) => column.statuses.includes(status))?.key const kanbanSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8, }, }) ) const handleKanbanDragStart = (event: DragStartEvent) => { setActiveKanbanTaskId(String(event.active.id)) } const resolveKanbanColumnKey = (overId: string): string | null => { if (overId.startsWith("kanban-col-")) { return overId.replace("kanban-col-", "") } if (overId.startsWith("kanban-status-")) { const status = overId.replace("kanban-status-", "") as TaskStatus return getColumnKeyForStatus(status) || null } const overTask = sprintTasks.find((task) => task.id === overId) if (!overTask) return null return getColumnKeyForStatus(overTask.status) || null } const handleKanbanDragOver = (event: DragOverEvent) => { if (!event.over) { setDragOverKanbanColumnKey(null) return } setDragOverKanbanColumnKey(resolveKanbanColumnKey(String(event.over.id))) } const handleKanbanDragEnd = (event: DragEndEvent) => { const { active, over } = event setActiveKanbanTaskId(null) setDragOverKanbanColumnKey(null) if (!over) return const taskId = String(active.id) const draggedTask = sprintTasks.find((task) => task.id === taskId) if (!draggedTask) return const destination = String(over.id) const overTask = sprintTasks.find((task) => task.id === destination) if (overTask) { if (overTask.status !== draggedTask.status) { updateTask(taskId, { status: overTask.status }) } return } if (destination.startsWith("kanban-status-")) { const exactStatus = destination.replace("kanban-status-", "") as TaskStatus if (exactStatus !== draggedTask.status) { updateTask(taskId, { status: exactStatus }) } return } if (!destination.startsWith("kanban-col-")) return const destinationColumnKey = destination.replace("kanban-col-", "") const sourceColumnKey = getColumnKeyForStatus(draggedTask.status) if (sourceColumnKey === destinationColumnKey) return const newStatus = sprintColumnDropStatus[destinationColumnKey] if (!newStatus) return updateTask(taskId, { status: newStatus }) } const handleKanbanDragCancel = () => { setActiveKanbanTaskId(null) setDragOverKanbanColumnKey(null) } const handleAddTask = () => { if (newTask.title?.trim()) { // If a specific sprint is selected, use that sprint's project const selectedSprint = newTask.sprintId ? sprints.find(s => s.id === newTask.sprintId) : null const targetProjectId = selectedSprint?.projectId || selectedProjectId || projects[0]?.id || '2' const taskToCreate: Omit = { title: newTask.title.trim(), description: newTask.description?.trim() || undefined, type: (newTask.type || "task") as TaskType, priority: (newTask.priority || "medium") as Priority, status: (newTask.status || "open") as TaskStatus, tags: newTask.tags || [], projectId: targetProjectId, sprintId: newTask.sprintId || currentSprint?.id, } addTask(taskToCreate) setNewTask({ title: "", description: "", type: "task", priority: "medium", status: "open", tags: [], sprintId: undefined }) setNewTaskLabelInput("") setNewTaskOpen(false) } } const handleAddComment = () => { if (newComment.trim() && selectedTaskId) { addComment(selectedTaskId, newComment.trim(), "user") setNewComment("") } } return (
{/* Header */}

OpenClaw Task Hub

Track ideas, tasks, bugs, and plans โ€” with threaded notes

{isLoading && ( Syncing... )} {tasks.length} tasks ยท {allLabels.length} labels
{/* Main Content */}

{currentSprint ? currentSprint.name : "Work Board"}

{sprintTasks.length} tasks ยท {sprintTasks.filter((t) => t.status === "done").length} done

{/* View Toggle */}
{/* View Content */} {viewMode === 'backlog' ? ( ) : ( <> {/* Current Sprint Header */}

{currentSprint?.name || "No Active Sprint"}

{currentSprint ? `${new Date(currentSprint.startDate).toLocaleDateString()} - ${new Date(currentSprint.endDate).toLocaleDateString()}` : "Create or activate a sprint to group work"}

{currentSprint && Active}
{/* Kanban Columns */}
{sprintColumns.map((column) => { const columnTasks = sprintTasks.filter((t) => column.statuses.includes(t.status) ) return ( ({ status, count: sprintTasks.filter((task) => task.status === status).length, }))} isDragging={!!activeKanbanTaskId} isActiveDropColumn={dragOverKanbanColumnKey === column.key} > {columnTasks.map((task) => ( selectTask(task.id)} onDelete={() => deleteTask(task.id)} /> ))} ) })}
{activeKanbanTask ? (
{activeKanbanTask.title}
) : null}
)}
{/* New Task Dialog */} New Task
setNewTask({ ...newTask, title: e.target.value })} className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:border-blue-500" placeholder="What needs to be done?" />