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:
Apetu Ezekiel
2026-05-25 15:54:36 +00:00
committed by GitHub
co-authored by Marie Stoppa
parent a3e41ec267
commit f613886511
14 changed files with 880 additions and 29 deletions
+19 -1
View File
@@ -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>
@@ -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');
});
});
});
@@ -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');
});
});
});
@@ -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');
});
});
});
@@ -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,
});
@@ -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';