feat(bf-3xp): add bead workspace scanner + project breakdown in /api/productivity
Phase 9 implementation: Bead workspace scanner and project breakdown. - Add beadWorkspaceScanner.ts to scan .beads/issues.jsonl files - Count CLOSED beads per project, deriving project from bead id prefix - Use close_reason/closed_at/assignee for productivity tracking - Add configurable workspace list in config.ts (WorkspaceConfig interface) - Extend GET /api/productivity to add byProject array - Add By Project section to ProductivityPanel React component - Add tests for bead workspace scanner Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d79a407468
commit
5e029c142c
5 changed files with 612 additions and 2 deletions
230
src/beadWorkspaceScanner.test.ts
Normal file
230
src/beadWorkspaceScanner.test.ts
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
209
src/beadWorkspaceScanner.ts
Normal file
209
src/beadWorkspaceScanner.ts
Normal file
|
|
@ -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<string, number>;
|
||||
/** 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<string, ProjectStats>();
|
||||
|
||||
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;
|
||||
}
|
||||
103
src/config.ts
103
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();
|
||||
|
|
|
|||
|
|
@ -11,9 +11,18 @@ interface WorkerStat {
|
|||
beadsPerHour: number;
|
||||
}
|
||||
|
||||
interface ProjectStat {
|
||||
name: string;
|
||||
prefix: string;
|
||||
closedCount: number;
|
||||
byAssignee: Record<string, number>;
|
||||
lastClosedAt?: string;
|
||||
}
|
||||
|
||||
interface ProductivityData {
|
||||
daily: DailyCount[];
|
||||
workers: WorkerStat[];
|
||||
byProject: ProjectStat[];
|
||||
}
|
||||
|
||||
interface ProductivityPanelProps {
|
||||
|
|
@ -119,6 +128,7 @@ const ProductivityPanel: React.FC<ProductivityPanelProps> = ({ 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 (
|
||||
<div className="analytics-panel productivity-panel">
|
||||
|
|
@ -185,6 +195,60 @@ const ProductivityPanel: React.FC<ProductivityPanelProps> = ({ visible, onClose
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="analytics-section">
|
||||
<h3 className="analytics-section-title">By Project</h3>
|
||||
<div className="analytics-section-body">
|
||||
{data.byProject.length === 0 ? (
|
||||
<p className="analytics-empty">No project data available.</p>
|
||||
) : (
|
||||
<table className="productivity-projects">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Closed Beads</th>
|
||||
<th>Progress</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr key={project.name}>
|
||||
<td className="productivity-project-name">
|
||||
{project.name}
|
||||
<span className="productivity-project-prefix"> ({project.prefix}-*)</span>
|
||||
</td>
|
||||
<td className="productivity-count">{project.closedCount}</td>
|
||||
<td className="productivity-bar-cell">
|
||||
<div className="productivity-bar-bg">
|
||||
<div
|
||||
className="productivity-bar-fill"
|
||||
style={{ width: `${width}%` }}
|
||||
title={`${project.closedCount} beads closed`}
|
||||
/>
|
||||
</div>
|
||||
{assignees.length > 0 && (
|
||||
<div className="productivity-assignees" title={`Top assignees: ${assignees.map(([a]) => a).join(', ')}`}>
|
||||
{assignees.map(([assignee, count]) => (
|
||||
<span key={assignee} className="productivity-assignee">
|
||||
{assignee.split('-').pop()}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue