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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user