Format date displayed in Releases section (#14183)

Closes #14177 

I noticed a `formatDisplayDate` file being used in `twenty-website` and
created the same one for `twenty-front` as well. Unit tests for the same
have been added.

<img width="1438" height="770" alt="image"
src="https://github.com/user-attachments/assets/5aa6eef5-19c5-4108-bf3f-c582d0ca1b59"
/>
<img width="1275" height="785" alt="image"
src="https://github.com/user-attachments/assets/20a87ba6-fca4-472c-a691-362901505303"
/>

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Balaji Krishnamurthy
2025-09-04 20:57:31 +05:30
committed by GitHub
parent 40251d34ec
commit 7a999c8476
23 changed files with 344 additions and 264 deletions
@@ -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 (
<Section key={monthTime}>
@@ -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 (
<StyledContainer>
<StyledHead>
<StyledHeading>{subject}</StyledHeading>
{lastMessageSentAt && (
<StyledContent>
Last message {beautifyPastDateRelativeToNow(lastMessageSentAt)}
{t`Last message ${lastMessageSentAtFormatted}`}
</StyledContent>
)}
</StyledHead>
@@ -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 (
<StyledEmailThreadMessageSender>
<ParticipantChip participant={sender} variant="bold" />
<StyledThreadMessageSentAt>
{beautifyPastDateRelativeToNow(sentAt)}
{beautifyPastDateRelativeToNow(sentAt, localeCatalog)}
</StyledThreadMessageSentAt>
</StyledEmailThreadMessageSender>
);
@@ -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 = ({
<AnimatedPlaceholder type="emptyInbox" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
Empty Inbox
<Trans>Empty Inbox</Trans>
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
No email exchange has occurred with this record yet.
<Trans>No email exchange has occurred with this record yet.</Trans>
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
</AnimatedPlaceholderEmptyContainer>
@@ -110,7 +111,8 @@ export const EmailThreads = ({
<StyledH1Title
title={
<>
Inbox <StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
<Trans>Inbox</Trans>{' '}
<StyledEmailCount>{totalNumberOfThreads}</StyledEmailCount>
</>
}
fontColor={H1TitleFontColor.Primary}
@@ -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 = ({
<AnimatedPlaceholder type="noFile" />
<AnimatedPlaceholderEmptyTextContainer>
<AnimatedPlaceholderEmptyTitle>
No Files
<Trans>No Files</Trans>
</AnimatedPlaceholderEmptyTitle>
<AnimatedPlaceholderEmptySubTitle>
There are no associated files with this record.
<Trans>There are no associated files with this record.</Trans>
</AnimatedPlaceholderEmptySubTitle>
</AnimatedPlaceholderEmptyTextContainer>
<StyledFileInput
@@ -115,7 +118,7 @@ export const Attachments = ({
{hasObjectUpdatePermissions && (
<Button
Icon={IconPlus}
title="Add file"
title={t`Add file`}
variant="secondary"
onClick={handleUploadFileClick}
/>
@@ -136,7 +139,7 @@ export const Attachments = ({
/>
<AttachmentList
targetableObject={targetableObject}
title="All"
title={t`All`}
attachments={attachments ?? []}
button={
hasObjectUpdatePermissions && (
@@ -144,7 +147,7 @@ export const Attachments = ({
Icon={IconPlus}
size="small"
variant="secondary"
title="Add file"
title={t`Add file`}
onClick={handleUploadFileClick}
></Button>
)
@@ -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,
);
@@ -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 <LazyMarkdownRenderer text={text} />;
@@ -248,7 +251,12 @@ export const AIChatMessage = ({
)}
{message.content && (
<StyledMessageFooter className="message-footer">
<span>{beautifyPastDateRelativeToNow(message.createdAt)}</span>
<span>
{beautifyPastDateRelativeToNow(
message.createdAt,
localeCatalog,
)}
</span>
<LightCopyIconButton copyText={message.content} />
</StyledMessageFooter>
)}
@@ -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) => {
) : (
<StyledShortcutKeyContainer>
<StyledShortcutKey>{shortcut.firstHotKey}</StyledShortcutKey>
then
{t`then`}
<StyledShortcutKey>{shortcut.secondHotKey}</StyledShortcutKey>
</StyledShortcutKeyContainer>
)
@@ -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<typeof SettingsAccountsBlocklistTable> = {
title: 'Modules/Settings/Accounts/Blocklist/SettingsAccountsBlocklistTable',
component: SettingsAccountsBlocklistTable,
decorators: [ComponentDecorator, ClearMocksDecorator],
decorators: [ComponentDecorator, ClearMocksDecorator, I18nFrontDecorator],
args: {
blocklist: mockedBlocklist,
handleBlockedEmailRemove: handleBlockedEmailRemoveJestFn,
@@ -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<typeof SettingsAccountsBlocklistTableRow> = {
title:
'Modules/Settings/Accounts/Blocklist/SettingsAccountsBlocklistTableRow',
component: SettingsAccountsBlocklistTableRow,
decorators: [ComponentDecorator, ClearMocksDecorator],
decorators: [ComponentDecorator, ClearMocksDecorator, I18nFrontDecorator],
args: {
blocklistItem: mockedBlocklist[0],
onRemove: onRemoveJestFn,
@@ -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 = ({
<StyledTruncatedCell
color={
formattedExpiration === 'Expired'
isExpired(apiKey.expiresAt || null)
? theme.font.color.danger
: theme.font.color.tertiary
}
@@ -1,4 +1,12 @@
import { formatExpiration } from '@/settings/developers/utils/formatExpiration';
import {
formatExpiration,
isExpired,
} from '@/settings/developers/utils/formatExpiration';
import { i18n } from '@lingui/core';
import { messages as enMessages } from '~/locales/generated/en';
i18n.load('en', enMessages);
i18n.activate('en');
jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
@@ -39,3 +47,24 @@ describe('formatExpiration', () => {
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);
});
});
@@ -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}`;
};
@@ -76,7 +76,7 @@ export const SettingsRoleDefaultRole = ({
<Card rounded>
<SettingsOptionCardContentSelect
Icon={IconUserPin}
title="Default Role"
title={t`Default Role`}
description={t`Set a default role for this workspace`}
>
<Select
@@ -11,9 +11,10 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ApolloError } from '@apollo/client';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { IconAt, IconMailCog, Status } from 'twenty-ui/display';
import { useGetApprovedAccessDomainsQuery } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
@@ -25,6 +26,7 @@ export const SettingsApprovedAccessDomainsListCard = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const navigate = useNavigate();
const { t } = useLingui();
const { localeCatalog } = useRecoilValue(dateLocaleState);
const [approvedAccessDomains, setApprovedAccessDomains] = useRecoilState(
approvedAccessDomainsState,
@@ -43,7 +45,10 @@ export const SettingsApprovedAccessDomainsListCard = () => {
});
const getItemDescription = (createdAt: string) => {
const beautifyPastDateRelative = beautifyPastDateRelativeToNow(createdAt);
const beautifyPastDateRelative = beautifyPastDateRelativeToNow(
createdAt,
localeCatalog,
);
return t`Added ${beautifyPastDateRelative}`;
};
@@ -1,11 +1,14 @@
import styled from '@emotion/styled';
import { DateTime } from 'luxon';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { Select } from '@/ui/input/components/Select';
import { DateTimeInput } from '@/ui/input/components/internal/date/components/DateTimeInput';
import { getMonthSelectOptions } from '@/ui/input/components/internal/date/utils/getMonthSelectOptions';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import {
@@ -54,6 +57,9 @@ export const AbsoluteDatePickerHeader = ({
isDateTimeInput,
hideInput = false,
}: AbsoluteDatePickerHeaderProps) => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE;
const endOfDayDateTimeInLocalTimezone = DateTime.now().set({
day: date.getDate(),
month: date.getMonth() + 1,
@@ -84,7 +90,7 @@ export const AbsoluteDatePickerHeader = ({
>
<Select
dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID}
options={getMonthSelectOptions()}
options={getMonthSelectOptions(userLocale)}
onChange={onChangeMonth}
value={endOfDayInLocalTimezone.getMonth()}
fullWidth
@@ -1,16 +1,24 @@
const getMonthName = (index: number): string =>
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,
}));
@@ -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<HTMLInputElement>(null);
@@ -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) => (
<React.Fragment key={release.slug}>
<StyledReleaseHeader>{release.release}</StyledReleaseHeader>
<StyledReleaseDate>{release.date}</StyledReleaseDate>
<StyledReleaseDate>
{formatToHumanReadableDate(release.date)}
</StyledReleaseDate>
<div dangerouslySetInnerHTML={{ __html: release.html }}></div>
</React.Fragment>
))}
@@ -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"
});
});
});
@@ -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');
+66 -107
View File
@@ -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 = (
+15 -29
View File
@@ -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';
}