From e9c07630253425b7cd55e848ee768f29d130743e Mon Sep 17 00:00:00 2001 From: Hammer Date: Thu, 29 Jan 2026 11:04:39 +0000 Subject: [PATCH] feat: task tags, sort controls, and tag filtering - Added tags (JSONB array) to tasks schema with full CRUD support - Tag editor in TaskDetailPanel with chip UI, Enter/comma to add, Backspace to remove - Tag badges on TaskCard, KanbanBoard cards, and DashboardPage - Sort controls on QueuePage: sort by priority, due date, created, updated, name - Sort direction toggle (asc/desc) with persistence to localStorage - Tag filter dropdown in QueuePage header (populated from existing tags) - Search now matches tags - Backend: tags in create/update, progressNotes in PATCH body --- backend/src/db/schema.ts | 1 + backend/src/routes/tasks.ts | 9 ++ frontend/src/components/KanbanBoard.tsx | 5 + frontend/src/components/TaskCard.tsx | 8 ++ frontend/src/components/TaskDetailPanel.tsx | 58 +++++++++- frontend/src/lib/types.ts | 1 + frontend/src/pages/QueuePage.tsx | 115 ++++++++++++++++++-- 7 files changed, 185 insertions(+), 12 deletions(-) diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 58c7725..8f2cca3 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -82,6 +82,7 @@ export const tasks = pgTable("tasks", { assigneeName: text("assignee_name"), projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), dueDate: timestamp("due_date", { withTimezone: true }), + tags: jsonb("tags").$type().default([]), subtasks: jsonb("subtasks").$type().default([]), progressNotes: jsonb("progress_notes").$type().default([]), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index 9fd9a8d..5517219 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -154,6 +154,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) taskNumber: nextNumber, projectId: body.projectId || null, dueDate: body.dueDate ? new Date(body.dueDate) : null, + tags: body.tags || [], subtasks: [], progressNotes: [], }) @@ -193,6 +194,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) ), projectId: t.Optional(t.Union([t.String(), t.Null()])), dueDate: t.Optional(t.Union([t.String(), t.Null()])), + tags: t.Optional(t.Array(t.String())), }), } ) @@ -240,7 +242,9 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) if (body.assigneeName !== undefined) updates.assigneeName = body.assigneeName; if (body.projectId !== undefined) updates.projectId = body.projectId; if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null; + if (body.tags !== undefined) updates.tags = body.tags; if (body.subtasks !== undefined) updates.subtasks = body.subtasks; + if (body.progressNotes !== undefined) updates.progressNotes = body.progressNotes; const updated = await db .update(tasks) @@ -269,6 +273,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) assigneeName: t.Optional(t.Union([t.String(), t.Null()])), projectId: t.Optional(t.Union([t.String(), t.Null()])), dueDate: t.Optional(t.Union([t.String(), t.Null()])), + tags: t.Optional(t.Array(t.String())), subtasks: t.Optional(t.Array(t.Object({ id: t.String(), title: t.String(), @@ -276,6 +281,10 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) completedAt: t.Optional(t.String()), createdAt: t.String(), }))), + progressNotes: t.Optional(t.Array(t.Object({ + timestamp: t.String(), + note: t.String(), + }))), }), } ) diff --git a/frontend/src/components/KanbanBoard.tsx b/frontend/src/components/KanbanBoard.tsx index 4e0496f..4342093 100644 --- a/frontend/src/components/KanbanBoard.tsx +++ b/frontend/src/components/KanbanBoard.tsx @@ -113,6 +113,11 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC 👤 {task.assigneeName} )} + {task.tags?.slice(0, 2).map(tag => ( + + 🏷️ {tag} + + ))} {task.dueDate && (() => { const due = new Date(task.dueDate); const diffMs = due.getTime() - Date.now(); diff --git a/frontend/src/components/TaskCard.tsx b/frontend/src/components/TaskCard.tsx index ff0d5b4..e2b77c7 100644 --- a/frontend/src/components/TaskCard.tsx +++ b/frontend/src/components/TaskCard.tsx @@ -122,6 +122,14 @@ export function TaskCard({ 👤 {task.assigneeName} )} + {task.tags?.length > 0 && task.tags.slice(0, 2).map(tag => ( + + 🏷️ {tag} + + ))} + {task.tags?.length > 2 && ( + +{task.tags.length - 2} + )} {timeAgo(task.createdAt)} diff --git a/frontend/src/components/TaskDetailPanel.tsx b/frontend/src/components/TaskDetailPanel.tsx index f74f449..ca50d74 100644 --- a/frontend/src/components/TaskDetailPanel.tsx +++ b/frontend/src/components/TaskDetailPanel.tsx @@ -258,6 +258,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, const [draftProjectId, setDraftProjectId] = useState(task.projectId || ""); const [draftDueDate, setDraftDueDate] = useState(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); const [draftAssigneeName, setDraftAssigneeName] = useState(task.assigneeName || ""); + const [draftTags, setDraftTags] = useState(task.tags || []); + const [tagInput, setTagInput] = useState(""); const [projects, setProjects] = useState([]); const [newSubtaskTitle, setNewSubtaskTitle] = useState(""); const [addingSubtask, setAddingSubtask] = useState(false); @@ -282,7 +284,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, setDraftProjectId(task.projectId || ""); setDraftDueDate(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); setDraftAssigneeName(task.assigneeName || ""); - }, [task.id, task.title, task.description, task.priority, task.source, task.projectId, task.dueDate, task.assigneeName]); + setDraftTags(task.tags || []); + }, [task.id, task.title, task.description, task.priority, task.source, task.projectId, task.dueDate, task.assigneeName, task.tags]); const currentDueDate = task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""; const isDirty = @@ -292,7 +295,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, draftSource !== task.source || draftProjectId !== (task.projectId || "") || draftDueDate !== currentDueDate || - draftAssigneeName !== (task.assigneeName || ""); + draftAssigneeName !== (task.assigneeName || "") || + JSON.stringify(draftTags) !== JSON.stringify(task.tags || []); const handleCancel = () => { setDraftTitle(task.title); @@ -302,6 +306,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, setDraftProjectId(task.projectId || ""); setDraftDueDate(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); setDraftAssigneeName(task.assigneeName || ""); + setDraftTags(task.tags || []); }; const handleSave = async () => { @@ -316,6 +321,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, if (draftProjectId !== (task.projectId || "")) updates.projectId = draftProjectId || null; if (draftDueDate !== currentDueDate) updates.dueDate = draftDueDate ? new Date(draftDueDate).toISOString() : null; if (draftAssigneeName !== (task.assigneeName || "")) updates.assigneeName = draftAssigneeName || null; + if (JSON.stringify(draftTags) !== JSON.stringify(task.tags || [])) (updates as any).tags = draftTags; await updateTask(task.id, updates, token); onTaskUpdated(); toast("Changes saved", "success"); @@ -579,6 +585,54 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, )} + {/* Tags */} +
+

Tags

+
+ {draftTags.map((tag) => ( + + 🏷️ {tag} + {hasToken && ( + + )} + + ))} + {hasToken && ( + setTagInput(e.target.value)} + placeholder={draftTags.length === 0 ? "Add tags..." : "+"} + className="text-xs border border-transparent focus:border-gray-200 dark:focus:border-gray-700 rounded-lg px-2 py-1 focus:outline-none focus:ring-1 focus:ring-amber-200 dark:focus:ring-amber-800 bg-transparent focus:bg-white dark:focus:bg-gray-800 text-gray-700 dark:text-gray-300 placeholder:text-gray-400 dark:placeholder:text-gray-500 w-20 focus:w-32 transition-all" + onKeyDown={(e) => { + if ((e.key === "Enter" || e.key === ",") && tagInput.trim()) { + e.preventDefault(); + const newTag = tagInput.trim().toLowerCase().replace(/,/g, ""); + if (newTag && !draftTags.includes(newTag)) { + setDraftTags([...draftTags, newTag]); + } + setTagInput(""); + } + if (e.key === "Backspace" && !tagInput && draftTags.length > 0) { + setDraftTags(draftTags.slice(0, -1)); + } + }} + /> + )} + {!hasToken && draftTags.length === 0 && ( + No tags + )} +
+
+ {/* Description */}

Description

diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ee5b0a7..214436a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -48,6 +48,7 @@ export interface Task { assigneeName: string | null; projectId: string | null; dueDate: string | null; + tags: string[]; subtasks: Subtask[]; progressNotes: ProgressNote[]; createdAt: string; diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 25671b9..331eb26 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -10,6 +10,10 @@ import { updateTask, reorderTasks, createTask, fetchProjects } from "../lib/api" import type { TaskStatus, Project } from "../lib/types"; type ViewMode = "list" | "board"; +type SortField = "position" | "priority" | "dueDate" | "created" | "title" | "updated"; +type SortDir = "asc" | "desc"; + +const PRIORITY_ORDER: Record = { critical: 0, high: 1, medium: 2, low: 3 }; export function QueuePage() { const { tasks, loading, error, refresh } = useTasks(5000); @@ -20,16 +24,29 @@ export function QueuePage() { const [search, setSearch] = useState(""); const [filterPriority, setFilterPriority] = useState(""); const [filterStatus, setFilterStatus] = useState(""); + const [filterTag, setFilterTag] = useState(""); + const [sortField, setSortField] = useState(() => { + return (localStorage.getItem("hammer-queue-sort") as SortField) || "position"; + }); + const [sortDir, setSortDir] = useState(() => { + return (localStorage.getItem("hammer-queue-sort-dir") as SortDir) || "asc"; + }); const [viewMode, setViewMode] = useState(() => { return (localStorage.getItem("hammer-queue-view") as ViewMode) || "list"; }); const [projects, setProjects] = useState([]); const { toast } = useToast(); - // Persist view mode + // Persist view mode and sort useEffect(() => { localStorage.setItem("hammer-queue-view", viewMode); }, [viewMode]); + useEffect(() => { + localStorage.setItem("hammer-queue-sort", sortField); + }, [sortField]); + useEffect(() => { + localStorage.setItem("hammer-queue-sort-dir", sortDir); + }, [sortDir]); // Load projects for name display useEffect(() => { @@ -56,6 +73,15 @@ export function QueuePage() { return () => window.removeEventListener("keydown", handleKey); }, [showCreate]); + // Collect all tags across tasks for the filter dropdown + const allTags = useMemo(() => { + const tagSet = new Set(); + for (const t of tasks) { + if (t.tags) for (const tag of t.tags) tagSet.add(tag); + } + return Array.from(tagSet).sort(); + }, [tasks]); + const filteredTasks = useMemo(() => { let filtered = tasks; if (search.trim()) { @@ -65,7 +91,8 @@ export function QueuePage() { 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)) + (t.assigneeName && t.assigneeName.toLowerCase().includes(q)) || + (t.tags && t.tags.some(tag => tag.toLowerCase().includes(q))) ); } if (filterPriority) { @@ -74,20 +101,52 @@ export function QueuePage() { if (filterStatus) { filtered = filtered.filter((t) => t.status === filterStatus); } + if (filterTag) { + filtered = filtered.filter((t) => t.tags && t.tags.includes(filterTag)); + } return filtered; - }, [tasks, search, filterPriority, filterStatus]); + }, [tasks, search, filterPriority, filterStatus, filterTag]); + + // Sort helper + const sortTasks = (taskList: typeof tasks) => { + if (sortField === "position") return taskList; // default order from API + return [...taskList].sort((a, b) => { + let cmp = 0; + switch (sortField) { + case "priority": + cmp = (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9); + break; + case "dueDate": { + const aDate = a.dueDate ? new Date(a.dueDate).getTime() : Infinity; + const bDate = b.dueDate ? new Date(b.dueDate).getTime() : Infinity; + cmp = aDate - bDate; + break; + } + case "created": + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + break; + case "updated": + cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); + break; + case "title": + cmp = a.title.localeCompare(b.title); + break; + } + return sortDir === "desc" ? -cmp : cmp; + }); + }; const selectedTaskData = useMemo(() => { if (!selectedTask) return null; return tasks.find((t) => t.id === selectedTask) || null; }, [tasks, selectedTask]); - const activeTasks = useMemo(() => filteredTasks.filter((t) => t.status === "active"), [filteredTasks]); - const queuedTasks = useMemo(() => filteredTasks.filter((t) => t.status === "queued"), [filteredTasks]); - const blockedTasks = useMemo(() => filteredTasks.filter((t) => t.status === "blocked"), [filteredTasks]); + const activeTasks = useMemo(() => sortTasks(filteredTasks.filter((t) => t.status === "active")), [filteredTasks, sortField, sortDir]); + const queuedTasks = useMemo(() => sortTasks(filteredTasks.filter((t) => t.status === "queued")), [filteredTasks, sortField, sortDir]); + const blockedTasks = useMemo(() => sortTasks(filteredTasks.filter((t) => t.status === "blocked")), [filteredTasks, sortField, sortDir]); const completedTasks = useMemo( - () => filteredTasks.filter((t) => t.status === "completed" || t.status === "cancelled"), - [filteredTasks] + () => sortTasks(filteredTasks.filter((t) => t.status === "completed" || t.status === "cancelled")), + [filteredTasks, sortField, sortDir] ); // When filtering by status, determine which sections to show @@ -136,7 +195,7 @@ export function QueuePage() { } }; - const activeFilters = [filterPriority, filterStatus].filter(Boolean).length; + const activeFilters = [filterPriority, filterStatus, filterTag].filter(Boolean).length + (sortField !== "position" ? 1 : 0); return (
@@ -234,9 +293,45 @@ export function QueuePage() { )} + {allTags.length > 0 && ( + + )} +
+ + {sortField !== "position" && ( + + )} +
{activeFilters > 0 && (