From 6bca085c655f99e954988fd91f140b28256e82aa Mon Sep 17 00:00:00 2001 From: Sean Teramae Date: Wed, 12 Aug 2026 16:04:32 -0700 Subject: [PATCH] feat(studio): MetricTrendPanel Signed-off-by: Sean Teramae --- .../MetricTrendPanel.stories.tsx | 68 ++++++ .../MetricTrendPanel.test.tsx | 84 +++++++ .../charts/MetricTrendPanel/index.tsx | 215 ++++++++++++++++++ 3 files changed, 367 insertions(+) create mode 100644 web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.stories.tsx create mode 100644 web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.test.tsx create mode 100644 web/packages/studio/src/components/charts/MetricTrendPanel/index.tsx diff --git a/web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.stories.tsx b/web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.stories.tsx new file mode 100644 index 0000000000..ac587ef8fd --- /dev/null +++ b/web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.stories.tsx @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Meta, StoryObj } from '@storybook/react'; +import { + MetricTrendPanel, + type MetricTrendPoint, +} from '@studio/components/charts/MetricTrendPanel'; + +/** Deterministic wobbly upward trend so the story renders the same every run. */ +const makePoints = (start: number, step: number, count = 30): MetricTrendPoint[] => + Array.from({ length: count }, (_, i) => ({ + label: `Day ${i + 1}`, + value: Number((start + i * step + (i % 3 === 0 ? -step : step) * 0.8).toFixed(1)), + })); + +const meta: Meta = { + title: 'Charts/MetricTrendPanel', + component: MetricTrendPanel, + args: { + title: 'Primary use cases', + description: + 'Continuously evaluate every merge to main against the full Support-Bench v3 benchmark.', + comparisonLabel: 'vs. 7 days ago', + series: [ + { id: 'solved', label: 'Solved', value: 78.4, delta: 3.3, points: makePoints(62, 0.55) }, + { + id: 'helpfulness', + label: 'Helpfulness', + value: 84.1, + delta: 1.2, + points: makePoints(74, 0.35), + }, + { id: 'tool-use', label: 'Tool Use', value: 69.2, delta: -2.4, points: makePoints(78, -0.3) }, + ], + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { onViewClick: () => {} }, +}; + +export const SingleSeries: Story = { + args: { + series: [ + { id: 'solved', label: 'Solved', value: 78.4, delta: 3.3, points: makePoints(62, 0.55) }, + ], + }, +}; + +export const NegativeDelta: Story = { + args: { selectedSeriesId: 'tool-use' }, +}; + +export const Loading: Story = { + args: { isPending: true }, +}; diff --git a/web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.test.tsx b/web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.test.tsx new file mode 100644 index 0000000000..182816d049 --- /dev/null +++ b/web/packages/studio/src/components/charts/MetricTrendPanel/MetricTrendPanel.test.tsx @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + MetricTrendPanel, + type MetricTrendSeries, +} from '@studio/components/charts/MetricTrendPanel'; +import { fireEvent, render, screen } from '@studio/tests/util/render'; + +const series: MetricTrendSeries[] = [ + { + id: 'solved', + label: 'Solved', + value: 78.4, + delta: 3.3, + points: [ + { label: 'Day 1', value: 70 }, + { label: 'Day 2', value: 78.4 }, + ], + }, + { + id: 'tool-use', + label: 'Tool Use', + value: 69.2, + delta: -2.4, + points: [ + { label: 'Day 1', value: 71 }, + { label: 'Day 2', value: 69.2 }, + ], + }, +]; + +describe('MetricTrendPanel', () => { + it('renders the first series value and delta by default', () => { + render( + + ); + + expect(screen.getByText('78.4%')).toBeInTheDocument(); + expect(screen.getByText('+3.3')).toBeInTheDocument(); + expect(screen.getByText('vs. 7 days ago')).toBeInTheDocument(); + }); + + it('switches series when a pill is clicked', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Tool Use' })); + + expect(screen.getByText('69.2%')).toBeInTheDocument(); + expect(screen.getByText('−2.4')).toBeInTheDocument(); + }); + + it('stays on the controlled series and reports the change', () => { + const onSeriesChange = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Tool Use' })); + + expect(onSeriesChange).toHaveBeenCalledWith('tool-use'); + expect(screen.getByText('78.4%')).toBeInTheDocument(); + }); + + it('calls onViewClick from the header action', () => { + const onViewClick = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: 'View' })); + + expect(onViewClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/packages/studio/src/components/charts/MetricTrendPanel/index.tsx b/web/packages/studio/src/components/charts/MetricTrendPanel/index.tsx new file mode 100644 index 0000000000..7567cefde5 --- /dev/null +++ b/web/packages/studio/src/components/charts/MetricTrendPanel/index.tsx @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Button, + Flex, + PanelContent, + PanelHeader, + PanelRoot, + Stack, + Tag, + Text, +} from '@nvidia/foundations-react-core'; +import { useNvColorMode } from '@studio/components/DagCanvas/useNvColorMode'; +import { StackedSkeleton } from '@studio/components/StackedSkeleton'; +import { Triangle } from 'lucide-react'; +import { FC, useId, useMemo, useState } from 'react'; +import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; + +export interface MetricTrendPoint { + /** X-axis label for the point, shown in the tooltip. */ + label: string; + value: number; +} + +export interface MetricTrendSeries { + id: string; + /** Pill label, e.g. "Solved". */ + label: string; + value: number; + /** Change over the compared period. Positive renders up, negative down. */ + delta?: number; + points: MetricTrendPoint[]; +} + +interface Props { + title: string; + description?: string; + series: MetricTrendSeries[]; + /** Qualifies the delta, e.g. "vs. 7 days ago". */ + comparisonLabel?: string; + /** Controls the active pill. Leave undefined to let the panel manage it. */ + selectedSeriesId?: string; + onSeriesChange?: (seriesId: string) => void; + onViewClick?: () => void; + viewLabel?: string; + formatValue?: (value: number) => string; + formatDelta?: (delta: number) => string; + chartHeight?: number; + isPending?: boolean; +} + +const DEFAULT_CHART_HEIGHT = 160; + +/** + * The area fill sits on a near-black surface in dark mode, so the light-mode opacities + * wash out to almost nothing. Carry more of the accent color, and keep a floor at the + * bottom of the gradient so the fill stays readable all the way down. + */ +const AREA_GRADIENT = { + dark: { top: 0.65, mid: 0.3, bottom: 0.08 }, + light: { top: 0.3, mid: 0.12, bottom: 0 }, +} as const; + +const formatPercent = (value: number): string => `${value.toFixed(1)}%`; + +const formatSignedDelta = (delta: number): string => + `${delta > 0 ? '+' : delta < 0 ? '−' : ''}${Math.abs(delta).toFixed(1)}`; + +export const MetricTrendPanel: FC = ({ + title, + description, + series, + comparisonLabel, + selectedSeriesId, + onSeriesChange, + onViewClick, + viewLabel = 'View', + formatValue = formatPercent, + formatDelta = formatSignedDelta, + chartHeight = DEFAULT_CHART_HEIGHT, + isPending = false, +}) => { + // useId() emits colons, which are not valid inside an SVG `url(#...)` reference. + const gradientId = `metric-trend-${useId().replace(/:/g, '')}`; + const [internalSeriesId, setInternalSeriesId] = useState(series[0]?.id); + + const activeId = selectedSeriesId ?? internalSeriesId; + const active = useMemo( + () => series.find((s) => s.id === activeId) ?? series[0], + [series, activeId] + ); + + const selectSeries = (seriesId: string) => { + if (selectedSeriesId === undefined) { + setInternalSeriesId(seriesId); + } + onSeriesChange?.(seriesId); + }; + + const delta = active?.delta; + const isNegative = delta !== undefined && delta < 0; + const deltaColor = isNegative ? 'red' : 'green'; + const lineColor = isNegative ? 'var(--text-color-accent-red)' : 'var(--text-color-brand)'; + const colorMode = useNvColorMode(); + const gradient = colorMode === 'dark' ? AREA_GRADIENT.dark : AREA_GRADIENT.light; + + return ( + + + + {title} + {description && ( + + {description} + + )} + + {onViewClick && ( + + )} + + + + + {active ? formatValue(active.value) : '—'} + + + {delta !== undefined && ( + + + + {formatDelta(delta)} + + {comparisonLabel && ( + + {comparisonLabel} + + )} + + )} + + {series.length > 1 && ( + + {series.map((s) => { + const isActive = s.id === active?.id; + return ( + selectSeries(s.id)} + > + {s.label} + + ); + })} + + )} + + + + +
+ {isPending || !active ? ( + + ) : ( + + + + + + + + + + + + [formatValue(value), active.label]} + contentStyle={{ + fontSize: 12, + backgroundColor: 'var(--background-color-component-tooltip)', + borderColor: 'var(--border-color-base)', + color: 'var(--text-color-base)', + }} + labelStyle={{ color: 'var(--text-color-base)' }} + itemStyle={{ color: 'var(--text-color-base)' }} + /> + + + + )} +
+
+ ); +};