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
94 changes: 54 additions & 40 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 (Spec 456, Bugfix #529).
*
* Tests: useAnalytics hook behavior, AnalyticsView rendering,
* null value formatting, error states, and range switching.
Expand All @@ -25,16 +25,11 @@ vi.mock('../src/lib/api.js', () => ({
function makeStats(overrides: Partial<AnalyticsResponse> = {}): AnalyticsResponse {
return {
timeRange: '7d',
github: {
prsMerged: 12,
avgTimeToMergeHours: 3.5,
bugBacklog: 4,
nonBugBacklog: 8,
issuesClosed: 6,
avgTimeToCloseBugsHours: 1.2,
},
builders: {
activity: {
projectsCompleted: 5,
projectsByProtocol: { spir: 3, aspir: 2 },
bugsFixed: 4,
avgTimeToMergeHours: 3.5,
throughputPerWeek: 2.5,
activeBuilders: 2,
},
Expand All @@ -50,10 +45,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 All @@ -78,13 +69,12 @@ describe('useAnalytics', () => {
const { useAnalytics } = await import('../src/hooks/useAnalytics.js');
const { result } = renderHook(() => useAnalytics(true));

// Flush the async effect
await waitFor(() => {
expect(result.current.data).not.toBeNull();
});

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

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

it('renders all three section headers', async () => {
it('renders Activity and Consultation section headers', 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();
});

it('renders GitHub metric values', async () => {
it('does not render separate GitHub or Builders sections', async () => {
mockFetchAnalytics.mockResolvedValue(makeStats());

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

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

expect(screen.getByText('12')).toBeInTheDocument();
expect(screen.getByText('3.5h')).toBeInTheDocument();
expect(screen.queryByText('GitHub')).not.toBeInTheDocument();
expect(screen.queryByText('Builders')).not.toBeInTheDocument();
});

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

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

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

expect(screen.getByText('Bugs Fixed')).toBeInTheDocument();
expect(screen.getByText('5')).toBeInTheDocument(); // projectsCompleted
expect(screen.getByText('4')).toBeInTheDocument(); // bugsFixed
expect(screen.getByText('3.5h')).toBeInTheDocument(); // avgTimeToMerge
});

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

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

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

const dashes = screen.getAllByText('\u2014');
expect(dashes.length).toBeGreaterThanOrEqual(2);
expect(dashes.length).toBeGreaterThanOrEqual(1);
});

it('renders per-section error messages', async () => {
const stats = makeStats({
errors: { github: 'GitHub CLI unavailable' },
errors: { activity: 'Project scan failed' },
});
mockFetchAnalytics.mockResolvedValue(stats);

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

await waitFor(() => {
expect(screen.getByText('GitHub CLI unavailable')).toBeInTheDocument();
expect(screen.getByText('Project scan failed')).toBeInTheDocument();
});
});

Expand All @@ -273,19 +278,28 @@ 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('Cost per Project')).toBeInTheDocument();
expect(screen.getByText('Consultation')).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('Cost per Project')).not.toBeInTheDocument();
});

it('renders Projects by Protocol sub-section', 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();
});
});

it('calls fetchAnalytics with new range when range button is clicked', async () => {
Expand All @@ -295,7 +309,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 +327,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
99 changes: 17 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,77 +98,31 @@ 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,
}));

return (
<Section title="GitHub" error={errors?.github}>
<Section title="Activity" error={errors?.activity}>
<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')} />
<Metric label="Projects Completed" value={String(activity.projectsCompleted)} />
<Metric label="Bugs Fixed" value={String(activity.bugsFixed)} />
<Metric label="Avg Time to Merge" value={fmt(activity.avgTimeToMergeHours, 1, 'h')} />
<Metric label="Throughput / Week" value={fmt(activity.throughputPerWeek)} />
<Metric label="Active Builders" value={String(activity.activeBuilders)} />
</MetricGrid>
{backlogData.length > 0 && (
{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>
<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)} />
</MetricGrid>
</Section>
);
}

function ConsultationSection({ consultation, errors }: { consultation: AnalyticsResponse['consultation']; errors?: AnalyticsResponse['errors'] }) {
const modelData = consultation.byModel.map(m => ({
name: m.model,
Expand All @@ -189,11 +142,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 +196,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 +248,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
Loading