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 <corentin@twenty.com>
This commit is contained in:
Lucas Bordeau
2024-07-30 14:52:10 +02:00
committed by GitHub
parent 45ebb0b824
commit ccf4d1eeec
64 changed files with 1176 additions and 165 deletions

View File

@ -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();

View File

@ -1015,11 +1015,29 @@ export type WorkspaceMember = {
__typename?: 'WorkspaceMember';
avatarUrl?: Maybe<Scalars['String']>;
colorScheme: Scalars['String'];
dateFormat?: Maybe<WorkspaceMemberDateFormatEnum>;
id: Scalars['UUID'];
locale: Scalars['String'];
locale?: Maybe<Scalars['String']>;
name: FullName;
timeFormat?: Maybe<WorkspaceMemberTimeFormatEnum>;
timeZone?: Maybe<Scalars['String']>;
};
/** 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

View File

@ -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;

View File

@ -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[];

View File

@ -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;

View File

@ -1,6 +1,6 @@
import { createContext } from 'react';
import { TimelineCalendarEvent } from '~/generated-metadata/graphql';
import { TimelineCalendarEvent } from '~/generated/graphql';
type CalendarContextValue = {
calendarEventsByDayTime: Record<number, TimelineCalendarEvent[] | undefined>;

View File

@ -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',
},
];

View File

@ -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 = <T extends CalendarEventGeneric>(
calendarEvents: T[],
) => {
export const useCalendarEvents = (calendarEvents: TimelineCalendarEvent[]) => {
const calendarEventsByDayTime = groupArrayItemsBy(
calendarEvents,
(calendarEvent) =>
@ -36,14 +29,14 @@ export const useCalendarEvents = <T extends CalendarEventGeneric>(
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]

View File

@ -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 (
<StyledEventCardCalendarEventContainer

View File

@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { useApolloClient } from '@apollo/client';
import { useCallback } from 'react';
import {
snapshot_UNSTABLE,
useGotoRecoilSnapshot,
@ -32,6 +32,12 @@ import {
} from '~/generated/graphql';
import { isDefined } from '~/utils/isDefined';
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 { currentUserState } from '../states/currentUserState';
import { tokenPairState } from '../states/tokenPairState';
@ -56,6 +62,8 @@ export const useAuth = () => {
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,
],
);

View File

@ -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',
}

View File

@ -0,0 +1,5 @@
export enum TimeFormat {
SYSTEM = 'SYSTEM',
HOUR_24 = 'HH:mm',
HOUR_12 = 'h:mm aa',
}

View File

@ -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'],
},
});

View File

@ -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);
});
});

View File

@ -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();
});
});

View File

@ -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);
});
});

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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.

View File

@ -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}`,
);
};

View File

@ -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}`);
};

View File

@ -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}`,
);
};

View File

@ -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;
};

View File

@ -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;
}
};

View File

@ -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;
}
};

View File

@ -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;
}
};

View File

@ -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;
}
};

View File

@ -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 (
<UserContext.Provider
value={{
dateFormat: DateFormat.SYSTEM,
timeFormat: TimeFormat.SYSTEM,
timeZone: 'UTC',
}}
>
<Story />
</UserContext.Provider>
);
},
],
component: DateFieldDisplay,
args: {},

View File

@ -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 (
<UserContext.Provider
value={{
dateFormat: DateFormat.SYSTEM,
timeFormat: TimeFormat.SYSTEM,
timeZone: 'UTC',
}}
>
<Story />
</UserContext.Provider>
);
},
],
component: DateTimeFieldDisplay,
args: {},

View File

@ -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;

View File

@ -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}

View File

@ -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 (
<StyledContainer>

View File

@ -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}

View File

@ -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 = {

View File

@ -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';

View File

@ -1,4 +0,0 @@
export enum DateFormat {
US = 'MMM d, yyyy',
UK = 'd MMM yyyy',
}

View File

@ -1,4 +0,0 @@
export enum TimeFormat {
'24h' = 'HH:mm',
'12h' = 'h:mm aa',
}

View File

@ -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) => (
<EllipsisDisplay>
{value ? formatISOStringToHumanReadableDate(value) : ''}
</EllipsisDisplay>
);
export const DateDisplay = ({ value }: DateDisplayProps) => {
const { dateFormat, timeZone } = useContext(UserContext);
const formattedDate = value
? formatDateISOStringToDate(value, timeZone, dateFormat)
: '';
return <EllipsisDisplay>{formattedDate}</EllipsisDisplay>;
};

View File

@ -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) => (
<EllipsisDisplay>
{value ? formatISOStringToHumanReadableDateTime(value) : ''}
</EllipsisDisplay>
);
export const DateTimeDisplay = ({ value }: DateTimeDisplayProps) => {
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const formattedDate = value
? formatDateISOStringToDateTime(value, timeZone, dateFormat, timeFormat)
: '';
return <EllipsisDisplay>{formattedDate}</EllipsisDisplay>;
};

View File

@ -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;

View File

@ -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 (
<StyledContainer onKeyDown={handleKeyDown}>
@ -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={
<DateTimeInput
date={dateToUse}
date={internalDate}
isDateTimeInput={isDateTimeInput}
onChange={onChange}
userTimezone={timeZone}
/>
}
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}
/>
<StyledCustomDatePickerHeader>
<Select
@ -441,51 +503,33 @@ export const InternalDatePicker = ({
options={months}
disableBlur
onChange={handleChangeMonth}
value={internalDate.getUTCMonth()}
value={endOfDayInLocalTimezone.getMonth()}
fullWidth
/>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_YEAR_SELECT_ID}
onChange={handleChangeYear}
value={internalDate.getUTCFullYear()}
value={endOfDayInLocalTimezone.getFullYear()}
options={years}
disableBlur
fullWidth
/>
<LightIconButton
Icon={IconChevronLeft}
onClick={() => decreaseMonth()}
onClick={handleSubtractMonth}
size="medium"
disabled={prevMonthButtonDisabled}
/>
<LightIconButton
Icon={IconChevronRight}
onClick={() => increaseMonth()}
onClick={handleAddMonth}
size="medium"
disabled={nextMonthButtonDisabled}
/>
</StyledCustomDatePickerHeader>
</>
)}
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}
/>
</div>
{clearable && (

View File

@ -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) ? (
<UserOrMetadataLoader />
) : (
<>{children}</>
<UserContext.Provider
value={{
dateFormat: dateTimeFormat.dateFormat,
timeFormat: dateTimeFormat.timeFormat,
timeZone: dateTimeFormat.timeZone,
}}
>
{children}
</UserContext.Provider>
);
};

View File

@ -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 <></>;

View File

@ -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<UserContextType>(
{} as UserContextType,
);

View File

@ -18,6 +18,9 @@ export const USER_QUERY_FRAGMENT = gql`
colorScheme
avatarUrl
locale
timeZone
dateFormat
timeFormat
}
defaultWorkspace {
id

View File

@ -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;
};

View File

@ -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<PageDecoratorArgs> = {
title: 'Pages/Settings/SettingsAppearance',

View File

@ -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 (
<StyledContainer>
<DateTimeSettingsTimeZoneSelect
value={timeZone}
onChange={(value) => handleSettingsChange('timeZone', value)}
/>
<DateTimeSettingsDateFormatSelect
value={dateFormat}
onChange={(value) => handleSettingsChange('dateFormat', value)}
timeZone={timeZone}
/>
<DateTimeSettingsTimeFormatSelect
value={timeFormat}
onChange={(value) => handleSettingsChange('timeFormat', value)}
timeZone={timeZone}
/>
</StyledContainer>
);
};

View File

@ -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 (
<Select
dropdownId="datetime-settings-date-format"
dropdownWidth={218}
label="Date format"
fullWidth
value={value}
options={[
{
label: `System settings`,
value: DateFormat.SYSTEM,
},
{
label: `${formatInTimeZone(
Date.now(),
setTimeZone,
DateFormat.MONTH_FIRST,
)}`,
value: DateFormat.MONTH_FIRST,
},
{
label: `${formatInTimeZone(
Date.now(),
setTimeZone,
DateFormat.DAY_FIRST,
)}`,
value: DateFormat.DAY_FIRST,
},
{
label: `${formatInTimeZone(
Date.now(),
setTimeZone,
DateFormat.YEAR_FIRST,
)}`,
value: DateFormat.YEAR_FIRST,
},
]}
onChange={onChange}
/>
);
};

View File

@ -0,0 +1,51 @@
import { formatInTimeZone } from 'date-fns-tz';
import { TimeFormat } from '@/localization/constants/TimeFormat';
import { detectTimeZone } from '@/localization/utils/detectTimeZone';
import { Select } from '@/ui/input/components/Select';
type DateTimeSettingsTimeFormatSelectProps = {
value: TimeFormat;
onChange: (nextValue: TimeFormat) => void;
timeZone: string;
};
export const DateTimeSettingsTimeFormatSelect = ({
onChange,
timeZone,
value,
}: DateTimeSettingsTimeFormatSelectProps) => {
const setTimeZone = timeZone === 'system' ? detectTimeZone() : timeZone;
return (
<Select
dropdownId="datetime-settings-time-format"
dropdownWidth={218}
label="Time format"
fullWidth
value={value}
options={[
{
label: 'System settings',
value: TimeFormat.SYSTEM,
},
{
label: `24h (${formatInTimeZone(
Date.now(),
setTimeZone,
TimeFormat.HOUR_24,
)})`,
value: TimeFormat.HOUR_24,
},
{
label: `12h (${formatInTimeZone(
Date.now(),
setTimeZone,
TimeFormat.HOUR_12,
)})`,
value: TimeFormat.HOUR_12,
},
]}
onChange={onChange}
/>
);
};

View File

@ -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 (
<Select
dropdownId="settings-accounts-calendar-time-zone"
dropdownWidth={416}
label="Time zone"
fullWidth
value={
value === 'system'
? 'System settings'
: findAvailableTimeZoneOption(value)?.value
}
options={[
{ label: 'System settings', value: 'system' },
...AVAILABLE_TIMEZONE_OPTIONS,
]}
onChange={onChange}
withSearchInput
/>
);
};

View File

@ -6,6 +6,7 @@ import { ColorSchemePicker } from '@/ui/input/color-scheme/components/ColorSchem
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Section } from '@/ui/layout/section/components/Section';
import { useColorScheme } from '@/ui/theme/hooks/useColorScheme';
import { DateTimeSettings } from '~/pages/settings/profile/appearance/components/DateTimeSettings';
const StyledH1Title = styled(H1Title)`
margin-bottom: 0;
@ -22,6 +23,13 @@ export const SettingsAppearance = () => {
<H2Title title="Theme" />
<ColorSchemePicker value={colorScheme} onChange={setColorScheme} />
</Section>
<Section>
<H2Title
title="Date and time"
description="Configure how dates are displayed across the app"
/>
<DateTimeSettings />
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);

View File

@ -1,5 +1,7 @@
import { TimelineCalendarEvent } from '~/generated-metadata/graphql';
import { CalendarChannelVisibility } from '~/generated/graphql';
import {
CalendarChannelVisibility,
TimelineCalendarEvent,
} from '~/generated/graphql';
export const mockedTimelineCalendarEvents: TimelineCalendarEvent[] = [
{

View File

@ -151,7 +151,7 @@ const getMonthLabels = () => {
.map((date) => formatter.format(date));
};
const getMonthLabelsMemoized = moize(getMonthLabels);
export const getMonthLabelsMemoized = moize(getMonthLabels);
export const formatISOStringToHumanReadableDateTime = (date: string) => {
const monthLabels = getMonthLabelsMemoized();

View File

@ -7,7 +7,7 @@ import {
describe('formatToHumanReadableMonth', () => {
it('should format the date to a human-readable month', () => {
const date = new Date('2022-01-01');
const result = formatToHumanReadableMonth(date);
const result = formatToHumanReadableMonth(date, 'UTC');
expect(result).toBe('Jan');
});
});
@ -15,7 +15,7 @@ describe('formatToHumanReadableMonth', () => {
describe('formatToHumanReadableDay', () => {
it('should format the date to a human-readable day', () => {
const date = new Date('2022-01-01');
const result = formatToHumanReadableDay(date);
const result = formatToHumanReadableDay(date, 'UTC');
expect(result).toBe('1');
});
});
@ -23,7 +23,7 @@ describe('formatToHumanReadableDay', () => {
describe('formatToHumanReadableTime', () => {
it('should format the date to a human-readable time', () => {
const date = new Date('2022-01-01T12:30:00');
const result = formatToHumanReadableTime(date);
const result = formatToHumanReadableTime(date, 'UTC');
// it seems when running locally on MacOS the space is not the same
expect(['12:30 PM', '12:30PM']).toContain(result);
});

View File

@ -1,26 +1,38 @@
import { parseDate } from '~/utils/date-utils';
export const formatToHumanReadableMonth = (date: Date | string) => {
export const formatToHumanReadableMonth = (
date: Date | string,
timeZone: string,
) => {
const parsedJSDate = parseDate(date).toJSDate();
return new Intl.DateTimeFormat(undefined, {
month: 'short',
timeZone: timeZone,
}).format(parsedJSDate);
};
export const formatToHumanReadableDay = (date: Date | string) => {
export const formatToHumanReadableDay = (
date: Date | string,
timeZone: string,
) => {
const parsedJSDate = parseDate(date).toJSDate();
return new Intl.DateTimeFormat(undefined, {
day: 'numeric',
timeZone: timeZone,
}).format(parsedJSDate);
};
export const formatToHumanReadableTime = (date: Date | string) => {
export const formatToHumanReadableTime = (
date: Date | string,
timeZone: string,
) => {
const parsedJSDate = parseDate(date).toJSDate();
return new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: 'numeric',
timeZone: timeZone,
}).format(parsedJSDate);
};

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { existsSync } from 'fs';
import fs from 'fs/promises';
import { join as joinPath } from 'path';
import { kebabCase } from 'src/utils/kebab-case';
@ -23,7 +24,7 @@ export class CommandLogger {
fileName: string,
data: unknown,
append = false,
): Promise<void> {
): Promise<string> {
const path = `./logs/${kebabCase(this.className)}`;
if (existsSync(path) === false) {
@ -31,17 +32,20 @@ export class CommandLogger {
}
try {
await fs.writeFile(
`${path}/${fileName}.json`,
JSON.stringify(data, null, 2),
{
flag: append ? 'a' : 'w',
},
);
const logFilePath = `${path}/${fileName}.json`;
await fs.writeFile(logFilePath, JSON.stringify(data, null, 2), {
flag: append ? 'a' : 'w',
});
const absoluteLogFilePath = joinPath(process.cwd(), logFilePath);
return absoluteLogFilePath;
} catch (err) {
console.error(
`Error writing to file ${path}/${fileName}.json: ${err?.message}`,
);
throw err;
}
}

View File

@ -3,6 +3,10 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import {
WorkspaceMemberDateFormatEnum,
WorkspaceMemberTimeFormatEnum,
} from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@ObjectType('FullName')
export class FullName {
@ -27,6 +31,15 @@ export class WorkspaceMember {
@Field({ nullable: true })
avatarUrl: string;
@Field({ nullable: false })
@Field({ nullable: true })
locale: string;
@Field({ nullable: true })
timeZone: string;
@Field(() => WorkspaceMemberDateFormatEnum, { nullable: true })
dateFormat: WorkspaceMemberDateFormatEnum;
@Field(() => WorkspaceMemberTimeFormatEnum, { nullable: true })
timeFormat: WorkspaceMemberTimeFormatEnum;
}

View File

@ -70,6 +70,9 @@ export class UserService extends TypeOrmQueryService<User> {
firstName: workspaceMembers[0].nameFirstName,
lastName: workspaceMembers[0].nameLastName,
};
userWorkspaceMember.timeZone = workspaceMembers[0].timeZone;
userWorkspaceMember.dateFormat = workspaceMembers[0].dateFormat;
userWorkspaceMember.timeFormat = workspaceMembers[0].timeFormat;
return userWorkspaceMember;
}

View File

@ -1,13 +1,13 @@
import { Logger } from '@nestjs/common';
import { Command, CommandRunner, Option } from 'nest-commander';
import chalk from 'chalk';
import { Command, CommandRunner, Option } from 'nest-commander';
import { WorkspaceHealthMode } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
import { WorkspaceHealthFixKind } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-fix-kind.interface';
import { WorkspaceHealthMode } from 'src/engine/workspace-manager/workspace-health/interfaces/workspace-health-options.interface';
import { WorkspaceHealthService } from 'src/engine/workspace-manager/workspace-health/workspace-health.service';
import { CommandLogger } from 'src/command/command-logger';
import { WorkspaceHealthService } from 'src/engine/workspace-manager/workspace-health/workspace-health.service';
interface WorkspaceHealthCommandOptions {
workspaceId: string;
@ -48,13 +48,20 @@ export class WorkspaceHealthCommand extends CommandRunner {
chalk.red(`Workspace is not healthy, found ${issues.length} issues`),
);
if (options.dryRun) {
await this.commandLogger.writeLog(
`workspace-health-issues-${options.workspaceId}`,
issues,
for (let issueIndex = 0; issueIndex < issues.length; issueIndex++) {
this.logger.log(
chalk.red(`Issue #${issueIndex + 1} : ${issues[issueIndex].message}`),
);
this.logger.log(chalk.yellow('Issues written to log'));
}
const logFilePath = await this.commandLogger.writeLog(
`workspace-health-issues-${options.workspaceId}`,
issues,
);
this.logger.log(
chalk.yellow(`Issues written to log at : ${logFilePath}`),
);
}
if (options.fix) {

View File

@ -369,6 +369,9 @@ export const WORKSPACE_MEMBER_STANDARD_FIELD_IDS = {
calendarEventParticipants: '20202020-0dbc-4841-9ce1-3e793b5b3512',
timelineActivities: '20202020-e15b-47b8-94fe-8200e3c66615',
auditLogs: '20202020-2f54-4739-a5e2-99563385e83d',
timeZone: '20202020-2d33-4c21-a86e-5943b050dd54',
dateFormat: '20202020-af13-4e11-b1e7-b8cf5ea13dc0',
timeFormat: '20202020-8acb-4cf8-a851-a6ed443c8d81',
};
export const CUSTOM_OBJECT_STANDARD_FIELD_IDS = {

View File

@ -1,3 +1,5 @@
import { registerEnumType } from '@nestjs/graphql';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type';
@ -27,6 +29,30 @@ import { MessageParticipantWorkspaceEntity } from 'src/modules/messaging/common/
import { AuditLogWorkspaceEntity } from 'src/modules/timeline/standard-objects/audit-log.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
export enum WorkspaceMemberDateFormatEnum {
SYSTEM = 'SYSTEM',
MONTH_FIRST = 'MONTH_FIRST',
DAY_FIRST = 'DAY_FIRST',
YEAR_FIRST = 'YEAR_FIRST',
}
export enum WorkspaceMemberTimeFormatEnum {
SYSTEM = 'SYSTEM',
HOUR_12 = 'HOUR_12',
HOUR_24 = 'HOUR_24',
}
registerEnumType(WorkspaceMemberTimeFormatEnum, {
name: 'WorkspaceMemberTimeFormatEnum',
description: 'Time time as Military, Standard or system as default',
});
registerEnumType(WorkspaceMemberDateFormatEnum, {
name: 'WorkspaceMemberDateFormatEnum',
description:
'Date format as Month first, Day first, Year first or system as default',
});
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.workspaceMember,
namePlural: 'workspaceMembers',
@ -242,4 +268,80 @@ export class WorkspaceMemberWorkspaceEntity extends BaseWorkspaceEntity {
@WorkspaceIsNullable()
@WorkspaceIsSystem()
auditLogs: Relation<AuditLogWorkspaceEntity[]>;
@WorkspaceField({
standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.timeZone,
type: FieldMetadataType.TEXT,
label: 'Time zone',
defaultValue: "'system'",
description: 'User time zone',
icon: 'IconTimezone',
})
timeZone: string;
@WorkspaceField({
standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.dateFormat,
type: FieldMetadataType.SELECT,
label: 'Date format',
description: "User's preferred date format",
icon: 'IconCalendarEvent',
options: [
{
value: WorkspaceMemberDateFormatEnum.SYSTEM,
label: 'System',
position: 0,
color: 'turquoise',
},
{
value: WorkspaceMemberDateFormatEnum.MONTH_FIRST,
label: 'Month First',
position: 1,
color: 'red',
},
{
value: WorkspaceMemberDateFormatEnum.DAY_FIRST,
label: 'Day First',
position: 2,
color: 'purple',
},
{
value: WorkspaceMemberDateFormatEnum.YEAR_FIRST,
label: 'Year First',
position: 3,
color: 'sky',
},
],
defaultValue: `'${WorkspaceMemberDateFormatEnum.SYSTEM}'`,
})
dateFormat: string;
@WorkspaceField({
standardId: WORKSPACE_MEMBER_STANDARD_FIELD_IDS.timeFormat,
type: FieldMetadataType.SELECT,
label: 'Time format',
description: "User's preferred time format",
icon: 'IconClock2',
options: [
{
value: WorkspaceMemberTimeFormatEnum.SYSTEM,
label: 'System',
position: 0,
color: 'sky',
},
{
value: WorkspaceMemberTimeFormatEnum.HOUR_24,
label: '24HRS',
position: 1,
color: 'red',
},
{
value: WorkspaceMemberTimeFormatEnum.HOUR_12,
label: '12HRS',
position: 2,
color: 'purple',
},
],
defaultValue: `'${WorkspaceMemberTimeFormatEnum.SYSTEM}'`,
})
timeFormat: string;
}