From c09420dc6837d28cbbf06dfd7b9f9d12098ecf4c Mon Sep 17 00:00:00 2001 From: zenchantlive Date: Wed, 11 Feb 2026 17:55:26 -0800 Subject: [PATCH] Add tracer-bullet Kanban baseline with live issues read path --- next-env.d.ts | 4 +- package.json | 2 +- src/app/page.tsx | 12 +- src/components/kanban/kanban-board-client.tsx | 178 ++++++++++++++++++ src/components/kanban/kanban-board.tsx | 39 ++++ src/components/kanban/kanban-card.tsx | 47 +++++ src/components/kanban/kanban-controls.tsx | 59 ++++++ src/components/kanban/kanban-detail.tsx | 50 +++++ src/components/kanban/kanban-page.tsx | 60 ++++++ src/components/shared/chip.tsx | 23 +++ src/components/shared/stat-pill.tsx | 21 +++ src/lib/kanban.ts | 99 ++++++++++ src/lib/read-issues.ts | 33 ++++ tests/lib/kanban.test.ts | 88 +++++++++ tests/lib/read-issues.test.ts | 41 ++++ 15 files changed, 748 insertions(+), 8 deletions(-) create mode 100644 src/components/kanban/kanban-board-client.tsx create mode 100644 src/components/kanban/kanban-board.tsx create mode 100644 src/components/kanban/kanban-card.tsx create mode 100644 src/components/kanban/kanban-controls.tsx create mode 100644 src/components/kanban/kanban-detail.tsx create mode 100644 src/components/kanban/kanban-page.tsx create mode 100644 src/components/shared/chip.tsx create mode 100644 src/components/shared/stat-pill.tsx create mode 100644 src/lib/kanban.ts create mode 100644 src/lib/read-issues.ts create mode 100644 tests/lib/kanban.test.ts create mode 100644 tests/lib/read-issues.test.ts diff --git a/next-env.d.ts b/next-env.d.ts index e292b42..830fb59 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,4 +1,6 @@ /// /// +/// -// NOTE: This file is auto-generated by Next.js type tooling. +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index c1860d3..d74369c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs" + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/lib/kanban.test.ts && node --import tsx --test tests/lib/read-issues.test.ts && node --test tests/guards/no-direct-jsonl-write.test.mjs" }, "dependencies": { "next": "15.5.7", diff --git a/src/app/page.tsx b/src/app/page.tsx index 379ad7a..a3d12e7 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,7 @@ -export default function Page() { - return ( -
-

BeadBoard

