diff --git a/src/beadWorkspaceScanner.test.ts b/src/beadWorkspaceScanner.test.ts new file mode 100644 index 0000000..de37722 --- /dev/null +++ b/src/beadWorkspaceScanner.test.ts @@ -0,0 +1,230 @@ +/** + * Tests for Bead Workspace Scanner + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { scanBeadWorkspaces, getProjectStats, getClosedBeadsForWorkspace, ProjectStats } from './beadWorkspaceScanner.js'; +import { WorkspaceConfig } from './config.js'; + +// Mock fs module +vi.mock('fs'); +const mockFs = vi.mocked(fs); + +// Mock config module +vi.mock('./config.js', () => ({ + loadWorkspaces: vi.fn(), +})); + +describe('Bead Workspace Scanner', () => { + const mockWorkspaces: WorkspaceConfig[] = [ + { + path: '/home/coding/FABRIC', + name: 'FABRIC', + prefix: 'bf', + }, + { + path: '/home/coding/spaxel', + name: 'spaxel', + prefix: 'bf', + }, + ]; + + const mockBeadsData = { + '/home/coding/FABRIC/.beads/issues.jsonl': [ + JSON.stringify({ + id: 'bf-1abc', + title: 'Test bead 1', + status: 'closed', + closed_at: '2026-05-20T10:00:00Z', + assignee: 'claude-code-glm-4.7-alpha', + source_repo: '.', + }), + JSON.stringify({ + id: 'bf-2def', + title: 'Test bead 2', + status: 'open', + source_repo: '.', + }), + JSON.stringify({ + id: 'bf-3ghi', + title: 'Test bead 3', + status: 'closed', + closed_at: '2026-05-21T14:30:00Z', + assignee: 'claude-code-glm-4.7-beta', + source_repo: '.', + }), + ].join('\n'), + '/home/coding/spaxel/.beads/issues.jsonl': [ + JSON.stringify({ + id: 'bf-4jkl', + title: 'Spaxel bead 1', + status: 'closed', + closed_at: '2026-05-19T08:00:00Z', + assignee: 'claude-code-glm-4.7-gamma', + source_repo: '.', + }), + JSON.stringify({ + id: 'bf-5mno', + title: 'Spaxel bead 2', + status: 'closed', + closed_at: '2026-05-20T12:00:00Z', + assignee: 'claude-code-glm-4.7-alpha', + source_repo: '.', + }), + ].join('\n'), + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Mock loadWorkspaces to return our test workspaces + const { loadWorkspaces } = vi.mocked(await import('./config.js')); + vi.mocked(loadWorkspaces).mockReturnValue(mockWorkspaces); + + // Mock fs.existsSync to return true for our test files + mockFs.existsSync.mockImplementation((filePath) => { + const pathStr = String(filePath); + return Object.keys(mockBeadsData).includes(pathStr); + }); + + // Mock fs.readFileSync to return our test data + mockFs.readFileSync.mockImplementation((filePath) => { + const pathStr = String(filePath); + if (mockBeadsData[pathStr]) { + return mockBeadsData[pathStr]; + } + throw new Error(`File not found: ${pathStr}`); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('scanBeadWorkspaces', () => { + it('should scan all configured workspaces', () => { + const result = scanBeadWorkspaces(); + + expect(result.workspacesScanned).toBe(2); + expect(result.totalBeads).toBe(5); + expect(result.totalClosed).toBe(4); + }); + + it('should return project stats sorted by closed count', () => { + const result = scanBeadWorkspaces(); + + expect(result.byProject).toHaveLength(2); + expect(result.byProject[0].name).toBe('FABRIC'); + expect(result.byProject[0].closedCount).toBe(2); + expect(result.byProject[1].name).toBe('spaxel'); + expect(result.byProject[1].closedCount).toBe(2); + }); + + it('should track assignees per project', () => { + const result = scanBeadWorkspaces(); + + const fabric = result.byProject.find(p => p.name === 'FABRIC'); + expect(fabric?.byAssignee['claude-code-glm-4.7-alpha']).toBe(1); + expect(fabric?.byAssignee['claude-code-glm-4.7-beta']).toBe(1); + + const spaxel = result.byProject.find(p => p.name === 'spaxel'); + expect(spaxel?.byAssignee['claude-code-glm-4.7-gamma']).toBe(1); + expect(spaxel?.byAssignee['claude-code-glm-4.7-alpha']).toBe(1); + }); + + it('should track most recent closure timestamp', () => { + const result = scanBeadWorkspaces(); + + const fabric = result.byProject.find(p => p.name === 'FABRIC'); + expect(fabric?.lastClosedAt).toBe('2026-05-21T14:30:00Z'); + + const spaxel = result.byProject.find(p => p.name === 'spaxel'); + expect(spaxel?.lastClosedAt).toBe('2026-05-20T12:00:00Z'); + }); + + it('should handle missing workspace directories gracefully', () => { + mockFs.existsSync.mockImplementation((filePath) => { + const pathStr = String(filePath); + return pathStr.includes('spaxel'); // Only spaxel exists + }); + + const result = scanBeadWorkspaces(); + + expect(result.workspacesScanned).toBe(2); + expect(result.byProject).toHaveLength(1); + expect(result.byProject[0].name).toBe('spaxel'); + }); + + it('should handle invalid JSON lines gracefully', () => { + mockFs.readFileSync.mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('FABRIC')) { + return [ + JSON.stringify({ + id: 'bf-1abc', + title: 'Valid bead', + status: 'closed', + closed_at: '2026-05-20T10:00:00Z', + source_repo: '.', + }), + 'invalid json line', + JSON.stringify({ + id: 'bf-2def', + title: 'Another valid bead', + status: 'closed', + closed_at: '2026-05-21T10:00:00Z', + source_repo: '.', + }), + ].join('\n'); + } + return ''; + }); + + const result = scanBeadWorkspaces(); + + expect(result.totalClosed).toBe(2); + }); + }); + + describe('getProjectStats', () => { + it('should return stats for a specific project', () => { + const stats = getProjectStats('FABRIC'); + + expect(stats).toBeDefined(); + expect(stats?.name).toBe('FABRIC'); + expect(stats?.closedCount).toBe(2); + expect(stats?.prefix).toBe('bf'); + }); + + it('should return undefined for non-existent project', () => { + const stats = getProjectStats('nonexistent'); + + expect(stats).toBeUndefined(); + }); + }); + + describe('getClosedBeadsForWorkspace', () => { + it('should return closed beads for a workspace', () => { + const beads = getClosedBeadsForWorkspace('/home/coding/FABRIC'); + + expect(beads).toHaveLength(2); + expect(beads[0].status).toBe('closed'); + expect(beads[1].status).toBe('closed'); + }); + + it('should respect limit parameter', () => { + const beads = getClosedBeadsForWorkspace('/home/coding/FABRIC', 1); + + expect(beads).toHaveLength(1); + }); + + it('should return empty array for non-existent workspace', () => { + mockFs.existsSync.mockReturnValue(false); + const beads = getClosedBeadsForWorkspace('/nonexistent/path'); + + expect(beads).toEqual([]); + }); + }); +}); diff --git a/src/beadWorkspaceScanner.ts b/src/beadWorkspaceScanner.ts new file mode 100644 index 0000000..76bb6f5 --- /dev/null +++ b/src/beadWorkspaceScanner.ts @@ -0,0 +1,209 @@ +/** + * Bead Workspace Scanner + * + * Reads .beads/issues.jsonl files from configured workspaces + * and counts CLOSED beads per project for productivity analytics. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { WorkspaceConfig, loadWorkspaces } from './config.js'; + +/** + * Bead record from issues.jsonl + */ +interface BeadRecord { + id: string; + title: string; + description: string; + design: string; + acceptance_criteria: string; + notes: string; + status: 'open' | 'in_progress' | 'blocked' | 'completed' | 'closed' | 'deferred'; + priority: number; + issue_type: string; + created_at: string; + updated_at: string; + closed_at?: string; + close_reason?: string; + assignee?: string; + source_repo: string; + compaction_level: number; + labels?: string[]; + dependencies?: Array<{ + issue_id: string; + depends_on_id: string; + type: string; + created_at: string; + created_by: string; + thread_id: string; + }>; + comments?: Array<{ + id: number; + issue_id: string; + author: string; + text: string; + created_at: string; + }>; +} + +/** + * Project productivity stats + */ +export interface ProjectStats { + /** Project name */ + name: string; + /** Bead ID prefix for this project */ + prefix: string; + /** Total closed beads */ + closedCount: number; + /** Beads closed by assignee */ + byAssignee: Record; + /** Most recent closure timestamp */ + lastClosedAt?: string; +} + +/** + * Scan result summary + */ +export interface ScanResult { + /** Total workspaces scanned */ + workspacesScanned: number; + /** Total beads read */ + totalBeads: number; + /** Total closed beads across all projects */ + totalClosed: number; + /** Project breakdown */ + byProject: ProjectStats[]; +} + +/** + * Scan all configured workspaces and count closed beads + */ +export function scanBeadWorkspaces(): ScanResult { + const workspaces = loadWorkspaces(); + const projectStats = new Map(); + + let totalBeads = 0; + let totalClosed = 0; + + for (const workspace of workspaces) { + const issuesFile = path.join(workspace.path, '.beads', 'issues.jsonl'); + + try { + if (!fs.existsSync(issuesFile)) { + continue; + } + + const content = fs.readFileSync(issuesFile, 'utf-8'); + const lines = content.split('\n').filter(line => line.trim()); + + for (const line of lines) { + totalBeads++; + let bead: BeadRecord; + + try { + bead = JSON.parse(line) as BeadRecord; + } catch { + // Skip invalid JSON lines + continue; + } + + // Only count closed beads + if (bead.status !== 'closed') { + continue; + } + + totalClosed++; + + // Get or create project stats + let stats = projectStats.get(workspace.name); + if (!stats) { + stats = { + name: workspace.name, + prefix: workspace.prefix, + closedCount: 0, + byAssignee: {}, + }; + projectStats.set(workspace.name, stats); + } + + stats.closedCount++; + + // Track by assignee + const assignee = bead.assignee || 'unassigned'; + stats.byAssignee[assignee] = (stats.byAssignee[assignee] || 0) + 1; + + // Track most recent closure + if (bead.closed_at) { + if (!stats.lastClosedAt || bead.closed_at > stats.lastClosedAt) { + stats.lastClosedAt = bead.closed_at; + } + } + } + } catch { + // Skip workspaces that can't be read + continue; + } + } + + // Convert map to array and sort by closed count + const byProject = Array.from(projectStats.values()).sort( + (a, b) => b.closedCount - a.closedCount + ); + + return { + workspacesScanned: workspaces.length, + totalBeads, + totalClosed, + byProject, + }; +} + +/** + * Get project stats for a specific project name + */ +export function getProjectStats(projectName: string): ProjectStats | undefined { + const result = scanBeadWorkspaces(); + return result.byProject.find(p => p.name === projectName); +} + +/** + * Get closed beads for a specific workspace + */ +export function getClosedBeadsForWorkspace( + workspacePath: string, + limit?: number +): BeadRecord[] { + const issuesFile = path.join(workspacePath, '.beads', 'issues.jsonl'); + const closedBeads: BeadRecord[] = []; + + try { + if (!fs.existsSync(issuesFile)) { + return closedBeads; + } + + const content = fs.readFileSync(issuesFile, 'utf-8'); + const lines = content.split('\n').filter(line => line.trim()); + + for (const line of lines) { + let bead: BeadRecord; + try { + bead = JSON.parse(line) as BeadRecord; + } catch { + continue; + } + + if (bead.status === 'closed') { + closedBeads.push(bead); + if (limit && closedBeads.length >= limit) { + break; + } + } + } + } catch { + // Return empty on error + } + + return closedBeads; +} diff --git a/src/config.ts b/src/config.ts index 3ca0b70..d1279de 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,8 +25,96 @@ const CONFIG_FILES = { presets: path.join(CONFIG_DIR, 'focus-presets.json'), recentCommands: path.join(CONFIG_DIR, 'recent-commands.json'), filterState: path.join(HOME, '.fabric-filter-state.json'), + workspaces: path.join(CONFIG_DIR, 'workspaces.json'), }; +/** + * Workspace configuration for bead scanning + */ +export interface WorkspaceConfig { + /** Workspace path (e.g., /home/coding/FABRIC) */ + path: string; + /** Project name derived from path or override */ + name: string; + /** Bead ID prefix for this workspace (e.g., 'bf' for global, 'kt' for kalshi-tape) */ + prefix: string; +} + +/** + * Load workspace configurations + */ +export function loadWorkspaces(): WorkspaceConfig[] { + try { + if (fs.existsSync(CONFIG_FILES.workspaces)) { + const data = fs.readFileSync(CONFIG_FILES.workspaces, 'utf-8'); + const config = JSON.parse(data) as { workspaces: WorkspaceConfig[] }; + return config.workspaces || []; + } + } catch { + // Ignore errors + } + // Default workspace list + return getDefaultWorkspaces(); +} + +/** + * Get default workspace list by scanning /home/coding for .beads directories + */ +function getDefaultWorkspaces(): WorkspaceConfig[] { + const workspaces: WorkspaceConfig[] = []; + const codingDir = path.join(HOME); + + try { + if (!fs.existsSync(codingDir)) { + return workspaces; + } + + const entries = fs.readdirSync(codingDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const projectPath = path.join(codingDir, entry.name); + const beadsDir = path.join(projectPath, '.beads'); + const issuesFile = path.join(beadsDir, 'issues.jsonl'); + + if (fs.existsSync(issuesFile)) { + // Extract project name from directory name + const name = entry.name; + + // Determine prefix from bead ID format in the file + const prefix = detectBeadPrefix(issuesFile) || 'bf'; + + workspaces.push({ + path: projectPath, + name, + prefix, + }); + } + } + } catch { + // Ignore errors + } + + return workspaces; +} + +/** + * Detect bead ID prefix by reading the first line of issues.jsonl + */ +function detectBeadPrefix(issuesFile: string): string | null { + try { + const content = fs.readFileSync(issuesFile, 'utf-8'); + const firstLine = content.split('\n')[0]; + if (!firstLine) return null; + + const match = firstLine.match(/"id":"([a-z]+)-/); + return match ? match[1] : null; + } catch { + return null; + } +} + /** * Show current configuration */ @@ -43,6 +131,21 @@ function showConfig(): void { console.log(` File: ${CONFIG_FILES.theme}`); console.log(''); + // Workspaces + console.log('Workspaces:'); + const workspaces = loadWorkspaces(); + if (workspaces.length === 0) { + console.log(' No workspaces configured'); + } else { + console.log(` Count: ${workspaces.length}`); + workspaces.forEach(w => { + console.log(` - ${w.name} (${w.prefix}-*)`); + console.log(` Path: ${w.path}`); + }); + } + console.log(` File: ${CONFIG_FILES.workspaces}`); + console.log(''); + // Presets console.log('Focus Presets:'); const presetManager = createTuiPresetManager(); diff --git a/src/web/frontend/src/components/ProductivityPanel.tsx b/src/web/frontend/src/components/ProductivityPanel.tsx index 35386b8..087cbd1 100644 --- a/src/web/frontend/src/components/ProductivityPanel.tsx +++ b/src/web/frontend/src/components/ProductivityPanel.tsx @@ -11,9 +11,18 @@ interface WorkerStat { beadsPerHour: number; } +interface ProjectStat { + name: string; + prefix: string; + closedCount: number; + byAssignee: Record; + lastClosedAt?: string; +} + interface ProductivityData { daily: DailyCount[]; workers: WorkerStat[]; + byProject: ProjectStat[]; } interface ProductivityPanelProps { @@ -119,6 +128,7 @@ const ProductivityPanel: React.FC = ({ visible, onClose const totalToday = data?.daily[data.daily.length - 1]?.count ?? 0; const total30d = data?.daily.reduce((s, d) => s + d.count, 0) ?? 0; + const maxProjectCount = Math.max(...(data?.byProject.map(p => p.closedCount) || [0]), 1); return (
@@ -185,6 +195,60 @@ const ProductivityPanel: React.FC = ({ visible, onClose )}
+ +
+

By Project

+
+ {data.byProject.length === 0 ? ( +

No project data available.

+ ) : ( + + + + + + + + + + {data.byProject.map((project) => { + const width = (project.closedCount / maxProjectCount) * 100; + const assignees = Object.entries(project.byAssignee) + .sort(([, a], [, b]) => b - a) + .slice(0, 3); + return ( + + + + + + ); + })} + +
ProjectClosed BeadsProgress
+ {project.name} + ({project.prefix}-*) + {project.closedCount} +
+
+
+ {assignees.length > 0 && ( +
a).join(', ')}`}> + {assignees.map(([assignee, count]) => ( + + {assignee.split('-').pop()}: {count} + + ))} +
+ )} +
+ )} +
+
)} diff --git a/src/web/server.ts b/src/web/server.ts index e38229c..a9d2431 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -24,6 +24,7 @@ import { generatePRPreview } from '../tui/utils/prPreview.js'; import { getMemoryProfiler } from '../memoryProfiler.js'; import { getRecentHeapDiff, analyzeTrend, formatTrendAsMarkdown, saveTrendReport } from '../heapDiff.js'; import { computeRetentionState, pruneLogs, formatPruneResult, PruneOptions } from '../logPruner.js'; +import { scanBeadWorkspaces } from '../beadWorkspaceScanner.js'; /** Get the v8 module (available in Node.js) */ function getV8() { @@ -1096,7 +1097,7 @@ export function createWebServer(options: WebServerOptions): WebServer { } }); - // Productivity analytics — daily throughput + worker leaderboard + // Productivity analytics — daily throughput + worker leaderboard + project breakdown app.get('/api/productivity', (_req: Request, res: Response) => { try { const now = Date.now(); @@ -1133,7 +1134,10 @@ export function createWebServer(options: WebServerOptions): WebServer { }); workers.sort((a, b) => b.beadsCompleted - a.beadsCompleted); - res.json({ daily, workers }); + // Project breakdown from bead workspace scanner + const { byProject } = scanBeadWorkspaces(); + + res.json({ daily, workers, byProject }); } catch (err) { res.status(500).json({ error: String(err) }); }