diff --git a/apps/frontend/src/renderer/components/github-issues/components/DependencyList.tsx b/apps/frontend/src/renderer/components/github-issues/components/DependencyList.tsx new file mode 100644 index 00000000..35727b8b --- /dev/null +++ b/apps/frontend/src/renderer/components/github-issues/components/DependencyList.tsx @@ -0,0 +1,119 @@ +import { useTranslation } from 'react-i18next'; +import type { IssueDependencies } from '../../../../shared/types/dependencies'; +import { hasDependencies, totalDependencyCount } from '../../../../shared/types/dependencies'; + +interface DependencyListProps { + dependencies: IssueDependencies; + isLoading: boolean; + error: string | null; + onRefresh?: () => void; +} + +export function DependencyList({ + dependencies, + isLoading, + error, + onRefresh, +}: DependencyListProps) { + const { t } = useTranslation('common'); + const hasData = hasDependencies(dependencies); + const total = totalDependencyCount(dependencies); + + if (isLoading) { + return ( +
+ {t('dependencies.loading')} +
+ ); + } + + if (error) { + return ( +
+

{error}

+ {onRefresh && ( + + )} +
+ ); + } + + if (!hasData) { + return ( +

+ {t('dependencies.none')} +

+ ); + } + + return ( +
+
+ + {t('dependencies.title')} + + + {total} + +
+ + {dependencies.tracks.length > 0 && ( +
+ + {t('dependencies.tracks')} + + +
+ )} + + {dependencies.trackedBy.length > 0 && ( +
+ + {t('dependencies.trackedBy')} + + +
+ )} +
+ ); +} diff --git a/apps/frontend/src/renderer/components/github-issues/components/LabelSyncSettings.tsx b/apps/frontend/src/renderer/components/github-issues/components/LabelSyncSettings.tsx new file mode 100644 index 00000000..d7c58cd4 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-issues/components/LabelSyncSettings.tsx @@ -0,0 +1,76 @@ +import { useTranslation } from 'react-i18next'; + +interface LabelSyncSettingsProps { + enabled: boolean; + isSyncing: boolean; + lastSyncedAt: string | null; + error: string | null; + onEnable: () => void; + onDisable: (cleanup: boolean) => void; +} + +export function LabelSyncSettings({ + enabled, + isSyncing, + lastSyncedAt, + error, + onEnable, + onDisable, +}: LabelSyncSettingsProps) { + const { t } = useTranslation('common'); + + return ( +
+
+
+

+ {t('labelSync.title')} +

+

+ {t('labelSync.description')} +

+
+ +
+ + {enabled && lastSyncedAt && ( +

+ {t('labelSync.lastSynced', { date: new Date(lastSyncedAt).toLocaleDateString() })} +

+ )} + + {enabled && ( + + )} + + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/frontend/src/renderer/components/github-issues/components/MetricsDashboard.tsx b/apps/frontend/src/renderer/components/github-issues/components/MetricsDashboard.tsx new file mode 100644 index 00000000..ba109d09 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-issues/components/MetricsDashboard.tsx @@ -0,0 +1,172 @@ +import { useTranslation } from 'react-i18next'; +import type { TriageMetrics, MetricsTimeWindow } from '../../../../shared/types/metrics'; +import { formatDuration } from '../../../../shared/types/metrics'; +import { + WORKFLOW_STATE_COLORS, + WORKFLOW_STATE_LABELS, +} from '../../../../shared/constants/enrichment'; +import type { WorkflowState } from '../../../../shared/types/enrichment'; + +interface MetricsDashboardProps { + metrics: TriageMetrics; + timeWindow: MetricsTimeWindow; + isLoading: boolean; + error: string | null; + onTimeWindowChange: (window: MetricsTimeWindow) => void; + onRefresh: () => void; +} + +const TIME_WINDOWS: { value: MetricsTimeWindow; label: string }[] = [ + { value: '7d', label: '7 days' }, + { value: '30d', label: '30 days' }, + { value: 'all', label: 'All time' }, +]; + +const STATE_ORDER: WorkflowState[] = ['new', 'triage', 'ready', 'in_progress', 'review', 'done', 'blocked']; + +export function MetricsDashboard({ + metrics, + timeWindow, + isLoading, + error, + onTimeWindowChange, + onRefresh, +}: MetricsDashboardProps) { + const { t } = useTranslation('common'); + + const totalIssues = STATE_ORDER.reduce((sum, s) => sum + (metrics.stateCounts[s] ?? 0), 0); + + return ( +
+ {/* Header */} +
+

+ {t('metrics.title')} +

+
+
+ {TIME_WINDOWS.map((tw) => ( + + ))} +
+ +
+
+ + {error && ( +

{error}

+ )} + + {/* State Distribution */} +
+

+ {t('metrics.stateDistribution')} +

+
+ {STATE_ORDER.map((state) => { + const count = metrics.stateCounts[state] ?? 0; + const pct = totalIssues > 0 ? (count / totalIssues) * 100 : 0; + if (pct === 0) return null; + return ( +
+ ); + })} +
+
+ {STATE_ORDER.map((state) => { + const count = metrics.stateCounts[state] ?? 0; + if (count === 0) return null; + return ( +
+ + {WORKFLOW_STATE_LABELS[state]} + {count} +
+ ); + })} +
+
+ + {/* Completeness Distribution */} +
+

+ {t('metrics.completeness')} +

+
+ {(['low', 'medium', 'high', 'excellent'] as const).map((cat) => ( +
+
+ {metrics.completenessDistribution[cat]} +
+
+ {t(`metrics.completeness_${cat}`)} +
+
+ ))} +
+
+ + {/* Summary Stats */} +
+
+
+ {metrics.totalTransitions} +
+
+ {t('metrics.transitions')} +
+
+
+
+ {metrics.avgBacklogAge > 0 ? formatDuration(metrics.avgBacklogAge) : '—'} +
+
+ {t('metrics.avgBacklog')} +
+
+
+
+ {metrics.weeklyThroughput.length > 0 + ? metrics.weeklyThroughput[metrics.weeklyThroughput.length - 1].count + : 0} +
+
+ {t('metrics.thisWeek')} +
+
+
+
+ ); +} diff --git a/apps/frontend/src/renderer/components/github-issues/components/__tests__/DependencyList.test.tsx b/apps/frontend/src/renderer/components/github-issues/components/__tests__/DependencyList.test.tsx new file mode 100644 index 00000000..d9fce453 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-issues/components/__tests__/DependencyList.test.tsx @@ -0,0 +1,112 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { DependencyList } from '../DependencyList'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('DependencyList', () => { + const emptyDeps = { tracks: [], trackedBy: [] }; + + it('shows loading state', () => { + render(); + expect(screen.getByRole('status').textContent).toBe('dependencies.loading'); + }); + + it('shows error with retry button', () => { + const onRefresh = vi.fn(); + render( + , + ); + expect(screen.getByRole('alert').textContent).toBe('API unavailable'); + fireEvent.click(screen.getByText('dependencies.retry')); + expect(onRefresh).toHaveBeenCalledOnce(); + }); + + it('shows empty state when no dependencies', () => { + render(); + expect(screen.getByText('dependencies.none')).toBeDefined(); + }); + + it('renders tracks', () => { + const deps = { + tracks: [ + { issueNumber: 10, title: 'Sub-task A', state: 'open' as const }, + { issueNumber: 11, title: 'Sub-task B', state: 'closed' as const }, + ], + trackedBy: [], + }; + render(); + expect(screen.getByText('#10')).toBeDefined(); + expect(screen.getByText('#11')).toBeDefined(); + expect(screen.getByText('Sub-task A')).toBeDefined(); + expect(screen.getByText('Sub-task B')).toBeDefined(); + }); + + it('renders trackedBy', () => { + const deps = { + tracks: [], + trackedBy: [ + { issueNumber: 5, title: 'Parent Issue', state: 'open' as const }, + ], + }; + render(); + expect(screen.getByText('#5')).toBeDefined(); + expect(screen.getByText('Parent Issue')).toBeDefined(); + }); + + it('shows cross-repo reference', () => { + const deps = { + tracks: [ + { issueNumber: 10, title: 'Cross-repo', state: 'open' as const, repo: 'org/other' }, + ], + trackedBy: [], + }; + render(); + expect(screen.getByText('org/other#10')).toBeDefined(); + }); + + it('shows total count', () => { + const deps = { + tracks: [{ issueNumber: 1, title: 'A', state: 'open' as const }], + trackedBy: [{ issueNumber: 2, title: 'B', state: 'open' as const }], + }; + render(); + expect(screen.getByText('2')).toBeDefined(); + }); + + it('has accessible region role', () => { + const deps = { + tracks: [{ issueNumber: 1, title: 'A', state: 'open' as const }], + trackedBy: [], + }; + render(); + expect(screen.getByRole('region')).toBeDefined(); + }); + + it('shows state indicator dot with correct color', () => { + const deps = { + tracks: [ + { issueNumber: 10, title: 'Open', state: 'open' as const }, + { issueNumber: 11, title: 'Closed', state: 'closed' as const }, + ], + trackedBy: [], + }; + const { container } = render( + , + ); + const dots = container.querySelectorAll('.rounded-full'); + expect(dots.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/apps/frontend/src/renderer/components/github-issues/components/__tests__/LabelSyncSettings.test.tsx b/apps/frontend/src/renderer/components/github-issues/components/__tests__/LabelSyncSettings.test.tsx new file mode 100644 index 00000000..6e218021 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-issues/components/__tests__/LabelSyncSettings.test.tsx @@ -0,0 +1,92 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { LabelSyncSettings } from '../LabelSyncSettings'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('LabelSyncSettings', () => { + const defaultProps = { + enabled: false, + isSyncing: false, + lastSyncedAt: null, + error: null, + onEnable: vi.fn(), + onDisable: vi.fn(), + }; + + it('renders enable button when disabled', () => { + render(); + expect(screen.getByText('labelSync.enable')).toBeDefined(); + }); + + it('renders disable button when enabled', () => { + render(); + expect(screen.getByText('labelSync.disable')).toBeDefined(); + }); + + it('calls onEnable when enable clicked', () => { + const onEnable = vi.fn(); + render(); + fireEvent.click(screen.getByText('labelSync.enable')); + expect(onEnable).toHaveBeenCalledOnce(); + }); + + it('calls onDisable(false) when disable clicked', () => { + const onDisable = vi.fn(); + render(); + fireEvent.click(screen.getByText('labelSync.disable')); + expect(onDisable).toHaveBeenCalledWith(false); + }); + + it('shows syncing state', () => { + render(); + expect(screen.getByText('labelSync.syncing')).toBeDefined(); + }); + + it('shows last synced date when enabled', () => { + render( + , + ); + // Translation key is used directly in tests + expect(screen.getByText(/labelSync\.lastSynced/)).toBeDefined(); + }); + + it('shows cleanup option when enabled', () => { + render(); + expect(screen.getByText('labelSync.disableAndCleanup')).toBeDefined(); + }); + + it('calls onDisable(true) when cleanup clicked', () => { + const onDisable = vi.fn(); + render(); + fireEvent.click(screen.getByText('labelSync.disableAndCleanup')); + expect(onDisable).toHaveBeenCalledWith(true); + }); + + it('shows error message', () => { + render(); + expect(screen.getByRole('alert').textContent).toBe('Rate limited'); + }); + + it('has accessible region role', () => { + render(); + expect(screen.getByRole('region')).toBeDefined(); + }); + + it('disables buttons while syncing', () => { + render(); + const button = screen.getByText('labelSync.syncing'); + expect(button.getAttribute('disabled')).not.toBeNull(); + }); +}); diff --git a/apps/frontend/src/renderer/components/github-issues/components/__tests__/MetricsDashboard.test.tsx b/apps/frontend/src/renderer/components/github-issues/components/__tests__/MetricsDashboard.test.tsx new file mode 100644 index 00000000..25110b64 --- /dev/null +++ b/apps/frontend/src/renderer/components/github-issues/components/__tests__/MetricsDashboard.test.tsx @@ -0,0 +1,123 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MetricsDashboard } from '../MetricsDashboard'; +import { createEmptyMetrics } from '../../../../../shared/types/metrics'; +import type { TriageMetrics } from '../../../../../shared/types/metrics'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +function makeMetrics(overrides?: Partial): TriageMetrics { + return { ...createEmptyMetrics(), ...overrides }; +} + +describe('MetricsDashboard', () => { + const defaultProps = { + metrics: makeMetrics(), + timeWindow: '30d' as const, + isLoading: false, + error: null, + onTimeWindowChange: vi.fn(), + onRefresh: vi.fn(), + }; + + it('renders title', () => { + render(); + expect(screen.getByText('metrics.title')).toBeDefined(); + }); + + it('renders time window radio buttons', () => { + render(); + const radios = screen.getAllByRole('radio'); + expect(radios).toHaveLength(3); + }); + + it('highlights current time window', () => { + render(); + const radio7d = screen.getAllByRole('radio').find((el) => el.textContent === '7 days'); + expect(radio7d?.getAttribute('aria-checked')).toBe('true'); + }); + + it('calls onTimeWindowChange', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByText('All time')); + expect(onChange).toHaveBeenCalledWith('all'); + }); + + it('shows refresh button', () => { + render(); + fireEvent.click(screen.getByText('metrics.refresh')); + expect(defaultProps.onRefresh).toHaveBeenCalledOnce(); + }); + + it('shows computing state', () => { + render(); + expect(screen.getByText('metrics.computing')).toBeDefined(); + }); + + it('shows error message', () => { + render(); + expect(screen.getByRole('alert').textContent).toBe('Failed'); + }); + + it('renders state counts', () => { + const metrics = makeMetrics({ + stateCounts: { new: 5, triage: 3, ready: 2, in_progress: 1, review: 0, done: 4, blocked: 0 }, + }); + render(); + // State count badges should show values + expect(screen.getByText('5')).toBeDefined(); // new count + expect(screen.getByText('3')).toBeDefined(); // triage count + expect(screen.getByText('4')).toBeDefined(); // done count + }); + + it('renders completeness distribution', () => { + const metrics = makeMetrics({ + completenessDistribution: { low: 2, medium: 3, high: 4, excellent: 1 }, + }); + render(); + expect(screen.getByText('2')).toBeDefined(); // low + expect(screen.getByText('3')).toBeDefined(); // medium + expect(screen.getByText('4')).toBeDefined(); // high + }); + + it('renders total transitions', () => { + const metrics = makeMetrics({ totalTransitions: 42 }); + render(); + expect(screen.getByText('42')).toBeDefined(); + }); + + it('shows dash for zero backlog age', () => { + render(); + expect(screen.getByText('—')).toBeDefined(); + }); + + it('formats backlog age', () => { + const metrics = makeMetrics({ avgBacklogAge: 86_400_000 }); // 1 day + render(); + expect(screen.getByText('1d')).toBeDefined(); + }); + + it('shows this week throughput', () => { + const metrics = makeMetrics({ + weeklyThroughput: [ + { week: '2026-02-03', count: 5 }, + { week: '2026-02-10', count: 8 }, + ], + }); + render(); + expect(screen.getByText('8')).toBeDefined(); // latest week + }); + + it('has accessible region role', () => { + render(); + expect(screen.getByRole('region')).toBeDefined(); + }); +});