diff --git a/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx b/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx index 239740a..87ad381 100644 --- a/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx +++ b/packages/app/src/components/calculator/ThroughputCalculatorDisplay.tsx @@ -2,13 +2,13 @@ import { track } from '@/lib/analytics'; import Link from 'next/link'; -import { BarChart3, Download, FileSpreadsheet, Image, RotateCcw, Table2 } from 'lucide-react'; +import { BarChart3, Table2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useGlobalFilters } from '@/components/GlobalFilterContext'; import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; -import { Button } from '@/components/ui/button'; +import { ChartButtons } from '@/components/ui/chart-buttons'; import ChartLegend from '@/components/ui/chart-legend'; import { ModelSelector, @@ -28,6 +28,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { SegmentedToggle, type SegmentedToggleOption } from '@/components/ui/segmented-toggle'; import { Skeleton } from '@/components/ui/skeleton'; import { getModelLabel, @@ -40,8 +41,6 @@ import { import { getModelSortIndex, GPU_SPECS, HARDWARE_CONFIG } from '@/lib/constants'; import { useThemeColors } from '@/hooks/useThemeColors'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { useChartExport } from '@/hooks/useChartExport'; import { getDisplayLabel } from '@/lib/utils'; import { exportToCsv } from '@/lib/csv-export'; import { calculatorChartToCsv } from '@/lib/csv-export-helpers'; @@ -72,133 +71,25 @@ const BAR_METRIC_OPTIONS: { value: BarMetric; label: string }[] = [ { value: 'cost', label: 'Cost' }, ]; -function CalculatorChartButtons({ - viewMode, - setViewMode, - chartId, - onExportCsv, - setIsLegendExpanded, - exportFileName, -}: { - viewMode: 'chart' | 'table'; - setViewMode: (v: 'chart' | 'table') => void; - chartId: string; - onExportCsv: () => void; - setIsLegendExpanded?: (expanded: boolean) => void; - exportFileName?: string; -}) { - const { isExporting, exportToImage } = useChartExport({ - chartId, - setIsLegendExpanded, - exportFileName, - }); - const [exportPopoverOpen, setExportPopoverOpen] = useState(false); +type CalculatorViewMode = 'chart' | 'table'; + +const CALCULATOR_VIEW_MODE_OPTIONS: SegmentedToggleOption[] = [ + { + value: 'chart', + label: 'Chart', + icon: , + testId: 'calculator-chart-view-btn', + }, + { + value: 'table', + label: 'Table', + icon: , + testId: 'calculator-table-view-btn', + }, +]; - const handleExportPng = () => { - setExportPopoverOpen(false); - track('calculator_chart_exported'); - exportToImage(); - }; - - const handleExportCsv = () => { - setExportPopoverOpen(false); - track('calculator_csv_exported'); - onExportCsv(); - window.dispatchEvent(new CustomEvent('inferencex:action')); - }; - - return ( -
-
- - -
- - - - - - - - - - -
- ); -} +const CALCULATOR_MOBILE_VIEW_MODE_OPTIONS: SegmentedToggleOption[] = + CALCULATOR_VIEW_MODE_OPTIONS.map(({ testId: _testId, ...option }) => option); export default function ThroughputCalculatorDisplay() { const { @@ -225,7 +116,7 @@ export default function ThroughputCalculatorDisplay() { const [selectedBars, setSelectedBars] = useState>(new Set()); const [isLegendExpanded, setIsLegendExpanded] = useState(true); const [highContrast, setHighContrast] = useState(false); - const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart'); + const [viewMode, setViewMode] = useState('chart'); const { hardwareConfig, ranges, getResults, loading, error, hasData, availableHwKeys } = useThroughputData(selectedModel, selectedSequence, selectedPrecisions, selectedRunDate); @@ -383,6 +274,11 @@ export default function ThroughputCalculatorDisplay() { exportToCsv(`InferenceX_calculator_${selectedModel}`, headers, rows); }, [results, targetValue, hardwareConfig]); + const handleViewModeChange = useCallback((value: CalculatorViewMode) => { + setViewMode(value); + track('calculator_view_changed', { view: value }); + }, []); + const handleResetGpus = useCallback(() => { setVisibleHwKeys(new Set(availableHwKeys)); track('calculator_gpu_reset', { gpuCount: availableHwKeys.length }); @@ -696,13 +592,23 @@ export default function ThroughputCalculatorDisplay() { {/* Chart / Table */}
- + } /> {loading ? ( @@ -710,56 +616,19 @@ export default function ThroughputCalculatorDisplay() { ) : ( <> {(() => { - const mobileToggle = ( -
- - -
- ); - const captionContent = ( <>

{getChartTitle(barMetric, mode, targetValue, costType, costProvider)}

- {mobileToggle} +

{getModelLabel(selectedModel)} •{' '} diff --git a/packages/app/src/components/gpu-power/GpuPowerDisplay.tsx b/packages/app/src/components/gpu-power/GpuPowerDisplay.tsx index 3d239c3..c5e439c 100644 --- a/packages/app/src/components/gpu-power/GpuPowerDisplay.tsx +++ b/packages/app/src/components/gpu-power/GpuPowerDisplay.tsx @@ -11,6 +11,7 @@ import { Card } from '@/components/ui/card'; import ChartLegend from '@/components/ui/chart-legend'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { SegmentedToggle, type SegmentedToggleOption } from '@/components/ui/segmented-toggle'; import { ShareButton } from '@/components/ui/share-button'; import { ShareTwitterButton, ShareLinkedInButton } from '@/components/share-buttons'; import { @@ -35,6 +36,23 @@ import { ALL_METRIC_OPTIONS, getAvailableMetrics } from './types'; const GPU_COLORS = d3.schemeTableau10; const FEATURE_GATE_KEY = 'inferencex-feature-gate'; +type GpuMetricsView = 'chart' | 'correlation'; + +const GPU_METRICS_VIEW_OPTIONS: SegmentedToggleOption[] = [ + { + value: 'chart', + icon: , + ariaLabel: 'Line chart', + title: 'Line chart', + }, + { + value: 'correlation', + icon: , + ariaLabel: 'Correlation scatter', + title: 'Correlation scatter', + }, +]; + export default function GpuMetricsDisplay() { const router = useRouter(); const [runIdInput, setRunIdInput] = useState('22806827144'); @@ -49,7 +67,7 @@ export default function GpuMetricsDisplay() { const [isLegendExpanded, setIsLegendExpanded] = useState(true); const [downsample, setDownsample] = useState(true); // View toggle + correlation - const [chartView, setChartView] = useState<'chart' | 'correlation'>('chart'); + const [chartView, setChartView] = useState('chart'); const [corrXMetric, setCorrXMetric] = useState('power'); const [corrYMetric, setCorrYMetric] = useState('temperature'); // URL state @@ -205,6 +223,11 @@ export default function GpuMetricsDisplay() { track('gpu_metrics_gpu_reset_filter'); }, [allGpuIndices]); + const handleChartViewChange = useCallback((value: GpuMetricsView) => { + setChartView(value); + track('gpu_metrics_view_changed', { view: value }); + }, []); + return (

@@ -360,33 +383,16 @@ export default function GpuMetricsDisplay() { >
- {/* View toggle */} -
- - -
+ - - -
+
{viewMode === 'table' && ( diff --git a/packages/app/src/components/submissions/SubmissionsDisplay.tsx b/packages/app/src/components/submissions/SubmissionsDisplay.tsx index f736602..15b1f2f 100644 --- a/packages/app/src/components/submissions/SubmissionsDisplay.tsx +++ b/packages/app/src/components/submissions/SubmissionsDisplay.tsx @@ -1,16 +1,16 @@ 'use client'; -import { Download, FileSpreadsheet, Image, Lock, RotateCcw } from 'lucide-react'; +import { Lock } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { track } from '@/lib/analytics'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ChartButtons } from '@/components/ui/chart-buttons'; +import { SegmentedToggle, type SegmentedToggleOption } from '@/components/ui/segmented-toggle'; import { ShareButton } from '@/components/ui/share-button'; import { ShareTwitterButton, ShareLinkedInButton } from '@/components/share-buttons'; -import { useChartExport } from '@/hooks/useChartExport'; import { exportToCsv } from '@/lib/csv-export'; import { submissionsVolumeToCsv } from '@/lib/csv-export-helpers'; import { useSubmissions } from '@/hooks/api/use-submissions'; @@ -21,118 +21,10 @@ import { computeTotalStats } from './submissions-utils'; const CHART_ID = 'submissions-chart'; -function SubmissionsChartButtons({ - chartMode, - onModeChange, - onExportCsv, -}: { - chartMode: ChartMode; - onModeChange: (mode: ChartMode) => void; - onExportCsv: () => void; -}) { - const { isExporting, exportToImage } = useChartExport({ - chartId: CHART_ID, - exportFileName: 'InferenceX_submissions', - }); - const [exportPopoverOpen, setExportPopoverOpen] = useState(false); - - const handleExportPng = () => { - setExportPopoverOpen(false); - track('submissions_chart_exported'); - exportToImage(); - }; - - const handleExportCsv = () => { - setExportPopoverOpen(false); - track('submissions_csv_exported'); - onExportCsv(); - window.dispatchEvent(new CustomEvent('inferencex:action')); - }; - - return ( -
-
- - -
- - - - - - - - - - -
- ); -} +const SUBMISSIONS_CHART_MODE_OPTIONS: SegmentedToggleOption[] = [ + { value: 'weekly', label: 'Weekly', testId: 'submissions-weekly-btn' }, + { value: 'cumulative', label: 'Cumulative', testId: 'submissions-cumulative-btn' }, +]; const FEATURE_GATE_KEY = 'inferencex-feature-gate'; @@ -234,10 +126,22 @@ export default function SubmissionsDisplay() { {/* Activity chart */}
- + } /> {isLoading ? ( diff --git a/packages/app/src/components/ui/chart-buttons.tsx b/packages/app/src/components/ui/chart-buttons.tsx index 761eda5..5db3512 100644 --- a/packages/app/src/components/ui/chart-buttons.tsx +++ b/packages/app/src/components/ui/chart-buttons.tsx @@ -2,11 +2,12 @@ import { track } from '@/lib/analytics'; import { Download, FileSpreadsheet, Image, RotateCcw } from 'lucide-react'; -import { useState } from 'react'; +import { type ReactNode, useState } from 'react'; import { useChartExport } from '@/hooks/useChartExport'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; interface ChartButtonsProps { /** Unique chart ID for export targeting */ @@ -23,6 +24,13 @@ interface ChartButtonsProps { onExportCsv?: () => void; /** Human-readable base name for exported files (e.g. "DeepSeek-R1_throughput_interactivity"). Falls back to chartId. */ exportFileName?: string; + /** + * Optional controls rendered before export/reset buttons, such as a view toggle. + * These inherit this wrapper's desktop-only (`hidden md:flex`) and no-export behavior. + */ + leadingControls?: ReactNode; + /** Optional container class override for positioning/layout variants. */ + className?: string; } /** @@ -41,6 +49,8 @@ export function ChartButtons({ hideZoomReset, onExportCsv, exportFileName, + leadingControls, + className, }: ChartButtonsProps) { const { isExporting, exportToImage } = useChartExport({ chartId, @@ -65,7 +75,13 @@ export function ChartButtons({ }; return ( -
+
+ {leadingControls} {onExportCsv ? ( diff --git a/packages/app/src/components/ui/segmented-toggle.test.ts b/packages/app/src/components/ui/segmented-toggle.test.ts new file mode 100644 index 0000000..b31c477 --- /dev/null +++ b/packages/app/src/components/ui/segmented-toggle.test.ts @@ -0,0 +1,77 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SegmentedToggle, type SegmentedToggleOption } from '@/components/ui/segmented-toggle'; + +let container: HTMLDivElement; +let root: Root; + +const OPTIONS: SegmentedToggleOption<'chart' | 'correlation'>[] = [ + { value: 'chart', label: 'Chart', testId: 'chart-option' }, + { value: 'correlation', ariaLabel: 'Correlation scatter', testId: 'correlation-option' }, +]; + +beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe('SegmentedToggle', () => { + it('passes through tab test IDs, labels, aria-labels, and aria-selected state', () => { + act(() => { + root.render( + React.createElement(SegmentedToggle, { + value: 'chart', + options: OPTIONS, + onValueChange: () => {}, + ariaLabel: 'View mode', + testId: 'view-toggle', + }), + ); + }); + + const tablist = container.querySelector('[data-testid="view-toggle"]'); + const chartButton = container.querySelector('[data-testid="chart-option"]'); + const correlationButton = container.querySelector('[data-testid="correlation-option"]'); + + expect(tablist?.getAttribute('role')).toBe('tablist'); + expect(tablist?.getAttribute('aria-label')).toBe('View mode'); + expect(chartButton?.textContent).toBe('Chart'); + expect(chartButton?.getAttribute('aria-selected')).toBe('true'); + expect(correlationButton?.getAttribute('aria-label')).toBe('Correlation scatter'); + expect(correlationButton?.getAttribute('aria-selected')).toBe('false'); + }); + + it('invokes onValueChange with the selected option value', () => { + const handleValueChange = vi.fn(); + + act(() => { + root.render( + React.createElement(SegmentedToggle, { + value: 'chart', + options: OPTIONS, + onValueChange: handleValueChange, + ariaLabel: 'View mode', + }), + ); + }); + + const correlationButton = container.querySelector( + '[data-testid="correlation-option"]', + ) as HTMLButtonElement; + + act(() => { + correlationButton.click(); + }); + + expect(handleValueChange).toHaveBeenCalledExactlyOnceWith('correlation'); + }); +}); diff --git a/packages/app/src/components/ui/segmented-toggle.tsx b/packages/app/src/components/ui/segmented-toggle.tsx new file mode 100644 index 0000000..8bb9b92 --- /dev/null +++ b/packages/app/src/components/ui/segmented-toggle.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { type ReactNode } from 'react'; + +import { cn } from '@/lib/utils'; + +type SegmentedToggleOptionBase = { + value: TValue; + icon?: ReactNode; + title?: string; + testId?: string; + className?: string; +}; + +type SegmentedToggleOptionContent = + | { + label: string; + ariaLabel?: string; + } + | { + label?: undefined; + ariaLabel: string; + }; + +export type SegmentedToggleOption = SegmentedToggleOptionBase & + SegmentedToggleOptionContent; + +interface SegmentedToggleProps { + value: TValue; + options: SegmentedToggleOption[]; + onValueChange: (value: TValue) => void; + ariaLabel: string; + testId?: string; + className?: string; + buttonClassName?: string; + activeButtonClassName?: string; + inactiveButtonClassName?: string; +} + +export function SegmentedToggle({ + value, + options, + onValueChange, + ariaLabel, + testId, + className, + buttonClassName, + activeButtonClassName = 'bg-muted text-foreground', + inactiveButtonClassName = 'text-muted-foreground hover:text-foreground', +}: SegmentedToggleProps) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +}