"use client"; import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { TaskCard } from "@/components/molecules/TaskCard"; import { parseApiDatetime } from "@/lib/datetime"; import { cn } from "@/lib/utils"; type TaskStatus = "inbox" | "in_progress" | "review" | "done"; type Task = { id: string; title: string; status: TaskStatus; priority: string; description?: string | null; due_at?: string | null; assigned_agent_id?: string | null; assignee?: string | null; approvals_pending_count?: number; tags?: Array<{ id: string; name: string; slug: string; color: string }>; depends_on_task_ids?: string[]; blocked_by_task_ids?: string[]; is_blocked?: boolean; }; type TaskBoardProps = { tasks: Task[]; onTaskSelect?: (task: Task) => void; onTaskMove?: (taskId: string, status: TaskStatus) => void | Promise; readOnly?: boolean; }; type ReviewBucket = "all" | "approval_needed" | "waiting_lead" | "blocked"; const columns: Array<{ title: string; status: TaskStatus; dot: string; accent: string; text: string; badge: string; }> = [ { title: "Inbox", status: "inbox", dot: "bg-slate-400", accent: "hover:border-slate-400 hover:bg-slate-50", text: "group-hover:text-slate-700 text-slate-500", badge: "bg-slate-100 text-slate-600", }, { title: "In Progress", status: "in_progress", dot: "bg-purple-500", accent: "hover:border-purple-400 hover:bg-purple-50", text: "group-hover:text-purple-600 text-slate-500", badge: "bg-purple-100 text-purple-700", }, { title: "Review", status: "review", dot: "bg-indigo-500", accent: "hover:border-indigo-400 hover:bg-indigo-50", text: "group-hover:text-indigo-600 text-slate-500", badge: "bg-indigo-100 text-indigo-700", }, { title: "Done", status: "done", dot: "bg-green-500", accent: "hover:border-green-400 hover:bg-green-50", text: "group-hover:text-green-600 text-slate-500", badge: "bg-emerald-100 text-emerald-700", }, ]; /** * Build compact due-date UI state for a task card. * * - Returns `due: undefined` when the task has no due date (or it's invalid), so * callers can omit the due-date UI entirely. * - Treats a task as overdue only if it is not `done` (so "Done" tasks don't * keep showing as overdue forever). */ const resolveDueState = ( task: Task, ): { due: string | undefined; isOverdue: boolean } => { const date = parseApiDatetime(task.due_at); if (!date) return { due: undefined, isOverdue: false }; const dueLabel = date.toLocaleDateString(undefined, { month: "short", day: "numeric", }); const isOverdue = task.status !== "done" && date.getTime() < Date.now(); return { due: isOverdue ? `Overdue · ${dueLabel}` : dueLabel, isOverdue, }; }; type CardPosition = { left: number; top: number }; const KANBAN_MOVE_ANIMATION_MS = 240; const KANBAN_MOVE_EASING = "cubic-bezier(0.2, 0.8, 0.2, 1)"; /** * Kanban-style task board with 4 columns. * * Notes: * - Uses a lightweight FLIP animation (via `useLayoutEffect`) to animate cards * to their new positions when tasks move between columns. * - Drag interactions can temporarily fight browser-managed drag images; the * animation is disabled while a card is being dragged. * - Respects `prefers-reduced-motion`. */ export const TaskBoard = memo(function TaskBoard({ tasks, onTaskSelect, onTaskMove, readOnly = false, }: TaskBoardProps) { const boardRef = useRef(null); const cardRefs = useRef>(new Map()); const prevPositionsRef = useRef>(new Map()); const animationRafRef = useRef(null); const cleanupTimeoutRef = useRef(null); const animatedTaskIdsRef = useRef>(new Set()); const [draggingId, setDraggingId] = useState(null); const [activeColumn, setActiveColumn] = useState(null); const [reviewBucket, setReviewBucket] = useState("all"); const setCardRef = useCallback( (taskId: string) => (node: HTMLDivElement | null) => { if (node) { cardRefs.current.set(taskId, node); return; } cardRefs.current.delete(taskId); }, [], ); /** * Snapshot each card's position relative to the scroll container. * * We store these measurements so we can compute deltas (prev - next) and * apply the FLIP technique on the next render. */ const measurePositions = useCallback((): Map => { const positions = new Map(); const container = boardRef.current; const containerRect = container?.getBoundingClientRect(); const scrollLeft = container?.scrollLeft ?? 0; const scrollTop = container?.scrollTop ?? 0; for (const [taskId, element] of cardRefs.current.entries()) { const rect = element.getBoundingClientRect(); positions.set(taskId, { left: containerRect && container ? rect.left - containerRect.left + scrollLeft : rect.left, top: containerRect && container ? rect.top - containerRect.top + scrollTop : rect.top, }); } return positions; }, []); // Animate card reordering smoothly by applying FLIP whenever layout positions change. useLayoutEffect(() => { const cardRefsSnapshot = cardRefs.current; if (animationRafRef.current !== null) { window.cancelAnimationFrame(animationRafRef.current); animationRafRef.current = null; } if (cleanupTimeoutRef.current !== null) { window.clearTimeout(cleanupTimeoutRef.current); cleanupTimeoutRef.current = null; } for (const taskId of animatedTaskIdsRef.current) { const element = cardRefsSnapshot.get(taskId); if (!element) continue; element.style.transform = ""; element.style.transition = ""; element.style.willChange = ""; element.style.position = ""; element.style.zIndex = ""; } animatedTaskIdsRef.current.clear(); const prevPositions = prevPositionsRef.current; const nextPositions = measurePositions(); prevPositionsRef.current = nextPositions; // Avoid fighting the browser while it manages the drag image. if (draggingId) return; const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches ?? false; if (prefersReducedMotion) return; const moved: Array<{ taskId: string; element: HTMLDivElement; dx: number; dy: number; }> = []; for (const [taskId, next] of nextPositions.entries()) { const prev = prevPositions.get(taskId); if (!prev) continue; const dx = prev.left - next.left; const dy = prev.top - next.top; if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue; const element = cardRefsSnapshot.get(taskId); if (!element) continue; moved.push({ taskId, element, dx, dy }); } if (!moved.length) return; animatedTaskIdsRef.current = new Set(moved.map(({ taskId }) => taskId)); // FLIP: invert to the previous position before paint, then animate back to 0. for (const { element, dx, dy } of moved) { element.style.transform = `translate(${dx}px, ${dy}px)`; element.style.transition = "transform 0s"; element.style.willChange = "transform"; element.style.position = "relative"; element.style.zIndex = "1"; } animationRafRef.current = window.requestAnimationFrame(() => { for (const { element } of moved) { element.style.transition = `transform ${KANBAN_MOVE_ANIMATION_MS}ms ${KANBAN_MOVE_EASING}`; element.style.transform = ""; } cleanupTimeoutRef.current = window.setTimeout(() => { for (const { element } of moved) { element.style.transition = ""; element.style.willChange = ""; element.style.position = ""; element.style.zIndex = ""; } animatedTaskIdsRef.current.clear(); cleanupTimeoutRef.current = null; }, KANBAN_MOVE_ANIMATION_MS + 60); animationRafRef.current = null; }); return () => { if (animationRafRef.current !== null) { window.cancelAnimationFrame(animationRafRef.current); animationRafRef.current = null; } if (cleanupTimeoutRef.current !== null) { window.clearTimeout(cleanupTimeoutRef.current); cleanupTimeoutRef.current = null; } for (const taskId of animatedTaskIdsRef.current) { const element = cardRefsSnapshot.get(taskId); if (!element) continue; element.style.transform = ""; element.style.transition = ""; element.style.willChange = ""; element.style.position = ""; element.style.zIndex = ""; } animatedTaskIdsRef.current.clear(); }; }, [draggingId, measurePositions, tasks]); const grouped = useMemo(() => { const buckets: Record = { inbox: [], in_progress: [], review: [], done: [], }; for (const column of columns) { buckets[column.status] = []; } tasks.forEach((task) => { const bucket = buckets[task.status] ?? buckets.inbox; bucket.push(task); }); return buckets; }, [tasks]); // Keep drag/drop state and payload handling centralized for column move interactions. const handleDragStart = (task: Task) => (event: React.DragEvent) => { if (readOnly) { event.preventDefault(); return; } if (task.is_blocked) { event.preventDefault(); return; } setDraggingId(task.id); event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData( "text/plain", JSON.stringify({ taskId: task.id, status: task.status }), ); }; const handleDragEnd = () => { setDraggingId(null); setActiveColumn(null); }; const handleDrop = (status: TaskStatus) => (event: React.DragEvent) => { if (readOnly) return; event.preventDefault(); setActiveColumn(null); const raw = event.dataTransfer.getData("text/plain"); if (!raw) return; try { const payload = JSON.parse(raw) as { taskId?: string; status?: string }; if (!payload.taskId || !payload.status) return; if (payload.status === status) return; onTaskMove?.(payload.taskId, status); } catch { // Ignore malformed payloads. } }; const handleDragOver = (status: TaskStatus) => (event: React.DragEvent) => { if (readOnly) return; event.preventDefault(); if (activeColumn !== status) { setActiveColumn(status); } }; const handleDragLeave = (status: TaskStatus) => () => { if (readOnly) return; if (activeColumn === status) { setActiveColumn(null); } }; return (
{columns.map((column) => { const columnTasks = grouped[column.status] ?? []; // Derive review tab counts and the active subset from one canonical task list. const reviewCounts = column.status === "review" ? columnTasks.reduce( (acc, task) => { if (task.is_blocked) { acc.blocked += 1; return acc; } if ((task.approvals_pending_count ?? 0) > 0) { acc.approval_needed += 1; return acc; } acc.waiting_lead += 1; return acc; }, { all: columnTasks.length, approval_needed: 0, waiting_lead: 0, blocked: 0, }, ) : null; const filteredTasks = column.status === "review" && reviewBucket !== "all" ? columnTasks.filter((task) => { if (reviewBucket === "blocked") return Boolean(task.is_blocked); if (reviewBucket === "approval_needed") return ( (task.approvals_pending_count ?? 0) > 0 && !task.is_blocked ); if (reviewBucket === "waiting_lead") return ( !task.is_blocked && (task.approvals_pending_count ?? 0) === 0 ); return true; }) : columnTasks; return (

{column.title}

{filteredTasks.length}
{column.status === "review" && reviewCounts ? (
{( [ { key: "all", label: "All", count: reviewCounts.all }, { key: "approval_needed", label: "Approval needed", count: reviewCounts.approval_needed, }, { key: "waiting_lead", label: "Lead review", count: reviewCounts.waiting_lead, }, { key: "blocked", label: "Blocked", count: reviewCounts.blocked, }, ] as const ).map((option) => ( ))}
) : null}
{filteredTasks.map((task) => { const dueState = resolveDueState(task); return (
onTaskSelect?.(task)} draggable={!readOnly && !task.is_blocked} isDragging={draggingId === task.id} onDragStart={ readOnly ? undefined : handleDragStart(task) } onDragEnd={readOnly ? undefined : handleDragEnd} />
); })}
); })}
); }); TaskBoard.displayName = "TaskBoard";