diff --git a/packages/app/cypress/component/date-range-picker.cy.tsx b/packages/app/cypress/component/date-range-picker.cy.tsx index 9fd7c4ed..516fdd62 100644 --- a/packages/app/cypress/component/date-range-picker.cy.tsx +++ b/packages/app/cypress/component/date-range-picker.cy.tsx @@ -5,9 +5,11 @@ import { type DateRange, DateRangePicker } from '@/components/ui/date-range-pick function DateRangePickerHarness({ initialRange = { startDate: '', endDate: '' }, availableDates, + disabled, }: { initialRange?: DateRange; availableDates?: string[]; + disabled?: boolean; }) { const [range, setRange] = useState(initialRange); return ( @@ -17,6 +19,7 @@ function DateRangePickerHarness({ onChange={setRange} availableDates={availableDates} placeholder="Select date range" + disabled={disabled} />
{range.startDate && range.endDate ? `${range.startDate} to ${range.endDate}` : 'no range'} @@ -31,6 +34,13 @@ describe('DateRangePicker', () => { cy.get('[data-testid="date-range-wrapper"]').should('contain', 'Select date range'); }); + it('does not open dialog when disabled', () => { + cy.mount(); + cy.contains('Select date range').should('be.disabled'); + cy.contains('Select date range').click({ force: true }); + cy.get('[role="dialog"]').should('not.exist'); + }); + it('opens dialog when clicked', () => { cy.mount(); cy.contains('Select date range').click(); @@ -64,7 +74,7 @@ describe('DateRangePicker', () => { const dates = ['2025-12-01', '2025-12-15', '2026-01-01', '2026-02-01', '2026-03-01']; cy.mount(); cy.contains('Select date range').click(); - cy.contains('button', 'Max Range').should('be.visible'); + cy.contains('button', 'All Time').should('be.visible'); }); it('shows overlay when no available dates', () => { diff --git a/packages/app/cypress/component/inference-chart-controls.cy.tsx b/packages/app/cypress/component/inference-chart-controls.cy.tsx index 03e6a50c..197ee814 100644 --- a/packages/app/cypress/component/inference-chart-controls.cy.tsx +++ b/packages/app/cypress/component/inference-chart-controls.cy.tsx @@ -1,4 +1,5 @@ import InferenceChartControls from '@/components/inference/ui/ChartControls'; +import GpuComparisonCard from '@/components/inference/ui/GpuComparisonCard'; import { mountWithProviders } from '../support/test-utils'; describe('Inference ChartControls', () => { @@ -41,39 +42,132 @@ describe('Inference ChartControls', () => { cy.get('@setSelectedYAxisMetric').should('have.been.calledOnce'); }); - it('hides the GPU comparison section when no GPUs are selected', () => { - // Default mock: selectedGPUs = [] — GPU date range pickers should not render + it('does not render GPU comparison date controls (moved to GpuComparisonCard)', () => { cy.contains('Comparison Date Range').should('not.exist'); - cy.contains('Intermediary Dates').should('not.exist'); + cy.contains('GPU Comparison').should('not.exist'); }); +}); + +describe('GpuComparisonCard', () => { + const gpuOptions = [ + { value: 'h100_sglang', label: 'H100 SGLang' }, + { value: 'b200_sglang', label: 'B200 SGLang' }, + { value: 'mi300x_sglang', label: 'MI300X SGLang' }, + { value: 'h200_sglang', label: 'H200 SGLang' }, + ]; - it('renders the GPU config multi-select', () => { - // The GPU Config label should be present (hideGpuComparison defaults to false) - cy.contains('GPU Config').should('be.visible'); + it('renders GPU multiselect; date range disabled until a GPU is selected', () => { + mountWithProviders(, { + inference: { + selectedGPUs: [], + availableGPUs: gpuOptions, + }, + }); + + cy.get('[data-testid="gpu-comparison-card"]').should('be.visible'); + cy.contains('GPU Comparison').should('be.visible'); + cy.contains('Select one or more GPUs for date range comparison.').should('not.exist'); + cy.get('[data-testid="gpu-comparison-expand-toggle"]').should( + 'have.attr', + 'aria-expanded', + 'false', + ); + cy.get('[data-testid="gpu-comparison-expand-toggle"]').click(); + cy.contains('Select one or more GPUs for date range comparison.').should('be.visible'); cy.get('[data-testid="gpu-multiselect"]').should('be.visible'); + cy.get('[data-testid="gpu-multiselect-trigger"]').should('be.visible'); + cy.contains('Comparison Date Range').should('be.visible'); + cy.get('#gpu-comparison-date-picker').should('be.disabled'); + cy.get('[data-testid="date-range-shortcuts"]').should('be.visible'); + cy.get('[data-testid="date-shortcut-all-time"]').should('be.disabled'); + cy.get('[data-testid="date-shortcut-last-90-days"]').should('be.disabled'); + cy.get('[data-testid="date-shortcut-last-30-days"]').should('be.disabled'); }); -}); -describe('Inference ChartControls with GPUs selected', () => { - it('shows the date range picker when GPUs are selected', () => { - mountWithProviders(, { + it('enables date range picker and shortcuts when one GPU is selected', () => { + mountWithProviders(, { inference: { - selectedGPUs: ['h100'], + selectedGPUs: ['h100_sglang'], selectedDateRange: { startDate: '', endDate: '' }, + availableGPUs: gpuOptions, + dateRangeAvailableDates: ['2025-10-05', '2025-11-01', '2025-12-01'], }, }); - cy.contains('Comparison Date Range').should('be.visible'); + cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); + cy.get('[data-testid="date-shortcut-all-time"]').should('not.be.disabled'); + }); + + it('shows enabled shortcut buttons that call setSelectedDateRange when two GPUs are selected', () => { + mountWithProviders(, { + inference: { + selectedGPUs: ['h100_sglang', 'b200_sglang'], + selectedDateRange: { startDate: '', endDate: '' }, + availableGPUs: gpuOptions, + dateRangeAvailableDates: [ + '2025-10-05', + '2025-11-01', + '2025-12-01', + '2026-01-01', + '2026-02-01', + '2026-03-01', + ], + }, + }); + + cy.get('[data-testid="date-shortcut-all-time"]').should('not.be.disabled'); + cy.get('[data-testid="date-shortcut-all-time"]').click(); + cy.get('@setSelectedDateRange').should('have.been.calledOnce'); }); -}); -describe('Inference ChartControls with hideGpuComparison', () => { - it('hides GPU config selector when hideGpuComparison is true', () => { - mountWithProviders(, { - inference: {}, + it('selecting a third GPU from the multiselect calls setSelectedGPUs with three GPUs', () => { + mountWithProviders(, { + inference: { + selectedGPUs: ['h100_sglang', 'b200_sglang'], + selectedDateRange: { startDate: '', endDate: '' }, + availableGPUs: gpuOptions, + }, + }); + + cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); + cy.get('[data-testid="gpu-multiselect-trigger"]').click(); + cy.contains('[role="option"]', 'MI300X SGLang').click(); + cy.get('@setSelectedGPUs').should( + 'have.been.calledWith', + Cypress.sinon.match((v: string[]) => v.length === 3 && v.includes('mi300x_sglang')), + ); + }); + + it('shows max-selection summary when four GPUs are selected', () => { + mountWithProviders(, { + inference: { + selectedGPUs: ['h100_sglang', 'b200_sglang', 'mi300x_sglang', 'h200_sglang'], + selectedDateRange: { startDate: '', endDate: '' }, + availableGPUs: gpuOptions, + }, + }); + + // Wait for card expansion, then open the dropdown. + // With 4 chips the center of the trigger may land on a chip's remove + // button (which stopPropagates), so target the chevron icon instead. + cy.get('[data-testid="gpu-multiselect-trigger"]').should('be.visible'); + cy.get('[data-testid="gpu-multiselect-trigger"] svg').last().click(); + cy.contains('4 / 4 selected').should('be.visible'); + }); + + it('calls setSelectedGPUs without the removed GPU when a chip remove control is clicked', () => { + mountWithProviders(, { + inference: { + selectedGPUs: ['h100_sglang', 'b200_sglang', 'mi300x_sglang'], + selectedDateRange: { startDate: '', endDate: '' }, + availableGPUs: gpuOptions, + }, }); - cy.contains('GPU Config').should('not.exist'); - cy.get('[data-testid="gpu-multiselect"]').should('not.exist'); + cy.get('[aria-label="Remove MI300X SGLang"]').click(); + cy.get('@setSelectedGPUs').should( + 'have.been.calledWith', + Cypress.sinon.match((v: string[]) => v.length === 2 && !v.includes('mi300x_sglang')), + ); }); }); diff --git a/packages/app/cypress/e2e/historical-trends.cy.ts b/packages/app/cypress/e2e/historical-trends.cy.ts index f0a70a56..10975000 100644 --- a/packages/app/cypress/e2e/historical-trends.cy.ts +++ b/packages/app/cypress/e2e/historical-trends.cy.ts @@ -123,8 +123,8 @@ describe('Historical Trends — Content & Interactions', () => { cy.get('#historical-log-scale').should('have.attr', 'data-state', 'unchecked'); }); - it('GPU Config multi-select is hidden (Historical Trends uses hideGpuComparison)', () => { - cy.get('[data-testid="gpu-multiselect"]').should('not.exist'); + it('GPU Comparison card is not shown on Historical Trends (inference-only)', () => { + cy.get('[data-testid="gpu-comparison-card"]').should('not.exist'); }); it('Y-axis metric selector is present and can be changed', () => { diff --git a/packages/app/cypress/e2e/inference-chart.cy.ts b/packages/app/cypress/e2e/inference-chart.cy.ts index 93350d69..eeb4362b 100644 --- a/packages/app/cypress/e2e/inference-chart.cy.ts +++ b/packages/app/cypress/e2e/inference-chart.cy.ts @@ -1,3 +1,8 @@ +/** Opens dropdown without hitting chip remove controls (they stopPropagation). */ +function openGpuMultiselect() { + cy.get('[data-testid="gpu-multiselect-trigger"]').find('svg').last().click(); +} + describe('Inference Chart', () => { before(() => { cy.window().then((win) => { @@ -47,6 +52,85 @@ describe('Inference Chart', () => { }); it('shows the sidebar legend for GPU types', () => { - cy.get('.sidebar-legend').should('be.visible'); + // GpuComparisonCard sits above charts; first chart legend may be below the fold. + cy.get('.sidebar-legend').first().scrollIntoView(); + cy.get('.sidebar-legend').first().should('be.visible'); + }); +}); + +describe('GPU Comparison Card', () => { + beforeEach(() => { + cy.visit('/inference', { + onBeforeLoad(win) { + win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); + }, + }); + cy.get('[data-testid="gpu-comparison-card"]').should('be.visible'); + }); + + it('starts collapsed; expanding shows GPU comparison controls', () => { + cy.contains('Select one or more GPUs for date range comparison.').should('not.exist'); + cy.get('[data-testid="gpu-comparison-expand-toggle"]').should( + 'have.attr', + 'aria-expanded', + 'false', + ); + cy.get('[data-testid="gpu-comparison-expand-toggle"]').click(); + cy.get('[data-testid="gpu-comparison-expand-toggle"]').should( + 'have.attr', + 'aria-expanded', + 'true', + ); + cy.contains('Select one or more GPUs for date range comparison.').should('be.visible'); + cy.get('[data-testid="gpu-multiselect-trigger"]').should('be.visible'); + }); + + describe('when expanded', () => { + beforeEach(() => { + cy.get('[data-testid="gpu-comparison-expand-toggle"]').click(); + }); + + it('renders the GPU comparison card with a single GPU multiselect', () => { + cy.get('[data-testid="gpu-multiselect"]').should('be.visible'); + cy.get('[data-testid="gpu-multiselect-trigger"]').should('be.visible'); + }); + + it('shows date range shortcuts that are disabled until a GPU is selected', () => { + cy.get('[data-testid="date-range-shortcuts"]').should('be.visible'); + cy.get('[data-testid="date-shortcut-all-time"]').should('be.disabled'); + }); + + it('selects one GPU and verifies date range controls unlock', () => { + openGpuMultiselect(); + cy.get('[role="option"]').first().click(); + + // "All Time" stays disabled when the selected GPU has only one date in fixtures. + cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); + }); + + it('selects two GPUs and verifies date range auto-defaults', () => { + openGpuMultiselect(); + cy.get('[role="option"]').first().click(); + + // Pick eq(2) so the second GPU is a different family (e.g. b300) with a + // distinct date — eq(1) can be the MTP pair of eq(0) sharing one date. + openGpuMultiselect(); + cy.get('[role="option"]').eq(2).click(); + + cy.get('[data-testid="date-shortcut-all-time"]').should('not.be.disabled'); + cy.get('#gpu-comparison-date-picker').should('not.be.disabled'); + }); + + it('selecting a third GPU adds a third removable chip to the multiselect', () => { + openGpuMultiselect(); + cy.get('[role="option"]').first().click(); + openGpuMultiselect(); + cy.get('[role="option"]').eq(1).click(); + openGpuMultiselect(); + cy.get('[role="option"]').eq(2).click(); + cy.get('[data-testid="gpu-multiselect"]') + .find('[aria-label^="Remove "]') + .should('have.length', 3); + }); }); }); diff --git a/packages/app/src/components/dashboard-shell.tsx b/packages/app/src/components/dashboard-shell.tsx index 17eb1386..c7bd2065 100644 --- a/packages/app/src/components/dashboard-shell.tsx +++ b/packages/app/src/components/dashboard-shell.tsx @@ -11,7 +11,7 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
-
+
{children}
diff --git a/packages/app/src/components/inference/InferenceContext.tsx b/packages/app/src/components/inference/InferenceContext.tsx index a2b8752b..2eaa852b 100644 --- a/packages/app/src/components/inference/InferenceContext.tsx +++ b/packages/app/src/components/inference/InferenceContext.tsx @@ -52,6 +52,7 @@ import { clearAllMtpFamilies, resolveMtpToggle } from '@/lib/mtp-exclusion'; import { filterRunsByModel, getDisplayLabel } from '@/lib/utils'; import { useChartData } from './hooks/useChartData'; +import { normalizeComparisonGpuList } from './utils/normalize-comparison-gpus'; /** @internal Exported for test provider wrapping only. */ export const InferenceContext = createContext(undefined); @@ -108,7 +109,8 @@ export function InferenceProvider({ // ── Inference-specific filter state ───────────────────────────────────────── const [selectedGPUs, setSelectedGPUs] = useState(() => { const urlGpus = getUrlParam('i_gpus'); - return urlGpus ? urlGpus.split(',').filter(Boolean) : []; + const parts = urlGpus ? urlGpus.split(',').filter(Boolean) : []; + return normalizeComparisonGpuList(parts); }); const [selectedYAxisMetric, setSelectedYAxisMetric] = useState( () => getUrlParam('i_metric') || 'y_tpPerGpu', @@ -636,6 +638,27 @@ export function InferenceProvider({ } }, [dateRangeAvailableDates]); + // Auto-default to max date range once when GPU comparison first becomes ready. + // Uses a ref to fire only on the transition from 0 GPUs to >=1, avoiding a loop + // when the user intentionally clears the date range. + const prevGpuCountRef = useRef(selectedGPUs.length); + useEffect(() => { + const wasBelow = prevGpuCountRef.current < 1; + prevGpuCountRef.current = selectedGPUs.length; + if (!wasBelow) return; + if (selectedGPUs.length === 0) return; + if (selectedDateRange.startDate && selectedDateRange.endDate) return; + if (dateRangeAvailableDates.length < 2) return; + const startDate = dateRangeAvailableDates[0]; + const endDate = dateRangeAvailableDates.at(-1)!; + setSelectedDateRange({ startDate, endDate }); + track('inference_date_range_auto_defaulted', { + startDate, + endDate, + gpuCount: selectedGPUs.length, + }); + }, [selectedGPUs, selectedDateRange, dateRangeAvailableDates]); + useEffect(() => { setActiveDates(allDateIds); }, [allDateIds, setActiveDates]); @@ -814,7 +837,7 @@ export function InferenceProvider({ setActivePresetId(preset.id); setHighContrast(true); if (config.gpus && config.gpus.length > 0) { - setSelectedGPUs(config.gpus); + setSelectedGPUs(normalizeComparisonGpuList(config.gpus)); if (config.useDateRange) { setSelectedDateRange({ startDate: '', endDate: '' }); setSelectedDates([]); diff --git a/packages/app/src/components/inference/ui/ChartControls.tsx b/packages/app/src/components/inference/ui/ChartControls.tsx index 0b1705b0..ec6db4a0 100644 --- a/packages/app/src/components/inference/ui/ChartControls.tsx +++ b/packages/app/src/components/inference/ui/ChartControls.tsx @@ -10,9 +10,7 @@ import { SequenceSelector, PrecisionSelector, } from '@/components/ui/chart-selectors'; -import { DateRangePicker } from '@/components/ui/date-range-picker'; import { LabelWithTooltip } from '@/components/ui/label-with-tooltip'; -import { MultiSelect } from '@/components/ui/multi-select'; import { Select, SelectContent, @@ -73,12 +71,7 @@ const GROUPED_Y_AXIS_OPTIONS = METRIC_GROUPS.map((group) => ({ .map((m) => ({ value: m, label: METRIC_TITLE_MAP.get(m)! })), })).filter((g) => g.options.length > 0); -interface ChartControlsProps { - /** Hide GPU Config selector and related date pickers (used by Historical Trends tab) */ - hideGpuComparison?: boolean; -} - -export default function ChartControls({ hideGpuComparison = false }: ChartControlsProps) { +export default function ChartControls() { const [openDropdown, setOpenDropdown] = useState(null); const handleDropdownOpenChange = (dropdownKey: string) => (open: boolean) => { if (open) { @@ -97,13 +90,6 @@ export default function ChartControls({ hideGpuComparison = false }: ChartContro selectedYAxisMetric, setSelectedYAxisMetric, graphs, - selectedGPUs, - setSelectedGPUs, - availableGPUs, - selectedDateRange, - setSelectedDateRange, - dateRangeAvailableDates, - isCheckingAvailableDates, availablePrecisions, availableSequences, availableModels, @@ -164,14 +150,6 @@ export default function ChartControls({ hideGpuComparison = false }: ChartContro setTimeout(trackCombinedFilters, 0); }; - const handleGPUChange = (value: string[]) => { - setSelectedGPUs(value); - track('inference_gpu_selected', { - gpus: value.join(','), - }); - setTimeout(trackCombinedFilters, 0); - }; - const handleXAxisMetricChange = (value: string) => { setSelectedXAxisMetric(value); track('inference_x_axis_metric_selected', { @@ -194,14 +172,6 @@ export default function ChartControls({ hideGpuComparison = false }: ChartContro return title.toLowerCase().includes('input'); })(); - const handleDateRangeChange = (range: { startDate: string; endDate: string }) => { - setSelectedDateRange(range); - track('inference_date_range_changed', { - startDate: range.startDate, - endDate: range.endDate, - }); - }; - return (
@@ -301,50 +271,6 @@ export default function ChartControls({ hideGpuComparison = false }: ChartContro
)} - - {!hideGpuComparison && ( -
- -
- -
-
- )} - - {!hideGpuComparison && selectedGPUs.length > 0 && ( -
- - 0 && - (!selectedDateRange.startDate || !selectedDateRange.endDate) - ? 'border-red-500 ring-4 ring-red-500/40 animate-pulse' - : '' - } - /> -
- )}
diff --git a/packages/app/src/components/inference/ui/ChartDisplay.tsx b/packages/app/src/components/inference/ui/ChartDisplay.tsx index 71323b99..456777bb 100644 --- a/packages/app/src/components/inference/ui/ChartDisplay.tsx +++ b/packages/app/src/components/inference/ui/ChartDisplay.tsx @@ -40,13 +40,12 @@ import { getPrecisionLabel, getSequenceLabel, } from '@/lib/data-mappings'; -import { useComparisonChangelogs } from '@/hooks/api/use-comparison-changelogs'; import { useTrendData } from '@/components/inference/hooks/useTrendData'; import ChartControls from './ChartControls'; -import ComparisonChangelog from './ComparisonChangelog'; import CustomCosts from './CustomCosts'; import CustomPowers from './CustomPowers'; +import GpuComparisonCard from './GpuComparisonCard'; import GPUGraph from './GPUGraph'; import TrendChart from './TrendChart'; @@ -134,10 +133,7 @@ export default function ChartDisplay() { selectedE2eXAxisMetric, selectedGPUs, selectedPrecisions, - selectedDates, - setSelectedDates, selectedDateRange, - dateRangeAvailableDates, selectedModel, selectedSequence, selectedRunDate, @@ -151,11 +147,7 @@ export default function ChartDisplay() { setSelectedE2eXAxisMetric, } = useInference(); - const { - changelogs, - loading: changelogsLoading, - totalDatesQueried, - } = useComparisonChangelogs(selectedGPUs, selectedDateRange, dateRangeAvailableDates); + const comparisonReady = useMemo(() => selectedGPUs.length > 0, [selectedGPUs]); const [viewModes, setViewModes] = useState>({}); const getViewMode = (index: number): InferenceViewMode => viewModes[index] ?? 'chart'; @@ -325,9 +317,7 @@ export default function ChartDisplay() { 0 + selectedDateRange.startDate && selectedDateRange.endDate && comparisonReady ? 'gpu_timeseries' : graph.chartDefinition.chartType === 'e2e' ? 'latency' @@ -347,9 +337,7 @@ export default function ChartDisplay() { exportFileName={`InferenceX_${selectedModel}_${graph.chartDefinition.chartType}`} onExportCsv={() => { const isTimeline = - selectedDateRange.startDate && - selectedDateRange.endDate && - selectedGPUs.length > 0; + selectedDateRange.startDate && selectedDateRange.endDate && comparisonReady; const visibleData = graph.data.filter((d) => isTimeline ? activeDates.has(`${d.date}_${d.hwKey}`) @@ -413,7 +401,7 @@ export default function ChartDisplay() { const zoomPrefix = selectedDateRange.startDate && selectedDateRange.endDate && - selectedGPUs.length > 0 + comparisonReady ? 'gpu_timeseries' : 'latency'; return ( @@ -493,7 +481,7 @@ export default function ChartDisplay() { return selectedDateRange.startDate && selectedDateRange.endDate && - selectedGPUs.length > 0 ? ( + comparisonReady ? ( ) : ( -
- - {selectedGPUs.length > 0 && - (!selectedDateRange.startDate || !selectedDateRange.endDate) && ( -
-

- Select a date range to view GPU comparison -

-
- )} -
+ ); })()} @@ -544,14 +522,14 @@ export default function ChartDisplay() { )); return ( -
+
- -
+ +
-

Inference Performance

-

+

Inference Performance

+

Inference performance metrics across different models, hardware configurations, and serving parameters.

@@ -560,35 +538,15 @@ export default function ChartDisplay() {
- {selectedGPUs.length === 0 && } - {selectedGPUs.length > 0 && ( - { - if (!selectedDates.includes(date)) { - setSelectedDates([...selectedDates, date]); - } - }} - onRemoveDate={(date) => { - setSelectedDates(selectedDates.filter((d) => d !== date)); - }} - onAddAllDates={(dates) => { - const merged = [...new Set([...selectedDates, ...dates])]; - setSelectedDates(merged); - }} - firstAvailableDate={dateRangeAvailableDates[0]} - /> - )} +
+
+ +
+ {selectedYAxisMetric === 'y_costUser' && (
@@ -605,7 +563,7 @@ export default function ChartDisplay() { 0 && - !(selectedDateRange.startDate && selectedDateRange.endDate && selectedGPUs.length > 0) + !(selectedDateRange.startDate && selectedDateRange.endDate && comparisonReady) } onOpenChange={(open) => { if (!open) { diff --git a/packages/app/src/components/inference/ui/ComparisonChangelog.test.ts b/packages/app/src/components/inference/ui/ComparisonChangelog.test.ts deleted file mode 100644 index 2f3c519d..00000000 --- a/packages/app/src/components/inference/ui/ComparisonChangelog.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -/** - * Tests for the "add to chart" logic used in ComparisonChangelog. - * Verifies date filtering: which dates are on chart, which are addable. - */ - -interface MockChangelog { - date: string; - entries: { config_keys: string[]; description: string; pr_link: string | null }[]; -} - -function computeDatesOnChart( - selectedDates: string[], - selectedDateRange: { startDate: string; endDate: string }, -): Set { - const set = new Set(selectedDates); - if (selectedDateRange.startDate) set.add(selectedDateRange.startDate); - if (selectedDateRange.endDate) set.add(selectedDateRange.endDate); - return set; -} - -function computeAddableDates( - filteredChangelogs: MockChangelog[], - datesOnChart: Set, -): string[] { - return filteredChangelogs.map((c) => c.date).filter((d) => !datesOnChart.has(d)); -} - -const changelogs: MockChangelog[] = [ - { - date: '2026-01-15', - entries: [{ config_keys: ['dsr1-fp8-h200-sglang'], description: 'Update', pr_link: null }], - }, - { - date: '2026-01-20', - entries: [{ config_keys: ['dsr1-fp8-h200-sglang'], description: 'Bump', pr_link: null }], - }, - { - date: '2026-01-25', - entries: [{ config_keys: ['dsr1-fp8-h200-sglang'], description: 'Tweak', pr_link: null }], - }, -]; - -describe('ComparisonChangelog add-to-chart logic', () => { - it('all dates are addable when none are selected', () => { - const onChart = computeDatesOnChart([], { startDate: '', endDate: '' }); - const addable = computeAddableDates(changelogs, onChart); - expect(addable).toEqual(['2026-01-15', '2026-01-20', '2026-01-25']); - }); - - it('dates in selectedDates are marked as on chart', () => { - const onChart = computeDatesOnChart(['2026-01-15', '2026-01-20'], { - startDate: '', - endDate: '', - }); - expect(onChart.has('2026-01-15')).toBe(true); - expect(onChart.has('2026-01-20')).toBe(true); - expect(onChart.has('2026-01-25')).toBe(false); - const addable = computeAddableDates(changelogs, onChart); - expect(addable).toEqual(['2026-01-25']); - }); - - it('range endpoints are marked as on chart', () => { - const onChart = computeDatesOnChart([], { - startDate: '2026-01-15', - endDate: '2026-01-25', - }); - expect(onChart.has('2026-01-15')).toBe(true); - expect(onChart.has('2026-01-25')).toBe(true); - const addable = computeAddableDates(changelogs, onChart); - expect(addable).toEqual(['2026-01-20']); - }); - - it('addable excludes both selectedDates and range endpoints', () => { - const onChart = computeDatesOnChart(['2026-01-20'], { - startDate: '2026-01-15', - endDate: '2026-01-25', - }); - const addable = computeAddableDates(changelogs, onChart); - expect(addable).toEqual([]); - }); - - it('returns empty addable when all dates are already on chart', () => { - const onChart = computeDatesOnChart(['2026-01-15', '2026-01-20', '2026-01-25'], { - startDate: '', - endDate: '', - }); - const addable = computeAddableDates(changelogs, onChart); - expect(addable).toEqual([]); - }); -}); diff --git a/packages/app/src/components/inference/ui/ComparisonChangelog.tsx b/packages/app/src/components/inference/ui/ComparisonChangelog.tsx index 8106fe90..01db1af0 100644 --- a/packages/app/src/components/inference/ui/ComparisonChangelog.tsx +++ b/packages/app/src/components/inference/ui/ComparisonChangelog.tsx @@ -1,12 +1,16 @@ 'use client'; import { ChevronDown, ChevronUp, FileText, Lock, Minus, Plus } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { track } from '@/lib/analytics'; import { ExternalLinkIcon } from '@/components/ui/external-link-icon'; import type { ComparisonChangelog as ComparisonChangelogType } from '@/hooks/api/use-comparison-changelogs'; +import { + buildDatesOnComparisonChart, + getAddableChangelogDates, +} from '@/components/inference/utils/comparison-changelog-dates'; import { configKeyMatchesHwKey, formatChangelogDescription, @@ -27,6 +31,8 @@ interface ComparisonChangelogProps { onAddAllDates: (dates: string[]) => void; /** Earliest date the selected GPU config has benchmark data */ firstAvailableDate?: string; + /** When this flips from false to true, expand the panel (e.g. comparison + range just became ready). */ + expandWhenActive?: boolean; } export default function ComparisonChangelog({ @@ -41,8 +47,17 @@ export default function ComparisonChangelog({ onRemoveDate, onAddAllDates, firstAvailableDate, + expandWhenActive = false, }: ComparisonChangelogProps) { - const [isExpanded, setIsExpanded] = useState(true); + const [isExpanded, setIsExpanded] = useState(expandWhenActive); + const prevExpandActiveRef = useRef(expandWhenActive); + + useEffect(() => { + if (expandWhenActive && !prevExpandActiveRef.current) { + setIsExpanded(true); + } + prevExpandActiveRef.current = expandWhenActive; + }, [expandWhenActive]); // Filter changelog entries to only show those matching selected GPUs and precisions. // Always keep range endpoints and first appearance date visible. @@ -81,15 +96,13 @@ export default function ComparisonChangelog({ .toSorted((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); }, [changelogs, selectedGPUs, selectedPrecisions, pinnedDates]); - const datesOnChart = useMemo(() => { - const set = new Set(selectedDates); - if (selectedDateRange.startDate) set.add(selectedDateRange.startDate); - if (selectedDateRange.endDate) set.add(selectedDateRange.endDate); - return set; - }, [selectedDates, selectedDateRange]); + const datesOnChart = useMemo( + () => buildDatesOnComparisonChart(selectedDates, selectedDateRange), + [selectedDates, selectedDateRange], + ); const addableDates = useMemo( - () => filteredChangelogs.map((c) => c.date).filter((d) => !datesOnChart.has(d)), + () => getAddableChangelogDates(filteredChangelogs, datesOnChart), [filteredChangelogs, datesOnChart], ); @@ -135,7 +148,7 @@ export default function ComparisonChangelog({ className="text-xs font-medium text-brand hover:text-brand/80 transition-colors flex items-center gap-1" > - Add all to chart + Pin all dates )}
@@ -194,12 +207,12 @@ export default function ComparisonChangelog({ className="text-xs font-medium text-muted-foreground hover:text-destructive transition-colors flex items-center gap-0.5" > - Remove from chart + Unpin ) : ( - On chart + Pinned ) ) : ( @@ -212,7 +225,7 @@ export default function ComparisonChangelog({ className="text-xs font-medium text-brand hover:text-brand/80 transition-colors flex items-center gap-0.5" > - Add to chart + Pin )}
diff --git a/packages/app/src/components/inference/ui/GpuComparisonCard.tsx b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx new file mode 100644 index 00000000..0f6289f0 --- /dev/null +++ b/packages/app/src/components/inference/ui/GpuComparisonCard.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { ChevronDown } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { track } from '@/lib/analytics'; +import { cn } from '@/lib/utils'; + +import { useInference } from '@/components/inference/InferenceContext'; +import { MAX_COMPARISON_GPUS } from '@/components/inference/utils/normalize-comparison-gpus'; +import { useComparisonChangelogs } from '@/hooks/api/use-comparison-changelogs'; +import { DateRangePicker, getQuickDateRangeShortcuts } from '@/components/ui/date-range-picker'; +import { LabelWithTooltip } from '@/components/ui/label-with-tooltip'; +import { MultiSelect } from '@/components/ui/multi-select'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; + +import ComparisonChangelog from './ComparisonChangelog'; + +export default function GpuComparisonCard() { + const { + selectedGPUs, + setSelectedGPUs, + availableGPUs, + selectedDateRange, + setSelectedDateRange, + dateRangeAvailableDates, + isCheckingAvailableDates, + selectedPrecisions, + selectedDates, + setSelectedDates, + } = useInference(); + + const { + changelogs, + loading: changelogsLoading, + totalDatesQueried, + } = useComparisonChangelogs(selectedGPUs, selectedDateRange, dateRangeAvailableDates); + + const comparisonReady = selectedGPUs.length > 0; + + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + if (comparisonReady) { + setIsExpanded(true); + } + }, [comparisonReady]); + + const handleGPUChange = (value: string[]) => { + setSelectedGPUs(value); + track('inference_gpu_selected', { + gpus: value.join(','), + }); + }; + + const handleDateRangeChange = (range: { startDate: string; endDate: string }) => { + setSelectedDateRange(range); + track('inference_date_range_changed', { + startDate: range.startDate, + endDate: range.endDate, + }); + }; + + return ( + + +
+ + +
+
+
+ {isExpanded && ( +

+ Select one or more GPUs for date range comparison. +

+ )} +
+ +
+ +
+
+ +
+
+ + +
+
+ {getQuickDateRangeShortcuts(dateRangeAvailableDates).map( + ({ id, label, range, isAvailable }) => { + const canUse = comparisonReady && isAvailable && Boolean(range); + const isActive = + range !== null && + selectedDateRange.startDate === range.startDate && + selectedDateRange.endDate === range.endDate; + return ( + + ); + }, + )} +
+
+ + {comparisonReady && ( +
+ { + if (!selectedDates.includes(date)) { + setSelectedDates([...selectedDates, date]); + } + }} + onRemoveDate={(date) => { + setSelectedDates(selectedDates.filter((d) => d !== date)); + }} + onAddAllDates={(dates) => { + const merged = [...new Set([...selectedDates, ...dates])]; + setSelectedDates(merged); + }} + firstAvailableDate={dateRangeAvailableDates[0]} + /> +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx b/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx index 8458d597..2c6c297e 100644 --- a/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx +++ b/packages/app/src/components/inference/ui/WorkflowInfoDisplay.tsx @@ -12,7 +12,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { updateRepoUrl } from '@/lib/utils'; +import { cn, updateRepoUrl } from '@/lib/utils'; import { useGlobalFilters } from '@/components/GlobalFilterContext'; import { useInference } from '@/components/inference/InferenceContext'; @@ -50,8 +50,11 @@ function RunConclusionDot({ conclusion }: { conclusion: string | null }) { export default function WorkflowInfoDisplay({ workflowInfo, + controlsDisabled = false, }: { workflowInfo: WorkflowInfo[] | null; + /** When true, run date / run / changelog stay visible but are non-interactive (GPU comparison). */ + controlsDisabled?: boolean; }) { const { selectedRunDate, @@ -90,15 +93,29 @@ export default function WorkflowInfoDisplay({ } }; + const shellClass = cn( + controlsDisabled && 'cursor-not-allowed select-none opacity-50 saturate-50', + ); + const shellA11y = controlsDisabled + ? { + inert: true as unknown as boolean, + 'aria-disabled': true as const, + title: + 'Official run date and run list are fixed while GPU comparison is active. Clear a GPU slot in GPU Comparison to change them.', + } + : {}; + if (!workflowInfo || workflowInfo.length === 0 || !workflowInfo[0]) { return ( -
- setSelectedRunDate(date)} - availableDates={availableDates} - isCheckingAvailableDates={isCheckingAvailableDates} - /> +
+
+ setSelectedRunDate(date)} + availableDates={availableDates} + isCheckingAvailableDates={isCheckingAvailableDates} + /> +
); } @@ -120,144 +137,146 @@ export default function WorkflowInfoDisplay({ })(); return ( -
- {/*
+
+
+ {/*
Run Date: {workflowInfo[0].run_date} UTC
*/} - setSelectedRunDate(date)} - availableDates={availableDates} - isCheckingAvailableDates={isCheckingAvailableDates} - /> - {availableRuns ? ( -
- - { + track('inference_run_selected', { run: value }); + setSelectedRunId(value); }} > - - - - {Object.keys(availableRuns).map((run, index) => { - const runUrl = updateRepoUrl(availableRuns[run].runUrl); - return ( - { - const target = e.target as HTMLElement; - if (target.closest('[data-external-link]')) { - e.preventDefault(); - e.stopPropagation(); - window.open(runUrl, '_blank', 'noopener,noreferrer'); - } - }} - > - - - Run {index + 1}/{runIds.length} - - + { + const target = e.target as HTMLElement; + if (target.closest('[data-external-link]')) { + e.preventDefault(); + e.stopPropagation(); + const runUrl = availableRuns[selectedRunId]?.runUrl; + if (runUrl) { + window.open(updateRepoUrl(runUrl), '_blank', 'noopener,noreferrer'); + } + } + }} + > + + + + {Object.keys(availableRuns).map((run, index) => { + const runUrl = updateRepoUrl(availableRuns[run].runUrl); + return ( + { + const target = e.target as HTMLElement; + if (target.closest('[data-external-link]')) { + e.preventDefault(); + e.stopPropagation(); + window.open(runUrl, '_blank', 'noopener,noreferrer'); + } + }} + > + + + Run {index + 1}/{runIds.length} + + + - - - ); - })} - - - -
- ) : null} -
- - - - - -
- {changelog && changelog.entries.length > 0 ? ( - <> - {changelog.entries.map((entry, index) => ( -
- {index > 0 &&
} -
-
Description
- {formatChangelogDescription(entry.description)} -
Updated Configs
-
    - {entry.config_keys.map((key: string) => ( -
  • {formatConfigKeys(key)}
  • - ))} -
+
+ ) : null} +
+ + + + + +
+ {changelog && changelog.entries.length > 0 ? ( + <> + {changelog.entries.map((entry, index) => ( +
+ {index > 0 &&
} +
+
Description
+ {formatChangelogDescription(entry.description)} +
Updated Configs
+
    + {entry.config_keys.map((key: string) => ( +
  • {formatConfigKeys(key)}
  • + ))} +
+
-
- ))} - {changelog.entries[0]?.head_ref && ( - - Git Commit - - )} - - ) : ( -
-
Description
- No changelog data available. - - This date predates changelog tracking. - -
- )} -
- - + ))} + {changelog.entries[0]?.head_ref && ( + + Git Commit + + )} + + ) : ( +
+
Description
+ No changelog data available. + + This date predates changelog tracking. + +
+ )} +
+ + +
); diff --git a/packages/app/src/components/inference/utils/comparison-changelog-dates.test.ts b/packages/app/src/components/inference/utils/comparison-changelog-dates.test.ts new file mode 100644 index 00000000..28e7fc02 --- /dev/null +++ b/packages/app/src/components/inference/utils/comparison-changelog-dates.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildDatesOnComparisonChart, + getAddableChangelogDates, +} from '@/components/inference/utils/comparison-changelog-dates'; + +const changelogDates = [{ date: '2026-01-15' }, { date: '2026-01-20' }, { date: '2026-01-25' }]; + +describe('buildDatesOnComparisonChart', () => { + it('includes only selected dates when range is empty', () => { + const set = buildDatesOnComparisonChart(['2026-01-15', '2026-01-20'], { + startDate: '', + endDate: '', + }); + expect(set.has('2026-01-15')).toBe(true); + expect(set.has('2026-01-20')).toBe(true); + expect(set.has('2026-01-25')).toBe(false); + }); + + it('adds non-empty range endpoints', () => { + const set = buildDatesOnComparisonChart([], { + startDate: '2026-01-15', + endDate: '2026-01-25', + }); + expect(set.has('2026-01-15')).toBe(true); + expect(set.has('2026-01-25')).toBe(true); + expect(set.has('2026-01-20')).toBe(false); + }); + + it('merges pins and range endpoints', () => { + const set = buildDatesOnComparisonChart(['2026-01-20'], { + startDate: '2026-01-15', + endDate: '2026-01-25', + }); + expect([...set].toSorted()).toEqual(['2026-01-15', '2026-01-20', '2026-01-25']); + }); +}); + +describe('getAddableChangelogDates', () => { + it('returns all changelog dates when chart has none', () => { + const onChart = buildDatesOnComparisonChart([], { startDate: '', endDate: '' }); + expect(getAddableChangelogDates(changelogDates, onChart)).toEqual([ + '2026-01-15', + '2026-01-20', + '2026-01-25', + ]); + }); + + it('excludes dates already on chart from pins', () => { + const onChart = buildDatesOnComparisonChart(['2026-01-15', '2026-01-20'], { + startDate: '', + endDate: '', + }); + expect(getAddableChangelogDates(changelogDates, onChart)).toEqual(['2026-01-25']); + }); + + it('excludes range endpoints from addable', () => { + const onChart = buildDatesOnComparisonChart([], { + startDate: '2026-01-15', + endDate: '2026-01-25', + }); + expect(getAddableChangelogDates(changelogDates, onChart)).toEqual(['2026-01-20']); + }); + + it('returns empty when every changelog date is on chart', () => { + const onChart = buildDatesOnComparisonChart(['2026-01-20'], { + startDate: '2026-01-15', + endDate: '2026-01-25', + }); + expect(getAddableChangelogDates(changelogDates, onChart)).toEqual([]); + }); + + it('returns empty when all dates are pinned', () => { + const onChart = buildDatesOnComparisonChart(['2026-01-15', '2026-01-20', '2026-01-25'], { + startDate: '', + endDate: '', + }); + expect(getAddableChangelogDates(changelogDates, onChart)).toEqual([]); + }); +}); diff --git a/packages/app/src/components/inference/utils/comparison-changelog-dates.ts b/packages/app/src/components/inference/utils/comparison-changelog-dates.ts new file mode 100644 index 00000000..1a8f3fbd --- /dev/null +++ b/packages/app/src/components/inference/utils/comparison-changelog-dates.ts @@ -0,0 +1,28 @@ +export interface ComparisonDateRange { + startDate: string; + endDate: string; +} + +/** + * Dates currently represented on the comparison chart: explicitly pinned dates + * plus comparison range endpoints (when set). + */ +export function buildDatesOnComparisonChart( + selectedDates: string[], + selectedDateRange: ComparisonDateRange, +): Set { + const set = new Set(selectedDates); + if (selectedDateRange.startDate) set.add(selectedDateRange.startDate); + if (selectedDateRange.endDate) set.add(selectedDateRange.endDate); + return set; +} + +/** + * Changelog dates that are not already shown on the chart (candidates for "Pin" / "Pin all"). + */ +export function getAddableChangelogDates( + filteredChangelogs: T[], + datesOnChart: Set, +): string[] { + return filteredChangelogs.map((c) => c.date).filter((d) => !datesOnChart.has(d)); +} diff --git a/packages/app/src/components/inference/utils/normalize-comparison-gpus.test.ts b/packages/app/src/components/inference/utils/normalize-comparison-gpus.test.ts new file mode 100644 index 00000000..3b07de02 --- /dev/null +++ b/packages/app/src/components/inference/utils/normalize-comparison-gpus.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { MAX_COMPARISON_GPUS, normalizeComparisonGpuList } from './normalize-comparison-gpus'; + +describe('normalizeComparisonGpuList', () => { + it('returns empty for empty input', () => { + expect(normalizeComparisonGpuList([])).toEqual([]); + }); + + it('dedupes and preserves order', () => { + expect(normalizeComparisonGpuList(['a', 'a', 'b'])).toEqual(['a', 'b']); + }); + + it(`caps at ${MAX_COMPARISON_GPUS} distinct keys`, () => { + expect(normalizeComparisonGpuList(['w', 'x', 'y', 'z', 'extra'])).toEqual(['w', 'x', 'y', 'z']); + }); + + it('skips empty strings', () => { + expect(normalizeComparisonGpuList(['', 'h100', ''])).toEqual(['h100']); + }); +}); diff --git a/packages/app/src/components/inference/utils/normalize-comparison-gpus.ts b/packages/app/src/components/inference/utils/normalize-comparison-gpus.ts new file mode 100644 index 00000000..dde270f1 --- /dev/null +++ b/packages/app/src/components/inference/utils/normalize-comparison-gpus.ts @@ -0,0 +1,15 @@ +/** Maximum distinct hwKeys for GPU comparison (URL, presets, UI slots). */ +export const MAX_COMPARISON_GPUS = 4; + +/** Up to {@link MAX_COMPARISON_GPUS} distinct hwKeys, preserving order. */ +export function normalizeComparisonGpuList(gpus: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const g of gpus) { + if (!g || seen.has(g)) continue; + seen.add(g); + out.push(g); + if (out.length >= MAX_COMPARISON_GPUS) break; + } + return out; +} diff --git a/packages/app/src/components/tab-nav.tsx b/packages/app/src/components/tab-nav.tsx index ee2bfb2a..4d424949 100644 --- a/packages/app/src/components/tab-nav.tsx +++ b/packages/app/src/components/tab-nav.tsx @@ -132,7 +132,7 @@ export function TabNav() { return ( <> {/* Mobile: Dropdown */} -
+
@@ -158,7 +158,7 @@ export function TabNav() {
{/* Desktop: Nav links */} -
+
- +
@@ -197,7 +197,7 @@ export default function HistoricalTrendsDisplay() {
- + {/* Target interactivity slider */} {!loading && hasInteractivityChart && ( diff --git a/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts b/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts new file mode 100644 index 00000000..42df8202 --- /dev/null +++ b/packages/app/src/components/ui/date-range-picker.quick-ranges.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import { getQuickDateRangeShortcuts, getQuickDateRanges } from '@/components/ui/date-range-picker'; + +const FIXED_NOW = new Date('2026-05-09T12:00:00Z'); + +describe('getQuickDateRangeShortcuts', () => { + it('always returns three entries', () => { + expect(getQuickDateRangeShortcuts([])).toHaveLength(3); + expect(getQuickDateRangeShortcuts(['2026-01-01'])).toHaveLength(3); + }); + + it('marks none available when fewer than two dates', () => { + const s = getQuickDateRangeShortcuts(['2026-01-01']); + expect(s.every((x) => !x.isAvailable)).toBe(true); + expect(s.every((x) => x.range === null)).toBe(true); + }); + + it('matches getQuickDateRanges for the available subset', () => { + const dates = ['2025-10-01', '2025-11-01', '2026-01-15']; + const shortcuts = getQuickDateRangeShortcuts(dates, FIXED_NOW); + const fromHelper = getQuickDateRanges(dates, FIXED_NOW); + const fromShortcuts = shortcuts + .filter((x) => x.isAvailable && x.range) + .map((x) => ({ label: x.label, range: x.range! })); + expect(fromShortcuts).toEqual(fromHelper); + }); +}); + +describe('getQuickDateRanges', () => { + it('returns empty when fewer than 2 dates', () => { + expect(getQuickDateRanges([])).toEqual([]); + expect(getQuickDateRanges(['2026-01-01'])).toEqual([]); + }); + + it('returns All Time spanning first and last date', () => { + const dates = ['2025-10-01', '2025-11-01', '2026-01-15']; + const ranges = getQuickDateRanges(dates, FIXED_NOW); + expect(ranges[0]).toEqual({ + label: 'All Time', + range: { startDate: '2025-10-01', endDate: '2026-01-15' }, + }); + }); + + it('includes Last 90 Days and Last 30 Days when enough data in window', () => { + const dates: string[] = []; + for ( + const d = new Date('2026-01-01'); + d <= new Date('2026-05-09'); + d.setDate(d.getDate() + 7) + ) { + dates.push(d.toISOString().slice(0, 10)); + } + const ranges = getQuickDateRanges(dates, FIXED_NOW); + const labels = ranges.map((r) => r.label); + expect(labels).toContain('All Time'); + expect(labels).toContain('Last 90 Days'); + expect(labels).toContain('Last 30 Days'); + for (const { range } of ranges) { + expect(range.startDate <= range.endDate).toBe(true); + expect(dates.includes(range.startDate)).toBe(true); + expect(dates.includes(range.endDate)).toBe(true); + } + }); +}); diff --git a/packages/app/src/components/ui/date-range-picker.tsx b/packages/app/src/components/ui/date-range-picker.tsx index 6e643bc8..b1d8d1af 100644 --- a/packages/app/src/components/ui/date-range-picker.tsx +++ b/packages/app/src/components/ui/date-range-picker.tsx @@ -32,6 +32,69 @@ export interface DateRange { endDate: string; } +export interface QuickDateRange { + label: string; + range: DateRange; +} + +export interface QuickDateRangeShortcut { + id: 'all-time' | 'last-90-days' | 'last-30-days'; + label: string; + range: DateRange | null; + /** True when this shortcut has a valid range (at least two dates in scope). */ + isAvailable: boolean; +} + +/** All three comparison shortcuts, with availability driven by `availableDates` (e.g. intersection for selected GPUs). */ +export function getQuickDateRangeShortcuts( + availableDates: string[], + now?: Date, +): QuickDateRangeShortcut[] { + const allTimeAvailable = availableDates.length >= 2; + const allTimeRange: DateRange | null = allTimeAvailable + ? { startDate: availableDates[0], endDate: availableDates.at(-1)! } + : null; + + const rolling = ( + [ + { id: 'last-90-days' as const, label: 'Last 90 Days', days: 90 }, + { id: 'last-30-days' as const, label: 'Last 30 Days', days: 30 }, + ] as const + ).map(({ id, label, days }) => { + if (availableDates.length < 2) { + return { id, label, range: null, isAvailable: false }; + } + const cutoff = new Date(now ?? Date.now()); + cutoff.setDate(cutoff.getDate() - days); + const cutoffStr = cutoff.toISOString().slice(0, 10); + const filtered = availableDates.filter((d) => d >= cutoffStr); + const ok = filtered.length >= 2; + return { + id, + label, + range: ok ? { startDate: filtered[0], endDate: filtered.at(-1)! } : null, + isAvailable: ok, + }; + }); + + return [ + { + id: 'all-time', + label: 'All Time', + range: allTimeRange, + isAvailable: allTimeAvailable, + }, + ...rolling, + ]; +} + +/** Quick-select entries that are usable in the date picker dialog (omit unavailable). */ +export function getQuickDateRanges(availableDates: string[], now?: Date): QuickDateRange[] { + return getQuickDateRangeShortcuts(availableDates, now) + .filter((s) => s.isAvailable && s.range) + .map((s) => ({ label: s.label, range: s.range! })); +} + export interface DateRangePickerProps { dateRange: DateRange; onChange: (dateRange: DateRange) => void; @@ -41,6 +104,10 @@ export interface DateRangePickerProps { maxDate?: string; availableDates?: string[]; isCheckingAvailableDates?: boolean; + /** When true, the trigger is non-interactive and the dialog cannot open. */ + disabled?: boolean; + /** Sets `id` on the trigger button (e.g. for `Label htmlFor`). */ + triggerId?: string; } /** @@ -56,6 +123,8 @@ export function DateRangePicker({ maxDate, availableDates, isCheckingAvailableDates, + disabled = false, + triggerId, }: DateRangePickerProps) { const [open, setOpen] = useState(false); const [tempRange, setTempRange] = useState(dateRange); @@ -127,6 +196,7 @@ export function DateRangePicker({ // Reset when opening const handleOpenChange = (isOpen: boolean) => { + if (disabled && isOpen) return; track(isOpen ? 'date_range_picker_opened' : 'date_range_picker_closed'); if (isOpen) { setTempRange(dateRange); @@ -135,6 +205,10 @@ export function DateRangePicker({ setOpen(isOpen); }; + useEffect(() => { + if (disabled) setOpen(false); + }, [disabled]); + useEffect(() => { setError(''); }, [open]); @@ -144,10 +218,14 @@ export function DateRangePicker({ - ); - })} + {getQuickDateRanges(availableDates).map(({ label, range }) => ( + + ))}
) : (
diff --git a/packages/app/src/components/ui/multi-select.tsx b/packages/app/src/components/ui/multi-select.tsx index b3a83700..6cc0181b 100644 --- a/packages/app/src/components/ui/multi-select.tsx +++ b/packages/app/src/components/ui/multi-select.tsx @@ -364,11 +364,10 @@ function MultiSelect({
)} {showSelectionSummary && - (maxSelections !== undefined || minSelections !== undefined) && ( + (typeof maxSelections === 'number' || typeof minSelections === 'number') && (
- {value.length} - {maxSelections !== undefined && ` / ${maxSelections}`} selected - {minSelections !== undefined && minSelections > 0 && ( + {`${value.length}${typeof maxSelections === 'number' ? ` / ${maxSelections}` : ''} selected`} + {typeof minSelections === 'number' && minSelections > 0 && ( Minimum: {minSelections} )}