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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface Task {
|
||||
assigneeName: string | null;
|
||||
projectId: string | null;
|
||||
dueDate: string | null;
|
||||
tags: string[];
|
||||
subtasks: Subtask[];
|
||||
progressNotes: ProgressNote[];
|
||||
createdAt: string;
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user