diff --git a/static/app/components/charts/utils.tsx b/static/app/components/charts/utils.tsx index 182463bda435fb..05d960eedefbf0 100644 --- a/static/app/components/charts/utils.tsx +++ b/static/app/components/charts/utils.tsx @@ -27,8 +27,10 @@ export const SIXTY_DAYS = 86400; export const THIRTY_DAYS = 43200; export const TWO_WEEKS = 20160; export const ONE_WEEK = 10080; +export const FOUR_DAYS = 5760; export const FORTY_EIGHT_HOURS = 2880; export const TWENTY_FOUR_HOURS = 1440; +export const TWELVE_HOURS = 720; export const SIX_HOURS = 360; const THREE_HOURS = 180; export const ONE_HOUR = 60; diff --git a/static/app/utils/useChartInterval.spec.tsx b/static/app/utils/useChartInterval.spec.tsx index c599149b1f2cf2..ce8997d7327773 100644 --- a/static/app/utils/useChartInterval.spec.tsx +++ b/static/app/utils/useChartInterval.spec.tsx @@ -26,8 +26,7 @@ describe('useChartInterval', () => { expect(intervalOptions).toEqual([ {value: '1h', label: '1 hour'}, {value: '3h', label: '3 hours'}, - {value: '12h', label: '12 hours'}, - {value: '1d', label: '1 day'}, + {value: '6h', label: '6 hours'}, ]); expect(chartInterval).toBe('1h'); // default @@ -47,12 +46,11 @@ describe('useChartInterval', () => { expect(intervalOptions).toEqual([ {value: '1m', label: '1 minute'}, {value: '5m', label: '5 minutes'}, - {value: '15m', label: '15 minutes'}, ]); act(() => { - setChartInterval('15m'); + setChartInterval('5m'); }); - expect(chartInterval).toBe('15m'); + expect(chartInterval).toBe('5m'); }); }); diff --git a/static/app/utils/useChartInterval.tsx b/static/app/utils/useChartInterval.tsx index 93c8a06f916fdc..f07879ad8c912c 100644 --- a/static/app/utils/useChartInterval.tsx +++ b/static/app/utils/useChartInterval.tsx @@ -4,12 +4,13 @@ import type {Location} from 'history'; import { FIVE_MINUTES, FORTY_EIGHT_HOURS, + FOUR_DAYS, getDiffInMinutes, GranularityLadder, ONE_HOUR, - ONE_WEEK, SIX_HOURS, THIRTY_DAYS, + TWELVE_HOURS, TWO_WEEKS, } from 'sentry/components/charts/utils'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; @@ -20,7 +21,7 @@ import {decodeScalar} from 'sentry/utils/queryString'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; -enum ChartIntervalUnspecifiedStrategy { +export enum ChartIntervalUnspecifiedStrategy { /** Use the second biggest possible interval (e.g., pretty big buckets) */ USE_SECOND_BIGGEST = 'use_second_biggest', /** Use the smallest possible interval (e.g., the smallest possible buckets) */ @@ -104,12 +105,12 @@ function useChartIntervalImpl({ const ALL_INTERVAL_OPTIONS = [ {value: '1m', label: t('1 minute')}, {value: '5m', label: t('5 minutes')}, - {value: '15m', label: t('15 minutes')}, + {value: '10m', label: t('10 minutes')}, {value: '30m', label: t('30 minutes')}, {value: '1h', label: t('1 hour')}, {value: '3h', label: t('3 hours')}, + {value: '6h', label: t('6 hours')}, {value: '12h', label: t('12 hours')}, - {value: '1d', label: t('1 day')}, ]; /** @@ -119,19 +120,21 @@ const ALL_INTERVAL_OPTIONS = [ const MINIMUM_INTERVAL = new GranularityLadder([ [THIRTY_DAYS, '3h'], [TWO_WEEKS, '1h'], - [ONE_WEEK, '30m'], - [FORTY_EIGHT_HOURS, '15m'], - [SIX_HOURS, '5m'], + [FOUR_DAYS, '30m'], + [FORTY_EIGHT_HOURS, '10m'], + [TWELVE_HOURS, '5m'], + [SIX_HOURS, '1m'], [0, '1m'], ]); const MAXIMUM_INTERVAL = new GranularityLadder([ - [THIRTY_DAYS, '1d'], - [TWO_WEEKS, '1d'], - [ONE_WEEK, '12h'], - [FORTY_EIGHT_HOURS, '4h'], - [SIX_HOURS, '1h'], - [ONE_HOUR, '15m'], + [THIRTY_DAYS, '12h'], + [TWO_WEEKS, '6h'], + [FOUR_DAYS, '3h'], + [FORTY_EIGHT_HOURS, '1h'], + [TWELVE_HOURS, '30m'], + [SIX_HOURS, '10m'], + [ONE_HOUR, '5m'], [FIVE_MINUTES, '5m'], [0, '1m'], ]); diff --git a/static/app/views/dashboards/dashboard.spec.tsx b/static/app/views/dashboards/dashboard.spec.tsx index 2ba0775ceb9b62..ee1a25a8e0b3f3 100644 --- a/static/app/views/dashboards/dashboard.spec.tsx +++ b/static/app/views/dashboards/dashboard.spec.tsx @@ -11,10 +11,10 @@ import {resetMockDate, setMockDate} from 'sentry-test/utils'; import {PageFiltersStore} from 'sentry/components/pageFilters/store'; import {MemberListStore} from 'sentry/stores/memberListStore'; import {MEPSettingProvider} from 'sentry/utils/performance/contexts/metricsEnhancedSetting'; -import {useChartInterval} from 'sentry/utils/useChartInterval'; import {useLocation} from 'sentry/utils/useLocation'; import {Dashboard} from 'sentry/views/dashboards/dashboard'; import {FiltersBar} from 'sentry/views/dashboards/filtersBar'; +import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval'; import type { DashboardDetails, DashboardFilters, @@ -499,7 +499,7 @@ describe('Dashboards > Dashboard', () => { dashboard?: DashboardDetails; } = {}) { const location = useLocation(); - const [widgetInterval] = useChartInterval(); + const [widgetInterval] = useDashboardChartInterval(); return ( Dashboard', () => { }); describe('no interval set in URL', () => { - it('defaults to the smallest valid interval for the dashboard period', async () => { - const fiveMinuteMock = MockApiClient.addMockResponse({ + it('defaults to the second-biggest valid interval for the dashboard period', async () => { + const tenMinuteMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', method: 'GET', body: [], - match: [MockApiClient.matchQuery({interval: '5m'})], + match: [MockApiClient.matchQuery({interval: '10m'})], }); - const hourlyIntervalMock = MockApiClient.addMockResponse({ + const thirtyMinuteMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', method: 'GET', body: [], - match: [MockApiClient.matchQuery({interval: '1h'})], + match: [MockApiClient.matchQuery({interval: '30m'})], }); - // No interval in the URL — the 5m default is derived purely from the - // dashboard's saved 24h period via PageFiltersStore → useChartInterval. + // No interval in the URL — the 10m default (second-biggest of [5m, 10m, 30m]) + // is derived from the dashboard's saved 24h period via useDashboardChartInterval. const {router} = render(, { organization: orgWithFlag, initialRouterConfig: {location: {pathname: '/'}}, }); await screen.findByText('Test Spans Widget'); - await waitFor(() => expect(fiveMinuteMock).toHaveBeenCalled()); - expect(hourlyIntervalMock).not.toHaveBeenCalled(); + await waitFor(() => expect(tenMinuteMock).toHaveBeenCalled()); + expect(thirtyMinuteMock).not.toHaveBeenCalled(); - // Click the interval selector and choose '1 hour'. FiltersBar writes - // interval=1h to the URL, DashboardWithIntervalSelector re-renders with + // Click the interval selector and choose '30 minutes'. FiltersBar writes + // interval=30m to the URL, DashboardWithIntervalSelector re-renders with // the new widgetInterval, and the widget re-fetches with the new interval. - await userEvent.click(screen.getByRole('button', {name: '5 minutes'})); - await userEvent.click(screen.getByRole('option', {name: '1 hour'})); + await userEvent.click(screen.getByRole('button', {name: '10 minutes'})); + await userEvent.click(screen.getByRole('option', {name: '30 minutes'})); - await waitFor(() => expect(hourlyIntervalMock).toHaveBeenCalled()); - expect(router.location.query.interval).toBe('1h'); + await waitFor(() => expect(thirtyMinuteMock).toHaveBeenCalled()); + expect(router.location.query.interval).toBe('30m'); }); }); @@ -592,11 +592,11 @@ describe('Dashboards > Dashboard', () => { match: [MockApiClient.matchQuery({interval: '5m'})], }); - const hourlyIntervalMock = MockApiClient.addMockResponse({ + const tenMinuteMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', method: 'GET', body: [], - match: [MockApiClient.matchQuery({interval: '1h'})], + match: [MockApiClient.matchQuery({interval: '10m'})], }); const {router} = render(, { @@ -615,16 +615,16 @@ describe('Dashboards > Dashboard', () => { // Selecting a new interval updates the URL and triggers a re-fetch. await userEvent.click(screen.getByRole('button', {name: '30 minutes'})); - await userEvent.click(screen.getByRole('option', {name: '1 hour'})); + await userEvent.click(screen.getByRole('option', {name: '10 minutes'})); - await waitFor(() => expect(hourlyIntervalMock).toHaveBeenCalled()); - expect(router.location.query.interval).toBe('1h'); + await waitFor(() => expect(tenMinuteMock).toHaveBeenCalled()); + expect(router.location.query.interval).toBe('10m'); }); }); describe('URL interval not valid for the dashboard period', () => { beforeEach(() => { - // Override the outer 24h store setup — valid intervals for 30d are 3h, 12h, 1d. + // Override the outer 24h store setup — valid intervals for 30d are 3h, 6h, 12h. PageFiltersStore.init(); PageFiltersStore.onInitializeUrlState( getSavedFiltersAsPageFilters(thirtyDayDashboard) @@ -632,11 +632,11 @@ describe('Dashboards > Dashboard', () => { }); it('ignores the URL interval and falls back to the period default', async () => { - const threeHourMock = MockApiClient.addMockResponse({ + const sixHourMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', method: 'GET', body: [], - match: [MockApiClient.matchQuery({interval: '3h'})], + match: [MockApiClient.matchQuery({interval: '6h'})], }); const fiveMinuteMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/events-stats/', @@ -653,11 +653,12 @@ describe('Dashboards > Dashboard', () => { await screen.findByText('Test Spans Widget'); - // The selector should show the period-derived default, not the invalid URL value. - expect(screen.getByRole('button', {name: '3 hours'})).toBeInTheDocument(); + // The selector should show the period-derived default (6h = second-biggest + // of [3h, 6h, 12h]), not the invalid URL value (5m). + expect(screen.getByRole('button', {name: '6 hours'})).toBeInTheDocument(); // The widget should query with the valid default interval, not the URL value. - await waitFor(() => expect(threeHourMock).toHaveBeenCalled()); + await waitFor(() => expect(sixHourMock).toHaveBeenCalled()); expect(fiveMinuteMock).not.toHaveBeenCalled(); }); }); @@ -681,11 +682,11 @@ describe('Dashboards > Dashboard', () => { it('ignores the URL interval and falls back to the period default', async () => { // Uses the /sessions/ endpoint (session.status in columns → useSessionAPI=true), // which surfaces the "intervals too granular" error in production. - const threeHourMock = MockApiClient.addMockResponse({ + const sixHourMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/sessions/', method: 'GET', body: emptySessionsBody, - match: [MockApiClient.matchQuery({interval: '3h'})], + match: [MockApiClient.matchQuery({interval: '6h'})], }); const fiveMinuteMock = MockApiClient.addMockResponse({ url: '/organizations/org-slug/sessions/', @@ -701,13 +702,13 @@ describe('Dashboards > Dashboard', () => { await screen.findByText('Test Releases Widget'); - // The selector should show the period-derived default (3h), not the - // invalid URL value (5m). - expect(screen.getByRole('button', {name: '3 hours'})).toBeInTheDocument(); + // The selector should show the period-derived default (6h = second-biggest + // of [3h, 6h, 12h]), not the invalid URL value (5m). + expect(screen.getByRole('button', {name: '6 hours'})).toBeInTheDocument(); // The widget should query the sessions endpoint with the valid default // interval, not the 5m value from the URL. - await waitFor(() => expect(threeHourMock).toHaveBeenCalled()); + await waitFor(() => expect(sixHourMock).toHaveBeenCalled()); expect(fiveMinuteMock).not.toHaveBeenCalled(); }); }); diff --git a/static/app/views/dashboards/detail.tsx b/static/app/views/dashboards/detail.tsx index 47aed9a222d246..2f7711e2442dd2 100644 --- a/static/app/views/dashboards/detail.tsx +++ b/static/app/views/dashboards/detail.tsx @@ -53,7 +53,6 @@ import {OnRouteLeave} from 'sentry/utils/reactRouter6Compat/onRouteLeave'; import {scheduleMicroTask} from 'sentry/utils/scheduleMicroTask'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useApi} from 'sentry/utils/useApi'; -import {useChartInterval} from 'sentry/utils/useChartInterval'; import {useLocation} from 'sentry/utils/useLocation'; import type {ReactRouter3Navigate} from 'sentry/utils/useNavigate'; import {useNavigate} from 'sentry/utils/useNavigate'; @@ -61,6 +60,7 @@ import {useOrganization} from 'sentry/utils/useOrganization'; import {useParams} from 'sentry/utils/useParams'; import {useProjects} from 'sentry/utils/useProjects'; import {useRouter} from 'sentry/utils/useRouter'; +import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval'; import { cloneDashboard, getCurrentPageFilters, @@ -1462,7 +1462,7 @@ export function DashboardDetailWithInjectedProps( const location = useLocation(); const params = useParams(); const router = useRouter(); - const [chartInterval] = useChartInterval(); + const [chartInterval] = useDashboardChartInterval(); const queryClient = useQueryClient(); // Always use the validated chart interval so the UI dropdown and widget // requests stay in sync. chartInterval is validated against the current page diff --git a/static/app/views/dashboards/filtersBar.tsx b/static/app/views/dashboards/filtersBar.tsx index bc807621ac2fae..e2cda97867f23d 100644 --- a/static/app/views/dashboards/filtersBar.tsx +++ b/static/app/views/dashboards/filtersBar.tsx @@ -25,7 +25,6 @@ import type {User} from 'sentry/types/user'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {ToggleOnDemand} from 'sentry/utils/performance/contexts/onDemandControl'; -import {useChartInterval} from 'sentry/utils/useChartInterval'; import {useMaxPickableDays} from 'sentry/utils/useMaxPickableDays'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useUser} from 'sentry/utils/useUser'; @@ -33,6 +32,7 @@ import {useUserTeams} from 'sentry/utils/useUserTeams'; import {AddFilter} from 'sentry/views/dashboards/globalFilter/addFilter'; import {GenericFilterSelector} from 'sentry/views/dashboards/globalFilter/genericFilterSelector'; import {globalFilterKeysAreEqual} from 'sentry/views/dashboards/globalFilter/utils'; +import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval'; import {useDatasetSearchBarData} from 'sentry/views/dashboards/hooks/useDatasetSearchBarData'; import {useInvalidateStarredDashboards} from 'sentry/views/dashboards/hooks/useInvalidateStarredDashboards'; import {getDashboardFiltersFromURL} from 'sentry/views/dashboards/utils'; @@ -234,7 +234,7 @@ export function FiltersBar({ const hasIntervalSelection = organization.features.includes( 'dashboards-interval-selection' ); - const [interval, setInterval, intervalOptions] = useChartInterval(); + const [interval, setInterval, intervalOptions] = useDashboardChartInterval(); return ( diff --git a/static/app/views/dashboards/hooks/useDashboardChartInterval.tsx b/static/app/views/dashboards/hooks/useDashboardChartInterval.tsx new file mode 100644 index 00000000000000..d147f9a5304112 --- /dev/null +++ b/static/app/views/dashboards/hooks/useDashboardChartInterval.tsx @@ -0,0 +1,15 @@ +import { + ChartIntervalUnspecifiedStrategy, + useChartInterval, +} from 'sentry/utils/useChartInterval'; + +/** + * Wrapper around `useChartInterval` that uses the `USE_SECOND_BIGGEST` + * strategy for dashboards. This keeps the dashboard detail page, widget + * builder preview, and filters bar in sync. + */ +export function useDashboardChartInterval() { + return useChartInterval({ + unspecifiedStrategy: ChartIntervalUnspecifiedStrategy.USE_SECOND_BIGGEST, + }); +} diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx index 5f4909a8c4b49f..aa55709dda7c13 100644 --- a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.tsx @@ -3,10 +3,10 @@ import {useState} from 'react'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; import {dedupeArray} from 'sentry/utils/dedupeArray'; import type {Sort} from 'sentry/utils/discover/fields'; -import {useChartInterval} from 'sentry/utils/useChartInterval'; import {useLocation} from 'sentry/utils/useLocation'; import {useNavigate} from 'sentry/utils/useNavigate'; import {useOrganization} from 'sentry/utils/useOrganization'; +import {useDashboardChartInterval} from 'sentry/views/dashboards/hooks/useDashboardChartInterval'; import { DisplayType, WidgetType, @@ -44,7 +44,7 @@ export function WidgetPreview({ const location = useLocation(); const navigate = useNavigate(); const pageFilters = usePageFilters(); - const [chartInterval] = useChartInterval(); + const [chartInterval] = useDashboardChartInterval(); const {state, dispatch} = useWidgetBuilderContext(); const [tableWidths, setTableWidths] = useState();