Skip to content

Commit

Permalink
Add pie charts for total issue and PR status distribution
Browse files Browse the repository at this point in the history
  • Loading branch information
openhands-agent committed Dec 11, 2024
1 parent 3eeaecb commit 1fdbd6e
Show file tree
Hide file tree
Showing 3 changed files with 268 additions and 0 deletions.
5 changes: 5 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ActivityList } from './components/ActivityList';
import { ActivityFilter } from './components/ActivityFilter';
import { DateRangeFilter } from './components/DateRangeFilter';
import { ActivityChart } from './components/ActivityChart';
import { ActivityPieChart } from './components/ActivityPieChart';
import { LoadingSpinner } from './components/LoadingSpinner';
import { ErrorMessage } from './components/ErrorMessage';
import { ActivityFilter as FilterType, DateRange, AppState } from './types';
Expand Down Expand Up @@ -100,6 +101,10 @@ function App(): React.JSX.Element {
<ActivityChart activities={filteredActivities} type="issue" />
<ActivityChart activities={filteredActivities} type="pr" />
</div>
<div className="chart-container">
<ActivityPieChart activities={filteredActivities} type="issue" />
<ActivityPieChart activities={filteredActivities} type="pr" />
</div>
</section>

<section className="activity-list">
Expand Down
156 changes: 156 additions & 0 deletions src/components/ActivityPieChart.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ActivityPieChart } from './ActivityPieChart';
import { BotActivity } from '../types';

// Mock VegaLite component and capture the spec prop
interface VegaLiteProps {
spec: {
data: { values: any[] };
encoding: {
theta: { field: string; type: string };
color: { scale: { domain: string[]; range: string[] } };
};
title: { text: string; color: string };
};
}

const mockVegaLite = vi.fn();
vi.mock('react-vega', () => ({
VegaLite: (props: VegaLiteProps) => {
mockVegaLite(props);
return <div data-testid="vega-lite-pie-chart" />;
},
}));

function getLastVegaLiteProps(): VegaLiteProps {
expect(mockVegaLite).toHaveBeenCalled();
const lastCall = mockVegaLite.mock.calls[mockVegaLite.mock.calls.length - 1][0];
expect(lastCall).toBeDefined();
return lastCall;
}

describe('ActivityPieChart', () => {
const mockActivities: BotActivity[] = [
{
id: '1',
type: 'issue',
status: 'no_pr',
timestamp: '2023-11-28T12:00:00Z',
url: 'https://github.com/example/1',
title: 'Test Issue 1',
description: 'Issue without PR',
},
{
id: '2',
type: 'issue',
status: 'pr_open',
timestamp: '2023-11-28T13:00:00Z',
url: 'https://github.com/example/2',
title: 'Test Issue 2',
description: 'Issue with open PR',
prUrl: 'https://github.com/example/pr/2',
},
{
id: '3',
type: 'issue',
status: 'pr_merged',
timestamp: '2023-11-28T14:00:00Z',
url: 'https://github.com/example/3',
title: 'Test Issue 3',
description: 'Issue with merged PR',
prUrl: 'https://github.com/example/pr/3',
},
{
id: '4',
type: 'issue',
status: 'pr_closed',
timestamp: '2023-11-28T15:00:00Z',
url: 'https://github.com/example/4',
title: 'Test Issue 4',
description: 'Issue with closed PR',
prUrl: 'https://github.com/example/pr/4',
},
{
id: '5',
type: 'pr',
status: 'success',
timestamp: '2023-11-28T16:00:00Z',
url: 'https://github.com/example/5',
title: 'Test PR 1',
description: 'Successful PR',
},
{
id: '6',
type: 'pr',
status: 'failure',
timestamp: '2023-11-28T17:00:00Z',
url: 'https://github.com/example/6',
title: 'Test PR 2',
description: 'Failed PR',
},
];

it('renders pie chart component', () => {
const { getByTestId } = render(
<ActivityPieChart activities={mockActivities} type="issue" />
);

expect(getByTestId('vega-lite-pie-chart')).toBeInTheDocument();
});

it('aggregates data correctly for issues', () => {
render(<ActivityPieChart activities={mockActivities} type="issue" />);
const lastCall = getLastVegaLiteProps();
const chartData = lastCall.spec.data.values;

expect(chartData).toHaveLength(4);
expect(chartData).toEqual(expect.arrayContaining([
{ status: 'no_pr', count: 1 },
{ status: 'pr_open', count: 1 },
{ status: 'pr_merged', count: 1 },
{ status: 'pr_closed', count: 1 }
]));
});

it('aggregates data correctly for PRs', () => {
render(<ActivityPieChart activities={mockActivities} type="pr" />);
const lastCall = getLastVegaLiteProps();
const chartData = lastCall.spec.data.values;

expect(chartData).toHaveLength(2);
expect(chartData).toEqual(expect.arrayContaining([
{ status: 'success', count: 1 },
{ status: 'failure', count: 1 }
]));
});

it('configures issue color scale correctly', () => {
render(<ActivityPieChart activities={mockActivities} type="issue" />);
const lastCall = getLastVegaLiteProps();
const colorScale = lastCall.spec.encoding.color.scale;

expect(colorScale.domain).toEqual(['no_pr', 'pr_open', 'pr_merged', 'pr_closed']);
expect(colorScale.range).toEqual(['#ffffff', '#4caf50', '#9c27b0', '#f44336']);
});

it('configures PR color scale correctly', () => {
render(<ActivityPieChart activities={mockActivities} type="pr" />);
const lastCall = getLastVegaLiteProps();
const colorScale = lastCall.spec.encoding.color.scale;

expect(colorScale.domain).toEqual(['success', 'failure']);
expect(colorScale.range).toEqual(['#22c55e', '#ef4444']);
});

it('configures chart title correctly', () => {
render(<ActivityPieChart activities={mockActivities} type="issue" />);
const lastCall = getLastVegaLiteProps();
const { title } = lastCall.spec;

expect(title).toEqual({
text: 'Total ISSUE Status Distribution',
color: '#C4CBDA'
});
});
});
107 changes: 107 additions & 0 deletions src/components/ActivityPieChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useMemo } from 'react';
import { VegaLite } from 'react-vega';

