From 82075842ebd836b693b1e9d61496be02351a3a7f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 04:28:30 +0000 Subject: [PATCH] feat(inference): inline quick-range chips for date selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #273 Replace the footer-buried "Max Range / Last 90 Days / Last 30 Days" buttons with a 5-chip preset row (7D · 30D · 90D · YTD · All) rendered inline under the date-range trigger AND lifted to the top of the picker dialog. The inline placement gives repeat users a one-click path to a sensible window; the elevated dialog placement gives newcomers an obvious starting point the moment they open the picker. - New `quick-range-presets` lib with pure helpers: - `buildQuickRangePresets(today)` returns 5 presets with `getRange()` that returns null when fewer than 2 in-window dates exist. - `matchActivePreset()` round-trips a {start,end} back to the chip id that produced it, so URL-restored ranges light up the matching chip. - New `QuickRangeChips` component shared between inline and dialog uses, with disabled chips wrapped in tooltips ("Not enough data points in this window") for discoverability. - `inference_quick_range_selected { id, source: 'inline'|'dialog', ... }` analytics event on every chip click. - Removes the in-dialog footer row of legacy buttons; existing `date_range_picker_*` events for opens/closes/applies/manual clicks are preserved. Verified end-to-end at /inference?i_gpus=b200_sglang: chips render, "All" lights up after click, both dated lines (2025-10-01 and 2026-03-25) plot real data, dialog row displays above the calendar. Co-authored-by: functionstackx --- .../app/cypress/e2e/quick-range-chips.cy.ts | 46 +++++++ .../components/inference/ui/ChartControls.tsx | 9 ++ .../src/components/ui/date-range-picker.tsx | 68 +++-------- .../src/components/ui/quick-range-chips.tsx | 112 ++++++++++++++++++ .../app/src/lib/quick-range-presets.test.ts | 102 ++++++++++++++++ packages/app/src/lib/quick-range-presets.ts | 91 ++++++++++++++ 6 files changed, 375 insertions(+), 53 deletions(-) create mode 100644 packages/app/cypress/e2e/quick-range-chips.cy.ts create mode 100644 packages/app/src/components/ui/quick-range-chips.tsx create mode 100644 packages/app/src/lib/quick-range-presets.test.ts create mode 100644 packages/app/src/lib/quick-range-presets.ts diff --git a/packages/app/cypress/e2e/quick-range-chips.cy.ts b/packages/app/cypress/e2e/quick-range-chips.cy.ts new file mode 100644 index 00000000..dba5e247 --- /dev/null +++ b/packages/app/cypress/e2e/quick-range-chips.cy.ts @@ -0,0 +1,46 @@ +/** + * Tests for the inline QuickRangeChips that sit under the date-range picker + * in the Inference tab. Verifies the chips render only when a GPU is selected + * (and therefore a date range is needed), that clicking applies a range, and + * that the URL-restored range round-trips back to an active chip. + */ +describe('QuickRangeChips — inline below date-range trigger', () => { + beforeEach(() => { + cy.window().then((win) => { + win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); + }); + }); + + it('does not render the chips when no GPU is selected', () => { + cy.visit('/inference'); + cy.get('[data-testid="inference-chart-display"]').should('exist'); + cy.get('[data-testid="quick-range-chips"]').should('not.exist'); + }); + + it('renders the inline chips when a GPU is selected via URL', () => { + cy.visit('/inference?i_gpus=b200_sglang'); + cy.get('[data-testid="inference-chart-display"]').should('exist'); + cy.get('[data-testid="quick-range-chips"]', { timeout: 15000 }).should('be.visible'); + cy.get('[data-testid="quick-range-chip-all"]').should('exist'); + cy.get('[data-testid="quick-range-chip-ytd"]').should('exist'); + cy.get('[data-testid="quick-range-chip-90d"]').should('exist'); + cy.get('[data-testid="quick-range-chip-30d"]').should('exist'); + cy.get('[data-testid="quick-range-chip-7d"]').should('exist'); + }); + + it('lights up the matching chip when the URL range equals the "All" extent', () => { + cy.visit('/inference?i_gpus=b200_sglang'); + cy.get('[data-testid="quick-range-chips"]', { timeout: 15000 }).should('be.visible'); + cy.get('[data-testid="quick-range-chip-all"]').click(); + cy.get('[data-testid="quick-range-chip-all"]').should('have.attr', 'data-active', 'true'); + cy.get('[data-testid="quick-range-chip-all"]').should('have.attr', 'aria-pressed', 'true'); + }); + + it('clicking a chip updates the date-range trigger label', () => { + cy.visit('/inference?i_gpus=b200_sglang'); + cy.get('[data-testid="quick-range-chips"]', { timeout: 15000 }).should('be.visible'); + cy.get('[data-testid="quick-range-chip-all"]').click(); + // Trigger label should now show the start - end date pair, not the placeholder + cy.contains('button', 'Select date range').should('not.exist'); + }); +}); diff --git a/packages/app/src/components/inference/ui/ChartControls.tsx b/packages/app/src/components/inference/ui/ChartControls.tsx index 0b1705b0..3a4e0c71 100644 --- a/packages/app/src/components/inference/ui/ChartControls.tsx +++ b/packages/app/src/components/inference/ui/ChartControls.tsx @@ -13,6 +13,7 @@ import { import { DateRangePicker } from '@/components/ui/date-range-picker'; import { LabelWithTooltip } from '@/components/ui/label-with-tooltip'; import { MultiSelect } from '@/components/ui/multi-select'; +import { QuickRangeChips } from '@/components/ui/quick-range-chips'; import { Select, SelectContent, @@ -343,6 +344,14 @@ export default function ChartControls({ hideGpuComparison = false }: ChartContro : '' } /> + {dateRangeAvailableDates && dateRangeAvailableDates.length >= 2 && ( + + )} )} diff --git a/packages/app/src/components/ui/date-range-picker.tsx b/packages/app/src/components/ui/date-range-picker.tsx index 24cbc8b3..11715fc9 100644 --- a/packages/app/src/components/ui/date-range-picker.tsx +++ b/packages/app/src/components/ui/date-range-picker.tsx @@ -25,6 +25,7 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; +import { QuickRangeChips } from '@/components/ui/quick-range-chips'; import { cn } from '@/lib/utils'; export interface DateRange { @@ -180,6 +181,19 @@ export function DateRangePicker({ )} + {availableDates && availableDates.length >= 2 && ( +
+ + Quick range: + + setTempRange(range)} + source="dialog" + /> +
+ )}
{error &&

{error}

} - - {availableDates && availableDates.length >= 2 ? ( -
- {[ - { - label: 'Max Range', - getRange: () => ({ - startDate: availableDates[0], - endDate: availableDates.at(-1)!, - }), - }, - { - label: 'Last 90 Days', - getRange: () => { - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 90); - const cutoffStr = cutoff.toISOString().slice(0, 10); - const filtered = availableDates.filter((d) => d >= cutoffStr); - if (filtered.length < 2) return null; - return { startDate: filtered[0], endDate: filtered.at(-1)! }; - }, - }, - { - label: 'Last 30 Days', - getRange: () => { - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 30); - const cutoffStr = cutoff.toISOString().slice(0, 10); - const filtered = availableDates.filter((d) => d >= cutoffStr); - if (filtered.length < 2) return null; - return { startDate: filtered[0], endDate: filtered.at(-1)! }; - }, - }, - ].map(({ label, getRange }) => { - const range = getRange(); - if (!range) return null; - return ( - - ); - })} -
- ) : ( -
- )} +
+ ); + + if (!disabled) return button; + + // Tell the user *why* it's disabled — discoverability beats cleanliness for newcomers + // who haven't yet understood the data model. + return ( + + + {/* span wrapper so the tooltip still triggers on the disabled button */} + {button} + + Not enough data points in this window + + ); +} diff --git a/packages/app/src/lib/quick-range-presets.test.ts b/packages/app/src/lib/quick-range-presets.test.ts new file mode 100644 index 00000000..4cba24dc --- /dev/null +++ b/packages/app/src/lib/quick-range-presets.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; + +import { buildQuickRangePresets, matchActivePreset } from './quick-range-presets'; + +const TODAY = new Date('2026-03-25T12:00:00Z'); + +const DATES = [ + '2025-09-15', + '2025-10-01', + '2025-12-20', + '2025-12-30', + '2026-01-10', + '2026-02-05', + '2026-03-01', + '2026-03-20', + '2026-03-25', +]; + +describe('buildQuickRangePresets', () => { + it('returns five presets in fixed order', () => { + const presets = buildQuickRangePresets(TODAY); + expect(presets.map((p) => p.id)).toEqual(['7d', '30d', '90d', 'ytd', 'all']); + }); + + it('"All" spans first to last available date', () => { + const all = buildQuickRangePresets(TODAY).find((p) => p.id === 'all')!; + expect(all.getRange(DATES)).toEqual({ startDate: '2025-09-15', endDate: '2026-03-25' }); + }); + + it('"YTD" starts from Jan 1 of today\'s year', () => { + const ytd = buildQuickRangePresets(TODAY).find((p) => p.id === 'ytd')!; + expect(ytd.getRange(DATES)).toEqual({ startDate: '2026-01-10', endDate: '2026-03-25' }); + }); + + it('"90D" filters to the last 90 days from today', () => { + const ninety = buildQuickRangePresets(TODAY).find((p) => p.id === '90d')!; + // 2026-03-25 minus 90 days = 2025-12-25, so 2025-12-30 is the first in-window date + expect(ninety.getRange(DATES)).toEqual({ startDate: '2025-12-30', endDate: '2026-03-25' }); + }); + + it('"30D" returns null when only one in-window date exists', () => { + const thirty = buildQuickRangePresets(TODAY).find((p) => p.id === '30d')!; + // 2026-03-25 minus 30 days = 2026-02-23; only 2026-03-01, 03-20, 03-25 qualify → 3 ≥ 2 + const result = thirty.getRange(DATES); + expect(result).toEqual({ startDate: '2026-03-01', endDate: '2026-03-25' }); + }); + + it('"30D" returns null when zero in-window dates exist', () => { + const thirty = buildQuickRangePresets(TODAY).find((p) => p.id === '30d')!; + expect(thirty.getRange(['2024-01-01', '2024-01-02'])).toBeNull(); + }); + + it('"7D" returns null when fewer than 2 dates in window', () => { + const seven = buildQuickRangePresets(TODAY).find((p) => p.id === '7d')!; + // 2026-03-25 minus 7 = 2026-03-18; 03-20 and 03-25 qualify → 2 ≥ 2 + expect(seven.getRange(DATES)).toEqual({ startDate: '2026-03-20', endDate: '2026-03-25' }); + }); + + it('"7D" returns null with only one dated point in window', () => { + const seven = buildQuickRangePresets(TODAY).find((p) => p.id === '7d')!; + expect(seven.getRange(['2026-03-25', '2025-01-01'].toSorted())).toBeNull(); + }); + + it('"All" returns null when fewer than 2 total dates', () => { + const all = buildQuickRangePresets(TODAY).find((p) => p.id === 'all')!; + expect(all.getRange(['2026-03-25'])).toBeNull(); + expect(all.getRange([])).toBeNull(); + }); +}); + +describe('matchActivePreset', () => { + it('matches the "All" preset when range covers full extent', () => { + expect( + matchActivePreset({ startDate: '2025-09-15', endDate: '2026-03-25' }, DATES, TODAY), + ).toBe('all'); + }); + + it('matches "YTD" when range spans Jan 1 of current year to last date', () => { + expect( + matchActivePreset({ startDate: '2026-01-10', endDate: '2026-03-25' }, DATES, TODAY), + ).toBe('ytd'); + }); + + it('returns null for a custom range that no preset produces', () => { + expect( + matchActivePreset({ startDate: '2025-10-01', endDate: '2026-02-05' }, DATES, TODAY), + ).toBeNull(); + }); + + it('returns null for an empty range', () => { + expect(matchActivePreset({ startDate: '', endDate: '' }, DATES, TODAY)).toBeNull(); + }); + + it('returns the first preset id whose range matches when multiple presets coincide', () => { + // With these dates, 90d, ytd, and "all" all collapse to the same window; we expect the + // earliest (90d) to win because we iterate in fixed preset order. + const collapsed = ['2026-01-10', '2026-03-25']; + expect( + matchActivePreset({ startDate: '2026-01-10', endDate: '2026-03-25' }, collapsed, TODAY), + ).toBe('90d'); + }); +}); diff --git a/packages/app/src/lib/quick-range-presets.ts b/packages/app/src/lib/quick-range-presets.ts new file mode 100644 index 00000000..31a7ec35 --- /dev/null +++ b/packages/app/src/lib/quick-range-presets.ts @@ -0,0 +1,91 @@ +/** + * Quick-range presets for the inference date-range selector. + * + * Each preset takes the list of available benchmark dates (sorted ASC, ISO YYYY-MM-DD) + * and returns a {startDate, endDate} pair, or null if the window cannot be satisfied + * (fewer than 2 data points fall in the window). + */ + +export interface QuickRangePreset { + id: string; + label: string; + /** + * `null` is a sentinel for "not enough data points in this window" — used so the UI + * can render a disabled chip with a tooltip instead of hiding the affordance. + */ + getRange: (availableDates: string[]) => { startDate: string; endDate: string } | null; +} + +function filterFromCutoff( + availableDates: string[], + cutoffISO: string, +): { startDate: string; endDate: string } | null { + const filtered = availableDates.filter((d) => d >= cutoffISO); + if (filtered.length < 2) return null; + return { startDate: filtered[0], endDate: filtered.at(-1)! }; +} + +function daysAgoISO(days: number, today: Date = new Date()): string { + const cutoff = new Date(today); + cutoff.setDate(cutoff.getDate() - days); + return cutoff.toISOString().slice(0, 10); +} + +export function buildQuickRangePresets(today: Date = new Date()): QuickRangePreset[] { + return [ + { + id: '7d', + label: '7D', + getRange: (dates) => filterFromCutoff(dates, daysAgoISO(7, today)), + }, + { + id: '30d', + label: '30D', + getRange: (dates) => filterFromCutoff(dates, daysAgoISO(30, today)), + }, + { + id: '90d', + label: '90D', + getRange: (dates) => filterFromCutoff(dates, daysAgoISO(90, today)), + }, + { + id: 'ytd', + label: 'YTD', + getRange: (dates) => filterFromCutoff(dates, `${today.getFullYear()}-01-01`), + }, + { + id: 'all', + label: 'All', + getRange: (dates) => { + if (dates.length < 2) return null; + return { startDate: dates[0], endDate: dates.at(-1)! }; + }, + }, + ]; +} + +/** + * Round-trip a {startDate, endDate} back to the preset id that would produce it, + * so URL-restored ranges light up the matching chip. + * + * Returns the preset id (e.g. 'all') or null if the range is custom. + */ +export function matchActivePreset( + range: { startDate: string; endDate: string }, + availableDates: string[], + today: Date = new Date(), +): string | null { + if (!range.startDate || !range.endDate) return null; + const presets = buildQuickRangePresets(today); + for (const preset of presets) { + const presetRange = preset.getRange(availableDates); + if ( + presetRange && + presetRange.startDate === range.startDate && + presetRange.endDate === range.endDate + ) { + return preset.id; + } + } + return null; +}