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
@@ -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';