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 @@ -75,6 +75,7 @@ import { GitHubPRsPilotView } from './components/GitHubPRsPilotView';
import { GitHubIssuesPilotView } from './components/GitHubIssuesPilotView';
import { PatternsPilotView } from './components/PatternsPilotView';
import { AnalyticsPilotView } from './components/AnalyticsPilotView';
import { ProductivityPilotView } from './components/ProductivityPilotView';
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 @@ -1143,6 +1144,9 @@ export function App() {
{activeView === 'productivity' && (activeProjectId || selectedProjectId) && (
<ProductivityDashboard projectId={activeProjectId || selectedProjectId!} />
)}
{activeView === 'productivity-next' && (activeProjectId || selectedProjectId) && (
<ProductivityPilotView projectId={(activeProjectId || selectedProjectId)!} />
)}
{activeView === 'model-usage' && (activeProjectId || selectedProjectId) && (
<ModelUsageDashboard projectId={activeProjectId || selectedProjectId!} />
)}
Expand Down
130 changes: 130 additions & 0 deletions apps/frontend/src/renderer/__tests__/productivity-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Tests for the Productivity{Summary,Trends} → AnalyticsDashboard mappers.
*/

import { describe, expect, it } from 'vitest';
import {
buildProductivityCharts,
buildProductivityKpis,
buildProductivitySections,
formatHours,
} from '../lib/productivity-ui';
import type {
ProductivitySummary,
ProductivityTrendPoint,
} from '../../shared/types';

function makeSummary(overrides: Partial<ProductivitySummary> = {}): ProductivitySummary {
return {
period_start: '2026-05-01T00:00:00Z',
period_end: '2026-05-22T00:00:00Z',
total_specs: 60,
completed_specs: 52,
in_progress_specs: 5,
failed_specs: 3,
total_time_saved_hours: 142.4,
total_build_time_hours: 38.7,
average_success_rate: 0.87,
first_attempt_success_rate: 0.71,
specs_by_type: {},
specs_by_complexity: {},
average_subtasks_per_spec: 4.33,
average_qa_iterations: 1.2,
total_subtasks_completed: 225,
specs: [],
...overrides,
} as ProductivitySummary;
}

const TRENDS: ProductivityTrendPoint[] = [
{ date: '2026-05-01', total_specs: 5, completed_specs: 4, time_saved_hours: 8, success_rate: 0.8 },
{ date: '2026-05-08', total_specs: 7, completed_specs: 6, time_saved_hours: 12, success_rate: 0.85 },
{ date: '2026-05-15', total_specs: 8, completed_specs: 7, time_saved_hours: 14, success_rate: 0.9 },
];

const KPI_LABELS = {
specs: 'specs',
specsSub: 'period',
timeSaved: 'saved',
timeSavedSub: 'est',
successRate: 'success',
buildTime: 'build',
buildTimeSub: 'total',
};

describe('formatHours', () => {
it('rounds to whole hours with an h unit', () => {
expect(formatHours(52.4)).toBe('52h');
expect(formatHours(0)).toBe('0h');
expect(formatHours(-5)).toBe('0h');
});
});

describe('buildProductivityKpis', () => {
it('maps the summary onto four tiles, converting the 0-1 rate to a percent', () => {
const kpis = buildProductivityKpis(makeSummary(), TRENDS, KPI_LABELS);
expect(kpis).toHaveLength(4);
expect(kpis[0]).toMatchObject({ value: '52', label: 'specs' });
expect(kpis[1].value).toBe('142h');
expect(kpis[2]).toMatchObject({ value: '87%', tone: 'good' });
expect(kpis[3].value).toBe('39h');
// sparkline series come from the trends.
expect(kpis[0].trend).toEqual([4, 6, 7]);
expect(kpis[2].trend).toEqual([80, 85, 90]);
});

it('omits sparklines when trends are too short', () => {
const kpis = buildProductivityKpis(makeSummary(), [TRENDS[0]], KPI_LABELS);
expect(kpis[0].trend).toBeUndefined();
});

it('tones a low success rate red', () => {
const kpis = buildProductivityKpis(
makeSummary({ average_success_rate: 0.4 }),
TRENDS,
KPI_LABELS,
);
expect(kpis[2].tone).toBe('bad');
});
});

describe('buildProductivityCharts', () => {
const labels = {
velocityTitle: 'Completed',
velocityAria: 'v',
velocitySeries: 'Completed',
timeSavedTitle: 'Time saved',
timeSavedAria: 't',
timeSavedSeries: 'Hours',
};

it('builds velocity + time-saved charts with UTC date labels', () => {
const charts = buildProductivityCharts(TRENDS, labels, 'en-US');
expect(charts.map((c) => c.title)).toEqual(['Completed', 'Time saved']);
expect(charts[0].series[0].values).toEqual([4, 6, 7]);
expect(charts[1].series[0].values).toEqual([8, 12, 14]);
expect(charts[0].xLabels).toEqual(['May 1', 'May 8', 'May 15']);
});

it('returns no charts without at least two trend points', () => {
expect(buildProductivityCharts([TRENDS[0]], labels)).toEqual([]);
});
});

describe('buildProductivitySections', () => {
it('builds outcome + effort cards with one-decimal effort figures', () => {
const sections = buildProductivitySections(makeSummary(), {
outcomesTitle: 'Outcomes',
completed: 'Completed',
inProgress: 'In progress',
failed: 'Failed',
effortTitle: 'Effort',
subtasksPerSpec: 'Subtasks / spec',
qaIterations: 'QA iterations',
firstAttempt: 'First-attempt rate',
});
expect(sections[0].rows[0]).toEqual({ label: 'Completed', value: '52' });
expect(sections[1].rows[0]).toEqual({ label: 'Subtasks / spec', value: '4.3' });
expect(sections[1].rows[2]).toEqual({ label: 'First-attempt rate', value: '71%' });
});
});
159 changes: 159 additions & 0 deletions apps/frontend/src/renderer/components/ProductivityPilotView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Productivity pilot on the shared design system (U5 B2).
*
* Renders `libs/ui`'s AnalyticsDashboard from the existing
* `getProductivitySummary` + `getProductivityTrends` IPC, mapped with the
* pure `productivity-ui` helpers. Read-only — the legacy Productivity view
* keeps its spec table. Reachable via the "Productivity (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 {
ProductivitySummary,
ProductivityTrendPoint,
} from '../../shared/types';
import {
buildProductivityCharts,
buildProductivityKpis,
buildProductivitySections,
} from '../lib/productivity-ui';

interface ProductivityPilotViewProps {
projectId: string;
}

interface PilotState {
summary: ProductivitySummary | null;
trends: ProductivityTrendPoint[];
loading: boolean;
error: Error | null;
}

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

useEffect(() => {
let active = true;
setState({ summary: null, trends: [], loading: true, error: null });
Promise.all([
window.electronAPI.getProductivitySummary(projectId),
window.electronAPI.getProductivityTrends(projectId),
])
.then(([summaryResult, trendsResult]) => {
if (!active) return;
if (!summaryResult.success || summaryResult.data == null) {
console.error(
'[ProductivityPilotView] Failed to load summary:',
summaryResult.success ? 'no data' : summaryResult.error,
);
setState({
summary: null,
trends: [],
loading: false,
error: new Error(t('analytics:productivityPilot.error')),
});
return;
}
setState({
summary: summaryResult.data,
trends: trendsResult.success ? (trendsResult.data ?? []) : [],
loading: false,
error: null,
});
})
.catch((err: unknown) => {
if (!active) return;
console.error('[ProductivityPilotView] Failed to load productivity:', err);
setState({
summary: null,
trends: [],
loading: false,
error: new Error(t('analytics:productivityPilot.error')),
});
});
return () => {
active = false;
};
}, [projectId, reloadKey, t]);

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

const kpis = useMemo<UiKpi[] | null>(() => {
if (state.summary == null) return null;
return buildProductivityKpis(state.summary, state.trends, {
specs: t('analytics:productivityPilot.kpis.specs'),
specsSub: t('analytics:productivityPilot.kpis.specsSub'),
timeSaved: t('analytics:productivityPilot.kpis.timeSaved'),
timeSavedSub: t('analytics:productivityPilot.kpis.timeSavedSub'),
successRate: t('analytics:productivityPilot.kpis.successRate'),
buildTime: t('analytics:productivityPilot.kpis.buildTime'),
buildTimeSub: t('analytics:productivityPilot.kpis.buildTimeSub'),
});
}, [state.summary, state.trends, t]);

const charts = useMemo<UiChartCard[] | undefined>(() => {
if (state.summary == null) return undefined;
return buildProductivityCharts(
state.trends,
{
velocityTitle: t('analytics:productivityPilot.charts.velocityTitle'),
velocityAria: t('analytics:productivityPilot.charts.velocityAria'),
velocitySeries: t('analytics:productivityPilot.charts.velocitySeries'),
timeSavedTitle: t('analytics:productivityPilot.charts.timeSavedTitle'),
timeSavedAria: t('analytics:productivityPilot.charts.timeSavedAria'),
timeSavedSeries: t('analytics:productivityPilot.charts.timeSavedSeries'),
},
i18n.language,
);
}, [state.summary, state.trends, t, i18n.language]);

const sections = useMemo<UiMetaSection[] | undefined>(() => {
if (state.summary == null) return undefined;
return buildProductivitySections(state.summary, {
outcomesTitle: t('analytics:productivityPilot.sections.outcomesTitle'),
completed: t('analytics:productivityPilot.sections.completed'),
inProgress: t('analytics:productivityPilot.sections.inProgress'),
failed: t('analytics:productivityPilot.sections.failed'),
effortTitle: t('analytics:productivityPilot.sections.effortTitle'),
subtasksPerSpec: t('analytics:productivityPilot.sections.subtasksPerSpec'),
qaIterations: t('analytics:productivityPilot.sections.qaIterations'),
firstAttempt: t('analytics:productivityPilot.sections.firstAttempt'),
});
}, [state.summary, t]);

const stateLabels = useMemo(
() => ({
loading: t('analytics:productivityPilot.states.loading'),
retry: t('analytics:productivityPilot.states.retry'),
empty: t('analytics:productivityPilot.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' | 'analytics-next' | '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' | 'productivity-next' | 'merge-analytics' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'patterns-next' | 'model-usage' | 'agent-inspector';

interface SidebarProps {
onSettingsClick: () => void;
Expand Down Expand Up @@ -103,6 +103,7 @@ const baseNavItems: NavItem[] = [
{ 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: 'productivity-next', labelKey: 'navigation:items.productivityNext', icon: Activity },
{ id: 'patterns', labelKey: 'navigation:items.patterns', icon: Code, shortcut: 'Z' },
{ id: 'patterns-next', labelKey: 'navigation:items.patternsNext', icon: Code },
{ id: 'agent-tools', labelKey: 'navigation:items.agentTools', icon: Wrench, shortcut: 'M' },
Expand Down
Loading
Loading