diff --git a/frontend/package.json b/frontend/package.json
index b7305124..eae80f94 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -8,6 +8,7 @@
"start": "next start",
"lint": "eslint",
"test": "vitest run --passWithNoTests --coverage",
+ "test:full-coverage": "vitest run --passWithNoTests --coverage --config ./vitest.full-coverage.config.ts",
"test:watch": "vitest",
"dev:lan": "next dev --hostname 0.0.0.0 --port 3000",
"api:gen": "orval --config ./orval.config.ts",
diff --git a/frontend/src/components/molecules/TaskCard.test.tsx b/frontend/src/components/molecules/TaskCard.test.tsx
new file mode 100644
index 00000000..fde0daba
--- /dev/null
+++ b/frontend/src/components/molecules/TaskCard.test.tsx
@@ -0,0 +1,67 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { TaskCard } from "./TaskCard";
+
+describe("TaskCard", () => {
+ it("renders title, assignee, and due date", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Fix flaky test")).toBeInTheDocument();
+ expect(screen.getByText("Zara")).toBeInTheDocument();
+ expect(screen.getByText("Feb 11")).toBeInTheDocument();
+ expect(screen.getByText("HIGH")).toBeInTheDocument();
+ });
+
+ it("shows blocked state with count", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/Blocked · 2/i)).toBeInTheDocument();
+ });
+
+ it("shows approvals pending indicator", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(/Approval needed · 3/i)).toBeInTheDocument();
+ });
+
+ it("shows lead review indicator when status is review with no approvals and not blocked", () => {
+ render();
+
+ expect(screen.getByText(/Waiting for lead review/i)).toBeInTheDocument();
+ });
+
+ it("invokes onClick for mouse and keyboard activation", () => {
+ const onClick = vi.fn();
+
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /Clickable/i }));
+ expect(onClick).toHaveBeenCalledTimes(1);
+
+ fireEvent.keyDown(screen.getByRole("button", { name: /Clickable/i }), {
+ key: "Enter",
+ });
+ expect(onClick).toHaveBeenCalledTimes(2);
+
+ fireEvent.keyDown(screen.getByRole("button", { name: /Clickable/i }), {
+ key: " ",
+ });
+ expect(onClick).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/frontend/src/components/organisms/TaskBoard.test.tsx b/frontend/src/components/organisms/TaskBoard.test.tsx
index 35fb111f..1f37b7c1 100644
--- a/frontend/src/components/organisms/TaskBoard.test.tsx
+++ b/frontend/src/components/organisms/TaskBoard.test.tsx
@@ -1,8 +1,31 @@
-import { render, screen } from "@testing-library/react";
-import { describe, expect, it } from "vitest";
+import { fireEvent, render, screen, within } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
import { TaskBoard } from "./TaskBoard";
+type TaskStatus = "inbox" | "in_progress" | "review" | "done";
+
+type Task = {
+ id: string;
+ title: string;
+ status: TaskStatus;
+ priority: string;
+ approvals_pending_count?: number;
+ blocked_by_task_ids?: string[];
+ is_blocked?: boolean;
+};
+
+const buildTask = (overrides: Partial = {}): Task => ({
+ id: `task-${Math.random().toString(16).slice(2)}`,
+ title: "Task",
+ status: "inbox",
+ priority: "medium",
+ approvals_pending_count: 0,
+ blocked_by_task_ids: [],
+ is_blocked: false,
+ ...overrides,
+});
+
describe("TaskBoard", () => {
it("uses a mobile-first stacked layout (no horizontal scroll) with responsive kanban columns on larger screens", () => {
render(
@@ -48,4 +71,130 @@ describe("TaskBoard", () => {
// Ensure we didn't accidentally keep unscoped sticky behavior.
expect(header?.className).not.toContain("sticky top-0");
});
+
+ it("renders the 4 columns and shows per-column counts", () => {
+ const tasks: Task[] = [
+ buildTask({ id: "t1", title: "Inbox A", status: "inbox" }),
+ buildTask({ id: "t2", title: "Doing A", status: "in_progress" }),
+ buildTask({ id: "t3", title: "Review A", status: "review" }),
+ buildTask({ id: "t4", title: "Done A", status: "done" }),
+ buildTask({ id: "t5", title: "Inbox B", status: "inbox" }),
+ ];
+
+ render();
+
+ expect(screen.getByRole("heading", { name: "Inbox" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", { name: "In Progress" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Review" })).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "Done" })).toBeInTheDocument();
+
+ // Column count badges are plain spans; easiest stable check is text occurrence.
+ expect(screen.getAllByText("2").length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText("1").length).toBeGreaterThanOrEqual(1);
+
+ expect(screen.getByText("Inbox A")).toBeInTheDocument();
+ expect(screen.getByText("Inbox B")).toBeInTheDocument();
+ });
+
+ it("filters the review column by bucket", () => {
+ const tasks: Task[] = [
+ buildTask({
+ id: "blocked",
+ title: "Blocked Review",
+ status: "review",
+ is_blocked: true,
+ blocked_by_task_ids: ["dep-1"],
+ }),
+ buildTask({
+ id: "approval",
+ title: "Needs Approval",
+ status: "review",
+ approvals_pending_count: 2,
+ }),
+ buildTask({
+ id: "lead",
+ title: "Lead Review",
+ status: "review",
+ }),
+ ];
+
+ render();
+
+ const reviewHeading = screen.getByRole("heading", { name: "Review" });
+ const reviewColumn = reviewHeading.closest(".kanban-column");
+ expect(reviewColumn).toBeTruthy();
+ if (!reviewColumn) return;
+
+ const header = reviewColumn.querySelector(".column-header");
+ expect(header).toBeTruthy();
+ if (!header) return;
+
+ const headerQueries = within(header);
+
+ expect(headerQueries.getByRole("button", { name: /All · 3/i })).toBeInTheDocument();
+ expect(
+ headerQueries.getByRole("button", { name: /Approval needed · 1/i }),
+ ).toBeInTheDocument();
+ expect(
+ headerQueries.getByRole("button", { name: /Lead review · 1/i }),
+ ).toBeInTheDocument();
+ expect(
+ headerQueries.getByRole("button", { name: /Blocked · 1/i }),
+ ).toBeInTheDocument();
+
+ fireEvent.click(headerQueries.getByRole("button", { name: /Blocked · 1/i }));
+ expect(screen.getByText("Blocked Review")).toBeInTheDocument();
+ expect(screen.queryByText("Needs Approval")).not.toBeInTheDocument();
+ expect(screen.queryByText("Lead Review")).not.toBeInTheDocument();
+
+ fireEvent.click(
+ headerQueries.getByRole("button", { name: /Approval needed · 1/i }),
+ );
+ expect(screen.getByText("Needs Approval")).toBeInTheDocument();
+ expect(screen.queryByText("Blocked Review")).not.toBeInTheDocument();
+ expect(screen.queryByText("Lead Review")).not.toBeInTheDocument();
+
+ fireEvent.click(
+ headerQueries.getByRole("button", { name: /Lead review · 1/i }),
+ );
+ expect(screen.getByText("Lead Review")).toBeInTheDocument();
+ expect(screen.queryByText("Blocked Review")).not.toBeInTheDocument();
+ expect(screen.queryByText("Needs Approval")).not.toBeInTheDocument();
+ });
+
+ it("invokes onTaskMove when a task is dropped onto a different column", () => {
+ const onTaskMove = vi.fn();
+ const tasks: Task[] = [
+ buildTask({ id: "t1", title: "Inbox A", status: "inbox" }),
+ ];
+
+ render();
+
+ const dropTarget = screen
+ .getByRole("heading", { name: "Done" })
+ .closest(".kanban-column");
+ expect(dropTarget).toBeTruthy();
+ if (!dropTarget) return;
+
+ fireEvent.drop(dropTarget, {
+ dataTransfer: {
+ getData: () => JSON.stringify({ taskId: "t1", status: "inbox" }),
+ },
+ });
+
+ expect(onTaskMove).toHaveBeenCalledWith("t1", "done");
+ });
+
+ it("does not allow dragging when readOnly is true", () => {
+ const tasks: Task[] = [buildTask({ id: "t1", title: "Inbox A" })];
+
+ render();
+
+ expect(screen.getByRole("button", { name: /Inbox A/i })).toHaveAttribute(
+ "draggable",
+ "false",
+ );
+ });
});
diff --git a/frontend/vitest.full-coverage.config.ts b/frontend/vitest.full-coverage.config.ts
new file mode 100644
index 00000000..59eea1d4
--- /dev/null
+++ b/frontend/vitest.full-coverage.config.ts
@@ -0,0 +1,37 @@
+import path from "node:path";
+
+import { defineConfig } from "vitest/config";
+
+// Broader coverage config used for gap analysis.
+// - Does NOT enforce 100% thresholds.
+// - Includes most source files (excluding generated artifacts and types).
+// Keep the default vitest.config.ts as the scoped coverage gate.
+export default defineConfig({
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+ test: {
+ environment: "jsdom",
+ setupFiles: ["./src/setupTests.ts"],
+ globals: true,
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "lcov"],
+ reportsDirectory: "./coverage-full",
+ include: ["src/**/*.{ts,tsx}"],
+ exclude: [
+ "**/*.d.ts",
+ "src/**/__generated__/**",
+ "src/**/generated/**",
+ ],
+ thresholds: {
+ lines: 0,
+ statements: 0,
+ functions: 0,
+ branches: 0,
+ },
+ },
+ },
+});