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
This commit is contained in:
2026-01-29 11:04:39 +00:00
parent f4c60bf6aa
commit e9c0763025
7 changed files with 185 additions and 12 deletions

View File

@@ -82,6 +82,7 @@ export const tasks = pgTable("tasks", {
assigneeName: text("assignee_name"), assigneeName: text("assignee_name"),
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }), projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
dueDate: timestamp("due_date", { withTimezone: true }), dueDate: timestamp("due_date", { withTimezone: true }),
tags: jsonb("tags").$type<string[]>().default([]),
subtasks: jsonb("subtasks").$type<Subtask[]>().default([]), subtasks: jsonb("subtasks").$type<Subtask[]>().default([]),
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]), progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),

View File

@@ -154,6 +154,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
taskNumber: nextNumber, taskNumber: nextNumber,
projectId: body.projectId || null, projectId: body.projectId || null,
dueDate: body.dueDate ? new Date(body.dueDate) : null, dueDate: body.dueDate ? new Date(body.dueDate) : null,
tags: body.tags || [],
subtasks: [], subtasks: [],
progressNotes: [], progressNotes: [],
}) })
@@ -193,6 +194,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
), ),
projectId: 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()])), 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.assigneeName !== undefined) updates.assigneeName = body.assigneeName;
if (body.projectId !== undefined) updates.projectId = body.projectId; if (body.projectId !== undefined) updates.projectId = body.projectId;
if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null; 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.subtasks !== undefined) updates.subtasks = body.subtasks;
if (body.progressNotes !== undefined) updates.progressNotes = body.progressNotes;
const updated = await db const updated = await db
.update(tasks) .update(tasks)
@@ -269,6 +273,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
assigneeName: t.Optional(t.Union([t.String(), t.Null()])), assigneeName: t.Optional(t.Union([t.String(), t.Null()])),
projectId: 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()])), dueDate: t.Optional(t.Union([t.String(), t.Null()])),
tags: t.Optional(t.Array(t.String())),
subtasks: t.Optional(t.Array(t.Object({ subtasks: t.Optional(t.Array(t.Object({
id: t.String(), id: t.String(),
title: t.String(), title: t.String(),
@@ -276,6 +281,10 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
completedAt: t.Optional(t.String()), completedAt: t.Optional(t.String()),
createdAt: t.String(), createdAt: t.String(),
}))), }))),
progressNotes: t.Optional(t.Array(t.Object({
timestamp: t.String(),
note: t.String(),
}))),
}), }),
} }
) )

View File

@@ -113,6 +113,11 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC
👤 {task.assigneeName} 👤 {task.assigneeName}
</span> </span>
)} )}
{task.tags?.slice(0, 2).map(tag => (
<span key={tag} className="text-[10px] text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-900/30 px-1.5 py-0.5 rounded-full">
🏷 {tag}
</span>
))}
{task.dueDate && (() => { {task.dueDate && (() => {
const due = new Date(task.dueDate); const due = new Date(task.dueDate);
const diffMs = due.getTime() - Date.now(); const diffMs = due.getTime() - Date.now();

View File

@@ -122,6 +122,14 @@ export function TaskCard({
👤 {task.assigneeName} 👤 {task.assigneeName}
</span> </span>
)} )}
{task.tags?.length > 0 && task.tags.slice(0, 2).map(tag => (
<span key={tag} className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300">
🏷 {tag}
</span>
))}
{task.tags?.length > 2 && (
<span className="text-[10px] text-violet-500 dark:text-violet-400">+{task.tags.length - 2}</span>
)}
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500"> <span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500">
{timeAgo(task.createdAt)} {timeAgo(task.createdAt)}
</span> </span>

View File

@@ -258,6 +258,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
const [draftProjectId, setDraftProjectId] = useState(task.projectId || ""); const [draftProjectId, setDraftProjectId] = useState(task.projectId || "");
const [draftDueDate, setDraftDueDate] = useState(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); const [draftDueDate, setDraftDueDate] = useState(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : "");
const [draftAssigneeName, setDraftAssigneeName] = useState(task.assigneeName || ""); const [draftAssigneeName, setDraftAssigneeName] = useState(task.assigneeName || "");
const [draftTags, setDraftTags] = useState<string[]>(task.tags || []);
const [tagInput, setTagInput] = useState("");
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [newSubtaskTitle, setNewSubtaskTitle] = useState(""); const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
const [addingSubtask, setAddingSubtask] = useState(false); const [addingSubtask, setAddingSubtask] = useState(false);
@@ -282,7 +284,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
setDraftProjectId(task.projectId || ""); setDraftProjectId(task.projectId || "");
setDraftDueDate(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); setDraftDueDate(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : "");
setDraftAssigneeName(task.assigneeName || ""); 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 currentDueDate = task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : "";
const isDirty = const isDirty =
@@ -292,7 +295,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
draftSource !== task.source || draftSource !== task.source ||
draftProjectId !== (task.projectId || "") || draftProjectId !== (task.projectId || "") ||
draftDueDate !== currentDueDate || draftDueDate !== currentDueDate ||
draftAssigneeName !== (task.assigneeName || ""); draftAssigneeName !== (task.assigneeName || "") ||
JSON.stringify(draftTags) !== JSON.stringify(task.tags || []);
const handleCancel = () => { const handleCancel = () => {
setDraftTitle(task.title); setDraftTitle(task.title);
@@ -302,6 +306,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
setDraftProjectId(task.projectId || ""); setDraftProjectId(task.projectId || "");
setDraftDueDate(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""); setDraftDueDate(task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : "");
setDraftAssigneeName(task.assigneeName || ""); setDraftAssigneeName(task.assigneeName || "");
setDraftTags(task.tags || []);
}; };
const handleSave = async () => { const handleSave = async () => {
@@ -316,6 +321,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
if (draftProjectId !== (task.projectId || "")) updates.projectId = draftProjectId || null; if (draftProjectId !== (task.projectId || "")) updates.projectId = draftProjectId || null;
if (draftDueDate !== currentDueDate) updates.dueDate = draftDueDate ? new Date(draftDueDate).toISOString() : null; if (draftDueDate !== currentDueDate) updates.dueDate = draftDueDate ? new Date(draftDueDate).toISOString() : null;
if (draftAssigneeName !== (task.assigneeName || "")) updates.assigneeName = draftAssigneeName || 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); await updateTask(task.id, updates, token);
onTaskUpdated(); onTaskUpdated();
toast("Changes saved", "success"); toast("Changes saved", "success");
@@ -579,6 +585,54 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
)} )}
</div> </div>
{/* Tags */}
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Tags</h3>
<div className="flex flex-wrap gap-1.5 items-center">
{draftTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 text-xs px-2 py-1 bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 rounded-full font-medium"
>
🏷 {tag}
{hasToken && (
<button
onClick={() => setDraftTags(draftTags.filter((t) => t !== tag))}
className="text-violet-400 dark:text-violet-500 hover:text-red-500 dark:hover:text-red-400 transition ml-0.5"
>
×
</button>
)}
</span>
))}
{hasToken && (
<input
type="text"
value={tagInput}
onChange={(e) => 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 && (
<span className="text-gray-400 dark:text-gray-500 italic text-xs">No tags</span>
)}
</div>
</div>
{/* Description */} {/* Description */}
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800"> <div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3> <h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>

View File

@@ -48,6 +48,7 @@ export interface Task {
assigneeName: string | null; assigneeName: string | null;
projectId: string | null; projectId: string | null;
dueDate: string | null; dueDate: string | null;
tags: string[];
subtasks: Subtask[]; subtasks: Subtask[];
progressNotes: ProgressNote[]; progressNotes: ProgressNote[];
createdAt: string; createdAt: string;

View File

@@ -10,6 +10,10 @@ import { updateTask, reorderTasks, createTask, fetchProjects } from "../lib/api"
import type { TaskStatus, Project } from "../lib/types"; import type { TaskStatus, Project } from "../lib/types";
type ViewMode = "list" | "board"; type ViewMode = "list" | "board";
type SortField = "position" | "priority" | "dueDate" | "created" | "title" | "updated";
type SortDir = "asc" | "desc";
const PRIORITY_ORDER: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
export function QueuePage() { export function QueuePage() {
const { tasks, loading, error, refresh } = useTasks(5000); const { tasks, loading, error, refresh } = useTasks(5000);
@@ -20,16 +24,29 @@ export function QueuePage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filterPriority, setFilterPriority] = useState<string>(""); const [filterPriority, setFilterPriority] = useState<string>("");
const [filterStatus, setFilterStatus] = useState<string>(""); const [filterStatus, setFilterStatus] = useState<string>("");
const [filterTag, setFilterTag] = useState<string>("");
const [sortField, setSortField] = useState<SortField>(() => {
return (localStorage.getItem("hammer-queue-sort") as SortField) || "position";
});
const [sortDir, setSortDir] = useState<SortDir>(() => {
return (localStorage.getItem("hammer-queue-sort-dir") as SortDir) || "asc";
});
const [viewMode, setViewMode] = useState<ViewMode>(() => { const [viewMode, setViewMode] = useState<ViewMode>(() => {
return (localStorage.getItem("hammer-queue-view") as ViewMode) || "list"; return (localStorage.getItem("hammer-queue-view") as ViewMode) || "list";
}); });
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const { toast } = useToast(); const { toast } = useToast();
// Persist view mode // Persist view mode and sort
useEffect(() => { useEffect(() => {
localStorage.setItem("hammer-queue-view", viewMode); localStorage.setItem("hammer-queue-view", viewMode);
}, [viewMode]); }, [viewMode]);
useEffect(() => {
localStorage.setItem("hammer-queue-sort", sortField);
}, [sortField]);
useEffect(() => {
localStorage.setItem("hammer-queue-sort-dir", sortDir);
}, [sortDir]);
// Load projects for name display // Load projects for name display
useEffect(() => { useEffect(() => {
@@ -56,6 +73,15 @@ export function QueuePage() {
return () => window.removeEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey);
}, [showCreate]); }, [showCreate]);
// Collect all tags across tasks for the filter dropdown
const allTags = useMemo(() => {
const tagSet = new Set<string>();
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(() => { const filteredTasks = useMemo(() => {
let filtered = tasks; let filtered = tasks;
if (search.trim()) { if (search.trim()) {
@@ -65,7 +91,8 @@ export function QueuePage() {
t.title.toLowerCase().includes(q) || t.title.toLowerCase().includes(q) ||
(t.description && t.description.toLowerCase().includes(q)) || (t.description && t.description.toLowerCase().includes(q)) ||
(t.taskNumber && `hq-${t.taskNumber}`.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) { if (filterPriority) {
@@ -74,20 +101,52 @@ export function QueuePage() {
if (filterStatus) { if (filterStatus) {
filtered = filtered.filter((t) => t.status === filterStatus); filtered = filtered.filter((t) => t.status === filterStatus);
} }
if (filterTag) {
filtered = filtered.filter((t) => t.tags && t.tags.includes(filterTag));
}
return filtered; 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(() => { const selectedTaskData = useMemo(() => {
if (!selectedTask) return null; if (!selectedTask) return null;
return tasks.find((t) => t.id === selectedTask) || null; return tasks.find((t) => t.id === selectedTask) || null;
}, [tasks, selectedTask]); }, [tasks, selectedTask]);
const activeTasks = useMemo(() => filteredTasks.filter((t) => t.status === "active"), [filteredTasks]); const activeTasks = useMemo(() => sortTasks(filteredTasks.filter((t) => t.status === "active")), [filteredTasks, sortField, sortDir]);
const queuedTasks = useMemo(() => filteredTasks.filter((t) => t.status === "queued"), [filteredTasks]); const queuedTasks = useMemo(() => sortTasks(filteredTasks.filter((t) => t.status === "queued")), [filteredTasks, sortField, sortDir]);
const blockedTasks = useMemo(() => filteredTasks.filter((t) => t.status === "blocked"), [filteredTasks]); const blockedTasks = useMemo(() => sortTasks(filteredTasks.filter((t) => t.status === "blocked")), [filteredTasks, sortField, sortDir]);
const completedTasks = useMemo( const completedTasks = useMemo(
() => filteredTasks.filter((t) => t.status === "completed" || t.status === "cancelled"), () => sortTasks(filteredTasks.filter((t) => t.status === "completed" || t.status === "cancelled")),
[filteredTasks] [filteredTasks, sortField, sortDir]
); );
// When filtering by status, determine which sections to show // 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 ( return (
<div className="min-h-screen"> <div className="min-h-screen">
@@ -234,9 +293,45 @@ export function QueuePage() {
<option value="cancelled"> Cancelled</option> <option value="cancelled"> Cancelled</option>
</select> </select>
)} )}
{allTags.length > 0 && (
<select
value={filterTag}
onChange={(e) => setFilterTag(e.target.value)}
className="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
<option value="">All tags</option>
{allTags.map(tag => (
<option key={tag} value={tag}>🏷 {tag}</option>
))}
</select>
)}
<div className="hidden sm:flex items-center gap-1">
<select
value={sortField}
onChange={(e) => setSortField(e.target.value as SortField)}
className="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-2 py-2 focus:outline-none focus:ring-2 focus:ring-amber-200 dark:focus:ring-amber-800 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
title="Sort by"
>
<option value="position">Sort: Default</option>
<option value="priority">Sort: Priority</option>
<option value="dueDate">Sort: Due Date</option>
<option value="created">Sort: Created</option>
<option value="updated">Sort: Updated</option>
<option value="title">Sort: Name</option>
</select>
{sortField !== "position" && (
<button
onClick={() => setSortDir(d => d === "asc" ? "desc" : "asc")}
className="p-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition"
title={sortDir === "asc" ? "Ascending" : "Descending"}
>
{sortDir === "asc" ? "↑" : "↓"}
</button>
)}
</div>
{activeFilters > 0 && ( {activeFilters > 0 && (
<button <button
onClick={() => { setFilterPriority(""); setFilterStatus(""); }} onClick={() => { setFilterPriority(""); setFilterStatus(""); setFilterTag(""); setSortField("position"); setSortDir("asc"); }}
className="text-xs text-amber-600 hover:text-amber-700 font-medium px-2 py-2 shrink-0" className="text-xs text-amber-600 hover:text-amber-700 font-medium px-2 py-2 shrink-0"
title="Clear all filters" title="Clear all filters"
> >