diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx
index 7479c500a7c..2ce6f8dd0ae 100644
--- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx
+++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx
@@ -6,13 +6,13 @@ import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropd
import { DateTimePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { computeVariableDateViewFilterValue } from '@/views/view-filter-value/utils/computeVariableDateViewFilterValue';
+import { resolveDateViewFilterValue } from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
+import { useState } from 'react';
import {
- resolveDateViewFilterValue,
+ ViewFilterOperand,
type VariableDateViewFilterValueDirection,
type VariableDateViewFilterValueUnit,
-} from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
-import { useState } from 'react';
-import { ViewFilterOperand } from 'twenty-shared/types';
+} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts
index a861da31caf..862910bd301 100644
--- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts
+++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts
@@ -14,7 +14,7 @@ import { computeVariableDateViewFilterValue } from '@/views/view-filter-value/ut
import {
type VariableDateViewFilterValueDirection,
type VariableDateViewFilterValueUnit,
-} from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
+} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const useApplyObjectFilterDropdownOperand = () => {
diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts
index 337145f3bc9..ca3c992b2c9 100644
--- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts
+++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts
@@ -1,8 +1,8 @@
+import { plural } from 'pluralize';
import {
type VariableDateViewFilterValueDirection,
type VariableDateViewFilterValueUnit,
-} from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
-import { plural } from 'pluralize';
+} from 'twenty-shared/types';
import { capitalize } from 'twenty-shared/utils';
export const getRelativeDateDisplayValue = (
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
new file mode 100644
index 00000000000..4f66819cf2a
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormRelativeDatePicker.tsx
@@ -0,0 +1,43 @@
+import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader';
+
+import { isNonEmptyString, isString } from '@sniptt/guards';
+import {
+ DEFAULT_RELATIVE_DATE_VALUE,
+ type VariableDateViewFilterValue,
+} from 'twenty-shared/types';
+import { safeParseRelativeDateFilterValue } from 'twenty-shared/utils';
+import { type JsonValue } from 'type-fest';
+
+export type FormRelativeDatePickerProps = {
+ label?: string;
+ defaultValue?: string;
+ onChange: (value: JsonValue) => void;
+ readonly?: boolean;
+};
+
+export const FormRelativeDatePicker = ({
+ defaultValue,
+ onChange,
+ readonly,
+}: FormRelativeDatePickerProps) => {
+ const value =
+ isString(defaultValue) && isNonEmptyString(defaultValue)
+ ? safeParseRelativeDateFilterValue(defaultValue)
+ : DEFAULT_RELATIVE_DATE_VALUE;
+
+ const handleValueChange = (newValue: VariableDateViewFilterValue) => {
+ onChange(JSON.stringify(newValue));
+ };
+
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx
index 43d7f8ee6f8..58314010ae4 100644
--- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx
+++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx
@@ -14,14 +14,14 @@ import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/co
import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { UserContext } from '@/users/contexts/UserContext';
-import {
- type VariableDateViewFilterValueDirection,
- type VariableDateViewFilterValueUnit,
-} from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import 'react-datepicker/dist/react-datepicker.css';
import { useRecoilValue } from 'recoil';
+import {
+ type VariableDateViewFilterValueDirection,
+ type VariableDateViewFilterValueUnit,
+} from 'twenty-shared/types';
import { IconCalendarX } from 'twenty-ui/display';
import {
MenuItemLeftContent,
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 80f00504468..fcc0d7dc42d 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
@@ -2,20 +2,20 @@ import { Select } from '@/ui/input/components/Select';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { RELATIVE_DATE_DIRECTION_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions';
import { RELATIVE_DATE_UNITS_SELECT_OPTIONS } from '@/ui/input/components/internal/date/constants/RelativeDateUnitSelectOptions';
+import { variableDateViewFilterValuePartsSchema } from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
import {
type VariableDateViewFilterValueDirection,
type VariableDateViewFilterValueUnit,
- variableDateViewFilterValuePartsSchema,
-} from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
+} from 'twenty-shared/types';
import styled from '@emotion/styled';
import { useEffect, useState } from 'react';
-const StyledContainer = styled.div`
+const StyledContainer = styled.div<{ noPadding: boolean }>`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
- padding: ${({ theme }) => theme.spacing(2)};
+ padding: ${({ theme, noPadding }) => (noPadding ? '0' : theme.spacing(2))};
padding-bottom: 0;
`;
@@ -28,6 +28,9 @@ type RelativeDatePickerHeaderProps = {
amount?: number;
unit: VariableDateViewFilterValueUnit;
}) => void;
+ isFormField?: boolean;
+ readonly?: boolean;
+ unitDropdownWidth?: number;
};
export const RelativeDatePickerHeader = (
@@ -55,7 +58,7 @@ export const RelativeDatePickerHeader = (
}));
return (
-
+
);
diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions.ts
index 64207805459..b378e36dad7 100644
--- a/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions.ts
+++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/constants/RelativeDateDirectionSelectOptions.ts
@@ -1,4 +1,4 @@
-import { type VariableDateViewFilterValueDirection } from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
+import { type VariableDateViewFilterValueDirection } from 'twenty-shared/types';
type RelativeDateDirectionOption = {
value: VariableDateViewFilterValueDirection;
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
index 7a62cb2820e..6377cae3efd 100644
--- 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
@@ -1,4 +1,4 @@
-import { type VariableDateViewFilterValueUnit } from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
+import { type VariableDateViewFilterValueUnit } from 'twenty-shared/types';
type RelativeDateUnit = {
value: VariableDateViewFilterValueUnit;
diff --git a/packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts
index 34a91eec7ea..d77b30ffa4e 100644
--- a/packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts
+++ b/packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts
@@ -1,7 +1,7 @@
import {
type VariableDateViewFilterValueDirection,
type VariableDateViewFilterValueUnit,
-} from '@/views/view-filter-value/utils/resolveDateViewFilterValue';
+} from 'twenty-shared/types';
export const computeVariableDateViewFilterValue = (
direction: VariableDateViewFilterValueDirection,
diff --git a/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveDateViewFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveDateViewFilterValue.ts
index be61429fad5..32735ff0477 100644
--- a/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveDateViewFilterValue.ts
+++ b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveDateViewFilterValue.ts
@@ -19,7 +19,6 @@ import {
subYears,
} from 'date-fns';
import { ViewFilterOperand } from 'twenty-shared/types';
-
import { z } from 'zod';
const variableDateViewFilterValueDirectionSchema = z.enum([
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx
index aea33ee13f8..1a8c02be77c 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx
@@ -33,6 +33,7 @@ export const WorkflowStepFilterOperandSelect = ({
stepFilterToUpsert: {
...stepFilter,
operand,
+ value: '',
},
});
};
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx
index 335f8f881c0..0a5bc7892ea 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx
@@ -1,9 +1,11 @@
import { configurableViewFilterOperands } from '@/object-record/object-filter-dropdown/utils/configurableViewFilterOperands';
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput';
+import { FormRelativeDatePicker } from '@/object-record/record-field/ui/form-types/components/FormRelativeDatePicker';
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
+
import { WorkflowStepFilterValueCompositeInput } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueCompositeInput';
import { useGetFilterFieldMetadataItem } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useGetFilterFieldMetadataItem';
import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings';
@@ -12,7 +14,11 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components
import { useLingui } from '@lingui/react/macro';
import { isObject, isString } from '@sniptt/guards';
import { useContext } from 'react';
-import { FieldMetadataType, type StepFilter } from 'twenty-shared/types';
+import {
+ FieldMetadataType,
+ ViewFilterOperand,
+ type StepFilter,
+} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type JsonValue } from 'type-fest';
@@ -105,6 +111,10 @@ export const WorkflowStepFilterValueInput = ({
selectedFieldMetadataItem?.name === 'id' &&
isDefined(objectMetadataItem?.nameSingular);
+ const isDateField =
+ variableType === FieldMetadataType.DATE_TIME ||
+ variableType === FieldMetadataType.DATE;
+
if (isFullRecord) {
return (
+ );
+ }
+
const field = {
type: variableType as FieldMetadataType,
label: '',
diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts
index 9c4b913ffd6..f48d7d58f67 100644
--- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts
+++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts
@@ -43,15 +43,7 @@ export const FILTER_OPERANDS_MAP = {
ViewFilterOperand.IsToday,
ViewFilterOperand.IsBefore,
ViewFilterOperand.IsAfter,
- ...emptyOperands,
- ],
- DATE: [
- ViewFilterOperand.Is,
- ViewFilterOperand.IsInPast,
- ViewFilterOperand.IsInFuture,
- ViewFilterOperand.IsToday,
- ViewFilterOperand.IsBefore,
- ViewFilterOperand.IsAfter,
+ ViewFilterOperand.IsRelative,
...emptyOperands,
],
RATING: [ViewFilterOperand.Is, ViewFilterOperand.IsNot, ...emptyOperands],
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
new file mode 100644
index 00000000000..28272621e4e
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts
@@ -0,0 +1,590 @@
+import {
+ addDays,
+ addMonths,
+ addWeeks,
+ addYears,
+ subDays,
+ subMonths,
+ subWeeks,
+ subYears,
+} from 'date-fns';
+import { type VariableDateViewFilterValue } from 'twenty-shared/types';
+
+import {
+ evaluateRelativeDateFilter,
+ parseAndEvaluateRelativeDateFilter,
+} from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util';
+
+describe('Relative Date Filter Utils', () => {
+ const now = new Date('2024-01-15T12:00:00Z'); // Monday, January 15, 2024 at noon
+
+ beforeEach(() => {
+ // Mock Date constructor to return a fixed date for consistent testing
+ jest.useFakeTimers();
+ jest.setSystemTime(now);
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('parseAndEvaluateRelativeDateFilter', () => {
+ describe('Edge cases', () => {
+ it('should handle invalid JSON gracefully', () => {
+ const invalidJson = 'invalid json';
+
+ expect(
+ parseAndEvaluateRelativeDateFilter({
+ dateToCheck: now,
+ relativeDateString: invalidJson,
+ }),
+ ).toBe(false);
+ });
+
+ it('should handle missing direction gracefully', () => {
+ const relativeDateString = JSON.stringify({
+ amount: 1,
+ unit: 'DAY',
+ });
+
+ expect(
+ parseAndEvaluateRelativeDateFilter({
+ dateToCheck: now,
+ relativeDateString,
+ }),
+ ).toBe(false);
+ });
+
+ it('should handle missing unit gracefully', () => {
+ const relativeDateString = JSON.stringify({
+ direction: 'NEXT',
+ amount: 1,
+ });
+
+ expect(
+ parseAndEvaluateRelativeDateFilter({
+ dateToCheck: now,
+ relativeDateString,
+ }),
+ ).toBe(false);
+ });
+
+ it('should handle unknown direction gracefully', () => {
+ const relativeDateString = JSON.stringify({
+ direction: 'UNKNOWN',
+ amount: 1,
+ unit: 'DAY',
+ });
+
+ expect(
+ parseAndEvaluateRelativeDateFilter({
+ dateToCheck: now,
+ relativeDateString,
+ }),
+ ).toBe(false);
+ });
+
+ it('should handle unknown unit gracefully', () => {
+ const relativeDateString = JSON.stringify({
+ direction: 'NEXT',
+ amount: 1,
+ unit: 'UNKNOWN',
+ });
+
+ expect(
+ parseAndEvaluateRelativeDateFilter({
+ dateToCheck: now,
+ relativeDateString,
+ }),
+ ).toBe(false);
+ });
+ });
+ });
+
+ describe('evaluateRelativeDateFilter', () => {
+ describe('NEXT direction', () => {
+ it('should return true for dates within the next N days', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'NEXT',
+ amount: 3,
+ unit: 'DAY',
+ };
+
+ // Dates within the next 3 days should match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addDays(now, 2),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addDays(now, 3),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Dates outside the range should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addDays(now, 4),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return true for dates within the next N weeks', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'NEXT',
+ amount: 2,
+ unit: 'WEEK',
+ };
+
+ // Dates within the next 2 weeks should match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addWeeks(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addDays(now, 10),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Dates outside the range should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addWeeks(now, 3),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return true for dates within the next N months', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'NEXT',
+ amount: 2,
+ unit: 'MONTH',
+ };
+
+ // Dates within the next 2 months should match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addMonths(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addDays(now, 45),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Dates outside the range should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addMonths(now, 3),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return true for dates within the next N years', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'NEXT',
+ amount: 1,
+ unit: 'YEAR',
+ };
+
+ // Dates within the next year should match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addMonths(now, 6),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addMonths(now, 11),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Dates outside the range should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addYears(now, 2),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return false when amount is undefined', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'NEXT',
+ unit: 'DAY',
+ };
+
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+ });
+
+ describe('PAST direction', () => {
+ it('should return true for dates within the past N days', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'PAST',
+ amount: 3,
+ unit: 'DAY',
+ };
+
+ // Dates within the past 3 days should match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subDays(now, 2),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subDays(now, 3),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Dates outside the range should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subDays(now, 4),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return true for dates within the past N weeks', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'PAST',
+ amount: 2,
+ unit: 'WEEK',
+ };
+
+ // Dates within the past 2 weeks should match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subWeeks(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subDays(now, 10),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Dates outside the range should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subWeeks(now, 3),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return true for dates within the past N months', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'PAST',
+ amount: 2,
+ unit: 'MONTH',
+ };
+
+ // Dates within the past 2 months should match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subMonths(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subDays(now, 45),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Dates outside the range should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subMonths(now, 3),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return true for dates within the past N years', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'PAST',
+ amount: 1,
+ unit: 'YEAR',
+ };
+
+ // Dates within the past year should match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subMonths(now, 6),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subMonths(now, 11),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Dates outside the range should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subYears(now, 2),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: addDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return false when amount is undefined', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'PAST',
+ unit: 'DAY',
+ };
+
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: subDays(now, 1),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+ });
+
+ describe('THIS direction', () => {
+ it('should return true for dates within this day', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'THIS',
+ unit: 'DAY',
+ };
+
+ // Same day should match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: now,
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2024-01-15T08:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Different days should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2024-01-14T20:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return true for dates within this week', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'THIS',
+ unit: 'WEEK',
+ };
+
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: now,
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2024-01-14T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2024-01-20T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Different weeks should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2024-01-13T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2024-01-21T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return true for dates within this month', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'THIS',
+ unit: 'MONTH',
+ };
+
+ // Same month should match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: now,
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2024-01-01T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2024-01-31T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Different months should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2023-12-31T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2024-02-01T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+
+ it('should return true for dates within this year', () => {
+ const relativeDateFilterValue: VariableDateViewFilterValue = {
+ direction: 'THIS',
+ unit: 'YEAR',
+ };
+
+ // Same year should match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: now,
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2024-01-01T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2024-12-31T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(true);
+
+ // Different years should not match
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2023-12-31T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ expect(
+ evaluateRelativeDateFilter({
+ dateToCheck: new Date('2025-01-01T12:00:00Z'),
+ relativeDateFilterValue,
+ }),
+ ).toBe(false);
+ });
+ });
+ });
+});
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts
index d74ad030868..8638372bd2a 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts
@@ -5,6 +5,8 @@ import {
ViewFilterOperand,
} from 'twenty-shared/types';
+import { parseAndEvaluateRelativeDateFilter } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util';
+
type ResolvedFilter = Omit & {
rightOperand: unknown;
leftOperand: unknown;
@@ -153,11 +155,13 @@ function evaluateBooleanFilter(filter: ResolvedFilter): boolean {
function evaluateDateFilter(filter: ResolvedFilter): boolean {
const dateLeftValue = new Date(String(filter.leftOperand));
- const dateRightValue = new Date(String(filter.rightOperand));
switch (filter.operand) {
case ViewFilterOperand.Is:
- return dateLeftValue.getDate() === dateRightValue.getDate();
+ return (
+ dateLeftValue.getDate() ===
+ new Date(String(filter.rightOperand)).getDate()
+ );
case ViewFilterOperand.IsInPast:
return dateLeftValue.getTime() < Date.now();
@@ -168,10 +172,16 @@ function evaluateDateFilter(filter: ResolvedFilter): boolean {
return dateLeftValue.toDateString() === new Date().toDateString();
case ViewFilterOperand.IsBefore:
- return dateLeftValue.getTime() < dateRightValue.getTime();
+ return (
+ dateLeftValue.getTime() <
+ new Date(String(filter.rightOperand)).getTime()
+ );
case ViewFilterOperand.IsAfter:
- return dateLeftValue.getTime() > dateRightValue.getTime();
+ return (
+ dateLeftValue.getTime() >
+ new Date(String(filter.rightOperand)).getTime()
+ );
case ViewFilterOperand.IsEmpty:
return (
@@ -187,6 +197,12 @@ function evaluateDateFilter(filter: ResolvedFilter): boolean {
filter.leftOperand !== ''
);
+ case ViewFilterOperand.IsRelative:
+ return parseAndEvaluateRelativeDateFilter({
+ dateToCheck: dateLeftValue,
+ relativeDateString: String(filter.rightOperand),
+ });
+
default:
throw new Error(
`Operand ${filter.operand} not supported for date filter`,
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
new file mode 100644
index 00000000000..5a4419df5e8
--- /dev/null
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts
@@ -0,0 +1,182 @@
+import {
+ addDays,
+ addMonths,
+ addWeeks,
+ addYears,
+ endOfDay,
+ endOfMonth,
+ endOfWeek,
+ endOfYear,
+ isWithinInterval,
+ startOfDay,
+ startOfMonth,
+ startOfWeek,
+ startOfYear,
+ subDays,
+ subMonths,
+ subWeeks,
+ subYears,
+} from 'date-fns';
+import {
+ type VariableDateViewFilterValue,
+ type VariableDateViewFilterValueUnit,
+} from 'twenty-shared/types';
+import { safeParseRelativeDateFilterValue } from 'twenty-shared/utils';
+
+export const parseAndEvaluateRelativeDateFilter = ({
+ dateToCheck,
+ relativeDateString,
+}: {
+ dateToCheck: Date;
+ relativeDateString: string;
+}): boolean => {
+ const relativeDateFilterValue =
+ safeParseRelativeDateFilterValue(relativeDateString);
+
+ if (!relativeDateFilterValue) {
+ return false;
+ }
+
+ return evaluateRelativeDateFilter({
+ dateToCheck,
+ relativeDateFilterValue,
+ });
+};
+
+export const evaluateRelativeDateFilter = ({
+ dateToCheck,
+ relativeDateFilterValue,
+}: {
+ dateToCheck: Date;
+ relativeDateFilterValue: VariableDateViewFilterValue;
+}): 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;
+ }
+};
+
+const evaluateNextDirection = (
+ dateToCheck: Date,
+ relativeDateFilterValue: VariableDateViewFilterValue,
+ now: Date,
+): boolean => {
+ if (relativeDateFilterValue.amount === undefined) {
+ return false;
+ }
+
+ const { amount, unit } = relativeDateFilterValue;
+
+ const endOfPeriod = addUnitToDate(now, amount, unit);
+
+ if (!endOfPeriod) {
+ return false;
+ }
+
+ return isWithinInterval(dateToCheck, {
+ start: now,
+ end: endOfPeriod,
+ });
+};
+
+function evaluatePastDirection(
+ dateToCheck: Date,
+ relativeDateFilterValue: VariableDateViewFilterValue,
+ now: Date,
+): boolean {
+ if (relativeDateFilterValue.amount === undefined) {
+ return false;
+ }
+
+ const { amount, unit } = relativeDateFilterValue;
+
+ const startOfPeriod = subtractUnitFromDate(now, amount, unit);
+
+ if (!startOfPeriod) {
+ return false;
+ }
+
+ return isWithinInterval(dateToCheck, {
+ start: startOfPeriod,
+ end: now,
+ });
+}
+
+function evaluateThisDirection(
+ dateToCheck: Date,
+ relativeDateValue: VariableDateViewFilterValue,
+ now: Date,
+): boolean {
+ const { unit } = relativeDateValue;
+
+ switch (unit) {
+ case 'DAY':
+ return isWithinInterval(dateToCheck, {
+ start: startOfDay(now),
+ end: endOfDay(now),
+ });
+ case 'WEEK':
+ return isWithinInterval(dateToCheck, {
+ start: startOfWeek(now),
+ end: endOfWeek(now),
+ });
+ case 'MONTH':
+ return isWithinInterval(dateToCheck, {
+ start: startOfMonth(now),
+ end: endOfMonth(now),
+ });
+ case 'YEAR':
+ return isWithinInterval(dateToCheck, {
+ start: startOfYear(now),
+ end: endOfYear(now),
+ });
+ default:
+ return false;
+ }
+}
+
+function addUnitToDate(
+ date: Date,
+ amount: number,
+ unit: VariableDateViewFilterValueUnit,
+): Date | null {
+ switch (unit) {
+ case 'DAY':
+ return addDays(date, amount);
+ case 'WEEK':
+ return addWeeks(date, amount);
+ case 'MONTH':
+ return addMonths(date, amount);
+ case 'YEAR':
+ return addYears(date, amount);
+ default:
+ return null;
+ }
+}
+
+function subtractUnitFromDate(
+ date: Date,
+ amount: number,
+ unit: VariableDateViewFilterValueUnit,
+): Date | null {
+ switch (unit) {
+ case 'DAY':
+ return subDays(date, amount);
+ case 'WEEK':
+ return subWeeks(date, amount);
+ case 'MONTH':
+ return subMonths(date, amount);
+ case 'YEAR':
+ return subYears(date, amount);
+ default:
+ return null;
+ }
+}
diff --git a/packages/twenty-shared/src/types/RelativeDateValue.ts b/packages/twenty-shared/src/types/RelativeDateValue.ts
new file mode 100644
index 00000000000..655eb27dd49
--- /dev/null
+++ b/packages/twenty-shared/src/types/RelativeDateValue.ts
@@ -0,0 +1,15 @@
+export type VariableDateViewFilterValueDirection = 'NEXT' | 'THIS' | 'PAST';
+
+export type VariableDateViewFilterValueUnit = 'DAY' | 'WEEK' | 'MONTH' | 'YEAR';
+
+export type VariableDateViewFilterValue = {
+ direction: VariableDateViewFilterValueDirection;
+ amount?: number;
+ unit: VariableDateViewFilterValueUnit;
+};
+
+export const DEFAULT_RELATIVE_DATE_VALUE: VariableDateViewFilterValue = {
+ direction: 'THIS',
+ unit: 'DAY',
+ amount: undefined,
+};
diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts
index 29ac2191e7f..8d40d8acb54 100644
--- a/packages/twenty-shared/src/types/index.ts
+++ b/packages/twenty-shared/src/types/index.ts
@@ -24,6 +24,12 @@ export type { ObjectsPermissionsByRoleId } from './ObjectsPermissionsByRoleId';
export type { ObjectsPermissionsByRoleIdDeprecated } from './ObjectsPermissionsByRoleIdDeprecated';
export type { ObjectsPermissionsDeprecated } from './ObjectsPermissionsDeprecated';
export type { RelationAndMorphRelationFieldMetadataType } from './RelationAndMorphRelationFieldMetadataType';
+export type {
+ VariableDateViewFilterValueDirection,
+ VariableDateViewFilterValueUnit,
+ VariableDateViewFilterValue,
+} from './RelativeDateValue';
+export { DEFAULT_RELATIVE_DATE_VALUE } from './RelativeDateValue';
export type { RestrictedFieldPermissions } from './RestrictedFieldPermissions';
export type { RestrictedFieldsPermissions } from './RestrictedFieldsPermissions';
export type { StepFilterGroup, StepFilter } from './StepFilters';
diff --git a/packages/twenty-shared/src/utils/__tests__/safeParseRelativeDateFilterValue.test.ts b/packages/twenty-shared/src/utils/__tests__/safeParseRelativeDateFilterValue.test.ts
new file mode 100644
index 00000000000..39444f4a9e3
--- /dev/null
+++ b/packages/twenty-shared/src/utils/__tests__/safeParseRelativeDateFilterValue.test.ts
@@ -0,0 +1,422 @@
+import { safeParseRelativeDateFilterValue } from '../safeParseRelativeDateFilterValue';
+
+describe('safeParseRelativeDateFilterValue', () => {
+ describe('valid inputs', () => {
+ describe('NEXT direction', () => {
+ it('should parse NEXT direction with DAY unit', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ amount: 3,
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'NEXT',
+ amount: 3,
+ unit: 'DAY',
+ });
+ });
+
+ it('should parse NEXT direction with WEEK unit', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ amount: 2,
+ unit: 'WEEK',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'NEXT',
+ amount: 2,
+ unit: 'WEEK',
+ });
+ });
+
+ it('should parse NEXT direction with MONTH unit', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ amount: 1,
+ unit: 'MONTH',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'NEXT',
+ amount: 1,
+ unit: 'MONTH',
+ });
+ });
+
+ it('should parse NEXT direction with YEAR unit', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ amount: 5,
+ unit: 'YEAR',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'NEXT',
+ amount: 5,
+ unit: 'YEAR',
+ });
+ });
+ });
+
+ describe('PAST direction', () => {
+ it('should parse PAST direction with DAY unit', () => {
+ const input = JSON.stringify({
+ direction: 'PAST',
+ amount: 7,
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'PAST',
+ amount: 7,
+ unit: 'DAY',
+ });
+ });
+
+ it('should parse PAST direction with WEEK unit', () => {
+ const input = JSON.stringify({
+ direction: 'PAST',
+ amount: 3,
+ unit: 'WEEK',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'PAST',
+ amount: 3,
+ unit: 'WEEK',
+ });
+ });
+
+ it('should parse PAST direction with MONTH unit', () => {
+ const input = JSON.stringify({
+ direction: 'PAST',
+ amount: 6,
+ unit: 'MONTH',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'PAST',
+ amount: 6,
+ unit: 'MONTH',
+ });
+ });
+
+ it('should parse PAST direction with YEAR unit', () => {
+ const input = JSON.stringify({
+ direction: 'PAST',
+ amount: 2,
+ unit: 'YEAR',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'PAST',
+ amount: 2,
+ unit: 'YEAR',
+ });
+ });
+ });
+
+ describe('THIS direction', () => {
+ it('should parse THIS direction with DAY unit (no amount)', () => {
+ const input = JSON.stringify({
+ direction: 'THIS',
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'THIS',
+ unit: 'DAY',
+ });
+ });
+
+ it('should parse THIS direction with WEEK unit (no amount)', () => {
+ const input = JSON.stringify({
+ direction: 'THIS',
+ unit: 'WEEK',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'THIS',
+ unit: 'WEEK',
+ });
+ });
+
+ it('should parse THIS direction with MONTH unit (no amount)', () => {
+ const input = JSON.stringify({
+ direction: 'THIS',
+ unit: 'MONTH',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'THIS',
+ unit: 'MONTH',
+ });
+ });
+
+ it('should parse THIS direction with YEAR unit (no amount)', () => {
+ const input = JSON.stringify({
+ direction: 'THIS',
+ unit: 'YEAR',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'THIS',
+ unit: 'YEAR',
+ });
+ });
+
+ it('should parse THIS direction with undefined amount explicitly', () => {
+ const input = JSON.stringify({
+ direction: 'THIS',
+ unit: 'DAY',
+ amount: undefined,
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+
+ expect(result).toEqual({
+ direction: 'THIS',
+ unit: 'DAY',
+ });
+ });
+ });
+ });
+
+ describe('invalid inputs', () => {
+ describe('JSON parsing errors', () => {
+ it('should return undefined for invalid JSON', () => {
+ const result = safeParseRelativeDateFilterValue('invalid json');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for empty string', () => {
+ const result = safeParseRelativeDateFilterValue('');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for unclosed JSON', () => {
+ const result = safeParseRelativeDateFilterValue('{"direction": "NEXT"');
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('schema validation errors', () => {
+ it('should return undefined for missing direction', () => {
+ const input = JSON.stringify({
+ amount: 1,
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for missing unit', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ amount: 1,
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for invalid direction', () => {
+ const input = JSON.stringify({
+ direction: 'INVALID',
+ amount: 1,
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for invalid unit', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ amount: 1,
+ unit: 'HOUR',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for NEXT direction without amount', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for PAST direction without amount', () => {
+ const input = JSON.stringify({
+ direction: 'PAST',
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for NEXT direction with zero amount', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ amount: 0,
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for PAST direction with negative amount', () => {
+ const input = JSON.stringify({
+ direction: 'PAST',
+ amount: -1,
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for NEXT direction with string amount', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ amount: '1',
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for non-object input', () => {
+ const result = safeParseRelativeDateFilterValue('"string"');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for array input', () => {
+ const result = safeParseRelativeDateFilterValue('[1, 2, 3]');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for null input', () => {
+ const result = safeParseRelativeDateFilterValue('null');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for boolean input', () => {
+ const result = safeParseRelativeDateFilterValue('true');
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for number input', () => {
+ const result = safeParseRelativeDateFilterValue('123');
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should return undefined for NEXT direction with decimal amount', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ amount: 1.5,
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toEqual({
+ direction: 'NEXT',
+ amount: 1.5,
+ unit: 'DAY',
+ });
+ });
+
+ it('should return undefined for object with extra properties', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ amount: 1,
+ unit: 'DAY',
+ extraProperty: 'should be ignored',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toEqual({
+ direction: 'NEXT',
+ amount: 1,
+ unit: 'DAY',
+ });
+ });
+
+ it('should return undefined for empty object', () => {
+ const input = JSON.stringify({});
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toBeUndefined();
+ });
+
+ it('should return undefined for THIS direction with amount', () => {
+ const input = JSON.stringify({
+ direction: 'THIS',
+ amount: 1,
+ unit: 'DAY',
+ });
+
+ // THIS direction should work with amount present, as the schema allows it
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toEqual({
+ direction: 'THIS',
+ amount: 1,
+ unit: 'DAY',
+ });
+ });
+
+ it('should handle very large amounts', () => {
+ const input = JSON.stringify({
+ direction: 'NEXT',
+ amount: 999999,
+ unit: 'DAY',
+ });
+
+ const result = safeParseRelativeDateFilterValue(input);
+ expect(result).toEqual({
+ direction: 'NEXT',
+ amount: 999999,
+ unit: 'DAY',
+ });
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts
index f7bd03d0d9d..86448ed42e8 100644
--- a/packages/twenty-shared/src/utils/index.ts
+++ b/packages/twenty-shared/src/utils/index.ts
@@ -23,6 +23,7 @@ export { getUniqueConstraintsFields } from './indexMetadata/getUniqueConstraints
export { parseJson } from './parseJson';
export { removePropertiesFromRecord } from './removePropertiesFromRecord';
export { removeUndefinedFields } from './removeUndefinedFields';
+export { safeParseRelativeDateFilterValue } from './safeParseRelativeDateFilterValue';
export { getGenericOperationName } from './sentry/getGenericOperationName';
export { getHumanReadableNameFromCode } from './sentry/getHumanReadableNameFromCode';
export { capitalize } from './strings/capitalize';
diff --git a/packages/twenty-shared/src/utils/safeParseRelativeDateFilterValue.ts b/packages/twenty-shared/src/utils/safeParseRelativeDateFilterValue.ts
new file mode 100644
index 00000000000..88e445a33b1
--- /dev/null
+++ b/packages/twenty-shared/src/utils/safeParseRelativeDateFilterValue.ts
@@ -0,0 +1,37 @@
+import {
+ type VariableDateViewFilterValue,
+ type VariableDateViewFilterValueDirection,
+ type VariableDateViewFilterValueUnit,
+} from '@/types/RelativeDateValue';
+import { z } from 'zod';
+
+const RelativeDateValueSchema = z.object({
+ direction: z.enum(['NEXT', 'THIS', 'PAST'] as const) as z.ZodType,
+ unit: z.enum(['DAY', 'WEEK', 'MONTH', 'YEAR'] as const) as z.ZodType,
+ amount: z.number().positive().optional(),
+}).refine((data) => {
+ if (data.direction === 'NEXT' || data.direction === 'PAST') {
+ return data.amount !== undefined && data.amount > 0;
+ }
+ return true;
+}, {
+ message: 'Amount is required for NEXT and PAST directions and must be positive',
+});
+
+export const safeParseRelativeDateFilterValue = (
+ value: string,
+): VariableDateViewFilterValue | undefined => {
+ try {
+ const parsedJson = JSON.parse(value);
+
+ const result = RelativeDateValueSchema.safeParse(parsedJson);
+
+ if (result.success) {
+ return result.data;
+ }
+
+ return undefined;
+ } catch {
+ return undefined;
+ }
+}