use Temporal for date filter evaluation and fix IS operand (#22408)

## Summary
- Refactored `evaluateDateFilter` in the workflow filter action from
native `Date` to the Temporal API, using the shared
`parseToInstantOrThrow` and `isSamePlainDate` utilities from
`twenty-shared` (resolving the long-standing `// TODO: refactor this
with Temporal`).
- Fixed a bug in the `IS` operand: it previously compared only
`getDate()` (day-of-month 1–31), so e.g. `2023-01-15` incorrectly
matched `2023-02-15`. It now compares the full calendar day in UTC.
- Removed server-local-timezone leakage: `IS_TODAY` and day comparisons
now run in UTC (consistent with the neighbouring relative-date filter
util), instead of relying on `toDateString()`/`getDate()`.

## Behavior changes (intended)
- `IS` now matches on the full UTC calendar day, not day-of-month.
- `DATE_TIME` `IS` matches on the same UTC day (not exact-instant
equality).
- Date comparisons are UTC-based, so evaluations near midnight in a
non-UTC server locale may differ from the old local-timezone behavior.
- Parsing is stricter (ISO + known formats via `parseToInstantOrThrow`);
malformed operands resolve to "no match" instead of being loosely
guessed by `new Date()`.



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22408?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Abdul Rahman
2026-07-03 01:57:46 +05:30
committed by GitHub
parent 027a098099
commit f00b6ae185
2 changed files with 159 additions and 29 deletions
@@ -887,10 +887,12 @@ describe('evaluateFilterConditions', () => {
});
describe('date operands', () => {
const now = new Date();
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 1 day from now
const today = new Date();
const now = new Date().toISOString();
const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); // 1 day ago
const futureDate = new Date(
Date.now() + 24 * 60 * 60 * 1000,
).toISOString(); // 1 day from now
const today = new Date().toISOString();
it('should handle IsInPast operand correctly', () => {
const filter = createFilter(
@@ -997,22 +999,19 @@ describe('evaluateFilterConditions', () => {
});
it('should handle Is operand for dates correctly', () => {
const sameDate1 = new Date('2023-01-15');
const sameDate2 = new Date('2023-01-15');
const filter = createFilter(
ViewFilterOperand.IS,
sameDate1,
sameDate2,
'2023-01-15',
'2023-01-15',
'DATE',
);
expect(evaluateFilterConditions({ filters: [filter] })).toBe(true);
const otherDate = new Date('2023-01-16');
const differentFilter = createFilter(
ViewFilterOperand.IS,
sameDate1,
otherDate,
'2023-01-15',
'2023-01-16',
'DATE',
);
@@ -1021,6 +1020,103 @@ describe('evaluateFilterConditions', () => {
);
});
it('should not match Is when dates share the day of month but differ in month or year', () => {
const januaryFilter = createFilter(
ViewFilterOperand.IS,
'2023-01-15',
'2023-02-15',
'DATE',
);
expect(evaluateFilterConditions({ filters: [januaryFilter] })).toBe(
false,
);
const yearFilter = createFilter(
ViewFilterOperand.IS,
'2023-01-15',
'2024-01-15',
'DATE',
);
expect(evaluateFilterConditions({ filters: [yearFilter] })).toBe(false);
});
it('should match Is on the same UTC day for date-time values', () => {
const sameDayFilter = createFilter(
ViewFilterOperand.IS,
'2023-01-15T08:30:00.000Z',
'2023-01-15T21:45:00.000Z',
'DATE_TIME',
);
expect(evaluateFilterConditions({ filters: [sameDayFilter] })).toBe(
true,
);
});
it('should handle Date instance operands (database-event trigger passes raw ORM records)', () => {
const isFilter = createFilter(
ViewFilterOperand.IS,
new Date('2023-01-15T08:30:00.000Z'),
new Date('2023-01-15T21:45:00.000Z'),
'DATE_TIME',
);
expect(evaluateFilterConditions({ filters: [isFilter] })).toBe(true);
const beforeFilter = createFilter(
ViewFilterOperand.IS_BEFORE,
new Date('2023-01-15T00:00:00.000Z'),
new Date('2023-01-16T00:00:00.000Z'),
'DATE_TIME',
);
expect(evaluateFilterConditions({ filters: [beforeFilter] })).toBe(
true,
);
const invalidFilter = createFilter(
ViewFilterOperand.IS_BEFORE,
new Date('invalid'),
new Date('2023-01-16T00:00:00.000Z'),
'DATE_TIME',
);
expect(evaluateFilterConditions({ filters: [invalidFilter] })).toBe(
false,
);
});
it('should not match Is or Before/After when the right operand is an invalid date', () => {
const isFilter = createFilter(
ViewFilterOperand.IS,
'2023-01-15',
'not-a-date',
'DATE',
);
const beforeFilter = createFilter(
ViewFilterOperand.IS_BEFORE,
'2023-01-15',
'not-a-date',
'DATE',
);
const afterFilter = createFilter(
ViewFilterOperand.IS_AFTER,
'2023-01-15',
'not-a-date',
'DATE',
);
expect(evaluateFilterConditions({ filters: [isFilter] })).toBe(false);
expect(evaluateFilterConditions({ filters: [beforeFilter] })).toBe(
false,
);
expect(evaluateFilterConditions({ filters: [afterFilter] })).toBe(
false,
);
});
it('should handle date IsEmpty and IsNotEmpty operands', () => {
const emptyFilter = createFilter(
ViewFilterOperand.IS_EMPTY,
@@ -4,6 +4,7 @@ import {
isObject,
isString,
} from '@sniptt/guards';
import { Temporal } from 'temporal-polyfill';
import {
type StepFilter,
type StepFilterGroup,
@@ -13,6 +14,8 @@ import {
import {
convertViewFilterOperandToCoreOperand as convertViewFilterOperandDeprecated,
isDefined,
isSamePlainDate,
parseToInstantOrThrow,
} from 'twenty-shared/utils';
import { parseBooleanFromStringValue } from 'twenty-shared/workflow';
@@ -214,8 +217,23 @@ function evaluateBooleanFilter(filter: ResolvedFilter): boolean {
}
}
function parseDateOperandToInstant(value: unknown): Temporal.Instant | null {
try {
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return Temporal.Instant.fromEpochMilliseconds(value.getTime());
}
return parseToInstantOrThrow(String(value));
} catch {
return null;
}
}
function toUtcPlainDate(instant: Temporal.Instant): Temporal.PlainDate {
return instant.toZonedDateTimeISO('UTC').toPlainDate();
}
function evaluateDateFilter(filter: ResolvedFilter): boolean {
// TODO: refactor this with Temporal
if (filter.operand === ViewFilterOperand.IS_EMPTY) {
return !isDefined(filter.leftOperand) || filter.leftOperand === '';
}
@@ -224,42 +242,58 @@ function evaluateDateFilter(filter: ResolvedFilter): boolean {
return isDefined(filter.leftOperand) && filter.leftOperand !== '';
}
const dateLeftValue = new Date(String(filter.leftOperand));
const leftInstant = parseDateOperandToInstant(filter.leftOperand);
if (Number.isNaN(dateLeftValue.getTime())) {
if (!isDefined(leftInstant)) {
return false;
}
switch (filter.operand) {
case ViewFilterOperand.IS:
case ViewFilterOperand.IS: {
const rightInstant = parseDateOperandToInstant(filter.rightOperand);
return (
dateLeftValue.getDate() ===
new Date(String(filter.rightOperand)).getDate()
isDefined(rightInstant) &&
isSamePlainDate(
toUtcPlainDate(leftInstant),
toUtcPlainDate(rightInstant),
)
);
}
case ViewFilterOperand.IS_IN_PAST:
return dateLeftValue.getTime() < Date.now();
return Temporal.Instant.compare(leftInstant, Temporal.Now.instant()) < 0;
case ViewFilterOperand.IS_IN_FUTURE:
return dateLeftValue.getTime() > Date.now();
return Temporal.Instant.compare(leftInstant, Temporal.Now.instant()) > 0;
case ViewFilterOperand.IS_TODAY:
return dateLeftValue.toDateString() === new Date().toDateString();
case ViewFilterOperand.IS_BEFORE:
return (
dateLeftValue.getTime() <
new Date(String(filter.rightOperand)).getTime()
return isSamePlainDate(
toUtcPlainDate(leftInstant),
Temporal.Now.zonedDateTimeISO('UTC').toPlainDate(),
);
case ViewFilterOperand.IS_AFTER:
case ViewFilterOperand.IS_BEFORE: {
const rightInstant = parseDateOperandToInstant(filter.rightOperand);
return (
dateLeftValue.getTime() >
new Date(String(filter.rightOperand)).getTime()
isDefined(rightInstant) &&
Temporal.Instant.compare(leftInstant, rightInstant) < 0
);
}
case ViewFilterOperand.IS_AFTER: {
const rightInstant = parseDateOperandToInstant(filter.rightOperand);
return (
isDefined(rightInstant) &&
Temporal.Instant.compare(leftInstant, rightInstant) > 0
);
}
case ViewFilterOperand.IS_RELATIVE:
return parseAndEvaluateRelativeDateFilter({
dateToCheck: dateLeftValue,
dateToCheck: new Date(leftInstant.epochMilliseconds),
relativeDateString: String(filter.rightOperand),
});