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 16:54:36 +01:00
committed by GitHub
parent a3e41ec267
commit f613886511
14 changed files with 880 additions and 29 deletions
@@ -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({