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:
@@ -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