Fix timezone display + translate dates (#12147)

Fixes https://github.com/twentyhq/twenty/issues/11566 + translates dates

- Date display bug: We had an issue with date (not date time) display
depending on the timezone the user had selected. The date is stored in
the db as yyyy-mm-dd, eg. 2025-05-01 for **May 1st, 2025**. When
returned this date is formatted in a UTC DateTime at midnight, so
2025-05-1 00:00:00. Then when displaying the date we were converting
this date using the timeZone, so 2025-04-30 17:00:00, thus displaying
**April 30th, 2025**. The fix chosen is that we should not take into
account the timezone for date (not date time!) displays as we always
want to show the same date.
- Date translation: dates were not translated, not in their default
display (_May 1st, 2025_) nor in their relative display (_about a month
ago_). The lib we use for date formatting, date-fns, offers a
translation option with pre-built `Locale`s from their lib.
Unfortunately and surprisingly we cannot just use directly string locale
codes (like `fr-FR`), cf [open issue on
date-fns](https://github.com/date-fns/date-fns/issues/3660). A util was
introduced to offset this by dynamically importing the right date-fns
Locale based on the locale code.
This commit is contained in:
Marie
2025-05-22 14:04:44 +02:00
committed by GitHub
parent 0ac4cc6899
commit b5544b6853
25 changed files with 352 additions and 68 deletions

View File

@ -1,5 +1,6 @@
import { formatDateISOStringToCustomUnicodeFormat } from '@/localization/utils/formatDateISOStringToCustomUnicodeFormat';
import { formatInTimeZone } from 'date-fns-tz';
import { enUS } from 'date-fns/locale';
jest.mock('date-fns-tz');
@ -15,16 +16,18 @@ describe('formatDateISOStringToCustomUnicodeFormat', () => {
it('should use provided timezone', () => {
formatInTimeZone.mockReturnValue('06:30');
const result = formatDateISOStringToCustomUnicodeFormat(
mockDate,
mockTimeZone,
mockTimeFormat,
);
const result = formatDateISOStringToCustomUnicodeFormat({
date: mockDate,
timeZone: mockTimeZone,
dateFormat: mockTimeFormat,
localeCatalog: enUS,
});
expect(formatInTimeZone).toHaveBeenCalledWith(
new Date(mockDate),
mockTimeZone,
mockTimeFormat,
{ locale: enUS },
);
expect(result).toBe('06:30');
});
@ -34,16 +37,18 @@ describe('formatDateISOStringToCustomUnicodeFormat', () => {
throw new Error();
});
const result = formatDateISOStringToCustomUnicodeFormat(
mockDate,
mockTimeZone,
'f',
);
const result = formatDateISOStringToCustomUnicodeFormat({
date: mockDate,
timeZone: mockTimeZone,
dateFormat: 'f',
localeCatalog: enUS,
});
expect(formatInTimeZone).toHaveBeenCalledWith(
new Date(mockDate),
mockTimeZone,
'f',
{ locale: enUS },
);
expect(result).toBe('Invalid format string');
});

View File

@ -1,12 +1,20 @@
import { formatInTimeZone } from 'date-fns-tz';
export const formatDateISOStringToCustomUnicodeFormat = (
date: string,
timeZone: string,
dateFormat: string,
) => {
export const formatDateISOStringToCustomUnicodeFormat = ({
date,
timeZone,
dateFormat,
localeCatalog,
}: {
date: string;
timeZone: string;
dateFormat: string;
localeCatalog: Locale;
}) => {
try {
return formatInTimeZone(new Date(date), timeZone, dateFormat);
return formatInTimeZone(new Date(date), timeZone, dateFormat, {
locale: localeCatalog,
});
} catch (e) {
return 'Invalid format string';
}

View File

@ -1,10 +1,18 @@
import { DateFormat } from '@/localization/constants/DateFormat';
import { formatInTimeZone } from 'date-fns-tz';
export const formatDateISOStringToDate = (
date: string,
timeZone: string,
dateFormat: DateFormat,
) => {
return formatInTimeZone(new Date(date), timeZone, dateFormat);
export const formatDateISOStringToDate = ({
date,
timeZone,
dateFormat,
localeCatalog,
}: {
date: string;
timeZone: string;
dateFormat: DateFormat;
localeCatalog?: Locale;
}) => {
return formatInTimeZone(new Date(date), timeZone, dateFormat, {
locale: localeCatalog,
});
};

View File

@ -2,15 +2,25 @@ import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { formatInTimeZone } from 'date-fns-tz';
export const formatDateISOStringToDateTime = (
date: string,
timeZone: string,
dateFormat: DateFormat,
timeFormat: TimeFormat,
) => {
export const formatDateISOStringToDateTime = ({
date,
timeZone,
dateFormat,
timeFormat,
localeCatalog,
}: {
date: string;
timeZone: string;
dateFormat: DateFormat;
timeFormat: TimeFormat;
localeCatalog: Locale;
}) => {
return formatInTimeZone(
new Date(date),
timeZone,
`${dateFormat} ${timeFormat}`,
{
locale: localeCatalog,
},
);
};

View File

@ -1,25 +1,40 @@
import { t } from '@lingui/core/macro';
import {
differenceInDays,
formatDistance,
isToday,
isTomorrow,
isYesterday,
Locale,
startOfDay,
} from 'date-fns';
export const formatDateISOStringToRelativeDate = (
isoDate: string,
export const formatDateISOStringToRelativeDate = ({
isoDate,
isDayMaximumPrecision = false,
) => {
localeCatalog,
}: {
isoDate: string;
isDayMaximumPrecision?: boolean;
localeCatalog: Locale;
}) => {
const now = new Date();
const targetDate = new Date(isoDate);
if (isDayMaximumPrecision && isToday(targetDate)) return 'Today';
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), {
addSuffix: true,
locale: localeCatalog,
});
return formatDistance(targetDate, now, { addSuffix: true });
return formatDistance(targetDate, now, {
addSuffix: true,
locale: localeCatalog,
});
};