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

@@ -113,6 +113,11 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC
👤 {task.assigneeName}
</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 && (() => {
const due = new Date(task.dueDate);
const diffMs = due.getTime() - Date.now();

View File

@@ -122,6 +122,14 @@ export function TaskCard({
👤 {task.assigneeName}
</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">
{timeAgo(task.createdAt)}
</span>

View File

@@ -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<string[]>(task.tags || []);
const [tagInput, setTagInput] = useState("");
const [projects, setProjects] = useState<Project[]>([]);
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,
)}
</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 */}
<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>

View File

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

View File

@@ -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<string, number> = { 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<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>(() => {
return (localStorage.getItem("hammer-queue-view") as ViewMode) || "list";
});
const [projects, setProjects] = useState<Project[]>([]);
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<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(() => {
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 (
<div className="min-h-screen">
@@ -234,9 +293,45 @@ export function QueuePage() {
<option value="cancelled"> Cancelled</option>
</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 && (
<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"
title="Clear all filters"
>