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