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:
@ -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');
|
||||
});
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user