fix(localization): parse date-only ISO strings as local midnight in relative date formatter (#20630)
## 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 <marie@twenty.com>
This commit is contained in:
co-authored by
Marie Stoppa
parent
a3e41ec267
commit
f613886511
@@ -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 = {
|
||||
<ClickOutsideListenerContext.Provider
|
||||
value={{ excludedClickOutsideId: undefined }}
|
||||
>
|
||||
<Story />
|
||||
<UserContext.Provider value={STORYBOOK_DEFAULT_USER_CONTEXT}>
|
||||
<Story />
|
||||
</UserContext.Provider>
|
||||
</ClickOutsideListenerContext.Provider>
|
||||
</FileUploadProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
+112
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+124
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+385
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+7
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+41
-26
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export const formatDateString = ({
|
||||
isoDate: value,
|
||||
isDayMaximumPrecision: true,
|
||||
localeCatalog,
|
||||
timeZone,
|
||||
});
|
||||
case FieldDateDisplayFormat.USER_SETTINGS:
|
||||
return formatDateISOStringToDate({
|
||||
|
||||
@@ -32,7 +32,8 @@ export const formatDateTimeString = ({
|
||||
case FieldDateDisplayFormat.RELATIVE:
|
||||
return formatDateISOStringToRelativeDate({
|
||||
isoDate: value,
|
||||
localeCatalog: localeCatalog,
|
||||
localeCatalog,
|
||||
timeZone,
|
||||
});
|
||||
case FieldDateDisplayFormat.USER_SETTINGS:
|
||||
return formatDateISOStringToDateTime({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export const isDateWithoutTime = (isoDateString: string): boolean => {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(isoDateString);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user