From f613886511ea4418dc949dedb13c795028a67f4c Mon Sep 17 00:00:00 2001 From: Apetu Ezekiel <62860477+Apetuezekiel@users.noreply.github.com> Date: Mon, 25 May 2026 16:54:36 +0100 Subject: [PATCH] fix(localization): parse date-only ISO strings as local midnight in relative date formatter (#20630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #19634 ### Root Cause The ECMAScript spec treats date-only strings (`YYYY-MM-DD`) as **UTC midnight** when passed to `new Date()`. But `date-fns` comparison functions (`isToday`, `isYesterday`, `isTomorrow`) operate in **local time**. For users in UTC-negative timezones, UTC midnight April 14 is April 13 evening locally — so the label shows "Yesterday" instead of "Today". ### Fix In `formatDateISOStringToRelativeDate.ts`, detect date-only strings (length === 10) and append `T00:00:00` (no `Z`) to force local-time parsing: ```ts // Before const targetDate = new Date(isoDate); // After const targetDate = isoDate.length === 10 ? new Date(isoDate + 'T00:00:00') : new Date(isoDate); ``` Full datetime strings (with time component) are left unchanged — they already carry timezone information. ### Tests Added `formatDateISOStringToRelativeDate.test.ts` covering: - `Today` / `Yesterday` / `Tomorrow` labels for date-only strings - Regression case: date-only string parsed at local midnight (not UTC midnight) - Full datetime strings continue to work as before ## Before / After | Scenario | Before | After | |---|---|---| | `"2026-04-14"` viewed at UTC-5 on April 14 | Yesterday ❌ | Today ✓ | | `"2026-04-14"` viewed at UTC+0 on April 14 | Today ✓ | Today ✓ | | `"2026-04-14T12:00:00Z"` | Today ✓ | Today ✓ | --------- Co-authored-by: Marie Stoppa --- packages/twenty-front/.storybook/preview.tsx | 20 +- ...DateISOStringToCustomUnicodeFormat.test.ts | 112 +++++ .../formatDateISOStringToDate.test.ts | 124 ++++++ .../formatDateISOStringToRelativeDate.test.ts | 385 ++++++++++++++++++ ...ormatDateISOStringToCustomUnicodeFormat.ts | 7 + .../utils/formatDateISOStringToDate.ts | 7 + .../formatDateISOStringToRelativeDate.ts | 67 +-- .../utils/formatPlainDateISOString.ts | 20 + .../string/__tests__/formatDateString.test.ts | 96 ++++- .../src/utils/string/formatDateString.ts | 1 + .../src/utils/string/formatDateTimeString.ts | 3 +- .../date/__tests__/isDateWithoutTime.test.ts | 63 +++ .../src/utils/date/isDateWithoutTime.ts | 3 + packages/twenty-shared/src/utils/index.ts | 1 + 14 files changed, 880 insertions(+), 29 deletions(-) create mode 100644 packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToCustomUnicodeFormat.test.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToDate.test.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToRelativeDate.test.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/formatPlainDateISOString.ts create mode 100644 packages/twenty-shared/src/utils/date/__tests__/isDateWithoutTime.test.ts create mode 100644 packages/twenty-shared/src/utils/date/isDateWithoutTime.ts diff --git a/packages/twenty-front/.storybook/preview.tsx b/packages/twenty-front/.storybook/preview.tsx index b103ff720d0..9b1a4058b5b 100644 --- a/packages/twenty-front/.storybook/preview.tsx +++ b/packages/twenty-front/.storybook/preview.tsx @@ -4,12 +4,18 @@ import { type Preview } from '@storybook/react-vite'; import { initialize, mswLoader } from 'msw-storybook-addon'; import { SOURCE_LOCALE } from 'twenty-shared/translations'; +// oxlint-disable-next-line no-restricted-imports +import { DateFormat } from '../src/modules/localization/constants/DateFormat'; +// oxlint-disable-next-line no-restricted-imports +import { TimeFormat } from '../src/modules/localization/constants/TimeFormat'; // oxlint-disable-next-line no-restricted-imports import { FileUploadProvider } from '../src/modules/file-upload/components/FileUploadProvider'; // oxlint-disable-next-line no-restricted-imports import { RootDecorator } from '../src/testing/decorators/RootDecorator'; // oxlint-disable-next-line no-restricted-imports import { resetJotaiStore } from '../src/modules/ui/utilities/state/jotai/jotaiStore'; +// oxlint-disable-next-line no-restricted-imports +import { UserContext } from '../src/modules/users/contexts/UserContext'; import 'react-loading-skeleton/dist/skeleton.css'; import 'twenty-ui/style.css'; @@ -59,6 +65,16 @@ initialize({ quiet: true, }); +// Mirrors production's MinimalMetadataGater so any story rendering a +// date-aware component (DateTimeDisplay, etc.) sees a real IANA timeZone +// instead of UserContext's default `{}`. Stories needing a specific timezone +// can still override by nesting their own UserContext.Provider. +const STORYBOOK_DEFAULT_USER_CONTEXT = { + dateFormat: DateFormat.DAY_FIRST, + timeFormat: TimeFormat.HOUR_24, + timeZone: 'UTC', +}; + const preview: Preview = { decorators: [ (Story) => { @@ -69,7 +85,9 @@ const preview: Preview = { - + + + diff --git a/packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToCustomUnicodeFormat.test.ts b/packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToCustomUnicodeFormat.test.ts new file mode 100644 index 00000000000..d6863961f92 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToCustomUnicodeFormat.test.ts @@ -0,0 +1,112 @@ +import { formatDateISOStringToCustomUnicodeFormat } from '@/localization/utils/formatDateISOStringToCustomUnicodeFormat'; +import { enUS } from 'date-fns/locale'; + +describe('formatDateISOStringToCustomUnicodeFormat', () => { + describe('date-only ISO strings (no time component)', () => { + it('should render with a year-only unicode format', () => { + const result = formatDateISOStringToCustomUnicodeFormat({ + date: '2022-01-01', + timeZone: 'UTC', + dateFormat: 'yyyy', + localeCatalog: enUS, + }); + + expect(result).toBe('2022'); + }); + + it('should render with a custom day-month-year unicode format', () => { + const result = formatDateISOStringToCustomUnicodeFormat({ + date: '2022-03-14', + timeZone: 'UTC', + dateFormat: 'dd/MM/yyyy', + localeCatalog: enUS, + }); + + expect(result).toBe('14/03/2022'); + }); + + it('should render the same calendar date regardless of the user timezone', () => { + const resultUtc = formatDateISOStringToCustomUnicodeFormat({ + date: '2022-01-01', + timeZone: 'UTC', + dateFormat: 'yyyy-MM-dd', + localeCatalog: enUS, + }); + const resultLA = formatDateISOStringToCustomUnicodeFormat({ + date: '2022-01-01', + timeZone: 'America/Los_Angeles', + dateFormat: 'yyyy-MM-dd', + localeCatalog: enUS, + }); + const resultTokyo = formatDateISOStringToCustomUnicodeFormat({ + date: '2022-01-01', + timeZone: 'Asia/Tokyo', + dateFormat: 'yyyy-MM-dd', + localeCatalog: enUS, + }); + + expect(resultUtc).toBe('2022-01-01'); + expect(resultLA).toBe('2022-01-01'); + expect(resultTokyo).toBe('2022-01-01'); + }); + + it('should preserve last day of the year in a positive offset timezone', () => { + const result = formatDateISOStringToCustomUnicodeFormat({ + date: '2024-12-31', + timeZone: 'Asia/Tokyo', + dateFormat: 'yyyy-MM-dd', + localeCatalog: enUS, + }); + + expect(result).toBe('2024-12-31'); + }); + }); + + describe('datetime ISO strings', () => { + it('should render in the user timezone for a UTC value', () => { + const result = formatDateISOStringToCustomUnicodeFormat({ + date: '2022-01-01T12:00:00Z', + timeZone: 'UTC', + dateFormat: 'yyyy-MM-dd HH:mm', + localeCatalog: enUS, + }); + + expect(result).toBe('2022-01-01 12:00'); + }); + + it('should shift the displayed date backward in negative-offset timezones', () => { + // 2022-01-01 00:00 UTC = 2021-12-31 19:00 in America/New_York (UTC-5). + const result = formatDateISOStringToCustomUnicodeFormat({ + date: '2022-01-01T00:00:00Z', + timeZone: 'America/New_York', + dateFormat: 'yyyy-MM-dd HH:mm', + localeCatalog: enUS, + }); + + expect(result).toBe('2021-12-31 19:00'); + }); + + it('should shift the displayed date forward in positive-offset timezones', () => { + // 2022-01-01 22:00 UTC = 2022-01-02 07:00 in Asia/Tokyo (UTC+9). + const result = formatDateISOStringToCustomUnicodeFormat({ + date: '2022-01-01T22:00:00Z', + timeZone: 'Asia/Tokyo', + dateFormat: 'yyyy-MM-dd HH:mm', + localeCatalog: enUS, + }); + + expect(result).toBe('2022-01-02 07:00'); + }); + + it('should return the fallback string for an unknown timezone', () => { + const result = formatDateISOStringToCustomUnicodeFormat({ + date: '2022-01-01T12:00:00Z', + timeZone: 'Mars/Olympus', + dateFormat: 'yyyy-MM-dd', + localeCatalog: enUS, + }); + + expect(result).toBe('Invalid format string'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToDate.test.ts b/packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToDate.test.ts new file mode 100644 index 00000000000..9eb1880b069 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToDate.test.ts @@ -0,0 +1,124 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { formatDateISOStringToDate } from '@/localization/utils/formatDateISOStringToDate'; +import { enUS } from 'date-fns/locale'; + +describe('formatDateISOStringToDate', () => { + describe('date-only ISO strings (no time component)', () => { + it('should render the calendar date with DAY_FIRST format', () => { + const result = formatDateISOStringToDate({ + date: '2022-01-01', + timeZone: 'UTC', + dateFormat: DateFormat.DAY_FIRST, + localeCatalog: enUS, + }); + + expect(result).toBe('1 Jan, 2022'); + }); + + it('should render the calendar date with MONTH_FIRST format', () => { + const result = formatDateISOStringToDate({ + date: '2022-01-01', + timeZone: 'UTC', + dateFormat: DateFormat.MONTH_FIRST, + localeCatalog: enUS, + }); + + expect(result).toBe('Jan 1, 2022'); + }); + + it('should render the calendar date with YEAR_FIRST format', () => { + const result = formatDateISOStringToDate({ + date: '2022-01-01', + timeZone: 'UTC', + dateFormat: DateFormat.YEAR_FIRST, + localeCatalog: enUS, + }); + + expect(result).toBe('2022 Jan 1'); + }); + + it('should render the same calendar date regardless of the user timezone', () => { + const resultUtc = formatDateISOStringToDate({ + date: '2022-01-01', + timeZone: 'UTC', + dateFormat: DateFormat.DAY_FIRST, + localeCatalog: enUS, + }); + const resultLA = formatDateISOStringToDate({ + date: '2022-01-01', + timeZone: 'America/Los_Angeles', + dateFormat: DateFormat.DAY_FIRST, + localeCatalog: enUS, + }); + const resultTokyo = formatDateISOStringToDate({ + date: '2022-01-01', + timeZone: 'Asia/Tokyo', + dateFormat: DateFormat.DAY_FIRST, + localeCatalog: enUS, + }); + + expect(resultUtc).toBe('1 Jan, 2022'); + expect(resultLA).toBe('1 Jan, 2022'); + expect(resultTokyo).toBe('1 Jan, 2022'); + }); + + it('should preserve year boundaries on the last day of the year', () => { + const result = formatDateISOStringToDate({ + date: '2024-12-31', + timeZone: 'America/Los_Angeles', + dateFormat: DateFormat.DAY_FIRST, + localeCatalog: enUS, + }); + + expect(result).toBe('31 Dec, 2024'); + }); + + it('should preserve year boundaries on the first day of the year', () => { + const result = formatDateISOStringToDate({ + date: '2025-01-01', + timeZone: 'Asia/Tokyo', + dateFormat: DateFormat.DAY_FIRST, + localeCatalog: enUS, + }); + + expect(result).toBe('1 Jan, 2025'); + }); + }); + + describe('datetime ISO strings', () => { + it('should render the date in UTC when timeZone is UTC', () => { + const result = formatDateISOStringToDate({ + date: '2022-01-01T12:00:00Z', + timeZone: 'UTC', + dateFormat: DateFormat.DAY_FIRST, + localeCatalog: enUS, + }); + + expect(result).toBe('1 Jan, 2022'); + }); + + it('should shift the displayed day when the timezone rolls the date over', () => { + // 2022-01-01 00:00 UTC = 2021-12-31 19:00 in America/New_York (UTC-5). + const result = formatDateISOStringToDate({ + date: '2022-01-01T00:00:00Z', + timeZone: 'America/New_York', + dateFormat: DateFormat.DAY_FIRST, + localeCatalog: enUS, + }); + + expect(result).toBe('31 Dec, 2021'); + }); + + it('should shift the displayed day forward when the timezone is ahead of UTC', () => { + // 2022-01-01 22:00 UTC = 2022-01-02 07:00 in Asia/Tokyo (UTC+9). + const result = formatDateISOStringToDate({ + date: '2022-01-01T22:00:00Z', + timeZone: 'Asia/Tokyo', + dateFormat: DateFormat.DAY_FIRST, + localeCatalog: enUS, + }); + + expect(result).toBe('2 Jan, 2022'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToRelativeDate.test.ts b/packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToRelativeDate.test.ts new file mode 100644 index 00000000000..3f383bcfe67 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/__tests__/formatDateISOStringToRelativeDate.test.ts @@ -0,0 +1,385 @@ +import { formatDateISOStringToRelativeDate } from '@/localization/utils/formatDateISOStringToRelativeDate'; +import { enUS, fr } from 'date-fns/locale'; + +describe('formatDateISOStringToRelativeDate', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('date-only ISO strings with day-maximum precision', () => { + const baseParams = { + isDayMaximumPrecision: true, + localeCatalog: enUS, + }; + + describe('when the user is in UTC and the current instant is mid-day UTC', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2026-05-18T13:00:00Z')); + }); + + it('should return "Today" for the current calendar date in UTC', () => { + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-18', + timeZone: 'UTC', + }); + + expect(result).toBe('Today'); + }); + + it('should return "Yesterday" for the previous calendar date in UTC', () => { + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-17', + timeZone: 'UTC', + }); + + expect(result).toBe('Yesterday'); + }); + + it('should return "Tomorrow" for the next calendar date in UTC', () => { + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-19', + timeZone: 'UTC', + }); + + expect(result).toBe('Tomorrow'); + }); + }); + + describe('when the user timezone differs from UTC', () => { + it('should return "Today" when the current calendar date in Tokyo has already rolled over', () => { + // 2026-05-18 21:00 UTC = 2026-05-19 06:00 in Asia/Tokyo (UTC+9), + // so today in Tokyo is May 19. + jest.setSystemTime(new Date('2026-05-18T21:00:00Z')); + + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-19', + timeZone: 'Asia/Tokyo', + }); + + expect(result).toBe('Today'); + }); + + it('should return "Tomorrow" for the same value when the user is in UTC', () => { + jest.setSystemTime(new Date('2026-05-18T21:00:00Z')); + + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-19', + timeZone: 'UTC', + }); + + expect(result).toBe('Tomorrow'); + }); + + it('should return "Yesterday" in Los Angeles when UTC has already rolled over', () => { + // 2026-05-19 02:00 UTC = 2026-05-18 19:00 in America/Los_Angeles (UTC-7), + // so today in LA is still May 18 and "2026-05-17" is yesterday there. + jest.setSystemTime(new Date('2026-05-19T02:00:00Z')); + + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-17', + timeZone: 'America/Los_Angeles', + }); + + expect(result).toBe('Yesterday'); + }); + + it('should return "Today" for the same value in UTC at the same instant', () => { + // At 2026-05-19 02:00 UTC, today in UTC is already May 19, + // so "2026-05-17" is two days ago in UTC. + jest.setSystemTime(new Date('2026-05-19T02:00:00Z')); + + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-17', + timeZone: 'UTC', + }); + + expect(result).toBe('2 days ago'); + }); + + it('should return "Today" in Kingston when UTC has already rolled over', () => { + // 2026-05-19 03:00 UTC = 2026-05-18 22:00 in America/Jamaica (UTC-5), + // so today in Kingston is still May 18 even though UTC reads May 19. + jest.setSystemTime(new Date('2026-05-19T03:00:00Z')); + + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-18', + timeZone: 'America/Jamaica', + }); + + expect(result).toBe('Today'); + }); + + it('should return "Yesterday" in Kingston at the same UTC-rollover instant', () => { + // Today in Kingston is May 18, so "2026-05-17" is yesterday there + // even though UTC sees May 19 as today. + jest.setSystemTime(new Date('2026-05-19T03:00:00Z')); + + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-17', + timeZone: 'America/Jamaica', + }); + + expect(result).toBe('Yesterday'); + }); + + it('should return "Tomorrow" in Kingston for the date UTC already calls today', () => { + // Today in Kingston is May 18, so "2026-05-19" is tomorrow there + // even though UTC has already rolled over to May 19. + jest.setSystemTime(new Date('2026-05-19T03:00:00Z')); + + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-19', + timeZone: 'America/Jamaica', + }); + + expect(result).toBe('Tomorrow'); + }); + + it('should keep Kingston at UTC-5 in the summer because Jamaica does not observe DST', () => { + // 2026-07-19 03:00 UTC = 2026-07-18 22:00 in America/Jamaica. + // Jamaica stays on UTC-5 year-round, so today in Kingston is July 18 + // (unlike America/New_York which would be on UTC-4 EDT at this date). + jest.setSystemTime(new Date('2026-07-19T03:00:00Z')); + + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-07-18', + timeZone: 'America/Jamaica', + }); + + expect(result).toBe('Today'); + }); + }); + + describe('for distances beyond plus or minus one day', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2026-05-18T13:00:00Z')); + }); + + it('should return a forward-looking distance for future dates', () => { + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-25', + timeZone: 'UTC', + }); + + expect(result).toBe('in 7 days'); + }); + + it('should return a past-looking distance for past dates', () => { + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-05-13', + timeZone: 'UTC', + }); + + expect(result).toBe('5 days ago'); + }); + + it('should return month-level rounding for far-future dates', () => { + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-06-18', + timeZone: 'UTC', + }); + + expect(result).toBe('in about 1 month'); + }); + + it('should return month-level rounding for far-past dates', () => { + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + isoDate: '2026-04-18', + timeZone: 'UTC', + }); + + expect(result).toBe('about 1 month ago'); + }); + }); + + describe('with a non-English locale catalog', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2026-05-18T13:00:00Z')); + }); + + it('should localize the distance output via formatDistance', () => { + const result = formatDateISOStringToRelativeDate({ + ...baseParams, + localeCatalog: fr, + isoDate: '2026-05-25', + timeZone: 'UTC', + }); + + expect(result).toBe('dans 7 jours'); + }); + }); + }); + + describe('date-only ISO strings without day-maximum precision', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2026-05-18T13:00:00Z')); + }); + + it('should not short-circuit to "Today" and should return a distance instead', () => { + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-18', + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('less than a minute ago'); + }); + + it('should still respect the user timezone when computing the distance', () => { + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-25', + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('in 7 days'); + }); + + it('should not short-circuit to "Yesterday" and should return a day-based distance instead', () => { + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-17', + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('1 day ago'); + }); + + it('should not short-circuit to "Tomorrow" and should return a day-based distance instead', () => { + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-19', + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('in 1 day'); + }); + + it('should anchor the diff in the user timezone near midnight boundaries', () => { + // 2026-05-18 21:00 UTC = 2026-05-19 06:00 in Asia/Tokyo, + // so today in Tokyo is May 19 and the diff against "2026-05-19" is zero. + jest.setSystemTime(new Date('2026-05-18T21:00:00Z')); + + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-19', + localeCatalog: enUS, + timeZone: 'Asia/Tokyo', + }); + + expect(result).toBe('less than a minute ago'); + }); + }); + + describe('datetime ISO strings', () => { + beforeEach(() => { + jest.setSystemTime(new Date('2026-05-18T13:00:00Z')); + }); + + it('should return a past distance for a datetime in the past', () => { + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-16T13:00:00Z', + isDayMaximumPrecision: true, + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('2 days ago'); + }); + + it('should return a future distance for a datetime in the future', () => { + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-21T13:00:00Z', + isDayMaximumPrecision: true, + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('in 3 days'); + }); + + it('should return sub-day precision when within 24h and isDayMaximumPrecision is false', () => { + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-18T10:00:00Z', + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('about 3 hours ago'); + }); + + it('should bucket sub-day distances to a day-level when isDayMaximumPrecision is true', () => { + // Same calendar day, three hours apart: startOfDay collapses both + // anchors to the same instant so the formatted distance is zero. + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-18T10:00:00Z', + isDayMaximumPrecision: true, + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('less than a minute ago'); + }); + + it('should return forward-looking sub-day precision for future datetimes within 24h', () => { + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-18T16:00:00Z', + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('in about 3 hours'); + }); + + it('should switch to day buckets at the 24h boundary even without isDayMaximumPrecision', () => { + // Exactly 24h ahead: isWithin24h becomes false and the startOfDay + // branch takes over, producing a whole-day distance. + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-19T13:00:00Z', + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('in 1 day'); + }); + + it('should bucket to whole days for distances beyond 24h without isDayMaximumPrecision', () => { + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-21T20:00:00Z', + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('in 3 days'); + }); + + it('should parse datetime ISO strings with an explicit non-UTC offset', () => { + // 15:00 +09:00 = 06:00 UTC, roughly 7 hours before the fake "now". + const result = formatDateISOStringToRelativeDate({ + isoDate: '2026-05-18T15:00:00+09:00', + localeCatalog: enUS, + timeZone: 'UTC', + }); + + expect(result).toBe('about 7 hours ago'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToCustomUnicodeFormat.ts b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToCustomUnicodeFormat.ts index 4ac9d97c105..7d403f81205 100644 --- a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToCustomUnicodeFormat.ts +++ b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToCustomUnicodeFormat.ts @@ -1,4 +1,7 @@ +import { formatPlainDateISOString } from '@/localization/utils/formatPlainDateISOString'; +import { type Locale } from 'date-fns'; import { formatInTimeZone } from 'date-fns-tz'; +import { isDateWithoutTime } from 'twenty-shared/utils'; export const formatDateISOStringToCustomUnicodeFormat = ({ date, @@ -12,6 +15,10 @@ export const formatDateISOStringToCustomUnicodeFormat = ({ localeCatalog: Locale; }) => { try { + if (isDateWithoutTime(date)) { + return formatPlainDateISOString({ date, dateFormat, localeCatalog }); + } + return formatInTimeZone(new Date(date), timeZone, dateFormat, { locale: localeCatalog, }); diff --git a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDate.ts b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDate.ts index 5b4bb94322b..2c8cc68677e 100644 --- a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDate.ts +++ b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDate.ts @@ -1,5 +1,8 @@ import { type DateFormat } from '@/localization/constants/DateFormat'; +import { formatPlainDateISOString } from '@/localization/utils/formatPlainDateISOString'; +import { type Locale } from 'date-fns'; import { formatInTimeZone } from 'date-fns-tz'; +import { isDateWithoutTime } from 'twenty-shared/utils'; export const formatDateISOStringToDate = ({ date, @@ -12,6 +15,10 @@ export const formatDateISOStringToDate = ({ dateFormat: DateFormat; localeCatalog?: Locale; }) => { + if (isDateWithoutTime(date)) { + return formatPlainDateISOString({ date, dateFormat, localeCatalog }); + } + return formatInTimeZone(new Date(date), timeZone, dateFormat, { locale: localeCatalog, }); diff --git a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToRelativeDate.ts b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToRelativeDate.ts index c266554bf7f..b271c9c58c2 100644 --- a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToRelativeDate.ts +++ b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToRelativeDate.ts @@ -1,40 +1,55 @@ import { t } from '@lingui/core/macro'; -import { - differenceInDays, - formatDistance, - isToday, - isTomorrow, - isYesterday, - type Locale, - startOfDay, -} from 'date-fns'; +import { formatDistance, type Locale } from 'date-fns'; +import { Temporal } from 'temporal-polyfill'; +import { isDateWithoutTime } from 'twenty-shared/utils'; export const formatDateISOStringToRelativeDate = ({ isoDate, - isDayMaximumPrecision = false, localeCatalog, + timeZone, + isDayMaximumPrecision = false, }: { isoDate: string; - isDayMaximumPrecision?: boolean; localeCatalog: Locale; + isDayMaximumPrecision?: boolean; + timeZone: string; }) => { - const now = new Date(); - const targetDate = new Date(isoDate); - - if (isDayMaximumPrecision && isToday(targetDate)) return t`Today`; - if (isDayMaximumPrecision && isYesterday(targetDate)) return t`Yesterday`; - if (isDayMaximumPrecision && isTomorrow(targetDate)) return t`Tomorrow`; - - const isWithin24h = Math.abs(differenceInDays(targetDate, now)) < 1; - - if (isDayMaximumPrecision || !isWithin24h) - return formatDistance(startOfDay(targetDate), startOfDay(now), { + const formatRelative = (targetMs: number, baseMs: number) => + formatDistance(targetMs, baseMs, { addSuffix: true, locale: localeCatalog, }); - return formatDistance(targetDate, now, { - addSuffix: true, - locale: localeCatalog, - }); + if (isDateWithoutTime(isoDate)) { + const targetPlainDate = Temporal.PlainDate.from(isoDate); + const todayPlainDate = Temporal.Now.plainDateISO(timeZone); + const dayDiff = todayPlainDate.until(targetPlainDate, { + largestUnit: 'day', + }).days; + + if (isDayMaximumPrecision) { + if (dayDiff === 0) return t`Today`; + if (dayDiff === -1) return t`Yesterday`; + if (dayDiff === 1) return t`Tomorrow`; + } + + return formatRelative( + targetPlainDate.toZonedDateTime(timeZone).epochMilliseconds, + todayPlainDate.toZonedDateTime(timeZone).epochMilliseconds, + ); + } + + const now = Temporal.Now.zonedDateTimeISO(timeZone); + const target = Temporal.Instant.from(isoDate).toZonedDateTimeISO(timeZone); + const isWithin24h = + Math.abs(target.until(now, { largestUnit: 'day' }).days) < 1; + + if (isDayMaximumPrecision || !isWithin24h) { + return formatRelative( + target.startOfDay().epochMilliseconds, + now.startOfDay().epochMilliseconds, + ); + } + + return formatRelative(target.epochMilliseconds, now.epochMilliseconds); }; diff --git a/packages/twenty-front/src/modules/localization/utils/formatPlainDateISOString.ts b/packages/twenty-front/src/modules/localization/utils/formatPlainDateISOString.ts new file mode 100644 index 00000000000..450f538241b --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/formatPlainDateISOString.ts @@ -0,0 +1,20 @@ +import { format, type Locale } from 'date-fns'; +import { Temporal } from 'temporal-polyfill'; + +export const formatPlainDateISOString = ({ + date, + dateFormat, + localeCatalog, +}: { + date: string; + dateFormat: string; + localeCatalog?: Locale; +}) => { + const plainDate = Temporal.PlainDate.from(date); + + return format( + new Date(plainDate.year, plainDate.month - 1, plainDate.day), + dateFormat, + { locale: localeCatalog }, + ); +}; diff --git a/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts b/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts index dea07023acd..738e6c11816 100644 --- a/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts +++ b/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts @@ -1,7 +1,7 @@ import { DateFormat } from '@/localization/constants/DateFormat'; import { FieldDateDisplayFormat } from '@/object-record/record-field/ui/types/FieldMetadata'; -import { enUS } from 'date-fns/locale'; import { subDays } from 'date-fns'; +import { enUS } from 'date-fns/locale'; import { formatDateString } from '~/utils/string/formatDateString'; describe('formatDateString', () => { @@ -112,4 +112,98 @@ describe('formatDateString', () => { expect(result).toBe(mockFormattedDate); }); + + describe('date-only values across user timezones', () => { + it('should render the same calendar date for a UTC user', () => { + const result = formatDateString({ + ...defaultParams, + value: '2022-01-01', + timeZone: 'UTC', + localeCatalog: enUS, + }); + + expect(result).toBe('1 Jan, 2022'); + }); + + it('should render the same calendar date for a negative-offset user', () => { + const result = formatDateString({ + ...defaultParams, + value: '2022-01-01', + timeZone: 'America/Los_Angeles', + localeCatalog: enUS, + }); + + expect(result).toBe('1 Jan, 2022'); + }); + + it('should render the same calendar date for a positive-offset user', () => { + const result = formatDateString({ + ...defaultParams, + value: '2022-01-01', + timeZone: 'Asia/Tokyo', + localeCatalog: enUS, + }); + + expect(result).toBe('1 Jan, 2022'); + }); + + it('should render a date-only value with CUSTOM displayFormat without timezone shift', () => { + const result = formatDateString({ + ...defaultParams, + value: '2022-01-01', + timeZone: 'America/Los_Angeles', + dateFieldSettings: { + displayFormat: FieldDateDisplayFormat.CUSTOM, + customUnicodeDateFormat: 'yyyy-MM-dd', + }, + localeCatalog: enUS, + }); + + expect(result).toBe('2022-01-01'); + }); + }); + + describe('date-only values with RELATIVE displayFormat across timezones', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should return "Tomorrow" when the target value is the next calendar day in the user timezone', () => { + // 2026-05-18 13:00 UTC: today in UTC is May 18, so "2026-05-19" is tomorrow. + jest.setSystemTime(new Date('2026-05-18T13:00:00Z')); + + const result = formatDateString({ + ...defaultParams, + value: '2026-05-19', + timeZone: 'UTC', + dateFieldSettings: { + displayFormat: FieldDateDisplayFormat.RELATIVE, + }, + localeCatalog: enUS, + }); + + expect(result).toBe('Tomorrow'); + }); + + it('should return "Today" for the same value when the user timezone has already rolled over', () => { + // 2026-05-18 21:00 UTC = 2026-05-19 06:00 in Asia/Tokyo, so "2026-05-19" is today there. + jest.setSystemTime(new Date('2026-05-18T21:00:00Z')); + + const result = formatDateString({ + ...defaultParams, + value: '2026-05-19', + timeZone: 'Asia/Tokyo', + dateFieldSettings: { + displayFormat: FieldDateDisplayFormat.RELATIVE, + }, + localeCatalog: enUS, + }); + + expect(result).toBe('Today'); + }); + }); }); diff --git a/packages/twenty-front/src/utils/string/formatDateString.ts b/packages/twenty-front/src/utils/string/formatDateString.ts index d7385a23f9b..484fd797ff1 100644 --- a/packages/twenty-front/src/utils/string/formatDateString.ts +++ b/packages/twenty-front/src/utils/string/formatDateString.ts @@ -31,6 +31,7 @@ export const formatDateString = ({ isoDate: value, isDayMaximumPrecision: true, localeCatalog, + timeZone, }); case FieldDateDisplayFormat.USER_SETTINGS: return formatDateISOStringToDate({ diff --git a/packages/twenty-front/src/utils/string/formatDateTimeString.ts b/packages/twenty-front/src/utils/string/formatDateTimeString.ts index dd5532c7ffd..c9acf4b7e97 100644 --- a/packages/twenty-front/src/utils/string/formatDateTimeString.ts +++ b/packages/twenty-front/src/utils/string/formatDateTimeString.ts @@ -32,7 +32,8 @@ export const formatDateTimeString = ({ case FieldDateDisplayFormat.RELATIVE: return formatDateISOStringToRelativeDate({ isoDate: value, - localeCatalog: localeCatalog, + localeCatalog, + timeZone, }); case FieldDateDisplayFormat.USER_SETTINGS: return formatDateISOStringToDateTime({ diff --git a/packages/twenty-shared/src/utils/date/__tests__/isDateWithoutTime.test.ts b/packages/twenty-shared/src/utils/date/__tests__/isDateWithoutTime.test.ts new file mode 100644 index 00000000000..d459a8986f0 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/__tests__/isDateWithoutTime.test.ts @@ -0,0 +1,63 @@ +import { isDateWithoutTime } from '../isDateWithoutTime'; + +describe('isDateWithoutTime', () => { + describe('when the string is an ISO 8601 date-only value', () => { + it('should return true for a standard YYYY-MM-DD date', () => { + expect(isDateWithoutTime('2022-01-01')).toBe(true); + }); + + it('should return true for the end of a year', () => { + expect(isDateWithoutTime('2024-12-31')).toBe(true); + }); + + it('should return true for a leap day', () => { + expect(isDateWithoutTime('2024-02-29')).toBe(true); + }); + }); + + describe('when the string is an ISO 8601 datetime value', () => { + it('should return false for a datetime with UTC marker', () => { + expect(isDateWithoutTime('2022-01-01T00:00:00Z')).toBe(false); + }); + + it('should return false for a datetime with milliseconds', () => { + expect(isDateWithoutTime('2022-01-01T15:30:00.000Z')).toBe(false); + }); + + it('should return false for a datetime with a positive offset', () => { + expect(isDateWithoutTime('2022-01-01T15:30:00+02:00')).toBe(false); + }); + + it('should return false for a datetime with a negative offset', () => { + expect(isDateWithoutTime('2022-01-01T15:30:00-05:00')).toBe(false); + }); + + it('should return false for a datetime without a timezone marker', () => { + expect(isDateWithoutTime('2022-01-01T15:30:00')).toBe(false); + }); + }); + + describe('when the string is malformed or has extra characters', () => { + it('should return false for an empty string', () => { + expect(isDateWithoutTime('')).toBe(false); + }); + + it('should return false when the date has surrounding whitespace', () => { + expect(isDateWithoutTime(' 2022-01-01')).toBe(false); + expect(isDateWithoutTime('2022-01-01 ')).toBe(false); + }); + + it('should return false for a year-month-only string', () => { + expect(isDateWithoutTime('2022-01')).toBe(false); + }); + + it('should return false for a year-only string', () => { + expect(isDateWithoutTime('2022')).toBe(false); + }); + + it('should return false for a non-ISO date format', () => { + expect(isDateWithoutTime('01/01/2022')).toBe(false); + expect(isDateWithoutTime('2022/01/01')).toBe(false); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/date/isDateWithoutTime.ts b/packages/twenty-shared/src/utils/date/isDateWithoutTime.ts new file mode 100644 index 00000000000..40b11a2a674 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/isDateWithoutTime.ts @@ -0,0 +1,3 @@ +export const isDateWithoutTime = (isoDateString: string): boolean => { + return /^\d{4}-\d{2}-\d{2}$/.test(isoDateString); +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 04cbb9668c6..9fcddf8b2b2 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -29,6 +29,7 @@ export { interpolateCommandMenuItemTemplate } from './command-menu-items/interpo export { resolveObjectMetadataLabel } from './command-menu-items/resolveObjectMetadataLabel'; export { safeGetNestedProperty } from './command-menu-items/safeGetNestedProperty'; export { computeDiffBetweenObjects } from './compute-diff-between-objects'; +export { isDateWithoutTime } from './date/isDateWithoutTime'; export { isPlainDateAfter } from './date/isPlainDateAfter'; export { isPlainDateBefore } from './date/isPlainDateBefore'; export { isPlainDateBeforeOrEqual } from './date/isPlainDateBeforeOrEqual';