From a0cfff600028dfb352565ca66ab050f5eaa3e329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 12 Sep 2025 23:25:05 +0200 Subject: [PATCH] Remove Luxon from codebase (#14448) Fixes #14444 We shouldn't have 2 libraries to manage dates, one is enough --- package.json | 2 - .../AdvancedFilterRootRecordFilterGroup.tsx | 5 +- .../components/FormDateTimeFieldInput.tsx | 4 +- .../FormDateFieldInput.stories.tsx | 61 ++++++------- .../FormDateTimeFieldInput.stories.tsx | 63 +++++++------ .../utils/isMatchingDateFilter.ts | 18 ++-- .../components/__stories__/mockedBlocklist.ts | 18 ++-- .../utils/computeNewExpirationDate.ts | 10 +-- .../developers/utils/formatExpiration.ts | 11 ++- .../components/AbsoluteDatePickerHeader.tsx | 21 ++--- .../date/components/InternalDatePicker.tsx | 90 ++++++------------- .../internal/date/utils/parseDateToString.ts | 33 +++---- .../internal/date/utils/parseStringToDate.ts | 19 ++-- .../api-keys/SettingsDevelopersApiKeysNew.tsx | 9 +- .../src/utils/__tests__/date-utils.test.ts | 39 +++----- packages/twenty-front/src/utils/date-utils.ts | 75 ++++++++-------- .../src/utils/format/formatDate.ts | 6 +- .../src/utils/sortFieldMetadataItem.ts | 2 +- .../string/__tests__/formatDateString.test.ts | 4 +- .../__tests__/formatDateTimeString.test.ts | 4 +- yarn.lock | 11 +-- 21 files changed, 215 insertions(+), 290 deletions(-) diff --git a/package.json b/package.json index 9d42c99c03..fc8a1bad4f 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "lodash.pickby": "^4.6.0", "lodash.snakecase": "^4.1.1", "lodash.upperfirst": "^4.3.1", - "luxon": "^3.3.0", "microdiff": "^1.3.2", "moize": "^6.1.6", "patch-package": "^8.0.0", @@ -142,7 +141,6 @@ "@types/lodash.pickby": "^4.6.9", "@types/lodash.snakecase": "^4.1.7", "@types/lodash.upperfirst": "^4.3.7", - "@types/luxon": "^3.3.0", "@types/mailparser": "^3.4.6", "@types/ms": "^0.7.31", "@types/node": "^24.0.0", diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRootRecordFilterGroup.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRootRecordFilterGroup.tsx index 88fc55ad38..35250e0f67 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRootRecordFilterGroup.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRootRecordFilterGroup.tsx @@ -10,7 +10,6 @@ import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import styled from '@emotion/styled'; -import { id } from 'date-fns/locale'; import { isDefined } from 'twenty-shared/utils'; const StyledContainer = styled.div` @@ -37,7 +36,9 @@ export const AdvancedFilterRootRecordFilterGroup = () => { } return ( - + {childRecordFiltersAndRecordFilterGroups.map( diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput.tsx index d943239e36..5b03469c7c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput.tsx @@ -114,7 +114,9 @@ export const FormDateTimeFieldInput = ({ ); const draftValueAsDate = - isDefined(draftValue.value) && isNonEmptyString(draftValue.value) + isDefined(draftValue.value) && + isNonEmptyString(draftValue.value) && + draftValue.type === 'static' ? new Date(draftValue.value) : null; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx index 548e9d7f66..05d5ebe66b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateFieldInput.stories.tsx @@ -10,7 +10,6 @@ import { waitForElementToBeRemoved, within, } from '@storybook/test'; -import { DateTime } from 'luxon'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow'; @@ -208,23 +207,21 @@ export const DefaultsToMinValueWhenTypingReallyOldDate: Story = { }), ); - const expectedDate = DateTime.fromJSDate(MIN_DATE) - .toLocal() - .set({ - day: MIN_DATE.getUTCDate(), - month: MIN_DATE.getUTCMonth() + 1, - year: MIN_DATE.getUTCFullYear(), - hour: 0, - minute: 0, - second: 0, - millisecond: 0, - }); + const expectedDate = new Date( + MIN_DATE.getUTCFullYear(), + MIN_DATE.getUTCMonth(), + MIN_DATE.getUTCDate(), + 0, + 0, + 0, + 0, + ); const selectedDay = within(datePicker).getByRole('option', { selected: true, name: (accessibleName) => { // The name looks like "Choose Sunday, December 31st, 1899" - return accessibleName.includes(expectedDate.toFormat('yyyy')); + return accessibleName.includes(expectedDate.getFullYear().toString()); }, }); expect(selectedDay).toBeVisible(); @@ -264,23 +261,23 @@ export const DefaultsToMaxValueWhenTypingReallyFarDate: Story = { ); }), waitFor(() => { - const expectedDate = DateTime.fromJSDate(MAX_DATE) - .toLocal() - .set({ - day: MAX_DATE.getUTCDate(), - month: MAX_DATE.getUTCMonth() + 1, - year: MAX_DATE.getUTCFullYear(), - hour: 0, - minute: 0, - second: 0, - millisecond: 0, - }); + const expectedDate = new Date( + MAX_DATE.getUTCFullYear(), + MAX_DATE.getUTCMonth(), + MAX_DATE.getUTCDate(), + 0, + 0, + 0, + 0, + ); const selectedDay = within(datePicker).getByRole('option', { selected: true, name: (accessibleName) => { // The name looks like "Choose Thursday, December 30th, 2100" - return accessibleName.includes(expectedDate.toFormat('yyyy')); + return accessibleName.includes( + expectedDate.getFullYear().toString(), + ); }, }); expect(selectedDay).toBeVisible(); @@ -315,13 +312,7 @@ export const SwitchesToStandaloneVariable: Story = { const variableTag = await canvas.findByText('Creation date'); expect(variableTag).toBeVisible(); - const removeVariableButton = canvasElement.querySelector( - 'button .tabler-icon-x', - ); - - if (!removeVariableButton) { - throw new Error('Remove variable button not found'); - } + const removeVariableButton = canvas.getByLabelText('Remove variable'); await Promise.all([ userEvent.click(removeVariableButton), @@ -342,8 +333,12 @@ export const ClickingOutsideDoesNotResetInputState: Story = { onChange: fn(), }, play: async ({ canvasElement, args }) => { + if (!args.defaultValue) { + throw new Error('This test requires a defaultValue'); + } + const defaultValueAsDisplayString = parseDateToString({ - date: new Date(args.defaultValue!), + date: new Date(args.defaultValue), isDateTimeInput: false, userTimezone: undefined, }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateTimeFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateTimeFieldInput.stories.tsx index 287d25be1c..8ab9d80a75 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateTimeFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormDateTimeFieldInput.stories.tsx @@ -11,7 +11,6 @@ import { waitForElementToBeRemoved, within, } from '@storybook/test'; -import { DateTime } from 'luxon'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; import { MOCKED_STEP_ID } from '~/testing/mock-data/workflow'; @@ -257,23 +256,23 @@ export const DefaultsToMinValueWhenTypingReallyOldDate: Story = { ); }), waitFor(() => { - const expectedDate = DateTime.fromJSDate(MIN_DATE) - .toLocal() - .set({ - day: MIN_DATE.getUTCDate(), - month: MIN_DATE.getUTCMonth() + 1, - year: MIN_DATE.getUTCFullYear(), - hour: 0, - minute: 0, - second: 0, - millisecond: 0, - }); + const expectedDate = new Date( + MIN_DATE.getUTCFullYear(), + MIN_DATE.getUTCMonth(), + MIN_DATE.getUTCDate(), + 0, + 0, + 0, + 0, + ); const selectedDay = within(datePicker).getByRole('option', { selected: true, name: (accessibleName) => { // The name looks like "Choose Sunday, December 31st, 1899" - return accessibleName.includes(expectedDate.toFormat('yyyy')); + return accessibleName.includes( + expectedDate.getFullYear().toString(), + ); }, }); expect(selectedDay).toBeVisible(); @@ -315,23 +314,23 @@ export const DefaultsToMaxValueWhenTypingReallyFarDate: Story = { ); }), waitFor(() => { - const expectedDate = DateTime.fromJSDate(MAX_DATE) - .toLocal() - .set({ - day: MAX_DATE.getUTCDate(), - month: MAX_DATE.getUTCMonth() + 1, - year: MAX_DATE.getUTCFullYear(), - hour: 0, - minute: 0, - second: 0, - millisecond: 0, - }); + const expectedDate = new Date( + MAX_DATE.getUTCFullYear(), + MAX_DATE.getUTCMonth(), + MAX_DATE.getUTCDate(), + 0, + 0, + 0, + 0, + ); const selectedDay = within(datePicker).getByRole('option', { selected: true, name: (accessibleName) => { // The name looks like "Choose Thursday, December 30th, 2100" - return accessibleName.includes(expectedDate.toFormat('yyyy')); + return accessibleName.includes( + expectedDate.getFullYear().toString(), + ); }, }); expect(selectedDay).toBeVisible(); @@ -366,13 +365,7 @@ export const SwitchesToStandaloneVariable: Story = { const variableTag = await canvas.findByText('Creation date'); expect(variableTag).toBeVisible(); - const removeVariableButton = canvasElement.querySelector( - 'button .tabler-icon-x', - ); - - if (!removeVariableButton) { - throw new Error('Remove variable button not found'); - } + const removeVariableButton = canvas.getByLabelText('Remove variable'); await Promise.all([ userEvent.click(removeVariableButton), @@ -393,8 +386,12 @@ export const ClickingOutsideDoesNotResetInputState: Story = { onChange: fn(), }, play: async ({ canvasElement, args }) => { + if (!args.defaultValue) { + throw new Error('This test requires a defaultValue'); + } + const defaultValueAsDisplayString = parseDateToString({ - date: new Date(args.defaultValue!), + date: new Date(args.defaultValue), isDateTimeInput: true, userTimezone: undefined, }); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingDateFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingDateFilter.ts index c907785325..55e7ccd0eb 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingDateFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingDateFilter.ts @@ -1,4 +1,4 @@ -import { DateTime } from 'luxon'; +import { parseISO, isEqual, isAfter, isBefore } from 'date-fns'; import { type DateFilter } from '@/object-record//graphql/types/RecordGqlOperationFilter'; @@ -11,10 +11,10 @@ export const isMatchingDateFilter = ({ }) => { switch (true) { case dateFilter.eq !== undefined: { - return DateTime.fromISO(value).equals(DateTime.fromISO(dateFilter.eq)); + return isEqual(parseISO(value), parseISO(dateFilter.eq)); } case dateFilter.neq !== undefined: { - return !DateTime.fromISO(value).equals(DateTime.fromISO(dateFilter.neq)); + return !isEqual(parseISO(value), parseISO(dateFilter.neq)); } case dateFilter.in !== undefined: { return dateFilter.in.includes(value); @@ -27,16 +27,20 @@ export const isMatchingDateFilter = ({ } } case dateFilter.gt !== undefined: { - return DateTime.fromISO(value) > DateTime.fromISO(dateFilter.gt); + return isAfter(parseISO(value), parseISO(dateFilter.gt)); } case dateFilter.gte !== undefined: { - return DateTime.fromISO(value) >= DateTime.fromISO(dateFilter.gte); + const valueDate = parseISO(value); + const filterDate = parseISO(dateFilter.gte); + return isAfter(valueDate, filterDate) || isEqual(valueDate, filterDate); } case dateFilter.lt !== undefined: { - return DateTime.fromISO(value) < DateTime.fromISO(dateFilter.lt); + return isBefore(parseISO(value), parseISO(dateFilter.lt)); } case dateFilter.lte !== undefined: { - return DateTime.fromISO(value) <= DateTime.fromISO(dateFilter.lte); + const valueDate = parseISO(value); + const filterDate = parseISO(dateFilter.lte); + return isBefore(valueDate, filterDate) || isEqual(valueDate, filterDate); } default: { throw new Error( diff --git a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/mockedBlocklist.ts b/packages/twenty-front/src/modules/settings/accounts/components/__stories__/mockedBlocklist.ts index b281654ea9..ed85313d02 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/__stories__/mockedBlocklist.ts +++ b/packages/twenty-front/src/modules/settings/accounts/components/__stories__/mockedBlocklist.ts @@ -1,4 +1,4 @@ -import { DateTime } from 'luxon'; +import { formatISO, parseISO, subDays, subHours } from 'date-fns'; import { type BlocklistItem } from '@/accounts/types/BlocklistItem'; @@ -8,9 +8,7 @@ export const mockedBlocklist: BlocklistItem[] = [ handle: 'test1@twenty.com', workspaceMemberId: '1', createdAt: - DateTime.fromISO('2023-04-26T10:12:42.33625+00:00') - .minus({ hours: 2 }) - .toISO() ?? '', + formatISO(subHours(parseISO('2023-04-26T10:12:42.33625+00:00'), 2)) ?? '', __typename: 'BlocklistItem', }, { @@ -18,9 +16,7 @@ export const mockedBlocklist: BlocklistItem[] = [ handle: 'test2@twenty.com', workspaceMemberId: '1', createdAt: - DateTime.fromISO('2023-04-26T10:12:42.33625+00:00') - .minus({ days: 2 }) - .toISO() ?? '', + formatISO(subDays(parseISO('2023-04-26T10:12:42.33625+00:00'), 2)) ?? '', __typename: 'BlocklistItem', }, { @@ -28,9 +24,7 @@ export const mockedBlocklist: BlocklistItem[] = [ handle: 'test3@twenty.com', workspaceMemberId: '1', createdAt: - DateTime.fromISO('2023-04-26T10:12:42.33625+00:00') - .minus({ days: 3 }) - .toISO() ?? '', + formatISO(subDays(parseISO('2023-04-26T10:12:42.33625+00:00'), 3)) ?? '', __typename: 'BlocklistItem', }, { @@ -38,9 +32,7 @@ export const mockedBlocklist: BlocklistItem[] = [ handle: '@twenty.com', workspaceMemberId: '1', createdAt: - DateTime.fromISO('2023-04-26T10:12:42.33625+00:00') - .minus({ days: 4 }) - .toISO() ?? '', + formatISO(subDays(parseISO('2023-04-26T10:12:42.33625+00:00'), 4)) ?? '', __typename: 'BlocklistItem', }, ]; diff --git a/packages/twenty-front/src/modules/settings/developers/utils/computeNewExpirationDate.ts b/packages/twenty-front/src/modules/settings/developers/utils/computeNewExpirationDate.ts index 4dc342969b..aa41102c03 100644 --- a/packages/twenty-front/src/modules/settings/developers/utils/computeNewExpirationDate.ts +++ b/packages/twenty-front/src/modules/settings/developers/utils/computeNewExpirationDate.ts @@ -1,4 +1,4 @@ -import { DateTime } from 'luxon'; +import { differenceInDays, parseISO, addDays } from 'date-fns'; export const computeNewExpirationDate = ( expiresAt: string | null | undefined, @@ -7,8 +7,8 @@ export const computeNewExpirationDate = ( if (!expiresAt) { return null; } - const days = DateTime.fromISO(expiresAt).diff(DateTime.fromISO(createdAt), [ - 'days', - ]).days; - return DateTime.utc().plus({ days }).toISO(); + const expirationDate = parseISO(expiresAt); + const creationDate = parseISO(createdAt); + const days = differenceInDays(expirationDate, creationDate); + return addDays(new Date(), days).toISOString(); }; 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 7b8b211de2..dc92d43b92 100644 --- a/packages/twenty-front/src/modules/settings/developers/utils/formatExpiration.ts +++ b/packages/twenty-front/src/modules/settings/developers/utils/formatExpiration.ts @@ -1,16 +1,15 @@ import { t } from '@lingui/core/macro'; import { isNonEmptyString } from '@sniptt/guards'; -import { DateTime } from 'luxon'; +import { differenceInYears, parseISO } from 'date-fns'; import { NEVER_EXPIRE_DELTA_IN_YEARS } from '@/settings/developers/constants/NeverExpireDeltaInYears'; import { beautifyDateDiff } from '~/utils/date-utils'; export const doesNeverExpire = (expiresAt: string) => { - const dateDiff = DateTime.fromISO(expiresAt).diff(DateTime.now(), [ - 'years', - 'days', - ]); - return dateDiff.years > NEVER_EXPIRE_DELTA_IN_YEARS / 10; + const expirationDate = parseISO(expiresAt); + const now = new Date(); + const yearsDiff = differenceInYears(expirationDate, now); + return yearsDiff > NEVER_EXPIRE_DELTA_IN_YEARS / 10; }; export const isExpired = (expiresAt: string | null) => { diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx index 3b4735d7dd..961a92c48d 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/AbsoluteDatePickerHeader.tsx @@ -1,5 +1,4 @@ import styled from '@emotion/styled'; -import { DateTime } from 'luxon'; import { useRecoilValue } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; @@ -60,17 +59,15 @@ export const AbsoluteDatePickerHeader = ({ const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const userLocale = currentWorkspaceMember?.locale ?? SOURCE_LOCALE; - const endOfDayDateTimeInLocalTimezone = DateTime.now().set({ - day: date.getDate(), - month: date.getMonth() + 1, - year: date.getFullYear(), - hour: 23, - minute: 59, - second: 59, - millisecond: 999, - }); - - const endOfDayInLocalTimezone = endOfDayDateTimeInLocalTimezone.toJSDate(); + const endOfDayInLocalTimezone = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + 23, + 59, + 59, + 999, + ); return ( <> diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx index 58314010ae..fc9dff9531 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/InternalDatePicker.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -import { DateTime } from 'luxon'; -import { lazy, Suspense, useContext, type ComponentType } from 'react'; +import { addMonths, setDate, setMonth, setYear, subMonths } from 'date-fns'; +import { lazy, Suspense, type ComponentType } from 'react'; import type { ReactDatePickerProps as ReactDatePickerLibProps } from 'react-datepicker'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; @@ -13,7 +13,6 @@ import { DateTimeInput } from '@/ui/input/components/internal/date/components/Da import { RelativeDatePickerHeader } from '@/ui/input/components/internal/date/components/RelativeDatePickerHeader'; import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates'; import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; -import { UserContext } from '@/users/contexts/UserContext'; import { useTheme } from '@emotion/react'; import { t } from '@lingui/core/macro'; import 'react-datepicker/dist/react-datepicker.css'; @@ -352,7 +351,6 @@ export const DateTimePicker = ({ }: DateTimePickerProps) => { const internalDate = date ?? new Date(); - const { timeZone } = useContext(UserContext); const theme = useTheme(); const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown(); @@ -380,90 +378,60 @@ export const DateTimePicker = ({ }; const handleAddMonth = () => { - const dateParsed = DateTime.fromJSDate(internalDate, { zone: timeZone }) - .plus({ months: 1 }) - .toJSDate(); - + const dateParsed = addMonths(internalDate, 1); onChange?.(dateParsed); }; const handleSubtractMonth = () => { - const dateParsed = DateTime.fromJSDate(internalDate, { zone: timeZone }) - .minus({ months: 1 }) - .toJSDate(); - + const dateParsed = subMonths(internalDate, 1); onChange?.(dateParsed); }; const handleChangeYear = (year: number) => { - const dateParsed = DateTime.fromJSDate(internalDate, { zone: timeZone }) - .set({ year: year }) - .toJSDate(); - + const dateParsed = setYear(internalDate, year); 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(); + let dateParsed = setYear(internalDate, date.getFullYear()); + dateParsed = setMonth(dateParsed, date.getMonth()); + dateParsed = setDate(dateParsed, date.getDate()); 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(); + let dateParsed = setYear(internalDate, date.getFullYear()); + dateParsed = setMonth(dateParsed, date.getMonth()); + dateParsed = setDate(dateParsed, date.getDate()); handleClose?.(dateParsed); }; - const dateWithoutTime = DateTime.fromJSDate(internalDate) - .toLocal() - .set({ - day: internalDate.getUTCDate(), - month: internalDate.getUTCMonth() + 1, - year: internalDate.getUTCFullYear(), - hour: 0, - minute: 0, - second: 0, - millisecond: 0, - }) - .toJSDate(); - - const dateParsed = DateTime.fromJSDate(internalDate, { - zone: isDateTimeInput ? timeZone : 'local', - }); + const dateWithoutTime = new Date( + internalDate.getUTCFullYear(), + internalDate.getUTCMonth(), + internalDate.getUTCDate(), + 0, + 0, + 0, + 0, + ); // 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 endOfDayInLocalTimezone = new Date( + internalDate.getFullYear(), + internalDate.getMonth(), + internalDate.getDate(), + 23, + 59, + 59, + 999, + ); const dateToUse = isDateTimeInput ? endOfDayInLocalTimezone : dateWithoutTime; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseDateToString.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseDateToString.ts index 3a6ebd926c..2418ac8ff4 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseDateToString.ts +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseDateToString.ts @@ -1,5 +1,7 @@ import { DateFormat } from '@/localization/constants/DateFormat'; -import { DateTime } from 'luxon'; +import { format } from 'date-fns'; +import { formatInTimeZone } from 'date-fns-tz'; +import { isDefined } from 'twenty-shared/utils'; import { getDateFormatString } from '~/utils/date-utils'; type ParseDateToStringArgs = { @@ -17,23 +19,14 @@ export const parseDateToString = ({ }: ParseDateToStringArgs) => { const parsingFormat = getDateFormatString(dateFormat, isDateTimeInput); - const dateParsed = DateTime.fromJSDate(date, { zone: userTimezone }); - - const dateWithoutTime = DateTime.fromJSDate(date) - .toLocal() - .set({ - day: date.getUTCDate(), - month: date.getUTCMonth() + 1, - year: date.getUTCFullYear(), - hour: 0, - minute: 0, - second: 0, - millisecond: 0, - }); - - const formattedDate = isDateTimeInput - ? dateParsed.setZone(userTimezone).toFormat(parsingFormat) - : dateWithoutTime.toFormat(parsingFormat); - - return formattedDate; + if (isDateTimeInput && isDefined(userTimezone)) { + return formatInTimeZone(date, userTimezone, parsingFormat); + } else if (isDateTimeInput) { + return format(date, parsingFormat); + } else { + const dateWithoutTime = new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); + return format(dateWithoutTime, parsingFormat); + } }; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseStringToDate.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseStringToDate.ts index 9f85eb33f7..1cfc13976c 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseStringToDate.ts +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseStringToDate.ts @@ -1,5 +1,7 @@ import { type DateFormat } from '@/localization/constants/DateFormat'; -import { DateTime } from 'luxon'; +import { isValid, parse } from 'date-fns'; +import { zonedTimeToUtc } from 'date-fns-tz'; +import { isDefined } from 'twenty-shared/utils'; import { getDateFormatString } from '~/utils/date-utils'; type ParseStringToDateArgs = { @@ -16,18 +18,17 @@ export const parseStringToDate = ({ dateFormat, }: ParseStringToDateArgs) => { const parsingFormat = getDateFormatString(dateFormat, isDateTimeInput); + const referenceDate = new Date(); - const parsedDate = isDateTimeInput - ? DateTime.fromFormat(dateAsString, parsingFormat, { zone: userTimezone }) - : DateTime.fromFormat(dateAsString, parsingFormat, { zone: 'utc' }); + const parsedDate = parse(dateAsString, parsingFormat, referenceDate); - const isValid = parsedDate.isValid; - - if (!isValid) { + if (!isValid(parsedDate)) { return null; } - const jsDate = parsedDate.toJSDate(); + if (isDateTimeInput && isDefined(userTimezone)) { + return zonedTimeToUtc(parsedDate, userTimezone); + } - return jsDate; + return parsedDate; }; diff --git a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx index 606cb3ceb5..8d4be8f74c 100644 --- a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx +++ b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx @@ -1,4 +1,4 @@ -import { DateTime } from 'luxon'; +import { addDays } from 'date-fns'; import { useState } from 'react'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; @@ -72,9 +72,10 @@ export const SettingsDevelopersApiKeysNew = () => { ); const handleSave = async () => { - const expiresAt = DateTime.now() - .plus({ days: formValues.expirationDate ?? 30 }) - .toString(); + const expiresAt = addDays( + new Date(), + formValues.expirationDate ?? 30, + ).toISOString(); const roleIdToUse = formValues.roleId; 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 428df197b9..a123cfcb9a 100644 --- a/packages/twenty-front/src/utils/__tests__/date-utils.test.ts +++ b/packages/twenty-front/src/utils/__tests__/date-utils.test.ts @@ -1,7 +1,6 @@ import { i18n } from '@lingui/core'; -import { formatDistanceToNow } from 'date-fns'; +import { addDays, format, formatDistanceToNow, subDays } 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'; @@ -19,10 +18,6 @@ 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')); @@ -30,20 +25,16 @@ describe('beautifyExactDateTime', () => { it('should return the date in the correct format with time', () => { const mockDate = '2023-01-01T12:13:24'; const actualDate = new Date(mockDate); - const expected = DateTime.fromJSDate(actualDate) - .setLocale(getLuxonLocale()) - .toFormat('DD · T'); + const expected = format(actualDate, 'MMM d, yyyy · HH:mm'); const result = beautifyExactDateTime(mockDate); expect(result).toEqual(expected); }); it('should return the time in the correct format for a datetime that is today', () => { - const todayString = DateTime.local().toISODate(); + const todayString = '2024-01-01'; // Using the mocked date const mockDate = `${todayString}T12:13:24`; const actualDate = new Date(mockDate); - const expected = DateTime.fromJSDate(actualDate) - .setLocale(getLuxonLocale()) - .toFormat('T'); + const expected = format(actualDate, 'HH:mm'); const result = beautifyExactDateTime(mockDate); expect(result).toEqual(expected); @@ -54,15 +45,13 @@ describe('beautifyExactDate', () => { it('should return the past date in the correct format without time', () => { const mockDate = '2023-01-01T12:13:24'; const actualDate = new Date(mockDate); - const expected = DateTime.fromJSDate(actualDate) - .setLocale(getLuxonLocale()) - .toFormat('DD'); + const expected = format(actualDate, 'MMM d, yyyy'); const result = beautifyExactDate(mockDate); expect(result).toEqual(expected); }); it('should return "Today" if the date is today', () => { - const todayString = DateTime.local().toISODate(); + const todayString = '2024-01-01'; // Using the mocked date const mockDate = `${todayString}T12:13:24`; const expected = 'Today'; @@ -162,25 +151,25 @@ describe('hasDatePassed', () => { }); it('should return true when passed past date', () => { - const now = DateTime.local(); - const pastDate = now.minus({ day: 1 }); + const now = new Date(); + const pastDate = subDays(now, 1); - const result = hasDatePassed(pastDate.toJSDate()); + const result = hasDatePassed(pastDate); expect(result).toEqual(true); }); it('should return false when passed future date', () => { - const now = DateTime.local(); - const futureDate = now.plus({ days: 1 }); + const now = new Date(); + const futureDate = addDays(now, 1); - const result = hasDatePassed(futureDate.toJSDate()); + const result = hasDatePassed(futureDate); expect(result).toEqual(false); }); it('should return false when passed current date', () => { - const now = DateTime.local(); + const now = new Date(); - const result = hasDatePassed(now.toJSDate()); + const result = hasDatePassed(now); expect(result).toEqual(false); }); }); diff --git a/packages/twenty-front/src/utils/date-utils.ts b/packages/twenty-front/src/utils/date-utils.ts index 3a0ece73c6..be8dcb5bd5 100644 --- a/packages/twenty-front/src/utils/date-utils.ts +++ b/packages/twenty-front/src/utils/date-utils.ts @@ -1,14 +1,18 @@ import { isDate, isNumber, isString } from '@sniptt/guards'; import { differenceInCalendarDays, + differenceInDays, + differenceInYears, + format, formatDistance, formatDistanceToNow, + isToday, + isValid, + parseISO, type Locale, } from 'date-fns'; -import { DateTime } from 'luxon'; 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'; @@ -16,14 +20,10 @@ import { i18n } from '@lingui/core'; import { plural, t } from '@lingui/core/macro'; import { logError } from './logError'; -const getLuxonLocale = () => { - return SOURCE_LOCALE === 'en' ? 'en-US' : SOURCE_LOCALE; -}; +export const parseDate = (dateToParse: Date | string | number): Date => { + if (dateToParse === 'now') return new Date(); -export const parseDate = (dateToParse: Date | string | number) => { - if (dateToParse === 'now') return DateTime.fromJSDate(new Date()); - - let formattedDate: DateTime | null = null; + let formattedDate: Date | null = null; if (!dateToParse) { throw new CustomError( @@ -31,11 +31,11 @@ export const parseDate = (dateToParse: Date | string | number) => { 'INVALID_DATE_FORMAT', ); } else if (isString(dateToParse)) { - formattedDate = DateTime.fromISO(dateToParse); + formattedDate = parseISO(dateToParse); } else if (isDate(dateToParse)) { - formattedDate = DateTime.fromJSDate(dateToParse); + formattedDate = dateToParse; } else if (isNumber(dateToParse)) { - formattedDate = DateTime.fromMillis(dateToParse); + formattedDate = new Date(dateToParse); } if (!formattedDate) { @@ -45,26 +45,23 @@ export const parseDate = (dateToParse: Date | string | number) => { ); } - if (!formattedDate.isValid) { + if (!isValid(formattedDate)) { throw new CustomError( `Invalid date passed to formatPastDate: "${dateToParse}"`, 'INVALID_DATE_FORMAT', ); } - return formattedDate.setLocale(getLuxonLocale()); + return formattedDate; }; -const isSameDay = (a: DateTime, b: DateTime): boolean => - a.hasSame(b, 'day') && a.hasSame(b, 'month') && a.hasSame(b, 'year'); - export const formatDate = ( dateToFormat: Date | string | number, - format: string, + formatString: string, ) => { try { const parsedDate = parseDate(dateToFormat); - return parsedDate.toFormat(format); + return format(parsedDate, formatString); } catch (error) { logError(error); return ''; @@ -74,17 +71,19 @@ export const formatDate = ( export const beautifyExactDateTime = ( dateToBeautify: Date | string | number, ) => { - const isToday = isSameDay(parseDate(dateToBeautify), DateTime.local()); - const dateFormat = isToday ? 'T' : 'DD · T'; + const parsedDate = parseDate(dateToBeautify); + const isTodayDate = isToday(parsedDate); + const dateFormat = isTodayDate ? 'HH:mm' : 'MMM d, yyyy · HH:mm'; return formatDate(dateToBeautify, dateFormat); }; export const beautifyExactDate = (dateToBeautify: Date | string | number) => { - const isToday = isSameDay(parseDate(dateToBeautify), DateTime.local()); - if (isToday) { + const parsedDate = parseDate(dateToBeautify); + const isTodayDate = isToday(parsedDate); + if (isTodayDate) { return t`Today`; } - return formatDate(dateToBeautify, 'DD'); + return formatDate(dateToBeautify, 'MMM d, yyyy'); }; export const beautifyPastDateRelativeToNow = ( @@ -95,7 +94,7 @@ export const beautifyPastDateRelativeToNow = ( const parsedDate = parseDate(pastDate); const now = new Date(); const diffInSeconds = Math.abs( - (now.getTime() - parsedDate.toJSDate().getTime()) / 1000, + (now.getTime() - parsedDate.getTime()) / 1000, ); // For very recent times (less than 30 seconds), show "now" @@ -103,7 +102,7 @@ export const beautifyPastDateRelativeToNow = ( return t`now`; } - return formatDistanceToNow(parsedDate.toJSDate(), { + return formatDistanceToNow(parsedDate, { addSuffix: true, locale, includeSeconds: true, @@ -118,12 +117,7 @@ export const hasDatePassed = (date: Date | string | number) => { try { const parsedDate = parseDate(date); - return ( - differenceInCalendarDays( - DateTime.local().toJSDate(), - parsedDate.toJSDate(), - ) >= 1 - ); + return differenceInCalendarDays(new Date(), parsedDate) >= 1; } catch (error) { logError(error); return false; @@ -144,13 +138,16 @@ export const beautifyDateDiff = ( } // 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 fromDate = parseISO(date); + const toDate = dateToCompareWith ? parseISO(dateToCompareWith) : new Date(); - const years = Math.floor(dateDiff.years); - const days = Math.floor(dateDiff.days); + const years = differenceInYears(fromDate, toDate); + // Calculate remaining days after accounting for full years + const startDateForDayCalculation = new Date(toDate); + startDateForDayCalculation.setFullYear( + startDateForDayCalculation.getFullYear() + years, + ); + const days = differenceInDays(fromDate, startDateForDayCalculation); let result = ''; @@ -179,7 +176,7 @@ export const beautifyDateDiff = ( }; export const formatToHumanReadableDate = (date: Date | string) => { - const parsedJSDate = parseDate(date).toJSDate(); + const parsedJSDate = parseDate(date); return i18n.date(parsedJSDate, { dateStyle: 'medium' }); }; diff --git a/packages/twenty-front/src/utils/format/formatDate.ts b/packages/twenty-front/src/utils/format/formatDate.ts index 0d027dca5d..0b72dad0c6 100644 --- a/packages/twenty-front/src/utils/format/formatDate.ts +++ b/packages/twenty-front/src/utils/format/formatDate.ts @@ -4,7 +4,7 @@ export const formatToHumanReadableMonth = ( date: Date | string, timeZone: string, ) => { - const parsedJSDate = parseDate(date).toJSDate(); + const parsedJSDate = parseDate(date); return new Intl.DateTimeFormat(undefined, { month: 'short', @@ -16,7 +16,7 @@ export const formatToHumanReadableDay = ( date: Date | string, timeZone: string, ) => { - const parsedJSDate = parseDate(date).toJSDate(); + const parsedJSDate = parseDate(date); return new Intl.DateTimeFormat(undefined, { day: 'numeric', @@ -28,7 +28,7 @@ export const formatToHumanReadableTime = ( date: Date | string, timeZone: string, ) => { - const parsedJSDate = parseDate(date).toJSDate(); + const parsedJSDate = parseDate(date); return new Intl.DateTimeFormat(undefined, { hour: 'numeric', diff --git a/packages/twenty-front/src/utils/sortFieldMetadataItem.ts b/packages/twenty-front/src/utils/sortFieldMetadataItem.ts index 3e533025e9..52707c529e 100644 --- a/packages/twenty-front/src/utils/sortFieldMetadataItem.ts +++ b/packages/twenty-front/src/utils/sortFieldMetadataItem.ts @@ -16,5 +16,5 @@ export const sortFieldMetadataItem = ( if (!dateA) return 1; if (!dateB) return -1; - return dateB.diff(dateA).milliseconds > 0 ? -1 : 1; + return dateB.getTime() - dateA.getTime() > 0 ? -1 : 1; }; diff --git a/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts b/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts index 5888275192..dea07023ac 100644 --- a/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts +++ b/packages/twenty-front/src/utils/string/__tests__/formatDateString.test.ts @@ -1,7 +1,7 @@ import { DateFormat } from '@/localization/constants/DateFormat'; import { FieldDateDisplayFormat } from '@/object-record/record-field/ui/types/FieldMetadata'; import { enUS } from 'date-fns/locale'; -import { DateTime } from 'luxon'; +import { subDays } from 'date-fns'; import { formatDateString } from '~/utils/string/formatDateString'; describe('formatDateString', () => { @@ -31,7 +31,7 @@ describe('formatDateString', () => { }); it('should format date as relative when displayFormat is set to RELATIVE', () => { - const mockDate = DateTime.now().minus({ days: 2 }).toISO(); + const mockDate = subDays(new Date(), 2).toISOString(); const mockRelativeDate = '2 days ago'; const result = formatDateString({ diff --git a/packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts b/packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts index da5333c4bf..50ee5340f1 100644 --- a/packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts +++ b/packages/twenty-front/src/utils/string/__tests__/formatDateTimeString.test.ts @@ -1,8 +1,8 @@ import { DateFormat } from '@/localization/constants/DateFormat'; import { TimeFormat } from '@/localization/constants/TimeFormat'; import { FieldDateDisplayFormat } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { subDays } from 'date-fns'; import { enUS } from 'date-fns/locale'; -import { DateTime } from 'luxon'; import { formatDateTimeString } from '~/utils/string/formatDateTimeString'; describe('formatDateTimeString', () => { @@ -33,7 +33,7 @@ describe('formatDateTimeString', () => { }); it('should format date as relative when displayFormat is RELATIVE', () => { - const mockDate = DateTime.now().minus({ days: 2 }).toISO(); + const mockDate = subDays(new Date(), 2).toISOString(); const mockRelativeDate = '2 days ago'; const result = formatDateTimeString({ diff --git a/yarn.lock b/yarn.lock index a884214d98..094c687b2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20557,13 +20557,6 @@ __metadata: languageName: node linkType: hard -"@types/luxon@npm:^3.3.0": - version: 3.4.2 - resolution: "@types/luxon@npm:3.4.2" - checksum: 10c0/d835467de3daf7e17ba78b50bb5a14efd94272439ca067990d71332a54b311544459c69623eddd243b511b28d70194c9591a9ee8cf9c038962c965f991affd7e - languageName: node - linkType: hard - "@types/luxon@npm:~3.3.0": version: 3.3.8 resolution: "@types/luxon@npm:3.3.8" @@ -39460,7 +39453,7 @@ __metadata: languageName: node linkType: hard -"luxon@npm:^3.2.1, luxon@npm:^3.3.0, luxon@npm:^3.6.1": +"luxon@npm:^3.2.1, luxon@npm:^3.6.1": version: 3.6.1 resolution: "luxon@npm:3.6.1" checksum: 10c0/906d57a9dc4d1de9383f2e9223e378c298607c1b4d17b6657b836a3cd120feb1c1de3b5d06d846a3417e1ca764de8476e8c23b3cd4083b5cdb870adcb06a99d5 @@ -52062,7 +52055,6 @@ __metadata: "@types/lodash.pickby": "npm:^4.6.9" "@types/lodash.snakecase": "npm:^4.1.7" "@types/lodash.upperfirst": "npm:^4.3.7" - "@types/luxon": "npm:^3.3.0" "@types/mailparser": "npm:^3.4.6" "@types/ms": "npm:^0.7.31" "@types/node": "npm:^24.0.0" @@ -52140,7 +52132,6 @@ __metadata: lodash.pickby: "npm:^4.6.0" lodash.snakecase: "npm:^4.1.1" lodash.upperfirst: "npm:^4.3.1" - luxon: "npm:^3.3.0" microdiff: "npm:^1.3.2" moize: "npm:^6.1.6" msw: "npm:^2.0.11"