-
- ); +import { KanbanPage } from '../components/kanban/kanban-page'; +import { readIssuesFromDisk } from '../lib/read-issues'; + +export default async function Page() { + const issues = await readIssuesFromDisk(); + return ; } diff --git a/src/components/kanban/kanban-board-client.tsx b/src/components/kanban/kanban-board-client.tsx new file mode 100644 index 0000000..3765d92 --- /dev/null +++ b/src/components/kanban/kanban-board-client.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import { + KANBAN_STATUSES, + buildKanbanColumns, + buildKanbanStats, + filterKanbanIssues, + type KanbanStatus, +} from '../../lib/kanban'; +import type { BeadIssue } from '../../lib/types'; + +const STATUS_LABELS: Record = { + open: 'Open', + in_progress: 'In Progress', + blocked: 'Blocked', + deferred: 'Deferred', + closed: 'Done', +}; + +const STATUS_COLORS: Record = { + open: '#3b82f6', + in_progress: '#f59e0b', + blocked: '#ef4444', + deferred: '#6b7280', + closed: '#22c55e', +}; + +function formatDate(value: string | null): string { + if (!value) { + return 'N/A'; + } + + const date = new Date(value); + if (Number.isNaN(date.valueOf())) { + return value; + } + + return date.toLocaleString(); +} + +export function KanbanBoardClient({ issues, sourcePath }: { issues: BeadIssue[]; sourcePath: string }) { + const [query, setQuery] = useState(''); + const [type, setType] = useState('all'); + const [priority, setPriority] = useState('all'); + const [showClosed, setShowClosed] = useState(true); + const [selectedIssue, setSelectedIssue] = useState(null); + + const filteredIssues = useMemo( + () => filterKanbanIssues(issues, { query, type, priority, showClosed }), + [issues, query, type, priority, showClosed], + ); + + const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]); + const stats = useMemo(() => buildKanbanStats(filteredIssues), [filteredIssues]); + const visibleStatuses = showClosed ? KANBAN_STATUSES : KANBAN_STATUSES.filter((status) => status !== 'closed'); + const issueTypes = useMemo( + () => ['all', ...Array.from(new Set(issues.map((issue) => issue.issue_type))).sort()], + [issues], + ); + + return ( +
+
+

BeadBoard

+
Source: {sourcePath}
+ +
+ setQuery(e.target.value)} placeholder="Search beads" style={{ padding: '6px 8px' }} /> + + + +
+ +
+ Total {stats.total} + Open {stats.open} + Active {stats.active} + Blocked {stats.blocked} + Done {stats.done} + P0 {stats.p0} +
+
+ +
+ {visibleStatuses.map((status) => ( +
+
+ {STATUS_LABELS[status]} + {columns[status].length} +
+ +
+ {columns[status].map((issue) => ( + + ))} +
+
+ ))} +
+ + {selectedIssue ? ( + <> +
setSelectedIssue(null)} /> + + + ) : null} +
+ ); +} diff --git a/src/components/kanban/kanban-board.tsx b/src/components/kanban/kanban-board.tsx new file mode 100644 index 0000000..34eea9d --- /dev/null +++ b/src/components/kanban/kanban-board.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { KANBAN_STATUSES } from '../../lib/kanban'; +import type { BeadIssue } from '../../lib/types'; + +import { KanbanCard } from './kanban-card'; + +interface KanbanBoardProps { + columns: Record<(typeof KANBAN_STATUSES)[number], BeadIssue[]>; + selectedIssueId: string | null; + onSelect: (issue: BeadIssue) => void; +} + +export function KanbanBoard({ columns, selectedIssueId, onSelect }: KanbanBoardProps) { + return ( +
+ {KANBAN_STATUSES.map((status) => ( +
+
+ {status} + {columns[status].length} +
+
+ {columns[status].map((issue) => ( + + ))} +
+
+ ))} +
+ ); +} diff --git a/src/components/kanban/kanban-card.tsx b/src/components/kanban/kanban-card.tsx new file mode 100644 index 0000000..04df80b --- /dev/null +++ b/src/components/kanban/kanban-card.tsx @@ -0,0 +1,47 @@ +'use client'; + +import type { BeadIssue } from '../../lib/types'; + +import { Chip } from '../shared/chip'; + +interface KanbanCardProps { + issue: BeadIssue; + selected: boolean; + onSelect: (issue: BeadIssue) => void; +} + +export function KanbanCard({ issue, selected, onSelect }: KanbanCardProps) { + return ( + + ); +} diff --git a/src/components/kanban/kanban-controls.tsx b/src/components/kanban/kanban-controls.tsx new file mode 100644 index 0000000..cedefee --- /dev/null +++ b/src/components/kanban/kanban-controls.tsx @@ -0,0 +1,59 @@ +'use client'; + +import type { KanbanFilterOptions, KanbanStats } from '../../lib/kanban'; + +import { StatPill } from '../shared/stat-pill'; + +interface KanbanControlsProps { + filters: KanbanFilterOptions; + stats: KanbanStats; + onFiltersChange: (filters: KanbanFilterOptions) => void; +} + +export function KanbanControls({ filters, stats, onFiltersChange }: KanbanControlsProps) { + return ( +
+
+ onFiltersChange({ ...filters, query: event.target.value })} + placeholder="Search by id/title/labels" + style={{ flex: 1, minWidth: 260, borderRadius: 10, border: '1px solid #cbd5e1', padding: '0.5rem 0.6rem' }} + /> + onFiltersChange({ ...filters, type: event.target.value })} + placeholder="Type (task/bug/feature)" + style={{ width: 190, borderRadius: 10, border: '1px solid #cbd5e1', padding: '0.5rem 0.6rem' }} + /> + onFiltersChange({ ...filters, priority: event.target.value })} + placeholder="Priority" + style={{ width: 110, borderRadius: 10, border: '1px solid #cbd5e1', padding: '0.5rem 0.6rem' }} + /> + +
+
+ + + + + + +
+
+ ); +} diff --git a/src/components/kanban/kanban-detail.tsx b/src/components/kanban/kanban-detail.tsx new file mode 100644 index 0000000..954d47d --- /dev/null +++ b/src/components/kanban/kanban-detail.tsx @@ -0,0 +1,50 @@ +'use client'; + +import type { BeadIssue } from '../../lib/types'; + +import { Chip } from '../shared/chip'; + +interface KanbanDetailProps { + issue: BeadIssue | null; +} + +export function KanbanDetail({ issue }: KanbanDetailProps) { + if (!issue) { + return ( + + ); + } + + return ( + + ); +} diff --git a/src/components/kanban/kanban-page.tsx b/src/components/kanban/kanban-page.tsx new file mode 100644 index 0000000..a2e91f9 --- /dev/null +++ b/src/components/kanban/kanban-page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useMemo, useState } from 'react'; + +import type { KanbanFilterOptions } from '../../lib/kanban'; +import { buildKanbanColumns, buildKanbanStats, filterKanbanIssues } from '../../lib/kanban'; +import type { BeadIssue } from '../../lib/types'; + +import { KanbanBoard } from './kanban-board'; +import { KanbanControls } from './kanban-controls'; +import { KanbanDetail } from './kanban-detail'; + +interface KanbanPageProps { + issues: BeadIssue[]; +} + +export function KanbanPage({ issues }: KanbanPageProps) { + const [filters, setFilters] = useState({ + query: '', + type: '', + priority: '', + showClosed: false, + }); + const [selectedIssueId, setSelectedIssueId] = useState(issues[0]?.id ?? null); + + const filteredIssues = useMemo(() => filterKanbanIssues(issues, filters), [issues, filters]); + const columns = useMemo(() => buildKanbanColumns(filteredIssues), [filteredIssues]); + const stats = useMemo(() => buildKanbanStats(filteredIssues), [filteredIssues]); + + const selectedIssue = useMemo( + () => filteredIssues.find((issue) => issue.id === selectedIssueId) ?? filteredIssues[0] ?? null, + [filteredIssues, selectedIssueId], + ); + + return ( +
+
+

BeadBoard Kanban

+

Tracer Bullet 1 baseline from live `.beads/issues.jsonl`

+
+ +
+ setSelectedIssueId(issue.id)} + /> + +
+
+ ); +} diff --git a/src/components/shared/chip.tsx b/src/components/shared/chip.tsx new file mode 100644 index 0000000..b5150f8 --- /dev/null +++ b/src/components/shared/chip.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react'; + +interface ChipProps { + children: ReactNode; +} + +export function Chip({ children }: ChipProps) { + return ( + + {children} + + ); +} diff --git a/src/components/shared/stat-pill.tsx b/src/components/shared/stat-pill.tsx new file mode 100644 index 0000000..c76422c --- /dev/null +++ b/src/components/shared/stat-pill.tsx @@ -0,0 +1,21 @@ +interface StatPillProps { + label: string; + value: number; +} + +export function StatPill({ label, value }: StatPillProps) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/lib/kanban.ts b/src/lib/kanban.ts new file mode 100644 index 0000000..441a49a --- /dev/null +++ b/src/lib/kanban.ts @@ -0,0 +1,99 @@ +import type { BeadIssue } from './types'; + +export const KANBAN_STATUSES = ['open', 'in_progress', 'blocked', 'deferred', 'closed'] as const; + +export type KanbanStatus = (typeof KANBAN_STATUSES)[number]; + +export type KanbanColumns = Record; + +export interface KanbanFilterOptions { + query?: string; + type?: string; + priority?: string; + showClosed?: boolean; +} + +export interface KanbanStats { + total: number; + open: number; + active: number; + blocked: number; + done: number; + p0: number; +} + +function isKanbanStatus(status: string): status is KanbanStatus { + return KANBAN_STATUSES.includes(status as KanbanStatus); +} + +function issueSort(a: BeadIssue, b: BeadIssue): number { + const priorityDiff = a.priority - b.priority; + if (priorityDiff !== 0) { + return priorityDiff; + } + + return b.updated_at.localeCompare(a.updated_at); +} + +export function filterKanbanIssues(issues: BeadIssue[], filters: KanbanFilterOptions): BeadIssue[] { + const query = (filters.query ?? '').trim().toLowerCase(); + const type = (filters.type ?? '').trim().toLowerCase(); + const priority = (filters.priority ?? '').trim(); + const showClosed = filters.showClosed ?? false; + + return issues.filter((issue) => { + if (!showClosed && issue.status === 'closed') { + return false; + } + + if (query) { + const haystack = [issue.id, issue.title, issue.description ?? '', issue.assignee ?? '', issue.labels.join(' ')].join(' ').toLowerCase(); + if (!haystack.includes(query)) { + return false; + } + } + + if (type && issue.issue_type.toLowerCase() !== type) { + return false; + } + + if (priority && String(issue.priority) !== priority) { + return false; + } + + return true; + }); +} + +export function buildKanbanColumns(issues: BeadIssue[]): KanbanColumns { + const columns = { + open: [], + in_progress: [], + blocked: [], + deferred: [], + closed: [], + } as KanbanColumns; + + for (const issue of issues) { + if (isKanbanStatus(issue.status)) { + columns[issue.status].push(issue); + } + } + + for (const status of KANBAN_STATUSES) { + columns[status].sort(issueSort); + } + + return columns; +} + +export function buildKanbanStats(issues: BeadIssue[]): KanbanStats { + return { + total: issues.length, + open: issues.filter((x) => x.status === 'open').length, + active: issues.filter((x) => x.status === 'in_progress').length, + blocked: issues.filter((x) => x.status === 'blocked').length, + done: issues.filter((x) => x.status === 'closed').length, + p0: issues.filter((x) => x.priority === 0).length, + }; +} diff --git a/src/lib/read-issues.ts b/src/lib/read-issues.ts new file mode 100644 index 0000000..8e5412f --- /dev/null +++ b/src/lib/read-issues.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { parseIssuesJsonl } from './parser'; +import { canonicalizeWindowsPath } from './pathing'; +import type { BeadIssue } from './types'; + +export interface ReadIssuesOptions { + projectRoot?: string; + includeTombstones?: boolean; +} + +export function resolveIssuesJsonlPath(projectRoot: string = process.cwd()): string { + const absolute = path.resolve(projectRoot, '.beads', 'issues.jsonl'); + return canonicalizeWindowsPath(absolute); +} + +export async function readIssuesFromDisk(options: ReadIssuesOptions = {}): Promise { + const issuesPath = resolveIssuesJsonlPath(options.projectRoot); + + try { + const jsonl = await fs.readFile(issuesPath, 'utf8'); + return parseIssuesJsonl(jsonl, { + includeTombstones: options.includeTombstones ?? false, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + + throw error; + } +} diff --git a/tests/lib/kanban.test.ts b/tests/lib/kanban.test.ts new file mode 100644 index 0000000..b97efab --- /dev/null +++ b/tests/lib/kanban.test.ts @@ -0,0 +1,88 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { BeadIssue } from '../../src/lib/types'; +import { + KANBAN_STATUSES, + buildKanbanColumns, + buildKanbanStats, + filterKanbanIssues, +} from '../../src/lib/kanban'; + +function issue(overrides: Partial): BeadIssue { + return { + id: overrides.id ?? 'bb-x', + title: overrides.title ?? 'Issue', + description: overrides.description ?? null, + status: overrides.status ?? 'open', + priority: overrides.priority ?? 2, + issue_type: overrides.issue_type ?? 'task', + assignee: overrides.assignee ?? null, + owner: overrides.owner ?? null, + labels: overrides.labels ?? [], + dependencies: overrides.dependencies ?? [], + created_at: overrides.created_at ?? '2026-02-12T00:00:00Z', + updated_at: overrides.updated_at ?? '2026-02-12T00:00:00Z', + closed_at: overrides.closed_at ?? null, + close_reason: overrides.close_reason ?? null, + closed_by_session: overrides.closed_by_session ?? null, + created_by: overrides.created_by ?? null, + due_at: overrides.due_at ?? null, + estimated_minutes: overrides.estimated_minutes ?? null, + external_ref: overrides.external_ref ?? null, + metadata: overrides.metadata ?? {}, + }; +} + +test('filterKanbanIssues filters by query/type/priority and closed visibility', () => { + const issues = [ + issue({ id: 'bb-1', title: 'OAuth integration', labels: ['security'], status: 'open', priority: 1, issue_type: 'feature' }), + issue({ id: 'bb-2', title: 'Fix timezone bug', status: 'in_progress', priority: 0, issue_type: 'bug' }), + issue({ id: 'bb-3', title: 'Done task', status: 'closed', priority: 2, issue_type: 'task' }), + ]; + + const filtered = filterKanbanIssues(issues, { + query: 'oauth', + type: 'feature', + priority: '1', + showClosed: false, + }); + + assert.equal(filtered.length, 1); + assert.equal(filtered[0].id, 'bb-1'); +}); + +test('buildKanbanColumns groups by core statuses and sorts by priority ascending', () => { + const issues = [ + issue({ id: 'bb-1', status: 'open', priority: 2 }), + issue({ id: 'bb-2', status: 'open', priority: 0 }), + issue({ id: 'bb-3', status: 'blocked', priority: 1 }), + issue({ id: 'bb-4', status: 'pinned', priority: 1 }), + ]; + + const columns = buildKanbanColumns(issues); + + assert.deepEqual(Object.keys(columns), KANBAN_STATUSES); + assert.deepEqual(columns.open.map((x) => x.id), ['bb-2', 'bb-1']); + assert.deepEqual(columns.blocked.map((x) => x.id), ['bb-3']); + assert.equal(columns.closed.length, 0); +}); + +test('buildKanbanStats reports total/open/active/blocked/done/p0', () => { + const issues = [ + issue({ status: 'open', priority: 0 }), + issue({ status: 'open', priority: 2 }), + issue({ status: 'in_progress', priority: 1 }), + issue({ status: 'blocked', priority: 1 }), + issue({ status: 'closed', priority: 3 }), + ]; + + const stats = buildKanbanStats(issues); + + assert.equal(stats.total, 5); + assert.equal(stats.open, 2); + assert.equal(stats.active, 1); + assert.equal(stats.blocked, 1); + assert.equal(stats.done, 1); + assert.equal(stats.p0, 1); +}); diff --git a/tests/lib/read-issues.test.ts b/tests/lib/read-issues.test.ts new file mode 100644 index 0000000..244ac7c --- /dev/null +++ b/tests/lib/read-issues.test.ts @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { readIssuesFromDisk, resolveIssuesJsonlPath } from '../../src/lib/read-issues'; +import { sameWindowsPath } from '../../src/lib/pathing'; + +test('resolveIssuesJsonlPath appends .beads/issues.jsonl using windows-safe pathing', () => { + const resolved = resolveIssuesJsonlPath('C:/Repo/Project'); + assert.equal(sameWindowsPath(resolved, 'C:/Repo/Project/.beads/issues.jsonl'), true); +}); + +test('readIssuesFromDisk parses JSONL issues from disk', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-')); + const beadsDir = path.join(root, '.beads'); + const issuesPath = path.join(beadsDir, 'issues.jsonl'); + + await fs.mkdir(beadsDir, { recursive: true }); + await fs.writeFile( + issuesPath, + [ + JSON.stringify({ id: 'bb-1', title: 'Open issue', status: 'open', priority: 0, issue_type: 'task' }), + JSON.stringify({ id: 'bb-2', title: 'Hidden tombstone', status: 'tombstone' }), + ].join('\n'), + 'utf8', + ); + + const issues = await readIssuesFromDisk({ projectRoot: root }); + + assert.equal(issues.length, 1); + assert.equal(issues[0].id, 'bb-1'); + assert.equal(issues[0].priority, 0); +}); + +test('readIssuesFromDisk returns empty list when issues file does not exist', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'beadboard-read-missing-')); + const issues = await readIssuesFromDisk({ projectRoot: root }); + assert.deepEqual(issues, []); +});