From ccf4d1eeec7cd21abccfd52c0b2ea980e409d5cc Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Tue, 30 Jul 2024 14:52:10 +0200 Subject: [PATCH] Date formatting per workspace member settings (#6408) Implement date formatting per workspace member settings We'll need another round to maybe initialize all workspaces on the default settings. For now the default behavior is to take system settings if nothing is found in DB. --------- Co-authored-by: Weiko --- packages/twenty-front/src/App.tsx | 10 +- .../twenty-front/src/generated/graphql.tsx | 31 +++- .../components/CalendarCurrentEventCursor.tsx | 4 +- .../components/CalendarDayCardContent.tsx | 2 +- .../calendar/components/CalendarEventRow.tsx | 15 +- .../calendar/contexts/CalendarContext.ts | 2 +- .../__tests__/useCalendarEvents.test.tsx | 68 ++++++-- .../calendar/hooks/useCalendarEvents.ts | 17 +- .../components/EventCardCalendarEvent.tsx | 14 +- .../src/modules/auth/hooks/useAuth.ts | 37 ++++- .../localization/constants/DateFormat.ts | 6 + .../constants/IanaTimeZones.ts | 0 .../localization/constants/TimeFormat.ts | 5 + .../states/dateTimeFormatState.ts | 17 ++ .../utils/__tests__/detectDateFormat.test.ts | 69 +++++++++ .../utils/__tests__/detectTimeFormat.test.ts | 30 ++++ .../__tests__/formatTimeZoneLabel.test.ts | 21 +++ .../localization/utils/detectDateFormat.ts | 17 ++ .../localization/utils/detectTimeFormat.ts | 10 ++ .../utils/detectTimeZone.ts | 0 .../utils/findAvailableTimeZoneOption.ts | 2 +- .../localization/utils/formatDataTime.ts | 15 ++ .../utils/formatDateISOStringToDate.ts | 10 ++ .../utils/formatDateISOStringToDateTime.ts | 16 ++ .../localization/utils/formatTimeZoneLabel.ts | 29 ++++ .../getDateFormatFromWorkspaceDateFormat.ts | 20 +++ .../getTimeFormatFromWorkspaceTimeFormat.ts | 18 +++ .../getWorkspaceDateFormatFromDateFormat.ts | 19 +++ .../getWorkspaceTimeFormatFromTimeFormat.ts | 17 ++ .../perf/DateFieldDisplay.perf.stories.tsx | 16 ++ .../DateTimeFieldDisplay.perf.stories.tsx | 16 ++ ...ettingsAccountsCalendarChannelsGeneral.tsx | 2 +- ...ttingsAccountsCalendarDateFormatSelect.tsx | 10 +- ...ettingsAccountsCalendarDisplaySettings.tsx | 13 +- ...ttingsAccountsCalendarTimeFormatSelect.tsx | 10 +- ...SettingsAccountsCalendarTimeZoneSelect.tsx | 5 +- .../AvailableTimezoneOptionsByLabel.ts | 2 +- .../settings/accounts/constants/DateFormat.ts | 4 - .../settings/accounts/constants/TimeFormat.ts | 4 - .../field/display/components/DateDisplay.tsx | 19 ++- .../display/components/DateTimeDisplay.tsx | 19 ++- .../date/components/DateTimeInput.tsx | 14 +- .../date/components/InternalDatePicker.tsx | 126 ++++++++++----- .../modules/users/components/UserProvider.tsx | 14 +- .../users/components/UserProviderEffect.tsx | 23 +++ .../src/modules/users/contexts/UserContext.ts | 13 ++ .../graphql/fragments/userQueryFragment.ts | 3 + .../workspace-member/types/WorkspaceMember.ts | 10 +- .../SettingsAppearance.stories.tsx | 2 +- .../components/DateTimeSettings.tsx | 145 ++++++++++++++++++ .../DateTimeSettingsDateFormatSelect.tsx | 59 +++++++ .../DateTimeSettingsTimeFormatSelect.tsx | 51 ++++++ .../DateTimeSettingsTimeZoneSelect.tsx | 34 ++++ .../components}/SettingsAppearance.tsx | 8 + .../mock-data/timeline-calendar-events.ts | 6 +- packages/twenty-front/src/utils/date-utils.ts | 2 +- .../utils/format/__tests__/formatDate.test.ts | 6 +- .../src/utils/format/formatDate.ts | 18 ++- .../src/command/command-logger.ts | 20 ++- .../user/dtos/workspace-member.dto.ts | 15 +- .../user/services/user.service.ts | 3 + .../commands/workspace-health.command.ts | 23 ++- .../constants/standard-field-ids.ts | 3 + .../workspace-member.workspace-entity.ts | 102 ++++++++++++ 64 files changed, 1176 insertions(+), 165 deletions(-) create mode 100644 packages/twenty-front/src/modules/localization/constants/DateFormat.ts rename packages/twenty-front/src/modules/{settings/accounts => localization}/constants/IanaTimeZones.ts (100%) create mode 100644 packages/twenty-front/src/modules/localization/constants/TimeFormat.ts create mode 100644 packages/twenty-front/src/modules/localization/states/dateTimeFormatState.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/__tests__/detectDateFormat.test.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/__tests__/detectTimeFormat.test.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/__tests__/formatTimeZoneLabel.test.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/detectDateFormat.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/detectTimeFormat.ts rename packages/twenty-front/src/modules/{settings/accounts => localization}/utils/detectTimeZone.ts (100%) rename packages/twenty-front/src/modules/{settings/accounts => localization}/utils/findAvailableTimeZoneOption.ts (84%) create mode 100644 packages/twenty-front/src/modules/localization/utils/formatDataTime.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDate.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDateTime.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/formatTimeZoneLabel.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/getDateFormatFromWorkspaceDateFormat.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/getTimeFormatFromWorkspaceTimeFormat.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/getWorkspaceDateFormatFromDateFormat.ts create mode 100644 packages/twenty-front/src/modules/localization/utils/getWorkspaceTimeFormatFromTimeFormat.ts delete mode 100644 packages/twenty-front/src/modules/settings/accounts/constants/DateFormat.ts delete mode 100644 packages/twenty-front/src/modules/settings/accounts/constants/TimeFormat.ts create mode 100644 packages/twenty-front/src/modules/users/contexts/UserContext.ts create mode 100644 packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettings.tsx create mode 100644 packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsDateFormatSelect.tsx create mode 100644 packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsTimeFormatSelect.tsx create mode 100644 packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsTimeZoneSelect.tsx rename packages/twenty-front/src/pages/settings/{ => profile/appearance/components}/SettingsAppearance.tsx (76%) diff --git a/packages/twenty-front/src/App.tsx b/packages/twenty-front/src/App.tsx index a6bf9f2183db..dd65834c4f78 100644 --- a/packages/twenty-front/src/App.tsx +++ b/packages/twenty-front/src/App.tsx @@ -34,6 +34,7 @@ import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider'; import { PageTitle } from '@/ui/utilities/page-title/PageTitle'; import { UserProvider } from '@/users/components/UserProvider'; import { UserProviderEffect } from '@/users/components/UserProviderEffect'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { CommandMenuEffect } from '~/effect-components/CommandMenuEffect'; import { GotoHotkeysEffect } from '~/effect-components/GotoHotkeysEffect'; import { PageChangeEffect } from '~/effect-components/PageChangeEffect'; @@ -74,18 +75,17 @@ import { SettingsIntegrationEditDatabaseConnection } from '~/pages/settings/inte import { SettingsIntegrationNewDatabaseConnection } from '~/pages/settings/integrations/SettingsIntegrationNewDatabaseConnection'; import { SettingsIntegrations } from '~/pages/settings/integrations/SettingsIntegrations'; import { SettingsIntegrationShowDatabaseConnection } from '~/pages/settings/integrations/SettingsIntegrationShowDatabaseConnection'; +import { SettingsAppearance } from '~/pages/settings/profile/appearance/components/SettingsAppearance'; import { Releases } from '~/pages/settings/Releases'; -import { SettingsAppearance } from '~/pages/settings/SettingsAppearance'; +import { SettingsServerlessFunctionDetailWrapper } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetailWrapper'; +import { SettingsServerlessFunctions } from '~/pages/settings/serverless-functions/SettingsServerlessFunctions'; +import { SettingsServerlessFunctionsNew } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionsNew'; import { SettingsBilling } from '~/pages/settings/SettingsBilling'; import { SettingsProfile } from '~/pages/settings/SettingsProfile'; import { SettingsWorkspace } from '~/pages/settings/SettingsWorkspace'; import { SettingsWorkspaceMembers } from '~/pages/settings/SettingsWorkspaceMembers'; import { Tasks } from '~/pages/tasks/Tasks'; import { getPageTitleFromPath } from '~/utils/title-utils'; -import { SettingsServerlessFunctions } from '~/pages/settings/serverless-functions/SettingsServerlessFunctions'; -import { SettingsServerlessFunctionsNew } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionsNew'; -import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; -import { SettingsServerlessFunctionDetailWrapper } from '~/pages/settings/serverless-functions/SettingsServerlessFunctionDetailWrapper'; const ProvidersThatNeedRouterContext = () => { const { pathname } = useLocation(); diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 97d333280da4..9a44597c4d2c 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1015,11 +1015,29 @@ export type WorkspaceMember = { __typename?: 'WorkspaceMember'; avatarUrl?: Maybe; colorScheme: Scalars['String']; + dateFormat?: Maybe; id: Scalars['UUID']; - locale: Scalars['String']; + locale?: Maybe; name: FullName; + timeFormat?: Maybe; + timeZone?: Maybe; }; +/** Date format as Month first, Day first, Year first or system as default */ +export enum WorkspaceMemberDateFormatEnum { + DayFirst = 'DAY_FIRST', + MonthFirst = 'MONTH_FIRST', + System = 'SYSTEM', + YearFirst = 'YEAR_FIRST' +} + +/** Time time as Military, Standard or system as default */ +export enum WorkspaceMemberTimeFormatEnum { + Hour_12 = 'HOUR_12', + Hour_24 = 'HOUR_24', + System = 'SYSTEM' +} + export type Field = { __typename?: 'field'; createdAt: Scalars['DateTime']; @@ -1242,7 +1260,7 @@ export type ImpersonateMutationVariables = Exact<{ }>; -export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type RenewTokenMutationVariables = Exact<{ appToken: Scalars['String']; @@ -1274,7 +1292,7 @@ export type VerifyMutationVariables = Exact<{ }>; -export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type CheckUserExistsQueryVariables = Exact<{ email: Scalars['String']; @@ -1335,7 +1353,7 @@ export type GetAisqlQueryQueryVariables = Exact<{ export type GetAisqlQueryQuery = { __typename?: 'Query', getAISQLQuery: { __typename?: 'AISQLQueryResult', sqlQuery: string, sqlQueryResult?: string | null, queryFailedErrorMessage?: string | null } }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -1352,7 +1370,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, currentCacheVersion?: string | null, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; export type AddUserToWorkspaceMutationVariables = Exact<{ inviteHash: Scalars['String']; @@ -1506,6 +1524,9 @@ export const UserQueryFragmentFragmentDoc = gql` colorScheme avatarUrl locale + timeZone + dateFormat + timeFormat } defaultWorkspace { id diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx index 02d11fa87e7a..9d0211cf1b8d 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx @@ -1,4 +1,3 @@ -import { useContext, useMemo, useState } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { @@ -8,13 +7,14 @@ import { startOfMonth, } from 'date-fns'; import { AnimatePresence, motion } from 'framer-motion'; +import { useContext, useMemo, useState } from 'react'; import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate'; import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded'; import { hasCalendarEventStarted } from '@/activities/calendar/utils/hasCalendarEventStarted'; -import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; +import { TimelineCalendarEvent } from '~/generated/graphql'; type CalendarCurrentEventCursorProps = { calendarEvent: TimelineCalendarEvent; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx index c4ff26571109..ca656f3b52c1 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx @@ -5,7 +5,7 @@ import { differenceInSeconds, endOfDay, format } from 'date-fns'; import { CalendarEventRow } from '@/activities/calendar/components/CalendarEventRow'; import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; import { CardContent } from '@/ui/layout/card/components/CardContent'; -import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; +import { TimelineCalendarEvent } from '~/generated/graphql'; type CalendarDayCardContentProps = { calendarEvents: TimelineCalendarEvent[]; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx index bdaf54830ddd..a246ff432f31 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx @@ -3,7 +3,13 @@ import styled from '@emotion/styled'; import { format } from 'date-fns'; import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; -import { Avatar, AvatarGroup, IconArrowRight, IconLock } from 'twenty-ui'; +import { + Avatar, + AvatarGroup, + IconArrowRight, + IconLock, + isDefined, +} from 'twenty-ui'; import { CalendarCurrentEventCursor } from '@/activities/calendar/components/CalendarCurrentEventCursor'; import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; @@ -14,9 +20,10 @@ import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEv import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { Card } from '@/ui/layout/card/components/Card'; import { CardContent } from '@/ui/layout/card/components/CardContent'; -import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; -import { CalendarChannelVisibility } from '~/generated/graphql'; -import { isDefined } from '~/utils/isDefined'; +import { + CalendarChannelVisibility, + TimelineCalendarEvent, +} from '~/generated-metadata/graphql'; type CalendarEventRowProps = { calendarEvent: TimelineCalendarEvent; diff --git a/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts b/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts index 423b48f822e1..e012d90f204d 100644 --- a/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts +++ b/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts @@ -1,6 +1,6 @@ import { createContext } from 'react'; -import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; +import { TimelineCalendarEvent } from '~/generated/graphql'; type CalendarContextValue = { calendarEventsByDayTime: Record; diff --git a/packages/twenty-front/src/modules/activities/calendar/hooks/__tests__/useCalendarEvents.test.tsx b/packages/twenty-front/src/modules/activities/calendar/hooks/__tests__/useCalendarEvents.test.tsx index d25294901fd5..c335d8a8a7a9 100644 --- a/packages/twenty-front/src/modules/activities/calendar/hooks/__tests__/useCalendarEvents.test.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/hooks/__tests__/useCalendarEvents.test.tsx @@ -1,41 +1,87 @@ import { act, renderHook } from '@testing-library/react'; import { useCalendarEvents } from '@/activities/calendar/hooks/useCalendarEvents'; -import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; -import { CalendarChannelVisibility } from '~/generated/graphql'; +import { + CalendarChannelVisibility, + TimelineCalendarEvent, +} from '~/generated/graphql'; -const calendarEvents: CalendarEvent[] = [ +const calendarEvents: TimelineCalendarEvent[] = [ { id: '1234', - externalCreatedAt: '2024-02-17T20:45:43.854Z', isFullDay: false, startsAt: '2024-02-17T21:45:27.822Z', visibility: CalendarChannelVisibility.Metadata, - __typename: 'CalendarEvent', + conferenceLink: { + primaryLinkUrl: 'https://meet.google.com/abc-def-ghi', + primaryLinkLabel: 'Google Meet', + __typename: 'LinksMetadata', + }, + conferenceSolution: 'GoogleMeet', + description: 'Description', + endsAt: '2024-02-17T22:45:27.822Z', + isCanceled: false, + location: 'Location', + participants: [], + title: 'Title', + __typename: 'TimelineCalendarEvent', }, { id: '5678', - externalCreatedAt: '2024-02-18T19:43:37.854Z', isFullDay: false, startsAt: '2024-02-18T21:43:27.754Z', visibility: CalendarChannelVisibility.ShareEverything, - __typename: 'CalendarEvent', + conferenceLink: { + primaryLinkUrl: 'https://meet.google.com/abc-def-ghi', + primaryLinkLabel: 'Google Meet', + __typename: 'LinksMetadata', + }, + conferenceSolution: 'GoogleMeet', + description: 'Description', + endsAt: '2024-02-17T22:45:27.822Z', + isCanceled: false, + location: 'Location', + participants: [], + title: 'Title', + __typename: 'TimelineCalendarEvent', }, { id: '91011', - externalCreatedAt: '2024-02-19T20:45:20.854Z', isFullDay: true, startsAt: '2024-02-19T22:05:27.653Z', visibility: CalendarChannelVisibility.Metadata, - __typename: 'CalendarEvent', + conferenceLink: { + primaryLinkUrl: 'https://meet.google.com/abc-def-ghi', + primaryLinkLabel: 'Google Meet', + __typename: 'LinksMetadata', + }, + conferenceSolution: 'GoogleMeet', + description: 'Description', + endsAt: '2024-02-17T22:45:27.822Z', + isCanceled: false, + location: 'Location', + participants: [], + title: 'Title', + __typename: 'TimelineCalendarEvent', }, { id: '121314', - externalCreatedAt: '2024-02-20T20:45:12.854Z', isFullDay: true, startsAt: '2024-02-20T23:15:23.150Z', visibility: CalendarChannelVisibility.ShareEverything, - __typename: 'CalendarEvent', + conferenceLink: { + primaryLinkUrl: 'https://meet.google.com/abc-def-ghi', + primaryLinkLabel: 'Google Meet', + __typename: 'LinksMetadata', + }, + conferenceSolution: 'GoogleMeet', + description: 'Description', + endsAt: '2024-02-17T22:45:27.822Z', + isCanceled: false, + location: 'Location', + participants: [], + title: 'Title', + __typename: 'TimelineCalendarEvent', }, ]; diff --git a/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts b/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts index 15baaf0b2c9e..751122499b62 100644 --- a/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts +++ b/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts @@ -1,21 +1,14 @@ -import { useMemo, useState } from 'react'; import { getYear, isThisMonth, startOfDay, startOfMonth } from 'date-fns'; +import { useMemo, useState } from 'react'; -import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; import { findUpcomingCalendarEvent } from '@/activities/calendar/utils/findUpcomingCalendarEvent'; import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; +import { TimelineCalendarEvent } from '~/generated/graphql'; import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy'; import { isDefined } from '~/utils/isDefined'; import { sortDesc } from '~/utils/sort'; -type CalendarEventGeneric = Omit< - CalendarEvent, - 'participants' | 'externalCreatedAt' | '__typename' ->; - -export const useCalendarEvents = ( - calendarEvents: T[], -) => { +export const useCalendarEvents = (calendarEvents: TimelineCalendarEvent[]) => { const calendarEventsByDayTime = groupArrayItemsBy( calendarEvents, (calendarEvent) => @@ -36,14 +29,14 @@ export const useCalendarEvents = ( const monthTimesByYear = groupArrayItemsBy(sortedMonthTimes, getYear); - const getPreviousCalendarEvent = (calendarEvent: T) => { + const getPreviousCalendarEvent = (calendarEvent: TimelineCalendarEvent) => { const calendarEventIndex = calendarEvents.indexOf(calendarEvent); return calendarEventIndex < calendarEvents.length - 1 ? calendarEvents[calendarEventIndex + 1] : undefined; }; - const getNextCalendarEvent = (calendarEvent: T) => { + const getNextCalendarEvent = (calendarEvent: TimelineCalendarEvent) => { const calendarEventIndex = calendarEvents.indexOf(calendarEvent); return calendarEventIndex > 0 ? calendarEvents[calendarEventIndex - 1] diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx b/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx index 8d572594f4fc..c345d81f4d33 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx +++ b/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx @@ -6,6 +6,8 @@ import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; +import { UserContext } from '@/users/contexts/UserContext'; +import { useContext } from 'react'; import { formatToHumanReadableDay, formatToHumanReadableMonth, @@ -107,6 +109,8 @@ export const EventCardCalendarEvent = ({ const { openCalendarEventRightDrawer } = useOpenCalendarEventRightDrawer(); + const { timeZone } = useContext(UserContext); + if (isDefined(error)) { const shouldHideMessageContent = error.graphQLErrors.some( (e) => e.extensions?.code === 'FORBIDDEN', @@ -138,12 +142,14 @@ export const EventCardCalendarEvent = ({ throw new Error("Can't render a calendarEvent without a start date"); } - const startsAtMonth = formatToHumanReadableMonth(startsAtDate); + const startsAtMonth = formatToHumanReadableMonth(startsAtDate, timeZone); - const startsAtDay = formatToHumanReadableDay(startsAtDate); + const startsAtDay = formatToHumanReadableDay(startsAtDate, timeZone); - const startsAtHour = formatToHumanReadableTime(startsAtDate); - const endsAtHour = endsAtDate ? formatToHumanReadableTime(endsAtDate) : null; + const startsAtHour = formatToHumanReadableTime(startsAtDate, timeZone); + const endsAtHour = endsAtDate + ? formatToHumanReadableTime(endsAtDate, timeZone) + : null; return ( { const goToRecoilSnapshot = useGotoRecoilSnapshot(); + const setDateTimeFormat = useSetRecoilState(dateTimeFormatState); + const handleChallenge = useCallback( async (email: string, password: string, captchaToken?: string) => { const challengeResult = await challenge({ @@ -96,17 +104,42 @@ export const useAuth = () => { setTokenPair(verifyResult.data?.verify.tokens); const user = verifyResult.data?.verify.user; + let workspaceMember = null; + setCurrentUser(user); + if (isDefined(user.workspaceMember)) { workspaceMember = { ...user.workspaceMember, colorScheme: user.workspaceMember?.colorScheme as ColorScheme, }; + setCurrentWorkspaceMember(workspaceMember); + + // TODO: factorize with UserProviderEffect + setDateTimeFormat({ + timeZone: + workspaceMember.timeZone && workspaceMember.timeZone !== 'system' + ? workspaceMember.timeZone + : detectTimeZone(), + dateFormat: isDefined(user.workspaceMember.dateFormat) + ? getDateFormatFromWorkspaceDateFormat( + user.workspaceMember.dateFormat, + ) + : detectDateFormat(), + timeFormat: isDefined(user.workspaceMember.timeFormat) + ? getTimeFormatFromWorkspaceTimeFormat( + user.workspaceMember.timeFormat, + ) + : detectTimeFormat(), + }); } + const workspace = user.defaultWorkspace ?? null; + setCurrentWorkspace(workspace); + if (isDefined(verifyResult.data?.verify.user.workspaces)) { const validWorkspaces = verifyResult.data?.verify.user.workspaces .filter( @@ -117,6 +150,7 @@ export const useAuth = () => { setWorkspaces(validWorkspaces); } + return { user, workspaceMember, @@ -131,6 +165,7 @@ export const useAuth = () => { setCurrentWorkspaceMember, setCurrentWorkspace, setWorkspaces, + setDateTimeFormat, ], ); diff --git a/packages/twenty-front/src/modules/localization/constants/DateFormat.ts b/packages/twenty-front/src/modules/localization/constants/DateFormat.ts new file mode 100644 index 000000000000..503e28ce3b03 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/constants/DateFormat.ts @@ -0,0 +1,6 @@ +export enum DateFormat { + SYSTEM = 'SYSTEM', + MONTH_FIRST = 'MMM d, yyyy', // US + DAY_FIRST = 'd MMM, yyyy', // UK + YEAR_FIRST = 'yyyy MMM d', +} diff --git a/packages/twenty-front/src/modules/settings/accounts/constants/IanaTimeZones.ts b/packages/twenty-front/src/modules/localization/constants/IanaTimeZones.ts similarity index 100% rename from packages/twenty-front/src/modules/settings/accounts/constants/IanaTimeZones.ts rename to packages/twenty-front/src/modules/localization/constants/IanaTimeZones.ts diff --git a/packages/twenty-front/src/modules/localization/constants/TimeFormat.ts b/packages/twenty-front/src/modules/localization/constants/TimeFormat.ts new file mode 100644 index 000000000000..a169872b3357 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/constants/TimeFormat.ts @@ -0,0 +1,5 @@ +export enum TimeFormat { + SYSTEM = 'SYSTEM', + HOUR_24 = 'HH:mm', + HOUR_12 = 'h:mm aa', +} diff --git a/packages/twenty-front/src/modules/localization/states/dateTimeFormatState.ts b/packages/twenty-front/src/modules/localization/states/dateTimeFormatState.ts new file mode 100644 index 000000000000..2392151e3a59 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/states/dateTimeFormatState.ts @@ -0,0 +1,17 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { createState } from 'twenty-ui'; + +export const dateTimeFormatState = createState<{ + timeZone: string; + dateFormat: DateFormat; + timeFormat: TimeFormat; +}>({ + key: 'dateTimeFormatState', + defaultValue: { + timeZone: detectTimeZone(), + dateFormat: DateFormat.MONTH_FIRST, + timeFormat: TimeFormat['HOUR_24'], + }, +}); diff --git a/packages/twenty-front/src/modules/localization/utils/__tests__/detectDateFormat.test.ts b/packages/twenty-front/src/modules/localization/utils/__tests__/detectDateFormat.test.ts new file mode 100644 index 000000000000..2b641f302a63 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/__tests__/detectDateFormat.test.ts @@ -0,0 +1,69 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { detectDateFormat } from '@/localization/utils/detectDateFormat'; + +describe('detectDateFormat', () => { + it('should return DateFormat.MONTH_FIRST if the detected format starts with month', () => { + // Mock the Intl.DateTimeFormat to return a specific format + const mockDateTimeFormat = jest.fn().mockReturnValue({ + formatToParts: () => [ + { type: 'month', value: '01' }, + { type: 'day', value: '01' }, + { type: 'year', value: '2022' }, + ], + supportedLocalesOf: () => [], + }) as any; + global.Intl.DateTimeFormat = mockDateTimeFormat; + + const result = detectDateFormat(); + + expect(result).toBe(DateFormat.MONTH_FIRST); + }); + + it('should return DateFormat.DAY_FIRST if the detected format starts with day', () => { + // Mock the Intl.DateTimeFormat to return a specific format + const mockDateTimeFormat = jest.fn().mockReturnValue({ + formatToParts: () => [ + { type: 'day', value: '01' }, + { type: 'month', value: '01' }, + { type: 'year', value: '2022' }, + ], + }) as any; + global.Intl.DateTimeFormat = mockDateTimeFormat; + + const result = detectDateFormat(); + + expect(result).toBe(DateFormat.DAY_FIRST); + }); + + it('should return DateFormat.YEAR_FIRST if the detected format starts with year', () => { + // Mock the Intl.DateTimeFormat to return a specific format + const mockDateTimeFormat = jest.fn().mockReturnValue({ + formatToParts: () => [ + { type: 'year', value: '2022' }, + { type: 'month', value: '01' }, + { type: 'day', value: '01' }, + ], + }) as any; + global.Intl.DateTimeFormat = mockDateTimeFormat; + + const result = detectDateFormat(); + + expect(result).toBe(DateFormat.YEAR_FIRST); + }); + + it('should return DateFormat.MONTH_FIRST by default if the detected format does not match any specific order', () => { + // Mock the Intl.DateTimeFormat to return a specific format + const mockDateTimeFormat = jest.fn().mockReturnValue({ + formatToParts: () => [ + { type: 'hour', value: '12' }, + { type: 'minute', value: '00' }, + { type: 'second', value: '00' }, + ], + }) as any; + global.Intl.DateTimeFormat = mockDateTimeFormat; + + const result = detectDateFormat(); + + expect(result).toBe(DateFormat.MONTH_FIRST); + }); +}); diff --git a/packages/twenty-front/src/modules/localization/utils/__tests__/detectTimeFormat.test.ts b/packages/twenty-front/src/modules/localization/utils/__tests__/detectTimeFormat.test.ts new file mode 100644 index 000000000000..6433495789ee --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/__tests__/detectTimeFormat.test.ts @@ -0,0 +1,30 @@ +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { detectTimeFormat } from '@/localization/utils/detectTimeFormat'; + +describe('detectTimeFormat', () => { + it('should return TimeFormat.HOUR_12 if the hour format is 12-hour', () => { + // Mock the resolvedOptions method to return hour12 as true + const mockResolvedOptions = jest.fn(() => ({ hour12: true })); + Intl.DateTimeFormat = jest.fn().mockImplementation(() => ({ + resolvedOptions: mockResolvedOptions, + })) as any; + + const result = detectTimeFormat(); + + expect(result).toBe(TimeFormat.HOUR_12); + expect(mockResolvedOptions).toHaveBeenCalled(); + }); + + it('should return TimeFormat.HOUR_24 if the hour format is 24-hour', () => { + // Mock the resolvedOptions method to return hour12 as false + const mockResolvedOptions = jest.fn(() => ({ hour12: false })); + Intl.DateTimeFormat = jest.fn().mockImplementation(() => ({ + resolvedOptions: mockResolvedOptions, + })) as any; + + const result = detectTimeFormat(); + + expect(result).toBe(TimeFormat.HOUR_24); + expect(mockResolvedOptions).toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/localization/utils/__tests__/formatTimeZoneLabel.test.ts b/packages/twenty-front/src/modules/localization/utils/__tests__/formatTimeZoneLabel.test.ts new file mode 100644 index 000000000000..bec60cc4e0ad --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/__tests__/formatTimeZoneLabel.test.ts @@ -0,0 +1,21 @@ +import { formatTimeZoneLabel } from '@/localization/utils/formatTimeZoneLabel'; + +describe('formatTimeZoneLabel', () => { + it('should format the time zone label correctly when location is included in the label', () => { + const ianaTimeZone = 'Europe/Paris'; + const expectedLabel = '(GMT+02:00) Central European Summer Time - Paris'; + + const formattedLabel = formatTimeZoneLabel(ianaTimeZone); + + expect(formattedLabel).toEqual(expectedLabel); + }); + + it('should format the time zone label correctly when location is not included in the label', () => { + const ianaTimeZone = 'America/New_York'; + const expectedLabel = '(GMT-04:00) Eastern Daylight Time - New York'; + + const formattedLabel = formatTimeZoneLabel(ianaTimeZone); + + expect(formattedLabel).toEqual(expectedLabel); + }); +}); diff --git a/packages/twenty-front/src/modules/localization/utils/detectDateFormat.ts b/packages/twenty-front/src/modules/localization/utils/detectDateFormat.ts new file mode 100644 index 000000000000..b503ef826e60 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/detectDateFormat.ts @@ -0,0 +1,17 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; + +export const detectDateFormat = (): DateFormat => { + const date = new Date(); + const formatter = new Intl.DateTimeFormat(navigator.language); + const parts = formatter.formatToParts(date); + + const partOrder = parts + .filter((part) => ['year', 'month', 'day'].includes(part.type)) + .map((part) => part.type); + + if (partOrder[0] === 'month') return DateFormat.MONTH_FIRST; + if (partOrder[0] === 'day') return DateFormat.DAY_FIRST; + if (partOrder[0] === 'year') return DateFormat.YEAR_FIRST; + + return DateFormat.MONTH_FIRST; +}; diff --git a/packages/twenty-front/src/modules/localization/utils/detectTimeFormat.ts b/packages/twenty-front/src/modules/localization/utils/detectTimeFormat.ts new file mode 100644 index 000000000000..0580333afb90 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/detectTimeFormat.ts @@ -0,0 +1,10 @@ +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { isDefined } from '~/utils/isDefined'; + +export const detectTimeFormat = () => { + const isHour12 = Intl.DateTimeFormat(navigator.language, { + hour: 'numeric', + }).resolvedOptions().hour12; + if (isDefined(isHour12) && isHour12) return TimeFormat.HOUR_12; + return TimeFormat.HOUR_24; +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/utils/detectTimeZone.ts b/packages/twenty-front/src/modules/localization/utils/detectTimeZone.ts similarity index 100% rename from packages/twenty-front/src/modules/settings/accounts/utils/detectTimeZone.ts rename to packages/twenty-front/src/modules/localization/utils/detectTimeZone.ts diff --git a/packages/twenty-front/src/modules/settings/accounts/utils/findAvailableTimeZoneOption.ts b/packages/twenty-front/src/modules/localization/utils/findAvailableTimeZoneOption.ts similarity index 84% rename from packages/twenty-front/src/modules/settings/accounts/utils/findAvailableTimeZoneOption.ts rename to packages/twenty-front/src/modules/localization/utils/findAvailableTimeZoneOption.ts index ab3b6cb5e440..a1ce9a26b869 100644 --- a/packages/twenty-front/src/modules/settings/accounts/utils/findAvailableTimeZoneOption.ts +++ b/packages/twenty-front/src/modules/localization/utils/findAvailableTimeZoneOption.ts @@ -1,5 +1,5 @@ +import { formatTimeZoneLabel } from '@/localization/utils/formatTimeZoneLabel'; import { AVAILABLE_TIME_ZONE_OPTIONS_BY_LABEL } from '@/settings/accounts/constants/AvailableTimezoneOptionsByLabel'; -import { formatTimeZoneLabel } from '@/settings/accounts/utils/formatTimeZoneLabel'; /** * Finds the matching available IANA time zone select option from a given IANA time zone. diff --git a/packages/twenty-front/src/modules/localization/utils/formatDataTime.ts b/packages/twenty-front/src/modules/localization/utils/formatDataTime.ts new file mode 100644 index 000000000000..fd4aa504cca6 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/formatDataTime.ts @@ -0,0 +1,15 @@ +import { formatInTimeZone } from 'date-fns-tz'; +import { parseDate } from '~/utils/date-utils'; + +export const formatDatetime = ( + date: Date | string, + timeZone: string, + dateFormat: string, + timeFormat: string, +) => { + return formatInTimeZone( + parseDate(date).toJSDate(), + timeZone, + `${dateFormat} ${timeFormat}`, + ); +}; diff --git a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDate.ts b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDate.ts new file mode 100644 index 000000000000..9cbbf415e858 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDate.ts @@ -0,0 +1,10 @@ +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}`); +}; diff --git a/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDateTime.ts b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDateTime.ts new file mode 100644 index 000000000000..ad83892516c4 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/formatDateISOStringToDateTime.ts @@ -0,0 +1,16 @@ +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, +) => { + return formatInTimeZone( + new Date(date), + timeZone, + `${dateFormat} ${timeFormat}`, + ); +}; diff --git a/packages/twenty-front/src/modules/localization/utils/formatTimeZoneLabel.ts b/packages/twenty-front/src/modules/localization/utils/formatTimeZoneLabel.ts new file mode 100644 index 000000000000..f9640e2c2570 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/formatTimeZoneLabel.ts @@ -0,0 +1,29 @@ +import { formatInTimeZone } from 'date-fns-tz'; +import defaultLocale from 'date-fns/locale/en-US'; + +/** + * Formats a IANA time zone to a select option label. + * @param ianaTimeZone IANA time zone + * @returns Formatted label + * @example 'Europe/Paris' => '(GMT+01:00) Central European Time - Paris' + */ +export const formatTimeZoneLabel = (ianaTimeZone: string) => { + const timeZoneWithGmtOffset = formatInTimeZone( + Date.now(), + ianaTimeZone, + `(OOOO) zzzz`, + { locale: defaultLocale }, + ); + const ianaTimeZoneParts = ianaTimeZone.split('/'); + const location = + ianaTimeZoneParts.length > 1 + ? ianaTimeZoneParts.slice(-1)[0].replaceAll('_', ' ') + : undefined; + + const timeZoneLabel = + !location || timeZoneWithGmtOffset.includes(location) + ? timeZoneWithGmtOffset + : [timeZoneWithGmtOffset, location].join(' - '); + + return timeZoneLabel; +}; diff --git a/packages/twenty-front/src/modules/localization/utils/getDateFormatFromWorkspaceDateFormat.ts b/packages/twenty-front/src/modules/localization/utils/getDateFormatFromWorkspaceDateFormat.ts new file mode 100644 index 000000000000..f32bdbb93355 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/getDateFormatFromWorkspaceDateFormat.ts @@ -0,0 +1,20 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { detectDateFormat } from '@/localization/utils/detectDateFormat'; +import { WorkspaceMemberDateFormatEnum } from '~/generated/graphql'; + +export const getDateFormatFromWorkspaceDateFormat = ( + workspaceDateFormat: WorkspaceMemberDateFormatEnum, +) => { + switch (workspaceDateFormat) { + case WorkspaceMemberDateFormatEnum.System: + return detectDateFormat(); + case WorkspaceMemberDateFormatEnum.MonthFirst: + return DateFormat.MONTH_FIRST; + case WorkspaceMemberDateFormatEnum.DayFirst: + return DateFormat.DAY_FIRST; + case WorkspaceMemberDateFormatEnum.YearFirst: + return DateFormat.YEAR_FIRST; + default: + return DateFormat.MONTH_FIRST; + } +}; diff --git a/packages/twenty-front/src/modules/localization/utils/getTimeFormatFromWorkspaceTimeFormat.ts b/packages/twenty-front/src/modules/localization/utils/getTimeFormatFromWorkspaceTimeFormat.ts new file mode 100644 index 000000000000..f6aebb43779b --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/getTimeFormatFromWorkspaceTimeFormat.ts @@ -0,0 +1,18 @@ +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { detectTimeFormat } from '@/localization/utils/detectTimeFormat'; +import { WorkspaceMemberTimeFormatEnum } from '~/generated/graphql'; + +export const getTimeFormatFromWorkspaceTimeFormat = ( + workspaceTimeFormat: WorkspaceMemberTimeFormatEnum, +) => { + switch (workspaceTimeFormat) { + case WorkspaceMemberTimeFormatEnum.System: + return detectTimeFormat(); + case WorkspaceMemberTimeFormatEnum.Hour_24: + return TimeFormat.HOUR_24; + case WorkspaceMemberTimeFormatEnum.Hour_12: + return TimeFormat.HOUR_12; + default: + return TimeFormat.HOUR_24; + } +}; diff --git a/packages/twenty-front/src/modules/localization/utils/getWorkspaceDateFormatFromDateFormat.ts b/packages/twenty-front/src/modules/localization/utils/getWorkspaceDateFormatFromDateFormat.ts new file mode 100644 index 000000000000..251a575824f5 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/getWorkspaceDateFormatFromDateFormat.ts @@ -0,0 +1,19 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { WorkspaceMemberDateFormatEnum } from '~/generated/graphql'; + +export const getWorkspaceDateFormatFromDateFormat = ( + dateFormat: DateFormat, +) => { + switch (dateFormat) { + case DateFormat.SYSTEM: + return WorkspaceMemberDateFormatEnum.System; + case DateFormat.MONTH_FIRST: + return WorkspaceMemberDateFormatEnum.MonthFirst; + case DateFormat.DAY_FIRST: + return WorkspaceMemberDateFormatEnum.DayFirst; + case DateFormat.YEAR_FIRST: + return WorkspaceMemberDateFormatEnum.YearFirst; + default: + return WorkspaceMemberDateFormatEnum.MonthFirst; + } +}; diff --git a/packages/twenty-front/src/modules/localization/utils/getWorkspaceTimeFormatFromTimeFormat.ts b/packages/twenty-front/src/modules/localization/utils/getWorkspaceTimeFormatFromTimeFormat.ts new file mode 100644 index 000000000000..58a563f96177 --- /dev/null +++ b/packages/twenty-front/src/modules/localization/utils/getWorkspaceTimeFormatFromTimeFormat.ts @@ -0,0 +1,17 @@ +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { WorkspaceMemberTimeFormatEnum } from '~/generated/graphql'; + +export const getWorkspaceTimeFormatFromTimeFormat = ( + timeFormat: TimeFormat, +) => { + switch (timeFormat) { + case TimeFormat.SYSTEM: + return WorkspaceMemberTimeFormatEnum.System; + case TimeFormat.HOUR_24: + return WorkspaceMemberTimeFormatEnum.Hour_24; + case TimeFormat.HOUR_12: + return WorkspaceMemberTimeFormatEnum.Hour_12; + default: + return WorkspaceMemberTimeFormatEnum.Hour_24; + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateFieldDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateFieldDisplay.perf.stories.tsx index b3ab3a732cd9..35e9c6562e6a 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateFieldDisplay.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateFieldDisplay.perf.stories.tsx @@ -1,7 +1,10 @@ import { Meta, StoryObj } from '@storybook/react'; import { ComponentDecorator } from 'twenty-ui'; +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; import { DateFieldDisplay } from '@/object-record/record-field/meta-types/display/components/DateFieldDisplay'; +import { UserContext } from '@/users/contexts/UserContext'; import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator'; import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory'; @@ -12,6 +15,19 @@ const meta: Meta = { MemoryRouterDecorator, getFieldDecorator('person', 'createdAt'), ComponentDecorator, + (Story) => { + return ( + + + + ); + }, ], component: DateFieldDisplay, args: {}, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx index f974e587ab8e..079b84520e91 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/perf/DateTimeFieldDisplay.perf.stories.tsx @@ -1,7 +1,10 @@ import { Meta, StoryObj } from '@storybook/react'; import { ComponentDecorator } from 'twenty-ui'; +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; import { DateTimeFieldDisplay } from '@/object-record/record-field/meta-types/display/components/DateTimeFieldDisplay'; +import { UserContext } from '@/users/contexts/UserContext'; import { getFieldDecorator } from '~/testing/decorators/getFieldDecorator'; import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; import { getProfilingStory } from '~/testing/profiling/utils/getProfilingStory'; @@ -12,6 +15,19 @@ const meta: Meta = { MemoryRouterDecorator, getFieldDecorator('person', 'createdAt'), ComponentDecorator, + (Story) => { + return ( + + + + ); + }, ], component: DateTimeFieldDisplay, args: {}, diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx index b1e7c5134607..4b1a873a198d 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsGeneral.tsx @@ -10,7 +10,7 @@ import { H2Title } from 'twenty-ui'; import { CalendarChannelVisibility, TimelineCalendarEvent, -} from '~/generated-metadata/graphql'; +} from '~/generated/graphql'; const StyledGeneralContainer = styled.div` display: flex; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect.tsx index 278af533e3ab..7555556c97a8 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect.tsx @@ -1,6 +1,6 @@ import { formatInTimeZone } from 'date-fns-tz'; -import { DateFormat } from '@/settings/accounts/constants/DateFormat'; +import { DateFormat } from '@/localization/constants/DateFormat'; import { Select } from '@/ui/input/components/Select'; type SettingsAccountsCalendarDateFormatSelectProps = { @@ -21,12 +21,12 @@ export const SettingsAccountsCalendarDateFormatSelect = ({ value={value} options={[ { - label: formatInTimeZone(Date.now(), timeZone, DateFormat.US), - value: DateFormat.US, + label: formatInTimeZone(Date.now(), timeZone, DateFormat.MONTH_FIRST), + value: DateFormat.MONTH_FIRST, }, { - label: formatInTimeZone(Date.now(), timeZone, DateFormat.UK), - value: DateFormat.UK, + label: formatInTimeZone(Date.now(), timeZone, DateFormat.DAY_FIRST), + value: DateFormat.DAY_FIRST, }, ]} onChange={onChange} diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDisplaySettings.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDisplaySettings.tsx index 4f01ed496407..05231f0cff69 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDisplaySettings.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDisplaySettings.tsx @@ -1,12 +1,13 @@ -import { useState } from 'react'; import styled from '@emotion/styled'; +import { useState } from 'react'; import { SettingsAccountsCalendarDateFormatSelect } from '@/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect'; import { SettingsAccountsCalendarTimeFormatSelect } from '@/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect'; import { SettingsAccountsCalendarTimeZoneSelect } from '@/settings/accounts/components/SettingsAccountsCalendarTimeZoneSelect'; -import { DateFormat } from '@/settings/accounts/constants/DateFormat'; -import { TimeFormat } from '@/settings/accounts/constants/TimeFormat'; -import { detectTimeZone } from '@/settings/accounts/utils/detectTimeZone'; + +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; const StyledContainer = styled.div` display: flex; @@ -19,10 +20,10 @@ export const SettingsAccountsCalendarDisplaySettings = () => { const [timeZone, setTimeZone] = useState(detectTimeZone()); // TODO: use the user's saved date format. - const [dateFormat, setDateFormat] = useState(DateFormat.US); + const [dateFormat, setDateFormat] = useState(DateFormat.MONTH_FIRST); // TODO: use the user's saved time format. - const [timeFormat, setTimeFormat] = useState(TimeFormat['24h']); + const [timeFormat, setTimeFormat] = useState(TimeFormat['HOUR_24']); return ( diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect.tsx index cab95257dd15..bb59eb95f98e 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect.tsx @@ -1,6 +1,6 @@ import { formatInTimeZone } from 'date-fns-tz'; -import { TimeFormat } from '@/settings/accounts/constants/TimeFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; import { Select } from '@/ui/input/components/Select'; type SettingsAccountsCalendarTimeFormatSelectProps = { @@ -24,17 +24,17 @@ export const SettingsAccountsCalendarTimeFormatSelect = ({ label: `24h (${formatInTimeZone( Date.now(), timeZone, - TimeFormat['24h'], + TimeFormat.HOUR_24, )})`, - value: TimeFormat['24h'], + value: TimeFormat.HOUR_24, }, { label: `12h (${formatInTimeZone( Date.now(), timeZone, - TimeFormat['12h'], + TimeFormat.HOUR_12, )})`, - value: TimeFormat['12h'], + value: TimeFormat.HOUR_12, }, ]} onChange={onChange} diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeZoneSelect.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeZoneSelect.tsx index 81fba15c6240..db3155a4ee26 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeZoneSelect.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeZoneSelect.tsx @@ -1,6 +1,7 @@ +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { findAvailableTimeZoneOption } from '@/localization/utils/findAvailableTimeZoneOption'; import { AVAILABLE_TIMEZONE_OPTIONS } from '@/settings/accounts/constants/AvailableTimezoneOptions'; -import { detectTimeZone } from '@/settings/accounts/utils/detectTimeZone'; -import { findAvailableTimeZoneOption } from '@/settings/accounts/utils/findAvailableTimeZoneOption'; + import { Select } from '@/ui/input/components/Select'; type SettingsAccountsCalendarTimeZoneSelectProps = { diff --git a/packages/twenty-front/src/modules/settings/accounts/constants/AvailableTimezoneOptionsByLabel.ts b/packages/twenty-front/src/modules/settings/accounts/constants/AvailableTimezoneOptionsByLabel.ts index 391352a93653..0b78523b4a82 100644 --- a/packages/twenty-front/src/modules/settings/accounts/constants/AvailableTimezoneOptionsByLabel.ts +++ b/packages/twenty-front/src/modules/settings/accounts/constants/AvailableTimezoneOptionsByLabel.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @nx/workspace-max-consts-per-file */ -import { IANA_TIME_ZONES } from '@/settings/accounts/constants/IanaTimeZones'; +import { IANA_TIME_ZONES } from '@/localization/constants/IanaTimeZones'; import { formatTimeZoneLabel } from '@/settings/accounts/utils/formatTimeZoneLabel'; import { SelectOption } from '@/ui/input/components/Select'; diff --git a/packages/twenty-front/src/modules/settings/accounts/constants/DateFormat.ts b/packages/twenty-front/src/modules/settings/accounts/constants/DateFormat.ts deleted file mode 100644 index 985814c679e0..000000000000 --- a/packages/twenty-front/src/modules/settings/accounts/constants/DateFormat.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum DateFormat { - US = 'MMM d, yyyy', - UK = 'd MMM yyyy', -} diff --git a/packages/twenty-front/src/modules/settings/accounts/constants/TimeFormat.ts b/packages/twenty-front/src/modules/settings/accounts/constants/TimeFormat.ts deleted file mode 100644 index e168d1fda1cd..000000000000 --- a/packages/twenty-front/src/modules/settings/accounts/constants/TimeFormat.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum TimeFormat { - '24h' = 'HH:mm', - '12h' = 'h:mm aa', -} diff --git a/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx index 54053bc59f9c..982e7a9af07e 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/DateDisplay.tsx @@ -1,13 +1,18 @@ -import { formatISOStringToHumanReadableDate } from '~/utils/date-utils'; - +import { formatDateISOStringToDate } from '@/localization/utils/formatDateISOStringToDate'; +import { UserContext } from '@/users/contexts/UserContext'; +import { useContext } from 'react'; import { EllipsisDisplay } from './EllipsisDisplay'; type DateDisplayProps = { value: string | null | undefined; }; -export const DateDisplay = ({ value }: DateDisplayProps) => ( - - {value ? formatISOStringToHumanReadableDate(value) : ''} - -); +export const DateDisplay = ({ value }: DateDisplayProps) => { + const { dateFormat, timeZone } = useContext(UserContext); + + const formattedDate = value + ? formatDateISOStringToDate(value, timeZone, dateFormat) + : ''; + + return {formattedDate}; +}; diff --git a/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx index 7ce008c6af3a..f90e4a0c560c 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx @@ -1,13 +1,18 @@ -import { formatISOStringToHumanReadableDateTime } from '~/utils/date-utils'; - +import { formatDateISOStringToDateTime } from '@/localization/utils/formatDateISOStringToDateTime'; +import { UserContext } from '@/users/contexts/UserContext'; +import { useContext } from 'react'; import { EllipsisDisplay } from './EllipsisDisplay'; type DateTimeDisplayProps = { value: string | null | undefined; }; -export const DateTimeDisplay = ({ value }: DateTimeDisplayProps) => ( - - {value ? formatISOStringToHumanReadableDateTime(value) : ''} - -); +export const DateTimeDisplay = ({ value }: DateTimeDisplayProps) => { + const { dateFormat, timeFormat, timeZone } = useContext(UserContext); + + const formattedDate = value + ? formatDateISOStringToDateTime(value, timeZone, dateFormat, timeFormat) + : ''; + + return {formattedDate}; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimeInput.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimeInput.tsx index 239f8050922f..3fac47943aa0 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimeInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimeInput.tsx @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useState } from 'react'; -import { useIMask } from 'react-imask'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; import { DateTime } from 'luxon'; +import { useCallback, useEffect, useState } from 'react'; +import { useIMask } from 'react-imask'; import { DATE_BLOCKS } from '@/ui/input/components/internal/date/constants/DateBlocks'; import { DATE_MASK } from '@/ui/input/components/internal/date/constants/DateMask'; @@ -41,6 +41,7 @@ type DateTimeInputProps = { onChange?: (date: Date | null) => void; date: Date | null; isDateTimeInput?: boolean; + userTimezone?: string; onError?: (error: Error) => void; }; @@ -48,6 +49,7 @@ export const DateTimeInput = ({ date, onChange, isDateTimeInput, + userTimezone, }: DateTimeInputProps) => { const parsingFormat = isDateTimeInput ? 'MM/dd/yyyy HH:mm' : 'MM/dd/yyyy'; @@ -55,7 +57,7 @@ export const DateTimeInput = ({ const parseDateToString = useCallback( (date: any) => { - const dateParsed = DateTime.fromJSDate(date); + const dateParsed = DateTime.fromJSDate(date, { zone: userTimezone }); const dateWithoutTime = DateTime.fromJSDate(date) .toLocal() @@ -70,19 +72,19 @@ export const DateTimeInput = ({ }); const formattedDate = isDateTimeInput - ? dateParsed.toFormat(parsingFormat) + ? dateParsed.setZone(userTimezone).toFormat(parsingFormat) : dateWithoutTime.toFormat(parsingFormat); return formattedDate; }, - [parsingFormat, isDateTimeInput], + [parsingFormat, isDateTimeInput, userTimezone], ); const parseStringToDate = (str: string) => { setHasError(false); const parsedDate = isDateTimeInput - ? DateTime.fromFormat(str, parsingFormat) + ? DateTime.fromFormat(str, parsingFormat, { zone: userTimezone }) : DateTime.fromFormat(str, parsingFormat, { zone: 'utc' }); const isValid = parsedDate.isValid; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx index 95a1164cf79f..59f1cd481b7f 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx @@ -17,6 +17,8 @@ import { MenuItemLeftContent } from '@/ui/navigation/menu-item/internals/compone import { StyledHoverableMenuItemBase } from '@/ui/navigation/menu-item/internals/components/StyledMenuItemBase'; import { isDefined } from '~/utils/isDefined'; +import { UserContext } from '@/users/contexts/UserContext'; +import { useContext } from 'react'; import 'react-datepicker/dist/react-datepicker.css'; export const months = [ @@ -322,6 +324,8 @@ export const InternalDatePicker = ({ }: InternalDatePickerProps) => { const internalDate = date ?? new Date(); + const { timeZone } = useContext(UserContext); + const { closeDropdown } = useDropdown(MONTH_AND_YEAR_DROPDOWN_ID); const { closeDropdown: closeDropdownMonthSelect } = useDropdown( MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID, @@ -377,10 +381,56 @@ export const InternalDatePicker = ({ onChange?.(newDate); }; + const handleAddMonth = () => { + const dateParsed = DateTime.fromJSDate(internalDate, { zone: timeZone }) + .plus({ months: 1 }) + .toJSDate(); + + onChange?.(dateParsed); + }; + + const handleSubtractMonth = () => { + const dateParsed = DateTime.fromJSDate(internalDate, { zone: timeZone }) + .minus({ months: 1 }) + .toJSDate(); + + onChange?.(dateParsed); + }; + const handleChangeYear = (year: number) => { - const newDate = new Date(internalDate); - newDate.setFullYear(year); - onChange?.(newDate); + const dateParsed = DateTime.fromJSDate(internalDate, { zone: timeZone }) + .set({ year: year }) + .toJSDate(); + + onChange?.(dateParsed); + }; + + const handleDateChange = (date: Date) => { + const dateParsed = DateTime.fromJSDate(internalDate, { + zone: isDateTimeInput ? timeZone : 'local', + }) + .set({ + day: date.getDate(), + month: date.getMonth() + 1, + year: date.getFullYear(), + }) + .toJSDate(); + + onChange?.(dateParsed); + }; + + const handleDateSelect = (date: Date) => { + const dateParsed = DateTime.fromJSDate(internalDate, { + zone: isDateTimeInput ? timeZone : 'local', + }) + .set({ + day: date.getDate(), + month: date.getMonth() + 1, + year: date.getFullYear(), + }) + .toJSDate(); + + handleMouseSelect?.(dateParsed); }; const dateWithoutTime = DateTime.fromJSDate(internalDate) @@ -395,7 +445,29 @@ export const InternalDatePicker = ({ millisecond: 0, }) .toJSDate(); - const dateToUse = isDateTimeInput ? date : dateWithoutTime; + + const dateParsed = DateTime.fromJSDate(internalDate, { + zone: isDateTimeInput ? timeZone : 'local', + }); + + // We have to force a end of day on the computer local timezone with the given date + // Because JS Date API cannot hold a timezone other than the local one + // And if we don't do that workaround we will have problems when changing the date + // Because the shown date will have 1 day more or less than the real date + // Leading to bugs where we select 1st of January and it shows 31st of December for example + const endOfDayDateTimeInLocalTimezone = DateTime.now().set({ + day: dateParsed.get('day'), + month: dateParsed.get('month'), + year: dateParsed.get('year'), + hour: 23, + minute: 59, + second: 59, + millisecond: 999, + }); + + const endOfDayInLocalTimezone = endOfDayDateTimeInLocalTimezone.toJSDate(); + + const dateToUse = isDateTimeInput ? endOfDayInLocalTimezone : dateWithoutTime; return ( @@ -405,27 +477,16 @@ export const InternalDatePicker = ({ selected={dateToUse} openToDate={isDefined(dateToUse) ? dateToUse : undefined} disabledKeyboardNavigation - onChange={(newDate) => { - newDate?.setHours(internalDate.getUTCHours()); - newDate?.setUTCMinutes(internalDate.getUTCMinutes()); - onChange?.(newDate); - }} + onChange={handleDateChange} customInput={ } - onMonthChange={(newDate) => { - onChange?.(newDate); - }} - onYearChange={(newDate) => { - onChange?.(newDate); - }} renderCustomHeader={({ - decreaseMonth, - increaseMonth, prevMonthButtonDisabled, nextMonthButtonDisabled, }) => ( @@ -434,6 +495,7 @@ export const InternalDatePicker = ({ date={internalDate} isDateTimeInput={isDateTimeInput} onChange={onChange} + userTimezone={timeZone} /> decreaseMonth()} + onClick={handleSubtractMonth} size="medium" disabled={prevMonthButtonDisabled} /> increaseMonth()} + onClick={handleAddMonth} size="medium" disabled={nextMonthButtonDisabled} /> )} - onSelect={(date: Date) => { - const dateParsedWithoutTime = DateTime.fromObject( - { - day: date.getDate(), - month: date.getMonth() + 1, - year: date.getFullYear(), - hour: 0, - minute: 0, - second: 0, - }, - { zone: 'utc' }, - ).toJSDate(); - - const dateForUpdate = isDateTimeInput - ? date - : dateParsedWithoutTime; - - handleMouseSelect?.(dateForUpdate); - }} + onSelect={handleDateSelect} /> {clearable && ( diff --git a/packages/twenty-front/src/modules/users/components/UserProvider.tsx b/packages/twenty-front/src/modules/users/components/UserProvider.tsx index e35cbabfa138..4528a1ea1081 100644 --- a/packages/twenty-front/src/modules/users/components/UserProvider.tsx +++ b/packages/twenty-front/src/modules/users/components/UserProvider.tsx @@ -2,7 +2,9 @@ import React from 'react'; import { useRecoilValue } from 'recoil'; import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState'; +import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState'; import { AppPath } from '@/types/AppPath'; +import { UserContext } from '@/users/contexts/UserContext'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { UserOrMetadataLoader } from '~/loading/components/UserOrMetadataLoader'; @@ -10,10 +12,20 @@ export const UserProvider = ({ children }: React.PropsWithChildren) => { const isCurrentUserLoaded = useRecoilValue(isCurrentUserLoadedState); const isMatchingLocation = useIsMatchingLocation(); + const dateTimeFormat = useRecoilValue(dateTimeFormatState); + return !isCurrentUserLoaded && !isMatchingLocation(AppPath.CreateWorkspace) ? ( ) : ( - <>{children} + + {children} + ); }; diff --git a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx index 2951f4530afb..9ab6656f4004 100644 --- a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx +++ b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx @@ -6,6 +6,12 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState'; import { workspacesState } from '@/auth/states/workspaces'; +import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState'; +import { detectDateFormat } from '@/localization/utils/detectDateFormat'; +import { detectTimeFormat } from '@/localization/utils/detectTimeFormat'; +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/getDateFormatFromWorkspaceDateFormat'; +import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/getTimeFormatFromWorkspaceTimeFormat'; import { ColorScheme } from '@/workspace-member/types/WorkspaceMember'; import { useGetCurrentUserQuery } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; @@ -20,6 +26,8 @@ export const UserProviderEffect = () => { const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); const setWorkspaces = useSetRecoilState(workspacesState); + const setDateTimeFormat = useSetRecoilState(dateTimeFormatState); + const setCurrentWorkspaceMember = useSetRecoilState( currentWorkspaceMemberState, ); @@ -47,6 +55,20 @@ export const UserProviderEffect = () => { ...workspaceMember, colorScheme: (workspaceMember.colorScheme as ColorScheme) ?? 'Light', }); + + // TODO: factorize + setDateTimeFormat({ + timeZone: + workspaceMember.timeZone && workspaceMember.timeZone !== 'system' + ? workspaceMember.timeZone + : detectTimeZone(), + dateFormat: isDefined(workspaceMember.dateFormat) + ? getDateFormatFromWorkspaceDateFormat(workspaceMember.dateFormat) + : detectDateFormat(), + timeFormat: isDefined(workspaceMember.timeFormat) + ? getTimeFormatFromWorkspaceTimeFormat(workspaceMember.timeFormat) + : detectTimeFormat(), + }); } if (isDefined(userWorkspaces)) { @@ -65,6 +87,7 @@ export const UserProviderEffect = () => { setWorkspaces, queryData?.currentUser, setIsCurrentUserLoaded, + setDateTimeFormat, ]); return <>; diff --git a/packages/twenty-front/src/modules/users/contexts/UserContext.ts b/packages/twenty-front/src/modules/users/contexts/UserContext.ts new file mode 100644 index 000000000000..43776e2a5cf9 --- /dev/null +++ b/packages/twenty-front/src/modules/users/contexts/UserContext.ts @@ -0,0 +1,13 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { createContext } from 'react'; + +export type UserContextType = { + dateFormat: DateFormat; + timeFormat: TimeFormat; + timeZone: string; +}; + +export const UserContext = createContext( + {} as UserContextType, +); diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 67dfa226ea79..baaad11ca537 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -18,6 +18,9 @@ export const USER_QUERY_FRAGMENT = gql` colorScheme avatarUrl locale + timeZone + dateFormat + timeFormat } defaultWorkspace { id diff --git a/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts b/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts index 83f029206a5e..61977b340168 100644 --- a/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts +++ b/packages/twenty-front/src/modules/workspace-member/types/WorkspaceMember.ts @@ -1,3 +1,8 @@ +import { + WorkspaceMemberDateFormatEnum, + WorkspaceMemberTimeFormatEnum, +} from '~/generated/graphql'; + export type ColorScheme = 'Dark' | 'Light' | 'System'; export type WorkspaceMember = { @@ -9,10 +14,13 @@ export type WorkspaceMember = { lastName: string; }; avatarUrl?: string | null; - locale: string; + locale?: string | null; colorScheme?: ColorScheme; createdAt: string; updatedAt: string; userEmail: string; userId: string; + timeZone?: string | null; + dateFormat?: WorkspaceMemberDateFormatEnum | null; + timeFormat?: WorkspaceMemberTimeFormatEnum | null; }; diff --git a/packages/twenty-front/src/pages/settings/__stories__/SettingsAppearance.stories.tsx b/packages/twenty-front/src/pages/settings/__stories__/SettingsAppearance.stories.tsx index 5bb3a33ea0ed..5ad191a10a57 100644 --- a/packages/twenty-front/src/pages/settings/__stories__/SettingsAppearance.stories.tsx +++ b/packages/twenty-front/src/pages/settings/__stories__/SettingsAppearance.stories.tsx @@ -6,7 +6,7 @@ import { } from '~/testing/decorators/PageDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; -import { SettingsAppearance } from '../SettingsAppearance'; +import { SettingsAppearance } from '../profile/appearance/components/SettingsAppearance'; const meta: Meta = { title: 'Pages/Settings/SettingsAppearance', diff --git a/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettings.tsx b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettings.tsx new file mode 100644 index 000000000000..6a2e58e52c1f --- /dev/null +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettings.tsx @@ -0,0 +1,145 @@ +import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; + +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; + +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState'; +import { detectDateFormat } from '@/localization/utils/detectDateFormat'; +import { detectTimeFormat } from '@/localization/utils/detectTimeFormat'; +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { getWorkspaceDateFormatFromDateFormat } from '@/localization/utils/getWorkspaceDateFormatFromDateFormat'; +import { getWorkspaceTimeFormatFromTimeFormat } from '@/localization/utils/getWorkspaceTimeFormatFromTimeFormat'; +import { + WorkspaceMemberDateFormatEnum, + WorkspaceMemberTimeFormatEnum, +} from '~/generated/graphql'; +import { DateTimeSettingsDateFormatSelect } from '~/pages/settings/profile/appearance/components/DateTimeSettingsDateFormatSelect'; +import { DateTimeSettingsTimeFormatSelect } from '~/pages/settings/profile/appearance/components/DateTimeSettingsTimeFormatSelect'; +import { DateTimeSettingsTimeZoneSelect } from '~/pages/settings/profile/appearance/components/DateTimeSettingsTimeZoneSelect'; +import { isDefined } from '~/utils/isDefined'; +import { isEmptyObject } from '~/utils/isEmptyObject'; +import { logError } from '~/utils/logError'; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; +`; + +export const DateTimeSettings = () => { + const [currentWorkspaceMember, setCurrentWorkspaceMember] = useRecoilState( + currentWorkspaceMemberState, + ); + const [dateTimeFormat, setDateTimeFormat] = + useRecoilState(dateTimeFormatState); + + const { updateOneRecord } = useUpdateOneRecord({ + objectNameSingular: CoreObjectNameSingular.WorkspaceMember, + }); + + const updateWorkspaceMember = async (changedFields: any) => { + if (!currentWorkspaceMember?.id) { + throw new Error('User is not logged in'); + } + + try { + await updateOneRecord({ + idToUpdate: currentWorkspaceMember.id, + updateOneRecordInput: changedFields, + }); + } catch (error) { + logError(error); + } + }; + + if (!isDefined(currentWorkspaceMember)) return; + + const handleSettingsChange = ( + settingName: 'timeZone' | 'dateFormat' | 'timeFormat', + value: string, + ) => { + const workspaceMember: any = {}; + const dateTime: any = {}; + + switch (settingName) { + case 'timeZone': { + workspaceMember[settingName] = value; + dateTime[settingName] = value === 'system' ? detectTimeZone() : value; + break; + } + case 'dateFormat': { + workspaceMember[settingName] = getWorkspaceDateFormatFromDateFormat( + value as DateFormat, + ); + dateTime[settingName] = + (value as DateFormat) === DateFormat.SYSTEM + ? detectDateFormat() + : (value as DateFormat); + break; + } + case 'timeFormat': { + workspaceMember[settingName] = getWorkspaceTimeFormatFromTimeFormat( + value as TimeFormat, + ); + dateTime[settingName] = + (value as TimeFormat) === TimeFormat.SYSTEM + ? detectTimeFormat() + : (value as TimeFormat); + break; + } + } + + if (!isEmptyObject(dateTime)) { + setDateTimeFormat({ + ...dateTimeFormat, + ...dateTime, + }); + } + + if (!isEmptyObject(workspaceMember)) { + setCurrentWorkspaceMember({ + ...currentWorkspaceMember, + ...workspaceMember, + }); + updateWorkspaceMember(workspaceMember); + } + }; + + const timeZone = + currentWorkspaceMember.timeZone === 'system' + ? 'system' + : dateTimeFormat.timeZone; + + const dateFormat = + currentWorkspaceMember.dateFormat === WorkspaceMemberDateFormatEnum.System + ? DateFormat.SYSTEM + : dateTimeFormat.dateFormat; + + const timeFormat = + currentWorkspaceMember.timeFormat === WorkspaceMemberTimeFormatEnum.System + ? TimeFormat.SYSTEM + : dateTimeFormat.timeFormat; + + return ( + + handleSettingsChange('timeZone', value)} + /> + handleSettingsChange('dateFormat', value)} + timeZone={timeZone} + /> + handleSettingsChange('timeFormat', value)} + timeZone={timeZone} + /> + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsDateFormatSelect.tsx b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsDateFormatSelect.tsx new file mode 100644 index 000000000000..c17f7a8e712c --- /dev/null +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsDateFormatSelect.tsx @@ -0,0 +1,59 @@ +import { formatInTimeZone } from 'date-fns-tz'; + +import { DateFormat } from '@/localization/constants/DateFormat'; +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { Select } from '@/ui/input/components/Select'; + +type DateTimeSettingsDateFormatSelectProps = { + value: DateFormat; + onChange: (nextValue: DateFormat) => void; + timeZone: string; +}; + +export const DateTimeSettingsDateFormatSelect = ({ + onChange, + timeZone, + value, +}: DateTimeSettingsDateFormatSelectProps) => { + const setTimeZone = timeZone === 'system' ? detectTimeZone() : timeZone; + return ( + + ); +}; diff --git a/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsTimeZoneSelect.tsx b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsTimeZoneSelect.tsx new file mode 100644 index 000000000000..a48dea5db8de --- /dev/null +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsTimeZoneSelect.tsx @@ -0,0 +1,34 @@ +import { detectTimeZone } from '@/localization/utils/detectTimeZone'; +import { findAvailableTimeZoneOption } from '@/localization/utils/findAvailableTimeZoneOption'; +import { AVAILABLE_TIMEZONE_OPTIONS } from '@/settings/accounts/constants/AvailableTimezoneOptions'; +import { Select } from '@/ui/input/components/Select'; + +type DateTimeSettingsTimeZoneSelectProps = { + value?: string; + onChange: (nextValue: string) => void; +}; + +export const DateTimeSettingsTimeZoneSelect = ({ + value = detectTimeZone(), + onChange, +}: DateTimeSettingsTimeZoneSelectProps) => { + return ( +