diff --git a/src/App.tsx b/src/App.tsx
index c64cdd8..0ffd18d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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';
@@ -100,6 +101,10 @@ function App(): React.JSX.Element {
+
diff --git a/src/components/ActivityPieChart.test.tsx b/src/components/ActivityPieChart.test.tsx
new file mode 100644
index 0000000..8547c30
--- /dev/null
+++ b/src/components/ActivityPieChart.test.tsx
@@ -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 ;
+ },
+}));
+
+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(
+
+ );
+
+ expect(getByTestId('vega-lite-pie-chart')).toBeInTheDocument();
+ });
+
+ it('aggregates data correctly for issues', () => {
+ render();
+ 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();
+ 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();
+ 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();
+ 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();
+ const lastCall = getLastVegaLiteProps();
+ const { title } = lastCall.spec;
+
+ expect(title).toEqual({
+ text: 'Total ISSUE Status Distribution',
+ color: '#C4CBDA'
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/ActivityPieChart.tsx b/src/components/ActivityPieChart.tsx
new file mode 100644
index 0000000..85e84fd
--- /dev/null
+++ b/src/components/ActivityPieChart.tsx
@@ -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();
+
+ 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 ;
+}
\ No newline at end of file