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,6 +1,8 @@
import { FieldDateMetadataSettings } from '@/object-record/record-field/types/FieldMetadata';
import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { formatDateString } from '~/utils/string/formatDateString';
import { EllipsisDisplay } from './EllipsisDisplay';
@ -8,15 +10,16 @@ type DateDisplayProps = {
value: string | null | undefined;
dateFieldSettings?: FieldDateMetadataSettings;
};
export const DateDisplay = ({ value, dateFieldSettings }: DateDisplayProps) => {
const { dateFormat, timeZone } = useContext(UserContext);
const { dateFormat } = useContext(UserContext);
const dateLocale = useRecoilValue(dateLocaleState);
const formattedDate = formatDateString({
value,
timeZone,
timeZone: 'UTC', // Needed because we have db-stored date (yyyy-mm-dd) is converted to UTC dateTime by TypeORM
dateFormat,
dateFieldSettings,
localeCatalog: dateLocale.localeCatalog,
});
return <EllipsisDisplay>{formattedDate}</EllipsisDisplay>;

View File

@ -1,6 +1,8 @@
import { FieldDateMetadataSettings } from '@/object-record/record-field/types/FieldMetadata';
import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
import { EllipsisDisplay } from './EllipsisDisplay';
@ -14,6 +16,7 @@ export const DateTimeDisplay = ({
dateFieldSettings,
}: DateTimeDisplayProps) => {
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const dateLocale = useRecoilValue(dateLocaleState);
const formattedDate = formatDateTimeString({
value,
@ -21,6 +24,7 @@ export const DateTimeDisplay = ({
dateFormat,
timeFormat,
dateFieldSettings,
localeCatalog: dateLocale.localeCatalog,
});
return <EllipsisDisplay>{formattedDate}</EllipsisDisplay>;

View File

@ -0,0 +1,44 @@
import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { FieldDateDisplayFormat } from '@/object-record/record-field/types/FieldMetadata';
import { UserContext } from '@/users/contexts/UserContext';
import { expect } from '@storybook/jest';
import type { Meta, StoryObj } from '@storybook/react';
import { within } from '@storybook/testing-library';
import { DateDisplay } from '../DateDisplay';
const meta: Meta<typeof DateDisplay> = {
title: 'UI/Field/Display/DateDisplay',
component: DateDisplay,
decorators: [
(Story) => (
<UserContext.Provider
value={{
dateFormat: DateFormat.DAY_FIRST,
timeFormat: TimeFormat.HOUR_24,
timeZone: 'Pacific/Tahiti', // Needed for our test on time difference
}}
>
<Story />
</UserContext.Provider>
),
],
};
export default meta;
type Story = StoryObj<typeof DateDisplay>;
export const Default: Story = {
args: {
value: '2025-05-01T00:00:00.000Z',
dateFieldSettings: {
displayFormat: FieldDateDisplayFormat.USER_SETTINGS,
},
},
play: async ({ canvasElement }) => {
// Test that date is rightfully displayed and not converted to timeZone date which would be on April 30th
const canvas = within(canvasElement);
const dateElement = await canvas.findByText('1 May, 2025');
expect(dateElement).toBeInTheDocument();
},
};

View File

@ -0,0 +1,76 @@
import { APP_LOCALES } from 'twenty-shared/translations';
type AppLocale = keyof typeof APP_LOCALES;
export const getDateFnsLocaleImport = (locale: AppLocale) => {
switch (locale) {
case 'af-ZA':
return import('date-fns/locale/af');
case 'ar-SA':
return import('date-fns/locale/ar');
case 'ca-ES':
return import('date-fns/locale/ca');
case 'cs-CZ':
return import('date-fns/locale/cs');
case 'da-DK':
return import('date-fns/locale/da');
case 'de-DE':
return import('date-fns/locale/de');
case 'el-GR':
return import('date-fns/locale/el');
case 'en':
case 'pseudo-en':
return import('date-fns/locale/en-US');
case 'es-ES':
return import('date-fns/locale/es');
case 'fi-FI':
return import('date-fns/locale/fi');
case 'fr-FR':
return import('date-fns/locale/fr');
case 'he-IL':
return import('date-fns/locale/he');
case 'hu-HU':
return import('date-fns/locale/hu');
case 'it-IT':
return import('date-fns/locale/it');
case 'ja-JP':
return import('date-fns/locale/ja');
case 'ko-KR':
return import('date-fns/locale/ko');
case 'nl-NL':
return import('date-fns/locale/nl');
case 'no-NO':
return import('date-fns/locale/nb');
case 'pl-PL':
return import('date-fns/locale/pl');
case 'pt-BR':
case 'pt-PT':
return import('date-fns/locale/pt');
case 'ro-RO':
return import('date-fns/locale/ro');
case 'ru-RU':
return import('date-fns/locale/ru');
case 'sr-Cyrl':
return import('date-fns/locale/sr');
case 'sv-SE':
return import('date-fns/locale/sv');
case 'tr-TR':
return import('date-fns/locale/tr');
case 'uk-UA':
return import('date-fns/locale/uk');
case 'vi-VN':
return import('date-fns/locale/vi');
case 'zh-CN':
return import('date-fns/locale/zh-CN');
case 'zh-TW':
return import('date-fns/locale/zh-TW');
default: {
return import('date-fns/locale/en-US');
}
}
};
export const getDateFnsLocale = async (localeString?: string | null) => {
return getDateFnsLocaleImport(localeString as AppLocale)
.then((m) => m.default as unknown as Locale)
.catch((_e) => undefined);
};