Add tracer-bullet Kanban baseline with live issues read path
This commit is contained in:
parent
7537ec27b0
commit
c09420dc68
15 changed files with 748 additions and 8 deletions
4
next-env.d.ts
vendored
4
next-env.d.ts
vendored
|
|
@ -1,4 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export default function Page() {
|
||||
return (
|
||||
<main>
|
||||
<h1>BeadBoard</h1>
|
||||
</main>
|
||||
);
|
||||
import { KanbanPage } from '../components/kanban/kanban-page';
|
||||
import { readIssuesFromDisk } from '../lib/read-issues';
|
||||
|
||||
export default async function Page() {
|
||||
const issues = await readIssuesFromDisk();
|
||||
return <KanbanPage issues={issues} />;
|
||||
}
|
||||
|
|
|
|||
178
src/components/kanban/kanban-board-client.tsx
Normal file
178
src/components/kanban/kanban-board-client.tsx
Normal file
|
|
@ -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<KanbanStatus, string> = {
|
||||
open: 'Open',
|
||||
in_progress: 'In Progress',
|
||||
blocked: 'Blocked',
|
||||
deferred: 'Deferred',
|
||||
closed: 'Done',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<KanbanStatus, string> = {
|
||||
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<BeadIssue | null>(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 (
|
||||
<main style={{ background: '#0b1020', color: '#f8fafc', minHeight: '100vh', fontFamily: 'Segoe UI, sans-serif' }}>
|
||||
<header style={{ padding: 16, borderBottom: '1px solid rgba(148,163,184,0.3)', position: 'sticky', top: 0, background: '#0b1020', zIndex: 20 }}>
|
||||
<h1 style={{ margin: 0, fontSize: 22 }}>BeadBoard</h1>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8', marginTop: 4 }}>Source: {sourcePath}</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 12 }}>
|
||||
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search beads" style={{ padding: '6px 8px' }} />
|
||||
<select value={type} onChange={(e) => setType(e.target.value)} style={{ padding: '6px 8px' }}>
|
||||
{issueTypes.map((issueType) => (
|
||||
<option key={issueType} value={issueType}>
|
||||
{issueType === 'all' ? 'All types' : issueType}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={priority} onChange={(e) => setPriority(e.target.value)} style={{ padding: '6px 8px' }}>
|
||||
<option value="all">All priorities</option>
|
||||
<option value="0">P0</option>
|
||||
<option value="1">P1</option>
|
||||
<option value="2">P2</option>
|
||||
<option value="3">P3</option>
|
||||
<option value="4">P4</option>
|
||||
</select>
|
||||
<label style={{ display: 'inline-flex', gap: 4, alignItems: 'center', fontSize: 12 }}>
|
||||
<input type="checkbox" checked={showClosed} onChange={(e) => setShowClosed(e.target.checked)} />
|
||||
Show closed
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12, marginTop: 10, fontSize: 12, color: '#cbd5e1', flexWrap: 'wrap' }}>
|
||||
<span>Total {stats.total}</span>
|
||||
<span>Open {stats.open}</span>
|
||||
<span>Active {stats.active}</span>
|
||||
<span>Blocked {stats.blocked}</span>
|
||||
<span>Done {stats.done}</span>
|
||||
<span>P0 {stats.p0}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section style={{ display: 'grid', gridTemplateColumns: `repeat(${visibleStatuses.length}, minmax(220px, 1fr))`, gap: 8, padding: 12 }}>
|
||||
{visibleStatuses.map((status) => (
|
||||
<div key={status} style={{ background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(148,163,184,0.3)', borderRadius: 8, padding: 8, minHeight: 400 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<strong style={{ color: STATUS_COLORS[status] }}>{STATUS_LABELS[status]}</strong>
|
||||
<span style={{ fontSize: 12, color: '#cbd5e1' }}>{columns[status].length}</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 6 }}>
|
||||
{columns[status].map((issue) => (
|
||||
<button
|
||||
key={issue.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedIssue(issue)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
padding: 8,
|
||||
borderRadius: 6,
|
||||
border: '1px solid rgba(148,163,184,0.25)',
|
||||
background: 'rgba(15,23,42,0.8)',
|
||||
color: '#f8fafc',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<span style={{ fontFamily: 'Consolas, monospace', fontSize: 11, color: '#94a3b8' }}>{issue.id}</span>
|
||||
<span style={{ fontSize: 11 }}>P{issue.priority}</span>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, fontWeight: 600 }}>{issue.title}</div>
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: '#cbd5e1' }}>
|
||||
{issue.issue_type}
|
||||
{issue.assignee ? ` · ${issue.assignee}` : ''}
|
||||
{issue.dependencies.length ? ` · ${issue.dependencies.length} dep` : ''}
|
||||
</div>
|
||||
{issue.labels.length > 0 ? (
|
||||
<div style={{ marginTop: 6, fontSize: 11, color: '#7dd3fc' }}>{issue.labels.join(', ')}</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{selectedIssue ? (
|
||||
<>
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(2,6,23,0.7)' }} onClick={() => setSelectedIssue(null)} />
|
||||
<aside style={{ position: 'fixed', top: 0, right: 0, width: 420, maxWidth: '100%', height: '100%', background: '#0f172a', borderLeft: '1px solid rgba(148,163,184,0.35)', padding: 16, overflowY: 'auto', zIndex: 30 }}>
|
||||
<button type="button" onClick={() => setSelectedIssue(null)} style={{ float: 'right' }}>
|
||||
Close
|
||||
</button>
|
||||
<h2 style={{ marginTop: 0 }}>{selectedIssue.title}</h2>
|
||||
<p style={{ fontFamily: 'Consolas, monospace', color: '#94a3b8' }}>{selectedIssue.id}</p>
|
||||
<p>Status: {selectedIssue.status}</p>
|
||||
<p>Priority: P{selectedIssue.priority}</p>
|
||||
<p>Type: {selectedIssue.issue_type}</p>
|
||||
<p>Assignee: {selectedIssue.assignee ?? 'Unassigned'}</p>
|
||||
<p>Created: {formatDate(selectedIssue.created_at)}</p>
|
||||
<p>Updated: {formatDate(selectedIssue.updated_at)}</p>
|
||||
<p>Closed: {formatDate(selectedIssue.closed_at)}</p>
|
||||
<p>Description: {selectedIssue.description ?? 'None'}</p>
|
||||
<p>Labels: {selectedIssue.labels.join(', ') || 'None'}</p>
|
||||
<p>Dependencies:</p>
|
||||
<ul>
|
||||
{selectedIssue.dependencies.length === 0 ? <li>None</li> : null}
|
||||
{selectedIssue.dependencies.map((dep) => (
|
||||
<li key={`${dep.type}:${dep.target}`}>
|
||||
{dep.type} - {dep.target}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
39
src/components/kanban/kanban-board.tsx
Normal file
39
src/components/kanban/kanban-board.tsx
Normal file
|
|
@ -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 (
|
||||
<section
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(5, minmax(220px, 1fr))',
|
||||
gap: '0.8rem',
|
||||
overflowX: 'auto',
|
||||
}}
|
||||
>
|
||||
{KANBAN_STATUSES.map((status) => (
|
||||
<div key={status} style={{ background: '#edf2f7', borderRadius: 14, padding: '0.65rem', minHeight: 320 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.55rem' }}>
|
||||
<strong style={{ fontSize: '0.85rem', color: '#1f2937' }}>{status}</strong>
|
||||
<span style={{ fontSize: '0.8rem', color: '#475569' }}>{columns[status].length}</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '0.55rem' }}>
|
||||
{columns[status].map((issue) => (
|
||||
<KanbanCard key={issue.id} issue={issue} selected={selectedIssueId === issue.id} onSelect={onSelect} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
47
src/components/kanban/kanban-card.tsx
Normal file
47
src/components/kanban/kanban-card.tsx
Normal file
|
|
@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(issue)}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
border: selected ? '2px solid #0f766e' : '1px solid #d7dee8',
|
||||
borderRadius: 12,
|
||||
padding: '0.7rem',
|
||||
background: '#ffffff',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.74rem', color: '#5e6b7a' }}>{issue.id}</div>
|
||||
<div style={{ fontWeight: 700, color: '#0f1720', margin: '0.15rem 0 0.5rem' }}>{issue.title}</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.35rem', marginBottom: '0.45rem' }}>
|
||||
<Chip>P{issue.priority}</Chip>
|
||||
<Chip>{issue.issue_type}</Chip>
|
||||
<Chip>deps {issue.dependencies.length}</Chip>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#314152' }}>
|
||||
{issue.assignee ? `@${issue.assignee}` : 'unassigned'}
|
||||
</div>
|
||||
{issue.labels.length > 0 ? (
|
||||
<div style={{ marginTop: '0.45rem', display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||
{issue.labels.slice(0, 3).map((label) => (
|
||||
<Chip key={`${issue.id}-${label}`}>#{label}</Chip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
59
src/components/kanban/kanban-controls.tsx
Normal file
59
src/components/kanban/kanban-controls.tsx
Normal file
|
|
@ -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 (
|
||||
<section style={{ display: 'grid', gap: '0.75rem' }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.6rem' }}>
|
||||
<input
|
||||
type="search"
|
||||
value={filters.query ?? ''}
|
||||
onChange={(event) => 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' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={filters.type ?? ''}
|
||||
onChange={(event) => 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' }}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={4}
|
||||
value={filters.priority ?? ''}
|
||||
onChange={(event) => onFiltersChange({ ...filters, priority: event.target.value })}
|
||||
placeholder="Priority"
|
||||
style={{ width: 110, borderRadius: 10, border: '1px solid #cbd5e1', padding: '0.5rem 0.6rem' }}
|
||||
/>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.45rem', color: '#334155', fontSize: '0.9rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.showClosed ?? false}
|
||||
onChange={(event) => onFiltersChange({ ...filters, showClosed: event.target.checked })}
|
||||
/>
|
||||
Show closed
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.6rem' }}>
|
||||
<StatPill label="Total" value={stats.total} />
|
||||
<StatPill label="Open" value={stats.open} />
|
||||
<StatPill label="Active" value={stats.active} />
|
||||
<StatPill label="Blocked" value={stats.blocked} />
|
||||
<StatPill label="Done" value={stats.done} />
|
||||
<StatPill label="P0" value={stats.p0} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
50
src/components/kanban/kanban-detail.tsx
Normal file
50
src/components/kanban/kanban-detail.tsx
Normal file
|
|
@ -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 (
|
||||
<aside style={{ border: '1px solid #d7dee8', borderRadius: 14, padding: '0.9rem', background: '#ffffff' }}>
|
||||
<strong style={{ color: '#0f1720' }}>Details</strong>
|
||||
<p style={{ color: '#475569' }}>Select a card to inspect full issue details.</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside style={{ border: '1px solid #d7dee8', borderRadius: 14, padding: '0.9rem', background: '#ffffff' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.76rem', color: '#475569' }}>{issue.id}</div>
|
||||
<h2 style={{ margin: '0.1rem 0 0.3rem', fontSize: '1.2rem', color: '#0f1720' }}>{issue.title}</h2>
|
||||
</div>
|
||||
<Chip>{issue.status}</Chip>
|
||||
</div>
|
||||
{issue.description ? <p style={{ color: '#334155' }}>{issue.description}</p> : null}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.4rem', marginBottom: '0.5rem' }}>
|
||||
<Chip>priority {issue.priority}</Chip>
|
||||
<Chip>{issue.issue_type}</Chip>
|
||||
<Chip>{issue.assignee ? `@${issue.assignee}` : 'unassigned'}</Chip>
|
||||
<Chip>{issue.dependencies.length} dependencies</Chip>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.84rem', color: '#334155' }}>
|
||||
<div><strong>Created:</strong> {issue.created_at || '-'}</div>
|
||||
<div><strong>Updated:</strong> {issue.updated_at || '-'}</div>
|
||||
</div>
|
||||
{issue.labels.length > 0 ? (
|
||||
<div style={{ marginTop: '0.6rem', display: 'flex', flexWrap: 'wrap', gap: '0.35rem' }}>
|
||||
{issue.labels.map((label) => (
|
||||
<Chip key={`${issue.id}-${label}`}>#{label}</Chip>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
60
src/components/kanban/kanban-page.tsx
Normal file
60
src/components/kanban/kanban-page.tsx
Normal file
|
|
@ -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<KanbanFilterOptions>({
|
||||
query: '',
|
||||
type: '',
|
||||
priority: '',
|
||||
showClosed: false,
|
||||
});
|
||||
const [selectedIssueId, setSelectedIssueId] = useState<string | null>(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 (
|
||||
<main
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
padding: '1.2rem',
|
||||
background: 'linear-gradient(140deg, #f8fafc, #edf3f7 50%, #f8fbf9)',
|
||||
color: '#0f1720',
|
||||
fontFamily: 'Segoe UI, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
<header style={{ marginBottom: '0.9rem' }}>
|
||||
<h1 style={{ margin: 0, fontSize: '1.9rem' }}>BeadBoard Kanban</h1>
|
||||
<p style={{ margin: '0.35rem 0 0', color: '#475569' }}>Tracer Bullet 1 baseline from live `.beads/issues.jsonl`</p>
|
||||
</header>
|
||||
<KanbanControls filters={filters} stats={stats} onFiltersChange={setFilters} />
|
||||
<section style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 320px', gap: '0.9rem', marginTop: '0.9rem' }}>
|
||||
<KanbanBoard
|
||||
columns={columns}
|
||||
selectedIssueId={selectedIssue?.id ?? null}
|
||||
onSelect={(issue) => setSelectedIssueId(issue.id)}
|
||||
/>
|
||||
<KanbanDetail issue={selectedIssue} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
23
src/components/shared/chip.tsx
Normal file
23
src/components/shared/chip.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { ReactNode } from 'react';
|
||||
|
||||
interface ChipProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Chip({ children }: ChipProps) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
border: '1px solid #d7dee8',
|
||||
borderRadius: 999,
|
||||
padding: '0.2rem 0.55rem',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
color: '#1f2a38',
|
||||
background: '#f7fafc',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
21
src/components/shared/stat-pill.tsx
Normal file
21
src/components/shared/stat-pill.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
interface StatPillProps {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function StatPill({ label, value }: StatPillProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #d7dee8',
|
||||
borderRadius: 12,
|
||||
padding: '0.6rem 0.8rem',
|
||||
background: '#ffffff',
|
||||
minWidth: 90,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: '#5e6b7a' }}>{label}</div>
|
||||
<div style={{ fontSize: '1.05rem', fontWeight: 700, color: '#0f1720' }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
src/lib/kanban.ts
Normal file
99
src/lib/kanban.ts
Normal file
|
|
@ -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<KanbanStatus, BeadIssue[]>;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
33
src/lib/read-issues.ts
Normal file
33
src/lib/read-issues.ts
Normal file
|
|
@ -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<BeadIssue[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
88
tests/lib/kanban.test.ts
Normal file
88
tests/lib/kanban.test.ts
Normal file
|
|
@ -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>): 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);
|
||||
});
|
||||
41
tests/lib/read-issues.test.ts
Normal file
41
tests/lib/read-issues.test.ts
Normal file
|
|
@ -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, []);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue