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

@ -0,0 +1,17 @@
import { Locale } from 'date-fns';
import { enUS } from 'date-fns/locale';
import { APP_LOCALES } from 'twenty-shared/translations';
import { createState } from 'twenty-ui/utilities';
type DateLocaleState = {
locale?: keyof typeof APP_LOCALES;
localeCatalog: Locale;
};
export const dateLocaleState = createState<DateLocaleState>({
key: 'dateLocaleState',
defaultValue: {
locale: undefined,
localeCatalog: enUS,
},
});

View File

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

View File

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

View File

@ -1,10 +1,18 @@
import { DateFormat } from '@/localization/constants/DateFormat'; import { DateFormat } from '@/localization/constants/DateFormat';
import { formatInTimeZone } from 'date-fns-tz'; import { formatInTimeZone } from 'date-fns-tz';
export const formatDateISOStringToDate = ( export const formatDateISOStringToDate = ({
date: string, date,
timeZone: string, timeZone,
dateFormat: DateFormat, dateFormat,
) => { localeCatalog,
return formatInTimeZone(new Date(date), timeZone, dateFormat); }: {
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 { TimeFormat } from '@/localization/constants/TimeFormat';
import { formatInTimeZone } from 'date-fns-tz'; import { formatInTimeZone } from 'date-fns-tz';
export const formatDateISOStringToDateTime = ( export const formatDateISOStringToDateTime = ({
date: string, date,
timeZone: string, timeZone,
dateFormat: DateFormat, dateFormat,
timeFormat: TimeFormat, timeFormat,
) => { localeCatalog,
}: {
date: string;
timeZone: string;
dateFormat: DateFormat;
timeFormat: TimeFormat;
localeCatalog: Locale;
}) => {
return formatInTimeZone( return formatInTimeZone(
new Date(date), new Date(date),
timeZone, timeZone,
`${dateFormat} ${timeFormat}`, `${dateFormat} ${timeFormat}`,
{
locale: localeCatalog,
},
); );
}; };

View File

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

View File

@ -6,6 +6,7 @@ import { AggregateRecordsData } from '@/object-record/hooks/useAggregateRecords'
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel'; import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations'; import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { DATE_AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/DateAggregateOperations'; import { DATE_AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/DateAggregateOperations';
import { enUS } from 'date-fns/locale';
import { FieldMetadataType } from '~/generated/graphql'; import { FieldMetadataType } from '~/generated/graphql';
const MOCK_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a'; const MOCK_FIELD_ID = '7d2d7b5e-7b3e-4b4a-8b0a-7b3e4b4a8b0a';
@ -35,6 +36,7 @@ describe('computeAggregateValueAndLabel', () => {
objectMetadataItem: mockObjectMetadata, objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum, aggregateOperation: AGGREGATE_OPERATIONS.sum,
localeCatalog: enUS,
...defaultParams, ...defaultParams,
}); });
@ -53,6 +55,7 @@ describe('computeAggregateValueAndLabel', () => {
objectMetadataItem: mockObjectMetadata, objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum, aggregateOperation: AGGREGATE_OPERATIONS.sum,
localeCatalog: enUS,
...defaultParams, ...defaultParams,
}); });
@ -90,6 +93,7 @@ describe('computeAggregateValueAndLabel', () => {
objectMetadataItem: mockObjectMetadataWithPercentageField, objectMetadataItem: mockObjectMetadataWithPercentageField,
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.avg, aggregateOperation: AGGREGATE_OPERATIONS.avg,
localeCatalog: enUS,
...defaultParams, ...defaultParams,
}); });
@ -127,6 +131,7 @@ describe('computeAggregateValueAndLabel', () => {
objectMetadataItem: mockObjectMetadataWithDecimalsField, objectMetadataItem: mockObjectMetadataWithDecimalsField,
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum, aggregateOperation: AGGREGATE_OPERATIONS.sum,
localeCatalog: enUS,
...defaultParams, ...defaultParams,
}); });
@ -161,6 +166,7 @@ describe('computeAggregateValueAndLabel', () => {
objectMetadataItem: mockObjectMetadataWithDatetimeField, objectMetadataItem: mockObjectMetadataWithDatetimeField,
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: DATE_AGGREGATE_OPERATIONS.earliest, aggregateOperation: DATE_AGGREGATE_OPERATIONS.earliest,
localeCatalog: enUS,
...defaultParams, ...defaultParams,
}); });
@ -195,6 +201,7 @@ describe('computeAggregateValueAndLabel', () => {
objectMetadataItem: mockObjectMetadataWithDatetimeField, objectMetadataItem: mockObjectMetadataWithDatetimeField,
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: DATE_AGGREGATE_OPERATIONS.latest, aggregateOperation: DATE_AGGREGATE_OPERATIONS.latest,
localeCatalog: enUS,
...defaultParams, ...defaultParams,
}); });
@ -215,6 +222,7 @@ describe('computeAggregateValueAndLabel', () => {
const result = computeAggregateValueAndLabel({ const result = computeAggregateValueAndLabel({
data: mockData, data: mockData,
objectMetadataItem: mockObjectMetadata, objectMetadataItem: mockObjectMetadata,
localeCatalog: enUS,
...defaultParams, ...defaultParams,
}); });
@ -237,6 +245,7 @@ describe('computeAggregateValueAndLabel', () => {
objectMetadataItem: mockObjectMetadata, objectMetadataItem: mockObjectMetadata,
fieldMetadataId: MOCK_FIELD_ID, fieldMetadataId: MOCK_FIELD_ID,
aggregateOperation: AGGREGATE_OPERATIONS.sum, aggregateOperation: AGGREGATE_OPERATIONS.sum,
localeCatalog: enUS,
...defaultParams, ...defaultParams,
}); });

