From ef35e508c4163e36591e60214d3c306d9a9cae7f Mon Sep 17 00:00:00 2001
From: Hammer
Date: Thu, 29 Jan 2026 09:33:59 +0000
Subject: [PATCH] feat: kanban board view with drag-and-drop status changes
- New KanbanBoard component with 4 columns: Active, Queued, Blocked, Done
- HTML5 drag-and-drop to move tasks between status columns
- Visual drop zone highlighting and drag state
- Task cards show priority, project, assignee, due date, subtask progress
- View toggle (list/board) in Queue page header, persisted to localStorage
- Status filter hidden in board mode (columns serve as visual filter)
- Cards link to task detail pages, click opens detail panel
---
frontend/src/components/KanbanBoard.tsx | 316 ++++++++++++++++++++++++
frontend/src/pages/QueuePage.tsx | 309 +++++++++++++----------
2 files changed, 495 insertions(+), 130 deletions(-)
create mode 100644 frontend/src/components/KanbanBoard.tsx
diff --git a/frontend/src/components/KanbanBoard.tsx b/frontend/src/components/KanbanBoard.tsx
new file mode 100644
index 0000000..5f15734
--- /dev/null
+++ b/frontend/src/components/KanbanBoard.tsx
@@ -0,0 +1,316 @@
+import { useState, useCallback, useMemo } from "react";
+import { Link } from "react-router-dom";
+import type { Task, TaskStatus } from "../lib/types";
+
+interface KanbanColumn {
+ status: TaskStatus;
+ label: string;
+ icon: string;
+ color: string;
+ bgColor: string;
+ borderColor: string;
+ dropColor: string;
+}
+
+const COLUMNS: KanbanColumn[] = [
+ {
+ status: "active",
+ label: "Active",
+ icon: "⚡",
+ color: "text-amber-700",
+ bgColor: "bg-amber-50",
+ borderColor: "border-amber-200",
+ dropColor: "bg-amber-100",
+ },
+ {
+ status: "queued",
+ label: "Queued",
+ icon: "📋",
+ color: "text-blue-700",
+ bgColor: "bg-blue-50",
+ borderColor: "border-blue-200",
+ dropColor: "bg-blue-100",
+ },
+ {
+ status: "blocked",
+ label: "Blocked",
+ icon: "🚫",
+ color: "text-red-700",
+ bgColor: "bg-red-50",
+ borderColor: "border-red-200",
+ dropColor: "bg-red-100",
+ },
+ {
+ status: "completed",
+ label: "Done",
+ icon: "✅",
+ color: "text-green-700",
+ bgColor: "bg-green-50",
+ borderColor: "border-green-200",
+ dropColor: "bg-green-100",
+ },
+];
+
+const PRIORITY_COLORS: Record = {
+ critical: "bg-red-100 text-red-700 border-red-200",
+ high: "bg-orange-100 text-orange-700 border-orange-200",
+ medium: "bg-blue-100 text-blue-700 border-blue-200",
+ low: "bg-gray-100 text-gray-500 border-gray-200",
+};
+
+interface KanbanCardProps {
+ task: Task;
+ projectName?: string;
+ onDragStart: (taskId: string) => void;
+ onDragEnd: () => void;
+ isDragging: boolean;
+ onClick: () => void;
+}
+
+function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onClick }: KanbanCardProps) {
+ const subtasksDone = task.subtasks?.filter((s) => s.completed).length ?? 0;
+ const subtasksTotal = task.subtasks?.length ?? 0;
+
+ return (
+ {
+ e.dataTransfer.setData("text/plain", task.id);
+ e.dataTransfer.effectAllowed = "move";
+ onDragStart(task.id);
+ }}
+ onDragEnd={onDragEnd}
+ onClick={onClick}
+ className={`bg-white rounded-lg border border-gray-200 p-3 cursor-grab active:cursor-grabbing shadow-sm hover:shadow-md transition-all ${
+ isDragging ? "opacity-40 scale-95" : "opacity-100"
+ }`}
+ >
+ {/* Header: number + priority */}
+
+ e.stopPropagation()}
+ className="text-xs font-bold font-mono text-gray-500 hover:text-amber-600 transition"
+ >
+ HQ-{task.taskNumber}
+
+
+ {task.priority}
+
+
+
+ {/* Title */}
+
+ {task.title}
+
+
+ {/* Meta: project, assignee, subtasks, due */}
+
+ {projectName && (
+
+ 📁 {projectName}
+
+ )}
+ {task.assigneeName && (
+
+ 👤 {task.assigneeName}
+
+ )}
+ {task.dueDate && (() => {
+ const due = new Date(task.dueDate);
+ const diffMs = due.getTime() - Date.now();
+ const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
+ const isOverdue = diffMs < 0;
+ const label = isOverdue ? "overdue" : diffDays === 0 ? "today" : diffDays === 1 ? "tomorrow" : `${diffDays}d`;
+ return (
+
+ 📅 {label}
+
+ );
+ })()}
+
+
+ {/* Subtask progress */}
+ {subtasksTotal > 0 && (
+
+
+
{subtasksDone}/{subtasksTotal}
+
+ )}
+
+ );
+}
+
+interface KanbanColumnProps {
+ column: KanbanColumn;
+ tasks: Task[];
+ projectMap: Record;
+ draggingId: string | null;
+ onDragStart: (taskId: string) => void;
+ onDragEnd: () => void;
+ onDrop: (taskId: string, status: TaskStatus) => void;
+ onCardClick: (taskId: string) => void;
+}
+
+function KanbanColumnView({
+ column,
+ tasks,
+ projectMap,
+ draggingId,
+ onDragStart,
+ onDragEnd,
+ onDrop,
+ onCardClick,
+}: KanbanColumnProps) {
+ const [dragOver, setDragOver] = useState(false);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ setDragOver(true);
+ }, []);
+
+ const handleDragLeave = useCallback(() => {
+ setDragOver(false);
+ }, []);
+
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ setDragOver(false);
+ const taskId = e.dataTransfer.getData("text/plain");
+ if (taskId) {
+ onDrop(taskId, column.status);
+ }
+ },
+ [column.status, onDrop]
+ );
+
+ return (
+
+ {/* Column header */}
+
+
+ {column.icon}
+
{column.label}
+
+
+ {tasks.length}
+
+
+
+ {/* Cards */}
+
+ {tasks.length === 0 ? (
+
+ {dragOver ? "Drop here" : "No tasks"}
+
+ ) : (
+ tasks.map((task) => (
+
onCardClick(task.id)}
+ />
+ ))
+ )}
+ {dragOver && tasks.length > 0 && (
+
+ Drop here
+
+ )}
+
+
+ );
+}
+
+interface KanbanBoardProps {
+ tasks: Task[];
+ projectMap: Record;
+ onStatusChange: (taskId: string, status: TaskStatus) => void;
+ onCardClick: (taskId: string) => void;
+ search: string;
+ filterPriority: string;
+}
+
+export function KanbanBoard({
+ tasks,
+ projectMap,
+ onStatusChange,
+ onCardClick,
+ search,
+ filterPriority,
+}: KanbanBoardProps) {
+ const [draggingId, setDraggingId] = useState(null);
+
+ const filteredTasks = useMemo(() => {
+ let filtered = tasks;
+ if (search.trim()) {
+ const q = search.toLowerCase();
+ filtered = filtered.filter(
+ (t) =>
+ t.title.toLowerCase().includes(q) ||
+ (t.description && t.description.toLowerCase().includes(q)) ||
+ (t.taskNumber && `hq-${t.taskNumber}`.includes(q)) ||
+ (t.assigneeName && t.assigneeName.toLowerCase().includes(q))
+ );
+ }
+ if (filterPriority) {
+ filtered = filtered.filter((t) => t.priority === filterPriority);
+ }
+ return filtered;
+ }, [tasks, search, filterPriority]);
+
+ const tasksByStatus = useMemo(() => {
+ const map: Record = {};
+ for (const col of COLUMNS) {
+ map[col.status] = filteredTasks.filter((t) => t.status === col.status);
+ }
+ return map;
+ }, [filteredTasks]);
+
+ const handleDrop = useCallback(
+ (taskId: string, newStatus: TaskStatus) => {
+ const task = tasks.find((t) => t.id === taskId);
+ if (!task || task.status === newStatus) return;
+ onStatusChange(taskId, newStatus);
+ },
+ [tasks, onStatusChange]
+ );
+
+ return (
+
+ {COLUMNS.map((column) => (
+ setDraggingId(null)}
+ onDrop={handleDrop}
+ onCardClick={onCardClick}
+ />
+ ))}
+
+ );
+}
diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx
index 5280167..ed826c2 100644
--- a/frontend/src/pages/QueuePage.tsx
+++ b/frontend/src/pages/QueuePage.tsx
@@ -4,10 +4,13 @@ import { useCurrentUser } from "../hooks/useCurrentUser";
import { TaskCard } from "../components/TaskCard";
import { TaskDetailPanel } from "../components/TaskDetailPanel";
import { CreateTaskModal } from "../components/CreateTaskModal";
+import { KanbanBoard } from "../components/KanbanBoard";
import { useToast } from "../components/Toast";
import { updateTask, reorderTasks, createTask, fetchProjects } from "../lib/api";
import type { TaskStatus, Project } from "../lib/types";
+type ViewMode = "list" | "board";
+
export function QueuePage() {
const { tasks, loading, error, refresh } = useTasks(5000);
const { isAuthenticated } = useCurrentUser();
@@ -17,9 +20,17 @@ export function QueuePage() {
const [search, setSearch] = useState("");
const [filterPriority, setFilterPriority] = useState("");
const [filterStatus, setFilterStatus] = useState("");
+ const [viewMode, setViewMode] = useState(() => {
+ return (localStorage.getItem("hammer-queue-view") as ViewMode) || "list";
+ });
const [projects, setProjects] = useState([]);
const { toast } = useToast();
+ // Persist view mode
+ useEffect(() => {
+ localStorage.setItem("hammer-queue-view", viewMode);
+ }, [viewMode]);
+
// Load projects for name display
useEffect(() => {
fetchProjects().then(setProjects).catch(() => {});
@@ -142,13 +153,38 @@ export function QueuePage() {
)}
-
+
+ {/* View toggle */}
+
+
+
+
+
+
@@ -184,18 +220,20 @@ export function QueuePage() {
-
+ {viewMode === "list" && (
+
+ )}
{activeFilters > 0 && (