diff --git a/apps/web/src/__tests__/tasks-board-filters.test.ts b/apps/web/src/__tests__/tasks-board-filters.test.ts new file mode 100644 index 0000000..6ca4dd1 --- /dev/null +++ b/apps/web/src/__tests__/tasks-board-filters.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + boardFilterActive, + boardFilterNarrowing, + cardsForColumn, + compareCards, + DEFAULT_BOARD_FILTER, + filterAndSortBoardCards, + type BoardFilterState, + type FilterableCard, +} from "../lib/tasks-board-filters"; + +function card(partial: Partial & { id: string }): FilterableCard { + return { + column_id: "ready", + title: "Card", + description: null, + tags_json: null, + due_at: null, + priority: 2, + sort_order: 0, + parent_card_id: null, + context_json: null, + ...partial, + }; +} + +describe("tasks-board-filters", () => { + it("matches title and description search", () => { + const cards = [ + card({ id: "1", title: "Alpha deploy", description: "ship it" }), + card({ id: "2", title: "Beta", description: "deploy notes" }), + card({ id: "3", title: "Gamma", description: "other" }), + ]; + const state: BoardFilterState = { ...DEFAULT_BOARD_FILTER, query: "deploy" }; + expect(filterAndSortBoardCards(cards, state).map((c) => c.id)).toEqual([ + "1", + "2", + ]); + }); + + it("filters by priority, labels, assignees, milestone, column", () => { + const cards = [ + card({ + id: "a", + column_id: "ready", + priority: 0, + tags_json: JSON.stringify(["auto", "p0"]), + context_json: JSON.stringify({ + github: { + assignees: [{ login: "alice" }], + milestone: { title: "M1" }, + }, + }), + }), + card({ + id: "b", + column_id: "done", + priority: 2, + tags_json: JSON.stringify(["docs"]), + context_json: JSON.stringify({ + github: { + assignees: [{ login: "bob" }], + milestone: { title: "M2" }, + }, + }), + }), + ]; + const state: BoardFilterState = { + ...DEFAULT_BOARD_FILTER, + priorities: [0], + labels: ["auto"], + assignees: ["alice"], + milestones: ["M1"], + columns: ["ready"], + }; + expect(filterAndSortBoardCards(cards, state).map((c) => c.id)).toEqual(["a"]); + }); + + it("sorts by due then priority", () => { + const a = card({ id: "a", due_at: "2026-08-10", priority: 2, sort_order: 1 }); + const b = card({ id: "b", due_at: "2026-08-01", priority: 1, sort_order: 0 }); + const c = card({ id: "c", due_at: null, priority: 0, sort_order: 2 }); + expect([a, b, c].sort((x, y) => compareCards(x, y, "due")).map((x) => x.id)).toEqual([ + "b", + "a", + "c", + ]); + }); + + it("keeps column grouping after filter and reports empty columns", () => { + const cards = [ + card({ id: "1", column_id: "ready", title: "match me" }), + card({ id: "2", column_id: "done", title: "other" }), + ]; + const state: BoardFilterState = { ...DEFAULT_BOARD_FILTER, query: "match" }; + expect(cardsForColumn(cards, "ready", state).map((c) => c.id)).toEqual(["1"]); + expect(cardsForColumn(cards, "done", state)).toEqual([]); + }); + + it("treats sort-only changes as active but not narrowing", () => { + const sorted: BoardFilterState = { ...DEFAULT_BOARD_FILTER, sort: "due" }; + expect(boardFilterActive(sorted)).toBe(true); + expect(boardFilterNarrowing(sorted)).toBe(false); + const queried: BoardFilterState = { ...DEFAULT_BOARD_FILTER, query: "x" }; + expect(boardFilterNarrowing(queried)).toBe(true); + }); + + it("excludes subtasks from the board filter list", () => { + const cards = [ + card({ id: "parent", title: "Parent" }), + card({ id: "child", title: "Parent child", parent_card_id: "parent" }), + ]; + expect( + filterAndSortBoardCards(cards, { ...DEFAULT_BOARD_FILTER, query: "Parent" }).map( + (c) => c.id + ) + ).toEqual(["parent"]); + }); +}); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index c5acce0..9331b41 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1322,6 +1322,8 @@ export interface AiProjectCard { parent_card_id: string | null; status: string | null; assigned_agent_id: string | null; + created_at?: string; + updated_at?: string; } export type AgentBackendKind = diff --git a/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx b/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx index ffc0675..06a6042 100644 --- a/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx +++ b/apps/web/src/components/intelligence/projects/ProjectsBoard.tsx @@ -18,11 +18,13 @@ import { Calendar, CheckCircle2, Circle, + Filter, ListTree, MoreHorizontal, Paperclip, Pencil, Plus, + Search, SlidersHorizontal, Trash2, X, @@ -91,6 +93,18 @@ import { import { useIntelligence } from "@/lib/intelligence-context"; import type { ProductivityScope } from "@/lib/productivity-scope"; import { isUserScope, scopeReadOnly } from "@/lib/productivity-scope"; +import { + boardFilterActive, + boardFilterNarrowing, + cardsForColumn, + collectFilterOptions, + DEFAULT_BOARD_FILTER, + loadBoardFilter, + saveBoardFilter, + toggleFilterValue, + type BoardFilterState, + type BoardSortKey, +} from "@/lib/tasks-board-filters"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -1353,12 +1367,16 @@ export function ProjectsBoard({ const [face, setFace] = useState(() => loadCardFaceVisibility(boardKey) ); + const [filter, setFilter] = useState(() => + loadBoardFilter(boardKey) + ); const { clearReviewUnread } = useIntelligence(); const readOnly = scopeReadOnly(scope); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } })); useEffect(() => { setFace(loadCardFaceVisibility(boardKey)); + setFilter(loadBoardFilter(boardKey)); }, [boardKey]); const setFaceField = (key: keyof CardFaceVisibility, checked: boolean) => { @@ -1369,6 +1387,11 @@ export function ProjectsBoard({ }); }; + const updateFilter = (next: BoardFilterState) => { + setFilter(next); + saveBoardFilter(boardKey, next); + }; + const labelSuggestions = useMemo(() => { const set = new Set(); for (const c of cards) { @@ -1466,21 +1489,123 @@ export function ProjectsBoard({ if (target) openEditor(target); }; - // Board shows top-level cards only (subtasks are managed inside the editor), - // sorted by priority then manual order. + const filterOptions = useMemo(() => collectFilterOptions(cards), [cards]); + const filterNarrowing = boardFilterNarrowing(filter); + const filterIsActive = boardFilterActive(filter); + const matchedCount = useMemo(() => { + const ids = new Set(); + for (const col of columns) { + for (const c of cardsForColumn(cards, col.id, filter)) ids.add(c.id); + } + return ids.size; + }, [cards, columns, filter]); + + // Board shows top-level cards only (subtasks are managed inside the editor). const byColumn = (colId: string) => - cards - .filter((c) => c.column_id === colId && !c.parent_card_id) - .sort( - (a, b) => - (a.priority ?? 2) - (b.priority ?? 2) || a.sort_order - b.sort_order - ); + cardsForColumn(cards, colId, filter) as AiProjectCard[]; return (
-
- Tasks -
+
+
+ Tasks +
+ + + + Card fields + + } + /> + + Show on card face + + {( + [ + ["priority", "Priority"], + ["labels", "Labels"], + ["assignees", "Assignees"], + ["due", "Due date"], + ["milestone", "Milestone"], + ] as Array<[keyof CardFaceVisibility, string]> + ).map(([key, label]) => ( + setFaceField(key, !!v)} + > + {label} + + ))} + + + +
+
+
+
+ + + updateFilter({ ...filter, query: e.target.value }) + } + placeholder="Search title or body" + className="h-7 pl-7 text-xs" + aria-label="Search cards" + /> +
+ + 0 && "border-primary/50" + )} + > + + Priority + {filter.priorities.length > 0 + ? ` (${filter.priorities.length})` + : ""} + + } + /> + + Priority + + {[0, 1, 2, 3].map((p) => ( + + updateFilter(toggleFilterValue(filter, "priorities", p)) + } + > + P{p} + + ))} + + 0 && "border-primary/50" + )} + disabled={filterOptions.labels.length === 0} > - - Card fields + Labels + {filter.labels.length > 0 ? ` (${filter.labels.length})` : ""} } /> - - Show on card face + + Labels - {( - [ - ["priority", "Priority"], - ["labels", "Labels"], - ["assignees", "Assignees"], - ["due", "Due date"], - ["milestone", "Milestone"], - ] as Array<[keyof CardFaceVisibility, string]> - ).map(([key, label]) => ( + {filterOptions.labels.map((label) => ( setFaceField(key, !!v)} + key={label} + checked={filter.labels.includes(label)} + onCheckedChange={() => + updateFilter(toggleFilterValue(filter, "labels", label)) + } > {label} ))} - + } + /> + + Assignees + + {filterOptions.assignees.map((login) => ( + + updateFilter(toggleFilterValue(filter, "assignees", login)) + } + > + {login} + + ))} + + + + 0 && "border-primary/50" + )} + disabled={filterOptions.milestones.length === 0} + > + Milestone + {filter.milestones.length > 0 + ? ` (${filter.milestones.length})` + : ""} + + } + /> + + Milestone + + {filterOptions.milestones.map((m) => ( + + updateFilter(toggleFilterValue(filter, "milestones", m)) + } + > + {m} + + ))} + + + + 0 && "border-primary/50" + )} + disabled={columns.length === 0} + > + Status + {filter.columns.length > 0 + ? ` (${filter.columns.length})` + : ""} + + } + /> + + Column / Status + + {columns.map((col) => ( + + updateFilter(toggleFilterValue(filter, "columns", col.id)) + } + > + {col.name} + + ))} + + + + {filterIsActive ? ( + + ) : null} + {filterNarrowing ? ( + + {matchedCount} match{matchedCount === 1 ? "" : "es"} + + ) : null}
+ {filterNarrowing && matchedCount === 0 ? ( +
+
+

No cards match

+

+ Clear filters or adjust search to see cards again. +

+ +
+
+ ) : ( setActiveId(String(e.active.id))} onDragEnd={onDragEnd} >
- {columns.map((col) => { - const count = byColumn(col.id).length; + {columns + .filter( + (col) => + filter.columns.length === 0 || filter.columns.includes(col.id) + ) + .map((col) => { + const visible = byColumn(col.id); + const count = visible.length; + const totalInCol = cards.filter( + (c) => c.column_id === col.id && !c.parent_card_id + ).length; const wip = col.wip_limit != null && col.wip_limit > 0 ? col.wip_limit : null; - const overWip = wip != null && count > wip; + const overWip = wip != null && totalInCol > wip; return (
- {wip != null ? `${count}/${wip}` : count} + {wip != null + ? `${totalInCol}/${wip}` + : filterNarrowing && count !== totalInCol + ? `${count}/${totalInCol}` + : count}
c.id)} + items={visible.map((c) => c.id)} strategy={verticalListSortingStrategy} >
- {byColumn(col.id).map((card) => ( + {visible.map((card) => (
+ )}
; + milestone?: { title?: string | null } | null; +}; + +function parseTags(raw: string | null): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed.map((t) => String(t)).filter(Boolean); + } catch { + return raw + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + } + return []; +} + +function parseGithub(raw: string | null): GithubMeta { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return {}; + const github = (parsed as { github?: unknown }).github; + if (github && typeof github === "object") return github as GithubMeta; + } catch { + /* ignore */ + } + return {}; +} + +function normalizeQuery(q: string): string { + return q.trim().toLowerCase(); +} + +function cardMatchesQuery(card: FilterableCard, q: string): boolean { + if (!q) return true; + const hay = `${card.title ?? ""}\n${card.description ?? ""}`.toLowerCase(); + return hay.includes(q); +} + +function toggleInList(list: T[], value: T): T[] { + return list.includes(value) ? list.filter((v) => v !== value) : [...list, value]; +} + +export function toggleFilterValue( + state: BoardFilterState, + key: K, + value: BoardFilterState[K] extends Array ? U : never +): BoardFilterState { + const current = state[key]; + if (!Array.isArray(current)) return state; + return { + ...state, + [key]: toggleInList(current as unknown[], value), + }; +} + +export function boardFilterActive(state: BoardFilterState): boolean { + return ( + normalizeQuery(state.query).length > 0 || + state.priorities.length > 0 || + state.labels.length > 0 || + state.assignees.length > 0 || + state.milestones.length > 0 || + state.columns.length > 0 || + state.sort !== "priority" + ); +} + +export function boardFilterNarrowing(state: BoardFilterState): boolean { + return ( + normalizeQuery(state.query).length > 0 || + state.priorities.length > 0 || + state.labels.length > 0 || + state.assignees.length > 0 || + state.milestones.length > 0 || + state.columns.length > 0 + ); +} + +function dueSortKey(iso: string | null | undefined): number { + if (!iso) return Number.POSITIVE_INFINITY; + const t = Date.parse(iso); + return Number.isFinite(t) ? t : Number.POSITIVE_INFINITY; +} + +function updatedSortKey(card: FilterableCard): number { + const raw = card.updated_at || card.created_at || ""; + const t = Date.parse(raw); + return Number.isFinite(t) ? t : 0; +} + +export function compareCards( + a: FilterableCard, + b: FilterableCard, + sort: BoardSortKey +): number { + switch (sort) { + case "due": + return ( + dueSortKey(a.due_at) - dueSortKey(b.due_at) || + (a.priority ?? 2) - (b.priority ?? 2) || + a.sort_order - b.sort_order + ); + case "updated": + return ( + updatedSortKey(b) - updatedSortKey(a) || + (a.priority ?? 2) - (b.priority ?? 2) || + a.sort_order - b.sort_order + ); + case "manual": + return a.sort_order - b.sort_order; + case "priority": + default: + return ( + (a.priority ?? 2) - (b.priority ?? 2) || a.sort_order - b.sort_order + ); + } +} + +export function cardPassesBoardFilter( + card: FilterableCard, + state: BoardFilterState +): boolean { + if (card.parent_card_id) return false; + if (!cardMatchesQuery(card, normalizeQuery(state.query))) return false; + if ( + state.priorities.length > 0 && + !state.priorities.includes(card.priority ?? 2) + ) { + return false; + } + if (state.columns.length > 0 && !state.columns.includes(card.column_id)) { + return false; + } + const tags = parseTags(card.tags_json); + if ( + state.labels.length > 0 && + !state.labels.some((l) => tags.includes(l)) + ) { + return false; + } + const gh = parseGithub(card.context_json); + const assigneeLogins = (gh.assignees ?? []) + .map((a) => (a.login ?? "").toLowerCase()) + .filter(Boolean); + if ( + state.assignees.length > 0 && + !state.assignees.some((a) => assigneeLogins.includes(a.toLowerCase())) + ) { + return false; + } + const milestone = (gh.milestone?.title ?? "").trim(); + if ( + state.milestones.length > 0 && + !state.milestones.includes(milestone) + ) { + return false; + } + return true; +} + +export function filterAndSortBoardCards( + cards: FilterableCard[], + state: BoardFilterState +): FilterableCard[] { + return cards + .filter((c) => cardPassesBoardFilter(c, state)) + .sort((a, b) => compareCards(a, b, state.sort)); +} + +export function cardsForColumn( + cards: FilterableCard[], + columnId: string, + state: BoardFilterState +): FilterableCard[] { + return filterAndSortBoardCards(cards, state).filter( + (c) => c.column_id === columnId + ); +} + +export function collectFilterOptions(cards: FilterableCard[]): { + labels: string[]; + assignees: string[]; + milestones: string[]; +} { + const labels = new Set(); + const assignees = new Set(); + const milestones = new Set(); + for (const card of cards) { + if (card.parent_card_id) continue; + for (const t of parseTags(card.tags_json)) labels.add(t); + const gh = parseGithub(card.context_json); + for (const a of gh.assignees ?? []) { + if (a.login) assignees.add(a.login); + } + const m = (gh.milestone?.title ?? "").trim(); + if (m) milestones.add(m); + } + return { + labels: [...labels].sort((a, b) => a.localeCompare(b)), + assignees: [...assignees].sort((a, b) => a.localeCompare(b)), + milestones: [...milestones].sort((a, b) => a.localeCompare(b)), + }; +} + +export function boardFilterStorageKey(boardKey: string): string { + return `godmode.tasks.boardFilter.${boardKey}`; +} + +export function loadBoardFilter(boardKey: string): BoardFilterState { + try { + const raw = sessionStorage.getItem(boardFilterStorageKey(boardKey)); + if (!raw) return { ...DEFAULT_BOARD_FILTER }; + const parsed = JSON.parse(raw) as Partial; + return { + ...DEFAULT_BOARD_FILTER, + ...parsed, + query: typeof parsed.query === "string" ? parsed.query : "", + priorities: Array.isArray(parsed.priorities) + ? parsed.priorities.map(Number).filter((n) => Number.isFinite(n)) + : [], + labels: Array.isArray(parsed.labels) + ? parsed.labels.map(String) + : [], + assignees: Array.isArray(parsed.assignees) + ? parsed.assignees.map(String) + : [], + milestones: Array.isArray(parsed.milestones) + ? parsed.milestones.map(String) + : [], + columns: Array.isArray(parsed.columns) + ? parsed.columns.map(String) + : [], + sort: + parsed.sort === "due" || + parsed.sort === "updated" || + parsed.sort === "manual" || + parsed.sort === "priority" + ? parsed.sort + : "priority", + }; + } catch { + return { ...DEFAULT_BOARD_FILTER }; + } +} + +export function saveBoardFilter(boardKey: string, state: BoardFilterState): void { + try { + sessionStorage.setItem( + boardFilterStorageKey(boardKey), + JSON.stringify(state) + ); + } catch { + /* ignore quota */ + } +} diff --git a/docs/features/tasks.md b/docs/features/tasks.md index e1f270c..34144b5 100644 --- a/docs/features/tasks.md +++ b/docs/features/tasks.md @@ -12,6 +12,10 @@ summary: "Multiple personal kanban boards; optional GitHub Project sync; tag a c Tasks are personal Kanban boards with columns, priorities (P0–P3), subtasks, and comments. Create as many boards as you need (personal, family, roadmap, …) on the same sidebar workspace. Tag a card `auto` to queue autonomous agent work. The board layout scrolls horizontally so every column stays reachable; card detail opens in a side panel. +## Filter, search, and sort + +Each board has a filter bar (search, priority, labels, assignees, milestone, column/Status, and sort). Filters apply to visible cards only and do not break drag-and-drop or GitHub push-on-edit. Preferences persist for the browser session per board. Clear filters when nothing matches. Swimlanes / group-by stay in #274. + ## Multiple boards Use the board switcher on `/tasks` to create, rename, or archive boards. The default **My Tasks** board is always available. Board settings also cover optional GitHub linking (below). @@ -44,7 +48,7 @@ Field map: title, description, column↔Status, due date, priority (P0–P3 ↔ Boards created before multi-column sync get a default five-column `columns_json` on next open (schema backfill v19). Linked boards: run **Sync GitHub** once so columns match Status options and cards remap. Hide/WIP set after Sync stick on later pulls. -Kanban parity epic #259 (Done); GitHub App epic #266 (Done). Remaining Ready follow-ups: swimlanes / group-by (#274), board filter / search / sort (#276), Project field parity leftovers (#277), lifecycle polish for Issue create and archive vs delete (#278). +Kanban parity epic #259 (Done); GitHub App epic #266 (Done). Board filter / search / sort (#276) is available on the filter bar. Remaining Ready follow-ups: swimlanes / group-by (#274), Project field parity leftovers (#277), lifecycle polish for Issue create and archive vs delete (#278). ## Route