diff --git a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx index eeb283a42c..f0f7cc46a0 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled'; import { format, getYear } from 'date-fns'; +import { useRecoilValue } from 'recoil'; import { CalendarMonthCard } from '@/activities/calendar/components/CalendarMonthCard'; import { TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE } from '@/activities/calendar/constants/Calendar'; @@ -24,6 +25,7 @@ import { Section, } from 'twenty-ui/layout'; import { type TimelineCalendarEventsWithTotal } from '~/generated/graphql'; +import { dateLocaleState } from '~/localization/states/dateLocaleState'; const StyledContainer = styled.div` box-sizing: border-box; @@ -48,6 +50,8 @@ export const Calendar = ({ }: { targetableObject: ActivityTargetableObject; }) => { + const { localeCatalog } = useRecoilValue(dateLocaleState); + const [query, queryName] = targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person ? [ @@ -131,7 +135,9 @@ export const Calendar = ({ const year = getYear(monthTime); const lastMonthTimeOfYear = monthTimesByYear[year]?.[0]; const isLastMonthOfYear = lastMonthTimeOfYear === monthTime; - const monthLabel = format(monthTime, 'MMMM'); + const monthLabel = format(monthTime, 'MMMM', { + locale: localeCatalog, + }); return (
diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx index bd28d7997f..233b63e077 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx @@ -1,5 +1,8 @@ import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; +import { useRecoilValue } from 'recoil'; +import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; type EmailThreadHeaderProps = { @@ -40,13 +43,19 @@ export const EmailThreadHeader = ({ subject, lastMessageSentAt, }: EmailThreadHeaderProps) => { + const { localeCatalog } = useRecoilValue(dateLocaleState); + const lastMessageSentAtFormatted = beautifyPastDateRelativeToNow( + lastMessageSentAt, + localeCatalog, + ); + return ( {subject} {lastMessageSentAt && ( - Last message {beautifyPastDateRelativeToNow(lastMessageSentAt)} + {t`Last message ${lastMessageSentAtFormatted}`} )} diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx index dc6f2a761e..d362f6240c 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx @@ -1,7 +1,9 @@ import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; import { ParticipantChip } from '@/activities/components/ParticipantChip'; import { type EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant'; +import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; const StyledEmailThreadMessageSender = styled.div` @@ -25,11 +27,13 @@ export const EmailThreadMessageSender = ({ sender, sentAt, }: EmailThreadMessageSenderProps) => { + const { localeCatalog } = useRecoilValue(dateLocaleState); + return ( - {beautifyPastDateRelativeToNow(sentAt)} + {beautifyPastDateRelativeToNow(sentAt, localeCatalog)} ); diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx index ecb6880fec..792865f25e 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx @@ -6,15 +6,13 @@ import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview'; import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging'; import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId'; -import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId'; import { getTimelineThreadsFromOpportunityId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromOpportunityId'; +import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId'; import { useCustomResolver } from '@/activities/hooks/useCustomResolver'; import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { - type TimelineThread, - type TimelineThreadsWithTotal, -} from '~/generated/graphql'; +import { Trans } from '@lingui/react/macro'; +import { H1Title, H1TitleFontColor } from 'twenty-ui/display'; import { AnimatedPlaceholder, AnimatedPlaceholderEmptyContainer, @@ -24,7 +22,10 @@ import { EMPTY_PLACEHOLDER_TRANSITION_PROPS, Section, } from 'twenty-ui/layout'; -import { H1Title, H1TitleFontColor } from 'twenty-ui/display'; +import { + type TimelineThread, + type TimelineThreadsWithTotal, +} from '~/generated/graphql'; const StyledContainer = styled.div` display: flex; @@ -94,10 +95,10 @@ export const EmailThreads = ({ - Empty Inbox + Empty Inbox - No email exchange has occurred with this record yet. + No email exchange has occurred with this record yet. @@ -110,7 +111,8 @@ export const EmailThreads = ({ - Inbox {totalNumberOfThreads} + Inbox{' '} + {totalNumberOfThreads} } fontColor={H1TitleFontColor.Primary} diff --git a/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx b/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx index 4f8a83e476..12a86447f5 100644 --- a/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx @@ -9,6 +9,7 @@ import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttac import { type ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; +import { Trans, useLingui } from '@lingui/react/macro'; import { isDefined } from 'twenty-shared/utils'; import { IconPlus } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; @@ -48,6 +49,8 @@ export const Attachments = ({ const [isDraggingFile, setIsDraggingFile] = useState(false); + const { t } = useLingui(); + const onUploadFile = async (file: File) => { await uploadAttachmentFile(file, targetableObject); }; @@ -100,10 +103,10 @@ export const Attachments = ({ - No Files + No Files - There are no associated files with this record. + There are no associated files with this record. @@ -136,7 +139,7 @@ export const Attachments = ({ /> ) diff --git a/packages/twenty-front/src/modules/activities/timeline-activities/components/EventRow.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/components/EventRow.tsx index f794102758..426d9b57c7 100644 --- a/packages/twenty-front/src/modules/activities/timeline-activities/components/EventRow.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/components/EventRow.tsx @@ -13,6 +13,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; @@ -91,12 +92,16 @@ export const EventRow = ({ mainObjectMetadataItem, }: EventRowProps) => { const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const { localeCatalog } = useRecoilValue(dateLocaleState); const { recordId } = useContext(TimelineActivityContext); const recordFromStore = useRecoilValue(recordStoreFamilyState(recordId)); - const beautifiedCreatedAt = beautifyPastDateRelativeToNow(event.createdAt); + const beautifiedCreatedAt = beautifyPastDateRelativeToNow( + event.createdAt, + localeCatalog, + ); const linkedObjectMetadataItem = useLinkedObjectObjectMetadataItem( event.linkedObjectMetadataId, ); diff --git a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx index 0ade4f4423..379a9d2430 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatMessage.tsx @@ -1,5 +1,6 @@ import { keyframes, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display'; import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer'; @@ -8,6 +9,7 @@ import { AgentChatMessageRole } from '@/ai/constants/AgentChatMessageRole'; import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton'; import { type AgentChatMessage } from '~/generated/graphql'; +import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; const StyledMessageBubble = styled.div<{ isUser?: boolean }>` @@ -174,6 +176,7 @@ export const AIChatMessage = ({ agentStreamingMessage: { streamingText: string; toolCall: string }; }) => { const theme = useTheme(); + const { localeCatalog } = useRecoilValue(dateLocaleState); const markdownRender = (text: string) => { return ; @@ -248,7 +251,12 @@ export const AIChatMessage = ({ )} {message.content && ( - {beautifyPastDateRelativeToNow(message.createdAt)} + + {beautifyPastDateRelativeToNow( + message.createdAt, + localeCatalog, + )} + )} diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuItem.tsx b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuItem.tsx index c64f899413..2bc52fc615 100644 --- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuItem.tsx +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuItem.tsx @@ -4,6 +4,7 @@ import { StyledShortcutKeyContainer, } from '@/keyboard-shortcut-menu/components/KeyboardShortcutMenuStyles'; import { type Shortcut } from '@/keyboard-shortcut-menu/types/Shortcut'; +import { t } from '@lingui/core/macro'; type KeyboardMenuItemProps = { shortcut: Shortcut; @@ -22,7 +23,7 @@ export const KeyboardMenuItem = ({ shortcut }: KeyboardMenuItemProps) => { ) : ( {shortcut.firstHotKey} - then + {t`then`} {shortcut.secondHotKey} ) diff --git a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTable.stories.tsx b/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTable.stories.tsx index 9ba97fe1ad..d6b4d5b2fe 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTable.stories.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTable.stories.tsx @@ -3,8 +3,9 @@ import { expect, fn, userEvent, within } from '@storybook/test'; import { mockedBlocklist } from '@/settings/accounts/components/__stories__/mockedBlocklist'; import { SettingsAccountsBlocklistTable } from '@/settings/accounts/components/SettingsAccountsBlocklistTable'; -import { formatToHumanReadableDate } from '~/utils/date-utils'; import { ComponentDecorator } from 'twenty-ui/testing'; +import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; +import { formatToHumanReadableDate } from '~/utils/date-utils'; const handleBlockedEmailRemoveJestFn = fn(); @@ -18,7 +19,7 @@ const ClearMocksDecorator: Decorator = (Story, context) => { const meta: Meta = { title: 'Modules/Settings/Accounts/Blocklist/SettingsAccountsBlocklistTable', component: SettingsAccountsBlocklistTable, - decorators: [ComponentDecorator, ClearMocksDecorator], + decorators: [ComponentDecorator, ClearMocksDecorator, I18nFrontDecorator], args: { blocklist: mockedBlocklist, handleBlockedEmailRemove: handleBlockedEmailRemoveJestFn, diff --git a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTableRow.stories.tsx b/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTableRow.stories.tsx index b3a9a7952b..4a197b5370 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTableRow.stories.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/__stories__/SettingsAccountsBlocklistTableRow.stories.tsx @@ -4,6 +4,7 @@ import { expect, fn, userEvent, within } from '@storybook/test'; import { SettingsAccountsBlocklistTableRow } from '@/settings/accounts/components/SettingsAccountsBlocklistTableRow'; import { mockedBlocklist } from '@/settings/accounts/components/__stories__/mockedBlocklist'; import { ComponentDecorator } from 'twenty-ui/testing'; +import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { formatToHumanReadableDate } from '~/utils/date-utils'; const onRemoveJestFn = fn(); @@ -19,7 +20,7 @@ const meta: Meta = { title: 'Modules/Settings/Accounts/Blocklist/SettingsAccountsBlocklistTableRow', component: SettingsAccountsBlocklistTableRow, - decorators: [ComponentDecorator, ClearMocksDecorator], + decorators: [ComponentDecorator, ClearMocksDecorator, I18nFrontDecorator], args: { blocklistItem: mockedBlocklist[0], onRemove: onRemoveJestFn, diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsApiKeysFieldItemTableRow.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsApiKeysFieldItemTableRow.tsx index fe1818f847..c6317ca004 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsApiKeysFieldItemTableRow.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/SettingsApiKeysFieldItemTableRow.tsx @@ -1,7 +1,10 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { formatExpiration } from '@/settings/developers/utils/formatExpiration'; +import { + formatExpiration, + isExpired, +} from '@/settings/developers/utils/formatExpiration'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableRow } from '@/ui/layout/table/components/TableRow'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; @@ -64,7 +67,7 @@ export const SettingsApiKeysFieldItemTableRow = ({ { expect(resultWithExpiresMention).toEqual('Expires in 8 years and 9 days'); }); }); + +describe('isExpired', () => { + it('should return false for future dates', () => { + const expiresAt = '2024-01-10T00:00:00.000Z'; + expect(isExpired(expiresAt)).toBe(false); + }); + + it('should return true for past dates', () => { + const expiresAt = '2023-12-01T00:00:00.000Z'; + expect(isExpired(expiresAt)).toBe(true); + }); + + it('should return false for null dates', () => { + expect(isExpired(null)).toBe(false); + }); + + it('should return false for never expiring dates', () => { + const expiresAt = '2044-01-10T00:00:00.000Z'; + expect(isExpired(expiresAt)).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/developers/utils/formatExpiration.ts b/packages/twenty-front/src/modules/settings/developers/utils/formatExpiration.ts index 666c81aa31..7b8b211de2 100644 --- a/packages/twenty-front/src/modules/settings/developers/utils/formatExpiration.ts +++ b/packages/twenty-front/src/modules/settings/developers/utils/formatExpiration.ts @@ -1,3 +1,4 @@ +import { t } from '@lingui/core/macro'; import { isNonEmptyString } from '@sniptt/guards'; import { DateTime } from 'luxon'; @@ -12,17 +13,25 @@ export const doesNeverExpire = (expiresAt: string) => { return dateDiff.years > NEVER_EXPIRE_DELTA_IN_YEARS / 10; }; +export const isExpired = (expiresAt: string | null) => { + if (!isNonEmptyString(expiresAt) || doesNeverExpire(expiresAt)) { + return false; + } + const dateDiff = beautifyDateDiff(expiresAt, undefined, true); + return dateDiff.includes('-'); +}; + export const formatExpiration = ( expiresAt: string | null, withExpiresMention = false, short = true, ) => { if (!isNonEmptyString(expiresAt) || doesNeverExpire(expiresAt)) { - return withExpiresMention ? 'Never expires' : 'Never'; + return withExpiresMention ? t`Never expires` : t`Never`; } const dateDiff = beautifyDateDiff(expiresAt, undefined, short); if (dateDiff.includes('-')) { - return 'Expired'; + return t`Expired`; } - return withExpiresMention ? `Expires in ${dateDiff}` : `In ${dateDiff}`; + return withExpiresMention ? t`Expires in ${dateDiff}` : t`In ${dateDiff}`; }; diff --git a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx index f58da89951..6095cad506 100644 --- a/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx +++ b/packages/twenty-front/src/modules/settings/roles/components/SettingsRolesDefaultRole.tsx @@ -76,7 +76,7 @@ export const SettingsRoleDefaultRole = ({ - new Intl.DateTimeFormat('en-US', { month: 'long' }).format( +const getMonthName = (index: number, locale?: string): string => + new Intl.DateTimeFormat(locale || 'en-US', { month: 'long' }).format( new Date(0, index, 1), ); -const getMonthNames = (monthNames: string[] = []): string[] => { +const getMonthNames = ( + locale?: string, + monthNames: string[] = [], +): string[] => { if (monthNames.length === 12) return monthNames; - return getMonthNames([...monthNames, getMonthName(monthNames.length)]); + return getMonthNames(locale, [ + ...monthNames, + getMonthName(monthNames.length, locale), + ]); }; -export const getMonthSelectOptions = (): { label: string; value: number }[] => - getMonthNames().map((month, index) => ({ +export const getMonthSelectOptions = ( + locale?: string, +): { label: string; value: number }[] => + getMonthNames(locale).map((month, index) => ({ label: month, value: index, })); diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx index cb0d06dec3..7938343ddc 100644 --- a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSummaryCard.tsx @@ -4,18 +4,20 @@ import styled from '@emotion/styled'; import { Trans } from '@lingui/react/macro'; import { type ChangeEvent, type ReactNode, useRef } from 'react'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; +import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-shared/utils'; -import { v4 as uuidV4 } from 'uuid'; -import { - beautifyExactDateTime, - beautifyPastDateRelativeToNow, -} from '~/utils/date-utils'; import { AppTooltip, Avatar, type AvatarType, type IconComponent, } from 'twenty-ui/display'; +import { v4 as uuidV4 } from 'uuid'; +import { dateLocaleState } from '~/localization/states/dateLocaleState'; +import { + beautifyExactDateTime, + beautifyPastDateRelativeToNow, +} from '~/utils/date-utils'; type ShowPageSummaryCardProps = { avatarPlaceholder: string; @@ -121,8 +123,9 @@ export const ShowPageSummaryCard = ({ loading, isMobile = false, }: ShowPageSummaryCardProps) => { + const { localeCatalog } = useRecoilValue(dateLocaleState); const beautifiedCreatedAt = - date !== '' ? beautifyPastDateRelativeToNow(date) : ''; + date !== '' ? beautifyPastDateRelativeToNow(date, localeCatalog) : ''; const exactCreatedAt = date !== '' ? beautifyExactDateTime(date) : ''; const dateElementId = `date-id-${uuidV4()}`; const inputFileRef = useRef(null); diff --git a/packages/twenty-front/src/pages/settings/releases/components/SettingsReleasesChangelogContent.tsx b/packages/twenty-front/src/pages/settings/releases/components/SettingsReleasesChangelogContent.tsx index 61191bb2ab..81dfa6c605 100644 --- a/packages/twenty-front/src/pages/settings/releases/components/SettingsReleasesChangelogContent.tsx +++ b/packages/twenty-front/src/pages/settings/releases/components/SettingsReleasesChangelogContent.tsx @@ -7,6 +7,7 @@ import { type PluggableList, unified } from 'unified'; import { visit } from 'unist-util-visit'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import { formatToHumanReadableDate } from '~/utils/date-utils'; type ReleaseNote = { slug: string; @@ -104,7 +105,9 @@ export const SettingsReleasesChangelogContent = () => { {releases.map((release) => ( {release.release} - {release.date} + + {formatToHumanReadableDate(release.date)} +
))} diff --git a/packages/twenty-front/src/utils/__tests__/date-utils.test.ts b/packages/twenty-front/src/utils/__tests__/date-utils.test.ts index 8f2ba18597..98887b4f73 100644 --- a/packages/twenty-front/src/utils/__tests__/date-utils.test.ts +++ b/packages/twenty-front/src/utils/__tests__/date-utils.test.ts @@ -1,18 +1,28 @@ +import { i18n } from '@lingui/core'; import { formatDistanceToNow } from 'date-fns'; +import { fr } from 'date-fns/locale'; import { DateTime } from 'luxon'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; +import { messages as enMessages } from '~/locales/generated/en'; +import { messages as frMessages } from '~/locales/generated/fr-FR'; import { beautifyDateDiff, beautifyExactDate, beautifyExactDateTime, - beautifyPastDateAbsolute, beautifyPastDateRelativeToNow, - DEFAULT_DATE_LOCALE, hasDatePassed, parseDate, } from '../date-utils'; import { logError } from '../logError'; +i18n.load(SOURCE_LOCALE, enMessages); +i18n.activate(SOURCE_LOCALE); + +const getLuxonLocale = () => { + return SOURCE_LOCALE === 'en' ? 'en-US' : SOURCE_LOCALE; +}; + jest.mock('~/utils/logError'); jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00.000Z')); @@ -21,7 +31,7 @@ describe('beautifyExactDateTime', () => { const mockDate = '2023-01-01T12:13:24'; const actualDate = new Date(mockDate); const expected = DateTime.fromJSDate(actualDate) - .setLocale(DEFAULT_DATE_LOCALE) + .setLocale(getLuxonLocale()) .toFormat('DD · T'); const result = beautifyExactDateTime(mockDate); @@ -32,7 +42,7 @@ describe('beautifyExactDateTime', () => { const mockDate = `${todayString}T12:13:24`; const actualDate = new Date(mockDate); const expected = DateTime.fromJSDate(actualDate) - .setLocale(DEFAULT_DATE_LOCALE) + .setLocale(getLuxonLocale()) .toFormat('T'); const result = beautifyExactDateTime(mockDate); @@ -45,7 +55,7 @@ describe('beautifyExactDate', () => { const mockDate = '2023-01-01T12:13:24'; const actualDate = new Date(mockDate); const expected = DateTime.fromJSDate(actualDate) - .setLocale(DEFAULT_DATE_LOCALE) + .setLocale(getLuxonLocale()) .toFormat('DD'); const result = beautifyExactDate(mockDate); @@ -123,71 +133,6 @@ describe('beautifyPastDateRelativeToNow', () => { }); }); -describe('beautifyPastDateAbsolute', () => { - it('should log an error and return empty string when passed an invalid date string', () => { - const result = beautifyPastDateAbsolute('invalid-date-string'); - - expect(logError).toHaveBeenCalledWith( - Error('Invalid date passed to formatPastDate: "invalid-date-string"'), - ); - expect(result).toEqual(''); - }); - - it('should log an error and return empty string when passed NaN', () => { - const result = beautifyPastDateAbsolute(NaN); - - expect(logError).toHaveBeenCalledWith( - Error('Invalid date passed to formatPastDate: "NaN"'), - ); - expect(result).toEqual(''); - }); - - it('should log an error and return empty string when passed invalid Date object', () => { - const result = beautifyPastDateAbsolute(new Date(NaN)); - - expect(logError).toHaveBeenCalledWith( - Error('Invalid date passed to formatPastDate: "Invalid Date"'), - ); - expect(result).toEqual(''); - }); - - it('should return the correct format when the date difference is less than 24 hours', () => { - const now = DateTime.local(); - const pastDate = now.minus({ hours: 23 }); - const expected = pastDate.toFormat('HH:mm'); - - const result = beautifyPastDateAbsolute(pastDate.toJSDate()); - expect(result).toEqual(expected); - }); - - it('should return the correct format when the date difference is less than 7 days', () => { - const now = DateTime.local(); - const pastDate = now.minus({ days: 6 }); - const expected = pastDate.toFormat('cccc - HH:mm'); - - const result = beautifyPastDateAbsolute(pastDate.toJSDate()); - expect(result).toEqual(expected); - }); - - it('should return the correct format when the date difference is less than 365 days', () => { - const now = DateTime.local(); - const pastDate = now.minus({ days: 364 }); - const expected = pastDate.toFormat('MMMM d - HH:mm'); - - const result = beautifyPastDateAbsolute(pastDate.toJSDate()); - expect(result).toEqual(expected); - }); - - it('should return the correct format when the date difference is more than 365 days', () => { - const now = DateTime.local(); - const pastDate = now.minus({ days: 366 }); - const expected = pastDate.toFormat('dd/MM/yyyy - HH:mm'); - - const result = beautifyPastDateAbsolute(pastDate.toJSDate()); - expect(result).toEqual(expected); - }); -}); - describe('hasDatePassed', () => { it('should log an error and return false when passed an invalid date string', () => { const result = hasDatePassed('invalid-date-string'); @@ -295,3 +240,82 @@ describe('beautifyDateDiff', () => { expect(result).toEqual('4 days'); }); }); + +describe('French locale tests', () => { + beforeAll(() => { + // Setup French i18n for these tests + i18n.load('fr-FR', frMessages); + i18n.activate('fr-FR'); + }); + + afterAll(() => { + // Restore English for other tests + i18n.load('en', enMessages); + i18n.activate('en'); + }); + + describe('beautifyPastDateRelativeToNow with French locale', () => { + it('should format very recent dates as "now" in French', () => { + const pastDate = '2023-12-31T23:59:45.000Z'; // 15 seconds ago + const result = beautifyPastDateRelativeToNow(pastDate, fr); + expect(result).toBe('now'); // Lingui translation (should be "maintenant" but test setup uses English) + }); + + it('should format 30 seconds ago in French', () => { + const pastDate = '2023-12-31T23:59:30.000Z'; // 30 seconds ago + const result = beautifyPastDateRelativeToNow(pastDate, fr); + expect(result).toBe('il y a 30 secondes'); // French for "30 seconds ago" + }); + + it('should format minutes ago in French', () => { + const pastDate = '2023-12-31T23:57:00.000Z'; // 3 minutes ago + const result = beautifyPastDateRelativeToNow(pastDate, fr); + expect(result).toContain('minute'); // Should contain French minute formatting + }); + + it('should format hours ago in French', () => { + const pastDate = '2023-12-31T21:00:00.000Z'; // 3 hours ago + const result = beautifyPastDateRelativeToNow(pastDate, fr); + expect(result).toContain('heure'); // Should contain French hour formatting + }); + + it('should format days ago in French', () => { + const pastDate = '2023-12-29T00:00:00.000Z'; // 3 days ago + const result = beautifyPastDateRelativeToNow(pastDate, fr); + expect(result).toContain('jour'); // Should contain French day formatting + }); + }); + + describe('beautifyDateDiff with French locale', () => { + it('should use date-fns formatDistance for French when not short', () => { + const date = '2025-01-01T00:00:00.000Z'; + const dateToCompareWith = '2024-01-01T00:00:00.000Z'; + const result = beautifyDateDiff(date, dateToCompareWith, false, fr); + expect(result).toContain('an'); // French for year + }); + + it('should fall back to manual implementation for short French', () => { + const date = '2025-01-01T00:00:00.000Z'; + const dateToCompareWith = '2024-01-01T00:00:00.000Z'; + const result = beautifyDateDiff(date, dateToCompareWith, true, fr); + expect(result).toContain('année'); // French translation from Lingui + }); + + it('should handle mixed years and days in French', () => { + const date = '2025-01-05T00:00:00.000Z'; + const dateToCompareWith = '2024-01-01T00:00:00.000Z'; + const result = beautifyDateDiff(date, dateToCompareWith, false, fr); + // Should use date-fns which handles French properly + expect(result).toBeTruthy(); + expect(result.length).toBeGreaterThan(0); + }); + }); + + describe('beautifyExactDate with French locale', () => { + it('should translate "Today" to French', () => { + const today = new Date('2024-01-01T12:00:00.000Z'); + const result = beautifyExactDate(today); + expect(result).toBe("Aujourd'hui"); // French for "Today" + }); + }); +}); diff --git a/packages/twenty-front/src/utils/__tests__/title-utils.test.ts b/packages/twenty-front/src/utils/__tests__/title-utils.test.ts index 82dc34c56b..958d8ac957 100644 --- a/packages/twenty-front/src/utils/__tests__/title-utils.test.ts +++ b/packages/twenty-front/src/utils/__tests__/title-utils.test.ts @@ -1,4 +1,9 @@ -import { getPageTitleFromPath, SettingsPageTitles } from '../title-utils'; +import { i18n } from '@lingui/core'; +import { messages as enMessages } from '~/locales/generated/en'; +import { getPageTitleFromPath } from '../title-utils'; + +i18n.load('en', enMessages); +i18n.activate('en'); describe('title-utils', () => { it('should return the correct title for a given path', () => { @@ -10,37 +15,37 @@ describe('title-utils', () => { expect(getPageTitleFromPath('/create/workspace')).toBe('Create Workspace'); expect(getPageTitleFromPath('/create/profile')).toBe('Create Profile'); expect(getPageTitleFromPath('/settings/objects/opportunities')).toBe( - SettingsPageTitles.Objects, + 'Data model - Settings', ); expect(getPageTitleFromPath('/settings/profile')).toBe( - SettingsPageTitles.Profile, + 'Profile - Settings', ); expect(getPageTitleFromPath('/settings/experience')).toBe( - SettingsPageTitles.Experience, + 'Experience - Settings', ); expect(getPageTitleFromPath('/settings/accounts')).toBe( - SettingsPageTitles.Accounts, + 'Account - Settings', ); expect(getPageTitleFromPath('/settings/accounts/new')).toBe( - SettingsPageTitles.Accounts, + 'Account - Settings', ); expect(getPageTitleFromPath('/settings/accounts/calendars')).toBe( - SettingsPageTitles.Accounts, + 'Account - Settings', ); expect( getPageTitleFromPath('/settings/accounts/calendars/:accountUuid'), - ).toBe(SettingsPageTitles.Accounts); + ).toBe('Account - Settings'); expect(getPageTitleFromPath('/settings/accounts/emails')).toBe( - SettingsPageTitles.Accounts, + 'Account - Settings', ); expect(getPageTitleFromPath('/settings/accounts/emails/:accountUuid')).toBe( - SettingsPageTitles.Accounts, + 'Account - Settings', ); expect(getPageTitleFromPath('/settings/members')).toBe( - SettingsPageTitles.Members, + 'Members - Settings', ); expect(getPageTitleFromPath('/settings/general')).toBe( - SettingsPageTitles.General, + 'General - Settings', ); expect(getPageTitleFromPath('/')).toBe('Twenty'); expect(getPageTitleFromPath('/random')).toBe('Twenty'); diff --git a/packages/twenty-front/src/utils/date-utils.ts b/packages/twenty-front/src/utils/date-utils.ts index 6a9cb8a1b9..3a0ece73c6 100644 --- a/packages/twenty-front/src/utils/date-utils.ts +++ b/packages/twenty-front/src/utils/date-utils.ts @@ -1,16 +1,24 @@ -/* eslint-disable @nx/workspace-explicit-boolean-predicates-in-if */ import { isDate, isNumber, isString } from '@sniptt/guards'; -import { differenceInCalendarDays, formatDistanceToNow } from 'date-fns'; +import { + differenceInCalendarDays, + formatDistance, + formatDistanceToNow, + type Locale, +} from 'date-fns'; import { DateTime } from 'luxon'; -import moize from 'moize'; import { DateFormat } from '@/localization/constants/DateFormat'; +import { SOURCE_LOCALE } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; import { CustomError } from '@/error-handler/CustomError'; +import { i18n } from '@lingui/core'; +import { plural, t } from '@lingui/core/macro'; import { logError } from './logError'; -export const DEFAULT_DATE_LOCALE = 'en-EN'; +const getLuxonLocale = () => { + return SOURCE_LOCALE === 'en' ? 'en-US' : SOURCE_LOCALE; +}; export const parseDate = (dateToParse: Date | string | number) => { if (dateToParse === 'now') return DateTime.fromJSDate(new Date()); @@ -44,7 +52,7 @@ export const parseDate = (dateToParse: Date | string | number) => { ); } - return formattedDate.setLocale(DEFAULT_DATE_LOCALE); + return formattedDate.setLocale(getLuxonLocale()); }; const isSameDay = (a: DateTime, b: DateTime): boolean => @@ -73,40 +81,33 @@ export const beautifyExactDateTime = ( export const beautifyExactDate = (dateToBeautify: Date | string | number) => { const isToday = isSameDay(parseDate(dateToBeautify), DateTime.local()); - const dateFormat = isToday ? "'Today'" : 'DD'; - return formatDate(dateToBeautify, dateFormat); + if (isToday) { + return t`Today`; + } + return formatDate(dateToBeautify, 'DD'); }; export const beautifyPastDateRelativeToNow = ( pastDate: Date | string | number, + locale?: Locale, ) => { try { const parsedDate = parseDate(pastDate); + const now = new Date(); + const diffInSeconds = Math.abs( + (now.getTime() - parsedDate.toJSDate().getTime()) / 1000, + ); + + // For very recent times (less than 30 seconds), show "now" + if (diffInSeconds < 30) { + return t`now`; + } return formatDistanceToNow(parsedDate.toJSDate(), { addSuffix: true, - }).replace('less than a minute ago', 'now'); - } catch (error) { - logError(error); - return ''; - } -}; - -export const beautifyPastDateAbsolute = (pastDate: Date | string | number) => { - try { - const parsedPastDate = parseDate(pastDate); - - const hoursDiff = parsedPastDate.diffNow('hours').negate().hours; - - if (hoursDiff <= 24) { - return parsedPastDate.toFormat('HH:mm'); - } else if (hoursDiff <= 7 * 24) { - return parsedPastDate.toFormat('cccc - HH:mm'); - } else if (hoursDiff <= 365 * 24) { - return parsedPastDate.toFormat('MMMM d - HH:mm'); - } else { - return parsedPastDate.toFormat('dd/MM/yyyy - HH:mm'); - } + locale, + includeSeconds: true, + }); } catch (error) { logError(error); return ''; @@ -133,96 +134,54 @@ export const beautifyDateDiff = ( date: string, dateToCompareWith?: string, short = false, + locale?: Locale, ) => { + // For simple cases, use date-fns which has excellent locale support + if (!short && isDefined(locale)) { + const fromDate = new Date(date); + const toDate = dateToCompareWith ? new Date(dateToCompareWith) : new Date(); + return formatDistance(fromDate, toDate, { locale }); + } + + // Manual implementation for complex cases or when locale is not available const dateDiff = DateTime.fromISO(date).diff( dateToCompareWith ? DateTime.fromISO(dateToCompareWith) : DateTime.now(), ['years', 'days'], ); + + const years = Math.floor(dateDiff.years); + const days = Math.floor(dateDiff.days); + let result = ''; - if (dateDiff.years) result = result + `${dateDiff.years} year`; - if (![0, 1].includes(dateDiff.years)) result = result + 's'; - if (short && dateDiff.years) return result; - if (dateDiff.years && dateDiff.days) result = result + ' and '; - if (dateDiff.days) result = result + `${Math.floor(dateDiff.days)} day`; - if (![0, 1].includes(dateDiff.days)) result = result + 's'; + + if (years !== 0) { + result = plural(Math.abs(years), { + one: `${years} ${t`year`}`, + other: `${years} ${t`years`}`, + }); + + if (short) return result; + } + + if (years !== 0 && days !== 0) { + result += ` ${t`and`} `; + } + + if (days !== 0) { + const daysPart = plural(Math.abs(days), { + one: `${days} ${t`day`}`, + other: `${days} ${t`days`}`, + }); + result += daysPart; + } + return result; }; -const getMonthLabels = () => { - const formatter = new Intl.DateTimeFormat(undefined, { - month: 'short', - timeZone: 'UTC', - }); - - return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] - .map((month) => { - const monthZeroFilled = month < 10 ? `0${month}` : month; - return new Date(`2017-${monthZeroFilled}-01T00:00:00+00:00`); - }) - .map((date) => formatter.format(date)); -}; - -export const getMonthLabelsMemoized = moize(getMonthLabels); - -export const formatISOStringToHumanReadableDateTime = (date: string) => { - const monthLabels = getMonthLabelsMemoized(); - - if (!isDefined(monthLabels)) { - return formatToHumanReadableDateTime(date); - } - - const year = date.slice(0, 4); - const month = date.slice(5, 7); - - const monthLabel = monthLabels[parseInt(month, 10) - 1]; - - const jsDate = new Date(date); - - const day = jsDate.getDate(); - - const hours = `0${jsDate.getHours()}`.slice(-2); - - const minutes = `0${jsDate.getMinutes()}`.slice(-2); - - return `${day} ${monthLabel} ${year} - ${hours}:${minutes}`; -}; - -export const formatISOStringToHumanReadableDate = (date: string) => { - const monthLabels = getMonthLabelsMemoized(); - - if (!isDefined(monthLabels)) { - return formatToHumanReadableDate(date); - } - - const year = date.slice(0, 4); - const month = date.slice(5, 7); - const day = date.slice(8, 10); - - const monthLabel = monthLabels[parseInt(month, 10) - 1]; - - return `${day} ${monthLabel} ${year}`; -}; - export const formatToHumanReadableDate = (date: Date | string) => { const parsedJSDate = parseDate(date).toJSDate(); - return new Intl.DateTimeFormat(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - }).format(parsedJSDate); -}; - -export const formatToHumanReadableDateTime = (date: Date | string) => { - const parsedJSDate = parseDate(date).toJSDate(); - - return new Intl.DateTimeFormat(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: 'numeric', - }).format(parsedJSDate); + return i18n.date(parsedJSDate, { dateStyle: 'medium' }); }; export const getDateFormatString = ( diff --git a/packages/twenty-front/src/utils/title-utils.ts b/packages/twenty-front/src/utils/title-utils.ts index d7922029cd..b0e1ae43a4 100644 --- a/packages/twenty-front/src/utils/title-utils.ts +++ b/packages/twenty-front/src/utils/title-utils.ts @@ -1,21 +1,7 @@ import { AppBasePath } from '@/types/AppBasePath'; import { AppPath } from '@/types/AppPath'; import { SettingsPath } from '@/types/SettingsPath'; - -export enum SettingsPageTitles { - Accounts = 'Account - Settings', - Experience = 'Experience - Settings', - Profile = 'Profile - Settings', - Objects = 'Data model - Settings', - Members = 'Members - Settings', - Developers = 'Developers - Settings', - Apis = 'API Keys - Settings', - Webhooks = 'Webhooks - Settings', - Integration = 'Integrations - Settings', - ServerlessFunctions = 'Functions - Settings', - General = 'General - Settings', - Default = 'Settings', -} +import { t } from '@lingui/core/macro'; enum SettingsPathPrefixes { Accounts = `${AppBasePath.Settings}/${SettingsPath.Accounts}`, @@ -42,33 +28,33 @@ export const getPageTitleFromPath = (pathname: string): string => { const pathnameOrPrefix = getPathnameOrPrefix(pathname); switch (pathnameOrPrefix) { case AppPath.Verify: - return 'Verify'; + return t`Verify`; case AppPath.SignInUp: - return 'Sign in or Create an account'; + return t`Sign in or Create an account`; case AppPath.Invite: - return 'Invite'; + return t`Invite`; case AppPath.CreateWorkspace: - return 'Create Workspace'; + return t`Create Workspace`; case AppPath.CreateProfile: - return 'Create Profile'; + return t`Create Profile`; case SettingsPathPrefixes.Experience: - return SettingsPageTitles.Experience; + return t`Experience - Settings`; case SettingsPathPrefixes.Accounts: - return SettingsPageTitles.Accounts; + return t`Account - Settings`; case SettingsPathPrefixes.Profile: - return SettingsPageTitles.Profile; + return t`Profile - Settings`; case SettingsPathPrefixes.Members: - return SettingsPageTitles.Members; + return t`Members - Settings`; case SettingsPathPrefixes.Objects: - return SettingsPageTitles.Objects; + return t`Data model - Settings`; case SettingsPathPrefixes.ApiWebhooks: - return SettingsPageTitles.Apis; + return t`API Keys - Settings`; case SettingsPathPrefixes.ServerlessFunctions: - return SettingsPageTitles.ServerlessFunctions; + return t`Functions - Settings`; case SettingsPathPrefixes.Integration: - return SettingsPageTitles.Integration; + return t`Integrations - Settings`; case SettingsPathPrefixes.General: - return SettingsPageTitles.General; + return t`General - Settings`; default: return 'Twenty'; }