diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsStackedBarChart.client.test.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsStackedBarChart.client.test.tsx index 04b2fd8a8..b031f66e8 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsStackedBarChart.client.test.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsStackedBarChart.client.test.tsx @@ -7,11 +7,19 @@ import { AnalyticsStackedBarChart } from './AnalyticsStackedBarChart'; const mockRechartsState = vi.hoisted(() => ({ tooltipProps: null as Record | null, barProps: [] as Array>, + lineProps: [] as Array>, + chartData: [] as Array>, yAxisProps: [] as Array>, tooltipPayload: [ { name: 'Radia Perlman', value: 2, color: '#ff9900' }, { name: 'John Richmond', value: 1, color: '#3366ff' }, - ] as Array<{ name: string; value: number; color: string }>, + ] as Array<{ + name: string; + value: number; + color: string; + unit?: string; + payload?: { tokenSegments?: Record }; + }>, })); vi.mock('@/hooks/useIsMobile', () => ({ @@ -25,9 +33,16 @@ vi.mock('recharts', async () => { ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
{children}
), - BarChart: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), + ComposedChart: ({ + children, + data, + }: { + children: React.ReactNode; + data: Array>; + }) => { + mockRechartsState.chartData = data; + return
{children}
; + }, CartesianGrid: () => null, XAxis: () => null, YAxis: (props: Record) => { @@ -38,6 +53,10 @@ vi.mock('recharts', async () => { mockRechartsState.barProps.push(props); return null; }, + Line: (props: Record) => { + mockRechartsState.lineProps.push(props); + return null; + }, Tooltip: (props: Record) => { mockRechartsState.tooltipProps = props; @@ -141,7 +160,7 @@ const COST_CHART: AnalyticsChartResponse = { viewBy: 'provider', metric: 'cost', total: 2, - tokenTotal: 1_500_000, + tokenTotal: 3_500_000, series: [ { key: 'openai', @@ -149,15 +168,21 @@ const COST_CHART: AnalyticsChartResponse = { total: 2, tokenTotal: 1_500_000, }, + { + key: 'google', + label: 'Google', + total: 0, + tokenTotal: 2_000_000, + }, ], buckets: [ { key: '2026-03-27', label: 'Mar 27', total: 2, - tokenTotal: 1_500_000, - segments: { openai: 2 }, - tokenSegments: { openai: 1_500_000 }, + tokenTotal: 3_500_000, + segments: { openai: 2, google: 0 }, + tokenSegments: { openai: 1_500_000, google: 2_000_000 }, }, ], }; @@ -166,6 +191,8 @@ describe('AnalyticsStackedBarChart', () => { beforeEach(() => { mockRechartsState.tooltipProps = null; mockRechartsState.barProps = []; + mockRechartsState.lineProps = []; + mockRechartsState.chartData = []; mockRechartsState.yAxisProps = []; mockRechartsState.tooltipPayload = [ { name: 'Radia Perlman', value: 2, color: '#ff9900' }, @@ -332,8 +359,28 @@ describe('AnalyticsStackedBarChart', () => { ]); }); - it('renders cost and token stacks with matching colors and separate axes', () => { + it('renders grouped cost bars and one aggregate token line on separate axes', () => { const onSelectSegment = vi.fn(); + mockRechartsState.tooltipPayload = [ + { + name: 'OpenAI', + value: 2, + color: '#3366ff', + unit: 'cost', + payload: { + tokenSegments: { openai: 1_500_000, google: 2_000_000 }, + }, + }, + { + name: 'Total tokens', + value: 3_500_000, + color: '#ff0000', + unit: 'tokens', + payload: { + tokenSegments: { openai: 1_500_000, google: 2_000_000 }, + }, + }, + ]; render( { yAxisId: 'cost', }), expect.objectContaining({ - dataKey: 'tokens:openai', - fill: 'var(--color-chart-1)', - fillOpacity: 0.5, - stackId: 'tokens', + dataKey: 'google', + fill: 'var(--color-chart-2)', + stackId: 'cost', + unit: 'cost', + yAxisId: 'cost', + }), + ]); + expect(mockRechartsState.lineProps).toEqual([ + expect.objectContaining({ + dataKey: 'tokenTotal', + name: 'Total tokens', + stroke: 'var(--color-foreground)', + strokeWidth: 3, + type: 'linear', unit: 'tokens', yAxisId: 'tokens', }), ]); + expect(mockRechartsState.chartData).toEqual([ + expect.objectContaining({ + tokenTotal: 3_500_000, + tokenSegments: { openai: 1_500_000, google: 2_000_000 }, + }), + ]); + expect(screen.getByText('1.5M tokens')).toBeInTheDocument(); + expect(screen.getByText('2M tokens')).toBeInTheDocument(); + expect(screen.getByText('3.5M tokens')).toBeInTheDocument(); const costBarClick = mockRechartsState.barProps[0]?.onClick as ( data: unknown, @@ -381,20 +447,6 @@ describe('AnalyticsStackedBarChart', () => { seriesLabel: 'OpenAI', metric: 'cost', }); - - const tokenBarClick = mockRechartsState.barProps[1]?.onClick as ( - data: unknown, - ) => void; - tokenBarClick({ - payload: { bucketKey: '2026-03-27', label: 'Mar 27' }, - }); - expect(onSelectSegment).toHaveBeenLastCalledWith({ - bucketKey: '2026-03-27', - bucketLabel: 'Mar 27', - seriesKey: 'openai', - seriesLabel: 'OpenAI', - metric: 'tokens', - }); }); it('passes display labels to recharts while keeping stable series keys', () => { diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsStackedBarChart.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsStackedBarChart.tsx index 701ff07b1..67f86ccab 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsStackedBarChart.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsStackedBarChart.tsx @@ -3,8 +3,9 @@ import { useMemo, useState, type SyntheticEvent } from 'react'; import { Bar, - BarChart, CartesianGrid, + ComposedChart, + Line, ResponsiveContainer, Tooltip, XAxis, @@ -16,6 +17,7 @@ import type { AnalyticsDimension, AnalyticsGranularity, AnalyticsMetric, + AnalyticsSeries, } from '@/types'; import { cn } from '@/lib/utils'; import { formatInferenceCost, formatTokens } from '@/lib/formatters'; @@ -85,10 +87,6 @@ function getSeriesColor(params: { index: number }) { return CHART_COLORS[params.index % CHART_COLORS.length]; } -function getTokenSeriesDataKey(seriesKey: string) { - return `tokens:${seriesKey}`; -} - function stopTooltipEventPropagation(event: SyntheticEvent) { event.stopPropagation(); } @@ -175,6 +173,7 @@ function AnalyticsTooltip({ label, viewBy, metric, + series, }: { active?: boolean; hoveredSeriesKey: string | null; @@ -183,10 +182,14 @@ function AnalyticsTooltip({ value: number; color: string; unit?: string; + payload?: { + tokenSegments?: Record; + }; }>; label?: string; viewBy: AnalyticsDimension; metric: AnalyticsMetric; + series: AnalyticsSeries[]; }) { if (!active || !payload || payload.length === 0) { return null; @@ -198,7 +201,7 @@ function AnalyticsTooltip({ { name: string; cost: number; tokens: number; color: string } >(); for (const item of payload) { - if (item.value <= 0) { + if (item.unit === 'tokens') { continue; } @@ -208,16 +211,25 @@ function AnalyticsTooltip({ tokens: 0, color: item.color, }; - if (item.unit === 'tokens') { - current.tokens += item.value; - } else { - current.cost += item.value; - } + current.cost += item.value; groupedItems.set(item.name, current); } - const costItems = [...groupedItems.values()].sort((left, right) => - compareTooltipLabels(viewBy, left.name, right.name), - ); + const tokenSegments = payload.find((item) => item.payload?.tokenSegments) + ?.payload?.tokenSegments; + const costItems = series + .map((item, index) => { + const costItem = groupedItems.get(item.label); + return { + name: item.label, + cost: costItem?.cost ?? 0, + tokens: tokenSegments?.[item.key] ?? 0, + color: costItem?.color ?? getSeriesColor({ index }), + }; + }) + .filter((item) => item.cost > 0 || item.tokens > 0) + .sort((left, right) => + compareTooltipLabels(viewBy, left.name, right.name), + ); const totalCost = costItems.reduce((sum, item) => sum + item.cost, 0); const totalTokens = costItems.reduce((sum, item) => sum + item.tokens, 0); @@ -386,18 +398,16 @@ export function AnalyticsStackedBarChart({ } return chart.buckets.map((bucket) => { - const entry: Record = { + const entry: Record> = { bucketKey: bucket.key, label: bucket.label, total: bucket.total, + tokenTotal: bucket.tokenTotal ?? 0, + tokenSegments: bucket.tokenSegments ?? {}, }; for (const series of chart.series) { entry[series.key] = bucket.segments[series.key] ?? 0; - if (chart.object === 'costs') { - entry[getTokenSeriesDataKey(series.key)] = - bucket.tokenSegments?.[series.key] ?? 0; - } } return entry; @@ -406,7 +416,13 @@ export function AnalyticsStackedBarChart({ const metric = chart?.metric ?? 'tasks'; const isCostChart = chart?.object === 'costs'; - const yAxisWidth = isMobile ? 36 : metric === 'cost' ? 72 : 64; + const yAxisWidth = isMobile + ? isCostChart + ? 48 + : 36 + : metric === 'cost' + ? 72 + : 64; const chartMargin = { top: 8, right: 8, @@ -451,7 +467,7 @@ export function AnalyticsStackedBarChart({
- formatTokens(Number(value))} label={ @@ -524,6 +540,7 @@ export function AnalyticsStackedBarChart({ hoveredSeriesKey={hoveredSeriesKey} viewBy={chart.viewBy} metric={metric} + series={chart.series} /> } /> @@ -567,48 +584,21 @@ export function AnalyticsStackedBarChart({ className="cursor-pointer" /> ))} - {isCostChart - ? chart.series.map((series, index) => ( - setHoveredSeriesKey(series.label)} - onMouseLeave={() => setHoveredSeriesKey(null)} - onClick={(data) => { - const bucketKey = - typeof data?.payload?.bucketKey === 'string' - ? data.payload.bucketKey - : null; - const bucketLabel = - typeof data?.payload?.label === 'string' - ? data.payload.label - : ''; - - if (!bucketKey) { - return; - } - - onSelectSegment({ - bucketKey, - bucketLabel, - seriesKey: series.key, - seriesLabel: series.label, - metric: 'tokens', - }); - }} - radius={[0, 0, 0, 0]} - fill={getSeriesColor({ index })} - fillOpacity={0.5} - maxBarSize={64} - className="cursor-pointer" - /> - )) - : null} - + {isCostChart ? ( + + ) : null} +