View File

@ -26,6 +26,7 @@ export const computeAggregateValueAndLabel = ({
dateFormat, dateFormat,
timeFormat, timeFormat,
timeZone, timeZone,
localeCatalog,
}: { }: {
data: AggregateRecordsData; data: AggregateRecordsData;
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
@ -34,6 +35,7 @@ export const computeAggregateValueAndLabel = ({
dateFormat: DateFormat; dateFormat: DateFormat;
timeFormat: TimeFormat; timeFormat: TimeFormat;
timeZone: string; timeZone: string;
localeCatalog: Locale;
}) => { }) => {
if (isEmpty(data)) { if (isEmpty(data)) {
return {}; return {};
@ -105,6 +107,7 @@ export const computeAggregateValueAndLabel = ({
dateFormat, dateFormat,
timeFormat, timeFormat,
dateFieldSettings, dateFieldSettings,
localeCatalog,
}); });
break; break;
} }
@ -116,6 +119,7 @@ export const computeAggregateValueAndLabel = ({
timeZone, timeZone,
dateFormat, dateFormat,
dateFieldSettings, dateFieldSettings,
localeCatalog,
}); });
break; break;
} }

View File

@ -4,10 +4,10 @@ import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat'; import { TimeFormat } from '@/localization/constants/TimeFormat';
import { DateFieldDisplay } from '@/object-record/record-field/meta-types/display/components/DateFieldDisplay'; import { DateFieldDisplay } from '@/object-record/record-field/meta-types/display/components/DateFieldDisplay';
import { UserContext } from '@/users/contexts/UserContext'; import { UserContext } from '@/users/contexts/UserContext';
import { ComponentDecorator } from 'twenty-ui/testing';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator'; import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory'; import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
import { ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta = { const meta: Meta = {
title: 'UI/Data/Field/Display/DateFieldDisplay', title: 'UI/Data/Field/Display/DateFieldDisplay',

View File

@ -4,10 +4,10 @@ import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat'; import { TimeFormat } from '@/localization/constants/TimeFormat';
import { DateTimeFieldDisplay } from '@/object-record/record-field/meta-types/display/components/DateTimeFieldDisplay'; import { DateTimeFieldDisplay } from '@/object-record/record-field/meta-types/display/components/DateTimeFieldDisplay';
import { UserContext } from '@/users/contexts/UserContext'; import { UserContext } from '@/users/contexts/UserContext';
import { ComponentDecorator } from 'twenty-ui/testing';
import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator'; import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator';
import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator';
import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory'; import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory';
import { ComponentDecorator } from 'twenty-ui/testing';
const meta: Meta = { const meta: Meta = {
title: 'UI/Data/Field/Display/DateTimeFieldDisplay', title: 'UI/Data/Field/Display/DateTimeFieldDisplay',

View File

@ -1,6 +1,7 @@
/* eslint-disable @nx/workspace-no-navigate-prefer-link */ /* eslint-disable @nx/workspace-no-navigate-prefer-link */
import { RecordTableEmptyStateDisplay } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateDisplay'; import { RecordTableEmptyStateDisplay } from '@/object-record/record-table/empty-state/components/RecordTableEmptyStateDisplay';
import { SettingsPath } from '@/types/SettingsPath'; import { SettingsPath } from '@/types/SettingsPath';
import { t } from '@lingui/core/macro';
import { IconSettings } from 'twenty-ui/display'; import { IconSettings } from 'twenty-ui/display';
import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { useNavigateSettings } from '~/hooks/useNavigateSettings';
@ -13,9 +14,9 @@ export const RecordTableEmptyStateRemote = () => {
return ( return (
<RecordTableEmptyStateDisplay <RecordTableEmptyStateDisplay
buttonTitle={'Go to Settings'} buttonTitle={t`Go to Settings`}
subTitle={'If this is unexpected, please verify your settings.'} subTitle={t`If this is unexpected, please verify your settings.`}
title={'No Data Available for Remote Table'} title={t`No Data Available for Remote Table`}
ButtonIcon={IconSettings} ButtonIcon={IconSettings}
animatedPlaceholderType="noRecord" animatedPlaceholderType="noRecord"
onClick={handleButtonClick} onClick={handleButtonClick}

View File

@ -12,6 +12,7 @@ import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react'; import { useContext } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
type UseAggregateRecordsProps = { type UseAggregateRecordsProps = {
objectMetadataItem: ObjectMetadataItem; objectMetadataItem: ObjectMetadataItem;
@ -35,6 +36,8 @@ export const useAggregateRecordsForHeader = ({
recordIndexKanbanAggregateOperationState, recordIndexKanbanAggregateOperationState,
); );
const dateLocale = useRecoilValue(dateLocaleState);
const { filterValueDependencies } = useFilterValueDependencies(); const { filterValueDependencies } = useFilterValueDependencies();
const { dateFormat, timeFormat, timeZone } = useContext(UserContext); const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
@ -65,6 +68,7 @@ export const useAggregateRecordsForHeader = ({
dateFormat, dateFormat,
timeFormat, timeFormat,
timeZone, timeZone,
localeCatalog: dateLocale.localeCatalog,
}); });
return { return {

View File

@ -16,6 +16,7 @@ import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react'; import { useContext } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
export const useAggregateRecordsForRecordTableColumnFooter = ( export const useAggregateRecordsForRecordTableColumnFooter = (
fieldMetadataId: string, fieldMetadataId: string,
@ -31,6 +32,8 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
currentRecordFiltersComponentState, currentRecordFiltersComponentState,
); );
const dateLocale = useRecoilValue(dateLocaleState);
const { filterValueDependencies } = useFilterValueDependencies(); const { filterValueDependencies } = useFilterValueDependencies();
const requestFilters = computeRecordGqlOperationFilter({ const requestFilters = computeRecordGqlOperationFilter({
@ -99,6 +102,7 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
dateFormat, dateFormat,
timeFormat, timeFormat,
timeZone, timeZone,
localeCatalog: dateLocale.localeCatalog,
}); });
return { return {

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil'; import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil';
import { currentUserState } from '@/auth/states/currentUserState'; import { currentUserState } from '@/auth/states/currentUserState';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState'; import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
@ -18,12 +18,15 @@ import { detectTimeZone } from '@/localization/utils/detectTimeZone';
import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDateFormatFromWorkspaceDateFormat'; import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDateFormatFromWorkspaceDateFormat';
import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat'; import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat';
import { AppPath } from '@/types/AppPath'; import { AppPath } from '@/types/AppPath';
import { getDateFnsLocale } from '@/ui/field/display/utils/getDateFnsLocale.util';
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember'; import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import { enUS } from 'date-fns/locale';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { WorkspaceMember } from '~/generated-metadata/graphql'; import { WorkspaceMember } from '~/generated-metadata/graphql';
import { useGetCurrentUserQuery } from '~/generated/graphql'; import { useGetCurrentUserQuery } from '~/generated/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { isMatchingLocation } from '~/utils/isMatchingLocation'; import { isMatchingLocation } from '~/utils/isMatchingLocation';
@ -38,8 +41,22 @@ export const UserProviderEffect = () => {
const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState);
const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState); const setCurrentUserWorkspace = useSetRecoilState(currentUserWorkspaceState);
const setWorkspaces = useSetRecoilState(workspacesState); const setWorkspaces = useSetRecoilState(workspacesState);
const setDateTimeFormat = useSetRecoilState(dateTimeFormatState); const setDateTimeFormat = useSetRecoilState(dateTimeFormatState);
const updateLocaleCatalog = useRecoilCallback(
({ snapshot, set }) =>
async (newLocale: keyof typeof APP_LOCALES) => {
const localeValue = snapshot.getLoadable(dateLocaleState).getValue();
if (localeValue.locale !== newLocale) {
getDateFnsLocale(newLocale).then((localeCatalog) => {
set(dateLocaleState, {
locale: newLocale,
localeCatalog: localeCatalog || enUS,
});
});
}
},
[],
);
const setCurrentWorkspaceMember = useSetRecoilState( const setCurrentWorkspaceMember = useSetRecoilState(
currentWorkspaceMemberState, currentWorkspaceMemberState,
@ -98,9 +115,11 @@ export const UserProviderEffect = () => {
}; };
if (isDefined(workspaceMember)) { if (isDefined(workspaceMember)) {
setCurrentWorkspaceMember( const updatedWorkspaceMember =
affectDefaultValuesOnEmptyWorkspaceMemberFields(workspaceMember), affectDefaultValuesOnEmptyWorkspaceMemberFields(workspaceMember);
); setCurrentWorkspaceMember(updatedWorkspaceMember);
updateLocaleCatalog(updatedWorkspaceMember.locale);
// TODO: factorize // TODO: factorize
setDateTimeFormat({ setDateTimeFormat({
@ -152,6 +171,7 @@ export const UserProviderEffect = () => {
setIsCurrentUserLoaded, setIsCurrentUserLoaded,
setDateTimeFormat, setDateTimeFormat,
setCurrentWorkspaceMembersWithDeleted, setCurrentWorkspaceMembersWithDeleted,
updateLocaleCatalog,
]); ]);
return <></>; return <></>;

View File

@ -7,6 +7,7 @@ import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode';
import { viewPickerIsPersistingComponentState } from '@/views/view-picker/states/viewPickerIsPersistingComponentState'; import { viewPickerIsPersistingComponentState } from '@/views/view-picker/states/viewPickerIsPersistingComponentState';
import { viewPickerKanbanFieldMetadataIdComponentState } from '@/views/view-picker/states/viewPickerKanbanFieldMetadataIdComponentState'; import { viewPickerKanbanFieldMetadataIdComponentState } from '@/views/view-picker/states/viewPickerKanbanFieldMetadataIdComponentState';
import { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPickerTypeComponentState'; import { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPickerTypeComponentState';
import { t } from '@lingui/core/macro';
import { Button } from 'twenty-ui/input'; import { Button } from 'twenty-ui/input';
export const ViewPickerEditButton = () => { export const ViewPickerEditButton = () => {
@ -49,7 +50,7 @@ export const ViewPickerEditButton = () => {
) { ) {
return ( return (
<Button <Button
title="Go to Settings" title={t`Go to Settings`}
onClick={navigateToSelectSettings} onClick={navigateToSelectSettings}
size="small" size="small"
accent="blue" accent="blue"

View File

@ -6,6 +6,7 @@ import {
} from '@/ui/layout/modal/components/ConfirmationModal'; } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { useCreateDraftFromWorkflowVersion } from '@/workflow/hooks/useCreateDraftFromWorkflowVersion'; import { useCreateDraftFromWorkflowVersion } from '@/workflow/hooks/useCreateDraftFromWorkflowVersion';
import { t } from '@lingui/core/macro';
import { useNavigateApp } from '~/hooks/useNavigateApp'; import { useNavigateApp } from '~/hooks/useNavigateApp';
import { getAppPath } from '~/utils/navigation/getAppPath'; import { getAppPath } from '~/utils/navigation/getAppPath';
@ -56,7 +57,7 @@ export const OverrideWorkflowDraftConfirmationModal = ({
closeModal(OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID); closeModal(OVERRIDE_WORKFLOW_DRAFT_CONFIRMATION_MODAL_ID);
}} }}
variant="secondary" variant="secondary"
title="Go to Draft" title={t`Go to Draft`}
fullWidth fullWidth
/> />
} }

View File

@ -1,16 +1,19 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { getDateFnsLocale } from '@/ui/field/display/utils/getDateFnsLocale.util';
import { Select } from '@/ui/input/components/Select'; import { Select } from '@/ui/input/components/Select';
import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItem'; import { useRefreshObjectMetadataItems } from '@/object-metadata/hooks/useRefreshObjectMetadataItem';
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { enUS } from 'date-fns/locale';
import { APP_LOCALES } from 'twenty-shared/translations'; import { APP_LOCALES } from 'twenty-shared/translations';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { dynamicActivate } from '~/utils/i18n/dynamicActivate'; import { dynamicActivate } from '~/utils/i18n/dynamicActivate';
import { logError } from '~/utils/logError'; import { logError } from '~/utils/logError';
@ -25,6 +28,7 @@ export const LocalePicker = () => {
const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState( const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState(
currentWorkspaceMemberState, currentWorkspaceMemberState,
); );
const setDateLocale = useSetRecoilState(dateLocaleState);
const isDebugMode = useRecoilValue(isDebugModeState); const isDebugMode = useRecoilValue(isDebugModeState);
const { updateOneRecord } = useUpdateOneRecord({ const { updateOneRecord } = useUpdateOneRecord({
@ -58,6 +62,12 @@ export const LocalePicker = () => {
}); });
await updateWorkspaceMember({ locale: value }); await updateWorkspaceMember({ locale: value });
const dateFnsLocale = await getDateFnsLocale(value);
setDateLocale({
locale: value,
localeCatalog: dateFnsLocale || enUS,
});
await dynamicActivate(value); await dynamicActivate(value);
try { try {
localStorage.setItem('locale', value); localStorage.setItem('locale', value);

View File

@ -1,5 +1,6 @@
import { DateFormat } from '@/localization/constants/DateFormat'; import { DateFormat } from '@/localization/constants/DateFormat';
import { FieldDateDisplayFormat } from '@/object-record/record-field/types/FieldMetadata'; import { FieldDateDisplayFormat } from '@/object-record/record-field/types/FieldMetadata';
import { enUS } from 'date-fns/locale';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { formatDateString } from '~/utils/string/formatDateString'; import { formatDateString } from '~/utils/string/formatDateString';
@ -13,6 +14,7 @@ describe('formatDateString', () => {
const result = formatDateString({ const result = formatDateString({
...defaultParams, ...defaultParams,
value: null, value: null,
localeCatalog: enUS,
}); });
expect(result).toBe(''); expect(result).toBe('');
@ -22,6 +24,7 @@ describe('formatDateString', () => {
const result = formatDateString({ const result = formatDateString({
...defaultParams, ...defaultParams,
value: undefined, value: undefined,
localeCatalog: enUS,
}); });
expect(result).toBe(''); expect(result).toBe('');
@ -37,6 +40,7 @@ describe('formatDateString', () => {
dateFieldSettings: { dateFieldSettings: {
displayFormat: FieldDateDisplayFormat.RELATIVE, displayFormat: FieldDateDisplayFormat.RELATIVE,
}, },
localeCatalog: enUS,
}); });
expect(result).toBe(mockRelativeDate); expect(result).toBe(mockRelativeDate);
@ -58,6 +62,7 @@ describe('formatDateString', () => {
dateFieldSettings: { dateFieldSettings: {
displayFormat: FieldDateDisplayFormat.USER_SETTINGS, displayFormat: FieldDateDisplayFormat.USER_SETTINGS,
}, },
localeCatalog: enUS,
}); });
expect(result).toBe(mockFormattedDate); expect(result).toBe(mockFormattedDate);
@ -83,6 +88,7 @@ describe('formatDateString', () => {
displayFormat: FieldDateDisplayFormat.CUSTOM, displayFormat: FieldDateDisplayFormat.CUSTOM,
customUnicodeDateFormat: 'yyyy', customUnicodeDateFormat: 'yyyy',
}, },
localeCatalog: enUS,
}); });
expect(result).toBe(mockFormattedDate); expect(result).toBe(mockFormattedDate);
@ -101,6 +107,7 @@ describe('formatDateString', () => {
const result = formatDateString({ const result = formatDateString({
...defaultParams, ...defaultParams,
value: mockDate, value: mockDate,
localeCatalog: enUS,
}); });
expect(result).toBe(mockFormattedDate); expect(result).toBe(mockFormattedDate);

View File

@ -1,6 +1,7 @@
import { DateFormat } from '@/localization/constants/DateFormat'; import { DateFormat } from '@/localization/constants/DateFormat';
import { TimeFormat } from '@/localization/constants/TimeFormat'; import { TimeFormat } from '@/localization/constants/TimeFormat';
import { FieldDateDisplayFormat } from '@/object-record/record-field/types/FieldMetadata'; import { FieldDateDisplayFormat } from '@/object-record/record-field/types/FieldMetadata';
import { enUS } from 'date-fns/locale';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { formatDateTimeString } from '~/utils/string/formatDateTimeString'; import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
@ -15,6 +16,7 @@ describe('formatDateTimeString', () => {
const result = formatDateTimeString({ const result = formatDateTimeString({
...defaultParams, ...defaultParams,
value: null, value: null,
localeCatalog: enUS,
}); });
expect(result).toBe(''); expect(result).toBe('');
@ -24,6 +26,7 @@ describe('formatDateTimeString', () => {
const result = formatDateTimeString({ const result = formatDateTimeString({
...defaultParams, ...defaultParams,
value: undefined, value: undefined,
localeCatalog: enUS,
}); });
expect(result).toBe(''); expect(result).toBe('');
@ -39,6 +42,7 @@ describe('formatDateTimeString', () => {
dateFieldSettings: { dateFieldSettings: {
displayFormat: FieldDateDisplayFormat.RELATIVE, displayFormat: FieldDateDisplayFormat.RELATIVE,
}, },
localeCatalog: enUS,
}); });
expect(result).toBe(mockRelativeDate); expect(result).toBe(mockRelativeDate);
@ -60,6 +64,7 @@ describe('formatDateTimeString', () => {
dateFieldSettings: { dateFieldSettings: {
displayFormat: FieldDateDisplayFormat.USER_SETTINGS, displayFormat: FieldDateDisplayFormat.USER_SETTINGS,
}, },
localeCatalog: enUS,
}); });
expect(result).toBe(mockFormattedDate); expect(result).toBe(mockFormattedDate);
@ -85,6 +90,7 @@ describe('formatDateTimeString', () => {
displayFormat: FieldDateDisplayFormat.CUSTOM, displayFormat: FieldDateDisplayFormat.CUSTOM,
customUnicodeDateFormat: 'yyyy', customUnicodeDateFormat: 'yyyy',
}, },
localeCatalog: enUS,
}); });
expect(result).toBe(mockFormattedDate); expect(result).toBe(mockFormattedDate);
@ -103,6 +109,7 @@ describe('formatDateTimeString', () => {
const result = formatDateTimeString({ const result = formatDateTimeString({
...defaultParams, ...defaultParams,
value: mockDate, value: mockDate,
localeCatalog: enUS,
}); });
expect(result).toBe(mockFormattedDate); expect(result).toBe(mockFormattedDate);

View File

@ -13,11 +13,13 @@ export const formatDateString = ({
timeZone, timeZone,
dateFormat, dateFormat,
dateFieldSettings, dateFieldSettings,
localeCatalog,
}: { }: {
timeZone: string; timeZone: string;
dateFormat: DateFormat; dateFormat: DateFormat;
value?: string | null; value?: string | null;
dateFieldSettings?: FieldDateMetadataSettings; dateFieldSettings?: FieldDateMetadataSettings;
localeCatalog: Locale;
}): string => { }): string => {
if (!isDefined(value)) { if (!isDefined(value)) {
return ''; return '';
@ -25,16 +27,31 @@ export const formatDateString = ({
switch (dateFieldSettings?.displayFormat) { switch (dateFieldSettings?.displayFormat) {
case FieldDateDisplayFormat.RELATIVE: case FieldDateDisplayFormat.RELATIVE:
return formatDateISOStringToRelativeDate(value); return formatDateISOStringToRelativeDate({
isoDate: value,
isDayMaximumPrecision: true,
localeCatalog,
});
case FieldDateDisplayFormat.USER_SETTINGS: case FieldDateDisplayFormat.USER_SETTINGS:
return formatDateISOStringToDate(value, timeZone, dateFormat); return formatDateISOStringToDate({
case FieldDateDisplayFormat.CUSTOM: date: value,
return formatDateISOStringToCustomUnicodeFormat(
value,
timeZone, timeZone,
dateFieldSettings.customUnicodeDateFormat, dateFormat,
); localeCatalog,
});
case FieldDateDisplayFormat.CUSTOM:
return formatDateISOStringToCustomUnicodeFormat({
date: value,
timeZone,
dateFormat: dateFieldSettings.customUnicodeDateFormat,
localeCatalog,
});
default: default:
return formatDateISOStringToDate(value, timeZone, dateFormat); return formatDateISOStringToDate({
date: value,
timeZone,
dateFormat,
localeCatalog,
});
} }
}; };

View File

@ -7,6 +7,7 @@ import {
FieldDateDisplayFormat, FieldDateDisplayFormat,
FieldDateMetadataSettings, FieldDateMetadataSettings,
} from '@/object-record/record-field/types/FieldMetadata'; } from '@/object-record/record-field/types/FieldMetadata';
import { Locale } from 'date-fns';
export const formatDateTimeString = ({ export const formatDateTimeString = ({
value, value,
@ -14,12 +15,14 @@ export const formatDateTimeString = ({
dateFormat, dateFormat,
timeFormat, timeFormat,
dateFieldSettings, dateFieldSettings,
localeCatalog,
}: { }: {
timeZone: string; timeZone: string;
dateFormat: DateFormat; dateFormat: DateFormat;
timeFormat: TimeFormat; timeFormat: TimeFormat;
value?: string | null; value?: string | null;
dateFieldSettings?: FieldDateMetadataSettings; dateFieldSettings?: FieldDateMetadataSettings;
localeCatalog: Locale;
}) => { }) => {
if (!value) { if (!value) {
return ''; return '';
@ -27,26 +30,32 @@ export const formatDateTimeString = ({
switch (dateFieldSettings?.displayFormat) { switch (dateFieldSettings?.displayFormat) {
case FieldDateDisplayFormat.RELATIVE: case FieldDateDisplayFormat.RELATIVE:
return formatDateISOStringToRelativeDate(value); return formatDateISOStringToRelativeDate({
isoDate: value,
localeCatalog: localeCatalog,
});
case FieldDateDisplayFormat.USER_SETTINGS: case FieldDateDisplayFormat.USER_SETTINGS:
return formatDateISOStringToDateTime( return formatDateISOStringToDateTime({
value, date: value,
timeZone, timeZone,
dateFormat, dateFormat,
timeFormat, timeFormat,
); localeCatalog,
});
case FieldDateDisplayFormat.CUSTOM: case FieldDateDisplayFormat.CUSTOM:
return formatDateISOStringToCustomUnicodeFormat( return formatDateISOStringToCustomUnicodeFormat({
value, date: value,
timeZone, timeZone,
dateFieldSettings.customUnicodeDateFormat, dateFormat: dateFieldSettings.customUnicodeDateFormat,
); localeCatalog,
});
default: default:
return formatDateISOStringToDateTime( return formatDateISOStringToDateTime({
value, date: value,
timeZone, timeZone,
dateFormat, dateFormat,
timeFormat, timeFormat,
); localeCatalog,
});
} }
}; };