diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect.tsx index 69566cd1d8..7616a7b01d 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect.tsx @@ -4,12 +4,12 @@ import { useGetDefaultFieldMetadataItemForFilter } from '@/object-record/advance import { useSetRecordFilterUsedInAdvancedFilterDropdownRow } from '@/object-record/advanced-filter/hooks/useSetRecordFilterUsedInAdvancedFilterDropdownRow'; import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext'; import { getAdvancedFilterAddFilterRuleSelectDropdownId } from '@/object-record/advanced-filter/utils/getAdvancedFilterAddFilterRuleSelectDropdownId'; +import { getDefaultAdvancedFilterOperand } from '@/object-record/advanced-filter/utils/getDefaultAdvancedFilterOperand'; import { useUpsertRecordFilterGroup } from '@/object-record/record-filter-group/hooks/useUpsertRecordFilterGroup'; import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup'; import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { getDefaultSubFieldNameForCompositeFilterableFieldType } from '@/object-record/record-filter/utils/getDefaultSubFieldNameForCompositeFilterableFieldType'; -import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; @@ -77,9 +77,7 @@ export const AdvancedFilterAddFilterRuleSelect = ({ id: v4(), fieldMetadataId: defaultFieldMetadataItemForFilter.id, type: filterType, - operand: getRecordFilterOperands({ - filterType, - })[0], + operand: getDefaultAdvancedFilterOperand({ filterType }), value: '', displayValue: '', recordFilterGroupId: recordFilterGroup.id, @@ -126,9 +124,7 @@ export const AdvancedFilterAddFilterRuleSelect = ({ id: v4(), fieldMetadataId: defaultFieldMetadataItemForFilter.id, type: filterType, - operand: getRecordFilterOperands({ - filterType, - })[0], + operand: getDefaultAdvancedFilterOperand({ filterType }), value: '', displayValue: '', recordFilterGroupId: newRecordFilterGroupId, diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField.ts index 669a0723b4..402b24a9bd 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField.ts +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField.ts @@ -1,4 +1,5 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { getDefaultAdvancedFilterOperand } from '@/object-record/advanced-filter/utils/getDefaultAdvancedFilterOperand'; import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue'; import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState'; import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState'; @@ -9,7 +10,6 @@ import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; -import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils'; @@ -65,20 +65,20 @@ export const useApplyAdvancedFilterSourceField = () => { const filterType = getFilterTypeFromFieldType(sourceFieldMetadataItem.type); - const firstOperand = getRecordFilterOperands({ + const defaultOperand = getDefaultAdvancedFilterOperand({ filterType, subFieldName: null, - })?.[0]; + }); - if (!isDefined(firstOperand)) { + if (!isDefined(defaultOperand)) { throw new Error(`No valid operand found for filter type: ${filterType}`); } - setSelectedOperandInDropdown(firstOperand); + setSelectedOperandInDropdown(defaultOperand); const { value, displayValue } = getInitialFilterValue( filterType, - firstOperand, + defaultOperand, ); const existingRecordFilter = currentRecordFilters.find( @@ -89,7 +89,7 @@ export const useApplyAdvancedFilterSourceField = () => { id: recordFilterId, fieldMetadataId: sourceFieldMetadataItem.id, displayValue, - operand: firstOperand, + operand: defaultOperand, value, recordFilterGroupId: existingRecordFilter?.recordFilterGroupId, positionInRecordFilterGroup: diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx index 837f928317..459d8e4c32 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx @@ -144,6 +144,7 @@ export const AdvancedFilterSidePanelValueFormInput = ({ defaultValue={recordFilter.value} onChange={handleRelativeDateFilterChange} readonly={readonly} + isDateTimeField={recordFilter.type === FieldMetadataType.DATE_TIME} /> ); } diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/utils/__tests__/getDefaultAdvancedFilterOperand.test.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/utils/__tests__/getDefaultAdvancedFilterOperand.test.ts new file mode 100644 index 0000000000..cca8afc66c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/utils/__tests__/getDefaultAdvancedFilterOperand.test.ts @@ -0,0 +1,25 @@ +import { getDefaultAdvancedFilterOperand } from '@/object-record/advanced-filter/utils/getDefaultAdvancedFilterOperand'; +import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; +import { ViewFilterOperand } from 'twenty-shared/types'; + +describe('getDefaultAdvancedFilterOperand', () => { + it('should default DATE fields to IS_RELATIVE', () => { + expect(getDefaultAdvancedFilterOperand({ filterType: 'DATE' })).toBe( + ViewFilterOperand.IS_RELATIVE, + ); + }); + + it('should default DATE_TIME fields to IS_RELATIVE', () => { + expect(getDefaultAdvancedFilterOperand({ filterType: 'DATE_TIME' })).toBe( + ViewFilterOperand.IS_RELATIVE, + ); + }); + + it('should keep the first available operand for non-date fields', () => { + const filterType = 'TEXT'; + + expect(getDefaultAdvancedFilterOperand({ filterType })).toBe( + getRecordFilterOperands({ filterType })[0], + ); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/utils/getDefaultAdvancedFilterOperand.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/utils/getDefaultAdvancedFilterOperand.ts new file mode 100644 index 0000000000..3ed6b00298 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/utils/getDefaultAdvancedFilterOperand.ts @@ -0,0 +1,29 @@ +import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; +import { + type FilterableAndTSVectorFieldType, + ViewFilterOperand as RecordFilterOperand, +} from 'twenty-shared/types'; + +export const getDefaultAdvancedFilterOperand = ({ + filterType, + subFieldName, +}: { + filterType: FilterableAndTSVectorFieldType; + subFieldName?: string | null; +}): RecordFilterOperand => { + const availableOperands = getRecordFilterOperands({ + filterType, + subFieldName, + }); + + const isDateFilterType = filterType === 'DATE' || filterType === 'DATE_TIME'; + + if ( + isDateFilterType && + availableOperands.includes(RecordFilterOperand.IS_RELATIVE) + ) { + return RecordFilterOperand.IS_RELATIVE; + } + + return availableOperands[0]; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts index 228dfcd21f..2c15d7e5ae 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts @@ -2,9 +2,13 @@ import { Temporal } from 'temporal-polyfill'; import { useGetDateFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue'; import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue'; +import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue'; +import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone'; import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter'; +import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants'; import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types'; const activeDatePickerOperands = [ @@ -17,12 +21,25 @@ export const useGetInitialFilterValue = () => { const { userTimezone } = useUserTimezone(); const { getDateFilterDisplayValue } = useGetDateFilterDisplayValue(); const { getDateTimeFilterDisplayValue } = useGetDateTimeFilterDisplayValue(); + const { getRelativeDateFilterWithUserTimezone } = + useGetRelativeDateFilterWithUserTimezone(); const getInitialFilterValue = ( newType: FilterableAndTSVectorFieldType, newOperand: RecordFilterOperand, alreadyExistingZonedDateTime?: Temporal.ZonedDateTime, ): Pick | Record => { + if (newOperand === RecordFilterOperand.IS_RELATIVE) { + const newRelativeDateFilter = getRelativeDateFilterWithUserTimezone( + DEFAULT_RELATIVE_DATE_FILTER_VALUE, + ); + + return { + value: stringifyRelativeDateFilter(newRelativeDateFilter), + displayValue: getRelativeDateDisplayValue(newRelativeDateFilter), + }; + } + switch (newType) { case 'DATE': { if (activeDatePickerOperands.includes(newOperand)) { @@ -60,10 +77,6 @@ export const useGetInitialFilterValue = () => { return { value, displayValue }; } - if (newOperand === RecordFilterOperand.IS_RELATIVE) { - return { value: '', displayValue: '' }; - } - break; } case 'BOOLEAN': { diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx index d6b7fbd7f6..221d3c8d88 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx @@ -1,15 +1,25 @@ +import { RelativeDateFilterRangeHint } from '@/object-record/record-field/ui/form-types/components/RelativeDateFilterRangeHint'; import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone'; import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader'; +import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter'; +import { styled } from '@linaria/react'; import { isNonEmptyString } from '@sniptt/guards'; import { useId } from 'react'; import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; import { type RelativeDateFilter, resolveRelativeDateFilterStringified, } from 'twenty-shared/utils'; +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[1]}; +`; + export type FormRelativeDatePickerProps = { label?: string; defaultValue?: string; @@ -33,6 +43,14 @@ export const FormRelativeDatePicker = ({ ? resolveRelativeDateFilterStringified(defaultValue) : DEFAULT_RELATIVE_DATE_FILTER_VALUE; + const effectiveRelativeDateFilterValue = isNonEmptyString(defaultValue) + ? defaultValue + : stringifyRelativeDateFilter( + getRelativeDateFilterWithUserTimezone( + DEFAULT_RELATIVE_DATE_FILTER_VALUE, + ), + ); + const handleValueChange = (newValue: RelativeDateFilter) => { const newValueWithTimezone = getRelativeDateFilterWithUserTimezone(newValue); @@ -41,16 +59,22 @@ export const FormRelativeDatePicker = ({ }; return ( - + + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/RelativeDateFilterRangeHint.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/RelativeDateFilterRangeHint.tsx new file mode 100644 index 0000000000..e6affaac2c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/RelativeDateFilterRangeHint.tsx @@ -0,0 +1,86 @@ +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { InputHint } from '@/ui/input/components/InputHint'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { Temporal } from 'temporal-polyfill'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; +import { + isDefined, + isSubDayRelativeDateFilterUnit, + resolveRelativeDateFilterStringified, + resolveRelativeDateTimeFilterStringified, +} from 'twenty-shared/utils'; + +type RelativeDateFilterRangeHintProps = { + relativeDateFilterValue?: string | null; + isDateTimeField?: boolean; +}; + +export const RelativeDateFilterRangeHint = ({ + relativeDateFilterValue, + isDateTimeField, +}: RelativeDateFilterRangeHintProps) => { + const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); + const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE; + + const formatPlainDate = (plainDate: Temporal.PlainDate) => + new Intl.DateTimeFormat(userLocale, { dateStyle: 'medium' }).format( + new Date(plainDate.year, plainDate.month - 1, plainDate.day), + ); + + const formatPlainDateRange = ( + start: Temporal.PlainDate, + endInclusive: Temporal.PlainDate, + ) => + start.equals(endInclusive) + ? formatPlainDate(start) + : `${formatPlainDate(start)} – ${formatPlainDate(endInclusive)}`; + + const formatZonedDateTime = (zonedDateTime: Temporal.ZonedDateTime) => + new Intl.DateTimeFormat(userLocale, { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: zonedDateTime.timeZoneId, + }).format(new Date(zonedDateTime.epochMilliseconds)); + + let rangeLabel: string | null = null; + + if (isDateTimeField === true) { + const resolved = resolveRelativeDateTimeFilterStringified( + relativeDateFilterValue, + ); + + if ( + isDefined(resolved) && + isDefined(resolved.start) && + isDefined(resolved.end) + ) { + rangeLabel = isSubDayRelativeDateFilterUnit(resolved.unit) + ? `${formatZonedDateTime(resolved.start)} → ${formatZonedDateTime(resolved.end)}` + : formatPlainDateRange( + resolved.start.toPlainDate(), + resolved.end.subtract({ nanoseconds: 1 }).toPlainDate(), + ); + } + } else { + const resolved = resolveRelativeDateFilterStringified( + relativeDateFilterValue, + ); + + if ( + isDefined(resolved) && + isDefined(resolved.start) && + isDefined(resolved.end) + ) { + rangeLabel = formatPlainDateRange( + Temporal.PlainDate.from(resolved.start), + Temporal.PlainDate.from(resolved.end).subtract({ days: 1 }), + ); + } + } + + if (!isDefined(rangeLabel)) { + return null; + } + + return {rangeLabel}; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePicker.tsx index c9159fa993..ae07239d32 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePicker.tsx @@ -14,18 +14,18 @@ import { DATE_PICKER_CONTAINER_WIDTH, StyledDatePickerContainer, } from '@/ui/input/components/internal/date/components/StyledDatePickerContainer'; -import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates'; +import { getRelativeDatePickerCalendarRange } from '@/ui/input/components/internal/date/utils/getRelativeDatePickerCalendarRange'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { t } from '@lingui/core/macro'; import 'react-datepicker/dist/react-datepicker.css'; -import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { Temporal } from 'temporal-polyfill'; import { type Nullable } from 'twenty-shared/types'; import { isDefined, turnJSDateToPlainDate, + turnPlainDateToShiftedDateInSystemTimeZone, type RelativeDateFilter, } from 'twenty-shared/utils'; import { IconCalendarX } from 'twenty-ui/icon'; @@ -97,16 +97,14 @@ type DatePickerProps = { }; // react-datepicker v9 types its props as a discriminated union keyed on -// selectsRange/selectsMultiple. We drive selectsMultiple dynamically, which TS -// cannot narrow to a single union branch, so collapse the discriminants to plain -// optionals (selectedDates is accepted but ignored by the library at runtime). +// selectsRange/selectsMultiple. We drive selectsRange dynamically (relative +// filters highlight a contiguous range), which TS cannot narrow to a single +// union branch, so collapse the discriminants to plain optionals. type DatePickerPropsType = Omit< ReactDatePickerLibProps, 'selectsRange' | 'selectsMultiple' | 'onChange' | 'formatMultipleDates' > & { selectsRange?: boolean; - selectsMultiple?: boolean; - selectedDates?: Date[]; onChange?: (date: Date | null) => void; }; @@ -137,7 +135,25 @@ export const DatePicker = ({ ? Temporal.PlainDate.from(plainDateString) : Temporal.Now.plainDateISO(); - const { userTimezone } = useUserTimezone(); + const relativeRangeStart = isRelative ? relativeDate?.start : undefined; + const relativeRangeEnd = isRelative ? relativeDate?.end : undefined; + + const relativeRangeStartPlainDate = isDefined(relativeRangeStart) + ? Temporal.PlainDate.from(relativeRangeStart) + : null; + + const relativeRangeEndPlainDate = isDefined(relativeRangeEnd) + ? Temporal.PlainDate.from(relativeRangeEnd).subtract({ days: 1 }) + : null; + + const { + startDate: relativeRangeStartDate, + endDate: relativeRangeEndDate, + rangeKey: relativeDateRangeKey, + } = getRelativeDatePickerCalendarRange( + relativeRangeStartPlainDate, + relativeRangeEndPlainDate, + ); const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown(); const { closeDropdown: closeDropdownYearSelect } = useCloseDropdown(); @@ -199,38 +215,13 @@ export const DatePicker = ({ handleClose?.(plainDatePicked.toString()); }; - const highlightedDates = - isRelative && isDefined(relativeDate?.end) && isDefined(relativeDate?.start) - ? getHighlightedDates( - Temporal.PlainDate.from(relativeDate.start), - Temporal.PlainDate.from(relativeDate.end).subtract({ days: 1 }), - userTimezone, - ) - : []; - - const dateAsDate = new Date(plainDate.toString()); - - const selectedDates = isRelative - ? highlightedDates.map((plainDate) => new Date(plainDate.toString())) - : isDefined(dateAsDate) - ? [dateAsDate] - : []; - const calendarStartDay = currentWorkspaceMember?.calendarStartDay === CalendarStartDay.SYSTEM ? CalendarStartDay[detectCalendarStartDay()] : (currentWorkspaceMember?.calendarStartDay ?? undefined); - const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - - const dateShiftedToISOString = plainDate - ?.toZonedDateTime(systemTimeZone) - .toInstant() - .toString(); - - const dateForDatePicker = isDefined(dateShiftedToISOString) - ? new Date(dateShiftedToISOString) - : null; + const dateForDatePicker = + turnPlainDateToShiftedDateInSystemTimeZone(plainDate); return ( @@ -264,16 +255,23 @@ export const DatePicker = ({ } > @@ -284,6 +282,11 @@ export const DatePicker = ({ amount={relativeDate?.amount} unit={relativeDate?.unit ?? 'DAY'} onChange={onRelativeDateChange} + calendarMonthDate={monthDate} + onPreviousMonth={decreaseMonth} + onNextMonth={increaseMonth} + prevMonthButtonDisabled={prevMonthButtonDisabled} + nextMonthButtonDisabled={nextMonthButtonDisabled} /> ) : ( ) } - onSelect={handleDateSelect} - selectsMultiple={isRelative} /> diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx index 6327d8aa72..061fd1cd40 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePicker.tsx @@ -2,6 +2,7 @@ import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLo import { convertFirstDayOfTheWeekToCalendarStartDayNumber, isDefined, + isSubDayRelativeDateFilterUnit, type RelativeDateFilter, } from 'twenty-shared/utils'; @@ -10,8 +11,9 @@ import { DateTimePickerHeader, } from '@/ui/input/components/internal/date/components/DateTimePickerHeader'; import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader'; +import { RelativeDateTimeRangeText } from '@/ui/input/components/internal/date/components/RelativeDateTimeRangeText'; import { StyledDatePickerContainer } from '@/ui/input/components/internal/date/components/StyledDatePickerContainer'; -import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates'; +import { getRelativeDatePickerCalendarRange } from '@/ui/input/components/internal/date/utils/getRelativeDatePickerCalendarRange'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { styled } from '@linaria/react'; import { t } from '@lingui/core/macro'; @@ -110,16 +112,14 @@ type DateTimePickerProps = { }; // react-datepicker v9 types its props as a discriminated union keyed on -// selectsRange/selectsMultiple. We drive selectsMultiple dynamically, which TS -// cannot narrow to a single union branch, so collapse the discriminants to plain -// optionals (selectedDates is accepted but ignored by the library at runtime). +// selectsRange/selectsMultiple. We drive selectsRange dynamically (relative +// filters highlight a contiguous range), which TS cannot narrow to a single +// union branch, so collapse the discriminants to plain optionals. type DatePickerPropsType = Omit< ReactDatePickerLibProps, 'selectsRange' | 'selectsMultiple' | 'onChange' | 'formatMultipleDates' > & { selectsRange?: boolean; - selectsMultiple?: boolean; - selectedDates?: Date[]; onChange?: (date: Date | null) => void; }; @@ -228,14 +228,29 @@ export const DateTimePicker = ({ handleClose?.(zonedDateTime); }; - const highlightedDates = - isRelative && isDefined(relativeDate?.end) && isDefined(relativeDate?.start) - ? getHighlightedDates( - relativeDate?.start.toPlainDate(), - relativeDate?.end.subtract({ days: 1 }).toPlainDate(), - timeZone ?? userTimezone, - ) - : []; + const relativeUnit = relativeDate?.unit ?? 'DAY'; + const relativeRangeStart = isRelative ? relativeDate?.start : undefined; + const relativeRangeEnd = isRelative ? relativeDate?.end : undefined; + + const isSubDayRelativeUnit = + isRelative === true && isSubDayRelativeDateFilterUnit(relativeUnit); + + const relativeRangeStartPlainDate = isDefined(relativeRangeStart) + ? relativeRangeStart.toPlainDate() + : null; + + const relativeRangeEndPlainDate = isDefined(relativeRangeEnd) + ? relativeRangeEnd.subtract({ nanoseconds: 1 }).toPlainDate() + : null; + + const { + startDate: relativeRangeStartDate, + endDate: relativeRangeEndDate, + rangeKey: relativeDateRangeKey, + } = getRelativeDatePickerCalendarRange( + relativeRangeStartPlainDate, + relativeRangeEndPlainDate, + ); const nonShiftedDateForReactDatePicker = new Date( dateToUse.toInstant().toString(), @@ -246,96 +261,116 @@ export const DateTimePicker = ({ timeZone ?? userTimezone, ); - const selectedDates = isRelative - ? highlightedDates.map((plainDate) => { - const date = new Date(); - - date.setDate(1); - - date.setFullYear(plainDate.year); - date.setMonth(plainDate.month - 1); - - date.setDate(plainDate.day); - - return date; - }) - : [shiftedDateForReactDatePicker]; - const calendarStartDayNumber = convertFirstDayOfTheWeekToCalendarStartDayNumber(userFirstDayOfTheWeek); return ( - - - - - - - - - - } - > - + {isSubDayRelativeUnit ? ( + <> + + {isDefined(relativeRangeStart) && isDefined(relativeRangeEnd) && ( + + )} + + ) : ( + + + + + + + + } - renderCustomHeader={({ - prevMonthButtonDisabled, - nextMonthButtonDisabled, - }) => - isRelative ? ( - - ) : ( - - ) - } - onSelect={handleDateSelect} - selectsMultiple={isRelative} - /> - + > + + isRelative ? ( + + ) : ( + + ) + } + /> + + )} {clearable && ( <> diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerCalendarNavigation.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerCalendarNavigation.tsx new file mode 100644 index 0000000000..4ae73429f5 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerCalendarNavigation.tsx @@ -0,0 +1,63 @@ +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { styled } from '@linaria/react'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; +import { IconChevronLeft, IconChevronRight } from 'twenty-ui/icon'; +import { LightIconButton } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledContainer = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[1]}; +`; + +const StyledMonthYearLabel = styled.span` + color: ${themeCssVariables.font.color.primary}; + flex: 1; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.medium}; + text-align: center; +`; + +type RelativeDatePickerCalendarNavigationProps = { + monthLabelDate: Date; + onPreviousMonth: () => void; + onNextMonth: () => void; + prevMonthButtonDisabled: boolean; + nextMonthButtonDisabled: boolean; +}; + +export const RelativeDatePickerCalendarNavigation = ({ + monthLabelDate, + onPreviousMonth, + onNextMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, +}: RelativeDatePickerCalendarNavigationProps) => { + const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); + const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE; + + const monthYearLabel = new Intl.DateTimeFormat(userLocale, { + month: 'long', + year: 'numeric', + }).format(monthLabelDate); + + return ( + + + {monthYearLabel} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx index f3a28765c1..0e7fdf9789 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDatePickerHeader.tsx @@ -1,14 +1,16 @@ import { Select } from '@/ui/input/components/Select'; import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { RelativeDatePickerCalendarNavigation } from '@/ui/input/components/internal/date/components/RelativeDatePickerCalendarNavigation'; import { RELATIVE_DATE_DIRECTION_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions'; -import { RELATIVE_DATETIME_UNITS_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateTimeUnitSelectOptions'; -import { RELATIVE_DATE_UNITS_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions'; +import { RELATIVE_DATETIME_UNITS } from '@/ui/input/components/internal/date/constants/RelativeDateTimeUnits'; +import { RELATIVE_DATE_UNITS } from '@/ui/input/components/internal/date/constants/RelativeDateUnits'; -import { t } from '@lingui/core/macro'; +import { plural, t } from '@lingui/core/macro'; import { styled } from '@linaria/react'; import { useState } from 'react'; import { type Nullable } from 'twenty-shared/types'; import { + assertUnreachable, isDefined, relativeDateFilterSchema, type RelativeDateFilter, @@ -18,14 +20,20 @@ import { import { themeCssVariables } from 'twenty-ui/theme-constants'; const StyledContainer = styled.div<{ noPadding: boolean }>` - align-items: center; display: flex; + flex-direction: column; gap: ${themeCssVariables.spacing[1]}; padding: ${({ noPadding }) => noPadding ? '0' : themeCssVariables.spacing[2]}; padding-bottom: 0; `; +const StyledControlsRow = styled.div` + align-items: center; + display: flex; + gap: ${themeCssVariables.spacing[1]}; +`; + type RelativeDatePickerHeaderProps = { instanceId: string; direction: RelativeDateFilterDirection; @@ -36,6 +44,11 @@ type RelativeDatePickerHeaderProps = { readonly?: boolean; unitDropdownWidth?: number; allowIntraDayUnits?: boolean; + calendarMonthDate?: Date; + onPreviousMonth?: () => void; + onNextMonth?: () => void; + prevMonthButtonDisabled?: boolean; + nextMonthButtonDisabled?: boolean; }; export const RelativeDatePickerHeader = ({ @@ -48,6 +61,11 @@ export const RelativeDatePickerHeader = ({ readonly, unitDropdownWidth, allowIntraDayUnits, + calendarMonthDate, + onPreviousMonth, + onNextMonth, + prevMonthButtonDisabled, + nextMonthButtonDisabled, }: RelativeDatePickerHeaderProps) => { const amountString = amount?.toString() ?? ''; @@ -57,88 +75,128 @@ export const RelativeDatePickerHeader = ({ const [draftAmountValue, setDraftAmountValue] = useState(amountTextValue); const isUnitPlural = isDefined(amount) && amount > 1 && direction !== 'THIS'; + const unitCount = isUnitPlural ? 2 : 1; + + const getUnitLabel = (unitToLabel: RelativeDateFilterUnit): string => { + switch (unitToLabel) { + case 'SECOND': + return plural(unitCount, { one: 'Second', other: 'Seconds' }); + case 'MINUTE': + return plural(unitCount, { one: 'Minute', other: 'Minutes' }); + case 'HOUR': + return plural(unitCount, { one: 'Hour', other: 'Hours' }); + case 'DAY': + return plural(unitCount, { one: 'Day', other: 'Days' }); + case 'WEEK': + return plural(unitCount, { one: 'Week', other: 'Weeks' }); + case 'MONTH': + return plural(unitCount, { one: 'Month', other: 'Months' }); + case 'QUARTER': + return plural(unitCount, { one: 'Quarter', other: 'Quarters' }); + case 'YEAR': + return plural(unitCount, { one: 'Year', other: 'Years' }); + default: + return assertUnreachable(unitToLabel); + } + }; + const unitOptionsSource = allowIntraDayUnits - ? RELATIVE_DATETIME_UNITS_SELECT_OPTIONS - : RELATIVE_DATE_UNITS_SELECT_OPTIONS; - const unitSelectOptions = unitOptionsSource.map((unit) => ({ - ...unit, - label: `${unit.label}${isUnitPlural ? 's' : ''}`, + ? RELATIVE_DATETIME_UNITS + : RELATIVE_DATE_UNITS; + const unitSelectOptions = unitOptionsSource.map((unitOption) => ({ + value: unitOption, + label: getUnitLabel(unitOption), })); return ( - { + if (amount === undefined && newDirection !== 'THIS') { + return; + } - if (draftAmountValue === '') { - setDraftAmountValue('1'); - } + if (draftAmountValue === '') { + setDraftAmountValue('1'); + } - if (newDirection === 'THIS') { - setDraftAmountValue(''); - } + if (newDirection === 'THIS') { + setDraftAmountValue(''); + } - onChange?.({ - direction: newDirection, - amount: amount, - unit: unit, - }); - }} - options={RELATIVE_DATE_DIRECTION_SELECT_OPTIONS} - fullWidth - disabled={readonly} - /> - { - const amountString = text.replace(/[^0-9]|^0+/g, ''); - setDraftAmountValue(amountString); + onChange?.({ + direction: newDirection, + amount: amount, + unit: unit, + }); + }} + options={RELATIVE_DATE_DIRECTION_SELECT_OPTIONS} + fullWidth + disabled={readonly} + /> + { + const amountString = text.replace(/[^0-9]|^0+/g, ''); + setDraftAmountValue(amountString); - const amount = parseInt(amountString); + const amount = parseInt(amountString); - const valueParts = { - direction, - amount, - unit, - }; + const valueParts = { + direction, + amount, + unit, + }; - if (relativeDateFilterSchema.safeParse(valueParts).success === true) { - onChange?.(valueParts); - } - }} - placeholder={amountInputPlaceholder} - disabled={direction === 'THIS' || readonly} - /> - { + if (direction !== 'THIS' && amount === undefined) { + return; + } - if (draftAmountValue === '' && direction !== 'THIS') { - setDraftAmountValue('1'); - } + if (draftAmountValue === '' && direction !== 'THIS') { + setDraftAmountValue('1'); + } - onChange?.({ - direction, - amount: amount, - unit: newUnit, - }); - }} - fullWidth - options={unitSelectOptions} - disabled={readonly} - dropdownWidth={unitDropdownWidth} - /> + onChange?.({ + direction, + amount: amount, + unit: newUnit, + }); + }} + fullWidth + options={unitSelectOptions} + disabled={readonly} + dropdownWidth={unitDropdownWidth} + /> + + {isDefined(calendarMonthDate) && + isDefined(onPreviousMonth) && + isDefined(onNextMonth) && ( + + )} ); }; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDateTimeRangeText.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDateTimeRangeText.tsx new file mode 100644 index 0000000000..1850baf7b0 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/RelativeDateTimeRangeText.tsx @@ -0,0 +1,56 @@ +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { styled } from '@linaria/react'; +import { type Temporal } from 'temporal-polyfill'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; +import { IconArrowDown } from 'twenty-ui/icon'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const StyledContainer = styled.div` + align-items: center; + color: ${themeCssVariables.font.color.tertiary}; + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing[2]}; + justify-content: center; + min-height: 96px; + padding: ${themeCssVariables.spacing[4]} ${themeCssVariables.spacing[2]}; + text-align: center; +`; + +const StyledBound = styled.span` + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.md}; + font-weight: ${themeCssVariables.font.weight.medium}; +`; + +type RelativeDateTimeRangeTextProps = { + start: Temporal.ZonedDateTime; + end: Temporal.ZonedDateTime; +}; + +export const RelativeDateTimeRangeText = ({ + start, + end, +}: RelativeDateTimeRangeTextProps) => { + const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState); + const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE; + + const formatter = new Intl.DateTimeFormat(userLocale, { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: start.timeZoneId, + }); + + return ( + + + {formatter.format(new Date(start.epochMilliseconds))} + + + + {formatter.format(new Date(end.epochMilliseconds))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/StyledDatePickerContainer.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/StyledDatePickerContainer.tsx index d237cafebb..3a95730f8d 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/StyledDatePickerContainer.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/StyledDatePickerContainer.tsx @@ -3,12 +3,6 @@ import { themeCssVariables } from 'twenty-ui/theme-constants'; export const DATE_PICKER_CONTAINER_WIDTH = 280; -// Shared react-datepicker overrides for every internal date picker -// (DatePicker, DateTimePicker, DatePickerWithoutCalendar). Keep the calendar -// grid styling in one place so day/day-name spacing stays consistent across all -// pickers instead of drifting between per-component copies. -// - calendarDisabled: dims and freezes the grid (used for relative date mode) -// - hideCalendar: hides the day grid, keeping only the month/year header export const StyledDatePickerContainer = styled.div<{ calendarDisabled?: boolean; hideCalendar?: boolean; @@ -240,7 +234,10 @@ export const StyledDatePickerContainer = styled.div<{ color: ${themeCssVariables.font.color.primary}; } - & .react-datepicker__day--selected { + & .react-datepicker__day--selected, + & .react-datepicker__day--in-range, + & .react-datepicker__day--range-start, + & .react-datepicker__day--range-end { background-color: ${themeCssVariables.color.blue}; color: ${themeCssVariables.background.primary}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/DateTimePicker.stories.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/DateTimePicker.stories.tsx index 5c3cc51e14..c2b71f342e 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/DateTimePicker.stories.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/DateTimePicker.stories.tsx @@ -9,6 +9,22 @@ const INITIAL_DATE = Temporal.ZonedDateTime.from( '2023-01-01T02:00:00+00:00[UTC]', ); +const RELATIVE_CALENDAR_RANGE = { + direction: 'PAST' as const, + amount: 1, + unit: 'MONTH' as const, + start: Temporal.ZonedDateTime.from('2022-12-01T00:00:00+00:00[UTC]'), + end: Temporal.ZonedDateTime.from('2023-01-01T00:00:00+00:00[UTC]'), +}; + +const RELATIVE_SUB_DAY_RANGE = { + direction: 'PAST' as const, + amount: 3, + unit: 'HOUR' as const, + start: Temporal.ZonedDateTime.from('2023-01-01T00:00:00+00:00[UTC]'), + end: Temporal.ZonedDateTime.from('2023-01-01T03:00:00+00:00[UTC]'), +}; + const DateTimePickerStory = () => { const [date, setDate] = useState(INITIAL_DATE); @@ -125,3 +141,29 @@ export const WithTimeInput: Story = { expect(timeInput).toBeInTheDocument(); }, }; + +export const RelativeWithCalendarRange: Story = { + render: () => ( + {}} + onRelativeDateChange={() => {}} + /> + ), +}; + +export const RelativeWithSubDayText: Story = { + render: () => ( + {}} + onRelativeDateChange={() => {}} + /> + ), +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateTimeUnitSelectOptions.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateTimeUnitSelectOptions.ts deleted file mode 100644 index d5cedd6694..0000000000 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateTimeUnitSelectOptions.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { RELATIVE_DATE_UNITS_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions'; -import { type RelativeDateFilterUnit } from 'twenty-shared/utils'; - -type RelativeDateUnitOption = { - value: RelativeDateFilterUnit; - label: string; -}; - -export const RELATIVE_DATETIME_UNITS_SELECT_OPTIONS: RelativeDateUnitOption[] = - [ - ...RELATIVE_DATE_UNITS_SELECT_OPTIONS, - { value: 'HOUR', label: 'Hour' }, - { value: 'MINUTE', label: 'Minute' }, - { value: 'SECOND', label: 'Second' }, - ]; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateTimeUnits.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateTimeUnits.ts new file mode 100644 index 0000000000..43572faa3f --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateTimeUnits.ts @@ -0,0 +1,9 @@ +import { RELATIVE_DATE_UNITS } from '@/ui/input/components/internal/date/constants/RelativeDateUnits'; +import { type RelativeDateFilterUnit } from 'twenty-shared/utils'; + +export const RELATIVE_DATETIME_UNITS: RelativeDateFilterUnit[] = [ + 'SECOND', + 'MINUTE', + 'HOUR', + ...RELATIVE_DATE_UNITS, +]; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions.ts deleted file mode 100644 index 6b89613093..0000000000 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { type RelativeDateFilterUnit } from 'twenty-shared/utils'; - -type RelativeDateUnitOption = { - value: RelativeDateFilterUnit; - label: string; -}; - -export const RELATIVE_DATE_UNITS_SELECT_OPTIONS: RelativeDateUnitOption[] = [ - { value: 'DAY', label: 'Day' }, - { value: 'WEEK', label: 'Week' }, - { value: 'MONTH', label: 'Month' }, - { value: 'QUARTER', label: 'Quarter' }, - { value: 'YEAR', label: 'Year' }, -]; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateUnits.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateUnits.ts new file mode 100644 index 0000000000..495f7e17f2 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateUnits.ts @@ -0,0 +1,9 @@ +import { type RelativeDateFilterUnit } from 'twenty-shared/utils'; + +export const RELATIVE_DATE_UNITS: RelativeDateFilterUnit[] = [ + 'DAY', + 'WEEK', + 'MONTH', + 'QUARTER', + 'YEAR', +]; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/__tests__/getHighlightedDates.test.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/__tests__/getHighlightedDates.test.ts deleted file mode 100644 index 782d105d57..0000000000 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/__tests__/getHighlightedDates.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates'; -import { Temporal } from 'temporal-polyfill'; - -jest.useFakeTimers().setSystemTime(new Date('2024-10-01T00:00:00.000Z')); - -const TIME_ZONE = 'UTC'; - -const getUTCPlainDateFromISO = (isoStringDate: string) => { - return Temporal.Instant.from(isoStringDate) - .toZonedDateTimeISO('UTC') - .toPlainDate(); -}; - -describe('getHighlightedDates', () => { - it('should should return one day if range is one day', () => { - const dateRange = { - start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), - end: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), - }; - expect( - getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE), - ).toEqual([getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z')]); - }); - - it('should should return two days if range is 2 days', () => { - const dateRange = { - start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), - end: getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'), - }; - expect( - getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE), - ).toEqual([ - getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), - getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'), - ]); - }); - - it('should should return 10 days if range is 10 days', () => { - const dateRange = { - start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), - end: getUTCPlainDateFromISO('2024-10-21T00:00:00.000Z'), - }; - expect( - getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE), - ).toEqual([ - getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), - getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'), - getUTCPlainDateFromISO('2024-10-14T00:00:00.000Z'), - getUTCPlainDateFromISO('2024-10-15T00:00:00.000Z'), - getUTCPlainDateFromISO('2024-10-16T00:00:00.000Z'), - getUTCPlainDateFromISO('2024-10-17T00:00:00.000Z'), - getUTCPlainDateFromISO('2024-10-18T00:00:00.000Z'), - getUTCPlainDateFromISO('2024-10-19T00:00:00.000Z'), - getUTCPlainDateFromISO('2024-10-20T00:00:00.000Z'), - getUTCPlainDateFromISO('2024-10-21T00:00:00.000Z'), - ]); - }); - - it('should should return empty if range is 10 days but out of range', () => { - const dateRange = { - start: getUTCPlainDateFromISO('2023-10-01T00:00:00.000Z'), - end: getUTCPlainDateFromISO('2023-10-10T00:00:00.000Z'), - }; - expect( - getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE), - ).toEqual([]); - }); -}); diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getHighlightedDates.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getHighlightedDates.ts deleted file mode 100644 index c11beeb833..0000000000 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getHighlightedDates.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Temporal } from 'temporal-polyfill'; -import { isPlainDateAfter, isPlainDateBefore } from 'twenty-shared/utils'; - -export const getHighlightedDates = ( - start: Temporal.PlainDate, - end: Temporal.PlainDate, - timeZone: string, -): Temporal.PlainDate[] => { - const highlightedDates: Temporal.PlainDate[] = []; - - const currentDate = Temporal.Now.zonedDateTimeISO(timeZone) - .startOfDay() - .toPlainDate(); - - const minDate = currentDate.subtract({ months: 2 }); - const maxDate = currentDate.add({ months: 2 }); - - const startDate = isPlainDateBefore(start, minDate) ? minDate : start; - const lastDate = isPlainDateAfter(end, maxDate) ? maxDate : end; - - let dateToHighlight = startDate; - - while (isPlainDateBefore(dateToHighlight, lastDate.add({ days: 1 }))) { - highlightedDates.push(dateToHighlight); - dateToHighlight = dateToHighlight.add({ days: 1 }); - } - - return highlightedDates; -}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getRelativeDatePickerCalendarRange.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getRelativeDatePickerCalendarRange.ts new file mode 100644 index 0000000000..1da9be9ca3 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getRelativeDatePickerCalendarRange.ts @@ -0,0 +1,27 @@ +import { type Temporal } from 'temporal-polyfill'; +import { + isDefined, + turnPlainDateToShiftedDateInSystemTimeZone, +} from 'twenty-shared/utils'; + +type RelativeDatePickerCalendarRange = { + startDate: Date | undefined; + endDate: Date | undefined; + rangeKey: string | undefined; +}; + +export const getRelativeDatePickerCalendarRange = ( + startPlainDate: Temporal.PlainDate | null, + endInclusivePlainDate: Temporal.PlainDate | null, +): RelativeDatePickerCalendarRange => ({ + startDate: isDefined(startPlainDate) + ? turnPlainDateToShiftedDateInSystemTimeZone(startPlainDate) + : undefined, + endDate: isDefined(endInclusivePlainDate) + ? turnPlainDateToShiftedDateInSystemTimeZone(endInclusivePlainDate) + : undefined, + rangeKey: + isDefined(startPlainDate) && isDefined(endInclusivePlainDate) + ? `${startPlainDate.toString()}-${endInclusivePlainDate.toString()}` + : undefined, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts index b54d6045b5..4f3b9cb929 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts @@ -5,7 +5,6 @@ import { addMonths, addSeconds, addWeeks, - addYears, subDays, subHours, subMinutes, @@ -22,8 +21,6 @@ import { parseAndEvaluateRelativeDateFilter, } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util'; -// TODO: this test should be in twenty-shared, and the logic that is duplicated both front end and back end, -// should be merged and properly refactored with Temporal to unify and simplify this bug-prone zone of the codebase. describe('Relative Date Filter Utils', () => { const now = new Date('2024-01-15T12:00:00Z'); // Monday, January 15, 2024 at noon @@ -315,6 +312,13 @@ describe('Relative Date Filter Utils', () => { relativeDateFilterValue, }), ).toBe(false); + + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-16T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(false); }); it('should return true for dates within the next N months', () => { @@ -351,39 +355,51 @@ describe('Relative Date Filter Utils', () => { relativeDateFilterValue, }), ).toBe(false); + + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-20T12:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(false); }); - it('should return true for dates within the next N years', () => { + it('should match the next calendar year, not a rolling 12 months', () => { const relativeDateFilterValue: RelativeDateFilter = { direction: 'NEXT', amount: 1, unit: 'YEAR', }; - // Dates within the next year should match expect( evaluateRelativeDateFilter({ - dateToCheck: addMonths(now, 6), + dateToCheck: new Date('2025-01-01T00:00:00Z'), relativeDateFilterValue, }), ).toBe(true); expect( evaluateRelativeDateFilter({ - dateToCheck: addMonths(now, 11), + dateToCheck: new Date('2025-06-15T12:00:00Z'), relativeDateFilterValue, }), ).toBe(true); - // Dates outside the range should not match expect( evaluateRelativeDateFilter({ - dateToCheck: addYears(now, 2), + dateToCheck: new Date('2024-07-15T12:00:00Z'), relativeDateFilterValue, }), ).toBe(false); expect( evaluateRelativeDateFilter({ - dateToCheck: subDays(now, 1), + dateToCheck: new Date('2024-12-31T23:59:59Z'), + relativeDateFilterValue, + }), + ).toBe(false); + + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2026-01-01T00:00:00Z'), relativeDateFilterValue, }), ).toBe(false); @@ -607,6 +623,13 @@ describe('Relative Date Filter Utils', () => { relativeDateFilterValue, }), ).toBe(false); + + expect( + evaluateRelativeDateFilter({ + dateToCheck: new Date('2024-01-15T06:00:00Z'), + relativeDateFilterValue, + }), + ).toBe(false); }); it('should return true for dates within the past N months', () => { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts index 7bc48c9123..d707655852 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts @@ -1,35 +1,11 @@ -import { isNonEmptyString } from '@sniptt/guards'; +import { Temporal } from 'temporal-polyfill'; import { - endOfDay, - endOfHour, - endOfMinute, - endOfMonth, - endOfQuarter, - endOfSecond, - endOfWeek, - endOfYear, - isWithinInterval, - startOfDay, - startOfHour, - startOfMinute, - startOfMonth, - startOfQuarter, - startOfSecond, - startOfWeek, - startOfYear, -} from 'date-fns'; -import { - addUnitToDateTime, - assertUnreachable, - getFirstDayOfTheWeekAsANumberForDateFNS, isDefined, type RelativeDateFilter, + resolveRelativeDateTimeFilter, safeParseRelativeDateFilterJsonStringified, - subUnitFromDateTime, } from 'twenty-shared/utils'; -// TODO: Merge this logic with resolveRelativeDateFilter in twenty-shared -// But it is not urgent since we force all workflow filters to be in UTC export const parseAndEvaluateRelativeDateFilter = ({ dateToCheck, relativeDateString, @@ -57,127 +33,31 @@ export const evaluateRelativeDateFilter = ({ dateToCheck: Date; relativeDateFilterValue: RelativeDateFilter; }): boolean => { - const now = new Date(); - - switch (relativeDateFilterValue.direction) { - case 'NEXT': - return evaluateNextDirection(dateToCheck, relativeDateFilterValue, now); - case 'THIS': - return evaluateThisDirection(dateToCheck, relativeDateFilterValue, now); - case 'PAST': - return evaluatePastDirection(dateToCheck, relativeDateFilterValue, now); - default: - return false; + if ( + relativeDateFilterValue.direction !== 'THIS' && + !isDefined(relativeDateFilterValue.amount) + ) { + return false; } + + const referenceZonedDateTime = Temporal.Instant.fromEpochMilliseconds( + new Date().getTime(), + ).toZonedDateTimeISO('UTC'); + + const { start, end } = resolveRelativeDateTimeFilter( + relativeDateFilterValue, + referenceZonedDateTime, + ); + + const dateToCheckInstant = Temporal.Instant.fromEpochMilliseconds( + dateToCheck.getTime(), + ); + + // Half-open [start, end): the period end is exclusive (it is the start of the + // next period), so a value that lands exactly on a boundary is only counted in + // one of two adjacent periods. + return ( + Temporal.Instant.compare(dateToCheckInstant, start.toInstant()) >= 0 && + Temporal.Instant.compare(dateToCheckInstant, end.toInstant()) < 0 + ); }; - -const evaluateNextDirection = ( - dateToCheck: Date, - relativeDateFilterValue: RelativeDateFilter, - now: Date, -): boolean => { - if (!isDefined(relativeDateFilterValue.amount)) { - return false; - } - - const { amount, unit } = relativeDateFilterValue; - - const endOfPeriod = addUnitToDateTime(now, amount, unit); - - if (!endOfPeriod) { - return false; - } - - return isWithinInterval(dateToCheck, { - start: now, - end: endOfPeriod, - }); -}; - -function evaluatePastDirection( - dateToCheck: Date, - relativeDateFilterValue: RelativeDateFilter, - now: Date, -): boolean { - if (!isDefined(relativeDateFilterValue.amount)) { - return false; - } - - const { amount, unit } = relativeDateFilterValue; - - const startOfPeriod = subUnitFromDateTime(now, amount, unit); - - if (!startOfPeriod) { - return false; - } - - return isWithinInterval(dateToCheck, { - start: startOfPeriod, - end: now, - }); -} - -function evaluateThisDirection( - dateToCheck: Date, - relativeDateValue: RelativeDateFilter, - now: Date, -): boolean { - const { unit } = relativeDateValue; - - const firstDayOfTheWeekAsANumberForDateFNS = isNonEmptyString( - relativeDateValue.firstDayOfTheWeek, - ) - ? getFirstDayOfTheWeekAsANumberForDateFNS( - relativeDateValue.firstDayOfTheWeek, - ) - : 1; - - switch (unit) { - case 'SECOND': - return isWithinInterval(dateToCheck, { - start: startOfSecond(now), - end: endOfSecond(now), - }); - case 'MINUTE': - return isWithinInterval(dateToCheck, { - start: startOfMinute(now), - end: endOfMinute(now), - }); - case 'HOUR': - return isWithinInterval(dateToCheck, { - start: startOfHour(now), - end: endOfHour(now), - }); - case 'DAY': - return isWithinInterval(dateToCheck, { - start: startOfDay(now), - end: endOfDay(now), - }); - case 'WEEK': - return isWithinInterval(dateToCheck, { - start: startOfWeek(now, { - weekStartsOn: firstDayOfTheWeekAsANumberForDateFNS, - }), - end: endOfWeek(now, { - weekStartsOn: firstDayOfTheWeekAsANumberForDateFNS, - }), - }); - case 'MONTH': - return isWithinInterval(dateToCheck, { - start: startOfMonth(now), - end: endOfMonth(now), - }); - case 'YEAR': - return isWithinInterval(dateToCheck, { - start: startOfYear(now), - end: endOfYear(now), - }); - case 'QUARTER': - return isWithinInterval(dateToCheck, { - start: startOfQuarter(now), - end: endOfQuarter(now), - }); - default: - return assertUnreachable(unit); - } -} diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateFilter.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateFilter.test.ts index 84fc9aa46e..659f836f25 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateFilter.test.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateFilter.test.ts @@ -1,5 +1,6 @@ import { Temporal } from 'temporal-polyfill'; +import { FirstDayOfTheWeek } from '@/types'; import { resolveRelativeDateFilter } from '@/utils/filter/dates/utils/resolveRelativeDateFilter'; describe('resolveRelativeDateFilter', () => { @@ -98,4 +99,81 @@ describe('resolveRelativeDateFilter', () => { expect(result.end).toBe('2024-07-01'); }); }); + + describe('calendar-aligned PAST/NEXT for week/month/year', () => { + it('should compute PAST 1 WEEK as the previous calendar week', () => { + const result = resolveRelativeDateFilter( + { direction: 'PAST', amount: 1, unit: 'WEEK' }, + referenceZdt, + ); + + expect(result.start).toBe('2024-03-04'); + expect(result.end).toBe('2024-03-11'); + }); + + it('should compute NEXT 1 WEEK as the next calendar week', () => { + const result = resolveRelativeDateFilter( + { direction: 'NEXT', amount: 1, unit: 'WEEK' }, + referenceZdt, + ); + + expect(result.start).toBe('2024-03-18'); + expect(result.end).toBe('2024-03-25'); + }); + + it('should respect firstDayOfTheWeek when aligning PAST 1 WEEK', () => { + const result = resolveRelativeDateFilter( + { + direction: 'PAST', + amount: 1, + unit: 'WEEK', + firstDayOfTheWeek: FirstDayOfTheWeek.SUNDAY, + }, + referenceZdt, + ); + + expect(result.start).toBe('2024-03-03'); + expect(result.end).toBe('2024-03-10'); + }); + + it('should compute PAST 1 MONTH as the previous calendar month', () => { + const result = resolveRelativeDateFilter( + { direction: 'PAST', amount: 1, unit: 'MONTH' }, + referenceZdt, + ); + + expect(result.start).toBe('2024-02-01'); + expect(result.end).toBe('2024-03-01'); + }); + + it('should compute NEXT 1 MONTH as the next calendar month', () => { + const result = resolveRelativeDateFilter( + { direction: 'NEXT', amount: 1, unit: 'MONTH' }, + referenceZdt, + ); + + expect(result.start).toBe('2024-04-01'); + expect(result.end).toBe('2024-05-01'); + }); + + it('should compute PAST 1 YEAR as the previous calendar year', () => { + const result = resolveRelativeDateFilter( + { direction: 'PAST', amount: 1, unit: 'YEAR' }, + referenceZdt, + ); + + expect(result.start).toBe('2023-01-01'); + expect(result.end).toBe('2024-01-01'); + }); + + it('should compute NEXT 1 YEAR as the next calendar year', () => { + const result = resolveRelativeDateFilter( + { direction: 'NEXT', amount: 1, unit: 'YEAR' }, + referenceZdt, + ); + + expect(result.start).toBe('2025-01-01'); + expect(result.end).toBe('2026-01-01'); + }); + }); }); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateTimeFilter.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateTimeFilter.test.ts index 029f22ee4a..28f4925a3a 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateTimeFilter.test.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/resolveRelativeDateTimeFilter.test.ts @@ -12,8 +12,8 @@ describe('resolveRelativeDateTimeFilter', () => { referenceZdt, ); - expect(result.start).toEqual(referenceZdt); - expect(result.end?.hour).toBe(15); + expect(result.start?.hour).toBe(13); + expect(result.end?.hour).toBe(16); }); it('should compute for DAY unit', () => { @@ -105,4 +105,64 @@ describe('resolveRelativeDateTimeFilter', () => { expect(result.end?.month).toBe(4); }); }); + + describe('calendar-aligned PAST/NEXT for all units', () => { + it('should compute PAST 1 WEEK as the previous calendar week', () => { + const result = resolveRelativeDateTimeFilter( + { direction: 'PAST', amount: 1, unit: 'WEEK' }, + referenceZdt, + ); + + expect(result.start?.month).toBe(3); + expect(result.start?.day).toBe(4); + expect(result.end?.month).toBe(3); + expect(result.end?.day).toBe(11); + }); + + it('should compute NEXT 1 WEEK as the next calendar week', () => { + const result = resolveRelativeDateTimeFilter( + { direction: 'NEXT', amount: 1, unit: 'WEEK' }, + referenceZdt, + ); + + expect(result.start?.day).toBe(18); + expect(result.end?.day).toBe(25); + }); + + it('should compute PAST 1 MONTH as the previous calendar month', () => { + const result = resolveRelativeDateTimeFilter( + { direction: 'PAST', amount: 1, unit: 'MONTH' }, + referenceZdt, + ); + + expect(result.start?.month).toBe(2); + expect(result.start?.day).toBe(1); + expect(result.end?.month).toBe(3); + expect(result.end?.day).toBe(1); + }); + + it('should compute NEXT 1 MONTH as the next calendar month', () => { + const result = resolveRelativeDateTimeFilter( + { direction: 'NEXT', amount: 1, unit: 'MONTH' }, + referenceZdt, + ); + + expect(result.start?.month).toBe(4); + expect(result.start?.day).toBe(1); + expect(result.end?.month).toBe(5); + expect(result.end?.day).toBe(1); + }); + + it('should align PAST 1 HOUR to the previous clock hour', () => { + const result = resolveRelativeDateTimeFilter( + { direction: 'PAST', amount: 1, unit: 'HOUR' }, + Temporal.ZonedDateTime.from('2024-03-15T12:30:45[UTC]'), + ); + + expect(result.start?.hour).toBe(11); + expect(result.start?.minute).toBe(0); + expect(result.end?.hour).toBe(12); + expect(result.end?.minute).toBe(0); + }); + }); }); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/isSubDayRelativeDateFilterUnit.ts b/packages/twenty-shared/src/utils/filter/dates/utils/isSubDayRelativeDateFilterUnit.ts new file mode 100644 index 0000000000..b753120864 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/isSubDayRelativeDateFilterUnit.ts @@ -0,0 +1,11 @@ +import { type RelativeDateFilterUnit } from '@/utils/filter/dates/utils/relativeDateFilterUnitSchema'; + +const SUB_DAY_RELATIVE_DATE_FILTER_UNITS: readonly RelativeDateFilterUnit[] = [ + 'SECOND', + 'MINUTE', + 'HOUR', +]; + +export const isSubDayRelativeDateFilterUnit = ( + unit: RelativeDateFilterUnit, +): boolean => SUB_DAY_RELATIVE_DATE_FILTER_UNITS.includes(unit); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilter.ts b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilter.ts index b228322703..091d74b81f 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilter.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilter.ts @@ -6,7 +6,6 @@ import { subUnitFromZonedDateTime } from '@/utils/filter/dates/utils/subUnitFrom import { isDefined } from 'class-validator'; import { type Temporal } from 'temporal-polyfill'; -// TODO: use this in workflows where there is duplicated logic export const resolveRelativeDateFilter = ( relativeDateFilter: RelativeDateFilter, referenceTodayZonedDateTime: Temporal.ZonedDateTime, @@ -19,52 +18,22 @@ export const resolveRelativeDateFilter = ( throw new Error('Amount is required'); } - if (unit === 'QUARTER') { - const startOfCurrentQuarter = getPeriodStart( - referenceTodayZonedDateTime, - 'QUARTER', - firstDayOfTheWeek, - ); + const startOfNextPeriod = getNextPeriodStart( + referenceTodayZonedDateTime, + unit, + firstDayOfTheWeek, + ); - const startOfNextPeriod = addUnitToZonedDateTime( - startOfCurrentQuarter, - 'QUARTER', - 1, - ); - - const endOfNextPeriod = addUnitToZonedDateTime( - startOfNextPeriod, - 'QUARTER', - amount, - ); - - const start = startOfNextPeriod.toPlainDate().toString(); - const end = endOfNextPeriod.toPlainDate().toString(); - - return { - ...relativeDateFilter, - start, - end, - }; - } - - const startOfNextDay = referenceTodayZonedDateTime - .startOfDay() - .add({ days: 1 }); - - const startOfNextPeriod = addUnitToZonedDateTime( - startOfNextDay, + const endOfNextPeriod = addUnitToZonedDateTime( + startOfNextPeriod, unit, amount, ); - const start = startOfNextDay.toPlainDate().toString(); - const end = startOfNextPeriod?.toPlainDate().toString(); - return { ...relativeDateFilter, - start, - end, + start: startOfNextPeriod.toPlainDate().toString(), + end: endOfNextPeriod.toPlainDate().toString(), }; } case 'PAST': { @@ -72,44 +41,22 @@ export const resolveRelativeDateFilter = ( throw new Error('Amount is required'); } - if (unit === 'QUARTER') { - const startOfCurrentQuarter = getPeriodStart( - referenceTodayZonedDateTime, - 'QUARTER', - firstDayOfTheWeek, - ); + const startOfCurrentPeriod = getPeriodStart( + referenceTodayZonedDateTime, + unit, + firstDayOfTheWeek, + ); - const startOfPastPeriod = subUnitFromZonedDateTime( - startOfCurrentQuarter, - 'QUARTER', - amount, - ); - - const start = startOfPastPeriod.toPlainDate().toString(); - const end = startOfCurrentQuarter.toPlainDate().toString(); - - return { - ...relativeDateFilter, - start, - end, - }; - } - - const startOfDay = referenceTodayZonedDateTime.startOfDay(); - - const startOfNextPeriod = subUnitFromZonedDateTime( - startOfDay, + const startOfPastPeriod = subUnitFromZonedDateTime( + startOfCurrentPeriod, unit, amount, ); - const start = startOfNextPeriod?.toPlainDate().toString(); - const end = startOfDay.toPlainDate().toString(); - return { ...relativeDateFilter, - start, - end, + start: startOfPastPeriod.toPlainDate().toString(), + end: startOfCurrentPeriod.toPlainDate().toString(), }; } case 'THIS': { diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilter.ts b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilter.ts index 6fb9748082..644e91c0e4 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilter.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilter.ts @@ -12,78 +12,40 @@ export const resolveRelativeDateTimeFilter = ( ) => { const { direction, amount, unit, firstDayOfTheWeek } = relativeDateFilter; - const isSubDayUnit = ['SECOND', 'MINUTE', 'HOUR'].includes(unit); - switch (direction) { case 'NEXT': { if (!isDefined(amount)) { throw new Error('Amount is required'); } - if (unit === 'QUARTER') { - const startOfNextQuarter = getNextPeriodStart( - referenceZonedDateTime, - 'QUARTER', - ); + const startOfNextPeriod = getNextPeriodStart( + referenceZonedDateTime, + unit, + firstDayOfTheWeek, + ); - return { - ...relativeDateFilter, - start: startOfNextQuarter, - end: addUnitToZonedDateTime(startOfNextQuarter, unit, amount), - }; - } - - if (isSubDayUnit) { - return { - ...relativeDateFilter, - start: referenceZonedDateTime, - end: addUnitToZonedDateTime(referenceZonedDateTime, unit, amount), - }; - } else { - const startOfNextDay = referenceZonedDateTime - .startOfDay() - .add({ days: 1 }); - - return { - ...relativeDateFilter, - start: startOfNextDay, - end: addUnitToZonedDateTime(startOfNextDay, unit, amount), - }; - } + return { + ...relativeDateFilter, + start: startOfNextPeriod, + end: addUnitToZonedDateTime(startOfNextPeriod, unit, amount), + }; } case 'PAST': { if (!isDefined(amount)) { throw new Error('Amount is required'); } - if (unit === 'QUARTER') { - const startOfCurrentQuarter = getPeriodStart( - referenceZonedDateTime, - 'QUARTER', - ); + const startOfCurrentPeriod = getPeriodStart( + referenceZonedDateTime, + unit, + firstDayOfTheWeek, + ); - return { - ...relativeDateFilter, - start: subUnitFromZonedDateTime(startOfCurrentQuarter, unit, amount), - end: startOfCurrentQuarter, - }; - } - - if (isSubDayUnit) { - return { - ...relativeDateFilter, - start: subUnitFromZonedDateTime(referenceZonedDateTime, unit, amount), - end: referenceZonedDateTime, - }; - } else { - const startOfDay = referenceZonedDateTime.startOfDay(); - - return { - ...relativeDateFilter, - start: subUnitFromZonedDateTime(startOfDay, unit, amount), - end: startOfDay, - }; - } + return { + ...relativeDateFilter, + start: subUnitFromZonedDateTime(startOfCurrentPeriod, unit, amount), + end: startOfCurrentPeriod, + }; } case 'THIS': return { diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 22aeda3b9a..7bbd88ecc4 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -87,6 +87,7 @@ export { getNextPeriodStart, } from './filter/dates/utils/getNextPeriodStart'; export { getPeriodStart } from './filter/dates/utils/getPeriodStart'; +export { isSubDayRelativeDateFilterUnit } from './filter/dates/utils/isSubDayRelativeDateFilterUnit'; export { relativeDateFilterAmountSchema } from './filter/dates/utils/relativeDateFilterAmountSchema'; export type { RelativeDateFilterDirection } from './filter/dates/utils/relativeDateFilterDirectionSchema'; export { relativeDateFilterDirectionSchema } from './filter/dates/utils/relativeDateFilterDirectionSchema';