import { BotActivity, ActivityType } from '../types';

interface ActivityPieChartProps {
activities: BotActivity[];
type: ActivityType;
}

interface ChartData {
status: string;
count: number;
}

type ChartSpec = {
$schema: string;
data: { values: ChartData[] };
mark: { type: 'arc'; innerRadius: number };
encoding: {
theta: {
field: 'count';
type: 'quantitative';
};
color: {
field: 'status';
type: 'nominal';
title: 'Status';
scale?: {
domain: string[];
range: string[];
};
legend?: {
labelColor: string;
titleColor: string;
};
};
};
width: number;
height: number;
title: string | { text: string; color: string };
background?: string;
config?: {
view: {
stroke: string;
};
};
}

export function ActivityPieChart({ activities, type }: ActivityPieChartProps): React.JSX.Element {
const chartData = useMemo((): ChartData[] => {
const filteredActivities = activities.filter(a => a.type === type);
const statusCounts = new Map<string, number>();

filteredActivities.forEach(activity => {
const count = statusCounts.get(activity.status) || 0;
statusCounts.set(activity.status, count + 1);
});

return Array.from(statusCounts.entries()).map(([status, count]) => ({
status,
count,
}));
}, [activities, type]);

const spec: ChartSpec = {
$schema: 'https://vega.github.io/schema/vega-lite/v5.json',
data: { values: chartData },
mark: { type: 'arc', innerRadius: 50 },
encoding: {
theta: {
field: 'count',
type: 'quantitative'
},
color: {
field: 'status',
type: 'nominal',
title: 'Status',
scale: type === 'issue' ? {
domain: ['no_pr', 'pr_open', 'pr_merged', 'pr_closed'],
range: ['#ffffff', '#4caf50', '#9c27b0', '#f44336']
} : {
domain: ['success', 'failure'],
range: ['#22c55e', '#ef4444']
},
legend: {
labelColor: '#C4CBDA',
titleColor: '#C4CBDA'
}
},
},
width: 300,
height: 300,
background: '#1f2228',
title: {
text: `Total ${type.toUpperCase()} Status Distribution`,
color: '#C4CBDA'
},
config: {
view: {
stroke: 'transparent'
}
}
};

return <VegaLite spec={spec} />;
}

0 comments on commit 1fdbd6e

Please sign in to comment.