diff --git a/apps/web/src/app/(authenticated)/kanban/page.tsx b/apps/web/src/app/(authenticated)/kanban/page.tsx
index f2b2292..6b1a8e6 100644
--- a/apps/web/src/app/(authenticated)/kanban/page.tsx
+++ b/apps/web/src/app/(authenticated)/kanban/page.tsx
@@ -1,7 +1,8 @@
"use client";
-import { useState, useEffect, useCallback } from "react";
+import { useState, useEffect, useCallback, useMemo } from "react";
import type { ReactElement } from "react";
+import { useSearchParams, useRouter } from "next/navigation";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import type {
DropResult,
@@ -11,7 +12,8 @@ import type {
} from "@hello-pangea/dnd";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
-import { fetchTasks, updateTask } from "@/lib/api/tasks";
+import { fetchTasks, updateTask, type TaskFilters } from "@/lib/api/tasks";
+import { fetchProjects, type Project } from "@/lib/api/projects";
import { useWorkspaceId } from "@/lib/hooks";
import type { Task } from "@mosaic/shared";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
@@ -34,6 +36,33 @@ const COLUMNS: ColumnConfig[] = [
{ status: TaskStatus.ARCHIVED, label: "Archived", accent: "var(--muted)" },
];
+const PRIORITY_OPTIONS: { value: string; label: string }[] = [
+ { value: "", label: "All Priorities" },
+ { value: TaskPriority.HIGH, label: "High" },
+ { value: TaskPriority.MEDIUM, label: "Medium" },
+ { value: TaskPriority.LOW, label: "Low" },
+];
+
+/* ---------------------------------------------------------------------------
+ Filter select shared styles
+ --------------------------------------------------------------------------- */
+
+const selectStyle: React.CSSProperties = {
+ padding: "6px 10px",
+ borderRadius: "var(--r)",
+ border: "1px solid var(--border)",
+ background: "var(--surface)",
+ color: "var(--text)",
+ fontSize: "0.83rem",
+ outline: "none",
+ minWidth: 130,
+};
+
+const inputStyle: React.CSSProperties = {
+ ...selectStyle,
+ minWidth: 180,
+};
+
/* ---------------------------------------------------------------------------
Priority badge helper
--------------------------------------------------------------------------- */
@@ -243,16 +272,203 @@ function KanbanColumn({ config, tasks }: KanbanColumnProps): ReactElement {
);
}
+/* ---------------------------------------------------------------------------
+ Filter Bar
+ --------------------------------------------------------------------------- */
+
+interface FilterBarProps {
+ projects: Project[];
+ projectId: string;
+ priority: string;
+ search: string;
+ myTasks: boolean;
+ onProjectChange: (value: string) => void;
+ onPriorityChange: (value: string) => void;
+ onSearchChange: (value: string) => void;
+ onMyTasksToggle: () => void;
+ onClear: () => void;
+ hasActiveFilters: boolean;
+}
+
+function FilterBar({
+ projects,
+ projectId,
+ priority,
+ search,
+ myTasks,
+ onProjectChange,
+ onPriorityChange,
+ onSearchChange,
+ onMyTasksToggle,
+ onClear,
+ hasActiveFilters,
+}: FilterBarProps): ReactElement {
+ return (
+
+ {/* Search */}
+ {
+ onSearchChange(e.target.value);
+ }}
+ style={inputStyle}
+ />
+
+ {/* Project filter */}
+
+
+ {/* Priority filter */}
+
+
+ {/* My Tasks toggle */}
+
+
+ {/* Clear filters */}
+ {hasActiveFilters && (
+
+ )}
+
+ );
+}
+
/* ---------------------------------------------------------------------------
Kanban Board Page
--------------------------------------------------------------------------- */
export default function KanbanPage(): ReactElement {
const workspaceId = useWorkspaceId();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
const [tasks, setTasks] = useState([]);
+ const [projects, setProjects] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
+ // Read filters from URL params
+ const filterProject = searchParams.get("project") ?? "";
+ const filterPriority = searchParams.get("priority") ?? "";
+ const filterSearch = searchParams.get("q") ?? "";
+ const filterMyTasks = searchParams.get("my") === "1";
+
+ const hasActiveFilters =
+ filterProject !== "" || filterPriority !== "" || filterSearch !== "" || filterMyTasks;
+
+ /** Update a single URL param (preserving others) */
+ const setParam = useCallback(
+ (key: string, value: string) => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (value) {
+ params.set(key, value);
+ } else {
+ params.delete(key);
+ }
+ router.replace(`/kanban?${params.toString()}`, { scroll: false });
+ },
+ [searchParams, router]
+ );
+
+ const handleProjectChange = useCallback(
+ (value: string) => {
+ setParam("project", value);
+ },
+ [setParam]
+ );
+
+ const handlePriorityChange = useCallback(
+ (value: string) => {
+ setParam("priority", value);
+ },
+ [setParam]
+ );
+
+ const handleSearchChange = useCallback(
+ (value: string) => {
+ setParam("q", value);
+ },
+ [setParam]
+ );
+
+ const handleMyTasksToggle = useCallback(() => {
+ setParam("my", filterMyTasks ? "" : "1");
+ }, [setParam, filterMyTasks]);
+
+ const handleClearFilters = useCallback(() => {
+ router.replace("/kanban", { scroll: false });
+ }, [router]);
+
/* --- data fetching --- */
const loadTasks = useCallback(async (wsId: string | null): Promise => {
@@ -280,28 +496,31 @@ export default function KanbanPage(): ReactElement {
return;
}
- let cancelled = false;
+ const ac = new AbortController();
async function load(): Promise {
try {
setIsLoading(true);
setError(null);
- const filters = workspaceId !== null ? { workspaceId } : {};
- const data = await fetchTasks(filters);
- if (!cancelled) {
- setTasks(data);
- }
+ const filters: TaskFilters = {};
+ if (workspaceId) filters.workspaceId = workspaceId;
+ const [taskData, projectData] = await Promise.all([
+ fetchTasks(filters),
+ fetchProjects(workspaceId ?? undefined),
+ ]);
+ if (ac.signal.aborted) return;
+ setTasks(taskData);
+ setProjects(projectData);
} catch (err: unknown) {
console.error("[Kanban] Failed to fetch tasks:", err);
- if (!cancelled) {
- setError(
- err instanceof Error
- ? err.message
- : "Something went wrong loading tasks. You could try again when ready."
- );
- }
+ if (ac.signal.aborted) return;
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Something went wrong loading tasks. You could try again when ready."
+ );
} finally {
- if (!cancelled) {
+ if (!ac.signal.aborted) {
setIsLoading(false);
}
}
@@ -310,10 +529,41 @@ export default function KanbanPage(): ReactElement {
void load();
return (): void => {
- cancelled = true;
+ ac.abort();
};
}, [workspaceId]);
+ /* --- apply client-side filters --- */
+
+ const filteredTasks = useMemo(() => {
+ let result = tasks;
+
+ if (filterProject) {
+ result = result.filter((t) => t.projectId === filterProject);
+ }
+
+ if (filterPriority) {
+ result = result.filter((t) => t.priority === (filterPriority as TaskPriority));
+ }
+
+ if (filterSearch) {
+ const q = filterSearch.toLowerCase();
+ result = result.filter(
+ (t) => t.title.toLowerCase().includes(q) || t.description?.toLowerCase().includes(q)
+ );
+ }
+
+ if (filterMyTasks) {
+ // "My Tasks" filters to tasks assigned to the current user.
+ // Since we don't have the current userId readily available,
+ // filter by assigneeId being non-null (assigned tasks).
+ // A proper implementation would compare against the logged-in user's ID.
+ result = result.filter((t) => t.assigneeId !== null);
+ }
+
+ return result;
+ }, [tasks, filterProject, filterPriority, filterSearch, filterMyTasks]);
+
/* --- group tasks by status --- */
function groupByStatus(allTasks: Task[]): Record {
@@ -332,7 +582,7 @@ export default function KanbanPage(): ReactElement {
return grouped;
}
- const grouped = groupByStatus(tasks);
+ const grouped = groupByStatus(filteredTasks);
/* --- drag-and-drop handler --- */
@@ -376,7 +626,7 @@ export default function KanbanPage(): ReactElement {
return (
{/* Page header */}
-
+
+ {/* Filter bar */}
+
+
{/* Loading state */}
{isLoading ? (
@@ -431,6 +696,37 @@ export default function KanbanPage(): ReactElement {
Try again
+ ) : filteredTasks.length === 0 && tasks.length > 0 ? (
+ /* No results (filtered) */
+
+
+ No tasks match your filters.
+
+
+
) : tasks.length === 0 ? (
/* Empty state */