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
4 changes: 4 additions & 0 deletions apps/frontend/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import { ChangelogPilotView } from './components/ChangelogPilotView';
import { GitHubPRsPilotView } from './components/GitHubPRsPilotView';
import { GitHubIssuesPilotView } from './components/GitHubIssuesPilotView';
import { PatternsPilotView } from './components/PatternsPilotView';
import { AnalyticsPilotView } from './components/AnalyticsPilotView';
import { useSettingsStore, loadSettings, loadProfiles, saveSettings } from './stores/settings-store';
import { useClaudeProfileStore } from './stores/claude-profile-store';
import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store';
Expand Down Expand Up @@ -1063,6 +1064,9 @@ export function App() {
{activeView === 'analytics' && (activeProjectId || selectedProjectId) && (
<Analytics projectId={activeProjectId || selectedProjectId!} />
)}
{activeView === 'analytics-next' && (activeProjectId || selectedProjectId) && (
<AnalyticsPilotView projectId={(activeProjectId || selectedProjectId)!} />
)}
{activeView === 'github-issues' && (activeProjectId || selectedProjectId) && (
<GitHubIssues
onOpenSettings={() => {
Expand Down
183 changes: 183 additions & 0 deletions apps/frontend/src/renderer/__tests__/analytics-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* Tests for the AnalyticsReport → AnalyticsDashboard mapping helpers.
*/

import { describe, expect, it } from 'vitest';
import {
buildCharts,
buildKpis,
buildSections,
formatCost,
formatCount,
formatPercent,
sampleLabels,
shortDate,
successRateTone,
} from '../lib/analytics-ui';
import type { AnalyticsReport } from '../../shared/types';

function makeReport(overrides: Partial<AnalyticsReport> = {}): AnalyticsReport {
return {
summary: {
total_specs: 112,
completed_specs: 98,
failed_specs: 9,
in_progress_specs: 5,
overall_success_rate: 87.4,
total_cost: 142.1,
total_tokens: 4_200_000,
agent_stats: {},
complexity_stats: {},
qa_stats: {
total_reviews: 204,
approved: 181,
rejected: 23,
rejection_rate: 11.3,
common_issues: {},
},
last_updated: '2026-05-22T00:00:00Z',
},
trends: [
{ date: '2026-05-01', success_rate: 80, total_tasks: 10, total_cost: 12 },
{ date: '2026-05-08', success_rate: 84, total_tasks: 14, total_cost: 16 },
{ date: '2026-05-15', success_rate: 87, total_tasks: 16, total_cost: 18 },
],
generated_at: '2026-05-22T00:00:00Z',
...overrides,
};
}

const KPI_LABELS = {
specs: 'specs',
successRate: 'success',
cost: 'cost',
tokens: 'tokens',
specsSub: 'all time',
tokensSub: 'in+out',
};

describe('formatters', () => {
it('formatCount uses k/M with one decimal', () => {
expect(formatCount(420)).toBe('420');
expect(formatCount(4_200)).toBe('4.2k');
expect(formatCount(4_200_000)).toBe('4.2M');
expect(formatCount(-5)).toBe('0');
});

it('formatPercent rounds to a whole percent', () => {
expect(formatPercent(87.4)).toBe('87%');
expect(formatPercent(Number.NaN)).toBe('0%');
});

it('formatCost renders two-decimal USD, clamping bad input', () => {
expect(formatCost(142.1)).toBe('$142.10');
expect(formatCost(-1)).toBe('$0.00');
});
});

describe('successRateTone', () => {
it('buckets the rate into a tone', () => {
expect(successRateTone(90)).toBe('good');
expect(successRateTone(80)).toBe('good');
expect(successRateTone(60)).toBe('warn');
expect(successRateTone(40)).toBe('bad');
});
});

describe('shortDate', () => {
it('formats ISO calendar dates in UTC', () => {
expect(shortDate('2026-05-22', 'en-US')).toBe('May 22');
});

it('passes non-ISO strings through verbatim', () => {
expect(shortDate('week 5', 'en-US')).toBe('week 5');
});
});

describe('sampleLabels', () => {
it('keeps short label lists intact', () => {
expect(sampleLabels(['a', 'b', 'c'], 6)).toEqual(['a', 'b', 'c']);
});

it('evenly samples down to max, keeping first and last', () => {
const labels = Array.from({ length: 12 }, (_, i) => String(i));
const sampled = sampleLabels(labels, 4);
expect(sampled).toHaveLength(4);
expect(sampled[0]).toBe('0');
expect(sampled[3]).toBe('11');
});
});

describe('buildKpis', () => {
it('maps summary + trend sparklines onto four tiles', () => {
const kpis = buildKpis(makeReport(), KPI_LABELS);
expect(kpis).toHaveLength(4);
expect(kpis[0]).toMatchObject({ value: '112', label: 'specs', trendTone: 'info' });
expect(kpis[1]).toMatchObject({ value: '87%', tone: 'good', trendTone: 'good' });
expect(kpis[2].value).toBe('$142.10');
expect(kpis[3].value).toBe('4.2M');
// trend series come from the report trends.
expect(kpis[0].trend).toEqual([10, 14, 16]);
});

it('omits sparklines when there are fewer than two trend points', () => {
const kpis = buildKpis(
makeReport({ trends: [{ date: '2026-05-01', success_rate: 80, total_tasks: 10, total_cost: 12 }] }),
KPI_LABELS,
);
expect(kpis[0].trend).toBeUndefined();
});

it('tones a failing success rate red', () => {
const kpis = buildKpis(
makeReport({ summary: { ...makeReport().summary, overall_success_rate: 40 } }),
KPI_LABELS,
);
expect(kpis[1].tone).toBe('bad');
expect(kpis[1].trendTone).toBe('bad');
});
});

describe('buildCharts', () => {
const labels = {
velocityTitle: 'Velocity',
velocityAria: 'velocity',
tasksSeries: 'Tasks',
rateTitle: 'Rate',
rateAria: 'rate',
rateSeries: 'Success rate',
};

it('builds velocity + rate charts with sampled UTC date labels', () => {
const charts = buildCharts(makeReport(), labels, 'en-US');
expect(charts.map((c) => c.title)).toEqual(['Velocity', 'Rate']);
expect(charts[0].series[0].values).toEqual([10, 14, 16]);
expect(charts[0].xLabels).toEqual(['May 1', 'May 8', 'May 15']);
expect(charts[1].series[0].values).toEqual([80, 84, 87]);
});

it('returns no charts without at least two trend points', () => {
expect(buildCharts(makeReport({ trends: [] }), labels)).toEqual([]);
});
});

describe('buildSections', () => {
it('builds outcome + QA summary cards', () => {
const sections = buildSections(makeReport(), {
outcomesTitle: 'Outcomes',
completed: 'Completed',
failed: 'Failed',
inProgress: 'In progress',
qaTitle: 'QA',
reviews: 'Reviews',
approved: 'Approved',
rejectionRate: 'Rejection rate',
});
expect(sections[0].rows).toEqual([
{ label: 'Completed', value: '98' },
{ label: 'Failed', value: '9' },
{ label: 'In progress', value: '5' },
]);
expect(sections[1].rows[2]).toEqual({ label: 'Rejection rate', value: '11%' });
});
});
140 changes: 140 additions & 0 deletions apps/frontend/src/renderer/components/AnalyticsPilotView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Analytics pilot on the shared design system (U5 B2).
*
* Renders `libs/ui`'s AnalyticsDashboard from the existing
* `analytics.getReport` IPC, mapped with the pure `analytics-ui` helpers.
* Read-only overview — the legacy multi-tab Analytics view keeps the
* agent/QA drilldowns. Reachable via the "Analytics (new UI)" sidebar item.
*/

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AnalyticsDashboard } from '@auto-code/ui';
import type { UiChartCard, UiKpi, UiMetaSection } from '@auto-code/ui';
import type { AnalyticsReport } from '../../shared/types';
import {
buildCharts,
buildKpis,
buildSections,
} from '../lib/analytics-ui';

interface AnalyticsPilotViewProps {
projectId: string;
}

interface PilotState {
report: AnalyticsReport | null;
loading: boolean;
error: Error | null;
}

export function AnalyticsPilotView({
projectId,
}: Readonly<AnalyticsPilotViewProps>) {
const { t, i18n } = useTranslation(['analytics']);
const [state, setState] = useState<PilotState>({
report: null,
loading: true,
error: null,
});
const [reloadKey, setReloadKey] = useState(0);

useEffect(() => {
let active = true;
setState({ report: null, loading: true, error: null });
window.electronAPI.analytics
.getReport(projectId)
.then((result) => {
if (!active) return;
if (!result.success) {
console.error('[AnalyticsPilotView] Failed to load report:', result.error);
setState({
report: null,
loading: false,
error: new Error(t('analytics:analyticsPilot.error')),
});
return;
}
setState({ report: result.data ?? null, loading: false, error: null });
})
.catch((err: unknown) => {
if (!active) return;
console.error('[AnalyticsPilotView] Failed to load report:', err);
setState({
report: null,
loading: false,
error: new Error(t('analytics:analyticsPilot.error')),
});
});
return () => {
active = false;
};
}, [projectId, reloadKey, t]);

const reload = useCallback(() => setReloadKey((key) => key + 1), []);

const kpis = useMemo<UiKpi[] | null>(() => {
if (state.report == null) return null;
return buildKpis(state.report, {
specs: t('analytics:analyticsPilot.kpis.specs'),
successRate: t('analytics:analyticsPilot.kpis.successRate'),
cost: t('analytics:analyticsPilot.kpis.cost'),
tokens: t('analytics:analyticsPilot.kpis.tokens'),
specsSub: t('analytics:analyticsPilot.kpis.specsSub'),
tokensSub: t('analytics:analyticsPilot.kpis.tokensSub'),
});
}, [state.report, t]);

const charts = useMemo<UiChartCard[] | undefined>(() => {
if (state.report == null) return undefined;
return buildCharts(
state.report,
{
velocityTitle: t('analytics:analyticsPilot.charts.velocityTitle'),
velocityAria: t('analytics:analyticsPilot.charts.velocityAria'),
tasksSeries: t('analytics:analyticsPilot.charts.tasksSeries'),
rateTitle: t('analytics:analyticsPilot.charts.rateTitle'),
rateAria: t('analytics:analyticsPilot.charts.rateAria'),
rateSeries: t('analytics:analyticsPilot.charts.rateSeries'),
},
i18n.language,
);
}, [state.report, t, i18n.language]);

const sections = useMemo<UiMetaSection[] | undefined>(() => {
if (state.report == null) return undefined;
return buildSections(state.report, {
outcomesTitle: t('analytics:analyticsPilot.sections.outcomesTitle'),
completed: t('analytics:analyticsPilot.sections.completed'),
failed: t('analytics:analyticsPilot.sections.failed'),
inProgress: t('analytics:analyticsPilot.sections.inProgress'),
qaTitle: t('analytics:analyticsPilot.sections.qaTitle'),
reviews: t('analytics:analyticsPilot.sections.reviews'),
approved: t('analytics:analyticsPilot.sections.approved'),
rejectionRate: t('analytics:analyticsPilot.sections.rejectionRate'),
});
}, [state.report, t]);

const stateLabels = useMemo(
() => ({
loading: t('analytics:analyticsPilot.states.loading'),
retry: t('analytics:analyticsPilot.states.retry'),
empty: t('analytics:analyticsPilot.states.empty'),
}),
[t],
);

return (
<div className="h-full overflow-hidden">
<AnalyticsDashboard
kpis={kpis}
charts={charts}
sections={sections}
loading={state.loading}
error={state.error}
onRetry={reload}
stateLabels={stateLabels}
/>
</div>
);
}
3 changes: 2 additions & 1 deletion apps/frontend/src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import { SessionContextIndicator } from './SessionContextIndicator';
import { NavIndicator } from './NavIndicator';
import type { Project, AutoBuildVersionInfo, GitStatus, ProjectEnvConfig } from '../../shared/types';

export type SidebarView = 'kanban' | 'kanban-next' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'webhooks' | 'github-issues' | 'github-issues-next' | 'gitlab-issues' | 'github-prs' | 'github-prs-next' | 'gitlab-merge-requests' | 'changelog' | 'changelog-next' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'productivity' | 'merge-analytics' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'patterns-next' | 'model-usage' | 'agent-inspector';
export type SidebarView = 'kanban' | 'kanban-next' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'webhooks' | 'github-issues' | 'github-issues-next' | 'gitlab-issues' | 'github-prs' | 'github-prs-next' | 'gitlab-merge-requests' | 'changelog' | 'changelog-next' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'analytics-next' | 'productivity' | 'merge-analytics' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'patterns-next' | 'model-usage' | 'agent-inspector';

interface SidebarProps {
onSettingsClick: () => void;
Expand Down Expand Up @@ -101,6 +101,7 @@ const baseNavItems: NavItem[] = [
{ id: 'context', labelKey: 'navigation:items.context', icon: BookOpen, shortcut: 'C' },
{ id: 'webhooks', labelKey: 'navigation:items.webhooks', icon: Webhook },
{ id: 'analytics', labelKey: 'navigation:items.analytics', icon: BarChart3, shortcut: 'Y' },
{ id: 'analytics-next', labelKey: 'navigation:items.analyticsNext', icon: BarChart3 },
{ id: 'productivity', labelKey: 'navigation:items.productivity', icon: TrendingUp, shortcut: 'P' },
{ id: 'patterns', labelKey: 'navigation:items.patterns', icon: Code, shortcut: 'Z' },
{ id: 'patterns-next', labelKey: 'navigation:items.patternsNext', icon: Code },
Expand Down
Loading
Loading