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 a6bf9f218..dd65834c4 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 97d333280..9a44597c4 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 02d11fa87..9d0211cf1 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 c4ff26571..ca656f3b5 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 bdaf54830..a246ff432 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 423b48f82..e012d90f2 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 d25294901..c335d8a8a 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 15baaf0b2..751122499 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 8d572594f..c345d81f4 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 000000000..503e28ce3 --- /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 000000000..a169872b3 --- /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 000000000..2392151e3 --- /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 000000000..2b641f302 --- /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 000000000..643349578 --- /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 000000000..bec60cc4e --- /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 000000000..b503ef826 --- /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 000000000..0580333af --- /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 ab3b6cb5e..a1ce9a26b 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 000000000..fd4aa504c --- /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 000000000..9cbbf415e --- /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 000000000..ad8389251 --- /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 000000000..f9640e2c2 --- /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 000000000..f32bdbb93 --- /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 000000000..f6aebb437 --- /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 000000000..251a57582 --- /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 000000000..58a563f96 --- /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 b3ab3a732..35e9c6562 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 f974e587a..079b84520 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 b1e7c5134..4b1a873a1 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 278af533e..7555556c9 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 4f01ed496..05231f0cf 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 cab95257d..bb59eb95f 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 81fba15c6..db3155a4e 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 391352a93..0b78523b4 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 985814c67..000000000 --- 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 e168d1fda..000000000 --- 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 54053bc59..982e7a9af 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 7ce008c6a..f90e4a0c5 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 239f80509..3fac47943 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 95a1164cf..59f1cd481 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 e35cbabfa..4528a1ea1 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 2951f4530..9ab6656f4 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 000000000..43776e2a5 --- /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 67dfa226e..baaad11ca 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 83f029206..61977b340 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 5bb3a33ea..5ad191a10 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 000000000..6a2e58e52 --- /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 000000000..c17f7a8e7 --- /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 000000000..a48dea5db --- /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 ( +