diff --git a/apps/web/src/board/filters.test.ts b/apps/web/src/board/filters.test.ts new file mode 100644 index 0000000..73882b3 --- /dev/null +++ b/apps/web/src/board/filters.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { me, type StoreTask } from "../store/index.js"; +import { assigneeFilter, cardFilter, clearFilters, mineOnly, search } from "./filters.js"; + +function task(patch: Partial = {}): StoreTask { + return { + id: "tk_1", + listId: "ls_1", + title: "Review proposal", + statusId: "st_open", + assigneeId: null, + assignedBy: null, + assignedAt: null, + priority: null, + dueDate: null, + snoozedUntil: null, + blockedNote: null, + tags: [], + position: 0, + createdAt: 0, + updatedAt: 0, + handoff: null, + ...patch, + }; +} + +function reset(): void { + clearFilters(); + me.value = null; +} + +beforeEach(reset); +afterEach(reset); + +describe("cardFilter", () => { + it("matches a title substring with a trimmed, case-insensitive query", () => { + search.value = " PROPOS "; + expect(cardFilter.value(task())).toBe(true); + expect(cardFilter.value(task({ title: "Send invoice" }))).toBe(false); + }); + + it("matches a visible tag substring case-insensitively", () => { + search.value = " VOIC "; + expect(cardFilter.value(task({ tags: ["Finance", "Invoice"] }))).toBe(true); + expect(cardFilter.value(task({ tags: ["Finance"] }))).toBe(false); + }); + + it("does not match machine-only ext: tags", () => { + search.value = "invoice"; + expect(cardFilter.value(task({ tags: ["ext:invoice", "Finance"] }))).toBe(false); + expect(cardFilter.value(task({ tags: ["ext:invoice", "Invoice"] }))).toBe(true); + }); + + it("requires both a search match and the selected assignee", () => { + search.value = "invoice"; + assigneeFilter.value = "us_1"; + expect(cardFilter.value(task({ title: "Send invoice", assigneeId: "us_1" }))).toBe(true); + expect(cardFilter.value(task({ tags: ["Invoice"], assigneeId: "us_1" }))).toBe(true); + expect(cardFilter.value(task({ tags: ["Invoice"], assigneeId: "us_2" }))).toBe(false); + expect(cardFilter.value(task({ tags: ["Invoice"] }))).toBe(false); + expect(cardFilter.value(task({ assigneeId: "us_1" }))).toBe(false); + }); + + it("combines search with mine only, taking precedence over the assignee filter", () => { + me.value = { + id: "us_me", email: "me@example.com", name: "Me", + role: "member", deactivated: false, createdAt: 0, + }; + mineOnly.value = true; + assigneeFilter.value = "us_other"; + search.value = "invoice"; + expect(cardFilter.value(task({ tags: ["Invoice"], assigneeId: "us_me" }))).toBe(true); + expect(cardFilter.value(task({ tags: ["Invoice"], assigneeId: "us_other" }))).toBe(false); + expect(cardFilter.value(task({ assigneeId: "us_me" }))).toBe(false); + }); +}); diff --git a/apps/web/src/board/filters.ts b/apps/web/src/board/filters.ts index 4fc862b..ecce17b 100644 --- a/apps/web/src/board/filters.ts +++ b/apps/web/src/board/filters.ts @@ -1,7 +1,7 @@ // Board filter state. Client-side only — it narrows the tasks already loaded. // Full-text search across the workspace goes through /api/tasks/search later. import { computed, signal, type ReadonlySignal } from "@preact/signals"; -import { isSnoozed } from "../lib/fmt.js"; +import { isSnoozed, visibleTags } from "../lib/fmt.js"; import { me, type StoreTask } from "../store/index.js"; /** Empty = every status shows. */ @@ -52,7 +52,11 @@ export const cardFilter: ReadonlySignal<(task: StoreTask) => boolean> = computed if (!q && !assignee) return () => true; return (task: StoreTask) => { if (assignee && task.assigneeId !== assignee) return false; - if (q && !task.title.toLowerCase().includes(q)) return false; + if ( + q && + !task.title.toLowerCase().includes(q) && + !visibleTags(task.tags).some((tag) => tag.toLowerCase().includes(q)) + ) return false; return true; }; });