fix: relative date picker calendar display (#21895)

Part of
https://github.com/twentyhq/twenty/issues/19739#issuecomment-4652034526
(Bug 1-3). Maybe it feels like theses bugs are not actually bugs, but we
can maybe say it as UX improvements: specially needed in case when an
user will choose any past options.

### Bug 1: calendar open on wrong month
With Is Relative (e.g. Past 1 Quarter), the calendar opened on today’s
month instead of the range start. After the fix, it now opens on the
first month of the filtered range.

**Testing:**
View filter → Date field → Is Relative → Past 1 Quarter. Calendar opens
on January (range start), not today’s month


https://github.com/user-attachments/assets/8849d00a-4d5c-4f8a-8d31-3a62535eb311


### Bug 2: Dates not highlighted
Ranges older than ~2 months (e.g. Q1 when today is June) showed no
highlighted days. Highlighting now covers the full resolved range.

**Testing:**
Same setup: past 1 Quarter on a date when Q1 is outside the old 2‑month
window. Jan 1 - Mar 31 will highlight.


https://github.com/user-attachments/assets/d21e2272-c923-4493-80ff-bdf4228842b1


### Bug 3: No month navigation
Relative mode only showed Past - 1 - Quarter controls with no way to
browse months. Now see the new arrows move through months without
changing the filter.

<img width="377" height="455" alt="Screenshot 2026-06-20 181107"
src="https://github.com/user-attachments/assets/eb51feb9-af10-489a-b166-8b8d6c642e05"
/>


> [!NOTE]
> 1. We can't do the fixes by one by one, i have to fix them within one
PR because all the fixes are inter-related, like we can't test the bug 1
fix alone without implementing bug 3.
> 2. Bug 4 will be done in a separate PR which is actually the issue
#19739. See
https://github.com/twentyhq/twenty/issues/19739#issuecomment-4652034526
for better understanding.
> 3. If you see the screen recordings, they are actually done with the
alignment fixes from #21881 . So without that changes you will see the
alignmemt issues in the calendar grid in your local.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Parship Chowdhury
2026-06-26 13:33:21 +05:30
committed by GitHub
parent 6e2df0654b
commit b3e39e2198
30 changed files with 969 additions and 662 deletions
@@ -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', () => {
@@ -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);
}
}