diff --git a/libs/react/ui/src/components/dashboard/context/dashboard-context.tsx b/libs/react/ui/src/components/dashboard/context/dashboard-context.tsx index 48ed9355..efd13347 100644 --- a/libs/react/ui/src/components/dashboard/context/dashboard-context.tsx +++ b/libs/react/ui/src/components/dashboard/context/dashboard-context.tsx @@ -6,9 +6,8 @@ */ import type {VisibilityState} from '@tanstack/react-table'; -import {findOptionValueForInterval, type IntervalSelection} from 'components/interval-selector'; +import type {IntervalSelection} from 'components/interval-selector'; import {createContext, type ReactNode, useCallback, useContext, useMemo, useState} from 'react'; -import {intervalToNowFromDuration} from 'utils/date'; import type { DashboardState, FilterOption, @@ -138,13 +137,6 @@ export function DashboardProvider({ // State management const [searchQuery, setSearchQuery] = useState(''); const [selection, setSelection] = useState(initialSelection); - const [intervalValue, setIntervalValue] = useState(() => { - const interval = - initialSelection.type === 'interval' - ? initialSelection.interval - : intervalToNowFromDuration(initialSelection.duration); - return findOptionValueForInterval(interval); - }); const [lastUpdated, setLastUpdated] = useState('13s ago'); const [columns, setColumns] = useState(initialColumns); const [filters, setFilters] = useState(initialFilters); @@ -153,12 +145,6 @@ export function DashboardProvider({ const handleSelectionChange = useCallback((newSelection: IntervalSelection) => { setSelection(newSelection); - const interval = - newSelection.type === 'interval' - ? newSelection.interval - : intervalToNowFromDuration(newSelection.duration); - const value = findOptionValueForInterval(interval); - setIntervalValue(value); }, []); // Compute column visibility state @@ -182,8 +168,6 @@ export function DashboardProvider({ setSearchQuery, selection, setSelection: handleSelectionChange, - intervalValue, - setIntervalValue, lastUpdated, setLastUpdated, columns, @@ -201,7 +185,6 @@ export function DashboardProvider({ searchQuery, selection, handleSelectionChange, - intervalValue, lastUpdated, columns, columnVisibility, diff --git a/libs/react/ui/src/components/dashboard/context/types.ts b/libs/react/ui/src/components/dashboard/context/types.ts index 7063d690..3c3028ec 100644 --- a/libs/react/ui/src/components/dashboard/context/types.ts +++ b/libs/react/ui/src/components/dashboard/context/types.ts @@ -51,8 +51,6 @@ export interface DashboardState { // Time interval selection selection: IntervalSelection; setSelection: (selection: IntervalSelection) => void; - intervalValue: string | undefined; - setIntervalValue: (value: string | undefined) => void; // Last updated timestamp lastUpdated: string; diff --git a/libs/react/ui/src/components/dashboard/toolbar/page-toolbar.tsx b/libs/react/ui/src/components/dashboard/toolbar/page-toolbar.tsx index d649cf8a..62d1579c 100644 --- a/libs/react/ui/src/components/dashboard/toolbar/page-toolbar.tsx +++ b/libs/react/ui/src/components/dashboard/toolbar/page-toolbar.tsx @@ -82,8 +82,7 @@ export function PageToolbar({ className, ...props }: PageToolbarProps) { - const {selection, setSelection, intervalValue, setIntervalValue, lastUpdated} = - useDashboardContext(); + const {selection, setSelection, lastUpdated} = useDashboardContext(); return (
diff --git a/libs/react/ui/src/components/interval-selector/hooks/index.ts b/libs/react/ui/src/components/interval-selector/hooks/index.ts new file mode 100644 index 00000000..16f31e21 --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './use-interval-selector'; +export * from './use-interval-selector-input'; +export * from './use-interval-selector-navigation'; +export * from './use-interval-selector-state'; diff --git a/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-input.test.ts b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-input.test.ts new file mode 100644 index 00000000..2de6c230 --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-input.test.ts @@ -0,0 +1,138 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from '@shipfox/vitest/vi'; +import {act, renderHook} from '@testing-library/react'; +import type {RelativeSuggestion} from '../types'; +import {useIntervalSelectorInput} from './use-interval-selector-input'; + +describe('useIntervalSelectorInput', () => { + const mockPastIntervals: RelativeSuggestion[] = [{type: 'relative', duration: {minutes: 5}}]; + + const mockProps = { + relativeSuggestions: mockPastIntervals, + inputValue: '', + setInputValue: vi.fn(), + setDetectedShortcut: vi.fn(), + setConfirmedShortcut: vi.fn(), + setIsInvalid: vi.fn(), + setSelectedLabel: vi.fn(), + setHighlightedIndex: vi.fn(), + selectedValueRef: {current: undefined as string | undefined}, + triggerShakeAnimation: vi.fn(), + onSelect: vi.fn(), + }; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-19T17:45:00Z')); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('handleInputChange', () => { + it('should update input value and detect shortcut', () => { + const {result} = renderHook(() => useIntervalSelectorInput(mockProps)); + + act(() => { + result.current.handleInputChange({ + target: {value: '5m'}, + } as React.ChangeEvent); + }); + + expect(mockProps.setInputValue).toHaveBeenCalledWith('5m'); + expect(mockProps.setIsInvalid).toHaveBeenCalledWith(false); + }); + + it('should set invalid state for non-empty input without shortcut', () => { + const {result} = renderHook(() => useIntervalSelectorInput(mockProps)); + + act(() => { + result.current.handleInputChange({ + target: {value: 'invalid'}, + } as React.ChangeEvent); + }); + + expect(mockProps.setIsInvalid).toHaveBeenCalledWith(true); + }); + + it('should clear selection when input changes', () => { + mockProps.selectedValueRef.current = '5m'; + const {result} = renderHook(() => useIntervalSelectorInput(mockProps)); + + act(() => { + result.current.handleInputChange({ + target: {value: 'new'}, + } as React.ChangeEvent); + }); + + expect(mockProps.setSelectedLabel).toHaveBeenCalledWith(undefined); + expect(mockProps.selectedValueRef.current).toBeUndefined(); + expect(mockProps.setHighlightedIndex).toHaveBeenCalledWith(-1); + }); + + it('should clear selectedLabel even when selectedValueRef.current is undefined', () => { + mockProps.selectedValueRef.current = undefined; + const {result} = renderHook(() => useIntervalSelectorInput(mockProps)); + + act(() => { + result.current.handleInputChange({ + target: {value: 'new'}, + } as React.ChangeEvent); + }); + + expect(mockProps.setSelectedLabel).toHaveBeenCalledWith(undefined); + expect(mockProps.setHighlightedIndex).toHaveBeenCalledWith(-1); + }); + }); + + describe('handleConfirmInput', () => { + it('should handle relative time shortcut input', () => { + const {result} = renderHook(() => + useIntervalSelectorInput({ + ...mockProps, + inputValue: '5m', + }), + ); + + act(() => { + result.current.handleConfirmInput(); + }); + + expect(mockProps.onSelect).toHaveBeenCalledWith({ + type: 'relative', + duration: {minutes: 5}, + }); + }); + + it('should handle parsed interval input', () => { + const {result} = renderHook(() => + useIntervalSelectorInput({ + ...mockProps, + inputValue: 'Jan 1 2026 - Jan 15 2026', + }), + ); + + act(() => { + result.current.handleConfirmInput(); + }); + + expect(mockProps.onSelect).toHaveBeenCalled(); + }); + + it('should trigger shake animation for invalid input', () => { + const {result} = renderHook(() => + useIntervalSelectorInput({ + ...mockProps, + inputValue: 'invalid input', + }), + ); + + act(() => { + result.current.handleConfirmInput(); + }); + + expect(mockProps.triggerShakeAnimation).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-input.ts b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-input.ts new file mode 100644 index 00000000..8276d4ea --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-input.ts @@ -0,0 +1,56 @@ +import {useCallback} from 'react'; +import {parseTextDurationShortcut, parseTextInterval} from 'utils/date'; +import type {IntervalSelection} from '../types'; + +interface UseIntervalSelectorInputProps { + inputValue: string; + setInputValue: (value: string) => void; + setIsInvalid: (invalid: boolean) => void; + setSelectedLabel: (label: string | undefined) => void; + setHighlightedIndex: (index: number) => void; + selectedValueRef: React.RefObject; + triggerShakeAnimation: () => void; + onSelect: (selection: IntervalSelection) => void; +} + +export function useIntervalSelectorInput({ + inputValue, + setInputValue, + setIsInvalid, + setSelectedLabel, + setHighlightedIndex, + selectedValueRef, + triggerShakeAnimation, + onSelect, +}: UseIntervalSelectorInputProps) { + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + + const shortcut = parseTextDurationShortcut(newValue); + const interval = parseTextInterval(newValue); + const isValid = shortcut || interval || newValue.trim() === ''; + setIsInvalid(!isValid); + + if (selectedValueRef.current) selectedValueRef.current = undefined; + + setSelectedLabel(undefined); + setHighlightedIndex(-1); + }, + [setInputValue, setIsInvalid, setSelectedLabel, setHighlightedIndex, selectedValueRef], + ); + + const handleConfirmInput = useCallback(() => { + const shortcut = parseTextDurationShortcut(inputValue); + if (shortcut) return onSelect({type: 'relative', duration: shortcut}); + const interval = parseTextInterval(inputValue); + if (interval) return onSelect({type: 'interval', interval}); + triggerShakeAnimation(); + }, [inputValue, onSelect, triggerShakeAnimation]); + + return { + handleInputChange, + handleConfirmInput, + }; +} diff --git a/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-navigation.test.ts b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-navigation.test.ts new file mode 100644 index 00000000..ce3993a9 --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-navigation.test.ts @@ -0,0 +1,247 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from '@shipfox/vitest/vi'; +import {act, renderHook} from '@testing-library/react'; +import {endOfDay, startOfDay} from 'date-fns'; +import type {IntervalSuggestion, RelativeSuggestion} from '../types'; +import {useIntervalSelectorNavigation} from './use-interval-selector-navigation'; + +describe('useIntervalSelectorNavigation', () => { + const mockPastIntervals: RelativeSuggestion[] = [ + {type: 'relative', duration: {minutes: 5}}, + {type: 'relative', duration: {hours: 1}}, + ]; + const mockCalendarIntervals: IntervalSuggestion[] = [ + { + type: 'interval', + label: 'Today', + interval: {start: startOfDay(new Date()), end: endOfDay(new Date())}, + }, + ]; + + const mockProps = { + relativeSuggestions: mockPastIntervals, + intervalSuggestions: mockCalendarIntervals, + highlightedIndex: -1, + setHighlightedIndex: vi.fn(), + popoverOpen: true, + calendarOpen: false, + handleOpenCalendar: vi.fn(), + onSelect: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getAllNavigableItems', () => { + it('should return all navigable items including calendar option', () => { + const {result} = renderHook(() => useIntervalSelectorNavigation(mockProps)); + + const items = result.current.allNavigableItems; + + expect(items).toHaveLength(4); + expect(items[0]).toEqual(mockPastIntervals[0]); + expect(items[1]).toEqual(mockPastIntervals[1]); + expect(items[2]).toEqual(mockCalendarIntervals[0]); + expect(items[3]).toEqual({ + type: 'calendar', + label: 'Select from calendar', + }); + }); + }); + + describe('handleKeyDown', () => { + it('should navigate down with ArrowDown', () => { + const {result} = renderHook(() => useIntervalSelectorNavigation(mockProps)); + + const handleConfirmInput = vi.fn(); + const closeAll = vi.fn(); + + act(() => { + result.current.handleKeyDown( + { + key: 'ArrowDown', + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent, + handleConfirmInput, + closeAll, + ); + }); + + expect(mockProps.setHighlightedIndex).toHaveBeenCalledWith(0); + }); + + it('should navigate up with ArrowUp', () => { + const {result} = renderHook(() => + useIntervalSelectorNavigation({ + ...mockProps, + highlightedIndex: 1, + }), + ); + + const handleConfirmInput = vi.fn(); + const closeAll = vi.fn(); + + act(() => { + result.current.handleKeyDown( + { + key: 'ArrowUp', + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent, + handleConfirmInput, + closeAll, + ); + }); + + expect(mockProps.setHighlightedIndex).toHaveBeenCalledWith(0); + }); + + it('should wrap around when navigating down at end', () => { + const {result} = renderHook(() => + useIntervalSelectorNavigation({ + ...mockProps, + highlightedIndex: 3, + }), + ); + + const handleConfirmInput = vi.fn(); + const closeAll = vi.fn(); + + act(() => { + result.current.handleKeyDown( + { + key: 'ArrowDown', + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent, + handleConfirmInput, + closeAll, + ); + }); + + expect(mockProps.setHighlightedIndex).toHaveBeenCalledWith(0); + }); + + it('should wrap around when navigating up at start', () => { + const {result} = renderHook(() => + useIntervalSelectorNavigation({ + ...mockProps, + highlightedIndex: 0, + }), + ); + + const handleConfirmInput = vi.fn(); + const closeAll = vi.fn(); + + act(() => { + result.current.handleKeyDown( + { + key: 'ArrowUp', + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent, + handleConfirmInput, + closeAll, + ); + }); + + expect(mockProps.setHighlightedIndex).toHaveBeenCalledWith(3); + }); + + it('should select highlighted item on Enter', () => { + const {result} = renderHook(() => + useIntervalSelectorNavigation({ + ...mockProps, + highlightedIndex: 0, + }), + ); + + const handleConfirmInput = vi.fn(); + const closeAll = vi.fn(); + + act(() => { + result.current.handleKeyDown( + { + key: 'Enter', + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent, + handleConfirmInput, + closeAll, + ); + }); + + expect(mockProps.onSelect).toHaveBeenCalledWith({type: 'relative', duration: {minutes: 5}}); + }); + + it('should open calendar when calendar option is selected', () => { + const {result} = renderHook(() => + useIntervalSelectorNavigation({ + ...mockProps, + highlightedIndex: 3, + }), + ); + + const handleConfirmInput = vi.fn(); + const closeAll = vi.fn(); + + act(() => { + result.current.handleKeyDown( + { + key: 'Enter', + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent, + handleConfirmInput, + closeAll, + ); + }); + + expect(mockProps.handleOpenCalendar).toHaveBeenCalled(); + }); + + it('should call handleConfirmInput on Enter when popover closed', () => { + const {result} = renderHook(() => + useIntervalSelectorNavigation({ + ...mockProps, + popoverOpen: false, + }), + ); + + const handleConfirmInput = vi.fn(); + const closeAll = vi.fn(); + + act(() => { + result.current.handleKeyDown( + { + key: 'Enter', + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent, + handleConfirmInput, + closeAll, + ); + }); + + expect(handleConfirmInput).toHaveBeenCalled(); + }); + + it('should close all on Escape', () => { + const {result} = renderHook(() => useIntervalSelectorNavigation(mockProps)); + + const handleConfirmInput = vi.fn(); + const closeAll = vi.fn(); + + act(() => { + result.current.handleKeyDown( + { + key: 'Escape', + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent, + handleConfirmInput, + closeAll, + ); + }); + + expect(closeAll).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-navigation.ts b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-navigation.ts new file mode 100644 index 00000000..1901943b --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-navigation.ts @@ -0,0 +1,102 @@ +import {useCallback, useEffect, useMemo, useRef} from 'react'; +import type {IntervalSelection, IntervalSuggestion, RelativeSuggestion} from '../types'; + +interface UseIntervalSelectorNavigationProps { + relativeSuggestions: RelativeSuggestion[]; + intervalSuggestions: IntervalSuggestion[]; + highlightedIndex: number; + setHighlightedIndex: (index: number) => void; + popoverOpen: boolean; + calendarOpen: boolean; + handleOpenCalendar: () => void; + onSelect: (selection: IntervalSelection) => void; +} + +export function useIntervalSelectorNavigation({ + relativeSuggestions, + intervalSuggestions, + highlightedIndex, + setHighlightedIndex, + popoverOpen, + calendarOpen, + handleOpenCalendar, + onSelect, +}: UseIntervalSelectorNavigationProps) { + const highlightedIndexRef = useRef(highlightedIndex); + + useEffect(() => { + highlightedIndexRef.current = highlightedIndex; + }, [highlightedIndex]); + + const allNavigableItems = useMemo( + () => [ + ...relativeSuggestions, + ...intervalSuggestions, + {type: 'calendar' as const, label: 'Select from calendar'}, + ], + [relativeSuggestions, intervalSuggestions], + ); + + const handleKeyDown = useCallback( + ( + e: React.KeyboardEvent, + handleConfirmInput: () => void, + closeAll: () => void, + ) => { + if (popoverOpen && !calendarOpen) { + const items = allNavigableItems; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const currentIndex = highlightedIndexRef.current; + const nextIndex = currentIndex < items.length - 1 ? currentIndex + 1 : 0; + setHighlightedIndex(nextIndex); + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + const currentIndex = highlightedIndexRef.current; + const nextIndex = currentIndex > 0 ? currentIndex - 1 : items.length - 1; + setHighlightedIndex(nextIndex); + return; + } + + if (e.key === 'Enter') { + const currentIndex = highlightedIndexRef.current; + if (currentIndex >= 0 && currentIndex < items.length) { + e.preventDefault(); + const item = items[currentIndex]; + if (item.type === 'calendar') { + handleOpenCalendar(); + } else { + onSelect(item); + } + return; + } + } + } + + if (e.key === 'Enter') { + e.preventDefault(); + handleConfirmInput(); + } else if (e.key === 'Escape') { + e.preventDefault(); + closeAll(); + } + }, + [ + popoverOpen, + calendarOpen, + allNavigableItems, + setHighlightedIndex, + handleOpenCalendar, + onSelect, + ], + ); + + return { + allNavigableItems, + handleKeyDown, + }; +} diff --git a/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-state.test.ts b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-state.test.ts new file mode 100644 index 00000000..86f8b7f8 --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-state.test.ts @@ -0,0 +1,20 @@ +import {describe, expect, it} from '@shipfox/vitest/vi'; +import {renderHook} from '@testing-library/react'; +import {useIntervalSelectorState} from './use-interval-selector-state'; + +describe('useIntervalSelectorState', () => { + it('should maintain refs across renders', () => { + const {result, rerender} = renderHook(() => useIntervalSelectorState()); + + const initialInputRef = result.current.inputRef; + const initialSelectedValueRef = result.current.selectedValueRef; + + result.current.selectedValueRef.current = 'test-value'; + + rerender(); + + expect(result.current.inputRef).toBe(initialInputRef); + expect(result.current.selectedValueRef).toBe(initialSelectedValueRef); + expect(result.current.selectedValueRef.current).toBe('test-value'); + }); +}); diff --git a/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-state.ts b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-state.ts new file mode 100644 index 00000000..e6289785 --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector-state.ts @@ -0,0 +1,42 @@ +import {useRef, useState} from 'react'; + +export function useIntervalSelectorState() { + const [isFocused, setIsFocused] = useState(false); + const [popoverOpen, setPopoverOpen] = useState(false); + const [calendarOpen, setCalendarOpen] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [selectedLabel, setSelectedLabel] = useState(undefined); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const [isInvalid, setIsInvalid] = useState(false); + const [shouldShake, setShouldShake] = useState(false); + + const selectedValueRef = useRef(undefined); + const isSelectingRef = useRef(false); + const inputRef = useRef(null); + const shakeTimeoutRef = useRef | null>(null); + const isMouseDownOnInputRef = useRef(false); + + return { + isFocused, + setIsFocused, + popoverOpen, + setPopoverOpen, + calendarOpen, + setCalendarOpen, + inputValue, + setInputValue, + selectedLabel, + setSelectedLabel, + highlightedIndex, + setHighlightedIndex, + isInvalid, + setIsInvalid, + shouldShake, + setShouldShake, + selectedValueRef, + isSelectingRef, + inputRef, + shakeTimeoutRef, + isMouseDownOnInputRef, + }; +} diff --git a/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector.ts b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector.ts new file mode 100644 index 00000000..1de37076 --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/hooks/use-interval-selector.ts @@ -0,0 +1,163 @@ +import {useCallback} from 'react'; +import type {IntervalSelectorProps} from '../interval-selector'; +import type {IntervalSelection, IntervalSuggestion, RelativeSuggestion} from '../types'; +import {formatSelection, formatSelectionForInput, formatShortcut} from '../utils'; +import {useIntervalSelectorInput} from './use-interval-selector-input'; +import {useIntervalSelectorNavigation} from './use-interval-selector-navigation'; +import {useIntervalSelectorState} from './use-interval-selector-state'; + +export interface UseIntervalSelectorProps + extends Pick { + relativeSuggestions: RelativeSuggestion[]; + intervalSuggestions: IntervalSuggestion[]; +} + +export function useIntervalSelector({ + selection, + onSelectionChange, + relativeSuggestions, + intervalSuggestions, +}: UseIntervalSelectorProps) { + const state = useIntervalSelectorState(); + + const triggerShakeAnimation = useCallback(() => { + if (state.shakeTimeoutRef.current) { + clearTimeout(state.shakeTimeoutRef.current); + } + state.setIsInvalid(true); + state.setShouldShake(true); + state.shakeTimeoutRef.current = setTimeout(() => { + state.setShouldShake(false); + state.shakeTimeoutRef.current = null; + }, 500); + }, [state]); + + const closeInputAndPopover = useCallback(() => { + state.setIsFocused(false); + state.setPopoverOpen(false); + state.inputRef.current?.blur(); + }, [state]); + + const closeAll = useCallback(() => { + closeInputAndPopover(); + state.setCalendarOpen(false); + state.setHighlightedIndex(-1); + if (state.shakeTimeoutRef.current) { + clearTimeout(state.shakeTimeoutRef.current); + state.shakeTimeoutRef.current = null; + } + }, [closeInputAndPopover, state]); + + const onSelect = useCallback( + (selection: IntervalSelection) => { + state.isSelectingRef.current = true; + onSelectionChange(selection); + closeAll(); + }, + [state, onSelectionChange, closeAll], + ); + + const inputHandlers = useIntervalSelectorInput({ + inputValue: state.inputValue, + setInputValue: state.setInputValue, + setIsInvalid: state.setIsInvalid, + setSelectedLabel: state.setSelectedLabel, + setHighlightedIndex: state.setHighlightedIndex, + selectedValueRef: state.selectedValueRef, + triggerShakeAnimation, + onSelect, + }); + + const displayValue = formatSelection({ + selection, + isFocused: state.isFocused, + inputValue: state.inputValue, + }); + + const shortcutValue = formatShortcut({ + selection, + inputValue: state.inputValue, + isFocused: state.isFocused, + }); + + const handleFocus = useCallback(() => { + state.setIsFocused(true); + state.setPopoverOpen(true); + state.setInputValue(formatSelectionForInput(selection)); + state.setHighlightedIndex(-1); + state.setIsInvalid(false); + requestAnimationFrame(() => { + state.inputRef.current?.select(); + }); + }, [state, selection]); + + const handleMouseDown = useCallback(() => { + state.isMouseDownOnInputRef.current = true; + }, [state]); + + const handleMouseUp = useCallback(() => { + setTimeout(() => { + state.isMouseDownOnInputRef.current = false; + }); + }, [state]); + + const handleBlur = useCallback( + (e: React.FocusEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement; + if (relatedTarget?.closest('[role="dialog"]')) { + return; + } + if (state.isMouseDownOnInputRef.current) { + requestAnimationFrame(() => { + state.inputRef.current?.focus(); + }); + return; + } + state.setIsFocused(false); + if (!state.calendarOpen) { + state.setPopoverOpen(false); + } + }, + [state], + ); + + const navigation = useIntervalSelectorNavigation({ + relativeSuggestions, + intervalSuggestions, + highlightedIndex: state.highlightedIndex, + setHighlightedIndex: state.setHighlightedIndex, + popoverOpen: state.popoverOpen, + calendarOpen: state.calendarOpen, + handleOpenCalendar: () => state.setCalendarOpen(true), + onSelect, + }); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + navigation.handleKeyDown(e, inputHandlers.handleConfirmInput, closeAll); + }, + [navigation, inputHandlers.handleConfirmInput, closeAll], + ); + + return { + onSelect, + isFocused: state.isFocused, + popoverOpen: state.popoverOpen, + calendarOpen: state.calendarOpen, + displayValue, + shortcutValue, + highlightedIndex: state.highlightedIndex, + isInvalid: state.isInvalid, + shouldShake: state.shouldShake, + inputRef: state.inputRef, + handleFocus, + handleBlur, + handleMouseDown, + handleMouseUp, + handleInputChange: inputHandlers.handleInputChange, + handleKeyDown, + handleOpenCalendar: () => state.setCalendarOpen(true), + setPopoverOpen: state.setPopoverOpen, + closeAll, + }; +} diff --git a/libs/react/ui/src/components/interval-selector/index.ts b/libs/react/ui/src/components/interval-selector/index.ts index 06feb8ff..d73b22fc 100644 --- a/libs/react/ui/src/components/interval-selector/index.ts +++ b/libs/react/ui/src/components/interval-selector/index.ts @@ -1,2 +1,2 @@ export * from './interval-selector'; -export * from './interval-selector.utils'; +export * from './types'; diff --git a/libs/react/ui/src/components/interval-selector/interval-selector-calendar.tsx b/libs/react/ui/src/components/interval-selector/interval-selector-calendar.tsx index 71115ee0..5b2f010c 100644 --- a/libs/react/ui/src/components/interval-selector/interval-selector-calendar.tsx +++ b/libs/react/ui/src/components/interval-selector/interval-selector-calendar.tsx @@ -1,43 +1,27 @@ import {Calendar} from 'components/calendar'; -import {format} from 'date-fns'; +import {endOfDay, format} from 'date-fns'; import {useCallback, useState} from 'react'; import type {DateRange} from 'react-day-picker'; -import {intervalToNowFromDuration} from 'utils/date'; -import type {IntervalSelection} from './interval-selector'; +import type {IntervalSelection} from './types'; interface IntervalSelectorCalendarProps { - selection: IntervalSelection; - onSelect: (range: DateRange | undefined) => void; + onSelect: (selection: IntervalSelection) => void; } -export function IntervalSelectorCalendar({selection, onSelect}: IntervalSelectorCalendarProps) { - const interval = - selection.type === 'interval' - ? selection.interval - : intervalToNowFromDuration(selection.duration); - - const [selectedRange, setSelectedRange] = useState({ - from: interval.start, - to: interval.end, - }); +export function IntervalSelectorCalendar({onSelect}: IntervalSelectorCalendarProps) { + const [selectedRange, setSelectedRange] = useState(undefined); const handleSelect = useCallback( - (nextRange: DateRange | undefined, selectedDay: Date | undefined) => { - setSelectedRange((range) => { - if (range?.from && range?.to && selectedDay) { - const newRange = {from: selectedDay, to: undefined}; - onSelect(newRange); - return newRange; - } - if (nextRange?.from && nextRange?.to) { - onSelect({from: nextRange.from, to: nextRange.to}); - } else { - onSelect(nextRange); - } - return nextRange; + (_: DateRange | undefined, selectedDay: Date | undefined) => { + if (!selectedDay) return setSelectedRange(undefined); + if (!selectedRange?.from) return setSelectedRange({from: selectedDay, to: undefined}); + onSelect({ + type: 'interval', + interval: {start: selectedRange.from, end: endOfDay(selectedDay)}, }); + return setSelectedRange(undefined); }, - [onSelect], + [onSelect, selectedRange], ); return ( diff --git a/libs/react/ui/src/components/interval-selector/interval-selector-suggestions.tsx b/libs/react/ui/src/components/interval-selector/interval-selector-suggestions.tsx index bb7d3f64..07069536 100644 --- a/libs/react/ui/src/components/interval-selector/interval-selector-suggestions.tsx +++ b/libs/react/ui/src/components/interval-selector/interval-selector-suggestions.tsx @@ -4,26 +4,27 @@ import {Kbd} from 'components/kbd'; import {Label} from 'components/label'; import {useEffect, useRef} from 'react'; import {cn} from 'utils/cn'; -import type {IntervalOption} from './interval-selector.utils'; +import {generateDurationShortcut, humanizeDurationToNow} from 'utils/date'; +import type {IntervalSelection, IntervalSuggestion, RelativeSuggestion} from './types'; interface IntervalSelectorSuggestionsProps { - pastIntervals: IntervalOption[]; - calendarIntervals: IntervalOption[]; - onSelect: (value: string, label: string) => void; + relativeSuggestions: RelativeSuggestion[]; + intervalSuggestions: IntervalSuggestion[]; + onSelect: (selection: IntervalSelection) => void; onOpenCalendar: () => void; highlightedIndex: number; } export function IntervalSelectorSuggestions({ - pastIntervals, - calendarIntervals, + relativeSuggestions, + intervalSuggestions, onSelect, onOpenCalendar, highlightedIndex, }: IntervalSelectorSuggestionsProps) { const pastIntervalsStartIndex = 0; - const calendarIntervalsStartIndex = pastIntervals.length; - const calendarButtonIndex = calendarIntervalsStartIndex + calendarIntervals.length; + const calendarIntervalsStartIndex = relativeSuggestions.length; + const calendarButtonIndex = calendarIntervalsStartIndex + intervalSuggestions.length; const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); @@ -39,25 +40,27 @@ export function IntervalSelectorSuggestions({ return (
- {pastIntervals.map((option, index) => { + {relativeSuggestions.map((option, index) => { const itemIndex = pastIntervalsStartIndex + index; const isHighlighted = highlightedIndex === itemIndex; + const shortcut = generateDurationShortcut(option.duration); + const label = humanizeDurationToNow(option.duration); return ( ); })} @@ -68,24 +71,23 @@ export function IntervalSelectorSuggestions({ Calendar Time
- {calendarIntervals.map((option, index) => { + {intervalSuggestions.map((option, index) => { const itemIndex = calendarIntervalsStartIndex + index; const isHighlighted = highlightedIndex === itemIndex; return ( ); diff --git a/libs/react/ui/src/components/interval-selector/interval-selector.stories.tsx b/libs/react/ui/src/components/interval-selector/interval-selector.stories.tsx index 12775150..abaf6ce1 100644 --- a/libs/react/ui/src/components/interval-selector/interval-selector.stories.tsx +++ b/libs/react/ui/src/components/interval-selector/interval-selector.stories.tsx @@ -1,8 +1,8 @@ import type {Meta, StoryObj} from '@storybook/react'; +import {endOfDay, startOfDay} from 'date-fns'; import {useState} from 'react'; -import {type IntervalSelection, IntervalSelector} from './interval-selector'; -import type {IntervalOption} from './interval-selector.utils'; -import {getCalendarIntervals} from './interval-selector.utils'; +import {IntervalSelector} from './interval-selector'; +import type {IntervalSelection, IntervalSuggestion, RelativeSuggestion} from './types'; const meta = { title: 'Components/IntervalSelector', @@ -17,7 +17,6 @@ export default meta; type Story = StoryObj; function RelativeIntervalSelector() { - const [value, setValue] = useState('1d'); const [selection, setSelection] = useState({ type: 'relative', duration: {days: 1}, @@ -32,8 +31,6 @@ function RelativeIntervalSelector() {
@@ -52,7 +49,6 @@ function AbsoluteIntervalSelector() { end: new Date(now.getFullYear(), 0, 15), }, }); - const [value, setValue] = useState(undefined); return (
@@ -63,8 +59,6 @@ function AbsoluteIntervalSelector() {
@@ -81,8 +75,6 @@ export const Relative: Story = { duration: {days: 1}, }, onSelectionChange: () => undefined, - value: '1d', - onValueChange: () => undefined, }, render: () => (
@@ -104,8 +96,6 @@ export const Absolute: Story = { }; })(), onSelectionChange: () => undefined, - value: undefined, - onValueChange: () => undefined, }, render: () => (
@@ -115,42 +105,30 @@ export const Absolute: Story = { }; function CustomIntervalsSelector() { - const [value, setValue] = useState('1h'); const [selection, setSelection] = useState({ type: 'relative', duration: {hours: 1}, }); - const customPastIntervals: IntervalOption[] = [ + const customRelativeSuggestions: RelativeSuggestion[] = [ + {duration: {minutes: 15}, type: 'relative'}, + {duration: {hours: 1}, type: 'relative'}, + {duration: {hours: 6}, type: 'relative'}, + {duration: {hours: 24}, type: 'relative'}, + {duration: {days: 3}, type: 'relative'}, + {duration: {weeks: 1}, type: 'relative'}, + {duration: {weeks: 2}, type: 'relative'}, + {duration: {months: 1}, type: 'relative'}, + ]; + + const customIntervalSuggestions: IntervalSuggestion[] = [ { - value: '15m', - duration: {minutes: 15}, - label: 'Last 15 Minutes', - shortcut: '15m', - type: 'past', + type: 'interval', + label: "During Einstein's lifetime", + interval: {start: startOfDay(new Date('1879-03-14')), end: endOfDay(new Date('1955-04-18'))}, }, - {value: '1h', duration: {hours: 1}, label: 'Last Hour', shortcut: '1h', type: 'past'}, - {value: '6h', duration: {hours: 6}, label: 'Last 6 Hours', shortcut: '6h', type: 'past'}, - {value: '24h', duration: {hours: 24}, label: 'Last 24 Hours', shortcut: '24h', type: 'past'}, - {value: '3d', duration: {days: 3}, label: 'Last 3 Days', shortcut: '3d', type: 'past'}, - {value: '1w', duration: {weeks: 1}, label: 'Last Week', shortcut: '1w', type: 'past'}, - {value: '2w', duration: {weeks: 2}, label: 'Last 2 Weeks', shortcut: '2w', type: 'past'}, - {value: '1mo', duration: {months: 1}, label: 'Last Month', shortcut: '1mo', type: 'past'}, ]; - const customCalendarIntervals = (): IntervalOption[] => { - const defaults = getCalendarIntervals(); - return defaults.map((opt) => { - if (opt.value === 'today') { - return {...opt, label: 'Today (So Far)'}; - } - if (opt.value === 'yesterday') { - return {...opt, label: 'Full Yesterday'}; - } - return opt; - }); - }; - return (
@@ -160,10 +138,8 @@ function CustomIntervalsSelector() {
@@ -180,8 +156,6 @@ export const CustomIntervals: Story = { duration: {hours: 1}, }, onSelectionChange: () => undefined, - value: '1h', - onValueChange: () => undefined, }, render: () => (
diff --git a/libs/react/ui/src/components/interval-selector/interval-selector.tsx b/libs/react/ui/src/components/interval-selector/interval-selector.tsx index e416bd2d..0fbc8f32 100644 --- a/libs/react/ui/src/components/interval-selector/interval-selector.tsx +++ b/libs/react/ui/src/components/interval-selector/interval-selector.tsx @@ -1,54 +1,40 @@ import {Input} from 'components/input'; import {Kbd} from 'components/kbd'; import {Popover, PopoverContent, PopoverTrigger} from 'components/popover'; -import type {Duration, NormalizedInterval} from 'date-fns'; import {cn} from 'utils/cn'; -import type {IntervalOption} from './interval-selector.utils'; -import {getCalendarIntervals, PAST_INTERVALS} from './interval-selector.utils'; +import {useIntervalSelector} from './hooks/use-interval-selector'; import {IntervalSelectorCalendar} from './interval-selector-calendar'; import {IntervalSelectorSuggestions} from './interval-selector-suggestions'; -import {useIntervalSelector} from './use-interval-selector'; - -export type IntervalSelection = - | { - type: 'relative'; - duration: Duration; - } - | { - type: 'interval'; - interval: NormalizedInterval; - }; +import type {IntervalSelection, IntervalSuggestion, RelativeSuggestion} from './types'; +import {defaultIntervalSuggestions, defaultRelativeSuggestions} from './utils'; export interface IntervalSelectorProps { selection: IntervalSelection; onSelectionChange: (selection: IntervalSelection) => void; - value?: string; - onValueChange?: (value: string | undefined) => void; container?: HTMLElement | null; className?: string; inputClassName?: string; - pastIntervals?: IntervalOption[]; - calendarIntervals?: IntervalOption[] | (() => IntervalOption[]); + relativeSuggestions?: RelativeSuggestion[]; + intervalSuggestions?: IntervalSuggestion[]; } export function IntervalSelector({ selection, onSelectionChange, - value, - onValueChange, container, className, inputClassName, - pastIntervals = PAST_INTERVALS, - calendarIntervals = getCalendarIntervals, + relativeSuggestions = defaultRelativeSuggestions, + intervalSuggestions = defaultIntervalSuggestions, }: IntervalSelectorProps) { const { + onSelect, isFocused, popoverOpen, calendarOpen, displayValue, + shortcutValue, highlightedIndex, - displayShortcut, isInvalid, shouldShake, inputRef, @@ -58,19 +44,14 @@ export function IntervalSelector({ handleMouseUp, handleInputChange, handleKeyDown, - handleOptionSelect, - handleCalendarSelect, handleOpenCalendar, setPopoverOpen, closeAll, - resolvedCalendarIntervals, } = useIntervalSelector({ selection, onSelectionChange, - value, - onValueChange, - pastIntervals, - calendarIntervals, + relativeSuggestions, + intervalSuggestions, }); return ( @@ -97,7 +78,7 @@ export function IntervalSelector({ onKeyDown={handleKeyDown} readOnly={!isFocused} aria-invalid={isInvalid && isFocused} - iconLeft={{displayShortcut}} + iconLeft={{shortcutValue}} className={cn('w-full pl-50', inputClassName)} />
@@ -105,7 +86,7 @@ export function IntervalSelector({ e.preventDefault()} onInteractOutside={(e) => { e.preventDefault(); @@ -124,12 +105,12 @@ export function IntervalSelector({ container={container} > {calendarOpen ? ( - + ) : popoverOpen ? ( diff --git a/libs/react/ui/src/components/interval-selector/interval-selector.utils.ts b/libs/react/ui/src/components/interval-selector/interval-selector.utils.ts deleted file mode 100644 index cd2a4934..00000000 --- a/libs/react/ui/src/components/interval-selector/interval-selector.utils.ts +++ /dev/null @@ -1,598 +0,0 @@ -import type {Duration, NormalizedInterval, StartOfWeekOptions} from 'date-fns'; -import { - differenceInDays, - differenceInHours, - differenceInMilliseconds, - differenceInMonths, - differenceInYears, - endOfDay, - endOfMonth, - endOfWeek, - endOfYear, - startOfDay, - startOfMonth, - startOfWeek, - startOfYear, - subDays, - subMonths, - subWeeks, - subYears, -} from 'date-fns'; -import { - generateDurationShortcut, - humanizeDurationToNow, - intervalToNowFromDuration, - isEndOfDay, - isStartOfDay, - parseTextDurationShortcut, -} from 'utils/date'; -import {formatDateTimeRange} from 'utils/format/date'; - -const RECENT_THRESHOLD_MS = 60000; -const INTERVAL_MATCH_TOLERANCE_MS = 60000; -const DEFAULT_TOLERANCE = 0.05; -const MS_PER_SECOND = 1000; -const SECONDS_PER_MINUTE = 60; -const MINUTES_PER_HOUR = 60; -const HOURS_PER_DAY = 24; -const DAYS_PER_WEEK = 7; -const MS_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND; -const MS_PER_WEEK = DAYS_PER_WEEK * MS_PER_DAY; -const WEEK_STARTS_ON_MONDAY = 1; -const WEEK_OPTIONS: StartOfWeekOptions = {weekStartsOn: WEEK_STARTS_ON_MONDAY}; - -function isWithinTolerance( - date1: Date, - date2: Date, - toleranceMs = INTERVAL_MATCH_TOLERANCE_MS, -): boolean { - return Math.abs(date1.getTime() - date2.getTime()) < toleranceMs; -} - -function intervalsMatch(interval1: NormalizedInterval, interval2: NormalizedInterval): boolean { - return ( - isWithinTolerance(interval1.start, interval2.start) && - isWithinTolerance(interval1.end, interval2.end) - ); -} - -function getCalendarIntervalDurationMs(optionValue: string): number | undefined { - const calendarInterval = getCalendarInterval(optionValue); - if (!calendarInterval) return undefined; - return differenceInMilliseconds(calendarInterval.end, calendarInterval.start); -} - -export type IntervalOptionType = 'past' | 'calendar' | 'custom'; - -export interface IntervalOption { - value: string; - label: string; - shortcut: string; - type: IntervalOptionType; - duration?: Duration; -} - -export const PAST_INTERVALS: IntervalOption[] = [ - {value: '5m', duration: {minutes: 5}, label: 'Past 5 Minutes', shortcut: '5m', type: 'past'}, - {value: '15m', duration: {minutes: 15}, label: 'Past 15 Minutes', shortcut: '15m', type: 'past'}, - {value: '30m', duration: {minutes: 30}, label: 'Past 30 Minutes', shortcut: '30m', type: 'past'}, - {value: '1h', duration: {hours: 1}, label: 'Past 1 Hour', shortcut: '1h', type: 'past'}, - {value: '4h', duration: {hours: 4}, label: 'Past 4 Hours', shortcut: '4h', type: 'past'}, - {value: '1d', duration: {days: 1}, label: 'Past 1 Day', shortcut: '1d', type: 'past'}, - {value: '2d', duration: {days: 2}, label: 'Past 2 Days', shortcut: '2d', type: 'past'}, - {value: '1w', duration: {weeks: 1}, label: 'Past 1 Week', shortcut: '1w', type: 'past'}, - {value: '1mo', duration: {months: 1}, label: 'Past 1 Month', shortcut: '1mo', type: 'past'}, -]; - -function calculateCalendarShortcut(value: string): string { - const now = new Date(); - - switch (value) { - case 'today': { - const todayStart = startOfDay(now); - const hours = differenceInHours(now, todayStart); - return generateDurationShortcut({hours}) || '0h'; - } - case 'week-to-date': { - const weekStart = startOfWeek(now, WEEK_OPTIONS); - const days = differenceInDays(now, weekStart); - const hours = differenceInHours(now, weekStart); - return days > 0 ? generateDurationShortcut({days}) : generateDurationShortcut({hours}); - } - case 'month-to-date': { - const monthStart = startOfMonth(now); - const days = differenceInDays(now, monthStart); - return generateDurationShortcut({days}) || '0d'; - } - case 'year-to-date': { - const yearStart = startOfYear(now); - const days = differenceInDays(now, yearStart); - return generateDurationShortcut({days}) || '0d'; - } - default: - return ''; - } -} - -export function getCalendarIntervals(): IntervalOption[] { - const baseIntervals: Omit[] = [ - {value: 'today', label: 'Today', type: 'calendar'}, - {value: 'yesterday', label: 'Yesterday', type: 'calendar', duration: {days: 1}}, - {value: 'week-to-date', label: 'Week to Date', type: 'calendar'}, - { - value: 'previous-week', - label: 'Previous Week', - type: 'calendar', - duration: {weeks: 1}, - }, - {value: 'month-to-date', label: 'Month to Date', type: 'calendar'}, - { - value: 'previous-month', - label: 'Previous Month', - type: 'calendar', - duration: {months: 1}, - }, - {value: 'year-to-date', label: 'Year to Date', type: 'calendar'}, - { - value: 'previous-year', - label: 'Previous Year', - type: 'calendar', - duration: {years: 1}, - }, - ]; - - return baseIntervals.map((interval) => { - const dynamicShortcut = calculateCalendarShortcut(interval.value); - return { - ...interval, - shortcut: - dynamicShortcut || - (interval.duration ? generateDurationShortcut(interval.duration) || '' : ''), - }; - }); -} - -export function findOption( - value: string, - pastIntervals: IntervalOption[] = PAST_INTERVALS, - calendarIntervals: IntervalOption[] = getCalendarIntervals(), -): IntervalOption | undefined { - return [...pastIntervals, ...calendarIntervals].find((opt) => opt.value === value); -} - -export function getLabelForValue( - val: string | undefined, - pastIntervals: IntervalOption[] = PAST_INTERVALS, - calendarIntervals: IntervalOption[] = getCalendarIntervals(), -): string | undefined { - if (!val) return undefined; - const option = findOption(val, pastIntervals, calendarIntervals); - return option?.label; -} - -export function findOptionByInterval( - interval: NormalizedInterval, - pastIntervals: IntervalOption[] = PAST_INTERVALS, - calendarIntervals: IntervalOption[] = getCalendarIntervals(), -): IntervalOption | undefined { - for (const opt of calendarIntervals) { - const calendarInterval = getCalendarInterval(opt.value); - if (calendarInterval && intervalsMatch(interval, calendarInterval)) { - return opt; - } - } - - for (const opt of pastIntervals) { - if (opt.duration) { - const expectedInterval = intervalToNowFromDuration(opt.duration); - if (intervalsMatch(interval, expectedInterval)) { - return opt; - } - } - } - - return undefined; -} - -export function findOptionValueForInterval( - interval: NormalizedInterval, - tolerance = DEFAULT_TOLERANCE, -): string | undefined { - const now = new Date(); - const isRecent = Math.abs(interval.end.getTime() - now.getTime()) < RECENT_THRESHOLD_MS; - - if (!isRecent) { - return undefined; - } - - const duration = interval.end.getTime() - interval.start.getTime(); - - for (const option of PAST_INTERVALS) { - if (option.duration) { - const expectedInterval = intervalToNowFromDuration(option.duration); - const expectedDuration = expectedInterval.end.getTime() - expectedInterval.start.getTime(); - const durationDiff = Math.abs(duration - expectedDuration); - - if (durationDiff < expectedDuration * tolerance) { - return option.value; - } - } - } - - return undefined; -} - -export function isRelativeToNow( - interval: NormalizedInterval, - toleranceMs = RECENT_THRESHOLD_MS, -): boolean { - const now = new Date(); - return isWithinTolerance(interval.end, now, toleranceMs); -} - -export function getDurationFromInterval( - interval: NormalizedInterval, - tolerance = DEFAULT_TOLERANCE, -): Duration | undefined { - if (!isRelativeToNow(interval)) { - return undefined; - } - - const durationMs = differenceInMilliseconds(interval.end, interval.start); - - for (const option of PAST_INTERVALS) { - if (option.duration) { - const expectedInterval = intervalToNowFromDuration(option.duration); - const expectedDurationMs = differenceInMilliseconds( - expectedInterval.end, - expectedInterval.start, - ); - const durationDiff = Math.abs(durationMs - expectedDurationMs); - - if (durationDiff < expectedDurationMs * tolerance) { - return option.duration; - } - } - } - - return undefined; -} - -export function getCalendarInterval(value: string): NormalizedInterval | undefined { - const now = new Date(); - const todayStart = startOfDay(now); - - switch (value) { - case 'today': - return {start: todayStart, end: now}; - case 'yesterday': { - const yesterday = subDays(now, 1); - return {start: startOfDay(yesterday), end: endOfDay(yesterday)}; - } - case 'week-to-date': - return {start: startOfWeek(now, WEEK_OPTIONS), end: now}; - case 'previous-week': { - const lastWeek = subWeeks(now, 1); - return { - start: startOfWeek(lastWeek, WEEK_OPTIONS), - end: endOfWeek(lastWeek, WEEK_OPTIONS), - }; - } - case 'month-to-date': - return {start: startOfMonth(now), end: now}; - case 'previous-month': { - const lastMonth = subMonths(now, 1); - return {start: startOfMonth(lastMonth), end: endOfMonth(lastMonth)}; - } - case 'year-to-date': - return {start: startOfYear(now), end: now}; - case 'previous-year': { - const lastYear = subYears(now, 1); - return {start: startOfYear(lastYear), end: endOfYear(lastYear)}; - } - default: - return undefined; - } -} - -export function getDurationFromCalendarInterval( - interval: NormalizedInterval, - tolerance = DEFAULT_TOLERANCE, -): Duration | undefined { - const intervalDurationMs = differenceInMilliseconds(interval.end, interval.start); - - for (const option of getCalendarIntervals()) { - if (!option.duration) continue; - - let expectedDurationMs: number | undefined; - - if (option.duration.days) { - const isFullDay = isStartOfDay(interval.start) && isEndOfDay(interval.end); - if (!isFullDay) continue; - expectedDurationMs = option.duration.days * MS_PER_DAY; - } else if (option.duration.weeks) { - const weekStart = startOfWeek(interval.start, WEEK_OPTIONS); - const weekEnd = endOfWeek(interval.end, WEEK_OPTIONS); - const isFullWeek = - isWithinTolerance(interval.start, weekStart) && isWithinTolerance(interval.end, weekEnd); - if (!isFullWeek) continue; - expectedDurationMs = option.duration.weeks * MS_PER_WEEK; - } else if (option.duration.months) { - const monthStart = startOfMonth(interval.start); - const monthEnd = endOfMonth(interval.end); - const isFullMonth = - isWithinTolerance(interval.start, monthStart) && isWithinTolerance(interval.end, monthEnd); - if (!isFullMonth) continue; - expectedDurationMs = getCalendarIntervalDurationMs(option.value); - if (expectedDurationMs === undefined) continue; - } else if (option.duration.years) { - const yearStart = startOfYear(interval.start); - const yearEnd = endOfYear(interval.end); - const isFullYear = - isWithinTolerance(interval.start, yearStart) && isWithinTolerance(interval.end, yearEnd); - if (!isFullYear) continue; - expectedDurationMs = getCalendarIntervalDurationMs(option.value); - if (expectedDurationMs === undefined) continue; - } else { - continue; - } - - const durationDiff = Math.abs(intervalDurationMs - expectedDurationMs); - if (durationDiff < expectedDurationMs * tolerance) { - return option.duration; - } - } - - return undefined; -} - -export function formatIntervalDisplay(interval: NormalizedInterval, isFocused: boolean): string { - const isAbsolute = !isRelativeToNow(interval); - - if (isFocused) { - return formatDateTimeRange(interval, {forceShowTime: isAbsolute}); - } - - const duration = getDurationFromInterval(interval); - if (duration) { - return humanizeDurationToNow(duration); - } - - const calendarDuration = getDurationFromCalendarInterval(interval); - if (calendarDuration) { - return humanizeDurationToNow(calendarDuration); - } - - return formatDateTimeRange(interval, {forceShowTime: isAbsolute}); -} - -export interface ParsedShortcut { - shortcut: string; - duration: Duration; - label: string; -} - -export interface CalendarShortcutResult { - shortcut: string | undefined; - label: string; - value: string | undefined; -} - -function normalizeDurationToAppropriateUnit(duration: Duration): Duration { - const normalized: Duration = {...duration}; - - if (normalized.minutes && normalized.minutes >= 60) { - const hours = Math.floor(normalized.minutes / 60); - const remainingMinutes = normalized.minutes % 60; - normalized.hours = (normalized.hours || 0) + hours; - if (remainingMinutes > 0) { - normalized.minutes = remainingMinutes; - } else { - delete normalized.minutes; - } - } - - if (normalized.hours && normalized.hours >= 24) { - const days = Math.round(normalized.hours / 24); - normalized.days = (normalized.days || 0) + days; - delete normalized.hours; - } - - if (normalized.days && normalized.days >= 30) { - const months = Math.round(normalized.days / 30); - normalized.months = (normalized.months || 0) + months; - delete normalized.days; - } - - if (normalized.months && normalized.months >= 12) { - const years = Math.floor(normalized.months / 12); - const remainingMonths = normalized.months % 12; - normalized.years = (normalized.years || 0) + years; - if (remainingMonths > 0) { - normalized.months = remainingMonths; - } else { - delete normalized.months; - } - } - - const units = ['years', 'months', 'weeks', 'days', 'hours', 'minutes'] as const; - for (const unit of units) { - if (normalized[unit]) { - return {[unit]: normalized[unit]}; - } - } - - return normalized; -} - -export function parseRelativeTimeShortcut(input: string): ParsedShortcut | undefined { - const trimmed = input.trim(); - if (!trimmed) return undefined; - - let duration = parseTextDurationShortcut(trimmed); - if (!duration) return undefined; - - if (duration.days !== undefined) { - const days = duration.days; - if (days > 730) return undefined; - if (days > 547) { - return { - shortcut: '2y', - duration: {years: 2}, - label: 'Past 2 Years', - }; - } - } - - duration = normalizeDurationToAppropriateUnit(duration); - - if (duration.years !== undefined) { - if (duration.years !== 1) return undefined; - return { - shortcut: '1y', - duration: {years: 1}, - label: 'Past 1 Year', - }; - } - - if (duration.months !== undefined) { - const months = duration.months; - if (months > 17) { - return { - shortcut: '2y', - duration: {years: 2}, - label: 'Past 2 Years', - }; - } - return { - shortcut: `${months}mo`, - duration: {months}, - label: `Past ${months} ${months === 1 ? 'Month' : 'Months'}`, - }; - } - - if (duration.weeks !== undefined) { - const weeks = duration.weeks; - if (weeks > 104) return undefined; - if (weeks > 78) { - return { - shortcut: '2y', - duration: {years: 2}, - label: 'Past 2 Years', - }; - } - if (weeks >= 52) { - return { - shortcut: '1y', - duration: {years: 1}, - label: 'Past 1 Year', - }; - } - if (weeks > 17) { - const months = Math.round(weeks / 4.33); - return { - shortcut: `${months}mo`, - duration: {months}, - label: `Past ${months} ${months === 1 ? 'Month' : 'Months'}`, - }; - } - return { - shortcut: `${weeks}w`, - duration: {weeks}, - label: `Past ${weeks} ${weeks === 1 ? 'Week' : 'Weeks'}`, - }; - } - - if (duration.days !== undefined) { - const days = duration.days; - if (days >= 30 && days <= 31) { - return { - shortcut: '1mo', - duration: {months: 1}, - label: 'Past 1 Month', - }; - } - if (days > 31) return undefined; - return { - shortcut: `${days}d`, - duration: {days}, - label: `Past ${days} ${days === 1 ? 'Day' : 'Days'}`, - }; - } - - if (duration.hours !== undefined) { - const hours = duration.hours; - return { - shortcut: `${hours}h`, - duration: {hours}, - label: `Past ${hours} ${hours === 1 ? 'Hour' : 'Hours'}`, - }; - } - - if (duration.minutes !== undefined) { - const minutes = duration.minutes; - return { - shortcut: `${minutes}m`, - duration: {minutes}, - label: `Past ${minutes} ${minutes === 1 ? 'Minute' : 'Minutes'}`, - }; - } - - return undefined; -} - -export function detectShortcutFromCalendarInterval( - interval: NormalizedInterval, -): CalendarShortcutResult | undefined { - if (isRelativeToNow(interval)) { - return undefined; - } - - const formatOptions = {forceShowTime: true}; - - const matchingOption = findOptionByInterval(interval); - if (matchingOption?.shortcut) { - return { - shortcut: matchingOption.shortcut, - label: formatDateTimeRange(interval, formatOptions), - value: matchingOption.value, - }; - } - - const days = Math.abs(differenceInDays(interval.end, interval.start)); - const months = Math.abs(differenceInMonths(interval.end, interval.start)); - const years = Math.abs(differenceInYears(interval.end, interval.start)); - - let shortcut: string | undefined; - - if (years > 0) { - if (years > 1) { - return { - shortcut: undefined, - label: formatDateTimeRange(interval, formatOptions), - value: undefined, - }; - } - shortcut = '1y'; - } else if (months > 0) { - shortcut = `${months}mo`; - } else if (days > 0) { - if (days > 30) { - shortcut = '1mo'; - } else { - shortcut = `${days}d`; - } - } else { - return { - shortcut: undefined, - label: formatDateTimeRange(interval, formatOptions), - value: undefined, - }; - } - - return { - shortcut, - label: formatDateTimeRange(interval, formatOptions), - value: undefined, - }; -} diff --git a/libs/react/ui/src/components/interval-selector/types.ts b/libs/react/ui/src/components/interval-selector/types.ts new file mode 100644 index 00000000..e8050c26 --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/types.ts @@ -0,0 +1,24 @@ +import type {Duration, NormalizedInterval} from 'date-fns'; + +export type IntervalSelection = + | { + type: 'relative'; + duration: Duration; + } + | { + type: 'interval'; + interval: NormalizedInterval; + }; + +export interface RelativeSuggestion { + type: 'relative'; + duration: Duration; +} + +export interface IntervalSuggestion { + label: string; + type: 'interval'; + interval: NormalizedInterval; +} + +export type Suggestion = RelativeSuggestion | IntervalSuggestion; diff --git a/libs/react/ui/src/components/interval-selector/use-interval-selector.ts b/libs/react/ui/src/components/interval-selector/use-interval-selector.ts deleted file mode 100644 index d123e478..00000000 --- a/libs/react/ui/src/components/interval-selector/use-interval-selector.ts +++ /dev/null @@ -1,506 +0,0 @@ -import type {NormalizedInterval} from 'date-fns'; -import {differenceInDays, differenceInHours, differenceInMinutes} from 'date-fns'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {DateRange as DayPickerDateRange} from 'react-day-picker'; -import {intervalToNowFromDuration, parseTextInterval} from 'utils/date'; -import type {IntervalSelection, IntervalSelectorProps} from './interval-selector'; -import { - detectShortcutFromCalendarInterval, - findOption, - findOptionByInterval, - formatIntervalDisplay, - getCalendarInterval, - getLabelForValue, - type IntervalOption, - parseRelativeTimeShortcut, -} from './interval-selector.utils'; - -interface UseIntervalSelectorProps - extends Pick< - IntervalSelectorProps, - 'selection' | 'onSelectionChange' | 'value' | 'onValueChange' - > { - pastIntervals: IntervalOption[]; - calendarIntervals: IntervalOption[] | (() => IntervalOption[]); -} - -export function useIntervalSelector({ - selection, - onSelectionChange, - value, - onValueChange, - pastIntervals, - calendarIntervals, -}: UseIntervalSelectorProps) { - const currentInterval: NormalizedInterval = - selection.type === 'interval' - ? selection.interval - : intervalToNowFromDuration(selection.duration); - const [isFocused, setIsFocused] = useState(false); - const [popoverOpen, setPopoverOpen] = useState(false); - const [calendarOpen, setCalendarOpen] = useState(false); - - const resolvedCalendarIntervals = useMemo(() => { - void popoverOpen; - return typeof calendarIntervals === 'function' ? calendarIntervals() : calendarIntervals; - }, [popoverOpen, calendarIntervals]); - - const [inputValue, setInputValue] = useState(''); - const [selectedLabel, setSelectedLabel] = useState(undefined); - const [highlightedIndex, setHighlightedIndex] = useState(-1); - const [detectedShortcut, setDetectedShortcut] = useState(undefined); - const [confirmedShortcut, setConfirmedShortcut] = useState(undefined); - const [isInvalid, setIsInvalid] = useState(false); - const [shouldShake, setShouldShake] = useState(false); - const selectedValueRef = useRef(undefined); - const isSelectingRef = useRef(false); - const inputRef = useRef(null); - const shakeTimeoutRef = useRef | null>(null); - const isMouseDownOnInputRef = useRef(false); - - const getShortcutFromValue = useCallback( - (value: string): string | undefined => { - const option = findOption(value, pastIntervals, resolvedCalendarIntervals); - if (option?.shortcut) { - return option.shortcut; - } - const parsedShortcut = parseRelativeTimeShortcut(value); - return parsedShortcut?.shortcut; - }, - [pastIntervals, resolvedCalendarIntervals], - ); - - const detectShortcutFromInterval = useCallback( - (interval: NormalizedInterval) => { - const matchingOption = findOptionByInterval( - interval, - pastIntervals, - resolvedCalendarIntervals, - ); - if (matchingOption?.shortcut) { - return { - shortcut: matchingOption.shortcut, - label: matchingOption.label, - value: matchingOption.value, - }; - } - - const days = Math.abs(differenceInDays(interval.end, interval.start)); - const hours = Math.abs(differenceInHours(interval.end, interval.start)); - const minutes = Math.abs(differenceInMinutes(interval.end, interval.start)); - - const durationString = - days > 0 ? `${days}d` : hours > 0 ? `${hours}h` : minutes > 0 ? `${minutes}m` : '-'; - const parsedShortcut = parseRelativeTimeShortcut(durationString); - - if (parsedShortcut) { - return { - shortcut: parsedShortcut.shortcut, - label: parsedShortcut.label, - value: undefined, - }; - } - - return {shortcut: undefined, label: undefined, value: undefined}; - }, - [pastIntervals, resolvedCalendarIntervals], - ); - - const clearSelectionState = useCallback(() => { - setSelectedLabel(undefined); - selectedValueRef.current = undefined; - setConfirmedShortcut(undefined); - }, []); - - const applyIntervalDetection = useCallback( - (interval: NormalizedInterval) => { - const calendarResult = detectShortcutFromCalendarInterval(interval); - if (calendarResult) { - setConfirmedShortcut(calendarResult.shortcut ?? undefined); - setSelectedLabel(calendarResult.label); - selectedValueRef.current = calendarResult.value ?? undefined; - return; - } - - const {shortcut, label, value} = detectShortcutFromInterval(interval); - if (shortcut) { - setConfirmedShortcut(shortcut); - setSelectedLabel(label ?? undefined); - selectedValueRef.current = value ?? undefined; - } else { - clearSelectionState(); - } - }, - [detectShortcutFromInterval, clearSelectionState], - ); - - const detectShortcutFromInput = useCallback( - (inputValue: string): string | undefined => { - const parsedShortcut = parseRelativeTimeShortcut(inputValue); - if (parsedShortcut) { - return parsedShortcut.shortcut; - } - - const parsedInterval = parseTextInterval(inputValue); - if (parsedInterval) { - const calendarResult = detectShortcutFromCalendarInterval(parsedInterval); - if (calendarResult) { - return calendarResult.shortcut ?? undefined; - } - const {shortcut} = detectShortcutFromInterval(parsedInterval); - return shortcut; - } - - return undefined; - }, - [detectShortcutFromInterval], - ); - - const triggerShakeAnimation = useCallback(() => { - setIsInvalid(true); - setShouldShake(true); - if (shakeTimeoutRef.current) { - clearTimeout(shakeTimeoutRef.current); - } - shakeTimeoutRef.current = setTimeout(() => { - setShouldShake(false); - shakeTimeoutRef.current = null; - }, 500); - }, []); - - const updateSelectionFromValue = useCallback( - (val: string) => { - const label = getLabelForValue(val, pastIntervals, resolvedCalendarIntervals); - if (label) { - setSelectedLabel(label); - selectedValueRef.current = val; - setConfirmedShortcut(getShortcutFromValue(val)); - return true; - } - return false; - }, - [getShortcutFromValue, pastIntervals, resolvedCalendarIntervals], - ); - - const updateSelectionFromRef = useCallback(() => { - if (!selectedValueRef.current) return false; - const option = findOption(selectedValueRef.current, pastIntervals, resolvedCalendarIntervals); - if (option) { - setSelectedLabel(option.label); - setConfirmedShortcut(getShortcutFromValue(option.value)); - return true; - } - clearSelectionState(); - return false; - }, [getShortcutFromValue, clearSelectionState, pastIntervals, resolvedCalendarIntervals]); - - const updateSelectionFromInterval = useCallback( - (int: NormalizedInterval) => { - const matchingOption = findOptionByInterval(int, pastIntervals, resolvedCalendarIntervals); - if (matchingOption) { - setSelectedLabel(matchingOption.label); - selectedValueRef.current = matchingOption.value; - setConfirmedShortcut(getShortcutFromValue(matchingOption.value)); - } else { - applyIntervalDetection(int); - } - }, - [getShortcutFromValue, applyIntervalDetection, pastIntervals, resolvedCalendarIntervals], - ); - - const emitSelection = useCallback( - (newSelection: IntervalSelection) => { - onSelectionChange(newSelection); - }, - [onSelectionChange], - ); - - const getAllNavigableItems = () => { - return [ - ...pastIntervals, - ...resolvedCalendarIntervals, - {value: '__calendar__', label: 'Select from calendar', type: 'custom' as const}, - ]; - }; - - const displayValue = isFocused - ? inputValue - : value && findOption(value, pastIntervals, resolvedCalendarIntervals) - ? (selectedLabel ?? - getLabelForValue(value, pastIntervals, resolvedCalendarIntervals) ?? - formatIntervalDisplay(currentInterval, false)) - : (selectedLabel ?? formatIntervalDisplay(currentInterval, false)); - - const closeInputAndPopover = () => { - setIsFocused(false); - setPopoverOpen(false); - inputRef.current?.blur(); - }; - - const closeAll = () => { - closeInputAndPopover(); - setCalendarOpen(false); - setHighlightedIndex(-1); - }; - - useEffect(() => { - if (!isFocused && !isSelectingRef.current) { - const explicitValue = formatIntervalDisplay(currentInterval, true); - setInputValue(explicitValue); - setDetectedShortcut(undefined); - - if (value && updateSelectionFromValue(value)) { - return; - } - - if (updateSelectionFromRef()) { - return; - } - - updateSelectionFromInterval(currentInterval); - } - }, [ - currentInterval, - isFocused, - value, - updateSelectionFromValue, - updateSelectionFromRef, - updateSelectionFromInterval, - ]); - - const handleFocus = () => { - setIsFocused(true); - setPopoverOpen(true); - setInputValue(formatIntervalDisplay(currentInterval, true)); - setHighlightedIndex(-1); - setIsInvalid(false); - requestAnimationFrame(() => { - inputRef.current?.select(); - }); - }; - - const handleMouseDown = () => { - isMouseDownOnInputRef.current = true; - }; - - const handleMouseUp = () => { - setTimeout(() => { - isMouseDownOnInputRef.current = false; - }); - }; - - const handleBlur = (e: React.FocusEvent) => { - const relatedTarget = e.relatedTarget as HTMLElement; - if (relatedTarget?.closest('[role="dialog"]')) { - return; - } - if (isMouseDownOnInputRef.current) { - requestAnimationFrame(() => { - inputRef.current?.focus(); - }); - return; - } - setIsFocused(false); - setDetectedShortcut(undefined); - if (!calendarOpen) { - setPopoverOpen(false); - } - }; - - const handleInputChange = (e: React.ChangeEvent) => { - const newValue = e.target.value; - setInputValue(newValue); - - const detectedShortcut = detectShortcutFromInput(newValue); - if (detectedShortcut) { - setDetectedShortcut(detectedShortcut); - setIsInvalid(false); - } else { - setDetectedShortcut(undefined); - if (newValue.trim()) { - setConfirmedShortcut(undefined); - setIsInvalid(true); - } else { - setConfirmedShortcut(undefined); - setIsInvalid(false); - } - } - - if (selectedLabel || selectedValueRef.current) { - setSelectedLabel(undefined); - selectedValueRef.current = undefined; - } - setHighlightedIndex(-1); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (popoverOpen && !calendarOpen) { - const items = getAllNavigableItems(); - - if (e.key === 'ArrowDown') { - e.preventDefault(); - setHighlightedIndex((prev) => (prev < items.length - 1 ? prev + 1 : 0)); - return; - } - - if (e.key === 'ArrowUp') { - e.preventDefault(); - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1)); - return; - } - - if (e.key === 'Enter' && highlightedIndex >= 0 && highlightedIndex < items.length) { - e.preventDefault(); - const item = items[highlightedIndex]; - if (item.value === '__calendar__') { - handleOpenCalendar(); - } else { - handleOptionSelect(item.value, item.label); - } - return; - } - } - - if (e.key === 'Enter') { - e.preventDefault(); - handleConfirmInput(); - } else if (e.key === 'Escape') { - e.preventDefault(); - closeAll(); - } - }; - - const handleConfirmInput = () => { - const trimmedValue = inputValue.trim(); - - const parsedShortcut = parseRelativeTimeShortcut(trimmedValue); - if (parsedShortcut) { - emitSelection({ - type: 'relative', - duration: parsedShortcut.duration, - }); - onValueChange?.(trimmedValue); - setSelectedLabel(parsedShortcut.label); - selectedValueRef.current = undefined; - setConfirmedShortcut(parsedShortcut.shortcut); - setDetectedShortcut(undefined); - setIsInvalid(false); - closeInputAndPopover(); - return; - } - - const parsedInterval = parseTextInterval(trimmedValue); - if (parsedInterval) { - const calendarResult = detectShortcutFromCalendarInterval(parsedInterval); - if (calendarResult?.value) { - onValueChange?.(calendarResult.value); - } else { - onValueChange?.(undefined); - } - applyIntervalDetection(parsedInterval); - emitSelection({ - type: 'interval', - interval: parsedInterval, - }); - setDetectedShortcut(undefined); - setIsInvalid(false); - closeInputAndPopover(); - return; - } - - triggerShakeAnimation(); - setDetectedShortcut(undefined); - }; - - const handleOptionSelect = (optionValue: string, label: string) => { - selectedValueRef.current = optionValue; - setSelectedLabel(label); - isSelectingRef.current = true; - setDetectedShortcut(undefined); - setConfirmedShortcut(getShortcutFromValue(optionValue)); - onValueChange?.(optionValue); - - const option = findOption(optionValue, pastIntervals, resolvedCalendarIntervals); - if (option) { - if (option.type === 'calendar') { - const calendarInterval = getCalendarInterval(optionValue); - if (calendarInterval) { - emitSelection({ - type: 'interval', - interval: calendarInterval, - }); - } - } else if (option.duration) { - emitSelection({ - type: 'relative', - duration: option.duration, - }); - } - } - - closeInputAndPopover(); - }; - - const handleCalendarSelect = (range: DayPickerDateRange | undefined) => { - if (!range?.from) return; - - const calendarInterval = range.to - ? {start: range.from, end: range.to} - : {start: range.from, end: range.from}; - - isSelectingRef.current = true; - - applyIntervalDetection(calendarInterval); - - emitSelection({ - type: 'interval', - interval: calendarInterval, - }); - - if (range.to) { - closeAll(); - } - }; - - const handleOpenCalendar = () => { - setCalendarOpen(true); - }; - - const displayShortcut = detectedShortcut ?? confirmedShortcut ?? '-'; - - useEffect(() => { - return () => { - isSelectingRef.current = false; - if (shakeTimeoutRef.current) { - clearTimeout(shakeTimeoutRef.current); - } - isMouseDownOnInputRef.current = false; - }; - }, []); - - return { - isFocused, - popoverOpen, - calendarOpen, - inputValue, - displayValue, - highlightedIndex, - displayShortcut, - isInvalid, - shouldShake, - inputRef, - handleFocus, - handleBlur, - handleMouseDown, - handleMouseUp, - handleInputChange, - handleKeyDown, - handleOptionSelect, - handleCalendarSelect, - handleOpenCalendar, - setPopoverOpen, - setIsFocused, - closeAll, - resolvedCalendarIntervals, - }; -} diff --git a/libs/react/ui/src/components/interval-selector/utils/constants.ts b/libs/react/ui/src/components/interval-selector/utils/constants.ts new file mode 100644 index 00000000..3f565962 --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/utils/constants.ts @@ -0,0 +1,104 @@ +import { + type Duration, + endOfDay, + endOfMonth, + endOfWeek, + endOfYear, + type NormalizedInterval, + type StartOfWeekOptions, + startOfDay, + startOfMonth, + startOfWeek, + startOfYear, + subDays, + subMonths, + subWeeks, + subYears, +} from 'date-fns'; + +export interface RelativeSuggestion { + type: 'relative'; + duration: Duration; +} + +export interface IntervalSuggestion { + label: string; + type: 'interval'; + interval: NormalizedInterval; +} + +export type Suggestion = RelativeSuggestion | IntervalSuggestion; + +export interface ParsedShortcut { + shortcut: string; + duration: Duration; + label: string; +} + +export interface CalendarShortcutResult { + shortcut: string | undefined; + label: string; + value: string | undefined; +} + +export const defaultRelativeSuggestions: RelativeSuggestion[] = [ + {duration: {minutes: 5}, type: 'relative'}, + {duration: {minutes: 15}, type: 'relative'}, + {duration: {minutes: 30}, type: 'relative'}, + {duration: {hours: 1}, type: 'relative'}, + {duration: {hours: 4}, type: 'relative'}, + {duration: {days: 1}, type: 'relative'}, + {duration: {days: 2}, type: 'relative'}, + {duration: {weeks: 1}, type: 'relative'}, + {duration: {months: 1}, type: 'relative'}, +]; + +const WEEK_OPTIONS: StartOfWeekOptions = {weekStartsOn: 1}; + +const now = new Date(); + +export const defaultIntervalSuggestions: IntervalSuggestion[] = [ + { + label: 'Today', + type: 'interval', + interval: {start: startOfDay(now), end: endOfDay(now)}, + }, + { + label: 'Yesterday', + type: 'interval', + interval: {start: startOfDay(subDays(now, 1)), end: endOfDay(subDays(now, 1))}, + }, + { + label: 'Week to Date', + type: 'interval', + interval: {start: startOfWeek(now, WEEK_OPTIONS), end: endOfDay(now)}, + }, + { + label: 'Previous Week', + type: 'interval', + interval: { + start: startOfWeek(subWeeks(now, 1), WEEK_OPTIONS), + end: endOfWeek(subWeeks(now, 1), WEEK_OPTIONS), + }, + }, + { + label: 'Month to Date', + type: 'interval', + interval: {start: startOfMonth(now), end: endOfDay(now)}, + }, + { + label: 'Previous Month', + type: 'interval', + interval: {start: startOfMonth(subMonths(now, 1)), end: endOfMonth(subMonths(now, 1))}, + }, + { + label: 'Year to Date', + type: 'interval', + interval: {start: startOfYear(now), end: endOfDay(now)}, + }, + { + label: 'Previous Year', + type: 'interval', + interval: {start: startOfYear(subYears(now, 1)), end: endOfYear(subYears(now, 1))}, + }, +]; diff --git a/libs/react/ui/src/components/interval-selector/utils/format.test.ts b/libs/react/ui/src/components/interval-selector/utils/format.test.ts new file mode 100644 index 00000000..94cb35d6 --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/utils/format.test.ts @@ -0,0 +1,149 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from '@shipfox/vitest/vi'; +import {formatSelection, formatSelectionForInput, formatShortcut} from './format'; + +process.env.TZ = 'UTC'; + +describe('interval-selector-format', () => { + const now = new Date('2026-01-19T17:45:00Z'); + + beforeEach(() => { + vi.useFakeTimers({now}); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('formatSelection', () => { + it('should show the selected interval in human readable format by default', () => { + const interval = { + start: new Date('2026-01-19T00:00:00Z'), + end: new Date('2026-01-19T01:00:00Z'), + }; + const result = formatSelection({ + selection: {type: 'interval', interval}, + isFocused: false, + inputValue: '', + }); + expect(result).toBe('Jan 19, 12:00 AM \u2013 Jan 19, 1:00 AM'); + }); + + it('should show the selected duration in human readable format by default', () => { + const duration = {hours: 1}; + const result = formatSelection({ + selection: {type: 'relative', duration}, + isFocused: false, + inputValue: '', + }); + expect(result).toBe('Past 1 hour'); + }); + + it('should show the input value when focused', () => { + const interval = { + start: new Date('2026-01-19T00:00:00Z'), + end: new Date('2026-01-19T01:00:00Z'), + }; + const result = formatSelection({ + selection: {type: 'interval', interval}, + isFocused: true, + inputValue: 'Hello world', + }); + expect(result).toBe('Hello world'); + }); + }); + + describe('formatSelectionForInput', () => { + it('should format the selected interval as a full date range', () => { + const interval = { + start: new Date('2026-01-19T00:00:00Z'), + end: new Date('2026-01-19T01:00:00Z'), + }; + const result = formatSelectionForInput({type: 'interval', interval}); + expect(result).toBe('Jan 19, 2026, 12:00 AM\u2009\u2013\u2009Jan 19, 2026, 1:00 AM'); + }); + + it('should format the selected duration as a full date range relative to now', () => { + const duration = {hours: 1}; + const result = formatSelectionForInput({type: 'relative', duration}); + expect(result).toBe('Jan 19, 2026, 4:45 PM\u2009\u2013\u2009Jan 19, 2026, 5:45 PM'); + }); + }); + + describe('formatShortcut', () => { + it('should format the selected duration as shortcut', () => { + const duration = {hours: 1}; + const result = formatShortcut({ + selection: {type: 'relative', duration}, + inputValue: '', + isFocused: false, + }); + expect(result).toBe('1h'); + }); + + it('should format the selected interval as shortcut', () => { + const interval = { + start: new Date('2026-01-19T00:00:00Z'), + end: new Date('2026-01-19T01:00:00Z'), + }; + const result = formatShortcut({ + selection: {type: 'interval', interval}, + inputValue: '', + isFocused: false, + }); + expect(result).toBe('1h'); + }); + + it('should format the selected interval as the closest duration shortcut', () => { + const interval = { + start: new Date('2026-01-19T00:00:00Z'), + end: new Date('2026-01-19T01:10:00Z'), + }; + const result = formatShortcut({ + selection: {type: 'interval', interval}, + inputValue: '', + isFocused: false, + }); + expect(result).toBe('1h'); + }); + + it('should format the selected duration for shortcut when the input is not focused and the value is a duration shortcut', () => { + const duration = {hours: 1}; + const result = formatShortcut({ + selection: {type: 'relative', duration}, + inputValue: '5d', + isFocused: false, + }); + expect(result).toBe('1h'); + }); + + it('should format the selected duration for shortcut when the input is focused and a duration', () => { + const duration = {hours: 1}; + const result = formatShortcut({ + selection: {type: 'relative', duration}, + inputValue: '5d', + isFocused: true, + }); + expect(result).toBe('5d'); + }); + + it('should format the selected duration for shortcut when the input is focused and an interval', () => { + const duration = {hours: 1}; + const result = formatShortcut({ + selection: {type: 'relative', duration}, + inputValue: '1 Dec 2025, 00:00 – 2 Dec 2025, 00:00', + isFocused: true, + }); + expect(result).toBe('1d'); + }); + + it('should return "-" when the input is focused and cannot be parsed', () => { + const duration = {hours: 1}; + const result = formatShortcut({ + selection: {type: 'relative', duration}, + inputValue: 'Hello world', + isFocused: true, + }); + expect(result).toBe('-'); + }); + }); +}); diff --git a/libs/react/ui/src/components/interval-selector/utils/format.ts b/libs/react/ui/src/components/interval-selector/utils/format.ts new file mode 100644 index 00000000..2a6e8fac --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/utils/format.ts @@ -0,0 +1,47 @@ +import {intervalToDuration} from 'date-fns'; +import { + generateDurationShortcut, + humanizeDurationToNow, + intervalToNowFromDuration, + parseTextDurationShortcut, + parseTextInterval, +} from 'utils/date'; +import {formatDateTime, formatDateTimeRange} from 'utils/format/date'; +import type {IntervalSelection} from '../types'; + +export function formatSelectionForInput(selection: IntervalSelection): string { + const interval = + selection.type === 'relative' + ? intervalToNowFromDuration(selection.duration) + : selection.interval; + return `${formatDateTime(interval.start)}\u2009\u2013\u2009${formatDateTime(interval.end)}`; +} + +interface FormatSelectionParams { + selection: IntervalSelection; + inputValue: string; + isFocused: boolean; +} + +export function formatSelection({selection, isFocused, inputValue}: FormatSelectionParams): string { + if (isFocused) return inputValue; + if (selection.type === 'relative') return humanizeDurationToNow(selection.duration); + return formatDateTimeRange(selection.interval); +} + +interface FormatShortcutParams { + selection: IntervalSelection; + inputValue: string; + isFocused: boolean; +} + +export function formatShortcut({selection, inputValue, isFocused}: FormatShortcutParams): string { + const inputShortcut = parseTextDurationShortcut(inputValue); + const inputInterval = parseTextInterval(inputValue); + if (isFocused && inputShortcut) return generateDurationShortcut(inputShortcut); + if (isFocused && inputInterval) + return generateDurationShortcut(intervalToDuration(inputInterval)); + if (isFocused) return '-'; + if (selection.type === 'relative') return generateDurationShortcut(selection.duration); + return generateDurationShortcut(intervalToDuration(selection.interval)); +} diff --git a/libs/react/ui/src/components/interval-selector/utils/index.ts b/libs/react/ui/src/components/interval-selector/utils/index.ts new file mode 100644 index 00000000..789efb84 --- /dev/null +++ b/libs/react/ui/src/components/interval-selector/utils/index.ts @@ -0,0 +1,2 @@ +export * from './constants'; +export * from './format'; diff --git a/libs/react/ui/src/utils/date.test.ts b/libs/react/ui/src/utils/date.test.ts index 102a9c17..46e25fd3 100644 --- a/libs/react/ui/src/utils/date.test.ts +++ b/libs/react/ui/src/utils/date.test.ts @@ -118,6 +118,57 @@ describe('date utils', () => { const result = generateDurationShortcut({years: 100}); expect(result).toEqual('100y'); }); + + it('should return the highest duration unit (years)', () => { + const result = generateDurationShortcut({ + years: 1, + months: 1, + weeks: 1, + days: 1, + hours: 1, + minutes: 1, + seconds: 1, + }); + expect(result).toEqual('1y'); + }); + + it('should return the highest duration unit (months)', () => { + const result = generateDurationShortcut({ + months: 1, + weeks: 1, + days: 1, + hours: 1, + minutes: 1, + seconds: 1, + }); + expect(result).toEqual('1mo'); + }); + + it('should return the highest duration unit (weeks)', () => { + const result = generateDurationShortcut({ + weeks: 1, + days: 1, + hours: 1, + minutes: 1, + seconds: 1, + }); + expect(result).toEqual('1w'); + }); + + it('should return the highest duration unit (days)', () => { + const result = generateDurationShortcut({days: 1, hours: 1, minutes: 1, seconds: 1}); + expect(result).toEqual('1d'); + }); + + it('should return the highest duration unit (hours)', () => { + const result = generateDurationShortcut({hours: 1, minutes: 1, seconds: 1}); + expect(result).toEqual('1h'); + }); + + it('should return the highest duration unit (minutes)', () => { + const result = generateDurationShortcut({minutes: 1, seconds: 1}); + expect(result).toEqual('1m'); + }); }); describe('parseTextInterval', () => { @@ -127,15 +178,6 @@ describe('date utils', () => { expect(parseTextInterval('invalid - invalid')).toBeUndefined(); }); - it('should parse duration shortcuts correctly', () => { - const result = parseTextInterval('5m'); - const expectedStart = sub(now, {minutes: 5}); - expect(result).toEqual({ - start: expectedStart, - end: now, - }); - }); - describe('year assignment when dates lack years', () => { const currentYear = now.getFullYear(); diff --git a/libs/react/ui/src/utils/date.ts b/libs/react/ui/src/utils/date.ts index f3246dd7..d627edc9 100644 --- a/libs/react/ui/src/utils/date.ts +++ b/libs/react/ui/src/utils/date.ts @@ -92,11 +92,13 @@ const DURATION_SHORTCUT_REGEX = new RegExp(`^(\\d+)\\s*(${SHORTCUT_PATTERN})$`, const DURATION_FULL_REGEX = new RegExp(`^(\\d+)\\s*(${FULL_NAME_PATTERN})\\s*$`, 'i'); export function generateDurationShortcut(duration: Duration): string { - const keys = Object.keys(duration) as (keyof Duration)[]; - if (keys.length !== 1) return ''; - const key = keys[0]; - const value = duration[key]; - return `${value}${DURATION_SHORTCUTS[key]}`; + for (const [key, shortcut] of Object.entries(DURATION_SHORTCUTS)) { + const value = duration[key as keyof Duration]; + if (value) { + return `${value}${shortcut}`; + } + } + return ''; } export function parseTextDurationShortcut(text: string): Duration | undefined { @@ -171,11 +173,6 @@ function fixInvalidInterval( } export function parseTextInterval(text: string): NormalizedInterval | undefined { - const durationShortcut = parseTextDurationShortcut(text); - if (durationShortcut) { - return intervalToNowFromDuration(durationShortcut); - } - const textDates = text.split(DATE_SPLITTER_REGEX).map((token) => token.trim()); if (textDates.length !== 2) { return undefined; diff --git a/libs/react/ui/src/utils/format/date.test.ts b/libs/react/ui/src/utils/format/date.test.ts index 12c8720d..b91de406 100644 --- a/libs/react/ui/src/utils/format/date.test.ts +++ b/libs/react/ui/src/utils/format/date.test.ts @@ -5,7 +5,6 @@ import { endOfDay, type NormalizedInterval, startOfDay, - subDays, subHours, subYears, } from 'date-fns'; @@ -13,7 +12,7 @@ import {formatDateTimeRange} from './date'; describe('Format - Date', () => { describe('formatDateTimeRange', () => { - const now = new Date('2024-03-20T17:45:00Z'); + const now = new Date('2024-03-21T00:45:00Z'); let interval: NormalizedInterval; beforeEach(() => { @@ -30,36 +29,42 @@ describe('Format - Date', () => { }); it('displays only the hour of the second date if both on same day (en-US)', () => { - const result = formatDateTimeRange(interval); - expect(result).toEqual('Mar 20, 4:45\u2009\u2013\u20095:45\u202fPM'); + const result = formatDateTimeRange(interval, {timeZone: 'America/Los_Angeles'}); + expect(result).toEqual('Mar 20, 4:45 PM – Mar 20, 5:45 PM'); }); it('displays the date for the second day if not on same day (en-US)', () => { interval.end = addDays(interval.end, 1); - const result = formatDateTimeRange(interval); - expect(result).toEqual('Mar 20, 4:45\u202fPM\u2009\u2013\u2009Mar 21, 5:45\u202fPM'); + const result = formatDateTimeRange(interval, {timeZone: 'America/Los_Angeles'}); + expect(result).toEqual('Mar 20, 4:45 PM – Mar 21, 5:45 PM'); }); it('displays the year for the second day if not on same day (en-US)', () => { interval.end = addYears(interval.end, 1); - const result = formatDateTimeRange(interval); - expect(result).toEqual( - 'Mar 20, 2024, 4:45\u202fPM\u2009\u2013\u2009Mar 20, 2025, 5:45\u202fPM', - ); + const result = formatDateTimeRange(interval, {timeZone: 'America/Los_Angeles'}); + expect(result).toEqual('Mar 20, 2024, 4:45 PM – Mar 20, 2025, 5:45 PM'); }); it('displays the year when the dates are both in another year (en-US)', () => { interval.start = subYears(interval.start, 1); interval.end = subYears(interval.end, 1); - const result = formatDateTimeRange(interval); - expect(result).toEqual('Mar 20, 2023, 4:45\u2009\u2013\u20095:45\u202fPM'); + const result = formatDateTimeRange(interval, {timeZone: 'America/Los_Angeles'}); + expect(result).toEqual('Mar 20, 2023, 4:45 PM – Mar 20, 2023, 5:45 PM'); }); it('does not display time when interval if full days', () => { - interval.start = startOfDay(subDays(interval.start, 1)); - interval.end = endOfDay(interval.end); - const result = formatDateTimeRange(interval); - expect(result).toEqual('Mar 19\u2009\u2013\u200920'); + const startUTC = new Date('2024-03-19T00:00:00Z'); + const endUTC = new Date('2024-03-21T00:00:00Z'); + interval.start = startOfDay(startUTC); + interval.end = endOfDay(endUTC); + const result = formatDateTimeRange(interval, {timeZone: 'America/Los_Angeles'}); + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Los_Angeles', + month: 'short', + day: 'numeric', + }); + const expected = formatter.formatRange(interval.start, interval.end); + expect(result).toEqual(expected); }); }); }); diff --git a/libs/react/ui/test/setup.ts b/libs/react/ui/test/setup.ts index 97767071..db0b5104 100644 --- a/libs/react/ui/test/setup.ts +++ b/libs/react/ui/test/setup.ts @@ -1,9 +1,10 @@ -import {afterEach, expect} from '@shipfox/vitest/vi'; +import {afterEach, expect, vi} from '@shipfox/vitest/vi'; import * as extensions from '@testing-library/jest-dom/matchers'; import {cleanup} from '@testing-library/react'; expect.extend(extensions); afterEach(() => { + vi.clearAllMocks(); cleanup(); }); diff --git a/libs/react/ui/vitest.config.ts b/libs/react/ui/vitest.config.ts index 7ed14cd8..9167fcd5 100644 --- a/libs/react/ui/vitest.config.ts +++ b/libs/react/ui/vitest.config.ts @@ -28,6 +28,7 @@ export default defineConfig( ], test: { name: 'storybook', + include: ['src/**/*.stories.@(js|jsx|ts|tsx|mdx)'], browser: { enabled: true, headless: true, @@ -37,6 +38,16 @@ export default defineConfig( setupFiles: ['.storybook/vitest.setup.ts'], }, }, + { + extends: true, + test: { + name: 'unit', + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'], + exclude: ['**/*.stories.*', '**/node_modules/**', '**/dist/**'], + environment: 'jsdom', + setupFiles: ['./test/setup.ts'], + }, + }, ], }, },