Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 52 additions & 26 deletions packages/codev/dashboard/__tests__/analytics.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Tests for the Analytics tab (Spec 456, Phase 3).
* Tests for the Analytics tab (Bugfix #531).
*
* Tests: useAnalytics hook behavior, AnalyticsView rendering,
* null value formatting, error states, and range switching.
Expand All @@ -25,18 +25,16 @@ vi.mock('../src/lib/api.js', () => ({
function makeStats(overrides: Partial<AnalyticsResponse> = {}): AnalyticsResponse {
return {
timeRange: '7d',
github: {
activity: {
prsMerged: 12,
avgTimeToMergeHours: 3.5,
bugBacklog: 4,
nonBugBacklog: 8,
issuesClosed: 6,
avgTimeToCloseBugsHours: 1.2,
},
builders: {
projectsCompleted: 5,
bugsFixed: 3,
throughputPerWeek: 2.5,
activeBuilders: 2,
projectsByProtocol: { spir: 3, bugfix: 2, aspir: 1 },
},
consultation: {
totalCount: 20,
Expand All @@ -50,10 +48,6 @@ function makeStats(overrides: Partial<AnalyticsResponse> = {}): AnalyticsRespons
],
byReviewType: { spec: 5, plan: 5, pr: 10 },
byProtocol: { spir: 15, tick: 5 },
costByProject: [
{ projectId: '456', totalCost: 0.75 },
{ projectId: '123', totalCost: 0.48 },
],
},
...overrides,
};
Expand Down Expand Up @@ -84,7 +78,7 @@ describe('useAnalytics', () => {
});

expect(mockFetchAnalytics).toHaveBeenCalledWith('7', false);
expect(result.current.data?.github.prsMerged).toBe(12);
expect(result.current.data?.activity.prsMerged).toBe(12);
expect(result.current.loading).toBe(false);
});

Expand Down Expand Up @@ -180,21 +174,22 @@ describe('AnalyticsView', () => {
expect(screen.getByText('Loading analytics...')).toBeInTheDocument();
});

it('renders all three section headers', async () => {
it('renders Activity and Consultation section headers (not GitHub/Builders)', async () => {
mockFetchAnalytics.mockResolvedValue(makeStats());

const { AnalyticsView } = await import('../src/components/AnalyticsView.js');
render(<AnalyticsView isActive={true} />);

await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
expect(screen.getByText('Activity')).toBeInTheDocument();
});

expect(screen.getByText('Builders')).toBeInTheDocument();
expect(screen.getByText('Consultation')).toBeInTheDocument();
expect(screen.queryByText('GitHub')).not.toBeInTheDocument();
expect(screen.queryByText('Builders')).not.toBeInTheDocument();
});

it('renders GitHub metric values', async () => {
it('renders Activity metric values', async () => {
mockFetchAnalytics.mockResolvedValue(makeStats());

const { AnalyticsView } = await import('../src/components/AnalyticsView.js');
Expand All @@ -206,6 +201,23 @@ describe('AnalyticsView', () => {

expect(screen.getByText('12')).toBeInTheDocument();
expect(screen.getByText('3.5h')).toBeInTheDocument();
expect(screen.getByText('Projects Completed')).toBeInTheDocument();
expect(screen.getByText('Bugs Fixed')).toBeInTheDocument();
});

it('renders protocol breakdown metrics', async () => {
mockFetchAnalytics.mockResolvedValue(makeStats());

const { AnalyticsView } = await import('../src/components/AnalyticsView.js');
render(<AnalyticsView isActive={true} />);

await waitFor(() => {
expect(screen.getByText('Projects by Protocol')).toBeInTheDocument();
});

expect(screen.getByText('SPIR')).toBeInTheDocument();
expect(screen.getByText('BUGFIX')).toBeInTheDocument();
expect(screen.getByText('ASPIR')).toBeInTheDocument();
});

it('renders consultation total cost', async () => {
Expand All @@ -223,13 +235,16 @@ describe('AnalyticsView', () => {

it('displays null values as em-dash', async () => {
const stats = makeStats({
github: {
activity: {
prsMerged: 3,
avgTimeToMergeHours: null,
bugBacklog: 0,
nonBugBacklog: 0,
issuesClosed: 0,
avgTimeToCloseBugsHours: null,
projectsCompleted: 0,
bugsFixed: 0,
throughputPerWeek: 0,
activeBuilders: 0,
projectsByProtocol: {},
},
});
mockFetchAnalytics.mockResolvedValue(stats);
Expand All @@ -238,7 +253,7 @@ describe('AnalyticsView', () => {
render(<AnalyticsView isActive={true} />);

await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
expect(screen.getByText('Activity')).toBeInTheDocument();
});

const dashes = screen.getAllByText('\u2014');
Expand Down Expand Up @@ -273,19 +288,30 @@ describe('AnalyticsView', () => {
expect(screen.getByText('gpt-5.2-codex')).toBeInTheDocument();
});

it('renders cost per project section', async () => {
it('does not render Cost per Project section', async () => {
mockFetchAnalytics.mockResolvedValue(makeStats());

const { AnalyticsView } = await import('../src/components/AnalyticsView.js');
render(<AnalyticsView isActive={true} />);

await waitFor(() => {
expect(screen.getByText('Activity')).toBeInTheDocument();
});

expect(screen.queryByText('Cost per Project')).not.toBeInTheDocument();
});

it('does not render Open Issue Backlog', async () => {
mockFetchAnalytics.mockResolvedValue(makeStats());

const { AnalyticsView } = await import('../src/components/AnalyticsView.js');
render(<AnalyticsView isActive={true} />);

await waitFor(() => {
expect(screen.getByText('Cost per Project')).toBeInTheDocument();
expect(screen.getByText('Activity')).toBeInTheDocument();
});

// Chart internals (#456, $0.75) render as SVG via Recharts and are
// not visible in jsdom's 0-width ResponsiveContainer. Verifying the
// section heading confirms the data path is wired correctly.
expect(screen.queryByText('Open Issue Backlog')).not.toBeInTheDocument();
});

it('calls fetchAnalytics with new range when range button is clicked', async () => {
Expand All @@ -295,7 +321,7 @@ describe('AnalyticsView', () => {
render(<AnalyticsView isActive={true} />);

await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
expect(screen.getByText('Activity')).toBeInTheDocument();
});

mockFetchAnalytics.mockResolvedValue(makeStats({ timeRange: '30d' }));
Expand All @@ -313,7 +339,7 @@ describe('AnalyticsView', () => {
render(<AnalyticsView isActive={true} />);

await waitFor(() => {
expect(screen.getByText('GitHub')).toBeInTheDocument();
expect(screen.getByText('Activity')).toBeInTheDocument();
});

const refreshBtn = screen.getByRole('button', { name: /Refresh/ });
Expand Down
106 changes: 24 additions & 82 deletions packages/codev/dashboard/src/components/AnalyticsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useAnalytics } from '../hooks/useAnalytics.js';
import type { AnalyticsResponse } from '../lib/api.js';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell,
PieChart, Pie,
} from 'recharts';

interface AnalyticsViewProps {
Expand Down Expand Up @@ -99,72 +98,33 @@ function MiniBarChart({ data, dataKey, nameKey, color, formatter }: {
);
}

function MiniPieChart({ data, dataKey, nameKey }: {
data: Array<Record<string, unknown>>;
dataKey: string;
nameKey: string;
}) {
if (data.length === 0) return null;
return (
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie
data={data}
dataKey={dataKey}
nameKey={nameKey}
cx="50%"
cy="50%"
outerRadius={55}
innerRadius={30}
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
labelLine={false}
style={{ fontSize: 10 }}
>
{data.map((_entry, idx) => (
<Cell key={idx} fill={CHART_COLORS[idx % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{ background: 'var(--bg-primary)', border: '1px solid var(--border-color)', fontSize: 11, borderRadius: 4 }}
labelStyle={{ color: 'var(--text-primary)' }}
itemStyle={{ color: 'var(--text-secondary)' }}
/>
</PieChart>
</ResponsiveContainer>
);
}

function GitHubSection({ github, errors }: { github: AnalyticsResponse['github']; errors?: AnalyticsResponse['errors'] }) {
const backlogData = [
{ name: 'Bug', value: github.bugBacklog },
{ name: 'Non-Bug', value: github.nonBugBacklog },
].filter(d => d.value > 0);
function ActivitySection({ activity, errors }: { activity: AnalyticsResponse['activity']; errors?: AnalyticsResponse['errors'] }) {
const protocolData = Object.entries(activity.projectsByProtocol)
.map(([proto, count]) => ({ name: proto.toUpperCase(), value: count }))
.sort((a, b) => b.value - a.value);

return (
<Section title="GitHub" error={errors?.github}>
<MetricGrid>
<Metric label="PRs Merged" value={String(github.prsMerged)} />
<Metric label="Avg Time to Merge" value={fmt(github.avgTimeToMergeHours, 1, 'h')} />
<Metric label="Issues Closed" value={String(github.issuesClosed)} />
<Metric label="Avg Time to Close Bugs" value={fmt(github.avgTimeToCloseBugsHours, 1, 'h')} />
</MetricGrid>
{backlogData.length > 0 && (
<Section title="Activity" error={errors?.github}>
{protocolData.length > 0 && (
<div className="analytics-sub-section">
<h4 className="analytics-sub-title">Open Issue Backlog</h4>
<MiniBarChart data={backlogData} dataKey="value" nameKey="name" />
<h4 className="analytics-sub-title">Projects by Protocol</h4>
<MetricGrid>
{protocolData.map(d => (
<Metric key={d.name} label={d.name} value={String(d.value)} />
))}
</MetricGrid>
<MiniBarChart data={protocolData} dataKey="value" nameKey="name" />
</div>
)}
</Section>
);
}

function BuildersSection({ builders }: { builders: AnalyticsResponse['builders'] }) {
return (
<Section title="Builders">
<MetricGrid>
<Metric label="Projects Completed" value={String(builders.projectsCompleted)} />
<Metric label="Throughput / Week" value={fmt(builders.throughputPerWeek)} />
<Metric label="Active Builders" value={String(builders.activeBuilders)} />
<Metric label="Projects Completed" value={String(activity.projectsCompleted)} />
<Metric label="Bugs Fixed" value={String(activity.bugsFixed)} />
<Metric label="PRs Merged" value={String(activity.prsMerged)} />
<Metric label="Issues Closed" value={String(activity.issuesClosed)} />
<Metric label="Avg Time to Merge" value={fmt(activity.avgTimeToMergeHours, 1, 'h')} />
<Metric label="Avg Time to Close Bugs" value={fmt(activity.avgTimeToCloseBugsHours, 1, 'h')} />
<Metric label="Throughput / Week" value={fmt(activity.throughputPerWeek)} />
<Metric label="Active Builders" value={String(activity.activeBuilders)} />
</MetricGrid>
</Section>
);
Expand All @@ -189,11 +149,6 @@ function ConsultationSection({ consultation, errors }: { consultation: Analytics
value: count,
}));

const projectData = consultation.costByProject.map(p => ({
name: `#${p.projectId}`,
cost: p.totalCost,
}));

return (
<Section title="Consultation" error={errors?.consultation}>
<MetricGrid>
Expand Down Expand Up @@ -248,29 +203,17 @@ function ConsultationSection({ consultation, errors }: { consultation: Analytics
{reviewTypeData.length > 0 && (
<div className="analytics-sub-section analytics-chart-half">
<h4 className="analytics-sub-title">By Review Type</h4>
<MiniPieChart data={reviewTypeData} dataKey="value" nameKey="name" />
<MiniBarChart data={reviewTypeData} dataKey="value" nameKey="name" />
</div>
)}
{protocolData.length > 0 && (
<div className="analytics-sub-section analytics-chart-half">
<h4 className="analytics-sub-title">By Protocol</h4>
<MiniPieChart data={protocolData} dataKey="value" nameKey="name" />
<MiniBarChart data={protocolData} dataKey="value" nameKey="name" />
</div>
)}
</div>
)}

{projectData.length > 0 && (
<div className="analytics-sub-section">
<h4 className="analytics-sub-title">Cost per Project</h4>
<MiniBarChart
data={projectData}
dataKey="cost"
nameKey="name"
formatter={(v) => `$${v.toFixed(2)}`}
/>
</div>
)}
</Section>
);
}
Expand Down Expand Up @@ -312,8 +255,7 @@ export function AnalyticsView({ isActive }: AnalyticsViewProps) {

{data && (
<>
<GitHubSection github={data.github} errors={data.errors} />
<BuildersSection builders={data.builders} />
<ActivitySection activity={data.activity} errors={data.errors} />
<ConsultationSection consultation={data.consultation} errors={data.errors} />
</>
)}
Expand Down
12 changes: 3 additions & 9 deletions packages/codev/dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,16 @@ export interface OverviewData {

export interface AnalyticsResponse {
timeRange: '24h' | '7d' | '30d' | 'all';
github: {
activity: {
prsMerged: number;
avgTimeToMergeHours: number | null;
bugBacklog: number;
nonBugBacklog: number;
issuesClosed: number;
avgTimeToCloseBugsHours: number | null;
};
builders: {
projectsCompleted: number;
bugsFixed: number;
throughputPerWeek: number;
activeBuilders: number;
projectsByProtocol: Record<string, number>;
};
consultation: {
totalCount: number;
Expand All @@ -173,10 +171,6 @@ export interface AnalyticsResponse {
}>;
byReviewType: Record<string, number>;
byProtocol: Record<string, number>;
costByProject: Array<{
projectId: string;
totalCost: number;
}>;
};
errors?: {
github?: string;
Expand Down
Loading