diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index 29a8ccf08..3017209f2 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -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'; @@ -1063,6 +1064,9 @@ export function App() { {activeView === 'analytics' && (activeProjectId || selectedProjectId) && ( )} + {activeView === 'analytics-next' && (activeProjectId || selectedProjectId) && ( + + )} {activeView === 'github-issues' && (activeProjectId || selectedProjectId) && ( { diff --git a/apps/frontend/src/renderer/__tests__/analytics-ui.test.ts b/apps/frontend/src/renderer/__tests__/analytics-ui.test.ts new file mode 100644 index 000000000..d3e302172 --- /dev/null +++ b/apps/frontend/src/renderer/__tests__/analytics-ui.test.ts @@ -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 { + 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%' }); + }); +}); diff --git a/apps/frontend/src/renderer/components/AnalyticsPilotView.tsx b/apps/frontend/src/renderer/components/AnalyticsPilotView.tsx new file mode 100644 index 000000000..bd04db41d --- /dev/null +++ b/apps/frontend/src/renderer/components/AnalyticsPilotView.tsx @@ -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) { + const { t, i18n } = useTranslation(['analytics']); + const [state, setState] = useState({ + 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(() => { + 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(() => { + 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(() => { + 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 ( + + + + ); +} diff --git a/apps/frontend/src/renderer/components/Sidebar.tsx b/apps/frontend/src/renderer/components/Sidebar.tsx index cb9a3de36..e6dd01d30 100644 --- a/apps/frontend/src/renderer/components/Sidebar.tsx +++ b/apps/frontend/src/renderer/components/Sidebar.tsx @@ -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; @@ -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 }, diff --git a/apps/frontend/src/renderer/lib/analytics-ui.ts b/apps/frontend/src/renderer/lib/analytics-ui.ts new file mode 100644 index 000000000..f043c6d54 --- /dev/null +++ b/apps/frontend/src/renderer/lib/analytics-ui.ts @@ -0,0 +1,189 @@ +/** + * Pure mapping helpers between the AnalyticsReport IPC shape and the shared + * AnalyticsDashboard screen. Localized strings (KPI/section/chart labels) + * stay in the pilot; these helpers produce locale-neutral values + series. + */ + +import type { UiChartCard, UiKpi, UiMetaSection } from '@auto-code/ui'; +import type { AnalyticsReport } from '../../shared/types'; + +/** 4200000 → "4.2M", 4200 → "4.2k", 420 → "420". */ +export function formatCount(count: number): string { + if (!Number.isFinite(count) || count < 0) return '0'; + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`; + return String(Math.round(count)); +} + +/** Whole-number percent with a trailing sign, e.g. 87.4 → "87%". */ +export function formatPercent(pct: number): string { + if (!Number.isFinite(pct)) return '0%'; + return `${Math.round(pct)}%`; +} + +/** USD with two decimals, e.g. 142.1 → "$142.10". */ +export function formatCost(usd: number): string { + if (!Number.isFinite(usd) || usd < 0) return '$0.00'; + return `$${usd.toFixed(2)}`; +} + +/** Bucket a 0–100 success rate into a tile tone. */ +export function successRateTone(pct: number): 'good' | 'warn' | 'bad' { + if (pct >= 80) return 'good'; + if (pct >= 50) return 'warn'; + return 'bad'; +} + +export interface AnalyticsKpiLabels { + specs: string; + successRate: string; + cost: string; + tokens: string; + specsSub: string; + tokensSub: string; +} + +/** Build the KPI tiles from the report summary + trend sparklines. */ +export function buildKpis( + report: AnalyticsReport, + labels: AnalyticsKpiLabels, +): UiKpi[] { + const { summary, trends } = report; + const taskSeries = trends.map((point) => point.total_tasks); + const rateSeries = trends.map((point) => point.success_rate); + const costSeries = trends.map((point) => point.total_cost); + const successTone = successRateTone(summary.overall_success_rate); + return [ + { + value: String(summary.total_specs), + label: labels.specs, + sub: labels.specsSub, + trend: taskSeries.length >= 2 ? taskSeries : undefined, + trendTone: 'info', + }, + { + value: formatPercent(summary.overall_success_rate), + label: labels.successRate, + tone: successTone, + trend: rateSeries.length >= 2 ? rateSeries : undefined, + trendTone: successTone === 'bad' ? 'bad' : 'good', + }, + { + value: formatCost(summary.total_cost), + label: labels.cost, + trend: costSeries.length >= 2 ? costSeries : undefined, + trendTone: 'warn', + }, + { + value: formatCount(summary.total_tokens), + label: labels.tokens, + sub: labels.tokensSub, + }, + ]; +} + +export interface AnalyticsChartLabels { + velocityTitle: string; + velocityAria: string; + tasksSeries: string; + rateTitle: string; + rateAria: string; + rateSeries: string; +} + +/** "2026-05-22" → "May 22" (UTC); non-ISO input passes through verbatim. */ +export function shortDate(iso: string, locale?: string): string { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (match == null) return iso; + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return iso; + return parsed.toLocaleDateString(locale, { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }); +} + +/** Evenly sample up to `max` labels from a series so the axis stays legible. */ +export function sampleLabels(labels: readonly string[], max = 6): string[] { + if (labels.length <= max) return [...labels]; + const step = (labels.length - 1) / (max - 1); + return Array.from({ length: max }, (_, i) => labels[Math.round(i * step)]); +} + +export function buildCharts( + report: AnalyticsReport, + labels: AnalyticsChartLabels, + locale?: string, +): UiChartCard[] { + const { trends } = report; + if (trends.length < 2) return []; + const xLabels = sampleLabels(trends.map((p) => shortDate(p.date, locale))); + return [ + { + title: labels.velocityTitle, + ariaLabel: labels.velocityAria, + showLegend: false, + xLabels, + series: [ + { + label: labels.tasksSeries, + tone: 'info', + values: trends.map((p) => p.total_tasks), + }, + ], + }, + { + title: labels.rateTitle, + ariaLabel: labels.rateAria, + xLabels, + series: [ + { + label: labels.rateSeries, + tone: 'good', + values: trends.map((p) => p.success_rate), + }, + ], + }, + ]; +} + +export interface AnalyticsSectionLabels { + outcomesTitle: string; + completed: string; + failed: string; + inProgress: string; + qaTitle: string; + reviews: string; + approved: string; + rejectionRate: string; +} + +export function buildSections( + report: AnalyticsReport, + labels: AnalyticsSectionLabels, +): UiMetaSection[] { + const { summary } = report; + const qa = summary.qa_stats; + return [ + { + title: labels.outcomesTitle, + rows: [ + { label: labels.completed, value: String(summary.completed_specs) }, + { label: labels.failed, value: String(summary.failed_specs) }, + { label: labels.inProgress, value: String(summary.in_progress_specs) }, + ], + }, + { + title: labels.qaTitle, + rows: [ + { label: labels.reviews, value: String(qa.total_reviews) }, + { label: labels.approved, value: String(qa.approved) }, + { + label: labels.rejectionRate, + value: formatPercent(qa.rejection_rate), + }, + ], + }, + ]; +} diff --git a/apps/frontend/src/shared/i18n/locales/en/analytics.json b/apps/frontend/src/shared/i18n/locales/en/analytics.json index 0c72c45e6..cf9364944 100644 --- a/apps/frontend/src/shared/i18n/locales/en/analytics.json +++ b/apps/frontend/src/shared/i18n/locales/en/analytics.json @@ -3,20 +3,17 @@ "subtitle": "Track agent performance and success metrics", "loading": "Loading analytics data...", "lastUpdated": "Last updated", - "views": { "overview": "Overview", "agents": "Agents", "trends": "Trends", "qa": "QA" }, - "errors": { "title": "Unable to Load Analytics", "loadFailed": "Failed to load analytics data", "noData": "No analytics data available" }, - "overview": { "totalSpecs": "Total Specs", "successRate": "Success Rate", @@ -31,7 +28,6 @@ "avgTime": "avg", "avgCost": "avg" }, - "agents": { "totalAttempts": "total attempts", "successful": "Successful", @@ -41,7 +37,6 @@ "errorPatterns": "Common Error Patterns", "noData": "No agent data available" }, - "trends": { "title": "Trends Over Time", "description": "Historical performance data over time", @@ -54,7 +49,6 @@ "timeSaved": "Time Saved (hours)", "successRate": "Success Rate (%)" }, - "specSummary": { "title": "Spec Overview", "specCount": "{{count}} spec", @@ -68,7 +62,6 @@ "avgQAIterations": "Avg QA Iterations", "workflowBreakdown": "Workflow Type Breakdown" }, - "qa": { "totalReviews": "Total Reviews", "approved": "Approved", @@ -78,5 +71,39 @@ "commonIssues": "Common QA Issues", "issuesDescription": "Most frequently identified issues during QA review", "noIssues": "No QA issues recorded" + }, + "analyticsPilot": { + "error": "Failed to load analytics.", + "states": { + "loading": "Loading analytics…", + "retry": "Retry", + "empty": "No analytics yet." + }, + "kpis": { + "specs": "specs shipped", + "successRate": "success rate", + "cost": "total cost", + "tokens": "tokens used", + "specsSub": "all time", + "tokensSub": "input + output" + }, + "charts": { + "velocityTitle": "Spec velocity", + "velocityAria": "Tasks per day over the trend window", + "tasksSeries": "Tasks", + "rateTitle": "Success rate trend", + "rateAria": "Success rate over the trend window", + "rateSeries": "Success rate" + }, + "sections": { + "outcomesTitle": "Outcomes", + "completed": "Completed", + "failed": "Failed", + "inProgress": "In progress", + "qaTitle": "QA", + "reviews": "Reviews", + "approved": "Approved", + "rejectionRate": "Rejection rate" + } } } diff --git a/apps/frontend/src/shared/i18n/locales/en/navigation.json b/apps/frontend/src/shared/i18n/locales/en/navigation.json index 9dd06f37c..78584fd9e 100644 --- a/apps/frontend/src/shared/i18n/locales/en/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/en/navigation.json @@ -33,7 +33,8 @@ "changelogNext": "Changelog (New UI)", "githubPRsNext": "GitHub PRs (New UI)", "githubIssuesNext": "GitHub Issues (New UI)", - "patternsNext": "Patterns (New UI)" + "patternsNext": "Patterns (New UI)", + "analyticsNext": "Analytics (New UI)" }, "actions": { "settings": "Settings", diff --git a/apps/frontend/src/shared/i18n/locales/fr/analytics.json b/apps/frontend/src/shared/i18n/locales/fr/analytics.json index 7dc24fd25..2b054b63d 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/analytics.json +++ b/apps/frontend/src/shared/i18n/locales/fr/analytics.json @@ -3,20 +3,17 @@ "subtitle": "Suivre les performances de l'agent et les métriques de succès", "loading": "Chargement des données analytiques...", "lastUpdated": "Dernière mise à jour", - "views": { "overview": "Vue d'ensemble", "agents": "Agents", "trends": "Tendances", "qa": "QA" }, - "errors": { "title": "Impossible de charger les analytiques", "loadFailed": "Échec du chargement des données analytiques", "noData": "Aucune donnée analytique disponible" }, - "overview": { "totalSpecs": "Spécifications totales", "successRate": "Taux de réussite", @@ -31,7 +28,6 @@ "avgTime": "moy", "avgCost": "moy" }, - "agents": { "totalAttempts": "tentatives totales", "successful": "Réussi", @@ -41,7 +37,6 @@ "errorPatterns": "Modèles d'erreur courants", "noData": "Aucune donnée d'agent disponible" }, - "trends": { "title": "Tendances au fil du temps", "description": "Données de performance historiques au fil du temps", @@ -54,7 +49,6 @@ "timeSaved": "Temps économisé (heures)", "successRate": "Taux de réussite (%)" }, - "specSummary": { "title": "Apercu des spécifications", "specCount": "{{count}} spécification", @@ -68,7 +62,6 @@ "avgQAIterations": "Itérations QA moyennes", "workflowBreakdown": "Répartition par type de workflow" }, - "qa": { "totalReviews": "Revues totales", "approved": "Approuvé", @@ -78,5 +71,39 @@ "commonIssues": "Problèmes QA courants", "issuesDescription": "Problèmes les plus fréquemment identifiés lors de la revue QA", "noIssues": "Aucun problème QA enregistré" + }, + "analyticsPilot": { + "error": "Échec du chargement de l'analytique.", + "states": { + "loading": "Chargement de l'analytique…", + "retry": "Réessayer", + "empty": "Aucune analytique pour le moment." + }, + "kpis": { + "specs": "spécs livrées", + "successRate": "taux de réussite", + "cost": "coût total", + "tokens": "tokens utilisés", + "specsSub": "depuis le début", + "tokensSub": "entrée + sortie" + }, + "charts": { + "velocityTitle": "Vélocité des spécs", + "velocityAria": "Tâches par jour sur la période de tendance", + "tasksSeries": "Tâches", + "rateTitle": "Tendance du taux de réussite", + "rateAria": "Taux de réussite sur la période de tendance", + "rateSeries": "Taux de réussite" + }, + "sections": { + "outcomesTitle": "Résultats", + "completed": "Terminées", + "failed": "Échouées", + "inProgress": "En cours", + "qaTitle": "QA", + "reviews": "Revues", + "approved": "Approuvées", + "rejectionRate": "Taux de rejet" + } } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/navigation.json b/apps/frontend/src/shared/i18n/locales/fr/navigation.json index 6a13eac9c..c2ce10a78 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/fr/navigation.json @@ -33,7 +33,8 @@ "changelogNext": "Journal des modifications (nouvelle interface)", "githubPRsNext": "PRs GitHub (nouvelle interface)", "githubIssuesNext": "Issues GitHub (nouvelle interface)", - "patternsNext": "Patterns (nouvelle interface)" + "patternsNext": "Patterns (nouvelle interface)", + "analyticsNext": "Analytique (nouvelle interface)" }, "actions": { "settings": "Paramètres", diff --git a/libs/ui/src/client/types.ts b/libs/ui/src/client/types.ts index 0e51aa182..1105b163e 100644 --- a/libs/ui/src/client/types.ts +++ b/libs/ui/src/client/types.ts @@ -251,3 +251,31 @@ export interface UiPattern { /** Dot-separated footer facts (confidence, reuse count, provenance, …). */ footer?: UiPatternFooterStat[]; } + +/** One KPI tile in an analytics dashboard header row. */ +export interface UiKpi { + /** Pre-formatted figure (e.g. "112", "87%", "$4.20"). */ + value: string; + label: string; + sub?: string; + /** Colors the value; default is neutral ink. */ + tone?: 'good' | 'warn' | 'bad'; + /** Optional trend series rendered as a sparkline under the value. */ + trend?: readonly number[]; + /** Sparkline tone; defaults to info. */ + trendTone?: 'info' | 'good' | 'warn' | 'bad' | 'neutral'; +} + +/** One titled chart card in an analytics dashboard. */ +export interface UiChartCard { + title: string; + series: readonly { + label: string; + values: readonly number[]; + tone?: 'info' | 'good' | 'warn' | 'bad' | 'neutral'; + }[]; + xLabels?: readonly string[]; + showLegend?: boolean; + /** Accessible description of the chart. */ + ariaLabel: string; +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 526ec2d31..3e36f74a4 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -68,6 +68,8 @@ export type { UiPrCheckStatus, UiPrReviewer, UiPrStat, + UiKpi, + UiChartCard, CreateTaskInput, TaskStatus, BadgeTone, @@ -121,3 +123,9 @@ export type { PatternLibraryFilter, PatternLibraryStateLabels, } from './screens/PatternLibrary'; +// Analytics dashboard (U5 B2) +export { AnalyticsDashboard } from './screens/AnalyticsDashboard'; +export type { + AnalyticsDashboardProps, + AnalyticsDashboardStateLabels, +} from './screens/AnalyticsDashboard'; diff --git a/libs/ui/src/screens/AnalyticsDashboard.css b/libs/ui/src/screens/AnalyticsDashboard.css new file mode 100644 index 000000000..bd151f675 --- /dev/null +++ b/libs/ui/src/screens/AnalyticsDashboard.css @@ -0,0 +1,117 @@ +/* Generic analytics dashboard ported from .lazyweb/mockups/desktop-analytics.html: + KPI stat-tile row | chart-card column beside an optional summary rail. */ + +.ac-analytics { + font-family: var(--font-sans); + color: var(--ink); + height: 100%; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.ac-analytics__state { + color: var(--muted); + font-size: 14px; + padding: 20px 28px; +} +.ac-analytics__state--error { + color: var(--red); +} +.ac-analytics__retry { + font-family: inherit; + margin-left: 8px; + font-size: 13px; + color: var(--blue); + background: transparent; + border: 1px solid var(--line); + border-radius: 6px; + padding: 2px 10px; + cursor: pointer; +} + +/* --- KPI row --- */ + +.ac-analytics__kpis { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + padding: 18px 28px; +} + +/* --- Two-column body --- */ + +.ac-analytics__body { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 18px; + padding: 0 28px 24px; +} +.ac-analytics__body--no-rail { + grid-template-columns: minmax(0, 1fr); +} + +.ac-analytics__charts { + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; +} +.ac-analytics__chart-card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 16px 18px; +} +.ac-analytics__chart-title { + margin: 0 0 12px; + font-size: 13px; + font-weight: 780; +} + +/* --- Right rail summary cards (shared kv family) --- */ + +.ac-analytics__rail { + display: grid; + gap: 14px; + align-content: start; +} +.ac-analytics__card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px 14px; +} +.ac-analytics__card h3 { + margin: 0 0 6px; + font-size: 13px; + font-weight: 760; +} +.ac-analytics__kv { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + padding: 3px 0; + font-size: 12.5px; +} +.ac-analytics__kv span { + color: var(--quiet); + font-weight: 720; +} +.ac-analytics__kv strong { + font-weight: 720; + overflow-wrap: anywhere; +} + +@media (max-width: 1180px) { + .ac-analytics__body { + grid-template-columns: minmax(0, 1fr); + } +} +@media (max-width: 760px) { + .ac-analytics__kpis, + .ac-analytics__body { + padding-left: 14px; + padding-right: 14px; + } +} diff --git a/libs/ui/src/screens/AnalyticsDashboard.stories.tsx b/libs/ui/src/screens/AnalyticsDashboard.stories.tsx new file mode 100644 index 000000000..ee0c50347 --- /dev/null +++ b/libs/ui/src/screens/AnalyticsDashboard.stories.tsx @@ -0,0 +1,84 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { AnalyticsDashboard } from './AnalyticsDashboard'; + +const TREND = [42, 44, 41, 48, 46, 52, 50, 56, 54, 60, 58, 64]; + +const meta = { + title: 'Screens/AnalyticsDashboard', + component: AnalyticsDashboard, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( + + + + ), + ], + args: { + loading: false, + error: null, + kpis: [ + { value: '112', label: 'specs shipped', sub: 'this quarter', trend: TREND, trendTone: 'good' }, + { value: '87%', label: 'success rate', sub: '+4pt vs last month', tone: 'good', trend: [80, 82, 81, 84, 85, 87], trendTone: 'good' }, + { value: '$142', label: 'total cost', sub: 'across 112 specs', trend: [8, 10, 9, 12, 11, 14, 13, 16], trendTone: 'warn' }, + { value: '4.2M', label: 'tokens used', sub: 'input + output', trend: [30, 28, 32, 31, 35, 34, 38], trendTone: 'info' }, + ], + charts: [ + { + title: 'Spec velocity', + ariaLabel: 'Completed vs failed specs per week over 12 weeks', + showLegend: true, + xLabels: ['W1', 'W3', 'W5', 'W7', 'W9', 'W12'], + series: [ + { label: 'Completed', tone: 'good', values: [4, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13, 16] }, + { label: 'Failed', tone: 'bad', values: [2, 1, 2, 1, 2, 1, 1, 2, 1, 1, 0, 1] }, + ], + }, + { + title: 'Success rate trend', + ariaLabel: 'Overall success rate over 12 weeks', + xLabels: ['W1', 'W6', 'W12'], + series: [{ label: 'Success rate', tone: 'info', values: [72, 74, 71, 78, 80, 82, 81, 84, 85, 86, 87, 88] }], + }, + ], + sections: [ + { + title: 'Outcomes', + rows: [ + { label: 'Completed', value: '98' }, + { label: 'Failed', value: '9' }, + { label: 'In progress', value: '5' }, + ], + }, + { + title: 'QA', + rows: [ + { label: 'Reviews', value: '204' }, + { label: 'Approved', value: '181' }, + { label: 'Rejection rate', value: '11%' }, + ], + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Dashboard: Story = {}; + +export const NoRail: Story = { + args: { sections: undefined }, +}; + +export const Loading: Story = { + args: { kpis: null, loading: true }, +}; + +export const ErrorState: Story = { + args: { kpis: null, error: new Error('Failed to load analytics') }, +}; + +export const Empty: Story = { + args: { kpis: [], charts: undefined, sections: undefined }, +}; diff --git a/libs/ui/src/screens/AnalyticsDashboard.tsx b/libs/ui/src/screens/AnalyticsDashboard.tsx new file mode 100644 index 000000000..a0b2ac8d4 --- /dev/null +++ b/libs/ui/src/screens/AnalyticsDashboard.tsx @@ -0,0 +1,149 @@ +import { LineChart } from '../primitives/LineChart'; +import { Sparkline } from '../primitives/Sparkline'; +import { StatTile } from '../primitives/StatTile'; +import type { + UiChartCard, + UiKpi, + UiMetaSection, +} from '../client/types'; +import './AnalyticsDashboard.css'; + +export interface AnalyticsDashboardStateLabels { + loading?: string; + retry?: string; + empty?: string; +} + +export interface AnalyticsDashboardProps { + /** KPI tiles across the top; null renders the loading/empty state. */ + kpis: UiKpi[] | null; + /** Titled chart cards (line charts) in the main column. */ + charts?: UiChartCard[]; + /** Summary meta cards (outcomes, QA, agents …) in the right rail. */ + sections?: UiMetaSection[]; + loading?: boolean; + error?: Error | null; + onRetry?: () => void; + /** Localized loading/retry/empty labels; falls back to English. */ + stateLabels?: AnalyticsDashboardStateLabels; +} + +/** + * Generic analytics dashboard mirroring the `.lazyweb` layout: a KPI stat-tile + * row, a column of titled line-chart cards, and an optional right rail of + * summary meta cards. Composes the shared chart primitives; data-agnostic, so + * every B2 analytics screen (Analytics, Productivity, Merge, Model Usage) can + * reuse it by mapping its report onto UiKpi[] / UiChartCard[] / UiMetaSection[]. + */ +export function AnalyticsDashboard({ + kpis, + charts, + sections, + loading = false, + error = null, + onRetry, + stateLabels, +}: Readonly) { + if (loading) { + return ( + + + {stateLabels?.loading ?? 'Loading…'} + + + ); + } + + if (error != null) { + return ( + + + {error.message} + {onRetry != null && ( + + {stateLabels?.retry ?? 'Retry'} + + )} + + + ); + } + + if (kpis == null || kpis.length === 0) { + return ( + + + {stateLabels?.empty ?? 'No analytics yet.'} + + + ); + } + + const chartCards = charts ?? []; + const metaCards = sections ?? []; + + return ( + + + {kpis.map((kpi) => ( + = 2 ? ( + + ) : undefined + } + /> + ))} + + + 0 ? '' : ' ac-analytics__body--no-rail' + }`} + > + + {chartCards.map((chart) => ( + + {chart.title} + + + ))} + + + {metaCards.length > 0 && ( + + )} + + + ); +}
+ {stateLabels?.loading ?? 'Loading…'} +
+ {error.message} + {onRetry != null && ( + + {stateLabels?.retry ?? 'Retry'} + + )} +
+ {stateLabels?.empty ?? 'No analytics yet.'} +