From aa5dd7a4078e5f3acd49071590d5c29af884ae4b Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Mon, 25 May 2026 20:58:58 -0600 Subject: [PATCH 1/9] feat: extract responsive mobile navigation component - Extract MobileNav into src/components/mobile-nav.tsx with React state - Add theme toggle and version label inside mobile nav panel - Nav closes automatically when a link is tapped (hashchange + onClick) - Desktop nav hidden on small screens (hidden sm:flex) - Header and main content use consistent max-w-screen-2xl container --- src/app/layout.tsx | 50 +--------------------- src/components/mobile-nav.tsx | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 49 deletions(-) create mode 100644 src/components/mobile-nav.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a59f0a4..0fa847b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,7 +3,7 @@ import { Inter } from "next/font/google"; import Link from "next/link"; import "./globals.css"; import { ThemeToggle } from "@/components/theme-toggle"; -import { getVersionLabel } from "@/lib/version"; +import { MobileNav } from "@/components/mobile-nav"; const inter = Inter({ subsets: ["latin"] }); @@ -59,7 +59,6 @@ export default function RootLayout({ {/* Mobile menu button */} - {getVersionLabel()}
@@ -71,50 +70,3 @@ export default function RootLayout({ ); } - -function MobileNav() { - return ( - <> - - - - - ); -} diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx new file mode 100644 index 0000000..3d52f9e --- /dev/null +++ b/src/components/mobile-nav.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState, useEffect } from "react"; +import Link from "next/link"; +import { ThemeToggle } from "./theme-toggle"; +import { getVersionLabel } from "@/lib/version"; + +const navLinks = [ + { href: "/", label: "Overview" }, + { href: "/board", label: "Board" }, + { href: "/projects", label: "Projects" }, + { href: "/agents", label: "Agents" }, + { href: "/automation", label: "Automation" }, +]; + +export function MobileNav() { + const [open, setOpen] = useState(false); + + useEffect(() => { + if (!open) return; + function onHashChange() { + setOpen(false); + } + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, [open]); + + function close() { + setOpen(false); + } + + return ( +
+ + +
+ ); +} From a0ef34fde66abf6dfff5d81752525140be183b97 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Mon, 25 May 2026 21:08:34 -0600 Subject: [PATCH 2/9] fix: responsive mobile nav - version label visibility and test fixes - Restore version label to desktop header (was accidentally removed) - Conditionally render mobile nav content only when open to avoid duplicate DOM elements (version label, theme toggle) - Update tests: use fireEvent for checkbox interaction, add new test for sm:hidden container, verify nav links on open --- src/app/layout.test.tsx | 24 ++++++++++++---- src/app/layout.tsx | 2 ++ src/components/mobile-nav.tsx | 53 ++++++++++++++++++----------------- 3 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/app/layout.test.tsx b/src/app/layout.test.tsx index 9070507..de64ef2 100644 --- a/src/app/layout.test.tsx +++ b/src/app/layout.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import React from "react"; const versionLabel = "v0.2.1"; @@ -99,15 +99,29 @@ describe("RootLayout app shell", () => { expect(mobileToggle).toBeTruthy(); }); - it("mobile nav contains all primary nav links", () => { + it("mobile nav toggle is inside a sm:hidden container", () => { const { container } = render( , ); - const mobileNav = container.querySelector('nav[class*="sm:hidden"]'); - expect(mobileNav).toBeTruthy(); - expect(mobileNav?.querySelectorAll("a").length).toBe(5); + const mobileContainer = container.querySelector('div[class*="sm:hidden"]'); + expect(mobileContainer).toBeTruthy(); + }); + + it("mobile nav contains all primary nav links when open", () => { + const { container } = render( + + + , + ); + const checkbox = container.querySelector("#mobile-nav-toggle")!; + fireEvent.change(checkbox, { target: { checked: true } }); + expect(screen.getByText("Overview")).toBeInTheDocument(); + expect(screen.getByText("Board")).toBeInTheDocument(); + expect(screen.getByText("Projects")).toBeInTheDocument(); + expect(screen.getByText("Agents")).toBeInTheDocument(); + expect(screen.getByText("Automation")).toBeInTheDocument(); }); it("renders theme toggle", () => { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0fa847b..93e2d61 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import "./globals.css"; import { ThemeToggle } from "@/components/theme-toggle"; import { MobileNav } from "@/components/mobile-nav"; +import { getVersionLabel } from "@/lib/version"; const inter = Inter({ subsets: ["latin"] }); @@ -59,6 +60,7 @@ export default function RootLayout({ {/* Mobile menu button */} + {getVersionLabel()}
diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx index 3d52f9e..cb8a92b 100644 --- a/src/components/mobile-nav.tsx +++ b/src/components/mobile-nav.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from "react"; import Link from "next/link"; -import { ThemeToggle } from "./theme-toggle"; import { getVersionLabel } from "@/lib/version"; const navLinks = [ @@ -31,11 +30,9 @@ export function MobileNav() { return (
- - + + setOpen(e.target.checked)} + /> + {open && ( + + )}
); } From d9f8e46ee7609b888a7d5a23daf21e3a0ca39ad5 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Mon, 25 May 2026 21:15:08 -0600 Subject: [PATCH 3/9] fix: resolve client-side node:fs import in mobile-nav - Add src/lib/version-client.ts with getClientVersionLabel() that uses only NEXT_PUBLIC_DISPATCH_VERSION (no Node.js fs imports) - mobile-nav.tsx now uses getClientVersionLabel to avoid bundling node:fs into the client bundle (Turbopack chunking error) --- src/app/layout.test.tsx | 4 ++++ src/components/mobile-nav.tsx | 4 ++-- src/lib/version-client.ts | 13 +++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 src/lib/version-client.ts diff --git a/src/app/layout.test.tsx b/src/app/layout.test.tsx index de64ef2..0e42764 100644 --- a/src/app/layout.test.tsx +++ b/src/app/layout.test.tsx @@ -17,6 +17,10 @@ vi.mock("@/lib/version", () => ({ getVersionLabel: () => versionLabel, })); +vi.mock("@/lib/version-client", () => ({ + getClientVersionLabel: () => versionLabel, +})); + vi.mock("@/components/theme-toggle", () => ({ ThemeToggle: function ThemeToggle() { return React.createElement("button", { "aria-label": "Switch to dark mode" }, "theme-toggle"); diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx index cb8a92b..1a7e7e4 100644 --- a/src/components/mobile-nav.tsx +++ b/src/components/mobile-nav.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import Link from "next/link"; -import { getVersionLabel } from "@/lib/version"; +import { getClientVersionLabel } from "@/lib/version-client"; const navLinks = [ { href: "/", label: "Overview" }, @@ -71,7 +71,7 @@ export function MobileNav() { ))}
- {getVersionLabel()} + {getClientVersionLabel()}
)} diff --git a/src/lib/version-client.ts b/src/lib/version-client.ts new file mode 100644 index 0000000..91049ac --- /dev/null +++ b/src/lib/version-client.ts @@ -0,0 +1,13 @@ +/** + * Client-safe version label. + * + * Uses `NEXT_PUBLIC_DISPATCH_VERSION` (injected at build time by + * next.config.js). Falls back to `"0.0.0"` when the env var is not set. + * + * This module must NOT import any Node.js built-ins (fs, path, etc.) so + * that it can be safely tree-shaken into client bundles. + */ + +export function getClientVersionLabel(): string { + return `v${process.env.NEXT_PUBLIC_DISPATCH_VERSION ?? "0.0.0"}`; +} From fbd8188bb8e751c711df0c0f0551ea65833ea594 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Mon, 25 May 2026 21:18:33 -0600 Subject: [PATCH 4/9] feat: add theme toggle to mobile nav panel - ThemeToggle now renders inside mobile nav footer alongside version label - Desktop header still has its own ThemeToggle (right-aligned via ml-auto) - Update test to query desktop toggle via .ml-auto selector instead of getByLabelText which would match both instances --- src/app/layout.test.tsx | 7 ++++--- src/components/mobile-nav.tsx | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/layout.test.tsx b/src/app/layout.test.tsx index 0e42764..69cf60a 100644 --- a/src/app/layout.test.tsx +++ b/src/app/layout.test.tsx @@ -128,13 +128,14 @@ describe("RootLayout app shell", () => { expect(screen.getByText("Automation")).toBeInTheDocument(); }); - it("renders theme toggle", () => { - render( + it("renders theme toggle in desktop header", () => { + const { container } = render( , ); - expect(screen.getByLabelText("Switch to dark mode")).toBeInTheDocument(); + const desktopToggle = container.querySelector(".ml-auto button[aria-label='Switch to dark mode']"); + expect(desktopToggle).toBeTruthy(); }); it("renders version label", () => { diff --git a/src/components/mobile-nav.tsx b/src/components/mobile-nav.tsx index 1a7e7e4..8a8834d 100644 --- a/src/components/mobile-nav.tsx +++ b/src/components/mobile-nav.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import { getClientVersionLabel } from "@/lib/version-client"; +import { ThemeToggle } from "./theme-toggle"; const navLinks = [ { href: "/", label: "Overview" }, @@ -72,6 +73,7 @@ export function MobileNav() { ))}
{getClientVersionLabel()} +
)} From ac475aaa5266121b0a6d59372b8647a1708537cf Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Mon, 25 May 2026 21:22:08 -0600 Subject: [PATCH 5/9] test: add test for theme toggle and version in mobile nav panel --- src/app/layout.test.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/app/layout.test.tsx b/src/app/layout.test.tsx index 69cf60a..f295436 100644 --- a/src/app/layout.test.tsx +++ b/src/app/layout.test.tsx @@ -128,6 +128,19 @@ describe("RootLayout app shell", () => { expect(screen.getByText("Automation")).toBeInTheDocument(); }); + it("mobile nav contains theme toggle and version label when open", () => { + const { container } = render( + + + , + ); + const checkbox = container.querySelector("#mobile-nav-toggle")!; + fireEvent.click(checkbox); + const mobileNav = container.querySelector("nav.border-t"); + expect(mobileNav?.querySelector("button[aria-label='Switch to dark mode']")).toBeTruthy(); + expect(mobileNav?.querySelector("span")).toBeTruthy(); + }); + it("renders theme toggle in desktop header", () => { const { container } = render( From 701a00a0f53ebe752aedf6506dbe27fccb03b215 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Mon, 25 May 2026 21:38:14 -0600 Subject: [PATCH 6/9] Centralize board column definitions in BOARD_COLUMNS constant - Add BOARD_COLUMNS as shared source of truth in src/types/index.ts - Derive STATUS_LABELS from BOARD_COLUMNS to prevent drift - Update KanbanBoard to use BOARD_COLUMNS instead of local COLUMNS - Update Projects page to use BOARD_COLUMNS with human-readable titles - Add tests for BOARD_COLUMNS canonical order and STATUS_LABELS derivation - Update ProjectsPage test to expect human-readable column titles --- src/app/projects/page.test.tsx | 6 ++--- src/app/projects/page.tsx | 10 +++---- src/components/kanban-board.tsx | 14 +++------- src/types/index.test.ts | 46 +++++++++++++++++++++++++++++++++ src/types/index.ts | 15 ++++++++++- 5 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 src/types/index.test.ts diff --git a/src/app/projects/page.test.tsx b/src/app/projects/page.test.tsx index 7e2c549..ba74733 100644 --- a/src/app/projects/page.test.tsx +++ b/src/app/projects/page.test.tsx @@ -78,11 +78,11 @@ describe("ProjectsPage five-column layout", () => { // Collect all text content from the rendered page const allText = extractAllText(page); - // STATUS_LABELS order: backlog, ready, in-progress, in-review, done + // BOARD_COLUMNS order: Backlog, Ready, In Progress, In Review, Done expect(allText.toLowerCase()).toContain("backlog"); expect(allText.toLowerCase()).toContain("ready"); - expect(allText.toLowerCase()).toContain("in-progress"); - expect(allText.toLowerCase()).toContain("in-review"); + expect(allText.toLowerCase()).toContain("in progress"); + expect(allText.toLowerCase()).toContain("in review"); expect(allText.toLowerCase()).toContain("done"); }); diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 5cc57ea..7f2a070 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -1,7 +1,7 @@ import { prisma } from "@/lib/prisma"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; -import { STATUS_LABELS } from "@/types"; +import { BOARD_COLUMNS, STATUS_LABELS } from "@/types"; import { getProjectIssueStatus, groupIssuesByProject } from "@/lib/projects"; export const dynamic = "force-dynamic"; @@ -85,14 +85,14 @@ export default async function ProjectsPage(_props: PageProps) { className="grid grid-cols-1 lg:grid-cols-5 gap-4" style={{ minWidth: "fit-content" }} > - {STATUS_LABELS.map((status) => { + {BOARD_COLUMNS.map((column) => { const statusIssues = project.issues.filter( - (i: any) => getProjectIssueStatus(i as any) === status + (i: any) => getProjectIssueStatus(i as any) === column.id ); return ( -
+

- {status.replace("status/", "")} + {column.title}

{statusIssues.length === 0 ? (

No issues

diff --git a/src/components/kanban-board.tsx b/src/components/kanban-board.tsx index 45d05cc..254ca60 100644 --- a/src/components/kanban-board.tsx +++ b/src/components/kanban-board.tsx @@ -21,18 +21,10 @@ import { import { KanbanColumn } from "./kanban-column"; import { IssueCard } from "./issue-card"; import { Button } from "@/components/ui/button"; -import { Issue, StatusLabel } from "@/types"; +import { BOARD_COLUMNS, Issue, StatusLabel } from "@/types"; import { getIssuesByStatus, getIssueStatus } from "@/lib/kanban"; import { authedFetch } from "@/lib/client-auth"; -const COLUMNS: { id: StatusLabel; title: string }[] = [ - { id: "status/backlog", title: "Backlog" }, - { id: "status/ready", title: "Ready" }, - { id: "status/in-progress", title: "In Progress" }, - { id: "status/in-review", title: "In Review" }, - { id: "status/done", title: "Done" }, -]; - const AUTO_REFRESH_INTERVAL_MS = 30_000; // 30 seconds // 10s debounce balances responsiveness with rate-limiting: long enough to batch // rapid drag-and-drop moves on the same repo into a single sync, short enough @@ -189,7 +181,7 @@ export const KanbanBoard = forwardRef(function const activeId = active.id as string; const overId = over.id as string; - const overColumn = COLUMNS.find((c) => c.id === overId); + const overColumn = BOARD_COLUMNS.find((c) => c.id === overId); if (!overColumn) { const overIssue = issuesRef.current.find((i) => i.id === overId); if (!overIssue) return; @@ -309,7 +301,7 @@ export const KanbanBoard = forwardRef(function className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4" style={{ minWidth: "fit-content" }} > - {COLUMNS.map((column) => { + {BOARD_COLUMNS.map((column) => { const columnIssues = getIssuesByStatus(issues, column.id); return ( { + it("has exactly five columns", () => { + expect(BOARD_COLUMNS).toHaveLength(5); + }); + + it("defines columns in canonical order", () => { + const expected: StatusLabel[] = [ + "status/backlog", + "status/ready", + "status/in-progress", + "status/in-review", + "status/done", + ]; + expect(BOARD_COLUMNS.map((c) => c.id)).toEqual(expected); + }); + + it("provides human-readable titles", () => { + const expectedTitles = ["Backlog", "Ready", "In Progress", "In Review", "Done"]; + expect(BOARD_COLUMNS.map((c) => c.title)).toEqual(expectedTitles); + }); + + it("has unique column ids", () => { + const ids = BOARD_COLUMNS.map((c) => c.id); + expect(new Set(ids).size).toBe(ids.length); + }); +}); + +describe("STATUS_LABELS derivation", () => { + it("is derived from BOARD_COLUMNS and stays in sync", () => { + expect(STATUS_LABELS).toEqual(BOARD_COLUMNS.map((c) => c.id)); + }); + + it("contains all five status labels", () => { + const expected: StatusLabel[] = [ + "status/backlog", + "status/ready", + "status/in-progress", + "status/in-review", + "status/done", + ]; + expect(STATUS_LABELS).toEqual(expected); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 4a4b17e..c21f18b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -99,7 +99,20 @@ export type PriorityLabel = "priority/p0" | "priority/p1" | "priority/p2" | "pri export type TypeLabel = "type/bug" | "type/feature" | "type/chore" | "type/research" | "type/security"; export type ProjectLabel = `project/${string}`; -export const STATUS_LABELS: StatusLabel[] = ["status/backlog", "status/ready", "status/in-progress", "status/in-review", "status/done"]; +export interface BoardColumn { + id: StatusLabel; + title: string; +} + +export const BOARD_COLUMNS: BoardColumn[] = [ + { id: "status/backlog", title: "Backlog" }, + { id: "status/ready", title: "Ready" }, + { id: "status/in-progress", title: "In Progress" }, + { id: "status/in-review", title: "In Review" }, + { id: "status/done", title: "Done" }, +]; + +export const STATUS_LABELS: StatusLabel[] = BOARD_COLUMNS.map((col) => col.id); export const PRIORITY_LABELS: PriorityLabel[] = ["priority/p0", "priority/p1", "priority/p2", "priority/p3"]; export const PROJECT_PREFIX = "project/"; export const AGENT_PREFIX = "agent/"; From 03376ce26f9bd377a86af16ef234f3c3405832a3 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Tue, 26 May 2026 08:36:32 -0600 Subject: [PATCH 7/9] Centralize visible issue filtering and Done retention - Add buildVisibleIssueWhere() shared helper in src/lib/issue-filters.ts - Add getDoneRetentionDays() with DISPATCH_DONE_RETENTION_DAYS support (default 7) - Update Board page to use the shared helper instead of inline logic - Update Projects page to use the shared helper instead of inline logic - Update Issues API route to use shared helper (was open-only, now applies done retention) - Add comprehensive tests for buildVisibleIssueWhere and getDoneRetentionDays - Add tests for GET /api/issues route covering filtering behavior Fixes #196 --- src/app/api/issues/route.test.ts | 148 +++++++++++++++++++++++++++++++ src/app/api/issues/route.ts | 7 +- src/app/board/page.tsx | 27 +----- src/app/projects/page.tsx | 25 ++---- src/lib/issue-filters.test.ts | 88 +++++++++++++++++- src/lib/issue-filters.ts | 34 +++++++ 6 files changed, 278 insertions(+), 51 deletions(-) create mode 100644 src/app/api/issues/route.test.ts diff --git a/src/app/api/issues/route.test.ts b/src/app/api/issues/route.test.ts new file mode 100644 index 0000000..b01add7 --- /dev/null +++ b/src/app/api/issues/route.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + findManyIssues: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + issue: { findMany: mocks.findManyIssues }, + }, +})); + +import { GET } from "./route"; + +function makeRequest(urlString: string) { + return GET(new Request(urlString)); +} + +describe("GET /api/issues — visible issue filtering", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.DISPATCH_DONE_RETENTION_DAYS; + mocks.findManyIssues.mockResolvedValue([]); + }); + + it("defaults to open issues + recently closed Done issues (7-day retention)", async () => { + await makeRequest("http://localhost/api/issues"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.OR).toBeDefined(); + expect(Array.isArray(call.where.OR)).toBe(true); + expect(call.where.OR.length).toBe(2); + expect(call.where.OR[0]).toEqual({ state: "open" }); + expect(call.where.OR[1].state).toBe("closed"); + expect(call.where.OR[1].labels.has).toBe("status/done"); + expect(call.where.OR[1].closedAt.gte).toBeDefined(); + }); + + it("includes all issues when includeClosed=true", async () => { + await makeRequest("http://localhost/api/issues?includeClosed=true"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.OR).toBeUndefined(); + expect(call.where.state).toBeUndefined(); + }); + + it("respects DISPATCH_DONE_RETENTION_DAYS environment variable", async () => { + process.env.DISPATCH_DONE_RETENTION_DAYS = "30"; + await makeRequest("http://localhost/api/issues"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.OR).toBeDefined(); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 30); + expect(call.where.OR[1].closedAt.gte).toBeTruthy(); + }); + + it("filters by repository", async () => { + await makeRequest("http://localhost/api/issues?repo=myorg/myrepo"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.repository).toEqual({ enabled: true, fullName: "myorg/myrepo" }); + }); + + it("filters by agent label", async () => { + await makeRequest("http://localhost/api/issues?agent=agent/alpha"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels).toEqual({ has: "agent/alpha" }); + }); + + it("filters by owner label", async () => { + await makeRequest("http://localhost/api/issues?owner=owner/alice"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels).toEqual({ has: "owner/alice" }); + }); + + it("filters by priority label", async () => { + await makeRequest("http://localhost/api/issues?priority=priority/p1"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels).toEqual({ has: "priority/p1" }); + }); + + it("filters by project label", async () => { + await makeRequest("http://localhost/api/issues?project=api"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels).toEqual({ has: "project/api" }); + }); + + it("filters by decomposed status", async () => { + await makeRequest("http://localhost/api/issues?decomposed=true"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.decomposed).toBe(true); + }); + + it("orders by updatedAt descending", async () => { + await makeRequest("http://localhost/api/issues"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.orderBy).toEqual({ updatedAt: "desc" }); + }); + + it("includes repository relation", async () => { + await makeRequest("http://localhost/api/issues"); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.include).toEqual({ repository: true }); + }); + + it("combines agent, owner, and priority label filters", async () => { + await makeRequest( + "http://localhost/api/issues?agent=agent/alpha&owner=owner/alice&priority=priority/p1" + ); + + const call = mocks.findManyIssues.mock.calls[0][0]; + expect(call.where.labels).toEqual({ hasEvery: ["agent/alpha", "owner/alice", "priority/p1"] }); + }); + + it("returns JSON array of issues", async () => { + const expectedIssues = [ + { id: "1", title: "Test", state: "open" }, + { id: "2", title: "Another", state: "closed" }, + ]; + mocks.findManyIssues.mockResolvedValueOnce(expectedIssues); + + const res = await makeRequest("http://localhost/api/issues"); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body).toEqual(expectedIssues); + }); + + it("returns 500 on database error", async () => { + mocks.findManyIssues.mockRejectedValueOnce(new Error("db connection failed")); + + const res = await makeRequest("http://localhost/api/issues"); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body.error).toBe("Failed to fetch issues"); + }); +}); diff --git a/src/app/api/issues/route.ts b/src/app/api/issues/route.ts index f8a69f1..059c721 100644 --- a/src/app/api/issues/route.ts +++ b/src/app/api/issues/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; -import { buildLabelWhere, toProjectLabel } from "@/lib/issue-filters"; +import { buildLabelWhere, buildVisibleIssueWhere, toProjectLabel } from "@/lib/issue-filters"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); @@ -15,10 +15,7 @@ export async function GET(request: Request) { try { const where: Record = { repository: { enabled: true } }; - // Default to open issues only; include closed when explicitly requested - if (includeClosed !== "true") { - where.state = "open"; - } + buildVisibleIssueWhere(where, { includeClosed: includeClosed === "true" }); if (repo) { where.repository = { ...(where.repository as object), fullName: repo }; diff --git a/src/app/board/page.tsx b/src/app/board/page.tsx index affe055..5d43ecf 100644 --- a/src/app/board/page.tsx +++ b/src/app/board/page.tsx @@ -4,35 +4,14 @@ import { FilterBar } from "@/components/filter-bar"; import { SyncIssuesButton } from "@/components/sync-issues-button"; import { Card, CardContent } from "@/components/ui/card"; import { getTrackedRepos } from "@/lib/config"; -import { buildLabelWhere, discoverLabelFilterOptions } from "@/lib/issue-filters"; +import { buildLabelWhere, buildVisibleIssueWhere, discoverLabelFilterOptions, getDoneRetentionDays } from "@/lib/issue-filters"; export const dynamic = "force-dynamic"; -const DONE_RETENTION_DAYS = parseInt(process.env.DISPATCH_DONE_RETENTION_DAYS ?? "7", 10) || 7; - -function getDoneRetentionCutoff(): Date { - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - DONE_RETENTION_DAYS); - return cutoff; -} - async function getIssues(repo?: string, agent?: string, owner?: string, priority?: string, includeClosed?: boolean) { const where: Record = { repository: { enabled: true } }; - if (includeClosed === true) { - // Show all issues regardless of state or age - // No state filter — let Kanban grouping sort them by status label - } else { - // Default: open issues + recently closed Done issues (within retention window) - where.OR = [ - { state: "open" }, - { - state: "closed", - labels: { has: "status/done" }, - closedAt: { gte: getDoneRetentionCutoff() }, - }, - ]; - } + buildVisibleIssueWhere(where, { includeClosed }); if (repo) where.repository = { ...(where.repository as object), fullName: repo }; @@ -132,7 +111,7 @@ export default async function BoardPage({ searchParams }: PageProps) { {syncStatus.cachedIssueCount > 0 ? "Clear or adjust the filters to see synced issues." : syncStatus.trackedRepoCount > 0 - ? "Click Sync Issues to import open GitHub issues from tracked repositories. Recently completed Done issues are shown for " + DONE_RETENTION_DAYS + " days." + ? `Click Sync Issues to import open GitHub issues from tracked repositories. Recently completed Done issues are shown for ${getDoneRetentionDays()} days.` : "No tracked repositories are configured yet. Add tracked repositories before syncing issues."}

diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 7f2a070..80552ad 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { BOARD_COLUMNS, STATUS_LABELS } from "@/types"; +import { buildVisibleIssueWhere } from "@/lib/issue-filters"; import { getProjectIssueStatus, groupIssuesByProject } from "@/lib/projects"; export const dynamic = "force-dynamic"; @@ -10,30 +11,14 @@ interface PageProps { searchParams: Promise>; } -const DONE_RETENTION_DAYS = parseInt(process.env.DISPATCH_DONE_RETENTION_DAYS ?? "7", 10) || 7; - -function getDoneRetentionCutoff(): Date { - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - DONE_RETENTION_DAYS); - return cutoff; -} - async function getProjects() { // Fetch open issues + recently closed Done issues (within retention window) // to match Board page retention semantics. - const doneCutoff = getDoneRetentionCutoff(); + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where); + const issues = await prisma.issue.findMany({ - where: { - repository: { enabled: true }, - OR: [ - { state: "open" }, - { - state: "closed", - labels: { has: "status/done" }, - closedAt: { gte: doneCutoff }, - }, - ], - }, + where, include: { repository: true }, }); diff --git a/src/lib/issue-filters.test.ts b/src/lib/issue-filters.test.ts index 4272855..1fba8d1 100644 --- a/src/lib/issue-filters.test.ts +++ b/src/lib/issue-filters.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { buildLabelWhere, discoverLabelFilterOptions, toProjectLabel } from "./issue-filters"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { buildLabelWhere, discoverLabelFilterOptions, toProjectLabel, buildVisibleIssueWhere, getDoneRetentionDays, DEFAULT_DONE_RETENTION_DAYS } from "./issue-filters"; describe("issue filter helpers", () => { it("discovers sorted agent and owner options from labels only", () => { @@ -41,3 +41,87 @@ describe("issue filter helpers", () => { expect(toProjectLabel("")).toBeUndefined(); }); }); + +describe("buildVisibleIssueWhere", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.DISPATCH_DONE_RETENTION_DAYS; + }); + + it("defaults to open issues + recently closed Done issues (7-day retention)", () => { + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where); + + const or = where.OR as Array>; + expect(or).toBeDefined(); + expect(Array.isArray(or)).toBe(true); + expect(or.length).toBe(2); + expect(or[0]).toEqual({ state: "open" }); + const branch1 = or[1] as Record; + expect(branch1.state).toBe("closed"); + expect((branch1.labels as Record).has).toBe("status/done"); + expect((branch1.closedAt as Record).gte).toBeDefined(); + }); + + it("shows all issues when includeClosed is true", () => { + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where, { includeClosed: true }); + + expect(where.OR).toBeUndefined(); + expect(where.state).toBeUndefined(); + }); + + it("respects custom doneRetentionDays option", () => { + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where, { includeClosed: false, doneRetentionDays: 30 }); + + const or = where.OR as Array>; + expect(or).toBeDefined(); + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 30); + const branch1 = or[1] as Record; + expect((branch1.closedAt as Record).gte).toBeTruthy(); + }); + + it("does not set OR when includeClosed is true", () => { + const where: Record = { repository: { enabled: true }, state: "open" }; + buildVisibleIssueWhere(where, { includeClosed: true }); + + expect(where.OR).toBeUndefined(); + }); + + it("preserves existing where clauses while adding OR", () => { + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where); + + expect(where.repository).toEqual({ enabled: true }); + expect(where.OR).toBeDefined(); + }); +}); + +describe("getDoneRetentionDays", () => { + beforeEach(() => { + delete process.env.DISPATCH_DONE_RETENTION_DAYS; + }); + + it("returns default of 7 when env var is not set", () => { + expect(getDoneRetentionDays()).toBe(DEFAULT_DONE_RETENTION_DAYS); + expect(getDoneRetentionDays()).toBe(7); + }); + + it("respects DISPATCH_DONE_RETENTION_DAYS environment variable", () => { + process.env.DISPATCH_DONE_RETENTION_DAYS = "30"; + expect(getDoneRetentionDays()).toBe(30); + }); + + it("clamps invalid values to default", () => { + process.env.DISPATCH_DONE_RETENTION_DAYS = "abc"; + expect(getDoneRetentionDays()).toBe(DEFAULT_DONE_RETENTION_DAYS); + + process.env.DISPATCH_DONE_RETENTION_DAYS = "0"; + expect(getDoneRetentionDays()).toBe(DEFAULT_DONE_RETENTION_DAYS); + + process.env.DISPATCH_DONE_RETENTION_DAYS = "-5"; + expect(getDoneRetentionDays()).toBe(DEFAULT_DONE_RETENTION_DAYS); + }); +}); diff --git a/src/lib/issue-filters.ts b/src/lib/issue-filters.ts index 6a23ffe..0e2fadb 100644 --- a/src/lib/issue-filters.ts +++ b/src/lib/issue-filters.ts @@ -1,5 +1,10 @@ import { AGENT_PREFIX, isAgentLabel, isOwnerLabel, OWNER_PREFIX } from "@/types"; +export interface VisibleIssueWhereOptions { + includeClosed?: boolean; + doneRetentionDays?: number; +} + export interface LabelFilterOptions { agents: string[]; owners: string[]; @@ -35,6 +40,35 @@ export function toProjectLabel(project: string | null | undefined) { return project ? `project/${project}` : undefined; } +export const DEFAULT_DONE_RETENTION_DAYS = 7; + +export function getDoneRetentionDays(): number { + const parsed = parseInt(process.env.DISPATCH_DONE_RETENTION_DAYS ?? String(DEFAULT_DONE_RETENTION_DAYS), 10); + return parsed > 0 ? parsed : DEFAULT_DONE_RETENTION_DAYS; +} + +export function buildVisibleIssueWhere(where: Record, options?: VisibleIssueWhereOptions): void { + const { includeClosed = false, doneRetentionDays } = options ?? {}; + const retentionDays = doneRetentionDays ?? getDoneRetentionDays(); + + if (includeClosed) { + // Show all issues regardless of state or age — no state filter + return; + } + + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - retentionDays); + + where.OR = [ + { state: "open" }, + { + state: "closed", + labels: { has: "status/done" }, + closedAt: { gte: cutoff }, + }, + ]; +} + export const LABEL_FILTER_HELP = { agent: `Agent filters use ${AGENT_PREFIX} labels on synced GitHub issues.`, owner: `Owner filters use ${OWNER_PREFIX} labels on synced GitHub issues.`, From 8b9697ef0ec72a2077198c38dcdea8d324d90cb5 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Tue, 26 May 2026 08:48:15 -0600 Subject: [PATCH 8/9] docs: document DISPATCH_DONE_RETENTION_DAYS in .env.example --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 87a3c3c..0098e9e 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,9 @@ DISPATCH_AGENT_TOKEN="your_agent_token_here" # Closed issue cache retention (days) — closed issues older than this are pruned via POST /api/issues/prune-closed # DISPATCH_CLOSED_ISSUE_RETENTION_DAYS=30 +# Done issue visibility retention (days) — recently closed Done issues appear in Board/Projects/API for this window; old Done issues are hidden by default (default: 7) +# DISPATCH_DONE_RETENTION_DAYS=7 + # NextAuth.js secret — required for OIDC mode (JWT signing). # Generate a secure random string: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # NEXTAUTH_SECRET="your_nextauth_secret_here" From 4af49f99d43889ee0ddb763c810259cf583b9ff1 Mon Sep 17 00:00:00 2001 From: Jory Irving Date: Tue, 26 May 2026 08:53:37 -0600 Subject: [PATCH 9/9] test: verify exact cutoff date in buildVisibleIssueWhere tests --- src/lib/issue-filters.test.ts | 37 ++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/lib/issue-filters.test.ts b/src/lib/issue-filters.test.ts index 1fba8d1..1c65255 100644 --- a/src/lib/issue-filters.test.ts +++ b/src/lib/issue-filters.test.ts @@ -49,6 +49,9 @@ describe("buildVisibleIssueWhere", () => { }); it("defaults to open issues + recently closed Done issues (7-day retention)", () => { + const now = new Date(); + const expectedCutoff = new Date(); + expectedCutoff.setDate(expectedCutoff.getDate() - 7); const where: Record = { repository: { enabled: true } }; buildVisibleIssueWhere(where); @@ -60,7 +63,10 @@ describe("buildVisibleIssueWhere", () => { const branch1 = or[1] as Record; expect(branch1.state).toBe("closed"); expect((branch1.labels as Record).has).toBe("status/done"); - expect((branch1.closedAt as Record).gte).toBeDefined(); + const gte = (branch1.closedAt as Record).gte as Date; + expect(gte).toBeInstanceOf(Date); + expect(gte.getTime()).toBeGreaterThanOrEqual(expectedCutoff.getTime() - 1000); + expect(gte.getTime()).toBeLessThanOrEqual(now.getTime()); }); it("shows all issues when includeClosed is true", () => { @@ -71,16 +77,37 @@ describe("buildVisibleIssueWhere", () => { expect(where.state).toBeUndefined(); }); - it("respects custom doneRetentionDays option", () => { + it("respects custom doneRetentionDays option with exact cutoff", () => { + const now = new Date(); + const expectedCutoff = new Date(); + expectedCutoff.setDate(expectedCutoff.getDate() - 30); const where: Record = { repository: { enabled: true } }; buildVisibleIssueWhere(where, { includeClosed: false, doneRetentionDays: 30 }); const or = where.OR as Array>; expect(or).toBeDefined(); - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 30); const branch1 = or[1] as Record; - expect((branch1.closedAt as Record).gte).toBeTruthy(); + const gte = (branch1.closedAt as Record).gte as Date; + expect(gte).toBeInstanceOf(Date); + expect(gte.getTime()).toBeGreaterThanOrEqual(expectedCutoff.getTime() - 1000); + expect(gte.getTime()).toBeLessThanOrEqual(now.getTime()); + }); + + it("respects DISPATCH_DONE_RETENTION_DAYS env var with exact cutoff", () => { + const now = new Date(); + const expectedCutoff = new Date(); + expectedCutoff.setDate(expectedCutoff.getDate() - 14); + process.env.DISPATCH_DONE_RETENTION_DAYS = "14"; + const where: Record = { repository: { enabled: true } }; + buildVisibleIssueWhere(where); + + const or = where.OR as Array>; + const branch1 = or[1] as Record; + const gte = (branch1.closedAt as Record).gte as Date; + expect(gte).toBeInstanceOf(Date); + expect(gte.getTime()).toBeGreaterThanOrEqual(expectedCutoff.getTime() - 1000); + expect(gte.getTime()).toBeLessThanOrEqual(now.getTime()); + delete process.env.DISPATCH_DONE_RETENTION_DAYS; }); it("does not set OR when includeClosed is true", () => {