Add tracer-bullet Kanban baseline with live issues read path

This commit is contained in:
zenchantlive 2026-02-11 17:55:26 -08:00
parent 7537ec27b0
commit c09420dc68
15 changed files with 748 additions and 8 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}