Compare commits

...

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
7d21577e4f feat(global-time-store): add support to context, url persistence, store persistence, drift handle 2026-04-23 16:36:31 -03:00
24 changed files with 3590 additions and 282 deletions

View File

@@ -17,7 +17,7 @@ import dayjs, { Dayjs } from 'dayjs';
import {
useGlobalTimeQueryInvalidate,
useIsGlobalTimeQueryRefreshing,
} from 'hooks/globalTime';
} from 'store/globalTime';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -128,35 +128,33 @@ function DateTimeSelection({
}
}, [modalInitialStartTime, modalInitialEndTime]);
const {
localstorageStartTime,
localstorageEndTime,
} = ((): LocalStorageTimeRange => {
const routes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
const { localstorageStartTime, localstorageEndTime } =
((): LocalStorageTimeRange => {
const routes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
if (routes !== null) {
const routesObject = JSON.parse(routes || '{}');
const selectedTime = routesObject[location.pathname];
if (routes !== null) {
const routesObject = JSON.parse(routes || '{}');
const selectedTime = routesObject[location.pathname];
if (selectedTime) {
let parsedSelectedTime: TimeRange;
try {
parsedSelectedTime = JSON.parse(selectedTime);
} catch {
parsedSelectedTime = selectedTime;
if (selectedTime) {
let parsedSelectedTime: TimeRange;
try {
parsedSelectedTime = JSON.parse(selectedTime);
} catch {
parsedSelectedTime = selectedTime;
}
if (isObject(parsedSelectedTime)) {
return {
localstorageStartTime: parsedSelectedTime.startTime,
localstorageEndTime: parsedSelectedTime.endTime,
};
}
return { localstorageStartTime: null, localstorageEndTime: null };
}
if (isObject(parsedSelectedTime)) {
return {
localstorageStartTime: parsedSelectedTime.startTime,
localstorageEndTime: parsedSelectedTime.endTime,
};
}
return { localstorageStartTime: null, localstorageEndTime: null };
}
}
return { localstorageStartTime: null, localstorageEndTime: null };
})();
return { localstorageStartTime: null, localstorageEndTime: null };
})();
const getTime = useCallback((): [number, number] | undefined => {
if (searchEndTime && searchStartTime) {
@@ -183,9 +181,8 @@ function DateTimeSelection({
const [options, setOptions] = useState(getOptions(location.pathname));
const [refreshButtonHidden, setRefreshButtonHidden] = useState<boolean>(false);
const [customDateTimeVisible, setCustomDTPickerVisible] = useState<boolean>(
false,
);
const [customDateTimeVisible, setCustomDTPickerVisible] =
useState<boolean>(false);
const { stagedQuery, currentQuery, initQueryBuilderData } = useQueryBuilder();

View File

@@ -1,2 +0,0 @@
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';

View File

@@ -1,16 +0,0 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to invalida any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
return useCallback(async () => {
return await queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
});
}, [queryClient]);
}

View File

@@ -0,0 +1,69 @@
import {
// oxlint-disable-next-line no-restricted-imports
createContext,
ReactNode,
// oxlint-disable-next-line no-restricted-imports
useContext,
useState,
} from 'react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import get from 'api/browser/localstorage/get';
import {
createGlobalTimeStore,
defaultGlobalTimeStore,
GlobalTimeStoreApi,
} from './globalTimeStore';
import { GlobalTimeProviderOptions, GlobalTimeSelectedTime } from './types';
import { usePersistence } from './usePersistence';
import { useQueryCacheSync } from './useQueryCacheSync';
import { useUrlSync } from './useUrlSync';
import { useComputedMinMaxSync } from 'store/globalTime/useComputedMinMaxSync';
export const GlobalTimeContext = createContext<GlobalTimeStoreApi | null>(null);
export function GlobalTimeProvider({
children,
inheritGlobalTime = false,
initialTime,
enableUrlParams = false,
removeQueryParamsOnUnmount = false,
localStoragePersistKey,
refreshInterval: initialRefreshInterval,
}: GlobalTimeProviderOptions & { children: ReactNode }): JSX.Element {
const parentStore = useContext(GlobalTimeContext);
const globalStore = parentStore ?? defaultGlobalTimeStore;
const resolveInitialTime = (): GlobalTimeSelectedTime => {
if (inheritGlobalTime) {
return globalStore.getState().selectedTime;
}
if (localStoragePersistKey) {
const stored = get(localStoragePersistKey);
if (stored) {
return stored as GlobalTimeSelectedTime;
}
}
return initialTime ?? DEFAULT_TIME_RANGE;
};
// Create isolated store (stable reference)
const [store] = useState(() =>
createGlobalTimeStore({
selectedTime: resolveInitialTime(),
refreshInterval: initialRefreshInterval ?? 0,
}),
);
useComputedMinMaxSync(store);
useQueryCacheSync(store);
useUrlSync(store, enableUrlParams, removeQueryParamsOnUnmount);
usePersistence(store, localStoragePersistKey);
return (
<GlobalTimeContext.Provider value={store}>
{children}
</GlobalTimeContext.Provider>
);
}

View File

@@ -0,0 +1,698 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import set from 'api/browser/localstorage/set';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeProviderOptions } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
jest.mock('api/browser/localstorage/set');
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const createWrapper = (
providerProps: GlobalTimeProviderOptions,
nuqsProps?: { searchParams?: string },
) => {
const queryClient = createTestQueryClient();
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams={nuqsProps?.searchParams}>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('GlobalTimeProvider', () => {
describe('store isolation', () => {
it('should create isolated store for each provider', () => {
const wrapper1 = createWrapper({ initialTime: '1h' });
const wrapper2 = createWrapper({ initialTime: '15m' });
const { result: result1 } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: wrapper1 },
);
const { result: result2 } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: wrapper2 },
);
expect(result1.current).toBe('1h');
expect(result2.current).toBe('15m');
});
});
describe('inheritGlobalTime', () => {
it('should inherit time from parent store when inheritGlobalTime is true', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// Should inherit '6h' from parent provider
expect(result.current).toBe('6h');
});
it('should use initialTime when inheritGlobalTime is false', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// Should use its own initialTime, not parent's
expect(result.current).toBe('15m');
});
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="?relativeTime=1h">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should use inherited time when URL params are empty', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// No URL params, should keep inherited value
expect(result.current).toBe('6h');
});
it('should prefer custom time URL params over inheritGlobalTime', async () => {
const queryClient = createTestQueryClient();
const startTime = 1700000000000;
const endTime = 1700003600000;
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime(), {
wrapper: NestedWrapper,
});
// URL custom time params should override inherited time
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
});
describe('URL sync', () => {
it('should read relativeTime from URL on mount', async () => {
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: '?relativeTime=1h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should read custom time from URL on mount', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should use custom URL keys when provided', async () => {
const wrapper = createWrapper(
{
enableUrlParams: {
relativeTimeKey: 'modalTime',
},
},
{ searchParams: '?modalTime=3h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
await waitFor(() => {
expect(result.current).toBe('3h');
});
});
it('should use custom startTimeKey and endTimeKey when provided', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{
enableUrlParams: {
startTimeKey: 'customStart',
endTimeKey: 'customEnd',
},
},
{ searchParams: `?customStart=${startTime}&customEnd=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should NOT read from URL when enableUrlParams is false', async () => {
const wrapper = createWrapper(
{ enableUrlParams: false, initialTime: '15m' },
{ searchParams: '?relativeTime=1h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// Should use initialTime, not URL value
expect(result.current).toBe('15m');
});
it('should prefer startTime/endTime over relativeTime when both present in URL', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{
searchParams: `?relativeTime=15m&startTime=${startTime}&endTime=${endTime}`,
},
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
// Should use startTime/endTime, not relativeTime
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should use initialTime when URL has invalid time values', async () => {
const wrapper = createWrapper(
{ enableUrlParams: true, initialTime: '15m' },
{ searchParams: '?startTime=invalid&endTime=also-invalid' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// parseAsInteger returns null for invalid values, so should fallback to initialTime
expect(result.current).toBe('15m');
});
it('should update store when custom time is set from URL with only startTime and endTime', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
// Verify selectedTime is a custom time range string
expect(result.current.selectedTime).toContain('||_||');
});
});
describe('removeQueryParamsOnUnmount', () => {
const createUnmountTestWrapper = (
getQueryString: () => string,
setQueryString: (qs: string) => void,
) => {
return function TestWrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={getQueryString()}
onUrlUpdate={(event): void => {
setQueryString(event.queryString);
}}
>
{children}
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
it('should remove URL params when provider unmounts with removeQueryParamsOnUnmount=true', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams
removeQueryParamsOnUnmount
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('relativeTime');
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
});
it('should NOT remove URL params when provider unmounts with removeQueryParamsOnUnmount=false', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams
removeQueryParamsOnUnmount={false}
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// Wait a tick to ensure cleanup effects would have run
await waitFor(() => {
// URL params should still be present
expect(currentQueryString).toContain('relativeTime=1h');
});
});
it('should remove custom time URL params on unmount', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
let currentQueryString = `startTime=${startTime}&endTime=${endTime}`;
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime(), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('startTime');
expect(currentQueryString).toContain('endTime');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
});
it('should remove custom URL key params on unmount', async () => {
let currentQueryString = 'modalTime=3h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams={{
relativeTimeKey: 'modalTime',
}}
removeQueryParamsOnUnmount
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('modalTime=3h');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('modalTime');
});
});
it('should NOT remove URL params when enableUrlParams is false', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams={false}
removeQueryParamsOnUnmount
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// Wait a tick
await waitFor(() => {
// URL params should still be present (enableUrlParams is false)
expect(currentQueryString).toContain('relativeTime=1h');
});
});
});
});
describe('localStorage persistence', () => {
const mockSet = set as jest.MockedFunction<typeof set>;
beforeEach(() => {
localStorage.clear();
mockSet.mockClear();
mockSet.mockReturnValue(true);
});
it('should read from localStorage on mount', () => {
localStorage.setItem('test-time-key', '6h');
const wrapper = createWrapper({ localStoragePersistKey: 'test-time-key' });
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
expect(result.current).toBe('6h');
});
it('should write to localStorage on selectedTime change', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-persist-key',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
act(() => {
result.current.setSelectedTime('12h');
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-persist-key', '12h');
});
});
it('should NOT write to localStorage when persistKey is undefined', async () => {
const wrapper = createWrapper({ initialTime: '15m' });
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
act(() => {
result.current.setSelectedTime('1h');
});
// Wait a tick to ensure any async operations complete
await waitFor(() => {
expect(result.current.selectedTime).toBe('1h');
});
expect(mockSet).not.toHaveBeenCalled();
});
it('should only write to localStorage when selectedTime changes, not other state', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
// Change refreshInterval (not selectedTime)
act(() => {
result.current.setRefreshInterval(5000);
});
// Wait to ensure subscription handler had a chance to run
await waitFor(() => {
expect(result.current.refreshInterval).toBe(5000);
});
// Should NOT have written to localStorage for refreshInterval change
expect(mockSet).not.toHaveBeenCalled();
// Now change selectedTime
act(() => {
result.current.setSelectedTime('1h');
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-key', '1h');
});
});
it('should fallback to initialTime when localStorage contains empty string', () => {
localStorage.setItem('test-key', '');
const wrapper = createWrapper({
localStoragePersistKey: 'test-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// Empty string is falsy, should use initialTime
expect(result.current).toBe('15m');
});
it('should write custom time range to localStorage', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-custom-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime);
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-custom-key', customTime);
});
});
});
describe('refreshInterval', () => {
it('should initialize with provided refreshInterval', () => {
const wrapper = createWrapper({ refreshInterval: 5000 });
const { result } = renderHook(() => useGlobalTime(), { wrapper });
expect(result.current.refreshInterval).toBe(5000);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,83 @@
import { act } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { createGlobalTimeStore, defaultGlobalTimeStore } from '../globalTimeStore';
import { createCustomTimeRange } from '../utils';
describe('createGlobalTimeStore', () => {
describe('factory function', () => {
it('should create independent store instances', () => {
const store1 = createGlobalTimeStore();
const store2 = createGlobalTimeStore();
store1.getState().setSelectedTime('1h');
expect(store1.getState().selectedTime).toBe('1h');
expect(store2.getState().selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should accept initial state', () => {
const store = createGlobalTimeStore({
selectedTime: '15m',
refreshInterval: 5000,
});
expect(store.getState().selectedTime).toBe('15m');
expect(store.getState().refreshInterval).toBe(5000);
expect(store.getState().isRefreshEnabled).toBe(true);
});
it('should compute isRefreshEnabled correctly for custom time', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const store = createGlobalTimeStore({
selectedTime: customTime,
refreshInterval: 5000,
});
expect(store.getState().isRefreshEnabled).toBe(false);
});
});
describe('defaultGlobalTimeStore', () => {
it('should be a singleton', () => {
expect(defaultGlobalTimeStore).toBeDefined();
expect(defaultGlobalTimeStore.getState().selectedTime).toBeDefined();
});
});
describe('setRefreshInterval', () => {
it('should update refresh interval and enable refresh', () => {
const store = createGlobalTimeStore();
act(() => {
store.getState().setRefreshInterval(10000);
});
expect(store.getState().refreshInterval).toBe(10000);
expect(store.getState().isRefreshEnabled).toBe(true);
});
it('should disable refresh when interval is 0', () => {
const store = createGlobalTimeStore({ refreshInterval: 5000 });
act(() => {
store.getState().setRefreshInterval(0);
});
expect(store.getState().refreshInterval).toBe(0);
expect(store.getState().isRefreshEnabled).toBe(false);
});
it('should not enable refresh for custom time range', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const store = createGlobalTimeStore({ selectedTime: customTime });
act(() => {
store.getState().setRefreshInterval(10000);
});
expect(store.getState().refreshInterval).toBe(10000);
expect(store.getState().isRefreshEnabled).toBe(false);
});
});
});

View File

@@ -1,204 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeSelectedTime } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const {
minTime: resultMin,
maxTime: resultMax,
} = result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return different values on subsequent calls for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
// Advance time by 1 second
act(() => {
jest.advanceTimersByTime(1000);
});
const second = result.current.getMinMaxTime();
// maxTime should be different (1 second later)
expect(second.maxTime).toBe(first.maxTime + 1000 * NANO_SECOND_MULTIPLIER);
expect(second.minTime).toBe(first.minTime + 1000 * NANO_SECOND_MULTIPLIER);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,789 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { createGlobalTimeStore, useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeContext } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeSelectedTime, GlobalTimeState } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
/**
* Creates an isolated store wrapper for testing.
* Each test gets its own store instance, avoiding test pollution.
*/
function createIsolatedWrapper(
initialState?: Partial<GlobalTimeState>,
): ({ children }: { children: ReactNode }) => JSX.Element {
const store = createGlobalTimeStore(initialState);
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<GlobalTimeContext.Provider value={store}>
{children}
</GlobalTimeContext.Provider>
);
};
}
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
it('should have lastRefreshTimestamp as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.lastRefreshTimestamp).toBe(0);
});
it('should have lastComputedMinMax with default values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
it('should reset lastComputedMinMax when selectedTime changes', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Compute and store initial values
act(() => {
result.current.computeAndStoreMinMax();
});
// Verify we have cached values
expect(result.current.lastComputedMinMax.maxTime).toBeGreaterThan(0);
// Now switch to a custom time range
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime);
});
// lastComputedMinMax should be reset
expect(result.current.lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
});
it('should return fresh custom time values after switching from relative time', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Compute and cache values for relative time
act(() => {
result.current.computeAndStoreMinMax();
});
const relativeMinMax = { ...result.current.lastComputedMinMax };
// Switch to custom time range
const customMinTime = 5000000000;
const customMaxTime = 6000000000;
const customTime = createCustomTimeRange(customMinTime, customMaxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
// getMinMaxTime should return the custom time values, not cached relative values
const returned = result.current.getMinMaxTime();
expect(returned.minTime).toBe(customMinTime);
expect(returned.maxTime).toBe(customMaxTime);
expect(returned).not.toStrictEqual(relativeMinMax);
jest.useRealTimers();
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime: resultMin, maxTime: resultMax } =
result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should NOT round custom time range values to minute boundaries', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
// If rounding occurred, these would change to 12:30:00.000
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime, maxTime } = result.current.getMinMaxTime();
// Should return exact values, NOT rounded values
expect(minTime).toBe(minTimeWithSeconds);
expect(maxTime).toBe(maxTimeWithSeconds);
expect(minTime).not.toBe(minTimeRounded);
expect(maxTime).not.toBe(maxTimeRounded);
});
it('should NOT round custom time range passed as parameter', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Store is set to relative time
act(() => {
result.current.setSelectedTime('15m');
});
// Use timestamps that are NOT on minute boundaries
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
// Pass custom time as parameter (different from store's selectedTime)
const { minTime, maxTime } = result.current.getMinMaxTime(customTime);
// Should return exact values, NOT rounded values
expect(minTime).toBe(minTimeWithSeconds);
expect(maxTime).toBe(maxTimeWithSeconds);
expect(minTime).not.toBe(minTimeRounded);
expect(maxTime).not.toBe(maxTimeRounded);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return same values on subsequent calls for relative time under a minute', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
act(() => {
jest.advanceTimersByTime(59000);
});
const second = result.current.getMinMaxTime();
expect(second.maxTime).toBe(first.maxTime);
expect(second.minTime).toBe(first.minTime);
});
it('should return different values on subsequent calls for relative time only after a minute', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
act(() => {
jest.advanceTimersByTime(60000);
});
const second = result.current.getMinMaxTime();
expect(second.maxTime).toBe(first.maxTime + 60000 * NANO_SECOND_MULTIPLIER);
expect(second.minTime).toBe(first.minTime + 60000 * NANO_SECOND_MULTIPLIER);
});
it('should return stored lastComputedMinMax when available', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
result.current.computeAndStoreMinMax();
});
const stored = { ...result.current.lastComputedMinMax };
// Advance time by 5 seconds
act(() => {
jest.advanceTimersByTime(5000);
});
// getMinMaxTime should return stored values, not fresh computation
const returned = result.current.getMinMaxTime();
expect(returned).toStrictEqual(stored);
});
it('should compute fresh values when different selectedTime is provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
result.current.computeAndStoreMinMax();
});
const stored = { ...result.current.lastComputedMinMax };
// Request time for a different selectedTime
const freshValues = result.current.getMinMaxTime('1h');
// Should NOT equal stored values (different duration)
expect(freshValues).not.toStrictEqual(stored);
});
it('should behave same as no-param call when selectedTime matches state', () => {
// This tests the pattern used in K8sBaseDetails:
// getMinMaxTime(selectedTime) where selectedTime === state.selectedTime
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000, // isRefreshEnabled = true
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call with selectedTime parameter that matches state.selectedTime
// Should behave the same as calling without parameter
const withParam = result.current.getMinMaxTime('15m');
const withoutParam = result.current.getMinMaxTime();
expect(withParam).toStrictEqual(withoutParam);
expect(withParam.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
describe('with isRefreshEnabled (isolated store)', () => {
it('should compute fresh values when isRefreshEnabled is true', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// getMinMaxTime should return fresh values, not cached
const freshValues = result.current.getMinMaxTime();
expect(freshValues.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
expect(freshValues.minTime).toBe(
initialMinMax.minTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
it('should update lastComputedMinMax when values change', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call getMinMaxTime - should update lastComputedMinMax
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
expect(result.current.lastComputedMinMax.minTime).toBe(
initialMinMax.minTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
it('should update lastRefreshTimestamp when values change', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call getMinMaxTime - should update timestamp
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
it('should NOT update lastComputedMinMax when values have not changed (same minute)', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time but stay within same minute
act(() => {
jest.advanceTimersByTime(30000);
});
// Call getMinMaxTime - should NOT update store (same minute boundary)
act(() => {
result.current.getMinMaxTime();
});
// Values should be unchanged (no unnecessary re-renders)
expect(result.current.lastComputedMinMax).toStrictEqual(initialMinMax);
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
});
it('should return cached values when isRefreshEnabled is false', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 0, // Refresh disabled
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const storedMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// getMinMaxTime should return cached values since refresh is disabled
const returned = result.current.getMinMaxTime();
expect(returned).toStrictEqual(storedMinMax);
expect(result.current.lastComputedMinMax).toStrictEqual(storedMinMax);
});
it('should return same values for custom time range regardless of time passing', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
const wrapper = createIsolatedWrapper({
selectedTime: customTime,
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// isRefreshEnabled should be false for custom time ranges
expect(result.current.isRefreshEnabled).toBe(false);
// Custom time ranges always return the fixed values, not relative to "now"
const first = result.current.getMinMaxTime();
expect(first.minTime).toBe(minTime);
expect(first.maxTime).toBe(maxTime);
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Should still return the same fixed values (custom range doesn't drift)
const second = result.current.getMinMaxTime();
expect(second.minTime).toBe(minTime);
expect(second.maxTime).toBe(maxTime);
});
it('should handle multiple consecutive refetch intervals correctly', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Simulate 3 refetch intervals crossing minute boundaries
for (let i = 1; i <= 3; i++) {
act(() => {
jest.advanceTimersByTime(60000);
});
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + i * 60000 * NANO_SECOND_MULTIPLIER,
);
}
});
});
});
describe('computeAndStoreMinMax', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should compute and store rounded min/max values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
act(() => {
result.current.computeAndStoreMinMax();
});
// maxTime should be rounded to 12:30:00.000
const expectedMaxTime =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
expect(result.current.lastComputedMinMax.minTime).toBe(
expectedMaxTime - fifteenMinutesNs,
);
});
it('should update lastRefreshTimestamp', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const beforeTimestamp = Date.now();
act(() => {
result.current.computeAndStoreMinMax();
});
expect(result.current.lastRefreshTimestamp).toBeGreaterThanOrEqual(
beforeTimestamp,
);
});
it('should return the computed values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
expect(returnedValue).toStrictEqual(result.current.lastComputedMinMax);
});
it('should NOT round custom time range values to minute boundaries', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
// If rounding occurred, these would change to 12:30:00.000
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
act(() => {
result.current.setSelectedTime(customTime);
});
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
// Should return exact values, NOT rounded values
expect(returnedValue?.minTime).toBe(minTimeWithSeconds);
expect(returnedValue?.maxTime).toBe(maxTimeWithSeconds);
expect(returnedValue?.minTime).not.toBe(minTimeRounded);
expect(returnedValue?.maxTime).not.toBe(maxTimeRounded);
// lastComputedMinMax should also have exact values
expect(result.current.lastComputedMinMax.minTime).toBe(minTimeWithSeconds);
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTimeWithSeconds);
});
});
describe('updateRefreshTimestamp', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should update lastRefreshTimestamp to current time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.updateRefreshTimestamp();
});
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
});
it('should not modify lastComputedMinMax', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.computeAndStoreMinMax();
});
const beforeMinMax = { ...result.current.lastComputedMinMax };
act(() => {
jest.advanceTimersByTime(5000);
result.current.updateRefreshTimestamp();
});
expect(result.current.lastComputedMinMax).toStrictEqual(beforeMinMax);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,122 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { createGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeContext } from '../GlobalTimeContext';
import {
useGlobalTime,
useGlobalTimeStoreApi,
useIsCustomTimeRange,
useLastComputedMinMax,
} from '../hooks';
import { createCustomTimeRange } from '../utils';
describe('useGlobalTime', () => {
it('should return full store state without selector', () => {
const { result } = renderHook(() => useGlobalTime());
expect(result.current.selectedTime).toBeDefined();
expect(result.current.setSelectedTime).toBeInstanceOf(Function);
});
it('should return selected value with selector', () => {
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime));
expect(typeof result.current).toBe('string');
});
it('should use context store when provided', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '1h' });
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
expect(result.current).toBe('1h');
});
});
describe('useIsCustomTimeRange', () => {
it('should return false for relative time', () => {
const { result } = renderHook(() => useIsCustomTimeRange());
expect(result.current).toBe(false);
});
it('should return true for custom time range', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const contextStore = createGlobalTimeStore({ selectedTime: customTime });
const { result } = renderHook(() => useIsCustomTimeRange(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
expect(result.current).toBe(true);
});
});
describe('useGlobalTimeStoreApi', () => {
it('should return store API', () => {
const { result } = renderHook(() => useGlobalTimeStoreApi());
expect(result.current.getState).toBeInstanceOf(Function);
expect(result.current.subscribe).toBeInstanceOf(Function);
});
});
describe('useLastComputedMinMax', () => {
it('should return lastComputedMinMax from store', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
// Compute the min/max first
contextStore.getState().computeAndStoreMinMax();
const { result } = renderHook(() => useLastComputedMinMax(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
expect(result.current).toStrictEqual(contextStore.getState().lastComputedMinMax);
});
it('should update when store changes', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
contextStore.getState().computeAndStoreMinMax();
const { result } = renderHook(() => useLastComputedMinMax(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
const firstValue = { ...result.current };
// Change time and recompute
act(() => {
jest.advanceTimersByTime(60000); // Advance 1 minute
contextStore.getState().computeAndStoreMinMax();
});
expect(result.current).not.toStrictEqual(firstValue);
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,279 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeProviderOptions } from '../types';
import { useGlobalTimeQueryInvalidate } from '../useGlobalTimeQueryInvalidate';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: Infinity,
},
},
});
const createWrapper = (
providerProps: GlobalTimeProviderOptions,
queryClient: QueryClient,
) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('useGlobalTimeQueryInvalidate', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
queryClient.clear();
});
it('should return a function', () => {
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
expect(typeof result.current).toBe('function');
});
it('should call computeAndStoreMinMax before invalidating queries', async () => {
const wrapper = createWrapper(
{ initialTime: '15m', refreshInterval: 5000 },
queryClient,
);
const { result } = renderHook(
() => ({
invalidate: useGlobalTimeQueryInvalidate(),
globalTime: useGlobalTime(),
}),
{ wrapper },
);
// Initial computation
const initialMinMax = { ...result.current.globalTime.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call invalidate
await act(async () => {
await result.current.invalidate();
});
// lastComputedMinMax should have been updated
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
it('should invalidate queries with AUTO_REFRESH_QUERY key', async () => {
const mockQueryFn = jest.fn().mockResolvedValue({ data: 'test' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up a query with AUTO_REFRESH_QUERY key
const { result: queryResult } = renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
queryFn: mockQueryFn,
}),
{ wrapper },
);
// Wait for initial query to complete
await waitFor(() => {
expect(queryResult.current.isSuccess).toBe(true);
});
expect(mockQueryFn).toHaveBeenCalledTimes(1);
// Now render the invalidate hook and call it
const { result: invalidateResult } = renderHook(
() => useGlobalTimeQueryInvalidate(),
{ wrapper },
);
await act(async () => {
await invalidateResult.current();
});
// Query should have been refetched
await waitFor(() => {
expect(mockQueryFn).toHaveBeenCalledTimes(2);
});
});
it('should NOT invalidate queries without AUTO_REFRESH_QUERY key', async () => {
const autoRefreshQueryFn = jest.fn().mockResolvedValue({ data: 'auto' });
const regularQueryFn = jest.fn().mockResolvedValue({ data: 'regular' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up both types of queries
const { result: autoRefreshQuery } = renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto-query'],
queryFn: autoRefreshQueryFn,
}),
{ wrapper },
);
const { result: regularQuery } = renderHook(
() =>
useQuery({
queryKey: ['regular-query'],
queryFn: regularQueryFn,
}),
{ wrapper },
);
// Wait for initial queries to complete
await waitFor(() => {
expect(autoRefreshQuery.current.isSuccess).toBe(true);
expect(regularQuery.current.isSuccess).toBe(true);
});
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(1);
expect(regularQueryFn).toHaveBeenCalledTimes(1);
// Call invalidate
const { result: invalidateResult } = renderHook(
() => useGlobalTimeQueryInvalidate(),
{ wrapper },
);
await act(async () => {
await invalidateResult.current();
});
// Only auto-refresh query should be refetched
await waitFor(() => {
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(2);
});
// Regular query should NOT be refetched
expect(regularQueryFn).toHaveBeenCalledTimes(1);
});
it('should use exact custom time values (not rounded) when invalidating', async () => {
// Use timestamps that are NOT on minute boundaries
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
const wrapper = createWrapper({ initialTime: customTime }, queryClient);
const { result } = renderHook(
() => ({
invalidate: useGlobalTimeQueryInvalidate(),
globalTime: useGlobalTime(),
}),
{ wrapper },
);
// Call invalidate
await act(async () => {
await result.current.invalidate();
});
// Verify custom time values are NOT rounded
expect(result.current.globalTime.lastComputedMinMax.minTime).toBe(
minTimeWithSeconds,
);
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
maxTimeWithSeconds,
);
});
it('should invalidate multiple AUTO_REFRESH_QUERY queries at once', async () => {
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
const queryFn3 = jest.fn().mockResolvedValue({ data: 'query3' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up multiple auto-refresh queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: queryFn1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: queryFn2,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query3'],
queryFn: queryFn3,
}),
{ wrapper },
);
// Wait for initial queries
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(1);
expect(queryFn2).toHaveBeenCalledTimes(1);
expect(queryFn3).toHaveBeenCalledTimes(1);
});
// Call invalidate
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
await act(async () => {
await result.current();
});
// All queries should be refetched
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(2);
expect(queryFn2).toHaveBeenCalledTimes(2);
expect(queryFn3).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,229 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useIsGlobalTimeQueryRefreshing } from '../useIsGlobalTimeQueryRefreshing';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const createWrapper = (
queryClient: QueryClient,
): (({ children }: { children: ReactNode }) => JSX.Element) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
};
describe('useIsGlobalTimeQueryRefreshing', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
});
afterEach(() => {
queryClient.clear();
});
it('should return false when no queries are fetching', () => {
const wrapper = createWrapper(queryClient);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
expect(result.current).toBe(false);
});
it('should return true when AUTO_REFRESH_QUERY is fetching', async () => {
let resolveQuery: (value: unknown) => void;
const queryPromise = new Promise((resolve) => {
resolveQuery = resolve;
});
const wrapper = createWrapper(queryClient);
// Start the auto-refresh query
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
queryFn: () => queryPromise,
}),
{ wrapper },
);
// Check if refreshing hook detects it
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true while fetching
expect(result.current).toBe(true);
// Resolve the query
act(() => {
resolveQuery({ data: 'done' });
});
// Should be false after fetching completes
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should return false when non-AUTO_REFRESH_QUERY is fetching', async () => {
let resolveQuery: (value: unknown) => void;
const queryPromise = new Promise((resolve) => {
resolveQuery = resolve;
});
const wrapper = createWrapper(queryClient);
// Start a regular query (not auto-refresh)
renderHook(
() =>
useQuery({
queryKey: ['regular-query'],
queryFn: () => queryPromise,
}),
{ wrapper },
);
// Check if refreshing hook detects it
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be false - not an auto-refresh query
expect(result.current).toBe(false);
// Cleanup
act(() => {
resolveQuery({ data: 'done' });
});
});
it('should return true when multiple AUTO_REFRESH_QUERY queries are fetching', async () => {
let resolveQuery1: (value: unknown) => void;
let resolveQuery2: (value: unknown) => void;
const queryPromise1 = new Promise((resolve) => {
resolveQuery1 = resolve;
});
const queryPromise2 = new Promise((resolve) => {
resolveQuery2 = resolve;
});
const wrapper = createWrapper(queryClient);
// Start multiple auto-refresh queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: () => queryPromise1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: () => queryPromise2,
}),
{ wrapper },
);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true while fetching
expect(result.current).toBe(true);
// Resolve first query
act(() => {
resolveQuery1({ data: 'done1' });
});
// Should still be true (second query still fetching)
await waitFor(() => {
expect(result.current).toBe(true);
});
// Resolve second query
act(() => {
resolveQuery2({ data: 'done2' });
});
// Should be false after all complete
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should only track AUTO_REFRESH_QUERY, not other queries', async () => {
let resolveAutoRefresh: (value: unknown) => void;
let resolveRegular: (value: unknown) => void;
const autoRefreshPromise = new Promise((resolve) => {
resolveAutoRefresh = resolve;
});
const regularPromise = new Promise((resolve) => {
resolveRegular = resolve;
});
const wrapper = createWrapper(queryClient);
// Start both types of queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto'],
queryFn: () => autoRefreshPromise,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: ['regular'],
queryFn: () => regularPromise,
}),
{ wrapper },
);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true (auto-refresh is fetching)
expect(result.current).toBe(true);
// Resolve auto-refresh query
act(() => {
resolveAutoRefresh({ data: 'done' });
});
// Should be false even though regular query is still fetching
await waitFor(() => {
expect(result.current).toBe(false);
});
// Cleanup
act(() => {
resolveRegular({ data: 'done' });
});
});
});

View File

@@ -0,0 +1,100 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { createGlobalTimeStore, GlobalTimeStoreApi } from '../globalTimeStore';
import { useQueryCacheSync } from '../useQueryCacheSync';
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
function createWrapper(
queryClient: QueryClient,
): ({ children }: { children: ReactNode }) => JSX.Element {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe('useQueryCacheSync', () => {
let store: GlobalTimeStoreApi;
let queryClient: QueryClient;
beforeEach(() => {
store = createGlobalTimeStore();
queryClient = createTestQueryClient();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
queryClient.clear();
});
it('should update lastRefreshTimestamp when auto-refresh query succeeds', async () => {
// Initialize store
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
// Advance time
act(() => {
jest.advanceTimersByTime(5000);
});
// Render the hook
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Simulate a successful auto-refresh query
await act(async () => {
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
await waitFor(() => {
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
});
it('should not update timestamp for non-auto-refresh queries', async () => {
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Simulate a regular query (not auto-refresh)
await act(async () => {
await queryClient.fetchQuery({
queryKey: ['some-other-query'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
});
});

View File

@@ -1,10 +1,15 @@
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
computeRoundedMinMax,
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
getAutoRefreshQueryKey,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
roundDownToMinute,
} from '../utils';
describe('globalTime/utils', () => {
@@ -59,7 +64,7 @@ describe('globalTime/utils', () => {
const maxTime = 2000000000;
const timeString = `${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`;
const result = parseCustomTimeRange(timeString);
expect(result).toEqual({ minTime, maxTime });
expect(result).toStrictEqual({ minTime, maxTime });
});
it('should return null for non-custom time range strings', () => {
@@ -75,7 +80,7 @@ describe('globalTime/utils', () => {
it('should handle zero values', () => {
const result = parseCustomTimeRange(`0${CUSTOM_TIME_SEPARATOR}0`);
expect(result).toEqual({ minTime: 0, maxTime: 0 });
expect(result).toStrictEqual({ minTime: 0, maxTime: 0 });
});
});
@@ -94,7 +99,7 @@ describe('globalTime/utils', () => {
const maxTime = 2000000000;
const timeString = createCustomTimeRange(minTime, maxTime);
const result = parseSelectedTime(timeString);
expect(result).toEqual({ minTime, maxTime });
expect(result).toStrictEqual({ minTime, maxTime });
});
it('should return fallback for invalid custom time range', () => {
@@ -136,4 +141,130 @@ describe('globalTime/utils', () => {
expect(result.minTime).toBe(now - oneDayNs);
});
});
describe('roundDownToMinute', () => {
it('should round down timestamp to minute boundary', () => {
// 2024-01-15T12:30:45.123Z -> 2024-01-15T12:30:00.000Z
const inputNano = 1705321845123 * NANO_SECOND_MULTIPLIER; // 12:30:45.123
const expectedNano = 1705321800000 * NANO_SECOND_MULTIPLIER; // 12:30:00.000
expect(roundDownToMinute(inputNano)).toBe(expectedNano);
});
it('should not change timestamp already at minute boundary', () => {
const inputNano = 1705321800000 * NANO_SECOND_MULTIPLIER; // 12:30:00.000
expect(roundDownToMinute(inputNano)).toBe(inputNano);
});
it('should handle timestamp at 59 seconds', () => {
// 2024-01-15T12:30:59.999Z -> 2024-01-15T12:30:00.000Z
const inputNano = 1705321859999 * NANO_SECOND_MULTIPLIER;
const expectedNano = 1705321800000 * NANO_SECOND_MULTIPLIER;
expect(roundDownToMinute(inputNano)).toBe(expectedNano);
});
});
describe('computeRoundedMinMax', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return rounded maxTime for relative time', () => {
const result = computeRoundedMinMax('15m');
// maxTime should be rounded down to 12:30:00.000
const expectedMaxTime =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(expectedMaxTime);
});
it('should compute minTime based on rounded maxTime', () => {
const result = computeRoundedMinMax('15m');
const expectedMaxTime =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.minTime).toBe(expectedMaxTime - fifteenMinutesNs);
});
it('should return unchanged values for custom time range', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
const result = computeRoundedMinMax(customTime);
expect(result.minTime).toBe(minTime);
expect(result.maxTime).toBe(maxTime);
});
it('should return fallback for invalid custom time range', () => {
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
const invalidCustom = `invalid${CUSTOM_TIME_SEPARATOR}values`;
const result = computeRoundedMinMax(invalidCustom);
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fallbackDuration = 30 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(now);
expect(result.minTime).toBe(now - fallbackDuration);
});
});
describe('getAutoRefreshQueryKey', () => {
it('should prefix with AUTO_REFRESH_QUERY constant', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY');
expect(result[0]).toBe(REACT_QUERY_KEY.AUTO_REFRESH_QUERY);
});
it('should append selectedTime at end', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'MY_QUERY',
'param1',
'15m',
]);
});
it('should handle no additional query parts', () => {
const result = getAutoRefreshQueryKey('1h');
expect(result).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
});
it('should handle custom time range as selectedTime', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const result = getAutoRefreshQueryKey(customTime, 'METRICS');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'METRICS',
customTime,
]);
});
it('should handle object query parts', () => {
const params = { entityId: '123', filter: 'active' };
const result = getAutoRefreshQueryKey('15m', 'ENTITY', params);
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'ENTITY',
params,
'15m',
]);
});
});
});

View File

@@ -1,32 +1,138 @@
import { createStore, StoreApi, useStore } from 'zustand';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { create } from 'zustand';
import {
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStore,
ParsedTimeRange,
} from './types';
import { isCustomTimeRange, parseSelectedTime } from './utils';
import {
computeRoundedMinMax,
isCustomTimeRange,
parseSelectedTime,
} from './utils';
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
export type IGlobalTimeStore = GlobalTimeStore;
export const useGlobalTimeStore = create<IGlobalTimeStore>((set, get) => ({
selectedTime: DEFAULT_TIME_RANGE,
isRefreshEnabled: false,
refreshInterval: 0,
setSelectedTime: (selectedTime, refreshInterval): void => {
set((state) => {
const newRefreshInterval = refreshInterval ?? state.refreshInterval;
const isCustom = isCustomTimeRange(selectedTime);
function computeIsRefreshEnabled(
selectedTime: GlobalTimeSelectedTime,
refreshInterval: number,
): boolean {
if (isCustomTimeRange(selectedTime)) {
return false;
}
return refreshInterval > 0;
}
return {
selectedTime,
refreshInterval: newRefreshInterval,
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
};
});
},
getMinMaxTime: (selectedTime): ParsedTimeRange => {
return parseSelectedTime(selectedTime || get().selectedTime);
},
}));
export function createGlobalTimeStore(
initialState?: Partial<GlobalTimeState>,
): GlobalTimeStoreApi {
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
const refreshInterval = initialState?.refreshInterval ?? 0;
return createStore<GlobalTimeStore>((set, get) => ({
selectedTime,
refreshInterval,
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
lastRefreshTimestamp: 0,
lastComputedMinMax: { minTime: 0, maxTime: 0 },
setSelectedTime: (
time: GlobalTimeSelectedTime,
newRefreshInterval?: number,
): void => {
set((state) => {
const interval = newRefreshInterval ?? state.refreshInterval;
return {
selectedTime: time,
refreshInterval: interval,
isRefreshEnabled: computeIsRefreshEnabled(time, interval),
// Reset cached values so getMinMaxTime computes fresh values for the new selection
lastComputedMinMax: { minTime: 0, maxTime: 0 },
};
});
},
setRefreshInterval: (interval: number): void => {
set((state) => ({
refreshInterval: interval,
isRefreshEnabled: computeIsRefreshEnabled(state.selectedTime, interval),
}));
},
getMinMaxTime: (selectedTime?: GlobalTimeSelectedTime): ParsedTimeRange => {
const state = get();
const timeToUse = selectedTime ?? state.selectedTime;
// For custom time ranges, return exact values without rounding
if (isCustomTimeRange(timeToUse)) {
return parseSelectedTime(timeToUse);
}
if (selectedTime && selectedTime !== state.selectedTime) {
return computeRoundedMinMax(selectedTime);
}
// When auto-refresh is enabled, compute fresh values and update store
// This ensures time moves forward on each refetchInterval cycle
// Note: computeRoundedMinMax rounds to minute boundaries, so all queries
// calling getMinMaxTime within the same minute get consistent values
if (state.isRefreshEnabled) {
const freshMinMax = computeRoundedMinMax(state.selectedTime);
// Only update store if values changed (avoids unnecessary re-renders)
if (
freshMinMax.minTime !== state.lastComputedMinMax.minTime ||
freshMinMax.maxTime !== state.lastComputedMinMax.maxTime
) {
set({
lastComputedMinMax: freshMinMax,
lastRefreshTimestamp: Date.now(),
});
}
return freshMinMax;
}
// Return stored values if they exist (set by computeAndStoreMinMax)
// This ensures all callers get the same values within a refresh cycle
if (state.lastComputedMinMax.maxTime > 0) {
return state.lastComputedMinMax;
}
return computeRoundedMinMax(state.selectedTime);
},
computeAndStoreMinMax: (): ParsedTimeRange => {
const { selectedTime } = get();
// For custom time ranges, use exact values without rounding
const computedMinMax = isCustomTimeRange(selectedTime)
? parseSelectedTime(selectedTime)
: computeRoundedMinMax(selectedTime);
set({
lastComputedMinMax: computedMinMax,
lastRefreshTimestamp: Date.now(),
});
return computedMinMax;
},
updateRefreshTimestamp: (): void => {
set({ lastRefreshTimestamp: Date.now() });
},
}));
}
export const defaultGlobalTimeStore = createGlobalTimeStore();
export const useGlobalTimeStore = <T = GlobalTimeStore>(
selector?: (state: GlobalTimeStore) => T,
): T => {
return useStore(
defaultGlobalTimeStore,
selector ?? ((state) => state as unknown as T),
);
};

View File

@@ -0,0 +1,58 @@
// oxlint-disable-next-line no-restricted-imports
import { useContext } from 'react';
import { useStoreWithEqualityFn } from 'zustand/traditional';
import { GlobalTimeContext } from './GlobalTimeContext';
import { defaultGlobalTimeStore, GlobalTimeStoreApi } from './globalTimeStore';
import { GlobalTimeStore, ParsedTimeRange } from './types';
import { isCustomTimeRange } from './utils';
/**
* Access global time state with optional selector for performance.
*
* @example
* // Full state (re-renders on any change)
* const { selectedTime, setSelectedTime } = useGlobalTime();
*
* @example
* // With selector (re-renders only when selectedTime changes)
* const selectedTime = useGlobalTime(state => state.selectedTime);
*/
export function useGlobalTime<T = GlobalTimeStore>(
selector?: (state: GlobalTimeStore) => T,
equalityFn?: (a: T, b: T) => boolean,
): T {
const contextStore = useContext(GlobalTimeContext);
const store = contextStore ?? defaultGlobalTimeStore;
return useStoreWithEqualityFn(
store,
selector ?? ((state) => state as unknown as T),
equalityFn,
);
}
/**
* Check if currently using a custom time range.
*/
export function useIsCustomTimeRange(): boolean {
const selectedTime = useGlobalTime((state) => state.selectedTime);
return isCustomTimeRange(selectedTime);
}
/**
* Get the store API directly (for subscriptions or non-React contexts).
*/
export function useGlobalTimeStoreApi(): GlobalTimeStoreApi {
const contextStore = useContext(GlobalTimeContext);
return contextStore ?? defaultGlobalTimeStore;
}
/**
* Get the last computed min/max time values.
* Use this for display purposes to ensure consistency with query data.
*/
export function useLastComputedMinMax(): ParsedTimeRange {
return useGlobalTime((state) => state.lastComputedMinMax);
}

View File

@@ -1,9 +1,526 @@
export { useGlobalTimeStore } from './globalTimeStore';
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
/**
* # Global Time Store
*
* Centralized time management for the application with auto-refresh support.
*
* ## Quick Start
*
* ```tsx
* import {
* useGlobalTime,
* getAutoRefreshQueryKey,
* NANO_SECOND_MULTIPLIER,
* } from 'store/globalTime';
*
* function MyComponent() {
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* const { data } = useQuery({
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY', params),
* queryFn: () => {
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
* return fetchData({ start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* }
* ```
*
* ## Core Concepts
*
* ### Time Formats
*
* | Format | Example | Description |
* |--------|---------|-------------|
* | Relative | `'15m'`, `'1h'`, `'1d'` | Duration from now, supports auto-refresh |
* | Custom | `'1234567890||_||1234567899'` | Fixed range in nanoseconds, no auto-refresh |
*
* ### Time Units
*
* - Store values are in **nanoseconds**
* - Most APIs expect **seconds**
* - Convert to have seconds: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER / 1000)`
* - Convert to have ms: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER)`
*
* ## Integration Guide
*
* ### Step 1: Get Store State
*
* Use selectors for optimal re-render performance:
*
* ```tsx
* // Good - only re-renders when selectedTime changes
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
*
* // Avoid - re-renders on ANY store change
* const store = useGlobalTime();
* ```
*
* ### Step 2: Build Query Key
*
* Always use `getAutoRefreshQueryKey` to enable auto-refresh:
*
* ```tsx
* const queryKey = useMemo(
* () => getAutoRefreshQueryKey(
* selectedTime, // Required - triggers invalidation
* 'UNIQUE_KEY', // Your query identifier
* ...otherParams // Additional cache-busting params
* ),
* [selectedTime, ...deps]
* );
* ```
*
* ### Step 3: Fetch Data
*
* **IMPORTANT**: Call `getMinMaxTime()` INSIDE `queryFn`:
*
* ```tsx
* const { data } = useQuery({
* queryKey,
* queryFn: () => {
* // Fresh time values computed here during auto-refresh
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
* return api.fetch({ start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* ```
*
* ### Step 4: Add Refresh Button (Optional)
*
* ```tsx
* import {
* useGlobalTimeQueryInvalidate,
* useIsGlobalTimeQueryRefreshing,
* } from 'store/globalTime';
*
* function RefreshButton() {
* const invalidate = useGlobalTimeQueryInvalidate();
* const isRefreshing = useIsGlobalTimeQueryRefreshing();
*
* return (
* <button onClick={invalidate} disabled={isRefreshing}>
* {isRefreshing ? 'Refreshing...' : 'Refresh'}
* </button>
* );
* }
* ```
*
* ## Avoiding Stale Data
*
* ### Problem: Time Drift During Refresh
*
* If multiple queries compute time independently, they may use different values:
*
* ```tsx
* // BAD - each query gets different time
* queryFn: () => {
* const now = Date.now();
* return fetchData({ end: now, start: now - duration });
* }
* ```
*
* ### Solution: Use getMinMaxTime()
*
* `getMinMaxTime()` ensures all queries use consistent timestamps:
* - When auto-refresh is **disabled**: returns cached values from `computeAndStoreMinMax()`
* - When auto-refresh is **enabled**: computes fresh values (rounded to minute boundaries)
*
* Since values are rounded to minute boundaries, all queries calling `getMinMaxTime()`
* within the same minute get identical timestamps.
*
* ```tsx
* // GOOD - all queries get same time
* queryFn: () => {
* const { minTime, maxTime } = getMinMaxTime();
* return fetchData({ start: minTime, end: maxTime });
* }
* ```
*
* ### How It Works
*
* **Manual refresh:**
* 1. User clicks refresh
* 2. `useGlobalTimeQueryInvalidate` calls `computeAndStoreMinMax()`
* 3. Fresh min/max stored in `lastComputedMinMax`
* 4. All queries re-run and call `getMinMaxTime()`
* 5. All get the SAME cached values
*
* **Auto-refresh (when `isRefreshEnabled = true`):**
* 1. React-query's `refetchInterval` triggers query re-execution
* 2. `getMinMaxTime()` computes fresh values (rounded to minute)
* 3. If values changed, updates `lastComputedMinMax` cache
* 4. All queries within same minute get consistent values
*
* ## Auto-Refresh Setup
*
* Auto-refresh is enabled when:
* - `selectedTime` is a relative duration (e.g., `'15m'`)
* - `refreshInterval > 0`
*
* ```tsx
* // Auto-refresh configuration
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* useQuery({
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY'),
* queryFn: () => { ... },
* // Enable periodic refetch
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* ```
*
* ## API Reference
*
* ### Hooks
*
* | Hook | Returns | Description |
* |------|---------|-------------|
* | `useGlobalTime(selector?)` | `T` | Access store state with optional selector |
* | `useGlobalTimeQueryInvalidate()` | `() => Promise<void>` | Invalidate all auto-refresh queries |
* | `useIsGlobalTimeQueryRefreshing()` | `boolean` | Check if any query is refreshing |
* | `useIsCustomTimeRange()` | `boolean` | Check if using fixed time range |
* | `useLastComputedMinMax()` | `ParsedTimeRange` | Get cached min/max values |
* | `useGlobalTimeStoreApi()` | `GlobalTimeStoreApi` | Get raw store API |
*
* ### Store Actions
*
* | Action | Description |
* |--------|-------------|
* | `setSelectedTime(time, interval?)` | Set time range and optional refresh interval (resets cache) |
* | `setRefreshInterval(ms)` | Set auto-refresh interval |
* | `getMinMaxTime(time?)` | Get min/max (fresh if auto-refresh enabled, cached otherwise) |
* | `computeAndStoreMinMax()` | Compute fresh values and cache them |
*
* ### Utilities
*
* | Function | Description |
* |----------|-------------|
* | `getAutoRefreshQueryKey(time, ...parts)` | Build query key with auto-refresh support |
* | `parseSelectedTime(time)` | Parse time string to min/max (fresh computation) |
* | `isCustomTimeRange(time)` | Check if time is custom range format |
* | `createCustomTimeRange(min, max)` | Create custom range string |
*
* ### Constants
*
* | Constant | Value | Description |
* |----------|-------|-------------|
* | `NANO_SECOND_MULTIPLIER` | `1000000` | Convert ms to ns |
* | `CUSTOM_TIME_SEPARATOR` | `'||_||'` | Separator in custom range strings |
*
* ## Context & Composition
*
* ### Why Use Context?
*
* By default, `useGlobalTime()` uses a shared global store. Use `GlobalTimeProvider`
* to create isolated time state for specific UI sections (modals, drawers, etc.).
*
* ### Provider Options
*
* | Option | Type | Description |
* |--------|------|-------------|
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value |
* | `initialTime` | `string` | Initial time if not inheriting |
* | `enableUrlParams` | `boolean \| object` | Sync time to URL query params |
* | `removeQueryParamsOnUnmount` | `boolean` | Clean URL params on unmount |
* | `localStoragePersistKey` | `string` | Persist time to localStorage |
* | `refreshInterval` | `number` | Initial auto-refresh interval (ms) |
*
* ### Example 1: Isolated Time in Modal
*
* A modal with its own time picker that doesn't affect the main page:
*
* ```tsx
* import { GlobalTimeProvider, useGlobalTime } from 'store/globalTime';
*
* function EntityDetailsModal({ entity, onClose }) {
* return (
* <Modal open onClose={onClose}>
* // Isolated time context - changes here don't affect parent
* <GlobalTimeProvider
* inheritGlobalTime // Start with parent's current time
* refreshInterval={0} // No auto-refresh in modal
* >
* <ModalContent entity={entity} />
* </GlobalTimeProvider>
* </Modal>
* );
* }
*
* function ModalContent({ entity }) {
* // This useGlobalTime reads from the modal's isolated store
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
*
* return (
* <>
* <DateTimePicker
* value={selectedTime}
* onChange={(time) => setSelectedTime(time)}
* />
* <EntityMetrics entity={entity} />
* <EntityLogs entity={entity} />
* </>
* );
* }
* ```
*
* ### Example 2: List Page with Detail Drawer
*
* Main list uses global time, drawer has independent time:
*
* ```tsx
* // Main list page - uses global time (no provider needed)
* function K8sPodsList() {
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const [selectedPod, setSelectedPod] = useState(null);
*
* return (
* <>
* <PageHeader>
* <DateTimeSelectionV3 /> // Controls global time
* </PageHeader>
*
* <PodsTable
* timeRange={selectedTime}
* onRowClick={setSelectedPod}
* />
*
* {selectedPod && (
* <PodDetailsDrawer
* pod={selectedPod}
* onClose={() => setSelectedPod(null)}
* />
* )}
* </>
* );
* }
*
* // Drawer with its own time context
* function PodDetailsDrawer({ pod, onClose }) {
* return (
* <Drawer open onClose={onClose}>
* <GlobalTimeProvider
* inheritGlobalTime // Start with list's time
* removeQueryParamsOnUnmount // Clean up URL when drawer closes
* enableUrlParams={{
* relativeTimeKey: 'drawerTime',
* startTimeKey: 'drawerStart',
* endTimeKey: 'drawerEnd',
* }}
* >
* <DrawerHeader>
* <DateTimeSelectionV3 /> // Controls drawer's time only
* </DrawerHeader>
*
* <Tabs>
* <Tab label="Metrics"><PodMetrics pod={pod} /></Tab>
* <Tab label="Logs"><PodLogs pod={pod} /></Tab>
* <Tab label="Events"><PodEvents pod={pod} /></Tab>
* </Tabs>
* </GlobalTimeProvider>
* </Drawer>
* );
* }
* ```
*
* ### Example 3: Nested Contexts
*
* Contexts can be nested - each level creates isolation:
*
* ```tsx
* // App level - global time
* function App() {
* return (
* <QueryClientProvider>
* // No provider here = uses defaultGlobalTimeStore
* <Dashboard />
* </QueryClientProvider>
* );
* }
*
* // Dashboard with comparison panel
* function Dashboard() {
* return (
* <div className="dashboard">
* // Main dashboard uses global time
* <MainCharts />
*
* // Comparison panel has its own time
* <GlobalTimeProvider initialTime="1h">
* <ComparisonPanel />
* </GlobalTimeProvider>
* </div>
* );
* }
*
* function ComparisonPanel() {
* // This reads from ComparisonPanel's isolated store (1h)
* // Not affected by global time changes
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* return <ComparisonCharts timeRange={selectedTime} />;
* }
* ```
*
* ### Example 4: URL Sync for Shareable Links
*
* Persist time selection to URL for shareable links:
*
* ```tsx
* function TracesExplorer() {
* return (
* <GlobalTimeProvider
* enableUrlParams={{
* relativeTimeKey: 'time', // ?time=15m
* startTimeKey: 'startTime', // ?startTime=1234567890
* endTimeKey: 'endTime', // ?endTime=1234567899
* }}
* initialTime="15m" // Fallback if URL has no time params
* >
* <TracesContent />
* </GlobalTimeProvider>
* );
* }
* ```
*
* ### Example 5: localStorage Persistence
*
* Remember user's last selected time across sessions:
*
* ```tsx
* function MetricsExplorer() {
* return (
* <GlobalTimeProvider
* localStoragePersistKey="metrics-explorer-time"
* initialTime="1h" // Fallback for first visit
* >
* <MetricsContent />
* </GlobalTimeProvider>
* );
* }
* ```
*
* ### Context Resolution Order
*
* When `useGlobalTime()` is called, it resolves the store in this order:
*
* 1. Nearest `GlobalTimeProvider` ancestor (if any)
* 2. `defaultGlobalTimeStore` (global singleton)
*
* ```
* App (no provider -> uses defaultGlobalTimeStore)
* |-- Dashboard
* |-- MainCharts (uses defaultGlobalTimeStore)
* |-- GlobalTimeProvider (isolated store A)
* |-- ComparisonPanel (uses store A)
* |-- GlobalTimeProvider (isolated store B)
* |-- NestedChart (uses store B)
* ```
*
* ## Complete Example
*
* ```tsx
* import { useMemo } from 'react';
* import { useQuery } from 'react-query';
* import {
* useGlobalTime,
* getAutoRefreshQueryKey,
* NANO_SECOND_MULTIPLIER,
* } from 'store/globalTime';
*
* function MetricsPanel({ entityId }: { entityId: string }) {
* // 1. Get store state with selectors
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* // 2. Build query key (memoized)
* const queryKey = useMemo(
* () => getAutoRefreshQueryKey(selectedTime, 'METRICS', entityId),
* [selectedTime, entityId]
* );
*
* // 3. Query with auto-refresh
* const { data, isLoading } = useQuery({
* queryKey,
* queryFn: () => {
* // Get fresh time inside queryFn
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
*
* return fetchMetrics({ entityId, start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
*
* return <Chart data={data} loading={isLoading} />;
* }
* ```
*
* @module store/globalTime
*/
// Store
export {
createGlobalTimeStore,
defaultGlobalTimeStore,
useGlobalTimeStore,
} from './globalTimeStore';
export type { GlobalTimeStoreApi } from './globalTimeStore';
// Context & Provider
export { GlobalTimeContext, GlobalTimeProvider } from './GlobalTimeContext';
// Hooks
export {
useGlobalTime,
useGlobalTimeStoreApi,
useIsCustomTimeRange,
useLastComputedMinMax,
} from './hooks';
// Query hooks for auto-refresh
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
// Types
export type {
CustomTimeRange,
CustomTimeRangeSeparator,
GlobalTimeActions,
GlobalTimeProviderOptions,
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStore,
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
ParsedTimeRange,
} from './types';
// Utilities
export {
computeRoundedMinMax,
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
getAutoRefreshQueryKey,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
roundDownToMinute,
} from './utils';
// Internal hooks (for advanced use cases)
export { useQueryCacheSync } from './useQueryCacheSync';

View File

@@ -50,3 +50,52 @@ export interface IGlobalTimeStoreActions {
*/
getMinMaxTime: (selectedItem?: GlobalTimeSelectedTime) => ParsedTimeRange;
}
export interface GlobalTimeProviderOptions {
/** Initialize from parent/global time */
inheritGlobalTime?: boolean;
/** Initial time if not inheriting */
initialTime?: GlobalTimeSelectedTime;
/** URL sync configuration. When false/omitted, no URL sync. */
enableUrlParams?:
| boolean
| {
relativeTimeKey?: string;
startTimeKey?: string;
endTimeKey?: string;
};
removeQueryParamsOnUnmount?: boolean;
localStoragePersistKey?: string;
refreshInterval?: number;
}
export interface GlobalTimeState {
selectedTime: GlobalTimeSelectedTime;
refreshInterval: number;
isRefreshEnabled: boolean;
lastRefreshTimestamp: number;
lastComputedMinMax: ParsedTimeRange;
}
export interface GlobalTimeActions {
setSelectedTime: (
time: GlobalTimeSelectedTime,
refreshInterval?: number,
) => void;
setRefreshInterval: (interval: number) => void;
getMinMaxTime: (selectedTime?: GlobalTimeSelectedTime) => ParsedTimeRange;
/**
* Compute fresh rounded min/max values, store them, and update refresh timestamp.
* Call this before invalidating queries to ensure all queries use the same time values.
*
* @returns The newly computed ParsedTimeRange
*/
computeAndStoreMinMax: () => ParsedTimeRange;
/**
* Update the refresh timestamp to current time.
* Called by QueryCache listener when auto-refresh queries complete.
*/
updateRefreshTimestamp: () => void;
}
export type GlobalTimeStore = GlobalTimeState & GlobalTimeActions;

View File

@@ -0,0 +1,20 @@
import { useEffect } from 'react';
import { GlobalTimeStoreApi } from './globalTimeStore';
export function useComputedMinMaxSync(store: GlobalTimeStoreApi): void {
useEffect(() => {
store.getState().computeAndStoreMinMax();
}, [store]);
useEffect(() => {
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
if (state.selectedTime !== previousSelectedTime) {
previousSelectedTime = state.selectedTime;
store.getState().computeAndStoreMinMax();
}
});
}, [store]);
}

View File

@@ -0,0 +1,26 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGlobalTime } from './hooks';
/**
* Use when you want to invalidate any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*
* This hook computes fresh time values before invalidating queries,
* ensuring all queries use the same min/max time during a refresh cycle.
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
const computeAndStoreMinMax = useGlobalTime((s) => s.computeAndStoreMinMax);
return useCallback(async () => {
// Compute fresh time values BEFORE invalidating
// This ensures all queries that re-run will use the same time values
computeAndStoreMinMax();
return await queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
});
}, [queryClient, computeAndStoreMinMax]);
}

View File

@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import set from 'api/browser/localstorage/set';
import { GlobalTimeStoreApi } from './globalTimeStore';
export function usePersistence(
store: GlobalTimeStoreApi,
persistKey: string | undefined,
): void {
useEffect(() => {
if (!persistKey) {
return;
}
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
if (state.selectedTime === previousSelectedTime) {
return;
}
previousSelectedTime = state.selectedTime;
set(persistKey, state.selectedTime);
});
}, [store, persistKey]);
}

View File

@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GlobalTimeStoreApi } from './globalTimeStore';
/**
* Subscribes to QueryCache events and updates the store's lastRefreshTimestamp
* when auto-refresh queries complete successfully.
*/
export function useQueryCacheSync(store: GlobalTimeStoreApi): void {
const queryClient = useQueryClient();
useEffect(() => {
const queryCache = queryClient.getQueryCache();
return queryCache.subscribe((event) => {
// Only react to successful query updates
if (event?.type !== 'queryUpdated') {
return;
}
const action = event.action as { type?: string };
if (action?.type !== 'success') {
return;
}
// Check if it's an auto-refresh query by key prefix
const queryKey = event.query.queryKey;
if (
!Array.isArray(queryKey) ||
queryKey[0] !== REACT_QUERY_KEY.AUTO_REFRESH_QUERY
) {
return;
}
// Update the refresh timestamp in store
store.getState().updateRefreshTimestamp();
});
}, [queryClient, store]);
}

View File

@@ -0,0 +1,137 @@
import { useEffect, useRef } from 'react';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { GlobalTimeStoreApi } from './globalTimeStore';
import { GlobalTimeProviderOptions } from './types';
import {
createCustomTimeRange,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
} from './utils';
interface UrlSyncConfig {
relativeTimeKey: string;
startTimeKey: string;
endTimeKey: string;
}
export function useUrlSync(
store: GlobalTimeStoreApi,
enableUrlParams: GlobalTimeProviderOptions['enableUrlParams'],
removeOnUnmount: boolean,
): void {
const isInitialMount = useRef(true);
const keys: UrlSyncConfig =
enableUrlParams && typeof enableUrlParams === 'object'
? {
relativeTimeKey: enableUrlParams.relativeTimeKey ?? 'relativeTime',
startTimeKey: enableUrlParams.startTimeKey ?? 'startTime',
endTimeKey: enableUrlParams.endTimeKey ?? 'endTime',
}
: {
relativeTimeKey: 'relativeTime',
startTimeKey: 'startTime',
endTimeKey: 'endTime',
};
const [urlState, setUrlState] = useQueryStates(
{
[keys.relativeTimeKey]: parseAsString,
[keys.startTimeKey]: parseAsInteger,
[keys.endTimeKey]: parseAsInteger,
},
{ history: 'replace' },
);
useEffect(() => {
if (!enableUrlParams || !isInitialMount.current) {
return;
}
isInitialMount.current = false;
const relativeTime = urlState[keys.relativeTimeKey];
const startTime = urlState[keys.startTimeKey];
const endTime = urlState[keys.endTimeKey];
if (typeof startTime === 'number' && typeof endTime === 'number') {
const customTime = createCustomTimeRange(
startTime * NANO_SECOND_MULTIPLIER,
endTime * NANO_SECOND_MULTIPLIER,
);
store.getState().setSelectedTime(customTime);
} else if (relativeTime) {
store.getState().setSelectedTime(relativeTime as Time);
}
}, [
urlState,
keys?.startTimeKey,
keys?.endTimeKey,
keys?.relativeTimeKey,
store,
enableUrlParams,
]);
useEffect(() => {
if (!enableUrlParams) {
return;
}
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
// Only update URL when selectedTime actually changes
if (state.selectedTime === previousSelectedTime) {
return;
}
previousSelectedTime = state.selectedTime;
if (isCustomTimeRange(state.selectedTime)) {
const parsed = parseCustomTimeRange(state.selectedTime);
if (parsed) {
void setUrlState({
[keys.relativeTimeKey]: null,
[keys.startTimeKey]: Math.floor(parsed.minTime / NANO_SECOND_MULTIPLIER),
[keys.endTimeKey]: Math.floor(parsed.maxTime / NANO_SECOND_MULTIPLIER),
});
}
} else {
void setUrlState({
[keys.relativeTimeKey]: state.selectedTime,
[keys.startTimeKey]: null,
[keys.endTimeKey]: null,
});
}
});
}, [
store,
keys?.startTimeKey,
keys?.endTimeKey,
keys?.relativeTimeKey,
setUrlState,
enableUrlParams,
]);
useEffect(() => {
if (!enableUrlParams || !removeOnUnmount) {
return;
}
return (): void => {
void setUrlState({
[keys.relativeTimeKey]: null,
[keys.startTimeKey]: null,
[keys.endTimeKey]: null,
});
};
}, [
removeOnUnmount,
keys?.relativeTimeKey,
keys?.startTimeKey,
keys?.endTimeKey,
setUrlState,
enableUrlParams,
]);
}

View File

@@ -44,8 +44,8 @@ export function parseCustomTimeRange(
}
const [minStr, maxStr] = selectedTime.split(CUSTOM_TIME_SEPARATOR);
const minTime = parseInt(minStr, 10);
const maxTime = parseInt(maxStr, 10);
const minTime = Number.parseInt(minStr, 10);
const maxTime = Number.parseInt(maxStr, 10);
if (Number.isNaN(minTime) || Number.isNaN(maxTime)) {
return null;
@@ -87,3 +87,54 @@ export function getAutoRefreshQueryKey(
): unknown[] {
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
}
/**
* Round timestamp down to the nearest minute boundary.
* Used to ensure consistent time values across multiple consumers within the same minute.
*
* @param timestampNano - Timestamp in nanoseconds
* @returns Timestamp rounded down to minute boundary in nanoseconds
*/
export function roundDownToMinute(timestampNano: number): number {
const msPerMinute = 60 * 1000;
const timestampMs = Math.floor(timestampNano / NANO_SECOND_MULTIPLIER);
const roundedMs = Math.floor(timestampMs / msPerMinute) * msPerMinute;
return roundedMs * NANO_SECOND_MULTIPLIER;
}
/**
* Compute min/max time with maxTime rounded down to minute boundary.
* For relative times, this ensures all calls within the same minute return identical values.
* For custom time ranges, returns the stored values unchanged.
*
* @param selectedTime - The selected time (relative like '15m' or custom range)
* @returns ParsedTimeRange with rounded maxTime for relative times
*/
export function computeRoundedMinMax(selectedTime: string): ParsedTimeRange {
if (isCustomTimeRange(selectedTime)) {
const parsed = parseCustomTimeRange(selectedTime);
if (parsed) {
return parsed;
}
// Fallback if parsing fails
const now = Date.now() * NANO_SECOND_MULTIPLIER;
return { minTime: now - fallbackDurationInNanoSeconds, maxTime: now };
}
// For relative time, compute with rounded maxTime
const nowNano = Date.now() * NANO_SECOND_MULTIPLIER;
const roundedMaxTime = roundDownToMinute(nowNano);
// Get the duration from the relative time
const { minTime: originalMin, maxTime: originalMax } = getMinMaxForSelectedTime(
selectedTime as Time,
0,
0,
);
const durationNano = originalMax - originalMin;
return {
minTime: roundedMaxTime - durationNano,
maxTime: roundedMaxTime,
};
}