From 0b5be7caa31c5846fbb7abddf168df2c1ea9167a Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Tue, 23 Dec 2025 07:40:26 -1000 Subject: [PATCH] Refactored Date to Temporal in critical date zones (#16544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/twentyhq/twenty/issues/16110 This PR implements Temporal to replace the legacy Date object, in all features that are time zone sensitive. (around 80% of the app) Here we define a few utils to handle Temporal primitives and obtain an easier DX for timezone manipulation, front end and back end. This PR deactivates the usage of timezone from the graph configuration, because for now it's always UTC and is not really relevant, let's handle that later. Workflows code and backend only code that don't take user input are using UTC time zone, the affected utils have not been refactored yet because this PR is big enough. # New way of filtering on date intervals As we'll progressively rollup Temporal everywhere in the codebase and remove `Date` JS object everywhere possible, we'll use the way to filter that is recommended by Temporal. This way of filtering on date intervals involves half-open intervals, and is the preferred way to avoid edge-cases with DST and smallest time increment edge-case. ## Filtering endOfX with DST edge-cases Some day-light save time shifts involve having no existing hour, or even day on certain days, for example Samoa Islands have no 30th of December 2011 : https://www.timeanddate.com/news/time/samoa-dateline.html, it jumps from 29th to 31st, so filtering on `< next period start` makes it easier to let the date library handle the strict inferior comparison, than filtering on `≤ end of period` and trying to compute manually the end of the period. For example for Samoa Islands, is end of day `2011-12-29T23:59:59.999` or is it `2011-12-30T23:59:59.999` ? If you say I don't need to know and compute it, because I want everything strictly before `2011-12-29T00:00:00 + start of next day (according to the library which knows those edge-cases)`, then you have a 100% deterministic way of computing date intervals in any timezone, for any day of any year. Of course the Samoa example is an extreme one, but more common ones involve DST shifts of 1 hour, which are still problematic on certain days of the year. ## Computing the exact _end of period_ Having an open interval filtering, with `[included - included]` instead of half-open `[included - excluded)`, forces to compute the open end of an interval, which often involves taking an arbitrary unit like minute, second, microsecond or nanosecond, which will lead to edge-case of unhandled values. For example, let's say my code computes endOfDay by setting the time to `23:59:59.999`, if another library, API, or anything else, ends up giving me a date-time with another time precision `23:59:59.999999999` (down to the nanosecond), then this date-time will be filtered out, while it should not. The good deterministic way to avoid 100% of those complex bugs is to create a half-open filter : `≥ start of period` to `< start of next period` For example : `≥ 2025-01-01T00:00:00` to `< 2025-01-02T00:00:00` instead of `≥ 2025-01-01T00:00:00` to `≤ 2025-01-01T23:59:59.999` Because, `2025-01-01T00:00:00` = `2025-01-01T00:00:00.000` = `2025-01-01T00:00:00.000000` = `2025-01-01T00:00:00.000000000` => no risk of error in computing start of period But `2025-01-01T23:59:59` ≠ `2025-01-01T23:59:59.999` ≠ `2025-01-01T23:59:59.999999` ≠ `2025-01-01T23:59:59.999999999` => existing risk of error in computing end of period This is why an half-open interval has no risk of error in computing a date-time interval filter. Here is a link to this debate : https://github.com/tc39/proposal-temporal/issues/2568 > For this reason, we recommend not calculating the exact nanosecond at the end of the day if it's not absolutely necessary. For example, if it's needed for <= comparisons, we recommend just changing the comparison code. So instead of <= zdtEndOfDay your code could be < zdtStartOfNextDay which is easier to calculate and not subject to the issue of not knowing which unit is the right one. > > [Justin Grant](https://github.com/justingrant), top contributor of Temporal ## Application to our codebase Applying this half-open filtering paradigm to our codebase means we would have to rename `IS_AFTER` to `IS_AFTER_OR_EQUAL` and to keep `IS_BEFORE` (or even `IS_STRICTLY_BEFORE`) to make this half-open interval self-explanatory everywhere in the codebase, this will avoid any confusion. See the relevant issue : https://github.com/twentyhq/core-team-issues/issues/2010 In the mean time, we'll keep this operand and add this semantic in the naming everywhere possible. ## Example with a different user timezone Example on a graph grouped by week in timezone Pacific/Samoa, on a computer running on Europe/Paris : image Then the associated data in the table view, with our **half-open date-time filter** : image And the associated SQL query result to see how DATE_TRUNC in Postgres applies its internal start of week logic : image The associated SQL query without parameters to test in your SQL client : ```SQL SELECT "opportunity"."closeDate" as "close_date", TO_CHAR(DATE_TRUNC('week', "opportunity"."closeDate", 'Pacific/Samoa') AT TIME ZONE 'Pacific/Samoa', 'YYYY-MM-DD') AS "DATE_TRUNC by week start in timezone Pacific/Samoa", "opportunity"."name" FROM "workspace_1wgvd1injqtife6y4rvfbu3h5"."opportunity" "opportunity" ORDER BY "opportunity"."closeDate" ASC NULLS LAST ``` # Date picker simplification (not in this PR) Our DatePicker component, which is wrapping `react-datepicker` library component, is now exposing plain dates as string instead of Date object. The Date object is still used internally to manage the library component, but since the date picker calendar is only manipulating plain dates, there is no need to add timezone management to it, and no need to expose a handleChange with Date object. The timezone management relies on date time inputs now. The modification has been made in a previous PR : https://github.com/twentyhq/twenty/issues/15377 but it's good to reference it here. # Calendar feature refactor Calendar feature has been refactored to rely on Temporal.PlainDate as much as possible, while leaving some date-fns utils to avoid re-coding them. Since the trick is to use utils to convert back and from Date object in exec env reliably, we can do it everywhere we need to interface legacy Date object utils and Temporal related code. ## TimeZone is now shown on Calendar : image ## Month picker has been refactored image Since the days weren't useful, the picker has been refactored to remove the days. # Miscellaneous - Fixed a bug with drag and drop edge-case with 2 items in a list. # Improvements ## Lots of chained operations It would be nice to create small utils to avoid repeated chained operations, but that is how Temporal is designed, a very small set of primitive operations that allow to compose everything needed. Maybe we'll have wrappers on top of Temporal in the coming years. ## Creation of Temporal objects is throwing errors If the input is badly formatted Temporal will throw, we might want to adopt a global strategy to avoid that. Example : ```ts const newPlainDate = Temporal.PlainDate.from('bad-string'); // Will throw ``` --- package.json | 2 +- .../computeContextStoreFilters.test.ts | 1 + .../__tests__/useDateTimeFormat.test.tsx | 2 +- .../__tests__/useFormatPreferences.test.tsx | 5 +- .../hooks/useFormatPreferences.ts | 2 +- .../workspaceMemberFormatPreferencesState.ts | 2 +- .../utils/detection/detectCalendarStartDay.ts | 26 +- .../utils/detection/detectTimeFormat.ts | 1 + .../getCalendarStartDayFromWorkspaceMember.ts | 2 +- ...erCommandMenuRecordFilterOperandSelect.tsx | 7 +- ...dvancedFilterCommandMenuValueFormInput.tsx | 14 +- ...dvancedFilterRecordFilterOperandSelect.tsx | 13 +- ...FilterRecordFilterOperandSelectContent.tsx | 22 +- .../ObjectFilterDropdownDateInput.tsx | 5 +- .../ObjectFilterDropdownDateTimeInput.tsx | 59 +-- ...lterDropdownInnerSelectOperandDropdown.tsx | 13 +- .../useApplyObjectFilterDropdownOperand.ts | 65 +-- .../hooks/useGetDateFilterDisplayValue.ts | 17 + .../hooks/useGetDateTimeFilterDisplayValue.ts | 43 +- .../hooks/useGetInitialFilterValue.ts | 78 +-- .../utils/getOperandLabel.ts | 25 +- .../utils/getRelativeDateDisplayValue.ts | 25 +- ...tRelativeDateFilterTimeZoneAbbreviation.ts | 17 + .../components/RecordCalendarAddNew.tsx | 12 +- .../components/RecordCalendarTopBar.tsx | 39 +- .../RecordIndexCalendarDataLoaderEffect.tsx | 16 +- ...ordIndexCalendarSelectedDateInitEffect.tsx | 37 ++ .../hooks/useRecordCalendarGroupByRecords.ts | 9 +- .../month/components/RecordCalendarMonth.tsx | 5 + .../components/RecordCalendarMonthBody.tsx | 4 +- .../components/RecordCalendarMonthBodyDay.tsx | 42 +- .../RecordCalendarMonthBodyWeek.tsx | 16 +- .../contexts/RecordCalendarMonthContext.ts | 11 +- .../hooks/useRecordCalendarMonthDaysRange.tsx | 59 ++- .../useRecordCalendarQueryDateRangeFilter.tsx | 37 +- ...ecordCalendarSelectedDateComponentState.ts | 9 + ...ecordCalendarSelectedDateComponentState.ts | 5 +- ...lendarDayRecordsComponentFamilySelector.ts | 27 +- .../hooks/useProcessCalendarCardDrop.ts | 69 +-- .../ui/components/FormFieldInput.tsx | 3 + .../components/FormDateFieldInput.tsx | 2 +- .../components/FormDateTimeFieldInput.tsx | 161 ++---- .../input/components/DateTimeFieldInput.tsx | 26 +- .../hooks/useFilterValueDependencies.ts | 4 + .../hooks/useGetRecordFilterDisplayValue.ts | 123 ++--- ...seGetRelativeDateFilterWithUserTimezone.ts | 2 +- ...imeZoneAbbreviationForNowInUserTimeZone.ts | 14 + ...omputeViewRecordGqlOperationFilter.test.ts | 7 +- .../RecordIndexCalendarContainer.tsx | 2 + .../useFindManyRecordIndexTableParams.ts | 1 + .../hooks/useRecordIndexTableQuery.ts | 1 + .../utils/buildRecordInputFromFilter.ts | 1 + .../GraphWidgetBarChartRenderer.tsx | 8 +- .../hooks/useGraphBarChartWidgetData.ts | 9 + .../fillDateGapsInBarChartData.test.ts | 70 ++- .../generateDateGroupsInRange.test.ts | 53 +- ...transformGroupByDataToBarChartData.test.ts | 5 + ...woDimensionalGroupByToBarChartData.test.ts | 5 + .../utils/createEmptyDateGroup.ts | 5 +- ...illDateGapsInOneDimensionalBarChartData.ts | 14 +- ...illDateGapsInTwoDimensionalBarChartData.ts | 13 +- .../utils/generateDateGroupsInRange.ts | 31 +- .../utils/getDateGroupsFromData.ts | 20 +- .../transformGroupByDataToBarChartData.ts | 14 +- ...formOneDimensionalGroupByToBarChartData.ts | 10 +- ...formTwoDimensionalGroupByToBarChartData.ts | 13 +- .../GraphWidgetLineChartRenderer.tsx | 9 +- .../hooks/useGraphLineChartWidgetData.ts | 9 + .../GraphWidgetPieChartRenderer.tsx | 9 +- .../hooks/useGraphPieChartWidgetData.ts | 9 + ...transformGroupByDataToPieChartData.test.ts | 6 + .../transformGroupByDataToPieChartData.ts | 11 +- .../graph/hooks/useGraphWidgetGroupByQuery.ts | 5 + .../graph/hooks/useGraphWidgetQueryCommon.ts | 7 +- .../BuildChartDrilldownQueryParamsInput.ts | 2 + .../formatDateByGranularity.spec.ts.snap | 36 +- ...omBarOrLineChartConfiguration.test.ts.snap | 2 + ...blesFromPieChartConfiguration.test.ts.snap | 20 +- .../buildDateFilterForDayGranularity.test.ts | 55 ++- ...uildDateRangeFiltersForGranularity.test.ts | 75 +-- .../__tests__/buildGroupByFieldObject.test.ts | 26 +- .../calculateQuarterDateRange.test.ts | 72 --- .../__tests__/formatDateByGranularity.spec.ts | 125 +++-- .../formatPrimaryDimensionValues.test.ts | 14 +- ...lesFromBarOrLineChartConfiguration.test.ts | 14 +- ...VariablesFromPieChartConfiguration.test.ts | 9 +- ...eDimensionalGroupByToLineChartData.test.ts | 21 +- ...oDimensionalGroupByToLineChartData.test.ts | 57 ++- .../utils/buildChartDrilldownQueryParams.ts | 2 + .../utils/buildDateFilterForDayGranularity.ts | 25 +- .../buildDateRangeFiltersForGranularity.ts | 95 ++-- .../graph/utils/buildFilterFromChartBucket.ts | 61 ++- .../graph/utils/buildFiltersFromDateRange.ts | 14 +- .../graph/utils/buildGroupByFieldObject.ts | 75 ++- .../graph/utils/calculateQuarterDateRange.ts | 38 -- .../graph/utils/formatDateByGranularity.ts | 50 +- .../graph/utils/formatDimensionValue.ts | 31 +- .../utils/formatPrimaryDimensionValues.ts | 7 + ...ariablesFromBarOrLineChartConfiguration.ts | 4 + ...QueryVariablesFromPieChartConfiguration.ts | 3 + .../transformGroupByDataToLineChartData.ts | 14 +- ...ormOneDimensionalGroupByToLineChartData.ts | 10 +- ...ormTwoDimensionalGroupByToLineChartData.ts | 14 +- .../components/FormatPreferencesSettings.tsx | 2 +- .../display/components/DateTimeDisplay.tsx | 3 +- .../ui/field/input/components/DateInput.tsx | 2 +- .../field/input/components/DateTimeInput.tsx | 37 +- .../internal/date/components/DatePicker.tsx | 86 ++-- .../date/components/DatePickerHeader.tsx | 10 +- .../date/components/DatePickerInput.tsx | 3 +- .../components/DatePickerWithoutCalendar.tsx | 466 ++++++++++++++++++ .../date/components/DateTimePicker.tsx | 154 +++--- .../date/components/DateTimePickerHeader.tsx | 22 +- .../date/components/DateTimePickerInput.tsx | 104 +++- .../date/components/TimeZoneAbbreviation.tsx | 29 +- .../InternalDatePicker.stories.tsx | 10 +- ...ateTimeToIMaskDateTimeInputString.test.tsx | 87 ++++ .../useGetShiftedDateToCustomTimeZone.ts | 29 ++ .../useGetShiftedDateToSystemTimeZone.ts | 33 ++ ...ZonedDateTimeToIMaskDateTimeInputString.ts | 49 ++ ...intInTimeIntoReactDatePickerShiftedDate.ts | 29 -- ...atePickerShiftedDateBackIntoPointInTime.ts | 33 -- .../date/hooks/useUserFirstDayOfTheWeek.ts | 38 ++ .../internal/date/hooks/useUserTimezone.ts | 7 +- .../__tests__/getHighlightedDates.test.ts | 73 +-- .../date/utils/getHighlightedDates.ts | 35 +- .../date/utils/getMonthSelectOptions.ts | 2 +- ...getTimeZoneAbbreviationForZonedDateTime.ts | 14 + .../date/utils/parseDateTimeToString.ts | 26 - .../stringifyRelativeDateFilter.test.ts | 6 +- .../workflow/constants/WorkflowTimeZone.ts | 1 + .../WorkflowStepFilterOperandSelect.tsx | 4 +- .../WorkflowStepFilterValueInput.tsx | 2 + ...DateTimeSettingsCalendarStartDaySelect.tsx | 3 +- packages/twenty-front/src/utils/date-utils.ts | 2 + .../dates/formatZonedDateTimeDatePart.ts | 26 + .../dates/formatZonedDateTimeTimePart.ts | 28 ++ .../utils/dates/isDifferentZonedDateTime.ts | 18 + ...-date-and-date-time-field-or-throw.util.ts | 1 + .../common-group-by-query-runner.service.ts | 4 +- .../errors/common-query-runner.exception.ts | 2 + ...r-to-graphql-api-exception-handler.util.ts | 3 +- ...nner-to-rest-api-exception-handler.util.ts | 1 + .../date-field-group-by-definition.type.ts | 1 + .../resolvers/types/group-by-field.types.ts | 5 + .../utils/get-group-by-expression.util.ts | 43 +- .../utils/parse-group-by-args.util.ts | 1 + .../parse-group-by-relation-field.util.ts | 1 + .../interfaces/object-record.interface.ts | 1 + ...te-granularity-gql-input-type.generator.ts | 11 +- .../services/view-query-params.service.ts | 2 +- ...evaluate-relative-date-filter.util.spec.ts | 3 + .../utils/evaluate-filter-conditions.util.ts | 1 + ...-and-evaluate-relative-date-filter.util.ts | 4 +- .../find-records.workflow-action.ts | 4 +- .../group-by-resolver.integration-spec.ts | 53 +- ...oupByDateGranularityThatRequireTimeZone.ts | 9 + packages/twenty-shared/src/constants/index.ts | 1 + packages/twenty-shared/src/index.ts | 1 - .../src/types/ArraySortDirection.ts | 1 + .../types/RecordFilterValueDependencies.ts | 1 + .../src/types/ViewFilterOperand.ts | 2 +- packages/twenty-shared/src/types/index.ts | 1 + .../rich-text-variable-resolver.test.ts | 6 - .../safeParseRelativeDateFilterValue.test.ts | 68 +-- .../date/__tests__/sortPlainDate.spec.ts | 61 +++ .../src/utils/date/isPlainDateAfter.ts | 8 + .../src/utils/date/isPlainDateBefore.ts | 8 + .../utils/date/isPlainDateBeforeOrEqual.ts | 10 + .../src/utils/date/isPlainDateInSameMonth.ts | 10 + .../src/utils/date/isPlainDateInWeekend.ts | 5 + .../src/utils/date/isSamePlainDate.ts | 8 + .../src/utils/date/parseToPlainDateOrThrow.ts | 23 + .../src/utils/date/sortPlainDate.ts | 14 + .../src/utils/date/turnJSDateToPlainDate.ts | 11 + ...nPlainDateIntoUserTimeZoneInstantString.ts | 8 + ...nPlainDateToShiftedDateInSystemTimeZone.ts | 16 + .../computeRecordGqlOperationFilter.test.ts | 4 +- .../filter/dates/types/DateTimePeriod.ts | 3 + .../__tests__/getNextPeriodStart.test.ts | 132 +++++ .../utils/__tests__/getPeriodStart.test.ts | 137 +++++ .../filter/dates/utils/addUnitToDateTime.ts | 1 + .../dates/utils/addUnitToZonedDateTime.ts | 43 ++ ...StartDayNonIsoNumberToFirstDayOfTheWeek.ts | 21 + ...rstDayOfTheWeekToCalendarStartDayNumber.ts | 15 + .../dates/utils/getDateFromPlainDate.ts | 6 - .../dates/utils/getEndUnitOfDateTime.ts | 47 -- .../utils/getFirstDayOfTheWeekAsISONumber.ts | 18 + .../filter/dates/utils/getNextPeriodStart.ts | 44 ++ .../filter/dates/utils/getPeriodStart.ts | 59 +++ .../dates/utils/getPlainDateFromDate.ts | 6 - .../dates/utils/getStartUnitOfDateTime.ts | 47 -- .../relativeDateFilterStringifiedSchema.ts | 8 +- .../dates/utils/resolveDateTimeFilter.ts | 4 +- .../dates/utils/resolveRelativeDateFilter.ts | 85 +++- .../resolveRelativeDateFilterStringified.ts | 25 +- .../utils/resolveRelativeDateTimeFilter.ts | 78 +-- ...esolveRelativeDateTimeFilterStringified.ts | 47 +- ...neDifferenceInMinutesWithSystemTimezone.ts | 26 - .../dates/utils/subUnitFromZonedDateTime.ts | 43 ++ .../turnRecordFilterIntoGqlOperationFilter.ts | 218 ++++---- .../computeTimezoneDifferenceInMinutes.ts | 12 - packages/twenty-shared/src/utils/index.ts | 28 +- ...owercaseUrlOriginAndRemoveTrailingSlash.ts | 4 +- yarn.lock | 25 +- 205 files changed, 3813 insertions(+), 1755 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue.ts create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateFilterTimeZoneAbbreviation.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarSelectedDateInitEffect.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar/states/hasInitializedRecordCalendarSelectedDateComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/hooks/useTimeZoneAbbreviationForNowInUserTimeZone.ts delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/calculateQuarterDateRange.test.ts delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/calculateQuarterDateRange.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePickerWithoutCalendar.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/__tests__/useParseZonedDateTimeToIMaskDateTimeInputString.test.tsx create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useGetShiftedDateToCustomTimeZone.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useGetShiftedDateToSystemTimeZone.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useParseZonedDateTimeToIMaskDateTimeInputString.ts delete mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTurnPointInTimeIntoReactDatePickerShiftedDate.ts delete mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTurnReactDatePickerShiftedDateBackIntoPointInTime.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek.ts create mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime.ts delete mode 100644 packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseDateTimeToString.ts create mode 100644 packages/twenty-front/src/modules/workflow/constants/WorkflowTimeZone.ts create mode 100644 packages/twenty-front/src/utils/dates/formatZonedDateTimeDatePart.ts create mode 100644 packages/twenty-front/src/utils/dates/formatZonedDateTimeTimePart.ts create mode 100644 packages/twenty-front/src/utils/dates/isDifferentZonedDateTime.ts create mode 100644 packages/twenty-shared/src/constants/GroupByDateGranularityThatRequireTimeZone.ts create mode 100644 packages/twenty-shared/src/types/ArraySortDirection.ts create mode 100644 packages/twenty-shared/src/utils/date/__tests__/sortPlainDate.spec.ts create mode 100644 packages/twenty-shared/src/utils/date/isPlainDateAfter.ts create mode 100644 packages/twenty-shared/src/utils/date/isPlainDateBefore.ts create mode 100644 packages/twenty-shared/src/utils/date/isPlainDateBeforeOrEqual.ts create mode 100644 packages/twenty-shared/src/utils/date/isPlainDateInSameMonth.ts create mode 100644 packages/twenty-shared/src/utils/date/isPlainDateInWeekend.ts create mode 100644 packages/twenty-shared/src/utils/date/isSamePlainDate.ts create mode 100644 packages/twenty-shared/src/utils/date/parseToPlainDateOrThrow.ts create mode 100644 packages/twenty-shared/src/utils/date/sortPlainDate.ts create mode 100644 packages/twenty-shared/src/utils/date/turnJSDateToPlainDate.ts create mode 100644 packages/twenty-shared/src/utils/date/turnPlainDateIntoUserTimeZoneInstantString.ts create mode 100644 packages/twenty-shared/src/utils/date/turnPlainDateToShiftedDateInSystemTimeZone.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/types/DateTimePeriod.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/__tests__/getNextPeriodStart.test.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/__tests__/getPeriodStart.test.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/addUnitToZonedDateTime.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/convertFirstDayOfTheWeekToCalendarStartDayNumber.ts delete mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getDateFromPlainDate.ts delete mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getEndUnitOfDateTime.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getFirstDayOfTheWeekAsISONumber.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getNextPeriodStart.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getPeriodStart.ts delete mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getPlainDateFromDate.ts delete mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/getStartUnitOfDateTime.ts delete mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone.ts create mode 100644 packages/twenty-shared/src/utils/filter/dates/utils/subUnitFromZonedDateTime.ts delete mode 100644 packages/twenty-shared/src/utils/filter/utils/computeTimezoneDifferenceInMinutes.ts diff --git a/package.json b/package.json index c4edd3c931c..ae09e2a138b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,6 @@ "private": true, "dependencies": { "@apollo/client": "^3.7.17", - "@date-fns/tz": "^1.4.1", "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@floating-ui/react": "^0.24.3", @@ -54,6 +53,7 @@ "semver": "^7.5.4", "slash": "^5.1.0", "storybook-addon-mock-date": "^0.6.0", + "temporal-polyfill": "^0.3.0", "ts-key-enum": "^2.0.12", "tslib": "^2.8.1", "type-fest": "4.10.1", diff --git a/packages/twenty-front/src/modules/context-store/utils/__tests__/computeContextStoreFilters.test.ts b/packages/twenty-front/src/modules/context-store/utils/__tests__/computeContextStoreFilters.test.ts index 3136ee30ab9..f77bceb20d1 100644 --- a/packages/twenty-front/src/modules/context-store/utils/__tests__/computeContextStoreFilters.test.ts +++ b/packages/twenty-front/src/modules/context-store/utils/__tests__/computeContextStoreFilters.test.ts @@ -14,6 +14,7 @@ describe('computeContextStoreFilters', () => { const mockFilterValueDependencies: RecordFilterValueDependencies = { currentWorkspaceMemberId: '32219445-f587-4c40-b2b1-6d3205ed96da', + timeZone: 'Europe/Paris', }; it('should work for selection mode', () => { diff --git a/packages/twenty-front/src/modules/localization/hooks/__tests__/useDateTimeFormat.test.tsx b/packages/twenty-front/src/modules/localization/hooks/__tests__/useDateTimeFormat.test.tsx index 6ca7a07c50f..eee042944b7 100644 --- a/packages/twenty-front/src/modules/localization/hooks/__tests__/useDateTimeFormat.test.tsx +++ b/packages/twenty-front/src/modules/localization/hooks/__tests__/useDateTimeFormat.test.tsx @@ -2,11 +2,11 @@ import { renderHook } from '@testing-library/react'; import { type ReactNode } from 'react'; import { RecoilRoot } from 'recoil'; -import { CalendarStartDay } from 'twenty-shared'; import { DateFormat } from '@/localization/constants/DateFormat'; import { TimeFormat } from '@/localization/constants/TimeFormat'; import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat'; import { workspaceMemberFormatPreferencesState } from '@/localization/states/workspaceMemberFormatPreferencesState'; +import { CalendarStartDay } from 'twenty-shared/constants'; const mockPreferences = { timeZone: 'America/New_York', diff --git a/packages/twenty-front/src/modules/localization/hooks/__tests__/useFormatPreferences.test.tsx b/packages/twenty-front/src/modules/localization/hooks/__tests__/useFormatPreferences.test.tsx index ba2e86a160e..6855cde1150 100644 --- a/packages/twenty-front/src/modules/localization/hooks/__tests__/useFormatPreferences.test.tsx +++ b/packages/twenty-front/src/modules/localization/hooks/__tests__/useFormatPreferences.test.tsx @@ -3,7 +3,6 @@ import { type ReactNode } from 'react'; import { RecoilRoot, type MutableSnapshot } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { CalendarStartDay } from 'twenty-shared'; import { DateFormat } from '@/localization/constants/DateFormat'; import { NumberFormat } from '@/localization/constants/NumberFormat'; import { TimeFormat } from '@/localization/constants/TimeFormat'; @@ -16,6 +15,8 @@ import { detectTimeFormat } from '@/localization/utils/detection/detectTimeForma import { detectTimeZone } from '@/localization/utils/detection/detectTimeZone'; import { getWorkspaceMemberUpdateFromFormatPreferences } from '@/localization/utils/format-preferences/getWorkspaceMemberUpdateFromFormatPreferences'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { CalendarStartDay } from 'twenty-shared/constants'; +import { FirstDayOfTheWeek } from 'twenty-shared/types'; jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({ useUpdateOneRecord: jest.fn(), @@ -93,7 +94,7 @@ describe('useFormatPreferences', () => { mockDetectDateFormat.mockReturnValue('MONTH_FIRST'); mockDetectTimeFormat.mockReturnValue('HOUR_24'); mockDetectNumberFormat.mockReturnValue('COMMAS_AND_DOT'); - mockDetectCalendarStartDay.mockReturnValue('MONDAY'); + mockDetectCalendarStartDay.mockReturnValue(FirstDayOfTheWeek.MONDAY); mockGetWorkspaceMemberUpdateFromFormatPreferences.mockReturnValue({}); mockUpdateOneRecord.mockResolvedValue({}); diff --git a/packages/twenty-front/src/modules/localization/hooks/useFormatPreferences.ts b/packages/twenty-front/src/modules/localization/hooks/useFormatPreferences.ts index 8b4cbc34ca0..14cf203ae0c 100644 --- a/packages/twenty-front/src/modules/localization/hooks/useFormatPreferences.ts +++ b/packages/twenty-front/src/modules/localization/hooks/useFormatPreferences.ts @@ -2,7 +2,6 @@ import { useCallback } from 'react'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { CalendarStartDay } from 'twenty-shared'; import { DateFormat } from '@/localization/constants/DateFormat'; import { NumberFormat } from '@/localization/constants/NumberFormat'; import { TimeFormat } from '@/localization/constants/TimeFormat'; @@ -19,6 +18,7 @@ import { getFormatPreferencesFromWorkspaceMember } from '@/localization/utils/fo import { getWorkspaceMemberUpdateFromFormatPreferences } from '@/localization/utils/format-preferences/getWorkspaceMemberUpdateFromFormatPreferences'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { CalendarStartDay } from 'twenty-shared/constants'; import { logError } from '~/utils/logError'; export type FormatPreferenceKey = keyof WorkspaceMemberFormatPreferences; diff --git a/packages/twenty-front/src/modules/localization/states/workspaceMemberFormatPreferencesState.ts b/packages/twenty-front/src/modules/localization/states/workspaceMemberFormatPreferencesState.ts index 886eec0b108..bedc096c31a 100644 --- a/packages/twenty-front/src/modules/localization/states/workspaceMemberFormatPreferencesState.ts +++ b/packages/twenty-front/src/modules/localization/states/workspaceMemberFormatPreferencesState.ts @@ -1,4 +1,3 @@ -import { CalendarStartDay } from 'twenty-shared'; import { DateFormat } from '@/localization/constants/DateFormat'; import { NumberFormat } from '@/localization/constants/NumberFormat'; import { TimeFormat } from '@/localization/constants/TimeFormat'; @@ -7,6 +6,7 @@ import { detectDateFormat } from '@/localization/utils/detection/detectDateForma import { detectNumberFormat } from '@/localization/utils/detection/detectNumberFormat'; import { detectTimeFormat } from '@/localization/utils/detection/detectTimeFormat'; import { detectTimeZone } from '@/localization/utils/detection/detectTimeZone'; +import { CalendarStartDay } from 'twenty-shared/constants'; import { createState } from 'twenty-ui/utilities'; export type WorkspaceMemberFormatPreferences = { diff --git a/packages/twenty-front/src/modules/localization/utils/detection/detectCalendarStartDay.ts b/packages/twenty-front/src/modules/localization/utils/detection/detectCalendarStartDay.ts index 7f38f1dac3a..c604cec1a07 100644 --- a/packages/twenty-front/src/modules/localization/utils/detection/detectCalendarStartDay.ts +++ b/packages/twenty-front/src/modules/localization/utils/detection/detectCalendarStartDay.ts @@ -1,16 +1,6 @@ -import { type CalendarStartDay } from 'twenty-shared'; -import { type ExcludeLiteral } from '~/types/ExcludeLiteral'; +import { FirstDayOfTheWeek } from 'twenty-shared/types'; -const MONDAY_KEY: keyof typeof CalendarStartDay = 'MONDAY'; -const SATURDAY_KEY: keyof typeof CalendarStartDay = 'SATURDAY'; -const SUNDAY_KEY: keyof typeof CalendarStartDay = 'SUNDAY'; - -export type NonSystemCalendarStartDay = ExcludeLiteral< - keyof typeof CalendarStartDay, - 'SYSTEM' ->; - -export const detectCalendarStartDay = (): NonSystemCalendarStartDay => { +export const detectCalendarStartDay = (): FirstDayOfTheWeek => { // Use Intl.Locale to get the first day of the week from the user's locale // This requires a modern browser that supports Intl.Locale try { @@ -30,12 +20,12 @@ export const detectCalendarStartDay = (): NonSystemCalendarStartDay => { // Intl.Locale uses 1=Monday, 7=Sunday, 6=Saturday switch (firstDay) { case 1: - return MONDAY_KEY; + return FirstDayOfTheWeek.MONDAY; case 6: - return SATURDAY_KEY; + return FirstDayOfTheWeek.SATURDAY; case 7: default: - return SUNDAY_KEY; + return FirstDayOfTheWeek.SUNDAY; } } } catch { @@ -63,7 +53,7 @@ export const detectCalendarStartDay = (): NonSystemCalendarStartDay => { language.startsWith('en-au') || // Australian English language.startsWith('en-nz') // New Zealand English ) { - return MONDAY_KEY; + return FirstDayOfTheWeek.MONDAY; } // Middle Eastern countries often start with Saturday @@ -72,9 +62,9 @@ export const detectCalendarStartDay = (): NonSystemCalendarStartDay => { language.startsWith('he') || // Hebrew language.startsWith('fa') // Persian ) { - return SATURDAY_KEY; + return FirstDayOfTheWeek.SATURDAY; } // Default to Sunday (US, Canada, Japan, etc.) - return SUNDAY_KEY; + return FirstDayOfTheWeek.SUNDAY; }; diff --git a/packages/twenty-front/src/modules/localization/utils/detection/detectTimeFormat.ts b/packages/twenty-front/src/modules/localization/utils/detection/detectTimeFormat.ts index 8d9e13b4394..cd2eb06f78d 100644 --- a/packages/twenty-front/src/modules/localization/utils/detection/detectTimeFormat.ts +++ b/packages/twenty-front/src/modules/localization/utils/detection/detectTimeFormat.ts @@ -1,4 +1,5 @@ import { type TimeFormat } from '@/localization/constants/TimeFormat'; + import { isDefined } from 'twenty-shared/utils'; export const detectTimeFormat = (): keyof typeof TimeFormat => { diff --git a/packages/twenty-front/src/modules/localization/utils/format-preferences/getCalendarStartDayFromWorkspaceMember.ts b/packages/twenty-front/src/modules/localization/utils/format-preferences/getCalendarStartDayFromWorkspaceMember.ts index 3b7373ec620..32f1c2cf78b 100644 --- a/packages/twenty-front/src/modules/localization/utils/format-preferences/getCalendarStartDayFromWorkspaceMember.ts +++ b/packages/twenty-front/src/modules/localization/utils/format-preferences/getCalendarStartDayFromWorkspaceMember.ts @@ -1,5 +1,5 @@ import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; -import { CalendarStartDay } from 'twenty-shared'; +import { CalendarStartDay } from 'twenty-shared/constants'; import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuRecordFilterOperandSelect.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuRecordFilterOperandSelect.tsx index 8e0b2cff44f..948597fb2e3 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuRecordFilterOperandSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/command-menu/components/AdvancedFilterCommandMenuRecordFilterOperandSelect.tsx @@ -16,7 +16,7 @@ type AdvancedFilterCommandMenuRecordFilterOperandSelectProps = { export const AdvancedFilterCommandMenuRecordFilterOperandSelect = ({ recordFilterId, }: AdvancedFilterCommandMenuRecordFilterOperandSelectProps) => { - const { readonly } = useContext(AdvancedFilterContext); + const { readonly, isWorkflowFindRecords } = useContext(AdvancedFilterContext); const currentRecordFilters = useRecoilComponentValue( currentRecordFiltersComponentState, ); @@ -36,12 +36,15 @@ export const AdvancedFilterCommandMenuRecordFilterOperandSelect = ({ }) : []; + const shouldUseUTCTimeZone = isWorkflowFindRecords === true; + const timeZoneAbbreviation = shouldUseUTCTimeZone ? 'UTC' : undefined; + if (isDisabled === true) { return ( { - const { readonly, VariablePicker, objectMetadataItem } = useContext( - AdvancedFilterContext, - ); + const { + readonly, + VariablePicker, + objectMetadataItem, + isWorkflowFindRecords, + } = useContext(AdvancedFilterContext); const currentRecordFilters = useRecoilComponentValue( currentRecordFiltersComponentState, @@ -179,6 +183,9 @@ export const AdvancedFilterCommandMenuValueFormInput = ({ metadata: fieldDefinition?.metadata as FieldMetadata, }; + const shouldUseUTCTimeZone = isWorkflowFindRecords === true; + const timeZone = shouldUseUTCTimeZone ? WORKFLOW_TIMEZONE : undefined; + return ( ); }; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRecordFilterOperandSelect.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRecordFilterOperandSelect.tsx index 3a75a9db2ba..cca817fb206 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRecordFilterOperandSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRecordFilterOperandSelect.tsx @@ -1,7 +1,9 @@ import { AdvancedFilterRecordFilterOperandSelectContent } from '@/object-record/advanced-filter/components/AdvancedFilterRecordFilterOperandSelectContent'; import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel'; +import { useTimeZoneAbbreviationForNowInUserTimeZone } from '@/object-record/record-filter/hooks/useTimeZoneAbbreviationForNowInUserTimeZone'; import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { SelectControl } from '@/ui/input/components/SelectControl'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import styled from '@emotion/styled'; @@ -38,12 +40,21 @@ export const AdvancedFilterRecordFilterOperandSelect = ({ }) : []; + const { userTimeZoneAbbreviation } = + useTimeZoneAbbreviationForNowInUserTimeZone(); + + const { isSystemTimezone } = useUserTimezone(); + + const timeZoneAbbreviation = !isSystemTimezone + ? userTimeZoneAbbreviation + : null; + if (isDisabled) { return ( { const dropdownId = `advanced-filter-view-filter-operand-${recordFilterId}`; + const { isWorkflowFindRecords } = useContext(AdvancedFilterContext); + const { closeDropdown } = useCloseDropdown(); const { applyObjectFilterDropdownOperand } = @@ -47,6 +53,18 @@ export const AdvancedFilterRecordFilterOperandSelectContent = ({ dropdownId, ); + const { userTimeZoneAbbreviation } = + useTimeZoneAbbreviationForNowInUserTimeZone(); + + const { isSystemTimezone } = useUserTimezone(); + + const timeZoneAbbreviation = + isWorkflowFindRecords === true + ? 'UTC' + : !isSystemTimezone + ? userTimeZoneAbbreviation + : null; + return ( { handleOperandChange(filterOperand); }} - text={getOperandLabel(filterOperand)} + text={getOperandLabel(filterOperand, timeZoneAbbreviation)} /> ))} diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx index ba3badc423c..f56dbd328b2 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx @@ -1,5 +1,5 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { CalendarStartDay } from 'twenty-shared'; +import { CalendarStartDay } from 'twenty-shared/constants'; import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue'; @@ -40,6 +40,7 @@ export const ObjectFilterDropdownDateInput = () => { const handleAbsoluteDateChange = (newPlainDate: string | null) => { const newFilterValue = newPlainDate ?? ''; + // TODO: remove this and use getDisplayValue instead const formattedDate = formatDateString({ value: newPlainDate, timeZone, @@ -109,7 +110,7 @@ export const ObjectFilterDropdownDateInput = () => { instanceId={`object-filter-dropdown-date-input`} relativeDate={relativeDate} isRelative={isRelativeOperand} - date={plainDateValue ?? null} + plainDateString={plainDateValue ?? null} onChange={handleAbsoluteDateChange} onRelativeDateChange={handleRelativeDateChange} onClear={handleClear} diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateTimeInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateTimeInput.tsx index 197ec49fa6b..413d887ce1a 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateTimeInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateTimeInput.tsx @@ -1,5 +1,5 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { CalendarStartDay } from 'twenty-shared'; +import { CalendarStartDay } from 'twenty-shared/constants'; import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue'; @@ -9,7 +9,7 @@ import { DateTimePicker } from '@/ui/input/components/internal/date/components/D import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { UserContext } from '@/users/contexts/UserContext'; import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter'; -import { useContext, useState } from 'react'; +import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; import { ViewFilterOperand, type FirstDayOfTheWeek } from 'twenty-shared/types'; import { @@ -18,6 +18,9 @@ import { type RelativeDateFilter, } from 'twenty-shared/utils'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { isNonEmptyString } from '@sniptt/guards'; +import { Temporal } from 'temporal-polyfill'; import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { formatDateTimeString } from '~/utils/string/formatDateTimeString'; @@ -26,6 +29,8 @@ export const ObjectFilterDropdownDateTimeInput = () => { const dateLocale = useRecoilValue(dateLocaleState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const { userTimezone } = useUserTimezone(); + const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValue( objectFilterDropdownCurrentRecordFilterComponentState, ); @@ -33,21 +38,11 @@ export const ObjectFilterDropdownDateTimeInput = () => { const { applyObjectFilterDropdownFilterValue } = useApplyObjectFilterDropdownFilterValue(); - const initialFilterValue = isDefined(objectFilterDropdownCurrentRecordFilter) - ? resolveDateTimeFilter(objectFilterDropdownCurrentRecordFilter) - : null; - - const [internalDate, setInternalDate] = useState( - initialFilterValue instanceof Date ? initialFilterValue : null, - ); - - const handleAbsoluteDateChange = (newDate: Date | null) => { - setInternalDate(newDate); - - const newFilterValue = newDate?.toISOString() ?? ''; + const handleAbsoluteDateChange = (newDate: Temporal.ZonedDateTime | null) => { + const newFilterValue = newDate?.toInstant().toString() ?? ''; const formattedDateTime = formatDateTimeString({ - value: newDate?.toISOString(), + value: newFilterValue, timeZone, dateFormat, timeFormat, @@ -89,33 +84,39 @@ export const ObjectFilterDropdownDateTimeInput = () => { applyObjectFilterDropdownFilterValue(newFilterValue, newDisplayValue); }; - const isRelativeOperand = - objectFilterDropdownCurrentRecordFilter?.operand === - ViewFilterOperand.IS_RELATIVE; - - const handleClear = () => { - isRelativeOperand - ? handleRelativeDateChange(null) - : handleAbsoluteDateChange(null); - }; const resolvedValue = objectFilterDropdownCurrentRecordFilter ? resolveDateTimeFilter(objectFilterDropdownCurrentRecordFilter) : null; - const relativeDate = - resolvedValue && !(resolvedValue instanceof Date) + const isRelativeDateFilter = + objectFilterDropdownCurrentRecordFilter?.operand === + ViewFilterOperand.IS_RELATIVE && + isDefined(resolvedValue) && + typeof resolvedValue === 'object'; + + const relativeDate = isRelativeDateFilter ? resolvedValue : undefined; + const stringFilterValue = + objectFilterDropdownCurrentRecordFilter?.operand !== + ViewFilterOperand.IS_RELATIVE && typeof resolvedValue === 'string' ? resolvedValue : undefined; + const internalZonedDateTime = + !isRelativeDateFilter && isNonEmptyString(stringFilterValue) + ? Temporal.Instant.from(stringFilterValue).toZonedDateTimeISO( + timeZone ?? userTimezone, + ) + : null; + return ( ); }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx index 57cbdaf711e..2e65da73dcb 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx @@ -5,8 +5,10 @@ import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-recor import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState'; import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel'; +import { useTimeZoneAbbreviationForNowInUserTimeZone } from '@/object-record/record-filter/hooks/useTimeZoneAbbreviationForNowInUserTimeZone'; import { type RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { DropdownMenuInnerSelect } from '@/ui/layout/dropdown/components/DropdownMenuInnerSelect'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; @@ -38,8 +40,17 @@ export const ObjectFilterDropdownInnerSelectOperandDropdown = () => { }) : []; + const { userTimeZoneAbbreviation } = + useTimeZoneAbbreviationForNowInUserTimeZone(); + + const { isSystemTimezone } = useUserTimezone(); + + const timeZoneAbbreviation = !isSystemTimezone + ? userTimeZoneAbbreviation + : null; + const options = operandsForFilterType.map((operand) => ({ - label: getOperandLabel(operand), + label: getOperandLabel(operand, timeZoneAbbreviation), value: operand, })) as SelectOption[]; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts index be4b241dfd7..884a60d3d3b 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand.ts @@ -1,23 +1,27 @@ -import { DATE_OPERANDS_THAT_SHOULD_BE_INITIALIZED_WITH_NOW } from '@/object-record/object-filter-dropdown/constants/DateOperandsThatShouldBeInitializedWithNow'; -import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue'; import { useUpsertObjectFilterDropdownCurrentFilter } from '@/object-record/object-filter-dropdown/hooks/useUpsertObjectFilterDropdownCurrentFilter'; import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector'; import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState'; import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; -import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue'; import { useCreateEmptyRecordFilterFromFieldMetadataItem } from '@/object-record/record-filter/hooks/useCreateEmptyRecordFilterFromFieldMetadataItem'; import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone'; import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter'; +import { isNonEmptyString } from '@sniptt/guards'; +import { Temporal } from 'temporal-polyfill'; import { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from 'twenty-shared/constants'; -import { isDefined } from 'twenty-shared/utils'; +import { + isDefined, + relativeDateFilterStringifiedSchema, +} from 'twenty-shared/utils'; export const useApplyObjectFilterDropdownOperand = () => { + const { userTimezone } = useUserTimezone(); const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValue( objectFilterDropdownCurrentRecordFilterComponentState, ); @@ -40,8 +44,6 @@ export const useApplyObjectFilterDropdownOperand = () => { const { createEmptyRecordFilterFromFieldMetadataItem } = useCreateEmptyRecordFilterFromFieldMetadataItem(); - const { getInitialFilterValue } = useGetInitialFilterValue(); - const { getRelativeDateFilterWithUserTimezone } = useGetRelativeDateFilterWithUserTimezone(); @@ -86,24 +88,7 @@ export const useApplyObjectFilterDropdownOperand = () => { (recordFilterToUpsert.type === 'DATE' || recordFilterToUpsert.type === 'DATE_TIME') ) { - if ( - DATE_OPERANDS_THAT_SHOULD_BE_INITIALIZED_WITH_NOW.includes(newOperand) - ) { - // TODO: allow to keep same value when switching between is after, is before, is and is not - // For now we reset with now each time we switch operand - - const dateToUseAsISOString = new Date().toISOString(); - - const { displayValue, value } = getInitialFilterValue( - recordFilterToUpsert.type, - newOperand, - dateToUseAsISOString, - ); - - recordFilterToUpsert.value = value; - - recordFilterToUpsert.displayValue = displayValue; - } else if (newOperand === RecordFilterOperand.IS_RELATIVE) { + if (newOperand === RecordFilterOperand.IS_RELATIVE) { const newRelativeDateFilter = getRelativeDateFilterWithUserTimezone( DEFAULT_RELATIVE_DATE_FILTER_VALUE, ); @@ -111,13 +96,33 @@ export const useApplyObjectFilterDropdownOperand = () => { recordFilterToUpsert.value = stringifyRelativeDateFilter( newRelativeDateFilter, ); - - recordFilterToUpsert.displayValue = getRelativeDateDisplayValue( - newRelativeDateFilter, - ); } else { - recordFilterToUpsert.value = ''; - recordFilterToUpsert.displayValue = ''; + const filterValueIsEmpty = !isNonEmptyString( + recordFilterToUpsert.value, + ); + + const isStillRelativeFilterValue = + relativeDateFilterStringifiedSchema.safeParse( + recordFilterToUpsert.value, + ); + + if (filterValueIsEmpty || isStillRelativeFilterValue.success) { + const zonedDateToUse = Temporal.Now.zonedDateTimeISO(userTimezone); + + if (recordFilterToUpsert.type === 'DATE') { + const initialNowDateFilterValue = zonedDateToUse + .toPlainDate() + .toString(); + + recordFilterToUpsert.value = initialNowDateFilterValue; + } else { + const initialNowDateTimeFilterValue = zonedDateToUse + .toInstant() + .toString(); + + recordFilterToUpsert.value = initialNowDateTimeFilterValue; + } + } } } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue.ts new file mode 100644 index 00000000000..da83a2b7bdc --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue.ts @@ -0,0 +1,17 @@ +import { useUserDateFormat } from '@/ui/input/components/internal/date/hooks/useUserDateFormat'; +import { type Temporal } from 'temporal-polyfill'; +import { formatZonedDateTimeDatePart } from '~/utils/dates/formatZonedDateTimeDatePart'; + +export const useGetDateFilterDisplayValue = () => { + const { userDateFormat } = useUserDateFormat(); + + const getDateFilterDisplayValue = (zonedDateTime: Temporal.ZonedDateTime) => { + const displayValue = `${formatZonedDateTimeDatePart(zonedDateTime, userDateFormat)}`; + + return { displayValue }; + }; + + return { + getDateFilterDisplayValue, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue.ts index 3762090bd84..3d831b1e947 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue.ts @@ -1,46 +1,27 @@ -import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/format-preferences/getDateFormatFromWorkspaceDateFormat'; -import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/format-preferences/getTimeFormatFromWorkspaceTimeFormat'; import { useUserDateFormat } from '@/ui/input/components/internal/date/hooks/useUserDateFormat'; import { useUserTimeFormat } from '@/ui/input/components/internal/date/hooks/useUserTimeFormat'; import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; -import { format } from 'date-fns'; -import { shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone } from 'twenty-shared/utils'; +import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime'; +import { type Temporal } from 'temporal-polyfill'; +import { formatZonedDateTimeDatePart } from '~/utils/dates/formatZonedDateTimeDatePart'; +import { formatZonedDateTimeTimePart } from '~/utils/dates/formatZonedDateTimeTimePart'; export const useGetDateTimeFilterDisplayValue = () => { - const { - userTimezone, - isSystemTimezone, - getTimezoneAbbreviationForPointInTime, - } = useUserTimezone(); + const { isSystemTimezone } = useUserTimezone(); + const { userDateFormat } = useUserDateFormat(); const { userTimeFormat } = useUserTimeFormat(); - const getDateTimeFilterDisplayValue = (correctPointInTime: Date) => { - const shiftedDate = - shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone( - correctPointInTime, - userTimezone, - 'sub', - ); - - const dateFormatString = - getDateFormatFromWorkspaceDateFormat(userDateFormat); - - const timeFormatString = - getTimeFormatFromWorkspaceTimeFormat(userTimeFormat); - - const formatToUse = `${dateFormatString} ${timeFormatString}`; - + const getDateTimeFilterDisplayValue = ( + referenceZonedDateTime: Temporal.ZonedDateTime, + ) => { const timezoneSuffix = !isSystemTimezone - ? ` (${getTimezoneAbbreviationForPointInTime(shiftedDate)})` + ? ` (${getTimezoneAbbreviationForZonedDateTime(referenceZonedDateTime)})` : ''; - const displayValue = `${format(shiftedDate, formatToUse)}${timezoneSuffix}`; + const displayValue = `${formatZonedDateTimeDatePart(referenceZonedDateTime, userDateFormat)} ${formatZonedDateTimeTimePart(referenceZonedDateTime, userTimeFormat)}${timezoneSuffix}`; - return { - correctPointInTime, - displayValue, - }; + return { displayValue }; }; return { diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts index d8b3eac585a..0efe97f0dc0 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue.ts @@ -1,14 +1,9 @@ -import { getDateFormatFromWorkspaceDateFormat } from '@/localization/utils/format-preferences/getDateFormatFromWorkspaceDateFormat'; -import { getTimeFormatFromWorkspaceTimeFormat } from '@/localization/utils/format-preferences/getTimeFormatFromWorkspaceTimeFormat'; +import { useGetDateFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateFilterDisplayValue'; +import { useGetDateTimeFilterDisplayValue } from '@/object-record/object-filter-dropdown/hooks/useGetDateTimeFilterDisplayValue'; import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; -import { useUserDateFormat } from '@/ui/input/components/internal/date/hooks/useUserDateFormat'; -import { useUserTimeFormat } from '@/ui/input/components/internal/date/hooks/useUserTimeFormat'; import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; -import { TZDate } from '@date-fns/tz'; -import { isNonEmptyString } from '@sniptt/guards'; -import { format } from 'date-fns'; -import { DATE_TYPE_FORMAT } from 'twenty-shared/constants'; +import { Temporal } from 'temporal-polyfill'; import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types'; const activeDatePickerOperands = [ @@ -18,41 +13,25 @@ const activeDatePickerOperands = [ ]; export const useGetInitialFilterValue = () => { - const { - userTimezone, - isSystemTimezone, - getTimezoneAbbreviationForPointInTime, - } = useUserTimezone(); - const { userDateFormat } = useUserDateFormat(); - const { userTimeFormat } = useUserTimeFormat(); + const { userTimezone } = useUserTimezone(); + const { getDateFilterDisplayValue } = useGetDateFilterDisplayValue(); + const { getDateTimeFilterDisplayValue } = useGetDateTimeFilterDisplayValue(); const getInitialFilterValue = ( newType: FilterableAndTSVectorFieldType, newOperand: RecordFilterOperand, - alreadyExistingISODate?: string, + alreadyExistingZonedDateTime?: Temporal.ZonedDateTime, ): Pick | Record => { switch (newType) { case 'DATE': { if (activeDatePickerOperands.includes(newOperand)) { - const referenceDate = isNonEmptyString(alreadyExistingISODate) - ? new Date(alreadyExistingISODate) - : new Date(); + const referenceDate = + alreadyExistingZonedDateTime ?? + Temporal.Now.zonedDateTimeISO(userTimezone); - const shiftedDate = new TZDate( - referenceDate.getFullYear(), - referenceDate.getMonth(), - referenceDate.getDate(), - userTimezone, - ); + const value = referenceDate.toPlainDate().toString(); - const dateFormatString = - getDateFormatFromWorkspaceDateFormat(userDateFormat); - - shiftedDate.setSeconds(0); - shiftedDate.setMilliseconds(0); - - const value = format(shiftedDate, DATE_TYPE_FORMAT); - const displayValue = format(shiftedDate, dateFormatString); + const { displayValue } = getDateFilterDisplayValue(referenceDate); return { value, displayValue }; } @@ -61,36 +40,13 @@ export const useGetInitialFilterValue = () => { } case 'DATE_TIME': { if (activeDatePickerOperands.includes(newOperand)) { - const referenceDate = isNonEmptyString(alreadyExistingISODate) - ? new Date(alreadyExistingISODate) - : new Date(); + const referenceDate = + alreadyExistingZonedDateTime ?? + Temporal.Now.zonedDateTimeISO(userTimezone); - const shiftedDate = new TZDate( - referenceDate.getFullYear(), - referenceDate.getMonth(), - referenceDate.getDate(), - referenceDate.getHours(), - referenceDate.getMinutes(), - userTimezone, - ); + const value = referenceDate.toInstant().toString(); - shiftedDate.setSeconds(0); - shiftedDate.setMilliseconds(0); - - const dateFormatString = - getDateFormatFromWorkspaceDateFormat(userDateFormat); - - const timeFormatString = - getTimeFormatFromWorkspaceTimeFormat(userTimeFormat); - - const formatToUse = `${dateFormatString} ${timeFormatString}`; - - const timezoneSuffix = !isSystemTimezone - ? ` (${getTimezoneAbbreviationForPointInTime(shiftedDate)})` - : ''; - - const value = shiftedDate.toISOString(); - const displayValue = `${format(shiftedDate, formatToUse)}${timezoneSuffix}`; + const { displayValue } = getDateTimeFilterDisplayValue(referenceDate); return { value, displayValue }; } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts index 474fd170e98..fd60cfa6a81 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandLabel.ts @@ -1,9 +1,18 @@ import { t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; import { ViewFilterOperand } from 'twenty-shared/types'; export const getOperandLabel = ( operand: ViewFilterOperand | null | undefined, + timeZoneAbbreviation?: string | null | undefined, ) => { + const shouldDisplayTimeZoneAbbreviation = + isNonEmptyString(timeZoneAbbreviation); + + const timeZoneAbbreviationSuffix = shouldDisplayTimeZoneAbbreviation + ? ` (${timeZoneAbbreviation})` + : ''; + switch (operand) { case ViewFilterOperand.CONTAINS: return t`Contains`; @@ -16,7 +25,7 @@ export const getOperandLabel = ( case ViewFilterOperand.IS_BEFORE: return t`Is before`; case ViewFilterOperand.IS_AFTER: - return t`Is after`; + return t`Is after or equal`; case ViewFilterOperand.IS: return t`Is`; case ViewFilterOperand.IS_NOT: @@ -34,7 +43,7 @@ export const getOperandLabel = ( case ViewFilterOperand.IS_IN_FUTURE: return t`Is in future`; case ViewFilterOperand.IS_TODAY: - return t`Is today`; + return t`Is today${timeZoneAbbreviationSuffix}`; default: return ''; } @@ -42,7 +51,15 @@ export const getOperandLabel = ( export const getOperandLabelShort = ( operand: ViewFilterOperand | null | undefined, + timeZoneAbbreviation?: string | null | undefined, ) => { + const shouldDisplayTimeZoneAbbreviation = + isNonEmptyString(timeZoneAbbreviation); + + const timeZoneAbbreviationSuffix = shouldDisplayTimeZoneAbbreviation + ? ` (${timeZoneAbbreviation})` + : ''; + switch (operand) { case ViewFilterOperand.IS: case ViewFilterOperand.CONTAINS: @@ -63,13 +80,13 @@ export const getOperandLabelShort = ( case ViewFilterOperand.IS_BEFORE: return '\u00A0< '; case ViewFilterOperand.IS_AFTER: - return '\u00A0> '; + return '\u00A0≥ '; case ViewFilterOperand.IS_IN_PAST: return t`: Past`; case ViewFilterOperand.IS_IN_FUTURE: return t`: Future`; case ViewFilterOperand.IS_TODAY: - return t`: Today`; + return t`: Today${timeZoneAbbreviationSuffix}`; default: return ': '; } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts index 0ab46a8b7ec..191ea45b73f 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts @@ -1,16 +1,22 @@ +import { getRelativeDateFilterTimeZoneAbbreviation } from '@/object-record/object-filter-dropdown/utils/getRelativeDateFilterTimeZoneAbbreviation'; import { plural } from 'pluralize'; -import { capitalize, type RelativeDateFilter } from 'twenty-shared/utils'; +import { + capitalize, + isDefined, + type RelativeDateFilter, +} from 'twenty-shared/utils'; export const getRelativeDateDisplayValue = ( - relativeDate: RelativeDateFilter | null, + relativeDate: RelativeDateFilter, + shouldDisplayTimeZoneAbbreviation?: boolean, ) => { if (!relativeDate) return ''; const { direction, amount, unit } = relativeDate; - const directionStr = capitalize(direction.toLowerCase()); - const amountStr = direction === 'THIS' ? '' : amount; - const unitStr = + const directionFormatted = capitalize(direction.toLowerCase()); + const amountFormatted = direction === 'THIS' ? '' : amount; + let unitFormatted = direction === 'THIS' ? unit.toLowerCase() : amount @@ -19,7 +25,14 @@ export const getRelativeDateDisplayValue = ( : unit.toLowerCase() : undefined; - return [directionStr, amountStr, unitStr] + if (isDefined(relativeDate.timezone)) { + const timeZoneAbbreviation = + getRelativeDateFilterTimeZoneAbbreviation(relativeDate); + + unitFormatted = `${unitFormatted ?? ''} ${shouldDisplayTimeZoneAbbreviation ? `(${timeZoneAbbreviation})` : ''}`; + } + + return [directionFormatted, amountFormatted, unitFormatted] .filter((item) => item !== undefined) .join(' '); }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateFilterTimeZoneAbbreviation.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateFilterTimeZoneAbbreviation.ts new file mode 100644 index 00000000000..7141fb02b7a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateFilterTimeZoneAbbreviation.ts @@ -0,0 +1,17 @@ +import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime'; +import { Temporal } from 'temporal-polyfill'; + +import { type RelativeDateFilter } from 'twenty-shared/utils'; + +export const getRelativeDateFilterTimeZoneAbbreviation = ( + relativeDate: RelativeDateFilter, +) => { + const nowZonedDateTime = Temporal.Now.zonedDateTimeISO( + relativeDate.timezone ?? 'UTC', + ); + + const timeZoneAbbreviation = + getTimezoneAbbreviationForZonedDateTime(nowZonedDateTime); + + return timeZoneAbbreviation; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx index e74336344c2..b3c1f02a72b 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarAddNew.tsx @@ -1,12 +1,14 @@ import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; -import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions'; +import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState'; import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; +import { type Temporal } from 'temporal-polyfill'; import { IconPlus } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { useRecordCalendarContextOrThrow } from '../contexts/RecordCalendarContext'; @@ -18,12 +20,13 @@ const StyledButton = styled(Button)` `; type RecordCalendarAddNewProps = { - cardDate: string; + cardDate: Temporal.PlainDate; }; export const RecordCalendarAddNew = ({ cardDate, }: RecordCalendarAddNewProps) => { + const { userTimezone } = useUserTimezone(); const { objectMetadataItem } = useRecordCalendarContextOrThrow(); const theme = useTheme(); @@ -69,7 +72,10 @@ export const RecordCalendarAddNew = ({ { createNewIndexRecord({ - [calendarFieldMetadataItem.name]: cardDate, + [calendarFieldMetadataItem.name]: cardDate + .toZonedDateTime(userTimezone) + .toInstant() + .toString(), }); }} variant="tertiary" diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarTopBar.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarTopBar.tsx index 73e7dbf54e4..1826e7d8afa 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarTopBar.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendarTopBar.tsx @@ -1,7 +1,8 @@ import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState'; import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState'; -import { DateTimePicker } from '@/ui/input/components/internal/date/components/DateTimePicker'; +import { DatePickerWithoutCalendar } from '@/ui/input/components/internal/date/components/DatePickerWithoutCalendar'; +import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation'; import { Select } from '@/ui/input/components/Select'; import { SelectControl } from '@/ui/input/components/SelectControl'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; @@ -12,10 +13,14 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; -import { addMonths, format, subMonths } from 'date-fns'; +import { format } from 'date-fns'; import { useRecoilValue } from 'recoil'; +import { Temporal } from 'temporal-polyfill'; import { type Nullable } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { + isDefined, + turnPlainDateToShiftedDateInSystemTimeZone, +} from 'twenty-shared/utils'; import { IconChevronLeft, IconChevronRight } from 'twenty-ui/display'; import { Button } from 'twenty-ui/input'; import { ViewCalendarLayout } from '~/generated/graphql'; @@ -60,26 +65,33 @@ export const RecordCalendarTopBar = () => { const datePickerDropdownId = `record-calendar-date-picker-${recordCalendarId}`; const { closeDropdown } = useCloseDropdown(); - const handleDateChange = (date: Nullable) => { - if (isDefined(date)) { - setRecordCalendarSelectedDate(date); + const handleDateChange = (plainDateString: Nullable) => { + if (isDefined(plainDateString)) { + setRecordCalendarSelectedDate(Temporal.PlainDate.from(plainDateString)); } closeDropdown(datePickerDropdownId); }; const handlePreviousMonth = () => { - setRecordCalendarSelectedDate(subMonths(recordCalendarSelectedDate, 1)); + setRecordCalendarSelectedDate( + recordCalendarSelectedDate.subtract({ months: 1 }), + ); }; const handleNextMonth = () => { - setRecordCalendarSelectedDate(addMonths(recordCalendarSelectedDate, 1)); + setRecordCalendarSelectedDate( + recordCalendarSelectedDate?.add({ months: 1 }), + ); }; const handleTodayClick = () => { - setRecordCalendarSelectedDate(new Date()); + setRecordCalendarSelectedDate(Temporal.Now.plainDateISO()); }; - const formattedDate = format(recordCalendarSelectedDate, 'MMMM yyyy'); + const formattedDate = format( + turnPlainDateToShiftedDateInSystemTimeZone(recordCalendarSelectedDate), + 'MMMM yyyy', + ); const dropdownContentOffset = { x: 140, y: 0 } satisfies DropdownOffset; @@ -120,20 +132,19 @@ export const RecordCalendarTopBar = () => { } dropdownComponents={ - } dropdownOffset={dropdownContentOffset} /> + diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx index 66f6d01dc76..91cf339577e 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect.tsx @@ -1,6 +1,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { useRecordCalendarGroupByRecords } from '@/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords'; import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; +import { hasInitializedRecordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/hasInitializedRecordCalendarSelectedDateComponentState'; import { recordCalendarRecordIdsComponentState } from '@/object-record/record-calendar/states/recordCalendarRecordIdsComponentState'; import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState'; import { recordCalendarSelectedRecordIdsComponentSelector } from '@/object-record/record-calendar/states/selectors/recordCalendarSelectedRecordIdsComponentSelector'; @@ -38,11 +39,24 @@ export const RecordIndexCalendarDataLoaderEffect = () => { recordCalendarSelectedDate, ); + const hasInitializedRecordCalendarSelectedDate = useRecoilComponentValue( + hasInitializedRecordCalendarSelectedDateComponentState, + ); + useEffect(() => { + if (!hasInitializedRecordCalendarSelectedDate) { + return; + } + upsertRecordsInStore({ partialRecords: records }); const recordIds = records.map((record) => record.id); setRecordCalendarRecordIds(recordIds); - }, [records, setRecordCalendarRecordIds, upsertRecordsInStore]); + }, [ + hasInitializedRecordCalendarSelectedDate, + records, + setRecordCalendarRecordIds, + upsertRecordsInStore, + ]); useEffect(() => { setContextStoreTargetedRecords({ diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarSelectedDateInitEffect.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarSelectedDateInitEffect.tsx new file mode 100644 index 00000000000..6f0d0124893 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordIndexCalendarSelectedDateInitEffect.tsx @@ -0,0 +1,37 @@ +import { hasInitializedRecordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/hasInitializedRecordCalendarSelectedDateComponentState'; +import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; +import { useEffect } from 'react'; +import { Temporal } from 'temporal-polyfill'; + +export const RecordIndexCalendarSelectedDateInitEffect = () => { + const [, setRecordCalendarSelectedDate] = useRecoilComponentState( + recordCalendarSelectedDateComponentState, + ); + + const [ + hasInitializedRecordCalendarSelectedDate, + setHasInitializedRecordCalendarSelectedDate, + ] = useRecoilComponentState( + hasInitializedRecordCalendarSelectedDateComponentState, + ); + + const { userTimezone } = useUserTimezone(); + + useEffect(() => { + if (!hasInitializedRecordCalendarSelectedDate) { + setRecordCalendarSelectedDate( + Temporal.Now.zonedDateTimeISO(userTimezone).toPlainDate(), + ); + setHasInitializedRecordCalendarSelectedDate(true); + } + }, [ + hasInitializedRecordCalendarSelectedDate, + setHasInitializedRecordCalendarSelectedDate, + setRecordCalendarSelectedDate, + userTimezone, + ]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords.ts b/packages/twenty-front/src/modules/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords.ts index 2c7b92afa45..91ef7d6215b 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/hooks/useRecordCalendarGroupByRecords.ts @@ -9,15 +9,21 @@ import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/ho import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useQuery } from '@apollo/client'; import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; +import { type Temporal } from 'temporal-polyfill'; import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -export const useRecordCalendarGroupByRecords = (selectedDate: Date) => { +export const useRecordCalendarGroupByRecords = ( + selectedDate: Temporal.PlainDate, +) => { const { objectMetadataItem } = useRecordCalendarContextOrThrow(); + const { userTimezone } = useUserTimezone(); + const recordIndexCalendarFieldMetadataId = useRecoilValue( recordIndexCalendarFieldMetadataIdState, ); @@ -40,6 +46,7 @@ export const useRecordCalendarGroupByRecords = (selectedDate: Date) => { buildGroupByFieldObject({ field: calendarFieldMetadataItem, dateGranularity: ObjectRecordGroupByDateGranularity.DAY, + timeZone: userTimezone, }), ]; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonth.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonth.tsx index 5c6bf360784..46798c97438 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonth.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonth.tsx @@ -13,6 +13,7 @@ import { type DragStart, type OnDragEndResponder, } from '@hello-pangea/dnd'; +import { isDefined } from 'twenty-shared/utils'; const StyledContainer = styled.div` display: flex; @@ -26,6 +27,10 @@ export const RecordCalendarMonth = () => { recordCalendarSelectedDateComponentState, ); + if (!isDefined(recordCalendarSelectedDate)) { + throw new Error(`Cannot show RecordCalendarMonth without a selected date`); + } + const { processCalendarCardDrop } = useProcessCalendarCardDrop(); const { startRecordDrag } = useStartRecordDrag(); const { endRecordDrag } = useEndRecordDrag(); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBody.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBody.tsx index dc3a81e94b7..9ea32b96d78 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBody.tsx @@ -1,8 +1,6 @@ import { RecordCalendarMonthBodyWeek } from '@/object-record/record-calendar/month/components/RecordCalendarMonthBodyWeek'; import { useRecordCalendarMonthContextOrThrow } from '@/object-record/record-calendar/month/contexts/RecordCalendarMonthContext'; import styled from '@emotion/styled'; -import { format } from 'date-fns'; -import { DATE_TYPE_FORMAT } from 'twenty-shared/constants'; const StyledContainer = styled.div` display: flex; @@ -20,7 +18,7 @@ export const RecordCalendarMonthBody = () => { {weekFirstDays.map((weekFirstDay) => ( ))} diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyDay.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyDay.tsx index c8887f1e7d1..0c1ab84eb9f 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyDay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyDay.tsx @@ -1,14 +1,20 @@ import { RecordCalendarCardDraggableContainer } from '@/object-record/record-calendar/record-calendar-card/components/RecordCalendarCardDraggableContainer'; import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState'; import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; import { Droppable } from '@hello-pangea/dnd'; -import { format, isSameDay, isSameMonth, isWeekend } from 'date-fns'; import { useState } from 'react'; -import { DATE_TYPE_FORMAT } from 'twenty-shared/constants'; +import { Temporal } from 'temporal-polyfill'; +import { + isDefined, + isPlainDateInSameMonth, + isPlainDateInWeekend, + isSamePlainDate, +} from 'twenty-shared/utils'; import { RecordCalendarAddNew } from '../../components/RecordCalendarAddNew'; const StyledContainer = styled.div<{ @@ -94,34 +100,40 @@ const StyledCardsContainer = styled.div<{ isDraggedOver?: boolean }>` `; type RecordCalendarMonthBodyDayProps = { - day: Date; + day: Temporal.PlainDate; }; export const RecordCalendarMonthBodyDay = ({ day, }: RecordCalendarMonthBodyDayProps) => { + const { userTimezone } = useUserTimezone(); + const recordCalendarSelectedDate = useRecoilComponentValue( recordCalendarSelectedDateComponentState, ); - const dayKey = format(day, DATE_TYPE_FORMAT); + const dayKey = day.toString(); const recordIds = useRecoilComponentFamilyValue( calendarDayRecordIdsComponentFamilySelector, - dayKey, + { + day: day, + timeZone: userTimezone, + }, ); + const todayInUserTimeZone = + Temporal.Now.zonedDateTimeISO(userTimezone).toPlainDate(); + const [hovered, setHovered] = useState(false); - const isToday = isSameDay(day, new Date()); + const isToday = isSamePlainDate(day, todayInUserTimeZone); - const isOtherMonth = !isSameMonth(day, recordCalendarSelectedDate); + const isOtherMonth = isDefined(recordCalendarSelectedDate) + ? !isPlainDateInSameMonth(day, recordCalendarSelectedDate) + : false; - const isDayOfWeekend = isWeekend(day); - - const utcDate = new Date( - Date.UTC(day.getFullYear(), day.getMonth(), day.getDate()), - ); + const isDayOfWeekend = isPlainDateInWeekend(day); return ( setHovered(false)} > - {hovered && } + {hovered && } - - {day.getDate()} - + {day.day} diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyWeek.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyWeek.tsx index d1f50a67454..14c975ccea8 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyWeek.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/components/RecordCalendarMonthBodyWeek.tsx @@ -2,6 +2,11 @@ import { RecordCalendarMonthBodyDay } from '@/object-record/record-calendar/mont import { useRecordCalendarMonthContextOrThrow } from '@/object-record/record-calendar/month/contexts/RecordCalendarMonthContext'; import styled from '@emotion/styled'; import { eachDayOfInterval, endOfWeek } from 'date-fns'; +import { type Temporal } from 'temporal-polyfill'; +import { + turnJSDateToPlainDate, + turnPlainDateToShiftedDateInSystemTimeZone, +} from 'twenty-shared/utils'; const StyledContainer = styled.div` display: flex; @@ -14,7 +19,7 @@ const StyledContainer = styled.div` `; type RecordCalendarMonthBodyWeekProps = { - startDayOfWeek: Date; + startDayOfWeek: Temporal.PlainDate; }; export const RecordCalendarMonthBodyWeek = ({ @@ -23,8 +28,8 @@ export const RecordCalendarMonthBodyWeek = ({ const { weekStartsOnDayIndex } = useRecordCalendarMonthContextOrThrow(); const daysOfWeek = eachDayOfInterval({ - start: startDayOfWeek, - end: endOfWeek(startDayOfWeek, { + start: turnPlainDateToShiftedDateInSystemTimeZone(startDayOfWeek), + end: endOfWeek(turnPlainDateToShiftedDateInSystemTimeZone(startDayOfWeek), { weekStartsOn: weekStartsOnDayIndex, }), }); @@ -32,7 +37,10 @@ export const RecordCalendarMonthBodyWeek = ({ return ( {daysOfWeek.map((day, index) => ( - + ))} ); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/contexts/RecordCalendarMonthContext.ts b/packages/twenty-front/src/modules/object-record/record-calendar/month/contexts/RecordCalendarMonthContext.ts index 3b0ba782ace..3cd2208e3d5 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/contexts/RecordCalendarMonthContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/contexts/RecordCalendarMonthContext.ts @@ -1,12 +1,13 @@ +import { type Temporal } from 'temporal-polyfill'; import { createRequiredContext } from '~/utils/createRequiredContext'; type RecordCalendarMonthContextValue = { - firstDayOfMonth: Date; - lastDayOfMonth: Date; - firstDayOfFirstWeek: Date; - lastDayOfLastWeek: Date; + firstDayOfMonth: Temporal.PlainDate; + lastDayOfMonth: Temporal.PlainDate; + firstDayOfFirstWeek: Temporal.PlainDate; + lastDayOfLastWeek: Temporal.PlainDate; weekDayLabels: string[]; - weekFirstDays: Date[]; + weekFirstDays: Temporal.PlainDate[]; weekStartsOnDayIndex: 0 | 1 | 2 | 3 | 4 | 5 | 6; }; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange.tsx index c6178d86eec..57ae8befbff 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange.tsx @@ -1,5 +1,5 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { CalendarStartDay } from 'twenty-shared'; +import { CalendarStartDay } from 'twenty-shared/constants'; import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; import { @@ -7,15 +7,22 @@ import { eachWeekOfInterval, endOfWeek, format, - lastDayOfMonth as lastDayOfMonthFn, - startOfMonth, startOfWeek, } from 'date-fns'; import { useRecoilValue } from 'recoil'; +import { type Temporal } from 'temporal-polyfill'; +import { + turnJSDateToPlainDate, + turnPlainDateToShiftedDateInSystemTimeZone, +} from 'twenty-shared/utils'; import { dateLocaleState } from '~/localization/states/dateLocaleState'; -export const useRecordCalendarMonthDaysRange = (selectedDate: Date) => { +// TODO: we could refactor this to use Temporal.PlainDate directly +// But it would require recoding the utils here, not really worth it for now +export const useRecordCalendarMonthDaysRange = ( + selectedDate: Temporal.PlainDate, +) => { const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const dateLocale = useRecoilValue(dateLocaleState); @@ -29,36 +36,52 @@ export const useRecordCalendarMonthDaysRange = (selectedDate: Date) => { : (currentWorkspaceMember?.calendarStartDay ?? 0) ) as 0 | 1 | 2 | 3 | 4 | 5 | 6; - const firstDayOfMonth = startOfMonth(selectedDate); - const lastDayOfMonth = lastDayOfMonthFn(selectedDate); + const firstDayOfMonth = selectedDate.with({ day: 1 }); + const lastDayOfMonth = selectedDate + .with({ day: 1 }) + .add({ months: 1 }) + .subtract({ days: 1 }); - const firstDayOfFirstWeek = startOfWeek(firstDayOfMonth, { - weekStartsOn: weekStartsOnDayIndex, - locale: dateLocale.localeCatalog, - }); + const shiftedFirstDayOfMonth = + turnPlainDateToShiftedDateInSystemTimeZone(firstDayOfMonth); - const lastDayOfLastWeek = endOfWeek(lastDayOfMonth, { - weekStartsOn: weekStartsOnDayIndex, - locale: dateLocale.localeCatalog, - }); + const firstDayOfFirstWeek = turnJSDateToPlainDate( + startOfWeek(shiftedFirstDayOfMonth, { + weekStartsOn: weekStartsOnDayIndex, + locale: dateLocale.localeCatalog, + }), + ); + + const shiftedLastDayOfMonth = + turnPlainDateToShiftedDateInSystemTimeZone(lastDayOfMonth); + + const lastDayOfLastWeek = turnJSDateToPlainDate( + endOfWeek(shiftedLastDayOfMonth, { + weekStartsOn: weekStartsOnDayIndex, + locale: dateLocale.localeCatalog, + }), + ); const daysOfWeekLabels: string[] = []; for (let i = 0; i < 7; i++) { - const day = addDays(firstDayOfFirstWeek, i); + const day = addDays( + turnPlainDateToShiftedDateInSystemTimeZone(firstDayOfFirstWeek), + i, + ); const label = format(day, 'EEE', { locale: dateLocale.localeCatalog }); daysOfWeekLabels.push(label); } const weekFirstDays = eachWeekOfInterval( { - start: firstDayOfFirstWeek, - end: lastDayOfLastWeek, + start: turnPlainDateToShiftedDateInSystemTimeZone(firstDayOfFirstWeek), + end: turnPlainDateToShiftedDateInSystemTimeZone(lastDayOfLastWeek), }, { weekStartsOn: weekStartsOnDayIndex, }, - ); + ).map(turnJSDateToPlainDate); return { weekStartsOnDayIndex, diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx index 189bcc08a4b..ce074ccb896 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter.tsx @@ -6,24 +6,31 @@ import { anyFieldFilterValueComponentState } from '@/object-record/record-filter import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState'; import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { t } from '@lingui/core/macro'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; +import { t } from '@lingui/core/macro'; +import { type Temporal } from 'temporal-polyfill'; import { combineFilters, computeRecordGqlOperationFilter, isDefined, turnAnyFieldFilterIntoRecordGqlFilter, + turnPlainDateIntoUserTimeZoneInstantString, } from 'twenty-shared/utils'; const DATE_RANGE_FILTER_AFTER_ID = 'DATE_RANGE_FILTER_AFTER_ID'; const DATE_RANGE_FILTER_BEFORE_ID = 'DATE_RANGE_FILTER_BEFORE_ID'; -export const useRecordCalendarQueryDateRangeFilter = (selectedDate: Date) => { +export const useRecordCalendarQueryDateRangeFilter = ( + selectedDate: Temporal.PlainDate, +) => { const { objectMetadataItem } = useRecordCalendarContextOrThrow(); const { firstDayOfFirstWeek, lastDayOfLastWeek } = useRecordCalendarMonthDaysRange(selectedDate); + const { userTimezone } = useUserTimezone(); + const { currentView } = useGetCurrentViewOnly(); const currentRecordFilterGroups = useRecoilComponentValue( @@ -49,26 +56,38 @@ export const useRecordCalendarQueryDateRangeFilter = (selectedDate: Date) => { }; } + const firstDayOfFirstWeekISOString = + turnPlainDateIntoUserTimeZoneInstantString( + firstDayOfFirstWeek, + userTimezone, + ); + + const nextDayAfterLastDayOfLastWeekISOString = + turnPlainDateIntoUserTimeZoneInstantString( + lastDayOfLastWeek.add({ days: 1 }), + userTimezone, + ); + const dateRangeFilterFieldMetadataId = currentView.calendarFieldMetadataId; const dateRangeFilterAfter: RecordFilter = { id: DATE_RANGE_FILTER_AFTER_ID, fieldMetadataId: dateRangeFilterFieldMetadataId, - value: `${firstDayOfFirstWeek.toISOString()}`, + value: `${firstDayOfFirstWeekISOString}`, operand: RecordFilterOperand.IS_AFTER, - type: 'DATE', - label: t`After`, - displayValue: `${firstDayOfFirstWeek.toISOString()}`, + type: 'DATE_TIME', + label: t`After or equal`, + displayValue: `${firstDayOfFirstWeek.toString()}`, }; const dateRangeFilterBefore: RecordFilter = { id: DATE_RANGE_FILTER_BEFORE_ID, fieldMetadataId: dateRangeFilterFieldMetadataId, - value: `${lastDayOfLastWeek.toISOString()}`, + value: `${nextDayAfterLastDayOfLastWeekISOString}`, operand: RecordFilterOperand.IS_BEFORE, - type: 'DATE', + type: 'DATE_TIME', label: t`Before`, - displayValue: `${lastDayOfLastWeek.toISOString()}`, + displayValue: `${lastDayOfLastWeek.toString()}`, }; const dateRangeFilter = computeRecordGqlOperationFilter({ diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/states/hasInitializedRecordCalendarSelectedDateComponentState.ts b/packages/twenty-front/src/modules/object-record/record-calendar/states/hasInitializedRecordCalendarSelectedDateComponentState.ts new file mode 100644 index 00000000000..d0a65c1bc95 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar/states/hasInitializedRecordCalendarSelectedDateComponentState.ts @@ -0,0 +1,9 @@ +import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const hasInitializedRecordCalendarSelectedDateComponentState = + createComponentState({ + key: 'hasInitializedRecordCalendarSelectedDateComponentState', + defaultValue: false, + componentInstanceContext: RecordCalendarComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/states/recordCalendarSelectedDateComponentState.ts b/packages/twenty-front/src/modules/object-record/record-calendar/states/recordCalendarSelectedDateComponentState.ts index d034fef1f68..0ce34c030d8 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/states/recordCalendarSelectedDateComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/states/recordCalendarSelectedDateComponentState.ts @@ -1,9 +1,10 @@ import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; +import { Temporal } from 'temporal-polyfill'; export const recordCalendarSelectedDateComponentState = - createComponentState({ + createComponentState({ key: 'recordCalendarSelectedDateComponentState', - defaultValue: new Date(), + defaultValue: Temporal.Now.plainDateISO(), componentInstanceContext: RecordCalendarComponentInstanceContext, }); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts b/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts index 4e401155af5..7864a98670a 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector.ts @@ -1,19 +1,26 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasObjectMetadataItemPositionField'; + import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; import { recordCalendarRecordIdsComponentState } from '@/object-record/record-calendar/states/recordCalendarRecordIdsComponentState'; import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { createComponentFamilySelector } from '@/ui/utilities/state/component-state/utils/createComponentFamilySelector'; -import { isSameDay, parse } from 'date-fns'; -import { isDefined } from 'twenty-shared/utils'; +import { isNonEmptyString } from '@sniptt/guards'; + +import { Temporal } from 'temporal-polyfill'; +import { isDefined, isSamePlainDate } from 'twenty-shared/utils'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; export const calendarDayRecordIdsComponentFamilySelector = - createComponentFamilySelector({ + createComponentFamilySelector< + string[], + { day: Temporal.PlainDate; timeZone: string } + >({ key: 'calendarDayRecordsComponentFamilySelector', componentInstanceContext: RecordCalendarComponentInstanceContext, get: - ({ instanceId, familyKey: dayAsString }) => + ({ instanceId, familyKey: { day, timeZone } }) => ({ get }) => { const calendarFieldMetadataId = get( recordIndexCalendarFieldMetadataIdState, @@ -51,14 +58,18 @@ export const calendarDayRecordIdsComponentFamilySelector = const record = get(recordStoreFamilyState(recordId)); const recordDate = record?.[fieldMetadataItem.name]; - if (!recordDate) { + if (!isNonEmptyString(recordDate)) { return false; } - const dayDate = parse(dayAsString, 'yyyy-MM-dd', new Date()); - const recordDateObj = new Date(recordDate); + const recordDateAsPlainDateInTimeZone = + fieldMetadataItem.type === FieldMetadataType.DATE + ? Temporal.PlainDate.from(recordDate) + : Temporal.Instant.from(recordDate) + .toZonedDateTimeISO(timeZone) + .toPlainDate(); - return isSameDay(recordDateObj, dayDate); + return isSamePlainDate(day, recordDateAsPlainDateInTimeZone); }); if ( diff --git a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts index ef92f084600..7c32061ce79 100644 --- a/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts +++ b/packages/twenty-front/src/modules/object-record/record-drag/hooks/useProcessCalendarCardDrop.ts @@ -6,20 +6,12 @@ import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector'; import { extractRecordPositions } from '@/object-record/record-drag/utils/extractRecordPositions'; -import { isFieldDateTime } from '@/object-record/record-field/ui/types/guards/isFieldDateTime'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { computeNewPositionOfDraggedRecord } from '@/object-record/utils/computeNewPositionOfDraggedRecord'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState'; import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly'; -import { - formatISO, - getHours, - getMilliseconds, - getMinutes, - getSeconds, - parse, - set, -} from 'date-fns'; +import { Temporal } from 'temporal-polyfill'; import { isDefined } from 'twenty-shared/utils'; export const useProcessCalendarCardDrop = () => { @@ -29,6 +21,8 @@ export const useProcessCalendarCardDrop = () => { objectNameSingular: objectMetadataItem.nameSingular, }); + const { userTimezone } = useUserTimezone(); + const calendarDayRecordIdsSelector = useRecoilComponentCallbackState( calendarDayRecordIdsComponentFamilySelector, ); @@ -46,6 +40,8 @@ export const useProcessCalendarCardDrop = () => { const destinationDate = calendarCardDropResult.destination.droppableId; const destinationIndex = calendarCardDropResult.destination.index; + const destinationPlainDate = Temporal.PlainDate.from(destinationDate); + const record = snapshot .getLoadable(recordStoreFamilyState(recordId)) .getValue(); @@ -59,7 +55,12 @@ export const useProcessCalendarCardDrop = () => { if (!calendarFieldMetadata) return; const destinationRecordIds = snapshot - .getLoadable(calendarDayRecordIdsSelector(destinationDate)) + .getLoadable( + calendarDayRecordIdsSelector({ + day: destinationPlainDate, + timeZone: userTimezone, + }), + ) .getValue() as string[]; const targetDayIsEmpty = destinationRecordIds.length === 0; @@ -73,9 +74,15 @@ export const useProcessCalendarCardDrop = () => { destinationRecordIds, snapshot, ); + const droppedRecordIsFromAnotherList = !recordsWithPosition + .map((recordWithPosition) => recordWithPosition.id) + .includes(recordId); const isDroppedAfterList = - destinationIndex >= recordsWithPosition.length; + (recordsWithPosition.length === 2 && + destinationIndex === 1 && + !droppedRecordIsFromAnotherList) || + destinationIndex === recordsWithPosition.length; const targetRecord = isDroppedAfterList ? recordsWithPosition.at(-1) @@ -95,38 +102,38 @@ export const useProcessCalendarCardDrop = () => { }); } - const targetDate = parse(destinationDate, 'yyyy-MM-dd', new Date()); const currentFieldValue = record[calendarFieldMetadata.name]; - let newDate: Date; - if ( - isDefined(currentFieldValue) && - isFieldDateTime(calendarFieldMetadata) - ) { - const currentDateTime = new Date(currentFieldValue); - newDate = set(targetDate, { - hours: getHours(currentDateTime), - minutes: getMinutes(currentDateTime), - seconds: getSeconds(currentDateTime), - milliseconds: getMilliseconds(currentDateTime), - }); - } else { - newDate = targetDate; - } + const currentZonedDateTime = isDefined(currentFieldValue) + ? Temporal.Instant.from(currentFieldValue).toZonedDateTimeISO( + userTimezone, + ) + : null; + + const newDate = isDefined(currentZonedDateTime) + ? currentZonedDateTime.with({ + day: destinationPlainDate.day, + month: destinationPlainDate.month, + year: destinationPlainDate.year, + }) + : Temporal.PlainDate.from(destinationPlainDate).toZonedDateTime( + userTimezone, + ); await updateOneRecord({ idToUpdate: recordId, updateOneRecordInput: { - [calendarFieldMetadata.name]: formatISO(newDate), + [calendarFieldMetadata.name]: newDate.toInstant().toString(), position: newPosition, }, }); }, [ - objectMetadataItem, currentView, - updateOneRecord, + objectMetadataItem.fields, calendarDayRecordIdsSelector, + userTimezone, + updateOneRecord, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx index b72d0f8b0d4..d16e17c09e2 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/components/FormFieldInput.tsx @@ -62,6 +62,7 @@ type FormFieldInputProps = { placeholder?: string; error?: string; onError?: (error: string | undefined) => void; + timeZone?: string; }; export const FormFieldInput = ({ @@ -73,6 +74,7 @@ export const FormFieldInput = ({ placeholder, error, onError, + timeZone, }: FormFieldInputProps) => { return isFieldNumber(field) || field.type === FieldMetadataType.NUMERIC ? ( ) : isFieldMultiSelect(field) ? ( theme.spacing(1)}; `; -const StyledDateInput = styled.input<{ hasError?: boolean }>` - ${TEXT_INPUT_STYLE} - - &:disabled { - color: ${({ theme }) => theme.font.color.tertiary}; - } - - ${({ hasError, theme }) => - hasError && - css` - color: ${theme.color.red}; - `}; +const StyledDateInputTextContainer = styled.div` + align-items: center; + display: flex; `; const StyledDateInputContainer = styled.div` @@ -84,6 +66,7 @@ type FormDateTimeFieldInputProps = { placeholder?: string; VariablePicker?: VariablePickerComponent; readonly?: boolean; + timeZone?: string; }; export const FormDateTimeFieldInput = ({ @@ -92,15 +75,10 @@ export const FormDateTimeFieldInput = ({ onChange, VariablePicker, readonly, - placeholder, + timeZone, }: FormDateTimeFieldInputProps) => { const instanceId = useId(); - const { parseJSDateToDateTimeInputString: parseDateTimeToString } = - useParseJSDateToIMaskDateTimeInputString(); - const { parseDateTimeInputStringToJSDate: parseStringToDateTime } = - useParseDateTimeInputStringToJSDate(); - const [draftValue, setDraftValue] = useState( isStandaloneVariableString(defaultValue) ? { @@ -109,34 +87,18 @@ export const FormDateTimeFieldInput = ({ } : { type: 'static', - value: defaultValue ?? null, + value: defaultValue !== 'null' ? (defaultValue ?? null) : null, mode: 'view', }, ); - const draftValueAsDate = - isDefined(draftValue.value) && - isNonEmptyString(draftValue.value) && - draftValue.type === 'static' - ? new Date(draftValue.value) - : null; - - const [pickerDate, setPickerDate] = - useState>(draftValueAsDate); - const datePickerWrapperRef = useRef(null); - const [inputDateTime, setInputDateTime] = useState( - isDefined(draftValueAsDate) && !isStandaloneVariableString(defaultValue) - ? parseDateTimeToString(draftValueAsDate) - : '', - ); - - const persistDate = (newDate: Nullable) => { + const persistDate = (newDate: Nullable) => { if (!isDefined(newDate)) { onChange(null); } else { - const newDateISO = newDate.toISOString(); + const newDateISO = newDate.toInstant().toString(); onChange(newDateISO); } @@ -148,8 +110,6 @@ export const FormDateTimeFieldInput = ({ const displayDatePicker = draftValue.type === 'static' && draftValue.mode === 'edit'; - const placeholderToDisplay = placeholder ?? 'mm/dd/yyyy hh:mm'; - useListenClickOutside({ refs: [datePickerWrapperRef], listenerId: 'FormDateTimeFieldInputBase', @@ -167,17 +127,13 @@ export const FormDateTimeFieldInput = ({ ], }); - const handlePickerChange = (newDate: Nullable) => { + const handlePickerChange = (newDate: Nullable) => { setDraftValue({ type: 'static', mode: 'edit', - value: newDate?.toDateString() ?? null, + value: newDate?.toPlainDate().toString() ?? null, }); - setInputDateTime(isDefined(newDate) ? parseDateTimeToString(newDate) : ''); - - setPickerDate(newDate); - persistDate(newDate); }; @@ -208,24 +164,18 @@ export const FormDateTimeFieldInput = ({ mode: 'view', }); - setPickerDate(null); - - setInputDateTime(''); - persistDate(null); }; - const handlePickerMouseSelect = (newDate: Nullable) => { + const handlePickerMouseSelect = ( + newDate: Nullable, + ) => { setDraftValue({ type: 'static', - value: newDate?.toDateString() ?? null, + value: newDate?.toPlainDate().toString() ?? null, mode: 'view', }); - setPickerDate(newDate); - - setInputDateTime(isDefined(newDate) ? parseDateTimeToString(newDate) : ''); - persistDate(newDate); }; @@ -237,46 +187,18 @@ export const FormDateTimeFieldInput = ({ }); }; - const handleInputChange = (event: ChangeEvent) => { - setInputDateTime(event.target.value); - }; - - const handleInputKeydown = (event: KeyboardEvent) => { - if (event.key !== 'Enter') { + const handleInputChange = (newDate: Temporal.ZonedDateTime | null) => { + if (!isDefined(newDate)) { return; } - const inputDateTimeTrimmed = inputDateTime.trim(); - - if (inputDateTimeTrimmed === '') { - handlePickerClear(); - return; - } - - const parsedInputDateTime = parseStringToDateTime(inputDateTimeTrimmed); - - if (!isDefined(parsedInputDateTime)) { - return; - } - - let validatedDate = parsedInputDateTime; - if (parsedInputDateTime < MIN_DATE) { - validatedDate = MIN_DATE; - } else if (parsedInputDateTime > MAX_DATE) { - validatedDate = MAX_DATE; - } - setDraftValue({ type: 'static', - value: validatedDate.toDateString(), mode: 'edit', + value: newDate.toPlainDate().toString(), }); - setPickerDate(validatedDate); - - setInputDateTime(parseDateTimeToString(validatedDate)); - - persistDate(validatedDate); + persistDate(newDate); }; const handleVariableTagInsert = (variableName: string) => { @@ -285,8 +207,6 @@ export const FormDateTimeFieldInput = ({ value: variableName, }); - setInputDateTime(''); - onChange(variableName); }; @@ -297,8 +217,6 @@ export const FormDateTimeFieldInput = ({ mode: 'view', }); - setPickerDate(null); - onChange(null); }; @@ -309,6 +227,16 @@ export const FormDateTimeFieldInput = ({ dependencies: [handlePickerEscape], }); + const { userTimezone } = useUserTimezone(); + + const dateValue = isStandaloneVariableString(defaultValue) + ? null + : defaultValue === 'null' || defaultValue === '' || !isDefined(defaultValue) + ? null + : Temporal.Instant.from(defaultValue).toZonedDateTimeISO( + timeZone ?? userTimezone, + ); + return ( {label ? {label} : null} @@ -321,29 +249,29 @@ export const FormDateTimeFieldInput = ({ > {draftValue.type === 'static' ? ( <> - - + + + {draftValue.mode === 'edit' ? ( @@ -357,7 +285,6 @@ export const FormDateTimeFieldInput = ({ /> )} - {VariablePicker && !readonly ? ( { RecordFieldComponentInstanceContext, ); - const getDateToPersist = (newDate: Nullable) => { - if (!newDate) { + const getDateToPersist = (newInstant: Nullable) => { + if (!newInstant) { return null; } else { - const newDateISO = newDate?.toISOString(); + const newDateISO = newInstant?.toString(); return newDateISO; } }; - const handleEnter = (newDate: Nullable) => { + const handleEnter = (newDate: Nullable) => { onEnter?.({ newValue: getDateToPersist(newDate) }); }; - const handleEscape = (newDate: Nullable) => { + const handleEscape = (newDate: Nullable) => { onEscape?.({ newValue: getDateToPersist(newDate) }); }; const handleClickOutside = ( event: MouseEvent | TouchEvent, - newDate: Nullable, + newDate: Nullable, ) => { onClickOutside?.({ newValue: getDateToPersist(newDate), event }); }; - const handleChange = (newDate: Nullable) => { - setDraftValue(newDate?.toDateString() ?? ''); + const handleChange = (newInstant: Nullable) => { + setDraftValue(newInstant?.toString() ?? ''); }; const handleClear = () => { onSubmit?.({ newValue: null }); }; - const handleSubmit = (newDate: Nullable) => { - onSubmit?.({ newValue: getDateToPersist(newDate) }); + const handleSubmit = (newInstant: Nullable) => { + onSubmit?.({ newValue: getDateToPersist(newInstant) }); }; - const dateValue = fieldValue ? new Date(fieldValue) : null; + const dateValue = isNonEmptyString(fieldValue) + ? Temporal.Instant.from(fieldValue) + : null; return ( { - const { dateFormat, timeZone } = useContext(UserContext); - const dateLocale = useRecoilValue(dateLocaleState); - const { userTimezone } = useUserTimezone(); + const { isSystemTimezone, userTimezone } = useUserTimezone(); const { getDateTimeFilterDisplayValue } = useGetDateTimeFilterDisplayValue(); + const { getDateFilterDisplayValue } = useGetDateFilterDisplayValue(); const { getFieldMetadataItemByIdOrThrow } = useGetFieldMetadataItemByIdOrThrow(); @@ -44,65 +39,25 @@ export const useGetRecordFilterDisplayValue = () => { const operandIsEmptiness = isEmptinessOperand(recordFilter.operand); const recordFilterIsEmpty = isRecordFilterConsideredEmpty(recordFilter); + const nowInZonedDateTime = Temporal.Now.zonedDateTimeISO(userTimezone); + + const shouldDisplayTimeZoneAbbreviation = !isSystemTimezone; + const timeZoneAbbreviation = + getTimezoneAbbreviationForZonedDateTime(nowInZonedDateTime); + if (filterType === 'DATE') { switch (recordFilter.operand) { case RecordFilterOperand.IS: { - const date = getDateFromPlainDate(recordFilter.value); - - const shiftedDate = - shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone( - date, - userTimezone, - 'add', - ); - - if (!isValid(date)) { + if (!isDefined(recordFilter.value)) { return ''; } - const formattedDate = formatDateString({ - value: shiftedDate.toISOString(), - timeZone, - dateFormat, - localeCatalog: dateLocale.localeCatalog, - }); + const plainDate = Temporal.PlainDate.from(recordFilter.value); - return `${formattedDate}`; - } - case RecordFilterOperand.IS_RELATIVE: { - const relativeDateFilter = - relativeDateFilterStringifiedSchema.safeParse(recordFilter.value); - - if (!relativeDateFilter.success) { - return ``; - } - - const relativeDateDisplayValue = getRelativeDateDisplayValue( - relativeDateFilter.data, + const { displayValue } = getDateFilterDisplayValue( + plainDate.toZonedDateTime(userTimezone), ); - return ` ${relativeDateDisplayValue}`; - } - case RecordFilterOperand.IS_TODAY: - case RecordFilterOperand.IS_IN_FUTURE: - case RecordFilterOperand.IS_IN_PAST: - return ''; - default: - return ` ${recordFilter.displayValue}`; - } - } else if (recordFilter.type === 'DATE_TIME') { - switch (recordFilter.operand) { - case RecordFilterOperand.IS: - case RecordFilterOperand.IS_AFTER: - case RecordFilterOperand.IS_BEFORE: { - const pointInTime = new Date(recordFilter.value); - - if (!isValid(pointInTime)) { - return ''; - } - - const { displayValue } = getDateTimeFilterDisplayValue(pointInTime); - return `${displayValue}`; } case RecordFilterOperand.IS_RELATIVE: { @@ -115,11 +70,57 @@ export const useGetRecordFilterDisplayValue = () => { const relativeDateDisplayValue = getRelativeDateDisplayValue( relativeDateFilter.data, + shouldDisplayTimeZoneAbbreviation, + ); + + return ` ${relativeDateDisplayValue}`; + } + case RecordFilterOperand.IS_TODAY: + return shouldDisplayTimeZoneAbbreviation + ? `(${timeZoneAbbreviation})` + : ''; + case RecordFilterOperand.IS_IN_FUTURE: + case RecordFilterOperand.IS_IN_PAST: + return ''; + default: + return ` ${recordFilter.displayValue}`; + } + } else if (recordFilter.type === 'DATE_TIME') { + switch (recordFilter.operand) { + case RecordFilterOperand.IS: + case RecordFilterOperand.IS_AFTER: + case RecordFilterOperand.IS_BEFORE: { + if (!isNonEmptyString(recordFilter.value)) { + return ''; + } + + const zonedDateTime = Temporal.Instant.from( + recordFilter.value, + ).toZonedDateTimeISO(userTimezone); + + const { displayValue } = getDateTimeFilterDisplayValue(zonedDateTime); + + return `${displayValue}`; + } + case RecordFilterOperand.IS_RELATIVE: { + const relativeDateFilter = + relativeDateFilterStringifiedSchema.safeParse(recordFilter.value); + + if (!relativeDateFilter.success) { + return ``; + } + + const relativeDateDisplayValue = getRelativeDateDisplayValue( + relativeDateFilter.data, + shouldDisplayTimeZoneAbbreviation, ); return `${relativeDateDisplayValue}`; } case RecordFilterOperand.IS_TODAY: + return shouldDisplayTimeZoneAbbreviation + ? `(${timeZoneAbbreviation})` + : ''; case RecordFilterOperand.IS_IN_FUTURE: case RecordFilterOperand.IS_IN_PAST: return ''; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone.ts index 793ffb601bb..64daf72c4d9 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone.ts @@ -2,7 +2,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useRecoilValue } from 'recoil'; -import { CalendarStartDay } from 'twenty-shared'; +import { CalendarStartDay } from 'twenty-shared/constants'; import { type FirstDayOfTheWeek } from 'twenty-shared/types'; import { type RelativeDateFilter } from 'twenty-shared/utils'; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useTimeZoneAbbreviationForNowInUserTimeZone.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useTimeZoneAbbreviationForNowInUserTimeZone.ts new file mode 100644 index 00000000000..4d5aacd186f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useTimeZoneAbbreviationForNowInUserTimeZone.ts @@ -0,0 +1,14 @@ +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime'; +import { Temporal } from 'temporal-polyfill'; + +export const useTimeZoneAbbreviationForNowInUserTimeZone = () => { + const { userTimezone } = useUserTimezone(); + + const nowZonedDateTime = Temporal.Now.zonedDateTimeISO(userTimezone); + + const userTimeZoneAbbreviation = + getTimezoneAbbreviationForZonedDateTime(nowZonedDateTime); + + return { userTimeZoneAbbreviation }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts index 90aedd2d71d..6a5e54279b0 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts @@ -26,6 +26,7 @@ const personMockObjectMetadataItem = getMockObjectMetadataItemOrThrow('person'); const mockFilterValueDependencies: RecordFilterValueDependencies = { currentWorkspaceMemberId: '32219445-f587-4c40-b2b1-6d3205ed96da', + timeZone: 'Europe/Paris', }; jest.useFakeTimers().setSystemTime(new Date('2020-01-01')); @@ -1068,7 +1069,7 @@ describe('should work as expected for the different field types', () => { and: [ { createdAt: { - gt: '2024-09-17T20:46:58.922Z', + gte: '2024-09-17T20:46:58.922Z', }, }, { @@ -1080,12 +1081,12 @@ describe('should work as expected for the different field types', () => { and: [ { createdAt: { - lte: '2024-09-17T20:46:59.999Z', + lt: '2024-09-17T20:47:00Z', }, }, { createdAt: { - gte: '2024-09-17T20:46:00.000Z', + gte: '2024-09-17T20:46:00Z', }, }, ], diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexCalendarContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexCalendarContainer.tsx index 11d04771fff..ba77adc3b16 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexCalendarContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexCalendarContainer.tsx @@ -3,6 +3,7 @@ import { RecordComponentInstanceContextsWrapper } from '@/object-record/componen import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; import { RecordCalendar } from '@/object-record/record-calendar/components/RecordCalendar'; import { RecordIndexCalendarDataLoaderEffect } from '@/object-record/record-calendar/components/RecordIndexCalendarDataLoaderEffect'; +import { RecordIndexCalendarSelectedDateInitEffect } from '@/object-record/record-calendar/components/RecordIndexCalendarSelectedDateInitEffect'; import { RecordCalendarContextProvider } from '@/object-record/record-calendar/contexts/RecordCalendarContext'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; @@ -52,6 +53,7 @@ export const RecordIndexCalendarContainer = ({ > + ); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts index 8b2ea75c5a6..5fa9ad4f396 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts @@ -13,6 +13,7 @@ import { computeRecordGqlOperationFilter, turnAnyFieldFilterIntoRecordGqlFilter, } from 'twenty-shared/utils'; + export const useFindManyRecordIndexTableParams = ( objectNameSingular: string, ) => { diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts index f234aed4362..4ed2b22b540 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordIndexTableQuery.ts @@ -4,6 +4,7 @@ import { useRecordsFieldVisibleGqlFields } from '@/object-record/record-field/ho import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams'; import { SIGN_IN_BACKGROUND_MOCK_COMPANIES } from '@/sign-in-background-mock/constants/SignInBackgroundMockCompanies'; import { useShowAuthModal } from '@/ui/layout/hooks/useShowAuthModal'; + export const useRecordIndexTableQuery = (objectNameSingular: string) => { const showAuthModal = useShowAuthModal(); diff --git a/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts b/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts index c5ad711e0b4..b92f5425d5c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts @@ -123,6 +123,7 @@ const computeValueFromFilterText = ( } }; +// TODO: fix this with Temporal const computeValueFromFilterDate = ( operand: RecordFilterToRecordInputOperand<'DATE_TIME'>, value: string, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx index dc1ddf9bd36..378634b74dd 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer.tsx @@ -7,6 +7,8 @@ import { assertBarChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/a import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams'; import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey'; import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget'; +import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector'; import { type BarDatum, type ComputedDatum } from '@nivo/bar'; @@ -29,6 +31,9 @@ export const GraphWidgetBarChartRenderer = () => { assertBarChartWidgetOrThrow(widget); + const { userTimezone } = useUserTimezone(); + const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek(); + const { data, indexBy, @@ -84,7 +89,8 @@ export const GraphWidgetBarChartRenderer = () => { primaryBucketRawValue: rawValue, }, viewId: indexViewId, - timezone: configuration.timezone ?? undefined, + timezone: userTimezone, + firstDayOfTheWeek: userFirstDayOfTheWeek, }); const url = getAppPath( diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts index 0fe7ae7e5ce..4c085ea8acc 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData.ts @@ -6,6 +6,8 @@ import { getBarChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetBa import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData'; import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { type BarDatum } from '@nivo/bar'; import { useMemo } from 'react'; import { type BarChartConfiguration } from '~/generated/graphql'; @@ -43,6 +45,9 @@ export const useGraphBarChartWidgetData = ({ }); const { objectMetadataItems } = useObjectMetadataItems(); + const { userTimezone } = useUserTimezone(); + const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek(); + const limit = getBarChartQueryLimit(configuration); const { @@ -64,6 +69,8 @@ export const useGraphBarChartWidgetData = ({ objectMetadataItems: objectMetadataItems ?? [], configuration, aggregateOperation, + userTimezone, + firstDayOfTheWeek: userFirstDayOfTheWeek, }), [ groupByData, @@ -71,6 +78,8 @@ export const useGraphBarChartWidgetData = ({ objectMetadataItems, configuration, aggregateOperation, + userTimezone, + userFirstDayOfTheWeek, ], ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/fillDateGapsInBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/fillDateGapsInBarChartData.test.ts index 7058def8dda..c0e67ea9aae 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/fillDateGapsInBarChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/fillDateGapsInBarChartData.test.ts @@ -7,11 +7,11 @@ describe('fillDateGapsInBarChartData', () => { it('fills gaps in date data with zero values', () => { const data = [ { - groupByDimensionValues: ['2024-01-01T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-01'], count: 5, }, { - groupByDimensionValues: ['2024-01-03T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-03'], count: 3, }, ]; @@ -24,15 +24,15 @@ describe('fillDateGapsInBarChartData', () => { expect(result.data).toHaveLength(3); expect(result.data[0]).toEqual({ - groupByDimensionValues: ['2024-01-01T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-01'], count: 5, }); expect(result.data[1]).toEqual({ - groupByDimensionValues: ['2024-01-02T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-02'], count: 0, }); expect(result.data[2]).toEqual({ - groupByDimensionValues: ['2024-01-03T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-03'], count: 3, }); expect(result.wasTruncated).toBe(false); @@ -52,11 +52,11 @@ describe('fillDateGapsInBarChartData', () => { it('returns data in descending order when orderBy is FIELD_DESC', () => { const data = [ { - groupByDimensionValues: ['2024-01-01T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-01'], count: 5, }, { - groupByDimensionValues: ['2024-01-03T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-03'], count: 3, }, ]; @@ -70,15 +70,15 @@ describe('fillDateGapsInBarChartData', () => { expect(result.data).toHaveLength(3); expect(result.data[0]).toEqual({ - groupByDimensionValues: ['2024-01-03T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-03'], count: 3, }); expect(result.data[1]).toEqual({ - groupByDimensionValues: ['2024-01-02T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-02'], count: 0, }); expect(result.data[2]).toEqual({ - groupByDimensionValues: ['2024-01-01T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-01'], count: 5, }); expect(result.wasTruncated).toBe(false); @@ -87,11 +87,11 @@ describe('fillDateGapsInBarChartData', () => { it('returns data in ascending order when orderBy is FIELD_ASC', () => { const data = [ { - groupByDimensionValues: ['2024-01-01T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-01'], count: 5, }, { - groupByDimensionValues: ['2024-01-03T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-03'], count: 3, }, ]; @@ -105,15 +105,15 @@ describe('fillDateGapsInBarChartData', () => { expect(result.data).toHaveLength(3); expect(result.data[0]).toEqual({ - groupByDimensionValues: ['2024-01-01T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-01'], count: 5, }); expect(result.data[1]).toEqual({ - groupByDimensionValues: ['2024-01-02T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-02'], count: 0, }); expect(result.data[2]).toEqual({ - groupByDimensionValues: ['2024-01-03T00:00:00.000Z'], + groupByDimensionValues: ['2024-01-03'], count: 3, }); expect(result.wasTruncated).toBe(false); @@ -124,15 +124,15 @@ describe('fillDateGapsInBarChartData', () => { it('fills gaps for all second dimension values', () => { const data = [ { - groupByDimensionValues: ['2024-01-01T00:00:00.000Z', 'A'], + groupByDimensionValues: ['2024-01-01', 'A'], count: 5, }, { - groupByDimensionValues: ['2024-01-03T00:00:00.000Z', 'A'], + groupByDimensionValues: ['2024-01-03', 'A'], count: 3, }, { - groupByDimensionValues: ['2024-01-01T00:00:00.000Z', 'B'], + groupByDimensionValues: ['2024-01-01', 'B'], count: 2, }, ]; @@ -154,11 +154,11 @@ describe('fillDateGapsInBarChartData', () => { expect( result.data.find( (r) => - r.groupByDimensionValues[0] === '2024-01-02T00:00:00.000Z' && + r.groupByDimensionValues[0] === '2024-01-02' && r.groupByDimensionValues[1] === 'A', ), ).toEqual({ - groupByDimensionValues: ['2024-01-02T00:00:00.000Z', 'A'], + groupByDimensionValues: ['2024-01-02', 'A'], count: 0, }); expect(result.wasTruncated).toBe(false); @@ -167,15 +167,15 @@ describe('fillDateGapsInBarChartData', () => { it('fills gaps in descending order when orderBy is FIELD_DESC', () => { const data = [ { - groupByDimensionValues: ['2024-01-01T00:00:00.000Z', 'A'], + groupByDimensionValues: ['2024-01-01', 'A'], count: 5, }, { - groupByDimensionValues: ['2024-01-03T00:00:00.000Z', 'A'], + groupByDimensionValues: ['2024-01-03', 'A'], count: 3, }, { - groupByDimensionValues: ['2024-01-01T00:00:00.000Z', 'B'], + groupByDimensionValues: ['2024-01-01', 'B'], count: 2, }, ]; @@ -191,28 +191,16 @@ describe('fillDateGapsInBarChartData', () => { expect(result.data).toHaveLength(6); // First date group should be Jan 3 (descending) - expect(result.data[0].groupByDimensionValues[0]).toBe( - '2024-01-03T00:00:00.000Z', - ); - expect(result.data[1].groupByDimensionValues[0]).toBe( - '2024-01-03T00:00:00.000Z', - ); + expect(result.data[0].groupByDimensionValues[0]).toBe('2024-01-03'); + expect(result.data[1].groupByDimensionValues[0]).toBe('2024-01-03'); // Middle date group should be Jan 2 - expect(result.data[2].groupByDimensionValues[0]).toBe( - '2024-01-02T00:00:00.000Z', - ); - expect(result.data[3].groupByDimensionValues[0]).toBe( - '2024-01-02T00:00:00.000Z', - ); + expect(result.data[2].groupByDimensionValues[0]).toBe('2024-01-02'); + expect(result.data[3].groupByDimensionValues[0]).toBe('2024-01-02'); // Last date group should be Jan 1 - expect(result.data[4].groupByDimensionValues[0]).toBe( - '2024-01-01T00:00:00.000Z', - ); - expect(result.data[5].groupByDimensionValues[0]).toBe( - '2024-01-01T00:00:00.000Z', - ); + expect(result.data[4].groupByDimensionValues[0]).toBe('2024-01-01'); + expect(result.data[5].groupByDimensionValues[0]).toBe('2024-01-01'); expect(result.wasTruncated).toBe(false); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/generateDateGroupsInRange.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/generateDateGroupsInRange.test.ts index 27196eafd50..e5b4f955fb5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/generateDateGroupsInRange.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/generateDateGroupsInRange.test.ts @@ -1,64 +1,81 @@ import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; +import { Temporal } from 'temporal-polyfill'; import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; import { generateDateGroupsInRange } from '../generateDateGroupsInRange'; describe('generateDateGroupsInRange', () => { it('generates daily date groups', () => { const result = generateDateGroupsInRange({ - startDate: new Date('2024-01-01'), - endDate: new Date('2024-01-07'), + startDate: Temporal.PlainDate.from('2024-01-01'), + endDate: Temporal.PlainDate.from('2024-01-07'), granularity: ObjectRecordGroupByDateGranularity.DAY, }); expect(result.dates).toHaveLength(7); - expect(result.dates[0]).toEqual(new Date('2024-01-01')); - expect(result.dates[6]).toEqual(new Date('2024-01-07')); + expect(result.dates[0].toString()).toEqual( + Temporal.PlainDate.from('2024-01-01').toString(), + ); + expect(result.dates[6].toString()).toEqual( + Temporal.PlainDate.from('2024-01-07').toString(), + ); expect(result.wasTruncated).toBe(false); }); it('generates monthly date groups', () => { const result = generateDateGroupsInRange({ - startDate: new Date('2024-01-01'), - endDate: new Date('2024-06-01'), + startDate: Temporal.PlainDate.from('2024-01-01'), + endDate: Temporal.PlainDate.from('2024-06-01'), granularity: ObjectRecordGroupByDateGranularity.MONTH, }); expect(result.dates).toHaveLength(6); - expect(result.dates[0]).toEqual(new Date('2024-01-01')); - expect(result.dates[5]).toEqual(new Date('2024-06-01')); + expect(result.dates[0].toString()).toEqual( + Temporal.PlainDate.from('2024-01-01').toString(), + ); + expect(result.dates[5].toString()).toEqual( + Temporal.PlainDate.from('2024-06-01').toString(), + ); expect(result.wasTruncated).toBe(false); }); it('generates quarterly date groups', () => { const result = generateDateGroupsInRange({ - startDate: new Date('2024-01-01'), - endDate: new Date('2024-12-31'), + startDate: Temporal.PlainDate.from('2024-01-01'), + endDate: Temporal.PlainDate.from('2024-12-31'), granularity: ObjectRecordGroupByDateGranularity.QUARTER, }); expect(result.dates).toHaveLength(4); - expect(result.dates[0]).toEqual(new Date('2024-01-01')); - expect(result.dates[3]).toEqual(new Date('2024-10-01')); + expect(result.dates[0].toString()).toEqual( + Temporal.PlainDate.from('2024-01-01').toString(), + ); + expect(result.dates[3].toString()).toEqual( + Temporal.PlainDate.from('2024-10-01').toString(), + ); expect(result.wasTruncated).toBe(false); }); it('generates yearly date groups', () => { const result = generateDateGroupsInRange({ - startDate: new Date('2020-01-01'), - endDate: new Date('2024-12-31'), + startDate: Temporal.PlainDate.from('2020-01-01'), + endDate: Temporal.PlainDate.from('2024-12-31'), granularity: ObjectRecordGroupByDateGranularity.YEAR, }); expect(result.dates).toHaveLength(5); - expect(result.dates[0]).toEqual(new Date('2020-01-01')); - expect(result.dates[4]).toEqual(new Date('2024-01-01')); + expect(result.dates[0].toString()).toEqual( + Temporal.PlainDate.from('2020-01-01').toString(), + ); + expect(result.dates[4].toString()).toEqual( + Temporal.PlainDate.from('2024-01-01').toString(), + ); expect(result.wasTruncated).toBe(false); }); it('truncates when exceeding maximum number of bars', () => { const result = generateDateGroupsInRange({ - startDate: new Date('2024-01-01'), - endDate: new Date('2025-12-31'), + startDate: Temporal.PlainDate.from('2024-01-01'), + endDate: Temporal.PlainDate.from('2025-12-31'), granularity: ObjectRecordGroupByDateGranularity.DAY, }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformGroupByDataToBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformGroupByDataToBarChartData.test.ts index 5684e09fcff..3fb50f6a590 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformGroupByDataToBarChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformGroupByDataToBarChartData.test.ts @@ -1,6 +1,7 @@ import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData'; import { FieldMetadataType, + FirstDayOfTheWeek, ObjectRecordGroupByDateGranularity, } from 'twenty-shared/types'; import { GraphType } from '~/generated-metadata/graphql'; @@ -39,6 +40,8 @@ const { fillDateGapsInBarChartData } = jest.requireMock( ) as { fillDateGapsInBarChartData: jest.Mock }; describe('transformGroupByDataToBarChartData', () => { + const userTimezone = 'Europe/Paris'; + it('fills date gaps when grouping by a relation date subfield with granularity', () => { const groupByField = { id: 'group-by-field', @@ -91,6 +94,8 @@ describe('transformGroupByDataToBarChartData', () => { objectMetadataItems, configuration, aggregateOperation: 'COUNT', + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(fillDateGapsInBarChartData).toHaveBeenCalledTimes(1); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts index 7ae8bbc24e7..9681154468a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/transformTwoDimensionalGroupByToBarChartData.test.ts @@ -1,6 +1,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { FirstDayOfTheWeek } from 'twenty-shared/types'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { AggregateOperations, @@ -11,6 +12,8 @@ import { import { transformTwoDimensionalGroupByToBarChartData } from '../transformTwoDimensionalGroupByToBarChartData'; describe('transformTwoDimensionalGroupByToBarChartData', () => { + const userTimezone = 'Europe/Paris'; + const mockGroupByFieldX = { id: 'field-x', name: 'createdAt', @@ -85,6 +88,8 @@ describe('transformTwoDimensionalGroupByToBarChartData', () => { configuration: mockConfiguration, aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.keys).toEqual(['SCREENING', 'PROPOSAL', 'NEW', 'CUSTOMER']); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup.ts index 82dd175cbff..384d3232331 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup.ts @@ -1,6 +1,7 @@ import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { Temporal } from 'temporal-polyfill'; -export type DimensionValue = string | Date | number | null; +export type DimensionValue = string | Temporal.PlainDate | number | null; export const createEmptyDateGroup = ( dimensionValues: DimensionValue[], @@ -8,7 +9,7 @@ export const createEmptyDateGroup = ( ): GroupByRawResult => { const newItem: GroupByRawResult = { groupByDimensionValues: dimensionValues.map((value) => - value instanceof Date ? value.toISOString() : value, + value instanceof Temporal.PlainDate ? value.toString() : value, ), }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData.ts index 751130fe901..53ee161c503 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData.ts @@ -5,6 +5,7 @@ import { type SupportedDateGranularity, } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { Temporal } from 'temporal-polyfill'; import { isDefined } from 'twenty-shared/utils'; import { type GraphOrderBy } from '~/generated/graphql'; @@ -15,6 +16,7 @@ type OneDimensionalFillParams = { orderBy?: GraphOrderBy | null; }; +// TODO: should handle DATE and DATE_TIME here export const fillDateGapsInOneDimensionalBarChartData = ({ data, keys, @@ -22,7 +24,7 @@ export const fillDateGapsInOneDimensionalBarChartData = ({ orderBy, }: OneDimensionalFillParams): FillDateGapsResult => { const existingDateGroupsMap = new Map(); - const parsedDates: Date[] = []; + const parsedDates: Temporal.PlainDate[] = []; for (const item of data) { const dateValue = item.groupByDimensionValues?.[0]; @@ -31,14 +33,10 @@ export const fillDateGapsInOneDimensionalBarChartData = ({ continue; } - const parsedDate = new Date(String(dateValue)); - - if (isNaN(parsedDate.getTime())) { - continue; - } + const parsedDate = Temporal.PlainDate.from(String(dateValue)); parsedDates.push(parsedDate); - existingDateGroupsMap.set(parsedDate.toISOString(), item); + existingDateGroupsMap.set(parsedDate.toString(), item); } if (parsedDates.length === 0) { @@ -52,7 +50,7 @@ export const fillDateGapsInOneDimensionalBarChartData = ({ }); const filledData = allDates.map((date) => { - const key = date.toISOString(); + const key = date.toString(); const existingDateGroup = existingDateGroupsMap.get(key); return isDefined(existingDateGroup) diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData.ts index 3fbebbdc8ea..03ca1476abc 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData.ts @@ -8,6 +8,7 @@ import { type SupportedDateGranularity, } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { Temporal } from 'temporal-polyfill'; import { isDefined } from 'twenty-shared/utils'; import { type GraphOrderBy } from '~/generated/graphql'; @@ -25,7 +26,7 @@ export const fillDateGapsInTwoDimensionalBarChartData = ({ orderBy, }: TwoDimensionalFillParams): FillDateGapsResult => { const existingDateGroupsMap = new Map(); - const parsedDates: Date[] = []; + const parsedDates: Temporal.PlainDate[] = []; const uniqueSecondDimensionValues = new Set(); for (const item of data) { @@ -35,11 +36,7 @@ export const fillDateGapsInTwoDimensionalBarChartData = ({ continue; } - const parsedDate = new Date(String(dateValue)); - - if (isNaN(parsedDate.getTime())) { - continue; - } + const parsedDate = Temporal.PlainDate.from(String(dateValue)); parsedDates.push(parsedDate); @@ -47,7 +44,7 @@ export const fillDateGapsInTwoDimensionalBarChartData = ({ null) as DimensionValue; uniqueSecondDimensionValues.add(secondDimensionValue); - const key = `${parsedDate.toISOString()}_${String(secondDimensionValue)}`; + const key = `${parsedDate.toString()}_${String(secondDimensionValue)}`; existingDateGroupsMap.set(key, item); } @@ -63,7 +60,7 @@ export const fillDateGapsInTwoDimensionalBarChartData = ({ const filledData = allDates.flatMap((date) => Array.from(uniqueSecondDimensionValues).map((secondDimensionValue) => { - const key = `${date.toISOString()}_${String(secondDimensionValue)}`; + const key = `${date.toString()}_${String(secondDimensionValue)}`; const existingDateGroup = existingDateGroupsMap.get(key); return isDefined(existingDateGroup) diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange.ts index 194fbae642d..d2404110640 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange.ts @@ -1,10 +1,15 @@ import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants'; + +import { type Temporal } from 'temporal-polyfill'; import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { assertUnreachable } from 'twenty-shared/utils'; +import { + assertUnreachable, + isPlainDateBeforeOrEqual, +} from 'twenty-shared/utils'; type GenerateDateRangeParams = { - startDate: Date; - endDate: Date; + startDate: Temporal.PlainDate; + endDate: Temporal.PlainDate; granularity: | ObjectRecordGroupByDateGranularity.DAY | ObjectRecordGroupByDateGranularity.WEEK @@ -14,7 +19,7 @@ type GenerateDateRangeParams = { }; type GenerateDateRangeResult = { - dates: Date[]; + dates: Temporal.PlainDate[]; wasTruncated: boolean; }; @@ -23,41 +28,41 @@ export const generateDateGroupsInRange = ({ endDate, granularity, }: GenerateDateRangeParams): GenerateDateRangeResult => { - const dates: Date[] = []; + const dates: Temporal.PlainDate[] = []; let iterations = 0; let wasTruncated = false; - let currentDateCursor = new Date(startDate); + let currentDateCursor = startDate; - while (currentDateCursor <= endDate) { + while (isPlainDateBeforeOrEqual(currentDateCursor, endDate)) { if (iterations >= BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS) { wasTruncated = true; break; } - dates.push(new Date(currentDateCursor)); + dates.push(currentDateCursor); iterations++; switch (granularity) { case ObjectRecordGroupByDateGranularity.DAY: - currentDateCursor.setDate(currentDateCursor.getDate() + 1); + currentDateCursor = currentDateCursor.add({ days: 1 }); break; case ObjectRecordGroupByDateGranularity.WEEK: - currentDateCursor.setDate(currentDateCursor.getDate() + 7); + currentDateCursor = currentDateCursor.add({ weeks: 1 }); break; case ObjectRecordGroupByDateGranularity.MONTH: - currentDateCursor.setMonth(currentDateCursor.getMonth() + 1); + currentDateCursor = currentDateCursor.add({ months: 1 }); break; case ObjectRecordGroupByDateGranularity.QUARTER: - currentDateCursor.setMonth(currentDateCursor.getMonth() + 3); + currentDateCursor = currentDateCursor.add({ months: 3 }); break; case ObjectRecordGroupByDateGranularity.YEAR: - currentDateCursor.setFullYear(currentDateCursor.getFullYear() + 1); + currentDateCursor = currentDateCursor.add({ years: 1 }); break; default: diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData.ts index e6b50bbeb72..00057c1043a 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData.ts @@ -1,5 +1,7 @@ import { generateDateGroupsInRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange'; +import { type Temporal } from 'temporal-polyfill'; import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { isDefined, sortPlainDate } from 'twenty-shared/utils'; import { GraphOrderBy } from '~/generated/graphql'; export type SupportedDateGranularity = @@ -10,7 +12,7 @@ export type SupportedDateGranularity = | ObjectRecordGroupByDateGranularity.WEEK; type GetDateGroupsFromDataParams = { - parsedDates: Date[]; + parsedDates: Temporal.PlainDate[]; dateGranularity: SupportedDateGranularity; orderBy?: GraphOrderBy | null; }; @@ -19,10 +21,18 @@ export const getDateGroupsFromData = ({ parsedDates, dateGranularity, orderBy, -}: GetDateGroupsFromDataParams): { dates: Date[]; wasTruncated: boolean } => { - const timestamps = parsedDates.map((date) => date.getTime()); - const minDate = new Date(Math.min(...timestamps)); - const maxDate = new Date(Math.max(...timestamps)); +}: GetDateGroupsFromDataParams): { + dates: Temporal.PlainDate[]; + wasTruncated: boolean; +} => { + const sortedPlainDates = parsedDates.toSorted(sortPlainDate('asc')); + + const minDate = sortedPlainDates.at(0); + const maxDate = sortedPlainDates.at(-1); + + if (!isDefined(minDate) || !isDefined(maxDate)) { + return { dates: [], wasTruncated: false }; + } const result = generateDateGroupsInRange({ startDate: minDate, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts index 81602898dbd..10f38ac6482 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts @@ -15,7 +15,11 @@ import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGr import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind'; import { type BarDatum } from '@nivo/bar'; -import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; +import { + type FirstDayOfTheWeek, + isDefined, + isFieldMetadataDateKind, +} from 'twenty-shared/utils'; import { GraphType } from '~/generated-metadata/graphql'; import { AxisNameDisplay, @@ -28,6 +32,8 @@ type TransformGroupByDataToBarChartDataParams = { objectMetadataItems: ObjectMetadataItem[]; configuration: BarChartConfiguration; aggregateOperation: string; + userTimezone: string; + firstDayOfTheWeek: FirstDayOfTheWeek; }; type TransformGroupByDataToBarChartDataResult = { @@ -65,6 +71,8 @@ export const transformGroupByDataToBarChartData = ({ objectMetadataItems, configuration, aggregateOperation, + userTimezone, + firstDayOfTheWeek, }: TransformGroupByDataToBarChartDataParams): TransformGroupByDataToBarChartDataResult => { const groupByFieldX = objectMetadataItem.fields.find( (field: FieldMetadataItem) => @@ -243,6 +251,8 @@ export const transformGroupByDataToBarChartData = ({ aggregateOperation, objectMetadataItem, primaryAxisSubFieldName, + userTimezone, + firstDayOfTheWeek, }) : transformOneDimensionalGroupByToBarChartData({ rawResults: filteredResultsWithDateGaps, @@ -252,6 +262,8 @@ export const transformGroupByDataToBarChartData = ({ aggregateOperation, objectMetadataItem, primaryAxisSubFieldName, + userTimezone, + firstDayOfTheWeek, }); return { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts index 5eb17bf0f93..b0ed418312e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts @@ -14,7 +14,7 @@ import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDi import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; import { type BarDatum } from '@nivo/bar'; -import { isDefined } from 'twenty-shared/utils'; +import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils'; import { type BarChartConfiguration } from '~/generated/graphql'; type TransformOneDimensionalGroupByToBarChartDataParams = { @@ -25,6 +25,8 @@ type TransformOneDimensionalGroupByToBarChartDataParams = { aggregateOperation: string; objectMetadataItem: ObjectMetadataItem; primaryAxisSubFieldName?: string | null; + userTimezone: string; + firstDayOfTheWeek: FirstDayOfTheWeek; }; type TransformOneDimensionalGroupByToBarChartDataResult = { @@ -44,6 +46,8 @@ export const transformOneDimensionalGroupByToBarChartData = ({ aggregateOperation, objectMetadataItem, primaryAxisSubFieldName, + userTimezone, + firstDayOfTheWeek, }: TransformOneDimensionalGroupByToBarChartDataParams): TransformOneDimensionalGroupByToBarChartDataResult => { const indexByKey = getFieldKey({ field: groupByFieldX, @@ -67,6 +71,8 @@ export const transformOneDimensionalGroupByToBarChartData = ({ primaryAxisDateGranularity: configuration.primaryAxisDateGranularity ?? undefined, primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined, + userTimezone, + firstDayOfTheWeek, }); const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); @@ -82,6 +88,8 @@ export const transformOneDimensionalGroupByToBarChartData = ({ configuration.primaryAxisDateGranularity ?? undefined, subFieldName: configuration.primaryAxisGroupBySubFieldName ?? undefined, + userTimezone, + firstDayOfTheWeek, }) : ''; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts index f715a94039c..fd1de67bb58 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts @@ -15,7 +15,7 @@ import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/ import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey'; import { getSortedKeys } from '@/page-layout/widgets/graph/utils/getSortedKeys'; import { type BarDatum } from '@nivo/bar'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, type FirstDayOfTheWeek } from 'twenty-shared/utils'; import { BarChartGroupMode, type BarChartConfiguration, @@ -30,6 +30,8 @@ type TransformTwoDimensionalGroupByToBarChartDataParams = { aggregateOperation: string; objectMetadataItem: ObjectMetadataItem; primaryAxisSubFieldName?: string | null; + userTimezone: string; + firstDayOfTheWeek: FirstDayOfTheWeek; }; type TransformTwoDimensionalGroupByToBarChartDataResult = { @@ -50,6 +52,8 @@ export const transformTwoDimensionalGroupByToBarChartData = ({ aggregateOperation, objectMetadataItem, primaryAxisSubFieldName, + userTimezone, + firstDayOfTheWeek, }: TransformTwoDimensionalGroupByToBarChartDataParams): TransformTwoDimensionalGroupByToBarChartDataResult => { const indexByKey = getFieldKey({ field: groupByFieldX, @@ -65,6 +69,8 @@ export const transformTwoDimensionalGroupByToBarChartData = ({ primaryAxisDateGranularity: configuration.primaryAxisDateGranularity ?? undefined, primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined, + userTimezone, + firstDayOfTheWeek, }); const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); @@ -79,13 +85,18 @@ export const transformTwoDimensionalGroupByToBarChartData = ({ fieldMetadata: groupByFieldX, dateGranularity: configuration.primaryAxisDateGranularity ?? undefined, subFieldName: configuration.primaryAxisGroupBySubFieldName ?? undefined, + userTimezone, + firstDayOfTheWeek, }); + const yValue = formatDimensionValue({ value: dimensionValues[1], fieldMetadata: groupByFieldY, dateGranularity: configuration.secondaryAxisGroupByDateGranularity ?? undefined, subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined, + userTimezone, + firstDayOfTheWeek, }); if (isDefined(dimensionValues[0])) { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx index 3180c51c8b1..173d129e6b5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChartRenderer.tsx @@ -8,6 +8,8 @@ import { assertLineChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/ import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams'; import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey'; import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget'; +import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector'; import { type LineSeries, type Point } from '@nivo/line'; @@ -30,6 +32,8 @@ export const GraphWidgetLineChartRenderer = () => { assertLineChartWidgetOrThrow(widget); + const { userTimezone } = useUserTimezone(); + const { series, xAxisLabel, @@ -73,6 +77,8 @@ export const GraphWidgetLineChartRenderer = () => { }), ); + const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek(); + const handlePointClick = (point: Point) => { const xValue = (point.data as LineChartDataPoint).x; const rawValue = formattedToRawLookup.get(xValue as string) ?? null; @@ -84,7 +90,8 @@ export const GraphWidgetLineChartRenderer = () => { primaryBucketRawValue: rawValue, }, viewId: indexViewId, - timezone: configuration.timezone ?? undefined, + timezone: userTimezone, + firstDayOfTheWeek: userFirstDayOfTheWeek, }); const url = getAppPath( diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts index fcb6c6704e4..72f9cf17af1 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts @@ -5,6 +5,8 @@ import { getLineChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetL import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; import { transformGroupByDataToLineChartData } from '@/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData'; +import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useMemo } from 'react'; import { type LineChartConfiguration } from '~/generated/graphql'; @@ -50,6 +52,9 @@ export const useGraphLineChartWidgetData = ({ limit, }); + const { userTimezone } = useUserTimezone(); + const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek(); + const transformedData = useMemo( () => transformGroupByDataToLineChartData({ @@ -58,6 +63,8 @@ export const useGraphLineChartWidgetData = ({ objectMetadataItems: objectMetadataItems ?? [], configuration, aggregateOperation, + userTimezone, + firstDayOfTheWeek: userFirstDayOfTheWeek, }), [ groupByData, @@ -65,6 +72,8 @@ export const useGraphLineChartWidgetData = ({ objectMetadataItems, configuration, aggregateOperation, + userTimezone, + userFirstDayOfTheWeek, ], ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx index c1cc50cccd5..f27608116a5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx @@ -6,6 +6,8 @@ import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPi import { assertPieChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/assertPieChartWidget'; import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams'; import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget'; +import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector'; import { lazy, Suspense } from 'react'; @@ -27,6 +29,8 @@ export const GraphWidgetPieChartRenderer = () => { assertPieChartWidgetOrThrow(widget); + const { userTimezone } = useUserTimezone(); + const { data, loading, @@ -52,6 +56,8 @@ export const GraphWidgetPieChartRenderer = () => { }), ); + const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek(); + const handleSliceClick = (datum: PieChartDataItem) => { const rawValue = formattedToRawLookup.get(datum.id) ?? null; @@ -62,7 +68,8 @@ export const GraphWidgetPieChartRenderer = () => { primaryBucketRawValue: rawValue, }, viewId: indexViewId, - timezone: widget.configuration.timezone ?? undefined, + timezone: userTimezone, + firstDayOfTheWeek: userFirstDayOfTheWeek, }); const url = getAppPath( diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts index fc757b2a569..c60781b0113 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts @@ -7,6 +7,8 @@ import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPi import { transformGroupByDataToPieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData'; import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery'; import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useMemo } from 'react'; import { type PieChartConfiguration } from '~/generated/graphql'; @@ -48,6 +50,9 @@ export const useGraphPieChartWidgetData = ({ PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS, }); + const { userTimezone } = useUserTimezone(); + const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek(); + const transformedData = useMemo( () => transformGroupByDataToPieChartData({ @@ -56,6 +61,8 @@ export const useGraphPieChartWidgetData = ({ objectMetadataItems: objectMetadataItems ?? [], configuration, aggregateOperation, + userTimezone, + firstDayOfTheWeek: userFirstDayOfTheWeek, }), [ groupByData, @@ -63,6 +70,8 @@ export const useGraphPieChartWidgetData = ({ objectMetadataItems, configuration, aggregateOperation, + userTimezone, + userFirstDayOfTheWeek, ], ); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/transformGroupByDataToPieChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/transformGroupByDataToPieChartData.test.ts index 562f9bf2329..ee4d4b2a137 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/transformGroupByDataToPieChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/transformGroupByDataToPieChartData.test.ts @@ -1,6 +1,7 @@ import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant'; import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMaximumNumberOfSlices.constant'; import { transformGroupByDataToPieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData'; +import { FirstDayOfTheWeek } from 'twenty-shared/types'; import { AggregateOperations, FieldMetadataType, @@ -20,6 +21,7 @@ const { formatPrimaryDimensionValues } = jest.requireMock( ) as { formatPrimaryDimensionValues: jest.Mock }; describe('transformGroupByDataToPieChartData', () => { + const userTimezone = 'Europe/Paris'; it('keeps null group buckets aligned with their aggregate values', () => { const groupByField = { id: 'group-by-field', @@ -76,6 +78,8 @@ describe('transformGroupByDataToPieChartData', () => { objectMetadataItems, configuration, aggregateOperation: 'COUNT', + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.data).toEqual([ @@ -143,6 +147,8 @@ describe('transformGroupByDataToPieChartData', () => { objectMetadataItems, configuration, aggregateOperation: 'COUNT', + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.showLegend).toBe(false); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts index d23fbad99d4..5dd9143939e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts @@ -12,7 +12,10 @@ import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/bui import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind'; -import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { + type FirstDayOfTheWeek, + type ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; import { type PieChartConfiguration } from '~/generated/graphql'; @@ -22,6 +25,8 @@ type TransformGroupByDataToPieChartDataParams = { objectMetadataItems: ObjectMetadataItem[]; configuration: PieChartConfiguration; aggregateOperation: string; + userTimezone: string; + firstDayOfTheWeek: FirstDayOfTheWeek; }; type TransformGroupByDataToPieChartDataResult = { @@ -44,6 +49,8 @@ export const transformGroupByDataToPieChartData = ({ objectMetadataItems, configuration, aggregateOperation, + userTimezone, + firstDayOfTheWeek, }: TransformGroupByDataToPieChartDataParams): TransformGroupByDataToPieChartDataResult => { if (!isDefined(groupByData)) { return EMPTY_PIE_CHART_RESULT; @@ -100,6 +107,8 @@ export const transformGroupByDataToPieChartData = ({ primaryAxisDateGranularity: dateGranularity, primaryAxisGroupBySubFieldName: configuration.groupBySubFieldName ?? undefined, + userTimezone, + firstDayOfTheWeek, }); const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts index a325f2844d9..c230d5662ab 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery.ts @@ -8,6 +8,7 @@ import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/use import { type GroupByChartConfiguration } from '@/page-layout/widgets/graph/types/GroupByChartConfiguration'; import { generateGroupByQueryVariablesFromBarOrLineChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration'; import { generateGroupByQueryVariablesFromPieChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useQuery } from '@apollo/client'; import { useMemo } from 'react'; import { DEFAULT_NUMBER_OF_GROUPS_LIMIT } from 'twenty-shared/constants'; @@ -34,6 +35,8 @@ export const useGraphWidgetGroupByQuery = ({ configuration, }); + const { userTimezone } = useUserTimezone(); + const { objectMetadataItems } = useObjectMetadataItems(); if (!isDefined(aggregateField)) { @@ -65,6 +68,7 @@ export const useGraphWidgetGroupByQuery = ({ aggregateOperation: aggregateOperation, limit, firstDayOfTheWeek: calendarStartDay, + userTimeZone: userTimezone, }) : generateGroupByQueryVariablesFromBarOrLineChartConfiguration({ objectMetadataItem, @@ -75,6 +79,7 @@ export const useGraphWidgetGroupByQuery = ({ aggregateOperation: aggregateOperation, limit, firstDayOfTheWeek: calendarStartDay, + userTimeZone: userTimezone, }); const variables = { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon.ts index 0383505467a..1fcf5c7f28e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon.ts @@ -1,4 +1,5 @@ import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { computeRecordGqlOperationFilter, isDefined, @@ -35,9 +36,13 @@ export const useGraphWidgetQueryCommon = ({ throw new Error('Aggregate field not found'); } + const { userTimezone } = useUserTimezone(); + const gqlOperationFilter = computeRecordGqlOperationFilter({ fields: objectMetadataItem.fields, - filterValueDependencies: {}, + filterValueDependencies: { + timeZone: userTimezone, + }, recordFilters: configuration.filter?.recordFilters ?? [], recordFilterGroups: configuration.filter?.recordFilterGroups ?? [], }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput.ts index 7fbbc08e330..f881990f236 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/types/BuildChartDrilldownQueryParamsInput.ts @@ -1,5 +1,6 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { type FirstDayOfTheWeek } from 'twenty-shared/types'; import { type BarChartConfiguration, type LineChartConfiguration, @@ -17,4 +18,5 @@ export type BuildChartDrilldownQueryParamsInput = { }; viewId?: string; timezone?: string; + firstDayOfTheWeek: FirstDayOfTheWeek; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/formatDateByGranularity.spec.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/formatDateByGranularity.spec.ts.snap index 6a977695ca9..a5902898bb9 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/formatDateByGranularity.spec.ts.snap +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/formatDateByGranularity.spec.ts.snap @@ -1,21 +1,37 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-01-15T00:00:00.000Z 1`] = `"Q1 2024"`; +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-01-15 1`] = `"Q1 2024"`; -exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-04-15T00:00:00.000Z 1`] = `"Q2 2024"`; +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-02-15 1`] = `"Q1 2024"`; -exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-07-15T00:00:00.000Z 1`] = `"Q3 2024"`; +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-03-15 1`] = `"Q1 2024"`; -exports[`formatDateByGranularity quarter calculations should calculate quarter for QUARTER with date 2024-10-15T00:00:00.000Z 1`] = `"Q4 2024"`; +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-04-15 1`] = `"Q2 2024"`; -exports[`formatDateByGranularity should format date for DAY granularity 1`] = `"Mar 20, 2024"`; +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-05-15 1`] = `"Q2 2024"`; -exports[`formatDateByGranularity should format date for MONTH granularity 1`] = `"March 2024"`; +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-06-15 1`] = `"Q2 2024"`; -exports[`formatDateByGranularity should format date for NONE granularity 1`] = `"3/20/2024"`; +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-07-15 1`] = `"Q3 2024"`; -exports[`formatDateByGranularity should format date for QUARTER granularity 1`] = `"Q1 2024"`; +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-08-15 1`] = `"Q3 2024"`; -exports[`formatDateByGranularity should format date for WEEK granularity 1`] = `"Mar 20, 2024 20 - 26, 2024"`; +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-09-15 1`] = `"Q3 2024"`; -exports[`formatDateByGranularity should format date for YEAR granularity 1`] = `"2024"`; +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-10-15 1`] = `"Q4 2024"`; + +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-11-15 1`] = `"Q4 2024"`; + +exports[`formatDateByGranularity quarter calculations should calculate quarter for 2024-12-15 1`] = `"Q4 2024"`; + +exports[`formatDateByGranularity should format date for DAY granularity for 2024-03-20 1`] = `"Mar 20, 2024"`; + +exports[`formatDateByGranularity should format date for MONTH granularity for 2024-03-20 1`] = `"March 2024"`; + +exports[`formatDateByGranularity should format date for NONE granularity for 2024-03-20 1`] = `"3/20/2024"`; + +exports[`formatDateByGranularity should format date for QUARTER granularity for 2024-03-20 1`] = `"Q1 2024"`; + +exports[`formatDateByGranularity should format date for WEEK granularity for 2024-03-20 1`] = `"Mar 18 - 24, 2024"`; + +exports[`formatDateByGranularity should format date for YEAR granularity for 2024-03-20 1`] = `"2024"`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts.snap index 9163b537dcb..7f2c51b764b 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts.snap +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts.snap @@ -61,6 +61,7 @@ exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Bar Chart { "createdAt": { "granularity": "MONTH", + "timeZone": "Europe/Paris", }, }, ], @@ -136,6 +137,7 @@ exports[`generateGroupByQueryVariablesFromBarOrLineChartConfiguration Line Chart { "createdAt": { "granularity": "MONTH", + "timeZone": "Europe/Paris", }, }, ], diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts.snap index 2ca0f5b3beb..4e4f14e8731 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts.snap +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts.snap @@ -1,15 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configuration should generate variables with single groupBy field 1`] = ` -{ - "groupBy": [ - { - "stage": true, - }, - ], -} -`; - exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configuration should generate variables with composite field 1`] = ` { "groupBy": [ @@ -28,6 +18,7 @@ exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configurat { "createdAt": { "granularity": "MONTH", + "timeZone": "Europe/Paris", }, }, ], @@ -62,3 +53,12 @@ exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configurat } `; +exports[`generateGroupByQueryVariablesFromPieChartConfiguration Basic Configuration should generate variables with single groupBy field 1`] = ` +{ + "groupBy": [ + { + "stage": true, + }, + ], +} +`; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildDateFilterForDayGranularity.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildDateFilterForDayGranularity.test.ts index af96a8e3dc1..fd4af1bd283 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildDateFilterForDayGranularity.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildDateFilterForDayGranularity.test.ts @@ -1,3 +1,4 @@ +import { Temporal } from 'temporal-polyfill'; import { ViewFilterOperand } from 'twenty-shared/types'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { buildDateFilterForDayGranularity } from '../buildDateFilterForDayGranularity'; @@ -5,7 +6,10 @@ import { buildDateFilterForDayGranularity } from '../buildDateFilterForDayGranul describe('buildDateFilterForDayGranularity', () => { describe('DATE field type', () => { it('should return single IS filter with plain date for DATE field', () => { - const date = new Date('2024-03-15T10:00:00Z'); + const date = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({ + timeZone: 'Europe/Paris', + }); + const result = buildDateFilterForDayGranularity( date, FieldMetadataType.DATE, @@ -15,13 +19,16 @@ describe('buildDateFilterForDayGranularity', () => { expect(result).toHaveLength(1); expect(result[0].fieldName).toBe('createdAt'); expect(result[0].operand).toBe(ViewFilterOperand.IS); - expect(result[0].value).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(result[0].value).toMatch('2024-03-15'); }); }); describe('DATE_TIME field type', () => { it('should return IS_AFTER and IS_BEFORE filters for DATE_TIME field', () => { - const date = new Date('2024-03-15T10:00:00Z'); + const date = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({ + timeZone: 'Europe/Paris', + }); + const result = buildDateFilterForDayGranularity( date, FieldMetadataType.DATE_TIME, @@ -31,30 +38,53 @@ describe('buildDateFilterForDayGranularity', () => { expect(result).toHaveLength(2); expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER); expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE); - expect(result[0].value).toMatch(/^\d{4}-\d{2}-\d{2}T/); - expect(result[1].value).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(result[0].value).toMatch('2024-03-15T00:00:00+01:00'); + expect(result[1].value).toMatch('2024-03-16T00:00:00+01:00'); }); - it('should apply timezone for DATE_TIME field when provided', () => { - const date = new Date('2024-03-15T10:00:00Z'); - const timezone = 'America/New_York'; + it('should handle extreme timezone for DATE_TIME - Auckland GMT+13', () => { + const date = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({ + timeZone: 'Pacific/Auckland', + }); const result = buildDateFilterForDayGranularity( date, FieldMetadataType.DATE_TIME, 'createdAt', - timezone, ); expect(result).toHaveLength(2); expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER); expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE); + expect(result[0].value).toMatch('2024-03-15T00:00:00+13:00'); + expect(result[1].value).toMatch('2024-03-16T00:00:00+13:00'); + }); + + it('should handle extreme timezone for DATE_TIME - Samoa GMT-11', () => { + const date = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({ + timeZone: 'Pacific/Samoa', + }); + + const result = buildDateFilterForDayGranularity( + date, + FieldMetadataType.DATE_TIME, + 'createdAt', + ); + + expect(result).toHaveLength(2); + expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER); + expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE); + expect(result[0].value).toMatch('2024-03-15T00:00:00-11:00'); + expect(result[1].value).toMatch('2024-03-16T00:00:00-11:00'); }); }); describe('unsupported field types', () => { it('should return empty array for non-date field types', () => { - const date = new Date('2024-03-15T10:00:00Z'); + const date = Temporal.Instant.from( + '2024-03-15T10:00:00Z', + ).toZonedDateTimeISO('Europe/Paris'); + const result = buildDateFilterForDayGranularity( date, FieldMetadataType.TEXT as FieldMetadataType.DATE, @@ -67,7 +97,10 @@ describe('buildDateFilterForDayGranularity', () => { describe('field name handling', () => { it('should use provided fieldName in filters', () => { - const date = new Date('2024-03-15'); + const date = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({ + timeZone: 'Europe/Paris', + }); + const result = buildDateFilterForDayGranularity( date, FieldMetadataType.DATE, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildDateRangeFiltersForGranularity.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildDateRangeFiltersForGranularity.test.ts index e7cac93549c..c76cb0efeb1 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildDateRangeFiltersForGranularity.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildDateRangeFiltersForGranularity.test.ts @@ -1,3 +1,4 @@ +import { Temporal } from 'temporal-polyfill'; import { ObjectRecordGroupByDateGranularity, ViewFilterOperand, @@ -5,10 +6,13 @@ import { import { FieldMetadataType } from '~/generated-metadata/graphql'; import { buildDateRangeFiltersForGranularity } from '../buildDateRangeFiltersForGranularity'; +const testDate = Temporal.PlainDate.from('2024-03-15').toZonedDateTime({ + timeZone: 'Europe/Paris', +}); + describe('buildDateRangeFiltersForGranularity', () => { describe('WEEK granularity', () => { it('should return IS_AFTER and IS_BEFORE filters for DATE field', () => { - const testDate = new Date('2024-03-15'); const result = buildDateRangeFiltersForGranularity( testDate, ObjectRecordGroupByDateGranularity.WEEK, @@ -22,7 +26,6 @@ describe('buildDateRangeFiltersForGranularity', () => { }); it('should return ISO strings for DATE_TIME field', () => { - const testDate = new Date('2024-03-15T10:00:00Z'); const result = buildDateRangeFiltersForGranularity( testDate, ObjectRecordGroupByDateGranularity.WEEK, @@ -38,7 +41,6 @@ describe('buildDateRangeFiltersForGranularity', () => { describe('MONTH granularity', () => { it('should return IS_AFTER and IS_BEFORE filters for DATE field', () => { - const testDate = new Date('2024-03-15'); const result = buildDateRangeFiltersForGranularity( testDate, ObjectRecordGroupByDateGranularity.MONTH, @@ -52,7 +54,6 @@ describe('buildDateRangeFiltersForGranularity', () => { }); it('should return ISO strings for DATE_TIME field without timezone', () => { - const testDate = new Date('2024-03-15T10:00:00Z'); const result = buildDateRangeFiltersForGranularity( testDate, ObjectRecordGroupByDateGranularity.MONTH, @@ -66,31 +67,18 @@ describe('buildDateRangeFiltersForGranularity', () => { expect(result[0].value).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(result[1].value).toMatch(/^\d{4}-\d{2}-\d{2}T/); }); - - it('should apply timezone for DATE_TIME field when provided', () => { - const testDate = new Date('2024-03-15T10:00:00Z'); - const timezone = 'America/New_York'; - - const result = buildDateRangeFiltersForGranularity( - testDate, - ObjectRecordGroupByDateGranularity.MONTH, - FieldMetadataType.DATE_TIME, - 'createdAt', - timezone, - ); - - expect(result).toHaveLength(2); - expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER); - expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE); - }); }); describe('QUARTER granularity', () => { it('should return filters for Q1 date', () => { - const testDate = new Date('2024-02-15'); + const firstQuarterDateTime = Temporal.PlainDate.from( + '2024-02-15', + ).toZonedDateTime({ + timeZone: 'Europe/Paris', + }); const result = buildDateRangeFiltersForGranularity( - testDate, + firstQuarterDateTime, ObjectRecordGroupByDateGranularity.QUARTER, FieldMetadataType.DATE, 'createdAt', @@ -102,8 +90,6 @@ describe('buildDateRangeFiltersForGranularity', () => { }); it('should return ISO strings for DATE_TIME field', () => { - const testDate = new Date('2024-02-15'); - const result = buildDateRangeFiltersForGranularity( testDate, ObjectRecordGroupByDateGranularity.QUARTER, @@ -121,10 +107,14 @@ describe('buildDateRangeFiltersForGranularity', () => { describe('YEAR granularity', () => { it('should return start and end of year for DATE field', () => { - const testDate = new Date('2024-06-15'); + const midYearDateTime = Temporal.PlainDate.from( + '2024-06-15', + ).toZonedDateTime({ + timeZone: 'Europe/Paris', + }); const result = buildDateRangeFiltersForGranularity( - testDate, + midYearDateTime, ObjectRecordGroupByDateGranularity.YEAR, FieldMetadataType.DATE, 'createdAt', @@ -136,10 +126,14 @@ describe('buildDateRangeFiltersForGranularity', () => { }); it('should return ISO strings for DATE_TIME field', () => { - const testDate = new Date('2024-06-15T10:00:00Z'); + const midYearDateTime = Temporal.PlainDate.from( + '2024-06-15', + ).toZonedDateTime({ + timeZone: 'Europe/Paris', + }); const result = buildDateRangeFiltersForGranularity( - testDate, + midYearDateTime, ObjectRecordGroupByDateGranularity.YEAR, FieldMetadataType.DATE_TIME, 'createdAt', @@ -147,31 +141,12 @@ describe('buildDateRangeFiltersForGranularity', () => { expect(result).toHaveLength(2); expect(result[0].value).toMatch(/^2024-01-01T/); - expect(result[1].value).toMatch(/^2024-12-31T/); - }); - - it('should apply timezone for DATE_TIME field', () => { - const testDate = new Date('2024-06-15T10:00:00Z'); - const timezone = 'Asia/Tokyo'; - - const result = buildDateRangeFiltersForGranularity( - testDate, - ObjectRecordGroupByDateGranularity.YEAR, - FieldMetadataType.DATE_TIME, - 'createdAt', - timezone, - ); - - expect(result).toHaveLength(2); - expect(result[0].operand).toBe(ViewFilterOperand.IS_AFTER); - expect(result[1].operand).toBe(ViewFilterOperand.IS_BEFORE); + expect(result[1].value).toMatch(/^2025-01-01T/); }); }); describe('Field name handling', () => { it('should use provided fieldName in filters', () => { - const testDate = new Date('2024-03-15'); - const result = buildDateRangeFiltersForGranularity( testDate, ObjectRecordGroupByDateGranularity.MONTH, @@ -184,8 +159,6 @@ describe('buildDateRangeFiltersForGranularity', () => { }); it('should handle subField-style fieldName', () => { - const testDate = new Date('2024-03-15'); - const result = buildDateRangeFiltersForGranularity( testDate, ObjectRecordGroupByDateGranularity.MONTH, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildGroupByFieldObject.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildGroupByFieldObject.test.ts index 8bcddc7ae00..91d5204946c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildGroupByFieldObject.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/buildGroupByFieldObject.test.ts @@ -1,8 +1,10 @@ import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject'; -import { CalendarStartDay } from 'twenty-shared'; +import { CalendarStartDay } from 'twenty-shared/constants'; import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +const userTimezone = 'Europe/Paris'; + describe('buildGroupByFieldObject', () => { it('should return field with Id suffix for relation fields without subFieldName', () => { const field = { @@ -53,11 +55,15 @@ describe('buildGroupByFieldObject', () => { subFieldName: 'createdAt', dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, isNestedDateField: true, + timeZone: userTimezone, }); expect(result).toEqual({ company: { - createdAt: { granularity: ObjectRecordGroupByDateGranularity.MONTH }, + createdAt: { + granularity: ObjectRecordGroupByDateGranularity.MONTH, + timeZone: 'Europe/Paris', + }, }, }); }); @@ -97,11 +103,12 @@ describe('buildGroupByFieldObject', () => { type: FieldMetadataType.DATE, } as any; - const result = buildGroupByFieldObject({ field }); + const result = buildGroupByFieldObject({ field, timeZone: 'Europe/Paris' }); expect(result).toEqual({ createdAt: { granularity: ObjectRecordGroupByDateGranularity.DAY, + timeZone: 'Europe/Paris', }, }); }); @@ -112,11 +119,12 @@ describe('buildGroupByFieldObject', () => { type: FieldMetadataType.DATE_TIME, } as any; - const result = buildGroupByFieldObject({ field }); + const result = buildGroupByFieldObject({ field, timeZone: 'Europe/Paris' }); expect(result).toEqual({ updatedAt: { granularity: ObjectRecordGroupByDateGranularity.DAY, + timeZone: 'Europe/Paris', }, }); }); @@ -130,11 +138,13 @@ describe('buildGroupByFieldObject', () => { const result = buildGroupByFieldObject({ field, dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, + timeZone: 'Europe/Paris', }); expect(result).toEqual({ createdAt: { granularity: ObjectRecordGroupByDateGranularity.MONTH, + timeZone: 'Europe/Paris', }, }); }); @@ -160,12 +170,14 @@ describe('buildGroupByFieldObject', () => { field, dateGranularity: ObjectRecordGroupByDateGranularity.WEEK, firstDayOfTheWeek: CalendarStartDay.MONDAY, + timeZone: 'Europe/Paris', }); expect(result).toEqual({ createdAt: { granularity: ObjectRecordGroupByDateGranularity.WEEK, weekStartDay: 'MONDAY', + timeZone: 'Europe/Paris', }, }); }); @@ -180,12 +192,14 @@ describe('buildGroupByFieldObject', () => { field, dateGranularity: ObjectRecordGroupByDateGranularity.WEEK, firstDayOfTheWeek: CalendarStartDay.SUNDAY, + timeZone: userTimezone, }); expect(result).toEqual({ createdAt: { granularity: ObjectRecordGroupByDateGranularity.WEEK, weekStartDay: 'SUNDAY', + timeZone: userTimezone, }, }); }); @@ -200,11 +214,13 @@ describe('buildGroupByFieldObject', () => { field, dateGranularity: ObjectRecordGroupByDateGranularity.WEEK, firstDayOfTheWeek: CalendarStartDay.SYSTEM, + timeZone: userTimezone, }); expect(result).toEqual({ createdAt: { granularity: ObjectRecordGroupByDateGranularity.WEEK, + timeZone: userTimezone, }, }); }); @@ -219,11 +235,13 @@ describe('buildGroupByFieldObject', () => { field, dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, firstDayOfTheWeek: CalendarStartDay.MONDAY, + timeZone: userTimezone, }); expect(result).toEqual({ createdAt: { granularity: ObjectRecordGroupByDateGranularity.MONTH, + timeZone: userTimezone, }, }); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/calculateQuarterDateRange.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/calculateQuarterDateRange.test.ts deleted file mode 100644 index e7347e27e7a..00000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/calculateQuarterDateRange.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { calculateQuarterDateRange } from '../calculateQuarterDateRange'; - -describe('calculateQuarterDateRange', () => { - describe('Q1 (January - March)', () => { - it('should return Q1 range for January date', () => { - const date = new Date('2024-01-15'); - const result = calculateQuarterDateRange(date); - - expect(result.rangeStartDate.getFullYear()).toBe(2024); - expect(result.rangeStartDate.getMonth()).toBe(0); - expect(result.rangeStartDate.getDate()).toBe(1); - - expect(result.rangeEndDate.getFullYear()).toBe(2024); - expect(result.rangeEndDate.getMonth()).toBe(2); - expect(result.rangeEndDate.getDate()).toBe(31); - }); - - it('should return Q1 range for March date', () => { - const date = new Date('2024-03-20'); - const result = calculateQuarterDateRange(date); - - expect(result.rangeStartDate.getMonth()).toBe(0); - expect(result.rangeEndDate.getMonth()).toBe(2); - }); - }); - - describe('Q2 (April - June)', () => { - it('should return Q2 range for April date', () => { - const date = new Date('2024-04-15'); - const result = calculateQuarterDateRange(date); - - expect(result.rangeStartDate.getMonth()).toBe(3); - expect(result.rangeStartDate.getDate()).toBe(1); - expect(result.rangeEndDate.getMonth()).toBe(5); - expect(result.rangeEndDate.getDate()).toBe(30); - }); - }); - - describe('Q3 (July - September)', () => { - it('should return Q3 range for August date', () => { - const date = new Date('2024-08-15'); - const result = calculateQuarterDateRange(date); - - expect(result.rangeStartDate.getMonth()).toBe(6); - expect(result.rangeStartDate.getDate()).toBe(1); - expect(result.rangeEndDate.getMonth()).toBe(8); - expect(result.rangeEndDate.getDate()).toBe(30); - }); - }); - - describe('Q4 (October - December)', () => { - it('should return Q4 range for November date', () => { - const date = new Date('2024-11-15'); - const result = calculateQuarterDateRange(date); - - expect(result.rangeStartDate.getMonth()).toBe(9); - expect(result.rangeStartDate.getDate()).toBe(1); - expect(result.rangeEndDate.getMonth()).toBe(11); - expect(result.rangeEndDate.getDate()).toBe(31); - }); - }); - - describe('year boundary', () => { - it('should handle year correctly for Q1', () => { - const date = new Date('2025-02-15'); - const result = calculateQuarterDateRange(date); - - expect(result.rangeStartDate.getFullYear()).toBe(2025); - expect(result.rangeEndDate.getFullYear()).toBe(2025); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatDateByGranularity.spec.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatDateByGranularity.spec.ts index d36905e09ca..153ed0da46c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatDateByGranularity.spec.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatDateByGranularity.spec.ts @@ -1,28 +1,13 @@ -import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { Temporal } from 'temporal-polyfill'; +import { + FirstDayOfTheWeek, + ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; import { formatDateByGranularity } from '../formatDateByGranularity'; describe('formatDateByGranularity', () => { - const testDate = new Date('2024-03-20T12:00:00Z'); - - beforeAll(() => { - // Mock toLocaleDateString to avoid timezone/locale issues - jest - .spyOn(Date.prototype, 'toLocaleDateString') - .mockImplementation((_locales, options) => { - if (options?.weekday === 'long') return 'Wednesday'; - if (options?.month === 'long' && !isDefined(options?.year)) - return 'March'; - if (options?.month === 'long' && isDefined(options?.year)) - return 'March 2024'; - if (options?.month === 'short') return 'Mar 20, 2024'; - return '3/20/2024'; - }); - }); - - afterAll(() => { - jest.restoreAllMocks(); - }); + const testDate = Temporal.PlainDate.from('2024-03-20'); + const userTimezone = 'Europe/Paris'; const testCases: { granularity: @@ -42,54 +27,49 @@ describe('formatDateByGranularity', () => { ]; it.each(testCases)( - 'should format date for $granularity granularity', + `should format date for $granularity granularity for ${testDate.toString()}`, ({ granularity }) => { - expect(formatDateByGranularity(testDate, granularity)).toMatchSnapshot(); + expect( + formatDateByGranularity( + testDate, + granularity, + userTimezone, + FirstDayOfTheWeek.MONDAY, + ), + ).toMatchSnapshot(); }, ); describe('week calculations', () => { - beforeEach(() => { - jest.restoreAllMocks(); - }); - - afterEach(() => { - jest - .spyOn(Date.prototype, 'toLocaleDateString') - .mockImplementation((_locales, options) => { - if (options?.weekday === 'long') return 'Wednesday'; - if (options?.month === 'long' && !isDefined(options?.year)) - return 'March'; - if (options?.month === 'long' && isDefined(options?.year)) - return 'March 2024'; - if (options?.month === 'short') return 'Mar 20, 2024'; - return '3/20/2024'; - }); - }); - it('should format week within same month', () => { - const date = new Date('2024-05-06'); + const date = Temporal.PlainDate.from('2024-05-06'); const result = formatDateByGranularity( date, ObjectRecordGroupByDateGranularity.WEEK, + userTimezone, + FirstDayOfTheWeek.MONDAY, ); expect(result).toBe('May 6 - 12, 2024'); }); it('should format week crossing months', () => { - const date = new Date('2024-05-27'); + const date = Temporal.PlainDate.from('2024-05-27'); const result = formatDateByGranularity( date, ObjectRecordGroupByDateGranularity.WEEK, + userTimezone, + FirstDayOfTheWeek.MONDAY, ); expect(result).toBe('May 27 - Jun 2, 2024'); }); it('should format week crossing years', () => { - const date = new Date('2024-12-30'); + const date = Temporal.PlainDate.from('2024-12-30'); const result = formatDateByGranularity( date, ObjectRecordGroupByDateGranularity.WEEK, + userTimezone, + FirstDayOfTheWeek.MONDAY, ); expect(result).toBe('Dec 30, 2024 - Jan 5, 2025'); }); @@ -97,31 +77,70 @@ describe('formatDateByGranularity', () => { describe('quarter calculations', () => { const quarterTestCases: { - date: Date; + dayString: string; granularity: ObjectRecordGroupByDateGranularity.QUARTER; }[] = [ { - date: new Date('2024-01-15'), + dayString: '2024-01-15', granularity: ObjectRecordGroupByDateGranularity.QUARTER, }, { - date: new Date('2024-04-15'), + dayString: '2024-02-15', granularity: ObjectRecordGroupByDateGranularity.QUARTER, }, { - date: new Date('2024-07-15'), + dayString: '2024-03-15', granularity: ObjectRecordGroupByDateGranularity.QUARTER, }, { - date: new Date('2024-10-15'), + dayString: '2024-04-15', + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + { + dayString: '2024-05-15', + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + { + dayString: '2024-06-15', + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + { + dayString: '2024-07-15', + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + { + dayString: '2024-08-15', + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + { + dayString: '2024-09-15', + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + { + dayString: '2024-10-15', + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + { + dayString: '2024-11-15', + granularity: ObjectRecordGroupByDateGranularity.QUARTER, + }, + { + dayString: '2024-12-15', granularity: ObjectRecordGroupByDateGranularity.QUARTER, }, ]; it.each(quarterTestCases)( - 'should calculate quarter for $granularity with date $date', - ({ date, granularity }) => { - expect(formatDateByGranularity(date, granularity)).toMatchSnapshot(); + 'should calculate quarter for $dayString', + ({ dayString, granularity }) => { + expect( + formatDateByGranularity( + Temporal.PlainDate.from(dayString), + granularity, + 'Europe/Paris', + FirstDayOfTheWeek.MONDAY, + ), + ).toMatchSnapshot(); }, ); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatPrimaryDimensionValues.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatPrimaryDimensionValues.test.ts index c790fc4c304..cff0b2e656f 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatPrimaryDimensionValues.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/formatPrimaryDimensionValues.test.ts @@ -1,6 +1,7 @@ import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; import { FieldMetadataType, + FirstDayOfTheWeek, ObjectRecordGroupByDateGranularity, } from 'twenty-shared/types'; @@ -14,6 +15,8 @@ const { formatDimensionValue } = jest.requireMock( '@/page-layout/widgets/graph/utils/formatDimensionValue', ) as { formatDimensionValue: jest.Mock }; +const userTimezone = 'Europe/Paris'; + describe('formatPrimaryDimensionValues', () => { it('includes buckets where the primary dimension value is null', () => { const result = formatPrimaryDimensionValues({ @@ -25,6 +28,8 @@ describe('formatPrimaryDimensionValues', () => { name: 'status', type: FieldMetadataType.TEXT, } as any, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result).toHaveLength(2); @@ -46,13 +51,20 @@ describe('formatPrimaryDimensionValues', () => { } as any, primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.MONTH, primaryAxisGroupBySubFieldName: 'createdAt', + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(formatDimensionValue).toHaveBeenCalledWith({ value: '2024-01-15T00:00:00.000Z', - fieldMetadata: expect.objectContaining({ name: 'createdAt' }), + fieldMetadata: expect.objectContaining({ + name: 'createdAt', + type: FieldMetadataType.DATE_TIME, + }), dateGranularity: ObjectRecordGroupByDateGranularity.MONTH, subFieldName: 'createdAt', + userTimezone: 'Europe/Paris', + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); }); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts index 8984aed9cc0..5f2643553ca 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.test.ts @@ -124,6 +124,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => { primaryAxisGroupBySubFieldName: null, primaryAxisDateGranularity: 'MONTH' as any, }), + userTimeZone: 'Europe/Paris', }); expect(result).toMatchSnapshot(); @@ -199,11 +200,15 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => { primaryAxisGroupByFieldMetadataId: relationField.id, primaryAxisGroupBySubFieldName: 'createdAt', }), + userTimeZone: 'Europe/Paris', }); expect(result.groupBy[0]).toEqual({ company: { - createdAt: { granularity: ObjectRecordGroupByDateGranularity.DAY }, + createdAt: { + granularity: ObjectRecordGroupByDateGranularity.DAY, + timeZone: 'Europe/Paris', + }, }, }); }); @@ -245,11 +250,15 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => { primaryAxisGroupByFieldMetadataId: relationField.id, primaryAxisGroupBySubFieldName: 'createdAt', }), + userTimeZone: 'Europe/Paris', }); expect(result.groupBy[0]).toEqual({ company: { - createdAt: { granularity: ObjectRecordGroupByDateGranularity.DAY }, + createdAt: { + granularity: ObjectRecordGroupByDateGranularity.DAY, + timeZone: 'Europe/Paris', + }, }, }); }); @@ -299,6 +308,7 @@ describe('generateGroupByQueryVariablesFromBarOrLineChartConfiguration', () => { primaryAxisGroupBySubFieldName: null, primaryAxisDateGranularity: 'MONTH' as any, }), + userTimeZone: 'Europe/Paris', }); expect(result).toMatchSnapshot(); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts index a1ed8e63e80..6233e00fe98 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/generateGroupByQueryVariablesFromPieChartConfiguration.test.ts @@ -1,4 +1,5 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; import { AggregateOperations, FieldMetadataType, @@ -6,7 +7,6 @@ import { GraphType, type PieChartConfiguration, } from '~/generated-metadata/graphql'; -import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; import { generateGroupByQueryVariablesFromPieChartConfiguration } from '../generateGroupByQueryVariablesFromPieChartConfiguration'; describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => { @@ -86,6 +86,7 @@ describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => { groupBySubFieldName: null, dateGranularity: 'MONTH' as any, }), + userTimeZone: 'Europe/Paris', }); expect(result).toMatchSnapshot(); @@ -157,11 +158,15 @@ describe('generateGroupByQueryVariablesFromPieChartConfiguration', () => { groupByFieldMetadataId: relationField.id, groupBySubFieldName: 'createdAt', }), + userTimeZone: 'Europe/Paris', }); expect(result.groupBy[0]).toEqual({ company: { - createdAt: { granularity: ObjectRecordGroupByDateGranularity.DAY }, + createdAt: { + granularity: ObjectRecordGroupByDateGranularity.DAY, + timeZone: 'Europe/Paris', + }, }, }); }); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts index 97411526c3e..2e6e4c18ace 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts @@ -1,6 +1,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { FirstDayOfTheWeek } from 'twenty-shared/types'; import { AggregateOperations, FieldMetadataType, @@ -10,6 +11,8 @@ import { import { transformOneDimensionalGroupByToLineChartData } from '../transformOneDimensionalGroupByToLineChartData'; describe('transformOneDimensionalGroupByToLineChartData', () => { + const userTimezone = 'Europe/Paris'; + const mockAggregateField: FieldMetadataItem = { id: 'amount-field', name: 'amount', @@ -76,6 +79,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => { aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.series).toHaveLength(1); @@ -116,6 +121,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => { aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.series[0].data).toEqual([ @@ -130,15 +137,15 @@ describe('transformOneDimensionalGroupByToLineChartData', () => { it('should transform date-based groupBy results', () => { const rawResults: GroupByRawResult[] = [ { - groupByDimensionValues: ['2024-01'], + groupByDimensionValues: ['2024-01-01'], sumAmount: 50000, }, { - groupByDimensionValues: ['2024-02'], + groupByDimensionValues: ['2024-02-01'], sumAmount: 75000, }, { - groupByDimensionValues: ['2024-03'], + groupByDimensionValues: ['2024-03-01'], sumAmount: 60000, }, ]; @@ -154,6 +161,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => { aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.series).toHaveLength(1); @@ -173,6 +182,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => { aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.series).toHaveLength(1); @@ -194,6 +205,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => { aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.series[0].color).toBeDefined(); @@ -222,6 +235,8 @@ describe('transformOneDimensionalGroupByToLineChartData', () => { aggregateOperation: '_count', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.series[0].data).toEqual([ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts index 26cffd9a081..7c428b1247e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts @@ -1,6 +1,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { FirstDayOfTheWeek } from 'twenty-shared/types'; import { AggregateOperations, FieldMetadataType, @@ -10,6 +11,8 @@ import { import { transformTwoDimensionalGroupByToLineChartData } from '../transformTwoDimensionalGroupByToLineChartData'; describe('transformTwoDimensionalGroupByToLineChartData', () => { + const userTimezone = 'Europe/Paris'; + const mockAggregateField: FieldMetadataItem = { id: 'amount-field', name: 'amount', @@ -56,27 +59,27 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { it('should create multiple series from 2D groupBy results', () => { const rawResults: GroupByRawResult[] = [ { - groupByDimensionValues: ['2024-01', 'Qualification'], + groupByDimensionValues: ['2024-01-01', 'Qualification'], sumAmount: 50000, }, { - groupByDimensionValues: ['2024-01', 'Proposal'], + groupByDimensionValues: ['2024-01-01', 'Proposal'], sumAmount: 75000, }, { - groupByDimensionValues: ['2024-02', 'Qualification'], + groupByDimensionValues: ['2024-02-01', 'Qualification'], sumAmount: 60000, }, { - groupByDimensionValues: ['2024-02', 'Proposal'], + groupByDimensionValues: ['2024-02-01', 'Proposal'], sumAmount: 90000, }, { - groupByDimensionValues: ['2024-03', 'Qualification'], + groupByDimensionValues: ['2024-03-01', 'Qualification'], sumAmount: 55000, }, { - groupByDimensionValues: ['2024-03', 'Proposal'], + groupByDimensionValues: ['2024-03-01', 'Proposal'], sumAmount: 80000, }, ]; @@ -90,6 +93,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.series).toHaveLength(2); @@ -119,15 +124,15 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { it('should preserve backend ordering of data points', () => { const rawResults: GroupByRawResult[] = [ { - groupByDimensionValues: ['2024-01', 'Stage A'], + groupByDimensionValues: ['2024-01-01', 'Stage A'], sumAmount: 100, }, { - groupByDimensionValues: ['2024-02', 'Stage A'], + groupByDimensionValues: ['2024-02-01', 'Stage A'], sumAmount: 200, }, { - groupByDimensionValues: ['2024-03', 'Stage A'], + groupByDimensionValues: ['2024-03-01', 'Stage A'], sumAmount: 300, }, ]; @@ -141,6 +146,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); const series = result.series[0]; @@ -154,19 +161,19 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { it('should normalize sparse data (all series share same X values, with 0 for missing)', () => { const rawResults: GroupByRawResult[] = [ { - groupByDimensionValues: ['2024-01', 'Stage A'], + groupByDimensionValues: ['2024-01-01', 'Stage A'], sumAmount: 100, }, { - groupByDimensionValues: ['2024-02', 'Stage A'], + groupByDimensionValues: ['2024-02-01', 'Stage A'], sumAmount: 200, }, { - groupByDimensionValues: ['2024-01', 'Stage B'], + groupByDimensionValues: ['2024-01-01', 'Stage B'], sumAmount: 150, }, { - groupByDimensionValues: ['2024-03', 'Stage B'], + groupByDimensionValues: ['2024-03-01', 'Stage B'], sumAmount: 250, }, ]; @@ -180,6 +187,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); const stageA = result.series.find((s) => s.id === 'Stage A'); @@ -200,15 +209,15 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { it('should filter out null aggregate values', () => { const rawResults: GroupByRawResult[] = [ { - groupByDimensionValues: ['2024-01', 'Stage A'], + groupByDimensionValues: ['2024-01-01', 'Stage A'], sumAmount: 100, }, { - groupByDimensionValues: ['2024-02', 'Stage A'], + groupByDimensionValues: ['2024-02-01', 'Stage A'], sumAmount: null, }, { - groupByDimensionValues: ['2024-03', 'Stage A'], + groupByDimensionValues: ['2024-03-01', 'Stage A'], sumAmount: 200, }, ]; @@ -222,6 +231,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.series[0].data).toHaveLength(2); @@ -242,6 +253,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.series).toEqual([]); @@ -251,11 +264,11 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { it('should skip results with missing dimension values', () => { const rawResults: GroupByRawResult[] = [ { - groupByDimensionValues: ['2024-01'], + groupByDimensionValues: ['2024-01-01'], sumAmount: 100, }, { - groupByDimensionValues: ['2024-02', 'Stage A'], + groupByDimensionValues: ['2024-02-01', 'Stage A'], sumAmount: 200, }, ]; @@ -269,6 +282,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { aggregateOperation: 'sumAmount', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.series).toHaveLength(1); @@ -280,11 +295,11 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { it('should handle COUNT operation', () => { const rawResults: GroupByRawResult[] = [ { - groupByDimensionValues: ['2024-01', 'Stage A'], + groupByDimensionValues: ['2024-01-01', 'Stage A'], _count: 5, }, { - groupByDimensionValues: ['2024-02', 'Stage A'], + groupByDimensionValues: ['2024-02-01', 'Stage A'], _count: 10, }, ]; @@ -300,6 +315,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => { aggregateOperation: '_count', objectMetadataItem: mockObjectMetadataItem, primaryAxisSubFieldName: null, + userTimezone, + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }); expect(result.series[0].data.map((d) => d.y)).toEqual([5, 10]); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams.ts index c31a073ba97..d0b3c64f429 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams.ts @@ -11,6 +11,7 @@ export const buildChartDrilldownQueryParams = ({ clickedData, viewId, timezone, + firstDayOfTheWeek, }: BuildChartDrilldownQueryParamsInput): URLSearchParams => { const drilldownQueryParams = new URLSearchParams(); @@ -42,6 +43,7 @@ export const buildChartDrilldownQueryParams = ({ dateGranularity, subFieldName: groupBySubFieldName, timezone, + firstDayOfTheWeek, }); primaryFilters.forEach((filter) => { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildDateFilterForDayGranularity.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildDateFilterForDayGranularity.ts index f2f8a65d450..39ec23cc8a5 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildDateFilterForDayGranularity.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildDateFilterForDayGranularity.ts @@ -1,10 +1,6 @@ -import { TZDate } from '@date-fns/tz'; +import { type Temporal } from 'temporal-polyfill'; import { ViewFilterOperand } from 'twenty-shared/types'; -import { - getEndUnitOfDateTime, - getPlainDateFromDate, - getStartUnitOfDateTime, -} from 'twenty-shared/utils'; + import { FieldMetadataType } from '~/generated-metadata/graphql'; type ChartFilter = { @@ -14,39 +10,34 @@ type ChartFilter = { }; export const buildDateFilterForDayGranularity = ( - parsedBucketDate: Date, + parsedDateTime: Temporal.ZonedDateTime, fieldType: FieldMetadataType, fieldName: string, - timezone?: string, ): ChartFilter[] => { if (fieldType === FieldMetadataType.DATE) { return [ { fieldName, operand: ViewFilterOperand.IS, - value: getPlainDateFromDate(parsedBucketDate), + value: parsedDateTime.toPlainDate().toString(), }, ]; } if (fieldType === FieldMetadataType.DATE_TIME) { - const dateInTimezone = timezone - ? new TZDate(parsedBucketDate, timezone) - : parsedBucketDate; - - const startOfDayDate = getStartUnitOfDateTime(dateInTimezone, 'DAY'); - const endOfDayDate = getEndUnitOfDateTime(dateInTimezone, 'DAY'); + const startOfDay = parsedDateTime.startOfDay(); + const startOfNextDay = startOfDay.add({ days: 1 }); return [ { fieldName, operand: ViewFilterOperand.IS_AFTER, - value: startOfDayDate.toISOString(), + value: startOfDay.toString({ timeZoneName: 'never' }), }, { fieldName, operand: ViewFilterOperand.IS_BEFORE, - value: endOfDayDate.toISOString(), + value: startOfNextDay.toString({ timeZoneName: 'never' }), }, ]; } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildDateRangeFiltersForGranularity.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildDateRangeFiltersForGranularity.ts index 87cacea19f2..29ca8d8cc77 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildDateRangeFiltersForGranularity.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildDateRangeFiltersForGranularity.ts @@ -1,15 +1,13 @@ +import { buildDateFilterForDayGranularity } from '@/page-layout/widgets/graph/utils/buildDateFilterForDayGranularity'; import { buildFiltersFromDateRange } from '@/page-layout/widgets/graph/utils/buildFiltersFromDateRange'; -import { calculateQuarterDateRange } from '@/page-layout/widgets/graph/utils/calculateQuarterDateRange'; -import { TZDate } from '@date-fns/tz'; +import { type Temporal } from 'temporal-polyfill'; import { + type FirstDayOfTheWeek, ObjectRecordGroupByDateGranularity, type ViewFilterOperand, } from 'twenty-shared/types'; -import { - getEndUnitOfDateTime, - getStartUnitOfDateTime, -} from 'twenty-shared/utils'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { getNextPeriodStart, getPeriodStart } from 'twenty-shared/utils'; +import { type FieldMetadataType } from '~/generated-metadata/graphql'; export type RangeChartFilter = { fieldName: string; @@ -18,50 +16,51 @@ export type RangeChartFilter = { }; export const buildDateRangeFiltersForGranularity = ( - parsedBucketDate: Date, + referenceDayWithTimeZone: Temporal.ZonedDateTime, dateGranularity: ObjectRecordGroupByDateGranularity, fieldType: FieldMetadataType, fieldName: string, - timezone?: string, + firstDayOfTheWeek?: FirstDayOfTheWeek, ): RangeChartFilter[] => { - const dateInTimezone = - fieldType === FieldMetadataType.DATE_TIME && timezone - ? new TZDate(parsedBucketDate, timezone) - : parsedBucketDate; - - if (dateGranularity === ObjectRecordGroupByDateGranularity.WEEK) { - return buildFiltersFromDateRange( - getStartUnitOfDateTime(dateInTimezone, 'WEEK'), - getEndUnitOfDateTime(dateInTimezone, 'WEEK'), - fieldType, - fieldName, - ); + switch (dateGranularity) { + case ObjectRecordGroupByDateGranularity.WEEK: { + return buildFiltersFromDateRange( + getPeriodStart(referenceDayWithTimeZone, 'WEEK', firstDayOfTheWeek), + getNextPeriodStart(referenceDayWithTimeZone, 'WEEK', firstDayOfTheWeek), + fieldType, + fieldName, + ); + } + case ObjectRecordGroupByDateGranularity.MONTH: { + return buildFiltersFromDateRange( + getPeriodStart(referenceDayWithTimeZone, 'MONTH'), + getNextPeriodStart(referenceDayWithTimeZone, 'MONTH'), + fieldType, + fieldName, + ); + } + case ObjectRecordGroupByDateGranularity.QUARTER: { + return buildFiltersFromDateRange( + getPeriodStart(referenceDayWithTimeZone, 'QUARTER'), + getNextPeriodStart(referenceDayWithTimeZone, 'QUARTER'), + fieldType, + fieldName, + ); + } + case ObjectRecordGroupByDateGranularity.YEAR: { + return buildFiltersFromDateRange( + getPeriodStart(referenceDayWithTimeZone, 'YEAR'), + getNextPeriodStart(referenceDayWithTimeZone, 'YEAR'), + fieldType, + fieldName, + ); + } + case ObjectRecordGroupByDateGranularity.DAY: + default: + return buildDateFilterForDayGranularity( + referenceDayWithTimeZone, + fieldType, + fieldName, + ); } - - if (dateGranularity === ObjectRecordGroupByDateGranularity.MONTH) { - return buildFiltersFromDateRange( - getStartUnitOfDateTime(dateInTimezone, 'MONTH'), - getEndUnitOfDateTime(dateInTimezone, 'MONTH'), - fieldType, - fieldName, - ); - } - - if (dateGranularity === ObjectRecordGroupByDateGranularity.QUARTER) { - const quarterRange = calculateQuarterDateRange(dateInTimezone, timezone); - - return buildFiltersFromDateRange( - quarterRange.rangeStartDate, - quarterRange.rangeEndDate, - fieldType, - fieldName, - ); - } - - return buildFiltersFromDateRange( - getStartUnitOfDateTime(dateInTimezone, 'YEAR'), - getEndUnitOfDateTime(dateInTimezone, 'YEAR'), - fieldType, - fieldName, - ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterFromChartBucket.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterFromChartBucket.ts index 38b5c5ad783..0438513a389 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterFromChartBucket.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFilterFromChartBucket.ts @@ -2,13 +2,13 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; -import { buildDateFilterForDayGranularity } from '@/page-layout/widgets/graph/utils/buildDateFilterForDayGranularity'; import { buildDateRangeFiltersForGranularity } from '@/page-layout/widgets/graph/utils/buildDateRangeFiltersForGranularity'; import { isCyclicalDateGranularity } from '@/page-layout/widgets/graph/utils/isCyclicalDateGranularity'; import { isTimeRangeDateGranularity } from '@/page-layout/widgets/graph/utils/isTimeRangeDateGranularity'; import { serializeChartBucketValueForFilter } from '@/page-layout/widgets/graph/utils/serializeChartBucketValueForFilter'; import { isNonEmptyString } from '@sniptt/guards'; import { + type FirstDayOfTheWeek, ObjectRecordGroupByDateGranularity, ViewFilterOperand, } from 'twenty-shared/types'; @@ -16,6 +16,7 @@ import { getFilterTypeFromFieldType, isDefined, isFieldMetadataDateKind, + parseToPlainDateOrThrow, } from 'twenty-shared/utils'; type ChartFilter = { @@ -30,6 +31,7 @@ type BuildFilterFromChartBucketParams = { dateGranularity?: ObjectRecordGroupByDateGranularity | null; subFieldName?: string | null; timezone?: string; + firstDayOfTheWeek?: FirstDayOfTheWeek; }; export const buildFilterFromChartBucket = ({ @@ -38,6 +40,7 @@ export const buildFilterFromChartBucket = ({ dateGranularity, subFieldName, timezone, + firstDayOfTheWeek, }: BuildFilterFromChartBucketParams): ChartFilter[] => { const fieldName = isNonEmptyString(subFieldName) ? `${fieldMetadataItem.name}.${subFieldName}` @@ -61,36 +64,64 @@ export const buildFilterFromChartBucket = ({ } if (isFieldMetadataDateKind(fieldMetadataItem.type)) { - const parsedBucketDate = new Date(String(bucketRawValue)); - - if (isNaN(parsedBucketDate.getTime())) { - return []; - } - if (isCyclicalDateGranularity(dateGranularity)) { return []; } - if ( + const shouldAssumeDayRangeFilter = !isDefined(dateGranularity) || dateGranularity === ObjectRecordGroupByDateGranularity.DAY || - dateGranularity === ObjectRecordGroupByDateGranularity.NONE - ) { - return buildDateFilterForDayGranularity( - parsedBucketDate, + dateGranularity === ObjectRecordGroupByDateGranularity.NONE; + + if (shouldAssumeDayRangeFilter) { + if (!isNonEmptyString(timezone)) { + throw new Error( + `Timezone should be defined for date granularity group by day`, + ); + } + + if (!isDefined(firstDayOfTheWeek)) { + throw new Error( + `First day of the week should be defined for date granularity group by day`, + ); + } + + const parsedZonedDateTime = parseToPlainDateOrThrow( + String(bucketRawValue), + ).toZonedDateTime(timezone); + + return buildDateRangeFiltersForGranularity( + parsedZonedDateTime, + ObjectRecordGroupByDateGranularity.DAY, fieldMetadataItem.type, fieldName, - timezone, + firstDayOfTheWeek, ); } if (isTimeRangeDateGranularity(dateGranularity)) { + if (!isNonEmptyString(timezone)) { + throw new Error( + `Timezone should be defined for date granularity group by`, + ); + } + + if (!isDefined(firstDayOfTheWeek)) { + throw new Error( + `First day of the week should be defined for date granularity group by`, + ); + } + + const parsedDateTime = parseToPlainDateOrThrow( + String(bucketRawValue), + ).toZonedDateTime(timezone); + return buildDateRangeFiltersForGranularity( - parsedBucketDate, + parsedDateTime, dateGranularity, fieldMetadataItem.type, fieldName, - timezone, + firstDayOfTheWeek, ); } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFiltersFromDateRange.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFiltersFromDateRange.ts index fab7f752ddf..8223d39772e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFiltersFromDateRange.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildFiltersFromDateRange.ts @@ -1,11 +1,11 @@ import { type RangeChartFilter } from '@/page-layout/widgets/graph/utils/buildDateRangeFiltersForGranularity'; +import { type Temporal } from 'temporal-polyfill'; import { ViewFilterOperand } from 'twenty-shared/types'; -import { getPlainDateFromDate } from 'twenty-shared/utils'; import { FieldMetadataType } from '~/generated-metadata/graphql'; export const buildFiltersFromDateRange = ( - rangeStartDate: Date, - rangeEndDate: Date, + rangeStartDate: Temporal.ZonedDateTime, + rangeEndDate: Temporal.ZonedDateTime, fieldType: FieldMetadataType, fieldName: string, ): RangeChartFilter[] => { @@ -14,12 +14,12 @@ export const buildFiltersFromDateRange = ( { fieldName, operand: ViewFilterOperand.IS_AFTER, - value: rangeStartDate.toISOString(), + value: rangeStartDate.toString({ timeZoneName: 'never' }), }, { fieldName, operand: ViewFilterOperand.IS_BEFORE, - value: rangeEndDate.toISOString(), + value: rangeEndDate.toString({ timeZoneName: 'never' }), }, ]; } @@ -28,12 +28,12 @@ export const buildFiltersFromDateRange = ( { fieldName, operand: ViewFilterOperand.IS_AFTER, - value: getPlainDateFromDate(rangeStartDate), + value: rangeStartDate.toPlainDate().toString(), }, { fieldName, operand: ViewFilterOperand.IS_BEFORE, - value: getPlainDateFromDate(rangeEndDate), + value: rangeEndDate.toPlainDate().toString(), }, ]; }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildGroupByFieldObject.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildGroupByFieldObject.ts index 00403c1e1c4..40872309d77 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildGroupByFieldObject.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/buildGroupByFieldObject.ts @@ -3,7 +3,10 @@ import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/uti import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity'; -import { CalendarStartDay } from 'twenty-shared'; +import { + CalendarStartDay, + GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE, +} from 'twenty-shared/constants'; import { type FirstDayOfTheWeek, ObjectRecordGroupByDateGranularity, @@ -15,19 +18,23 @@ export type GroupByFieldObject = Record< boolean | Record> >; +export type GroupByFieldObjectParams = { + field: FieldMetadataItem; + subFieldName?: string | null; + dateGranularity?: ObjectRecordGroupByDateGranularity; + firstDayOfTheWeek?: number | null; + isNestedDateField?: boolean; + timeZone?: string; +}; + export const buildGroupByFieldObject = ({ field, subFieldName, dateGranularity, firstDayOfTheWeek, isNestedDateField, -}: { - field: FieldMetadataItem; - subFieldName?: string | null; - dateGranularity?: ObjectRecordGroupByDateGranularity; - firstDayOfTheWeek?: number | null; - isNestedDateField?: boolean; -}): GroupByFieldObject => { + timeZone, +}: GroupByFieldObjectParams): GroupByFieldObject => { const isRelation = isFieldRelation(field) || isFieldMorphRelation(field); const isComposite = isCompositeFieldType(field.type); const isDateField = isFieldMetadataDateKind(field.type); @@ -41,11 +48,36 @@ export const buildGroupByFieldObject = ({ const nestedFieldName = parts[0]; const nestedSubFieldName = parts[1]; - if (isNestedDateField === true || isDefined(dateGranularity)) { + if (isNestedDateField === true) { + const usedDateGranularity = + dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY; + + const shouldHaveTimeZone = + GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE.includes( + usedDateGranularity, + ); + + const timeZoneIsNotProvied = !isDefined(timeZone); + + if (shouldHaveTimeZone) { + if (timeZoneIsNotProvied) { + throw new Error(`Date order by should have a time zone.`); + } else { + return { + [field.name]: { + [nestedFieldName]: { + granularity: usedDateGranularity, + timeZone, + }, + }, + }; + } + } + return { [field.name]: { [nestedFieldName]: { - granularity: dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY, + granularity: usedDateGranularity, }, }, }; @@ -82,11 +114,28 @@ export const buildGroupByFieldObject = ({ } if (isDateField) { - const granularity = dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY; - const result: Record = { granularity }; + const usedDateGranularity = + dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY; + + const shouldHaveTimeZone = + GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE.includes( + usedDateGranularity, + ); + + const timeZoneIsNotProvied = !isDefined(timeZone); + + const result: Record = { granularity: usedDateGranularity }; + + if (shouldHaveTimeZone) { + if (timeZoneIsNotProvied) { + throw new Error(`Date order by should have a time zone.`); + } else { + result.timeZone = timeZone; + } + } if ( - granularity === ObjectRecordGroupByDateGranularity.WEEK && + usedDateGranularity === ObjectRecordGroupByDateGranularity.WEEK && isDefined(firstDayOfTheWeek) && firstDayOfTheWeek !== CalendarStartDay.SYSTEM ) { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/calculateQuarterDateRange.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/calculateQuarterDateRange.ts deleted file mode 100644 index 2f88047e8a5..00000000000 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/calculateQuarterDateRange.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { TZDate } from '@date-fns/tz'; -import { getEndUnitOfDateTime } from 'twenty-shared/utils'; - -type QuarterDateRange = { - rangeStartDate: Date; - rangeEndDate: Date; -}; - -export const calculateQuarterDateRange = ( - parsedBucketDate: Date, - timezone?: string, -): QuarterDateRange => { - const quarterStartMonthIndex = - Math.floor(parsedBucketDate.getMonth() / 3) * 3; - const rangeStartDate = timezone - ? new TZDate( - parsedBucketDate.getFullYear(), - quarterStartMonthIndex, - 1, - timezone, - ) - : new Date(parsedBucketDate.getFullYear(), quarterStartMonthIndex, 1); - - const quarterFinalMonthIndex = quarterStartMonthIndex + 2; - const rangeEndDate = getEndUnitOfDateTime( - timezone - ? new TZDate( - parsedBucketDate.getFullYear(), - quarterFinalMonthIndex, - 1, - timezone, - ) - : new Date(parsedBucketDate.getFullYear(), quarterFinalMonthIndex, 1), - 'MONTH', - ); - - return { rangeStartDate, rangeEndDate }; -}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDateByGranularity.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDateByGranularity.ts index 7812d13816e..6d9aa6e8a27 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDateByGranularity.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDateByGranularity.ts @@ -1,7 +1,12 @@ -import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { type Temporal } from 'temporal-polyfill'; +import { + type FirstDayOfTheWeek, + ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; +import { getNextPeriodStart, getPeriodStart } from 'twenty-shared/utils'; export const formatDateByGranularity = ( - date: Date, + plainDate: Temporal.PlainDate, granularity: | ObjectRecordGroupByDateGranularity.DAY | ObjectRecordGroupByDateGranularity.MONTH @@ -9,29 +14,39 @@ export const formatDateByGranularity = ( | ObjectRecordGroupByDateGranularity.YEAR | ObjectRecordGroupByDateGranularity.WEEK | ObjectRecordGroupByDateGranularity.NONE, + userTimezone: string, + firstDayOfTheWeek: FirstDayOfTheWeek, ): string => { switch (granularity) { case ObjectRecordGroupByDateGranularity.DAY: - return date.toLocaleDateString(undefined, { + return plainDate.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); case ObjectRecordGroupByDateGranularity.WEEK: { - const weekStart = new Date(date); - const weekEnd = new Date(date); - weekEnd.setDate(weekEnd.getDate() + 6); + const startOfWeek = getPeriodStart( + plainDate.toZonedDateTime(userTimezone), + 'WEEK', + firstDayOfTheWeek, + ); - const startMonth = weekStart.toLocaleDateString(undefined, { + const endOfWeek = getNextPeriodStart( + plainDate.toZonedDateTime(userTimezone), + 'WEEK', + firstDayOfTheWeek, + ).subtract({ days: 1 }); + + const startMonth = startOfWeek.toLocaleString(undefined, { month: 'short', }); - const endMonth = weekEnd.toLocaleDateString(undefined, { + const endMonth = endOfWeek.toLocaleString(undefined, { month: 'short', }); - const startDay = weekStart.getDate(); - const endDay = weekEnd.getDate(); - const startYear = weekStart.getFullYear(); - const endYear = weekEnd.getFullYear(); + const startDay = startOfWeek.day; + const endDay = endOfWeek.day; + const startYear = startOfWeek.year; + const endYear = endOfWeek.year; if (startYear !== endYear) { return `${startMonth} ${startDay}, ${startYear} - ${endMonth} ${endDay}, ${endYear}`; @@ -44,16 +59,17 @@ export const formatDateByGranularity = ( return `${startMonth} ${startDay} - ${endDay}, ${endYear}`; } case ObjectRecordGroupByDateGranularity.MONTH: - return date.toLocaleDateString(undefined, { + return plainDate.toLocaleString(undefined, { year: 'numeric', month: 'long', }); - case ObjectRecordGroupByDateGranularity.QUARTER: - return `Q${Math.floor(date.getMonth() / 3) + 1} ${date.getFullYear()}`; + case ObjectRecordGroupByDateGranularity.QUARTER: { + return `Q${Math.ceil(plainDate.month / 3)} ${plainDate.year}`; + } case ObjectRecordGroupByDateGranularity.YEAR: - return date.getFullYear().toString(); + return plainDate.year.toString(); case ObjectRecordGroupByDateGranularity.NONE: default: - return date.toLocaleDateString(); + return plainDate.toLocaleString(); } }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDimensionValue.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDimensionValue.ts index cf14cd7f397..2e63c93c535 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDimensionValue.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatDimensionValue.ts @@ -5,9 +5,10 @@ import { t } from '@lingui/core/macro'; import { isNonEmptyString } from '@sniptt/guards'; import { FieldMetadataType, + type FirstDayOfTheWeek, ObjectRecordGroupByDateGranularity, } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, parseToPlainDateOrThrow } from 'twenty-shared/utils'; import { formatToShortNumber } from '~/utils/format/formatToShortNumber'; type FormatDimensionValueParams = { @@ -15,6 +16,8 @@ type FormatDimensionValueParams = { fieldMetadata: FieldMetadataItem; dateGranularity?: ObjectRecordGroupByDateGranularity; subFieldName?: string; + userTimezone: string; + firstDayOfTheWeek: FirstDayOfTheWeek; }; const normalizeMultiSelectValue = (value: unknown): unknown[] => { @@ -43,6 +46,8 @@ export const formatDimensionValue = ({ fieldMetadata, dateGranularity, subFieldName, + userTimezone, + firstDayOfTheWeek, }: FormatDimensionValueParams): string => { if (!isDefined(value)) { return t`Not Set`; @@ -78,12 +83,6 @@ export const formatDimensionValue = ({ case FieldMetadataType.DATE: case FieldMetadataType.DATE_TIME: { - const parsedDate = new Date(String(value)); - - if (isNaN(parsedDate.getTime())) { - return String(value); - } - if ( effectiveDateGranularity === ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK || @@ -94,15 +93,21 @@ export const formatDimensionValue = ({ ) { return String(value); } - return formatDateByGranularity(parsedDate, effectiveDateGranularity); + + const parsedPlainDate = parseToPlainDateOrThrow(String(value)); + + return formatDateByGranularity( + parsedPlainDate, + effectiveDateGranularity, + userTimezone, + firstDayOfTheWeek, + ); } case FieldMetadataType.RELATION: { if (isDefined(dateGranularity)) { - const parsedDate = new Date(String(value)); - if (isNaN(parsedDate.getTime())) { - return String(value); - } + const parsedDayString = String(value); + if ( dateGranularity === ObjectRecordGroupByDateGranularity.DAY_OF_THE_WEEK || @@ -113,7 +118,7 @@ export const formatDimensionValue = ({ ) { return String(value); } - return formatDateByGranularity(parsedDate, dateGranularity); + return parsedDayString; } return String(value); } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatPrimaryDimensionValues.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatPrimaryDimensionValues.ts index 76b5f4dbe52..a6cb83780ea 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatPrimaryDimensionValues.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/formatPrimaryDimensionValues.ts @@ -2,6 +2,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; +import { type FirstDayOfTheWeek } from 'twenty-shared/types'; import { type ObjectRecordGroupByDateGranularity } from '~/generated/graphql'; type FormatPrimaryDimensionValuesParameters = { @@ -9,6 +10,8 @@ type FormatPrimaryDimensionValuesParameters = { primaryAxisGroupByField: FieldMetadataItem; primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity; primaryAxisGroupBySubFieldName?: string; + userTimezone: string; + firstDayOfTheWeek: FirstDayOfTheWeek; }; export type FormattedDimensionValue = { @@ -21,6 +24,8 @@ export const formatPrimaryDimensionValues = ({ primaryAxisGroupByField, primaryAxisDateGranularity, primaryAxisGroupBySubFieldName, + userTimezone, + firstDayOfTheWeek, }: FormatPrimaryDimensionValuesParameters): FormattedDimensionValue[] => { return groupByRawResults.reduce( (accumulator, rawResult) => { @@ -34,6 +39,8 @@ export const formatPrimaryDimensionValues = ({ fieldMetadata: primaryAxisGroupByField, dateGranularity: primaryAxisDateGranularity, subFieldName: primaryAxisGroupBySubFieldName, + userTimezone, + firstDayOfTheWeek, }); return [ diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts index 3e8016735bf..f193f7d0028 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts @@ -27,6 +27,7 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({ aggregateOperation, limit, firstDayOfTheWeek, + userTimeZone, }: { objectMetadataItem: ObjectMetadataItem; objectMetadataItems: ObjectMetadataItem[]; @@ -34,6 +35,7 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({ aggregateOperation?: string; limit?: number; firstDayOfTheWeek?: number; + userTimeZone?: string; }) => { const groupByFieldXId = chartConfiguration.primaryAxisGroupByFieldMetadataId; @@ -82,6 +84,7 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({ : undefined, firstDayOfTheWeek, isNestedDateField: isFieldXNestedDate, + timeZone: userTimeZone, }), ); @@ -106,6 +109,7 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({ : undefined, firstDayOfTheWeek, isNestedDateField: isFieldYNestedDate, + timeZone: userTimeZone, }), ); } diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration.ts index ad45b74e71d..abe7cdec077 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration.ts @@ -23,6 +23,7 @@ export const generateGroupByQueryVariablesFromPieChartConfiguration = ({ aggregateOperation, limit, firstDayOfTheWeek, + userTimeZone, }: { objectMetadataItem: ObjectMetadataItem; objectMetadataItems: ObjectMetadataItem[]; @@ -30,6 +31,7 @@ export const generateGroupByQueryVariablesFromPieChartConfiguration = ({ aggregateOperation?: string; limit?: number; firstDayOfTheWeek?: number; + userTimeZone?: string; }) => { const groupByFieldId = chartConfiguration.groupByFieldMetadataId; const groupBySubFieldName = @@ -65,6 +67,7 @@ export const generateGroupByQueryVariablesFromPieChartConfiguration = ({ : undefined, firstDayOfTheWeek, isNestedDateField: isNestedDate, + timeZone: userTimeZone, }), ]; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts index 40716c98a52..36601675a0e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts @@ -10,7 +10,11 @@ import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGr import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind'; import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData'; import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData'; -import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; +import { + type FirstDayOfTheWeek, + isDefined, + isFieldMetadataDateKind, +} from 'twenty-shared/utils'; import { AxisNameDisplay, type LineChartConfiguration, @@ -22,6 +26,8 @@ type TransformGroupByDataToLineChartDataParams = { objectMetadataItems: ObjectMetadataItem[]; configuration: LineChartConfiguration; aggregateOperation: string; + userTimezone: string; + firstDayOfTheWeek: FirstDayOfTheWeek; }; type TransformGroupByDataToLineChartDataResult = { @@ -51,6 +57,8 @@ export const transformGroupByDataToLineChartData = ({ objectMetadataItems, configuration, aggregateOperation, + userTimezone, + firstDayOfTheWeek, }: TransformGroupByDataToLineChartDataParams): TransformGroupByDataToLineChartDataResult => { const groupByFieldX = objectMetadataItem.fields.find( (field: FieldMetadataItem) => @@ -194,6 +202,8 @@ export const transformGroupByDataToLineChartData = ({ aggregateOperation, objectMetadataItem, primaryAxisSubFieldName, + userTimezone, + firstDayOfTheWeek, }) : transformOneDimensionalGroupByToLineChartData({ rawResults: filteredResults, @@ -203,6 +213,8 @@ export const transformGroupByDataToLineChartData = ({ aggregateOperation, objectMetadataItem, primaryAxisSubFieldName, + userTimezone, + firstDayOfTheWeek, }); return { diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts index 1af4328c7a0..fb2390e3e66 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts @@ -13,7 +13,7 @@ import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/bui import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult'; import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; -import { isDefined } from 'twenty-shared/utils'; +import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils'; import { type LineChartConfiguration } from '~/generated/graphql'; type TransformOneDimensionalGroupByToLineChartDataParams = { @@ -24,6 +24,8 @@ type TransformOneDimensionalGroupByToLineChartDataParams = { aggregateOperation: string; objectMetadataItem: ObjectMetadataItem; primaryAxisSubFieldName?: string | null; + userTimezone: string; + firstDayOfTheWeek: FirstDayOfTheWeek; }; type TransformOneDimensionalGroupByToLineChartDataResult = { @@ -40,6 +42,8 @@ export const transformOneDimensionalGroupByToLineChartData = ({ aggregateOperation, objectMetadataItem, primaryAxisSubFieldName, + userTimezone, + firstDayOfTheWeek, }: TransformOneDimensionalGroupByToLineChartDataParams): TransformOneDimensionalGroupByToLineChartDataResult => { // TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600) const limitedResults = rawResults.slice( @@ -53,6 +57,8 @@ export const transformOneDimensionalGroupByToLineChartData = ({ primaryAxisDateGranularity: configuration.primaryAxisDateGranularity ?? undefined, primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined, + userTimezone, + firstDayOfTheWeek, }); const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); @@ -73,6 +79,8 @@ export const transformOneDimensionalGroupByToLineChartData = ({ dateGranularity: configuration.primaryAxisDateGranularity ?? undefined, subFieldName: primaryAxisSubFieldName ?? undefined, + userTimezone, + firstDayOfTheWeek, }) : ''; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts index 60777b8c757..955feb18cec 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts @@ -13,7 +13,7 @@ import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/gr import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue'; import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues'; import { sortLineChartSeries } from '@/page-layout/widgets/graph/utils/sortLineChartSeries'; -import { isDefined } from 'twenty-shared/utils'; +import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils'; import { type LineChartConfiguration } from '~/generated/graphql'; type TransformTwoDimensionalGroupByToLineChartDataParams = { @@ -25,6 +25,8 @@ type TransformTwoDimensionalGroupByToLineChartDataParams = { aggregateOperation: string; objectMetadataItem: ObjectMetadataItem; primaryAxisSubFieldName?: string | null; + userTimezone: string; + firstDayOfTheWeek: FirstDayOfTheWeek; }; type TransformTwoDimensionalGroupByToLineChartDataResult = { @@ -42,17 +44,23 @@ export const transformTwoDimensionalGroupByToLineChartData = ({ aggregateOperation, objectMetadataItem, primaryAxisSubFieldName, + userTimezone, + firstDayOfTheWeek, }: TransformTwoDimensionalGroupByToLineChartDataParams): TransformTwoDimensionalGroupByToLineChartDataResult => { const seriesMap = new Map>(); const allXValues: string[] = []; const xValueSet = new Set(); + const formattedValues = formatPrimaryDimensionValues({ groupByRawResults: rawResults, primaryAxisGroupByField: groupByFieldX, primaryAxisDateGranularity: configuration.primaryAxisDateGranularity ?? undefined, primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined, + userTimezone, + firstDayOfTheWeek, }); + const formattedToRawLookup = buildFormattedToRawLookup(formattedValues); let hasTooManyGroups = false; @@ -68,6 +76,8 @@ export const transformTwoDimensionalGroupByToLineChartData = ({ fieldMetadata: groupByFieldX, dateGranularity: configuration.primaryAxisDateGranularity ?? undefined, subFieldName: primaryAxisSubFieldName ?? undefined, + userTimezone, + firstDayOfTheWeek, }); // TODO: Add a limit to the query instead of checking here (issue: twentyhq/core-team-issues#1600) @@ -94,6 +104,8 @@ export const transformTwoDimensionalGroupByToLineChartData = ({ dateGranularity: configuration.secondaryAxisGroupByDateGranularity ?? undefined, subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined, + userTimezone, + firstDayOfTheWeek, }); const aggregateValue = computeAggregateValueFromGroupByResult({ diff --git a/packages/twenty-front/src/modules/settings/experience/components/FormatPreferencesSettings.tsx b/packages/twenty-front/src/modules/settings/experience/components/FormatPreferencesSettings.tsx index 4785ad4f753..a350f2fdb94 100644 --- a/packages/twenty-front/src/modules/settings/experience/components/FormatPreferencesSettings.tsx +++ b/packages/twenty-front/src/modules/settings/experience/components/FormatPreferencesSettings.tsx @@ -2,7 +2,6 @@ import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { CalendarStartDay } from 'twenty-shared'; import { DateFormat } from '@/localization/constants/DateFormat'; import { NumberFormat } from '@/localization/constants/NumberFormat'; import { TimeFormat } from '@/localization/constants/TimeFormat'; @@ -11,6 +10,7 @@ import { DateTimeSettingsDateFormatSelect } from '@/settings/experience/componen import { DateTimeSettingsTimeFormatSelect } from '@/settings/experience/components/DateTimeSettingsTimeFormatSelect'; import { DateTimeSettingsTimeZoneSelect } from '@/settings/experience/components/DateTimeSettingsTimeZoneSelect'; import { NumberFormatSelect } from '@/settings/experience/components/NumberFormatSelect'; +import { CalendarStartDay } from 'twenty-shared/constants'; import { isDefined } from 'twenty-shared/utils'; import { WorkspaceMemberDateFormatEnum, diff --git a/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx index 9d37504ac2a..cc5578fb0ba 100644 --- a/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx +++ b/packages/twenty-front/src/modules/ui/field/display/components/DateTimeDisplay.tsx @@ -5,6 +5,7 @@ import styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; +import { Temporal } from 'temporal-polyfill'; import { dateLocaleState } from '~/localization/states/dateLocaleState'; import { formatDateTimeString } from '~/utils/string/formatDateTimeString'; import { EllipsisDisplay } from './EllipsisDisplay'; @@ -41,7 +42,7 @@ export const DateTimeDisplay = ({ {isNonEmptyString(value) && ( <> - + )} diff --git a/packages/twenty-front/src/modules/ui/field/input/components/DateInput.tsx b/packages/twenty-front/src/modules/ui/field/input/components/DateInput.tsx index 9c766d2e5b3..e37308e7dcc 100644 --- a/packages/twenty-front/src/modules/ui/field/input/components/DateInput.tsx +++ b/packages/twenty-front/src/modules/ui/field/input/components/DateInput.tsx @@ -110,7 +110,7 @@ export const DateInput = ({
; - onEnter: (newDateTime: Nullable) => void; - onEscape: (newDateTime: Nullable) => void; + value: Nullable; + onEnter: (newDateTime: Nullable) => void; + onEscape: (newDateTime: Nullable) => void; onClickOutside: ( event: MouseEvent | TouchEvent, - newDateTime: Nullable, + newDateTime: Nullable, ) => void; clearable?: boolean; - onChange?: (newDateTime: Nullable) => void; + onChange?: (newDateTime: Nullable) => void; onClear?: () => void; - onSubmit?: (newDateTime: Nullable) => void; + onSubmit?: (newDateTime: Nullable) => void; hideHeaderInput?: boolean; }; @@ -40,12 +42,13 @@ export const DateTimeInput = ({ hideHeaderInput, }: DateTimeInputProps) => { const [internalValue, setInternalValue] = useState(value); + const { userTimezone } = useUserTimezone(); const wrapperRef = useRef(null); - const handleChange = (newDateTime: Date | null) => { - setInternalValue(newDateTime); - onChange?.(newDateTime); + const handleChange = (newDateTime: Temporal.ZonedDateTime | null) => { + setInternalValue(newDateTime?.toInstant()); + onChange?.(newDateTime?.toInstant()); }; const handleClear = () => { @@ -53,9 +56,9 @@ export const DateTimeInput = ({ onClear?.(); }; - const handleClose = (newDateTime: Date | null) => { - setInternalValue(newDateTime); - onSubmit?.(newDateTime); + const handleClose = (newDateTime: Temporal.ZonedDateTime | null) => { + setInternalValue(newDateTime?.toInstant()); + onSubmit?.(newDateTime?.toInstant()); }; const { closeDropdown: closeDropdownMonthSelect } = useCloseDropdown(); @@ -97,10 +100,12 @@ export const DateTimeInput = ({ ], ); + const internalZonedDateTime = internalValue?.toZonedDateTimeISO(userTimezone); + useRegisterInputEvents({ focusId: instanceId, inputRef: wrapperRef, - inputValue: internalValue, + inputValue: internalZonedDateTime, onEnter: handleEnter, onEscape: handleEscape, onClickOutside: handleClickOutside, @@ -110,12 +115,12 @@ export const DateTimeInput = ({
diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePicker.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePicker.tsx index ef5defe79f3..306bfdb02d5 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DatePicker.tsx @@ -5,7 +5,7 @@ import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { CalendarStartDay } from 'twenty-shared'; +import { CalendarStartDay } from 'twenty-shared/constants'; import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; import { DatePickerHeader } from '@/ui/input/components/internal/date/components/DatePickerHeader'; @@ -14,15 +14,15 @@ import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/g import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; import { useTheme } from '@emotion/react'; import { t } from '@lingui/core/macro'; -import { addMonths, setMonth, setYear, subMonths } from 'date-fns'; import 'react-datepicker/dist/react-datepicker.css'; import { useRecoilValue } from 'recoil'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { Temporal } from 'temporal-polyfill'; import { type Nullable } from 'twenty-shared/types'; import { - getDateFromPlainDate, - getPlainDateFromDate, isDefined, + turnJSDateToPlainDate, type RelativeDateFilter, } from 'twenty-shared/utils'; import { IconCalendarX } from 'twenty-ui/display'; @@ -305,7 +305,7 @@ type DatePickerProps = { instanceId: string; isRelative?: boolean; hideHeaderInput?: boolean; - date: Nullable; + plainDateString: Nullable; relativeDate?: RelativeDateFilter & { start: string; end: string; @@ -320,6 +320,7 @@ type DatePickerProps = { onEscape?: (date: string | null) => void; keyboardEventsDisabled?: boolean; onClear?: () => void; + hideCalendar?: boolean; }; type DatePickerPropsType = ReactDatePickerLibProps< @@ -335,7 +336,7 @@ const ReactDatePicker = lazy>(() => export const DatePicker = ({ instanceId, - date, + plainDateString, onChange, onClose, clearable = true, @@ -345,8 +346,11 @@ export const DatePicker = ({ onRelativeDateChange, hideHeaderInput, }: DatePickerProps) => { - const dateOrToday = date ?? getPlainDateFromDate(new Date()); - const shiftedDateForReactPicker = getDateFromPlainDate(dateOrToday); + const plainDate = isDefined(plainDateString) + ? Temporal.PlainDate.from(plainDateString) + : Temporal.Now.plainDateISO(); + + const { userTimezone } = useUserTimezone(); const theme = useTheme(); @@ -369,61 +373,54 @@ export const DatePicker = ({ }; const handleChangeMonth = (month: number) => { - const newDate = setMonth(shiftedDateForReactPicker, month); + const newDate = plainDate?.with({ month: month }); - const plainDate = getPlainDateFromDate(newDate); - - onChange?.(plainDate); + onChange?.(newDate?.toString() ?? null); }; const handleAddMonth = () => { - const dateParsed = addMonths(shiftedDateForReactPicker, 1); + const newDate = plainDate?.add({ months: 1 }); - const plainDate = getPlainDateFromDate(dateParsed); - - onChange?.(plainDate); + onChange?.(newDate?.toString() ?? null); }; const handleSubtractMonth = () => { - const dateParsed = subMonths(shiftedDateForReactPicker, 1); + const newDate = plainDate?.subtract({ months: 1 }); - const plainDate = getPlainDateFromDate(dateParsed); - - onChange?.(plainDate); + onChange?.(newDate?.toString() ?? null); }; const handleChangeYear = (year: number) => { - const dateParsed = setYear(shiftedDateForReactPicker, year); + const newDate = plainDate?.with({ year: year }); - const plainDate = getPlainDateFromDate(dateParsed); - - onChange?.(plainDate); + onChange?.(newDate?.toString() ?? null); }; - const handleDateChange = (date: Date) => { - const plainDate = getPlainDateFromDate(date); + const handleDateChange = (datePicked: Date) => { + const plainDatePicked = turnJSDateToPlainDate(datePicked); - onChange?.(plainDate); + onChange?.(plainDatePicked.toString()); }; - const handleDateSelect = (date: Date) => { - const plainDate = getPlainDateFromDate(date); + const handleDateSelect = (datePicked: Date) => { + const plainDatePicked = turnJSDateToPlainDate(datePicked); - handleClose?.(plainDate); + handleClose?.(plainDatePicked.toString()); }; const highlightedDates = isRelative && isDefined(relativeDate?.end) && isDefined(relativeDate?.start) - ? getHighlightedDates({ - start: getDateFromPlainDate(relativeDate.start), - end: getDateFromPlainDate(relativeDate.end), - }) + ? getHighlightedDates( + Temporal.PlainDate.from(relativeDate.start), + Temporal.PlainDate.from(relativeDate.end).subtract({ days: 1 }), + userTimezone, + ) : []; - const dateAsDate = isDefined(date) ? getDateFromPlainDate(date) : undefined; + const dateAsDate = new Date(plainDate.toString()); const selectedDates = isRelative - ? highlightedDates + ? highlightedDates.map((plainDate) => new Date(plainDate.toString())) : isDefined(dateAsDate) ? [dateAsDate] : []; @@ -433,6 +430,17 @@ export const DatePicker = ({ ? CalendarStartDay[detectCalendarStartDay()] : (currentWorkspaceMember?.calendarStartDay ?? undefined); + const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const dateShiftedToISOString = plainDate + ?.toZonedDateTime(systemTimeZone) + .toInstant() + .toString(); + + const dateForDatePicker = isDefined(dateShiftedToISOString) + ? new Date(dateShiftedToISOString) + : null; + return (
@@ -466,9 +474,9 @@ export const DatePicker = ({ > ) : ( @@ -75,7 +75,7 @@ export const DatePickerHeader = ({ dropdownId={MONTH_AND_YEAR_DROPDOWN_MONTH_SELECT_ID} options={getMonthSelectOptions(userLocale)} onChange={onChangeMonth} - value={dateParsed?.getMonth()} + value={dateParsed?.month} fullWidth /> @@ -87,7 +87,7 @@ export const DatePickerHeader = ({ diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerInput.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerInput.tsx index 38667a26f8a..3414a81f315 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerInput.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/DateTimePickerInput.tsx @@ -8,15 +8,19 @@ import { MIN_DATE } from '@/ui/input/components/internal/date/constants/MinDate' import { getDateTimeMask } from '@/ui/input/components/internal/date/utils/getDateTimeMask'; import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation'; +import { useGetShiftedDateToCustomTimeZone } from '@/ui/input/components/internal/date/hooks/useGetShiftedDateToCustomTimeZone'; +import { useGetShiftedDateToSystemTimeZone } from '@/ui/input/components/internal/date/hooks/useGetShiftedDateToSystemTimeZone'; import { useParseDateTimeInputStringToJSDate } from '@/ui/input/components/internal/date/hooks/useParseDateTimeInputStringToJSDate'; import { useParseJSDateToIMaskDateTimeInputString } from '@/ui/input/components/internal/date/hooks/useParseJSDateToIMaskDateTimeInputString'; -import { useTurnReactDatePickerShiftedDateBackIntoPointInTime } from '@/ui/input/components/internal/date/hooks/useTurnReactDatePickerShiftedDateBackIntoPointInTime'; +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; import { useEffect, useState } from 'react'; +import { Temporal } from 'temporal-polyfill'; import { isDefined } from 'twenty-shared/utils'; +import { isDifferentZonedDateTime } from '~/utils/dates/isDifferentZonedDateTime'; const StyledInputContainer = styled.div` align-items: center; - border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; + border-top-left-radius: ${({ theme }) => theme.border.radius.md}; border-top-right-radius: ${({ theme }) => theme.border.radius.md}; display: flex; @@ -36,21 +40,32 @@ const StyledInput = styled.input<{ hasError?: boolean }>` `; type DateTimePickerInputProps = { - onChange?: (date: Date | null) => void; - date: Date | null; + onChange?: (date: Temporal.ZonedDateTime | null) => void; + date: Temporal.ZonedDateTime | null; + onFocus?: () => void; + readonly?: boolean; + timeZone?: string; }; export const DateTimePickerInput = ({ date, onChange, + onFocus, + readonly, + timeZone, }: DateTimePickerInputProps) => { - const { turnReactDatePickerShiftedDateBackIntoPointInTime } = - useTurnReactDatePickerShiftedDateBackIntoPointInTime(); - const [internalDate, setInternalDate] = useState(date); + const { userTimezone } = useUserTimezone(); + const { dateFormat } = useDateTimeFormat(); + const { getShiftedDateToSystemTimeZone } = + useGetShiftedDateToSystemTimeZone(); + + const { getShiftedDateToCustomTimeZone } = + useGetShiftedDateToCustomTimeZone(); + const { parseDateTimeInputStringToJSDate } = useParseDateTimeInputStringToJSDate(); const { parseJSDateToDateTimeInputString } = @@ -66,6 +81,17 @@ export const DateTimePickerInput = ({ const blocks = DATE_TIME_BLOCKS; + const defaultValueForIMask = isDefined(internalDate) + ? new Date(internalDate?.toInstant().toString()) + : null; + + const shiftedIMaskDate = isDefined(defaultValueForIMask) + ? getShiftedDateToSystemTimeZone( + defaultValueForIMask, + timeZone ?? userTimezone, + ) + : null; + const { ref, setValue } = useIMask( { mask: Date, @@ -79,9 +105,9 @@ export const DateTimePickerInput = ({ autofix: false, }, { - defaultValue: parseJSDateToDateTimeInputString( - internalDate ?? new Date(), - ), + defaultValue: isDefined(shiftedIMaskDate) + ? parseJSDateToDateTimeInputString(shiftedIMaskDate) + : undefined, onComplete: (value) => { const parsedDate = parseDateTimeInputStringToJSDate(value); @@ -89,25 +115,67 @@ export const DateTimePickerInput = ({ return; } - const pointInTime = - turnReactDatePickerShiftedDateBackIntoPointInTime(parsedDate); + const pointInTime = getShiftedDateToCustomTimeZone( + parsedDate, + timeZone ?? userTimezone, + ); - onChange?.(pointInTime); + setInternalDate(date); + + const zonedDateTime = Temporal.Instant.from( + pointInTime.toISOString(), + ).toZonedDateTimeISO(timeZone ?? userTimezone); + + onChange?.(zonedDateTime); }, }, ); useEffect(() => { - if (isDefined(date) && internalDate !== date) { + if (isDifferentZonedDateTime(internalDate, date)) { setInternalDate(date); - setValue(parseJSDateToDateTimeInputString(date)); + + if (!isDefined(date)) { + return; + } + + const newDateAsDate = new Date(date.toInstant().toString()); + + const newShiftedDate = getShiftedDateToSystemTimeZone( + newDateAsDate, + timeZone ?? userTimezone, + ); + + setValue(parseJSDateToDateTimeInputString(newShiftedDate)); } - }, [date, internalDate, parseJSDateToDateTimeInputString, setValue]); + }, [ + date, + internalDate, + parseJSDateToDateTimeInputString, + setValue, + shiftedIMaskDate, + timeZone, + getShiftedDateToSystemTimeZone, + userTimezone, + ]); + + const shouldDisplayReadOnly = readonly === true; + + const internalDateForTimeZoneAbbreviation = + internalDate?.toInstant() ?? Temporal.Now.instant(); return ( - - + + ); }; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/TimeZoneAbbreviation.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/TimeZoneAbbreviation.tsx index 9a5993ac6d3..93f759f42a7 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/TimeZoneAbbreviation.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/TimeZoneAbbreviation.tsx @@ -1,5 +1,8 @@ import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { getTimezoneAbbreviationForZonedDateTime } from '@/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime'; import styled from '@emotion/styled'; +import { isNonEmptyString } from '@sniptt/guards'; +import { type Temporal } from 'temporal-polyfill'; const StyledTimezoneAbbreviation = styled.span<{ hasError?: boolean }>` background: transparent; @@ -9,17 +12,27 @@ const StyledTimezoneAbbreviation = styled.span<{ hasError?: boolean }>` width: fit-content; user-select: none; - - line-height: 0.5px; `; -export const TimeZoneAbbreviation = ({ date }: { date: Date }) => { - const { isSystemTimezone, getTimezoneAbbreviationForPointInTime } = - useUserTimezone(); +export const TimeZoneAbbreviation = ({ + instant, + timeZone, +}: { + instant: Temporal.Instant; + timeZone?: string; +}) => { + const { isSystemTimezone, userTimezone, systemTimeZone } = useUserTimezone(); - const shouldShowTimezoneAbbreviation = !isSystemTimezone; - const timezoneSuffix = !isSystemTimezone - ? ` ${getTimezoneAbbreviationForPointInTime(date ?? new Date())}` + const zonedDate = instant.toZonedDateTimeISO(timeZone ?? userTimezone); + + const shouldUseParamsTimeZone = + isNonEmptyString(timeZone) && systemTimeZone !== timeZone; + + const shouldShowTimezoneAbbreviation = + !isSystemTimezone || shouldUseParamsTimeZone; + + const timezoneSuffix = shouldShowTimezoneAbbreviation + ? ` ${getTimezoneAbbreviationForZonedDateTime(zonedDate)}` : ''; if (!shouldShowTimezoneAbbreviation) { diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx index f2db8ceb593..7bc097d6e29 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/components/__stories__/InternalDatePicker.stories.tsx @@ -1,7 +1,7 @@ import { useArgs } from '@storybook/preview-api'; import { type Meta, type StoryObj } from '@storybook/react'; import { expect, userEvent, within } from '@storybook/test'; -import { isDefined } from 'twenty-shared/utils'; +import { Temporal } from 'temporal-polyfill'; import { ComponentDecorator } from 'twenty-ui/testing'; import { DateTimePicker } from '../DateTimePicker'; @@ -18,12 +18,16 @@ const meta: Meta = { return ( updateArgs({ date: newDate })} /> ); }, - args: { date: new Date('January 1, 2023 02:00:00') }, + args: { + date: Temporal.Instant.from('2023-01-01T02:00:00Z').toZonedDateTimeISO( + 'UTC', + ), + }, }; export default meta; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/__tests__/useParseZonedDateTimeToIMaskDateTimeInputString.test.tsx b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/__tests__/useParseZonedDateTimeToIMaskDateTimeInputString.test.tsx new file mode 100644 index 00000000000..71f03e6ec0e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/__tests__/useParseZonedDateTimeToIMaskDateTimeInputString.test.tsx @@ -0,0 +1,87 @@ +import { renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { Temporal } from 'temporal-polyfill'; + +import { DateFormat } from '@/localization/constants/DateFormat'; +import { NumberFormat } from '@/localization/constants/NumberFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; +import { workspaceMemberFormatPreferencesState } from '@/localization/states/workspaceMemberFormatPreferencesState'; +import { useParseZonedDateTimeToIMaskDateTimeInputString } from '@/ui/input/components/internal/date/hooks/useParseZonedDateTimeToIMaskDateTimeInputString'; +import { CalendarStartDay } from 'twenty-shared/constants'; + +describe('useParseZonedDateTimeToIMaskDateTimeInputString', () => { + const testZonedDateTime = Temporal.ZonedDateTime.from({ + year: 2024, + month: 3, + day: 15, + hour: 14, + minute: 30, + timeZone: 'Pacific/Auckland', + }); + + const createWrapper = + (dateFormat: DateFormat) => + ({ children }: { children: React.ReactNode }) => ( + { + snapshot.set(workspaceMemberFormatPreferencesState, { + timeZone: 'Pacific/Auckland', + dateFormat, + timeFormat: TimeFormat.HOUR_24, + numberFormat: NumberFormat.COMMAS_AND_DOT, + calendarStartDay: CalendarStartDay.MONDAY, + }); + }} + > + {children} + + ); + + it('should format with day first format (DD/MM/YYYY, HH:mm)', () => { + const { result } = renderHook( + () => useParseZonedDateTimeToIMaskDateTimeInputString(), + { wrapper: createWrapper(DateFormat.DAY_FIRST) }, + ); + + const formattedString = + result.current.parseZonedDateTimeToDateTimeInputString(testZonedDateTime); + + expect(formattedString).toBe('15/03/2024, 14:30'); + }); + + it('should format with year first format (YYYY-MM-DD, HH:mm)', () => { + const { result } = renderHook( + () => useParseZonedDateTimeToIMaskDateTimeInputString(), + { wrapper: createWrapper(DateFormat.YEAR_FIRST) }, + ); + + const formattedString = + result.current.parseZonedDateTimeToDateTimeInputString(testZonedDateTime); + + expect(formattedString).toBe('2024-03-15, 14:30'); + }); + + it('should format with month first format (MM/DD/YYYY, HH:mm)', () => { + const { result } = renderHook( + () => useParseZonedDateTimeToIMaskDateTimeInputString(), + { wrapper: createWrapper(DateFormat.MONTH_FIRST) }, + ); + + const formattedString = + result.current.parseZonedDateTimeToDateTimeInputString(testZonedDateTime); + + expect(formattedString).toBe('03/15/2024, 14:30'); + }); + + it('should format with month first format when dateFormat is SYSTEM', () => { + const { result } = renderHook( + () => useParseZonedDateTimeToIMaskDateTimeInputString(), + { wrapper: createWrapper(DateFormat.SYSTEM) }, + ); + + const formattedString = + result.current.parseZonedDateTimeToDateTimeInputString(testZonedDateTime); + + expect(formattedString).toBe('03/15/2024, 14:30'); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useGetShiftedDateToCustomTimeZone.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useGetShiftedDateToCustomTimeZone.ts new file mode 100644 index 00000000000..85f76f20010 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useGetShiftedDateToCustomTimeZone.ts @@ -0,0 +1,29 @@ +import { Temporal } from 'temporal-polyfill'; + +export const useGetShiftedDateToCustomTimeZone = () => { + const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const getShiftedDateToCustomTimeZone = ( + instantExpressedInSystemTimeZone: Date, + timeZone: string, + ) => { + const correctZonedDateTime = Temporal.Instant.from( + instantExpressedInSystemTimeZone.toISOString(), + ).toZonedDateTimeISO(systemTimeZone); + + const plainDateTimeWithoutTimeZone = correctZonedDateTime.toPlainDateTime(); + + const shiftedZonedDateTime = + plainDateTimeWithoutTimeZone.toZonedDateTime(timeZone); + + const dateObjectShiftedToCustomTimeZone = new Date( + shiftedZonedDateTime.toInstant().toString(), + ); + + return dateObjectShiftedToCustomTimeZone; + }; + + return { + getShiftedDateToCustomTimeZone, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useGetShiftedDateToSystemTimeZone.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useGetShiftedDateToSystemTimeZone.ts new file mode 100644 index 00000000000..1b422a0eb3a --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useGetShiftedDateToSystemTimeZone.ts @@ -0,0 +1,33 @@ +import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; +import { Temporal } from 'temporal-polyfill'; + +export const useGetShiftedDateToSystemTimeZone = () => { + const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const { userTimezone } = useUserTimezone(); + + const getShiftedDateToSystemTimeZone = ( + instantShiftedInCustomTimeZone: Date, + timeZone?: string, + ) => { + const zonedDateTimeInCustomTimeZone = Temporal.Instant.from( + instantShiftedInCustomTimeZone.toISOString(), + ).toZonedDateTimeISO(timeZone ?? userTimezone); + + const plainDateTimeWithoutTimeZone = + zonedDateTimeInCustomTimeZone.toPlainDateTime(); + + const zonedDateTimeShiftedToSystemTimeZone = + plainDateTimeWithoutTimeZone.toZonedDateTime(systemTimeZone); + + const dateObjectShiftedToSystemTimeZone = new Date( + zonedDateTimeShiftedToSystemTimeZone.toInstant().toString(), + ); + + return dateObjectShiftedToSystemTimeZone; + }; + + return { + getShiftedDateToSystemTimeZone, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useParseZonedDateTimeToIMaskDateTimeInputString.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useParseZonedDateTimeToIMaskDateTimeInputString.ts new file mode 100644 index 00000000000..1aaaa79c862 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useParseZonedDateTimeToIMaskDateTimeInputString.ts @@ -0,0 +1,49 @@ +import { DateFormat } from '@/localization/constants/DateFormat'; +import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat'; +import { type Temporal } from 'temporal-polyfill'; + +export const useParseZonedDateTimeToIMaskDateTimeInputString = () => { + const { dateFormat } = useDateTimeFormat(); + + const parseZonedDateTimeToDateTimeInputString = ( + zonedDateTime: Temporal.ZonedDateTime, + ) => { + switch (dateFormat) { + case DateFormat.DAY_FIRST: { + return zonedDateTime.toLocaleString('en-GB', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } + case DateFormat.YEAR_FIRST: { + return zonedDateTime.toLocaleString('en-CA', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } + case DateFormat.SYSTEM: + case DateFormat.MONTH_FIRST: { + return zonedDateTime.toLocaleString('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + } + } + }; + + return { + parseZonedDateTimeToDateTimeInputString, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTurnPointInTimeIntoReactDatePickerShiftedDate.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTurnPointInTimeIntoReactDatePickerShiftedDate.ts deleted file mode 100644 index 55cf6d3847b..00000000000 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTurnPointInTimeIntoReactDatePickerShiftedDate.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; -import { TZDate } from '@date-fns/tz'; - -export const useTurnPointInTimeIntoReactDatePickerShiftedDate = () => { - const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - - const { userTimezone } = useUserTimezone(); - - // TODO: replace here with shiftPointInTimeToFromTimezoneDifference - const turnPointInTimeIntoReactDatePickerShiftedDate = (pointInTime: Date) => { - const dateSure = new TZDate(pointInTime).withTimeZone(userTimezone); - - const shiftedDate = new TZDate( - dateSure.getFullYear(), - dateSure.getMonth(), - dateSure.getDate(), - dateSure.getHours(), - dateSure.getMinutes(), - dateSure.getSeconds(), - systemTimeZone, - ); - - return shiftedDate; - }; - - return { - turnPointInTimeIntoReactDatePickerShiftedDate, - }; -}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTurnReactDatePickerShiftedDateBackIntoPointInTime.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTurnReactDatePickerShiftedDateBackIntoPointInTime.ts deleted file mode 100644 index cb6cffa1e9e..00000000000 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useTurnReactDatePickerShiftedDateBackIntoPointInTime.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone'; -import { TZDate } from '@date-fns/tz'; - -export const useTurnReactDatePickerShiftedDateBackIntoPointInTime = () => { - const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - - const { userTimezone } = useUserTimezone(); - - // TODO: replace here with shiftPointInTimeToFromTimezoneDifference - const turnReactDatePickerShiftedDateBackIntoPointInTime = ( - reactDatePickerShiftedDate: Date, - ) => { - const dateSure = new TZDate(reactDatePickerShiftedDate).withTimeZone( - systemTimeZone, - ); - - const dateTz = new TZDate( - dateSure.getFullYear(), - dateSure.getMonth(), - dateSure.getDate(), - dateSure.getHours(), - dateSure.getMinutes(), - dateSure.getSeconds(), - userTimezone, - ); - - return dateTz; - }; - - return { - turnReactDatePickerShiftedDateBackIntoPointInTime, - }; -}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek.ts new file mode 100644 index 00000000000..cf75949f09d --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek.ts @@ -0,0 +1,38 @@ +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; +import { useRecoilValue } from 'recoil'; +import { CalendarStartDay } from 'twenty-shared/constants'; +import { + convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek, + isDefined, +} from 'twenty-shared/utils'; + +export const useUserFirstDayOfTheWeek = () => { + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const systemFirstDayOfTheWeek = detectCalendarStartDay(); + + const isSystemFirstDayOfTheWeek = + currentWorkspaceMember?.calendarStartDay === CalendarStartDay.SYSTEM; + + const currentWorkspaceMemberCalendarStartDayNonIsoNumber = + currentWorkspaceMember?.calendarStartDay; + + const currentWorkspaceMemberFirstDayOfTheWeek = isDefined( + currentWorkspaceMemberCalendarStartDayNonIsoNumber, + ) + ? convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek( + currentWorkspaceMemberCalendarStartDayNonIsoNumber, + systemFirstDayOfTheWeek, + ) + : undefined; + + const userFirstDayOfTheWeek = isSystemFirstDayOfTheWeek + ? systemFirstDayOfTheWeek + : (currentWorkspaceMemberFirstDayOfTheWeek ?? systemFirstDayOfTheWeek); + + return { + isSystemFirstDayOfTheWeek, + systemFirstDayOfTheWeek, + userFirstDayOfTheWeek, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserTimezone.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserTimezone.ts index e4acaed9f57..0f82c0352a1 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserTimezone.ts +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/hooks/useUserTimezone.ts @@ -1,5 +1,4 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { tzName } from '@date-fns/tz'; import { useRecoilValue } from 'recoil'; export const useUserTimezone = () => { @@ -13,13 +12,9 @@ export const useUserTimezone = () => { const isSystemTimezone = userTimezone === systemTimeZone; - const getTimezoneAbbreviationForPointInTime = (date: Date) => { - return tzName(userTimezone, date, 'short'); - }; - return { userTimezone, isSystemTimezone, - getTimezoneAbbreviationForPointInTime, + systemTimeZone, }; }; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/__tests__/getHighlightedDates.test.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/__tests__/getHighlightedDates.test.ts index 3f8e7a1825f..1b4b8b97e3c 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/__tests__/getHighlightedDates.test.ts +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/__tests__/getHighlightedDates.test.ts @@ -1,58 +1,69 @@ import { getHighlightedDates } from '@/ui/input/components/internal/date/utils/getHighlightedDates'; +import { expect } from '@storybook/test'; +import { Temporal } from 'temporal-polyfill'; jest.useFakeTimers().setSystemTime(new Date('2024-10-01T00:00:00.000Z')); -describe('getHighlightedDates', () => { - it('should should return empty if range is undefined', () => { - const dateRange = undefined; - expect(getHighlightedDates(dateRange)).toEqual([]); - }); +const TIME_ZONE = 'UTC'; +const getUTCPlainDateFromISO = (isoStringDate: string) => { + return Temporal.Instant.from(isoStringDate) + .toZonedDateTimeISO('UTC') + .toPlainDate(); +}; + +describe('getHighlightedDates', () => { it('should should return one day if range is one day', () => { const dateRange = { - start: new Date('2024-10-12T00:00:00.000Z'), - end: new Date('2024-10-12T00:00:00.000Z'), + start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), + end: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), }; - expect(getHighlightedDates(dateRange)).toEqual([ - new Date('2024-10-12T00:00:00.000Z'), - ]); + expect( + getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE), + ).toEqual([getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z')]); }); it('should should return two days if range is 2 days', () => { const dateRange = { - start: new Date('2024-10-12T00:00:00.000Z'), - end: new Date('2024-10-13T00:00:00.000Z'), + start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), + end: getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'), }; - expect(getHighlightedDates(dateRange)).toEqual([ - new Date('2024-10-12T00:00:00.000Z'), - new Date('2024-10-13T00:00:00.000Z'), + expect( + getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE), + ).toEqual([ + getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), + getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'), ]); }); it('should should return 10 days if range is 10 days', () => { const dateRange = { - start: new Date('2024-10-12T00:00:00.000Z'), - end: new Date('2024-10-21T00:00:00.000Z'), + start: getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), + end: getUTCPlainDateFromISO('2024-10-21T00:00:00.000Z'), }; - expect(getHighlightedDates(dateRange)).toEqual([ - new Date('2024-10-12T00:00:00.000Z'), - new Date('2024-10-13T00:00:00.000Z'), - new Date('2024-10-14T00:00:00.000Z'), - new Date('2024-10-15T00:00:00.000Z'), - new Date('2024-10-16T00:00:00.000Z'), - new Date('2024-10-17T00:00:00.000Z'), - new Date('2024-10-18T00:00:00.000Z'), - new Date('2024-10-19T00:00:00.000Z'), - new Date('2024-10-20T00:00:00.000Z'), - new Date('2024-10-21T00:00:00.000Z'), + expect( + getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE), + ).toEqual([ + getUTCPlainDateFromISO('2024-10-12T00:00:00.000Z'), + getUTCPlainDateFromISO('2024-10-13T00:00:00.000Z'), + getUTCPlainDateFromISO('2024-10-14T00:00:00.000Z'), + getUTCPlainDateFromISO('2024-10-15T00:00:00.000Z'), + getUTCPlainDateFromISO('2024-10-16T00:00:00.000Z'), + getUTCPlainDateFromISO('2024-10-17T00:00:00.000Z'), + getUTCPlainDateFromISO('2024-10-18T00:00:00.000Z'), + getUTCPlainDateFromISO('2024-10-19T00:00:00.000Z'), + getUTCPlainDateFromISO('2024-10-20T00:00:00.000Z'), + getUTCPlainDateFromISO('2024-10-21T00:00:00.000Z'), ]); }); it('should should return empty if range is 10 days but out of range', () => { const dateRange = { - start: new Date('2023-10-01T00:00:00.000Z'), - end: new Date('2023-10-10T00:00:00.000Z'), + start: getUTCPlainDateFromISO('2023-10-01T00:00:00.000Z'), + end: getUTCPlainDateFromISO('2023-10-10T00:00:00.000Z'), }; - expect(getHighlightedDates(dateRange)).toEqual([]); + expect( + getHighlightedDates(dateRange.start, dateRange.end, TIME_ZONE), + ).toEqual([]); }); }); diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getHighlightedDates.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getHighlightedDates.ts index ce8aabfd825..c11beeb8330 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getHighlightedDates.ts +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getHighlightedDates.ts @@ -1,25 +1,28 @@ -import { addDays, addMonths, startOfDay, subMonths } from 'date-fns'; +import { Temporal } from 'temporal-polyfill'; +import { isPlainDateAfter, isPlainDateBefore } from 'twenty-shared/utils'; -export const getHighlightedDates = (highlightedDateRange?: { - start: Date; - end: Date; -}): Date[] => { - if (!highlightedDateRange) return []; - const { start, end } = highlightedDateRange; +export const getHighlightedDates = ( + start: Temporal.PlainDate, + end: Temporal.PlainDate, + timeZone: string, +): Temporal.PlainDate[] => { + const highlightedDates: Temporal.PlainDate[] = []; - const highlightedDates: Date[] = []; - const currentDate = startOfDay(new Date()); - const minDate = subMonths(currentDate, 2); - const maxDate = addMonths(currentDate, 2); + const currentDate = Temporal.Now.zonedDateTimeISO(timeZone) + .startOfDay() + .toPlainDate(); - const startDate = start < minDate ? minDate : start; - const lastDate = end > maxDate ? maxDate : end; + const minDate = currentDate.subtract({ months: 2 }); + const maxDate = currentDate.add({ months: 2 }); - let dateToHighlight = new Date(startDate); + const startDate = isPlainDateBefore(start, minDate) ? minDate : start; + const lastDate = isPlainDateAfter(end, maxDate) ? maxDate : end; - while (dateToHighlight <= lastDate) { + let dateToHighlight = startDate; + + while (isPlainDateBefore(dateToHighlight, lastDate.add({ days: 1 }))) { highlightedDates.push(dateToHighlight); - dateToHighlight = addDays(dateToHighlight, 1); + dateToHighlight = dateToHighlight.add({ days: 1 }); } return highlightedDates; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getMonthSelectOptions.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getMonthSelectOptions.ts index 8dbcff2d9ef..9c01090ed48 100644 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getMonthSelectOptions.ts +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getMonthSelectOptions.ts @@ -20,5 +20,5 @@ export const getMonthSelectOptions = ( ): { label: string; value: number }[] => getMonthNames(locale).map((month, index) => ({ label: month, - value: index, + value: index + 1, })); diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime.ts new file mode 100644 index 00000000000..ad1fb12c794 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/getTimeZoneAbbreviationForZonedDateTime.ts @@ -0,0 +1,14 @@ +import { type Temporal } from 'temporal-polyfill'; + +export const getTimezoneAbbreviationForZonedDateTime = ( + zonedDateTime: Temporal.ZonedDateTime, +) => { + const parts = new Intl.DateTimeFormat('en', { + timeZoneName: 'short', + timeZone: zonedDateTime.timeZoneId, + }).formatToParts(new Date(zonedDateTime.toInstant().toString())); + + const timeZoneName = parts.filter((p) => p.type === 'timeZoneName')[0].value; + + return timeZoneName; +}; diff --git a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseDateTimeToString.ts b/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseDateTimeToString.ts deleted file mode 100644 index 8102a347a98..00000000000 --- a/packages/twenty-front/src/modules/ui/input/components/internal/date/utils/parseDateTimeToString.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { DateFormat } from '@/localization/constants/DateFormat'; -import { format } from 'date-fns'; -import { formatInTimeZone } from 'date-fns-tz'; -import { isDefined } from 'twenty-shared/utils'; -import { getDateTimeFormatStringFoDatePickerInputMask } from '~/utils/date-utils'; - -type ParseDateTimeToStringArgs = { - date: Date; - userTimezone: string | undefined; - dateFormat?: DateFormat; -}; - -export const parseDateTimeToString = ({ - date, - userTimezone, - dateFormat = DateFormat.MONTH_FIRST, -}: ParseDateTimeToStringArgs) => { - const parsingFormat = - getDateTimeFormatStringFoDatePickerInputMask(dateFormat); - - if (isDefined(userTimezone)) { - return formatInTimeZone(date, userTimezone, parsingFormat); - } else { - return format(date, parsingFormat); - } -}; diff --git a/packages/twenty-front/src/modules/views/view-filter-value/utils/__tests__/stringifyRelativeDateFilter.test.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/__tests__/stringifyRelativeDateFilter.test.ts index 26e5670a86b..91b8de71159 100644 --- a/packages/twenty-front/src/modules/views/view-filter-value/utils/__tests__/stringifyRelativeDateFilter.test.ts +++ b/packages/twenty-front/src/modules/views/view-filter-value/utils/__tests__/stringifyRelativeDateFilter.test.ts @@ -13,7 +13,7 @@ describe('stringifyRelativeDateFilter', () => { beforeEach(() => { jest.clearAllMocks(); - mockDetectCalendarStartDay.mockReturnValue('MONDAY'); + mockDetectCalendarStartDay.mockReturnValue(FirstDayOfTheWeek.MONDAY); }); describe('basic stringification', () => { @@ -241,7 +241,7 @@ describe('stringifyRelativeDateFilter', () => { }); it('should use detected calendar start day when firstDayOfTheWeek is not provided', () => { - mockDetectCalendarStartDay.mockReturnValue('MONDAY'); + mockDetectCalendarStartDay.mockReturnValue(FirstDayOfTheWeek.MONDAY); const filter: RelativeDateFilter = { direction: 'PAST', @@ -257,7 +257,7 @@ describe('stringifyRelativeDateFilter', () => { }); it('should use detected calendar start day when firstDayOfTheWeek is null', () => { - mockDetectCalendarStartDay.mockReturnValue('SATURDAY'); + mockDetectCalendarStartDay.mockReturnValue(FirstDayOfTheWeek.SATURDAY); const filter: RelativeDateFilter = { direction: 'PAST', diff --git a/packages/twenty-front/src/modules/workflow/constants/WorkflowTimeZone.ts b/packages/twenty-front/src/modules/workflow/constants/WorkflowTimeZone.ts new file mode 100644 index 00000000000..2b47a0d164a --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/constants/WorkflowTimeZone.ts @@ -0,0 +1 @@ +export const WORKFLOW_TIMEZONE = 'UTC'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx index 4a50dbce822..b95fabef3f5 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx @@ -3,6 +3,8 @@ import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/ge import { useGetRelativeDateFilterWithUserTimezone } from '@/object-record/record-filter/hooks/useGetRelativeDateFilterWithUserTimezone'; import { Select } from '@/ui/input/components/Select'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import { WORKFLOW_TIMEZONE } from '@/workflow/constants/WorkflowTimeZone'; + import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings'; import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext'; import { getStepFilterOperands } from '@/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands'; @@ -27,7 +29,7 @@ export const WorkflowStepFilterOperandSelect = ({ const options = operands.map((operand) => ({ value: operand, - label: getOperandLabel(operand), + label: getOperandLabel(operand, WORKFLOW_TIMEZONE), })); const { getRelativeDateFilterWithUserTimezone } = diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx index d3e867325cf..8460d1c6b8f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx @@ -10,6 +10,7 @@ import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-typ import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter'; +import { WORKFLOW_TIMEZONE } from '@/workflow/constants/WorkflowTimeZone'; import { WorkflowStepFilterValueCompositeInput } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueCompositeInput'; import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings'; @@ -270,6 +271,7 @@ export const WorkflowStepFilterValueInput = ({ readonly={readonly} VariablePicker={WorkflowVariablePicker} placeholder={t`Enter value`} + timeZone={WORKFLOW_TIMEZONE} /> ); }; diff --git a/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsCalendarStartDaySelect.tsx b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsCalendarStartDaySelect.tsx index 153b8010c05..77a3591963f 100644 --- a/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsCalendarStartDaySelect.tsx +++ b/packages/twenty-front/src/pages/settings/profile/appearance/components/DateTimeSettingsCalendarStartDaySelect.tsx @@ -1,11 +1,10 @@ import { useMemo } from 'react'; -import { CalendarStartDay } from 'twenty-shared'; - import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; import { Select } from '@/ui/input/components/Select'; import { type DayNameWithIndex } from '@/ui/input/components/internal/date/types/DayNameWithIndex'; import { t } from '@lingui/core/macro'; +import { CalendarStartDay } from 'twenty-shared/constants'; import { type SelectOption } from 'twenty-ui/input'; type DateTimeSettingsCalendarStartDaySelectProps = { diff --git a/packages/twenty-front/src/utils/date-utils.ts b/packages/twenty-front/src/utils/date-utils.ts index b4ca5a8e00f..ebae560f729 100644 --- a/packages/twenty-front/src/utils/date-utils.ts +++ b/packages/twenty-front/src/utils/date-utils.ts @@ -19,6 +19,8 @@ import { i18n } from '@lingui/core'; import { plural, t } from '@lingui/core/macro'; import { logError } from './logError'; +// TODO: review all of this with Temporal +// - also break down this into small files export const parseDate = (dateToParse: Date | string | number): Date => { if (dateToParse === 'now') return new Date(); diff --git a/packages/twenty-front/src/utils/dates/formatZonedDateTimeDatePart.ts b/packages/twenty-front/src/utils/dates/formatZonedDateTimeDatePart.ts new file mode 100644 index 00000000000..203ca8b4fbd --- /dev/null +++ b/packages/twenty-front/src/utils/dates/formatZonedDateTimeDatePart.ts @@ -0,0 +1,26 @@ +import { detectDateFormat } from '@/localization/utils/detection/detectDateFormat'; +import { type Temporal } from 'temporal-polyfill'; +import { WorkspaceMemberDateFormatEnum } from '~/generated/graphql'; + +export const formatZonedDateTimeDatePart = ( + zonedDateTime: Temporal.ZonedDateTime, + dateFormat: WorkspaceMemberDateFormatEnum, +): string => { + const MMM = zonedDateTime.toLocaleString('en-US', { month: 'short' }); + const d = zonedDateTime.day; + const yyyy = zonedDateTime.year; + + switch (dateFormat) { + case WorkspaceMemberDateFormatEnum.SYSTEM: { + const detectedFormat = WorkspaceMemberDateFormatEnum[detectDateFormat()]; + + return formatZonedDateTimeDatePart(zonedDateTime, detectedFormat); + } + case WorkspaceMemberDateFormatEnum.MONTH_FIRST: + return `${MMM} ${d}, ${yyyy}`; + case WorkspaceMemberDateFormatEnum.DAY_FIRST: + return `${d} ${MMM}, ${yyyy}`; + case WorkspaceMemberDateFormatEnum.YEAR_FIRST: + return `${yyyy} ${MMM} ${d}`; + } +}; diff --git a/packages/twenty-front/src/utils/dates/formatZonedDateTimeTimePart.ts b/packages/twenty-front/src/utils/dates/formatZonedDateTimeTimePart.ts new file mode 100644 index 00000000000..df444e98172 --- /dev/null +++ b/packages/twenty-front/src/utils/dates/formatZonedDateTimeTimePart.ts @@ -0,0 +1,28 @@ +import { detectTimeFormat } from '@/localization/utils/detection/detectTimeFormat'; +import { type Temporal } from 'temporal-polyfill'; +import { WorkspaceMemberTimeFormatEnum } from '~/generated/graphql'; + +export const formatZonedDateTimeTimePart = ( + zonedDateTime: Temporal.ZonedDateTime, + dateFormat: WorkspaceMemberTimeFormatEnum, +): string => { + const h24 = zonedDateTime.hour.toString().padStart(2, '0'); + const m = zonedDateTime.minute.toString().padStart(2, '0'); + + switch (dateFormat) { + case WorkspaceMemberTimeFormatEnum.SYSTEM: { + const detectedFormat = WorkspaceMemberTimeFormatEnum[detectTimeFormat()]; + + return formatZonedDateTimeTimePart(zonedDateTime, detectedFormat); + } + case WorkspaceMemberTimeFormatEnum.HOUR_12: { + const hour12 = zonedDateTime.hour % 12 || 12; + const suffix = zonedDateTime.hour < 12 ? 'AM' : 'PM'; + + return `${hour12}:${m} ${suffix}`; + } + case WorkspaceMemberTimeFormatEnum.HOUR_24: { + return `${h24}:${m}`; + } + } +}; diff --git a/packages/twenty-front/src/utils/dates/isDifferentZonedDateTime.ts b/packages/twenty-front/src/utils/dates/isDifferentZonedDateTime.ts new file mode 100644 index 00000000000..128993e9891 --- /dev/null +++ b/packages/twenty-front/src/utils/dates/isDifferentZonedDateTime.ts @@ -0,0 +1,18 @@ +import { Temporal } from 'temporal-polyfill'; +import { type Nullable } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export const isDifferentZonedDateTime = ( + zonedDateTimeA: Nullable, + zonedDateTimeB: Nullable, +) => { + if (!isDefined(zonedDateTimeA) && !isDefined(zonedDateTimeB)) { + return false; + } + + if (!isDefined(zonedDateTimeA) || !isDefined(zonedDateTimeB)) { + return true; + } + + return Temporal.ZonedDateTime.compare(zonedDateTimeA, zonedDateTimeB) !== 0; +}; diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-and-date-time-field-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-and-date-time-field-or-throw.util.ts index a4a39c3733f..c1884245c72 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-and-date-time-field-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-date-and-date-time-field-or-throw.util.ts @@ -7,6 +7,7 @@ import { CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; +// TODO: should be splitted in both validateDate and validateDateTime because both format are different even if Date parses them indeferrently export const validateDateAndDateTimeFieldOrThrow = ( value: unknown, fieldName: string, diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts index 011c2810db0..1c2b22071a9 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts @@ -253,7 +253,9 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic recordFilters, recordFilterGroups: recordFilterGroups, fields, - filterValueDependencies: {}, + filterValueDependencies: { + timeZone: 'UTC', // TODO: see if we use workspace member timezone here + }, }); let view: ViewEntity | null = viewFilters[0]?.view; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts index 9d8ded9cc0e..5da8fbd228c 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/errors/common-query-runner.exception.ts @@ -18,6 +18,7 @@ export enum CommonQueryRunnerExceptionCode { BAD_REQUEST = 'BAD_REQUEST', INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', TOO_COMPLEX_QUERY = 'TOO_COMPLEX_QUERY', + MISSING_TIMEZONE_FOR_DATE_GROUP_BY = 'MISSING_TIMEZONE_FOR_DATE_GROUP_BY', } const commonQueryRunnerExceptionUserFriendlyMessages: Record< @@ -38,6 +39,7 @@ const commonQueryRunnerExceptionUserFriendlyMessages: Record< [CommonQueryRunnerExceptionCode.BAD_REQUEST]: msg`Bad request.`, [CommonQueryRunnerExceptionCode.INTERNAL_SERVER_ERROR]: msg`An unexpected error occurred.`, [CommonQueryRunnerExceptionCode.TOO_COMPLEX_QUERY]: msg`Query is too complex.`, + [CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY]: msg`Missing time zone for date group by.`, }; export class CommonQueryRunnerException extends CustomException { diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts index 744fb258406..758c64239cc 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-graphql-api-exception-handler.util.ts @@ -1,8 +1,8 @@ import { assertUnreachable } from 'twenty-shared/utils'; import { - CommonQueryRunnerExceptionCode, type CommonQueryRunnerException, + CommonQueryRunnerExceptionCode, } from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; import { AuthenticationError, @@ -27,6 +27,7 @@ export const commonQueryRunnerToGraphqlApiExceptionHandler = ( case CommonQueryRunnerExceptionCode.TOO_MANY_RECORDS_TO_UPDATE: case CommonQueryRunnerExceptionCode.BAD_REQUEST: case CommonQueryRunnerExceptionCode.TOO_COMPLEX_QUERY: + case CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY: throw new UserInputError(error); case CommonQueryRunnerExceptionCode.INVALID_AUTH_CONTEXT: throw new AuthenticationError(error); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts index 2f897ddf573..7eab629ab27 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/common-query-runner-to-rest-api-exception-handler.util.ts @@ -26,6 +26,7 @@ export const commonQueryRunnerToRestApiExceptionHandler = ( case CommonQueryRunnerExceptionCode.TOO_MANY_RECORDS_TO_UPDATE: case CommonQueryRunnerExceptionCode.BAD_REQUEST: case CommonQueryRunnerExceptionCode.TOO_COMPLEX_QUERY: + case CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY: throw new BadRequestException(error.message); case CommonQueryRunnerExceptionCode.RECORD_NOT_FOUND: throw new NotFoundException('Record not found'); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts index 8d611165c60..06028db725c 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/date-field-group-by-definition.type.ts @@ -4,4 +4,5 @@ import { type FirstDayOfTheWeek } from 'twenty-shared/utils'; export type DateFieldGroupByDefinition = { granularity: ObjectRecordGroupByDateGranularity; weekStartDay?: FirstDayOfTheWeek; + timeZone?: string; }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts index 6d09f12c61a..3a36d2e895f 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types.ts @@ -7,19 +7,24 @@ export type GroupByRegularField = { fieldMetadata: FlatFieldMetadata; subFieldName?: string; }; + export type GroupByDateField = { fieldMetadata: FlatFieldMetadata; subFieldName?: string; dateGranularity: ObjectRecordGroupByDateGranularity; weekStartDay?: FirstDayOfTheWeek; + timeZone?: string; }; + export type GroupByRelationField = { fieldMetadata: FlatFieldMetadata; nestedFieldMetadata: FlatFieldMetadata; nestedSubFieldName?: string; dateGranularity?: ObjectRecordGroupByDateGranularity; weekStartDay?: FirstDayOfTheWeek; + timeZone?: string; }; + export type GroupByField = | GroupByRegularField | GroupByDateField diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts index d44598a5442..f40791493c4 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util.ts @@ -1,6 +1,15 @@ -import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { isNonEmptyString } from '@sniptt/guards'; +import { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from 'twenty-shared/constants'; +import { + FieldMetadataType, + ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; import { assertUnreachable, isDefined } from 'twenty-shared/utils'; +import { + CommonQueryRunnerException, + CommonQueryRunnerExceptionCode, +} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception'; import { type GroupByField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/types/group-by-field.types'; import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util'; import { isGroupByRelationField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-relation-field.util'; @@ -24,6 +33,28 @@ export const getGroupByExpression = ({ return columnNameWithQuotes; } + const shouldUseTimeZone = + GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE.includes( + dateGranularity, + ) && groupByField.fieldMetadata.type === FieldMetadataType.DATE_TIME; + + const timeZoneIsNotProvided = !isNonEmptyString(groupByField.timeZone); + + if (shouldUseTimeZone && timeZoneIsNotProvided) { + throw new CommonQueryRunnerException( + 'Time zone should be specified for a group by date on Day, Week, Month, Quarter or Year', + CommonQueryRunnerExceptionCode.MISSING_TIMEZONE_FOR_DATE_GROUP_BY, + ); + } + + const timeZoneAsDateTruncParameter = shouldUseTimeZone + ? `, '${groupByField.timeZone}'` + : ''; + + const timeZoneAsToCharParameter = shouldUseTimeZone + ? ` AT TIME ZONE '${groupByField.timeZone}'` + : ''; + switch (dateGranularity) { case ObjectRecordGroupByDateGranularity.NONE: return columnNameWithQuotes; @@ -35,23 +66,23 @@ export const getGroupByExpression = ({ return `TRIM(TO_CHAR(${columnNameWithQuotes}, '"Q"Q'))`; case ObjectRecordGroupByDateGranularity.WEEK: { const weekStartDay = groupByField.weekStartDay; - let shiftedExpression = `DATE_TRUNC('week', ${columnNameWithQuotes})`; + let shiftedExpression = `DATE_TRUNC('week', ${columnNameWithQuotes}${timeZoneAsDateTruncParameter})`; if (isDefined(weekStartDay)) { if (weekStartDay === 'SUNDAY') { - shiftedExpression = `DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '1 day') - INTERVAL '1 day'`; + shiftedExpression = `(DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '1 day'${timeZoneAsDateTruncParameter}) - INTERVAL '1 day')`; } else if (weekStartDay === 'SATURDAY') { - shiftedExpression = `DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '2 days') - INTERVAL '2 days'`; + shiftedExpression = `(DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '2 days'${timeZoneAsDateTruncParameter}) - INTERVAL '2 days')`; } } - return `TO_CHAR(${shiftedExpression}, 'YYYY-MM-DD"T"HH24:MI:SSTZH:TZM')`; + return `TO_CHAR(${shiftedExpression}${timeZoneAsToCharParameter}, 'YYYY-MM-DD')`; } case ObjectRecordGroupByDateGranularity.DAY: case ObjectRecordGroupByDateGranularity.MONTH: case ObjectRecordGroupByDateGranularity.QUARTER: case ObjectRecordGroupByDateGranularity.YEAR: - return `TO_CHAR(DATE_TRUNC('${dateGranularity}', ${columnNameWithQuotes}), 'YYYY-MM-DD"T"HH24:MI:SSTZH:TZM')`; + return `TO_CHAR(DATE_TRUNC('${dateGranularity}', ${columnNameWithQuotes}${timeZoneAsDateTruncParameter})${timeZoneAsToCharParameter}, 'YYYY-MM-DD')`; default: assertUnreachable(dateGranularity); } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts index 6acbb14dd56..1b1faf65758 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts @@ -83,6 +83,7 @@ export const parseGroupByArgs = ( fieldMetadata, dateGranularity: fieldGroupByDefinition.granularity, weekStartDay: fieldGroupByDefinition.weekStartDay, + timeZone: fieldGroupByDefinition.timeZone, }); continue; } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts index a2e4d437474..0afb620ab9d 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-relation-field.util.ts @@ -173,6 +173,7 @@ export const parseGroupByRelationField = ({ nestedFieldMetadata, dateGranularity: dateFieldDefinition.granularity, weekStartDay: dateFieldDefinition.weekStartDay, + timeZone: dateFieldDefinition.timeZone, }); return; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts index 5ba490fe7b8..eed8f96fa1b 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface.ts @@ -29,6 +29,7 @@ export type ObjectRecordGroupByForDateField = Partial<{ [Property in keyof ObjectRecord]: { granularity: ObjectRecordGroupByDateGranularity; weekStartDay?: FirstDayOfTheWeek; + timeZone?: string; }; }>; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts index ab7fefc4608..9d5fba1c605 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator.ts @@ -1,6 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { GraphQLEnumType, GraphQLInputObjectType } from 'graphql'; +import { + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLString, +} from 'graphql'; import { FirstDayOfTheWeek, ObjectRecordGroupByDateGranularity, @@ -73,6 +77,11 @@ export class GroupByDateGranularityInputTypeGenerator { description: 'First day of the week (only applicable when granularity is WEEK). Defaults to MONDAY if not specified.', }, + timeZone: { + type: GraphQLString, + description: + 'Timezone used to compute the aggregate value and in which is expressed the granular period, for example a day in UTC-12 is not the same period as a day in UTC+3, the requester needs to precise this otherwise the server will assume a timezone and the requester cannot know in which timezone the aggregate values are computed.', + }, }, }); diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts index bb5ce127198..d66a6375808 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/view-query-params.service.ts @@ -115,7 +115,7 @@ export class ViewQueryParamsService { fields, recordFilters, recordFilterGroups, - filterValueDependencies: { currentWorkspaceMemberId }, + filterValueDependencies: { currentWorkspaceMemberId, timeZone: 'UTC' }, // TODO: check if we need to put workspace member timezone here }); const orderBy: ObjectRecordOrderBy = (view.viewSorts ?? []) diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts index a3108812bed..b54d6045b5c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-relative-date-filter.util.spec.ts @@ -22,6 +22,8 @@ import { parseAndEvaluateRelativeDateFilter, } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util'; +// TODO: this test should be in twenty-shared, and the logic that is duplicated both front end and back end, +// should be merged and properly refactored with Temporal to unify and simplify this bug-prone zone of the codebase. describe('Relative Date Filter Utils', () => { const now = new Date('2024-01-15T12:00:00Z'); // Monday, January 15, 2024 at noon @@ -795,6 +797,7 @@ describe('Relative Date Filter Utils', () => { relativeDateFilterValue, }), ).toBe(true); + // TODO: this test fails if the exec env is not UTC, should be replaced by Temporal soon expect( evaluateRelativeDateFilter({ dateToCheck: new Date('2024-01-15T08:00:00Z'), diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts index c92802226aa..790b4aefe4d 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts @@ -211,6 +211,7 @@ function evaluateBooleanFilter(filter: ResolvedFilter): boolean { } function evaluateDateFilter(filter: ResolvedFilter): boolean { + // TODO: refactor this with Temporal const dateLeftValue = new Date(String(filter.leftOperand)); switch (filter.operand) { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts index ae89305bf67..9a530f70d4e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/parse-and-evaluate-relative-date-filter.util.ts @@ -2,9 +2,9 @@ import { isNonEmptyString } from '@sniptt/guards'; import { endOfDay, endOfHour, - endOfSecond, endOfMinute, endOfMonth, + endOfSecond, endOfWeek, endOfYear, isWithinInterval, @@ -25,6 +25,8 @@ import { subUnitFromDateTime, } from 'twenty-shared/utils'; +// TODO: Merge this logic with resolveRelativeDateFilter in twenty-shared +// But it is not urgent since we force all workflow filters to be in UTC export const parseAndEvaluateRelativeDateFilter = ({ dateToCheck, relativeDateString, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts index 9adf54170a3..353018cba7e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts @@ -101,7 +101,9 @@ export class FindRecordsWorkflowAction implements WorkflowAction { fields, recordFilters: workflowActionInput.filter.recordFilters, recordFilterGroups: workflowActionInput.filter.recordFilterGroups, - filterValueDependencies: {}, + filterValueDependencies: { + timeZone: 'UTC', + }, }) : {}; diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts index 9e64c769bbb..7cd719b7e96 100644 --- a/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts @@ -300,7 +300,7 @@ describe('group-by resolver (integration)', () => { }, { createdAt: { - lte: '2025-03-03T23:59:59.999Z', + lt: '2025-03-04T00:00:00.000Z', }, }, ], @@ -311,7 +311,9 @@ describe('group-by resolver (integration)', () => { groupByOperationFactory({ objectMetadataSingularName: 'person', objectMetadataPluralName: 'people', - groupBy: [{ createdAt: { granularity: 'MONTH' } }], + groupBy: [ + { createdAt: { granularity: 'MONTH', timeZone: 'Europe/Paris' } }, + ], filter: filter2025, }), ); @@ -322,9 +324,6 @@ describe('group-by resolver (integration)', () => { expect(Array.isArray(groups)).toBe(true); expect(groups.length).toBe(2); - // Expect two groups: one with 2 records (January) and one with 1 record (March) - // Note: DATE_TRUNC returns dates in server timezone, which when converted to UTC - // may show as the previous day at 23:00 (e.g., 2024-12-31T23:00 for Jan 1 local) const groupWith2Records = groups.find((g: any) => g.totalCount === 2); const groupWith1Record = groups.find((g: any) => g.totalCount === 1); @@ -393,7 +392,9 @@ describe('group-by resolver (integration)', () => { groupByOperationFactory({ objectMetadataSingularName: 'person', objectMetadataPluralName: 'people', - groupBy: [{ createdAt: { granularity: 'WEEK' } }], + groupBy: [ + { createdAt: { granularity: 'WEEK', timeZone: 'Europe/Paris' } }, + ], gqlFields: ` edges { node { @@ -413,7 +414,7 @@ describe('group-by resolver (integration)', () => { // Group starting week of monday dec 30th, 2024 const mondayDec30thGroup = groups.find((group: any) => - group.groupByDimensionValues[0].startsWith('2024-12-30T00:00:00'), + group.groupByDimensionValues[0].startsWith('2024-12-30'), ); expect(mondayDec30thGroup).toBeDefined(); @@ -422,7 +423,7 @@ describe('group-by resolver (integration)', () => { // Group starting week of monday jan 6th, 2025 const mondayJan6thGroup = groups.find((group: any) => - group.groupByDimensionValues[0].startsWith('2025-01-06T00:00:00'), + group.groupByDimensionValues[0].startsWith('2025-01-06'), ); expect(mondayJan6thGroup).toBeDefined(); @@ -431,7 +432,7 @@ describe('group-by resolver (integration)', () => { // Group starting week of monday feb 24th, 2025 const mondayFeb24thGroup = groups.find((group: any) => - group.groupByDimensionValues[0].startsWith('2025-02-24T00:00:00'), + group.groupByDimensionValues[0].startsWith('2025-02-24'), ); expect(mondayFeb24thGroup).toBeDefined(); @@ -449,7 +450,7 @@ describe('group-by resolver (integration)', () => { // Group starting week of monday march 3rd, 2025 const mondayMarch3rdGroup = groups.find((group: any) => - group.groupByDimensionValues[0].startsWith('2025-03-03T00:00:00'), + group.groupByDimensionValues[0].startsWith('2025-03-03'), ); expect(mondayMarch3rdGroup).toBeDefined(); @@ -472,7 +473,13 @@ describe('group-by resolver (integration)', () => { objectMetadataSingularName: 'person', objectMetadataPluralName: 'people', groupBy: [ - { createdAt: { granularity: 'WEEK', weekStartDay: 'SUNDAY' } }, + { + createdAt: { + granularity: 'WEEK', + weekStartDay: 'SUNDAY', + timeZone: 'Europe/Paris', + }, + }, ], gqlFields: ` edges { @@ -493,7 +500,7 @@ describe('group-by resolver (integration)', () => { // Group starting week of sunday dec 29th, 2024 const sundayDec29thGroup = groups.find((group: any) => - group.groupByDimensionValues[0].startsWith('2024-12-29T00:00:00'), + group.groupByDimensionValues[0].startsWith('2024-12-29'), ); expect(sundayDec29thGroup).toBeDefined(); @@ -504,7 +511,7 @@ describe('group-by resolver (integration)', () => { // Group starting week of sunday jan 5th, 2025 const sundayJan5thGroup = groups.find((group: any) => - group.groupByDimensionValues[0].startsWith('2025-01-05T00:00:00'), + group.groupByDimensionValues[0].startsWith('2025-01-05'), ); expect(sundayJan5thGroup).toBeDefined(); @@ -515,7 +522,7 @@ describe('group-by resolver (integration)', () => { // Group starting week of sunday feb 23rd, 2025 const sundayFeb23rdGroup = groups.find((group: any) => - group.groupByDimensionValues[0].startsWith('2025-02-23T00:00:00'), + group.groupByDimensionValues[0].startsWith('2025-02-23'), ); expect(sundayFeb23rdGroup).toBeDefined(); @@ -528,7 +535,7 @@ describe('group-by resolver (integration)', () => { // Group starting week of sunday march 2nd, 2025 const sundayMarch2ndGroup = groups.find((group: any) => - group.groupByDimensionValues[0].startsWith('2025-03-02T00:00:00'), + group.groupByDimensionValues[0].startsWith('2025-03-02'), ); expect(sundayMarch2ndGroup).toBeDefined(); @@ -556,7 +563,13 @@ describe('group-by resolver (integration)', () => { objectMetadataSingularName: 'person', objectMetadataPluralName: 'people', groupBy: [ - { createdAt: { granularity: 'WEEK', weekStartDay: 'SATURDAY' } }, + { + createdAt: { + granularity: 'WEEK', + weekStartDay: 'SATURDAY', + timeZone: 'Europe/Paris', + }, + }, ], gqlFields: ` edges { @@ -577,7 +590,7 @@ describe('group-by resolver (integration)', () => { // Group starting week of saturday dec 28th, 2024 const saturdayDec28thGroup = groups.find((group: any) => - group.groupByDimensionValues[0].startsWith('2024-12-28T00:00:00'), + group.groupByDimensionValues[0].startsWith('2024-12-28'), ); expect(saturdayDec28thGroup).toBeDefined(); @@ -590,7 +603,7 @@ describe('group-by resolver (integration)', () => { // Group starting week of saturday jan 4th, 2025 const saturdayJan4thGroup = groups.find((group: any) => - group.groupByDimensionValues[0].startsWith('2025-01-04T00:00:00'), + group.groupByDimensionValues[0].startsWith('2025-01-04'), ); expect(saturdayJan4thGroup).toBeDefined(); @@ -603,7 +616,7 @@ describe('group-by resolver (integration)', () => { // Group starting week of saturday march 1st, 2025 const saturdayMarch1stGroup = groups.find((group: any) => - group.groupByDimensionValues[0].startsWith('2025-03-01T00:00:00'), + group.groupByDimensionValues[0].startsWith('2025-03-01'), ); expect(saturdayMarch1stGroup).toBeDefined(); @@ -641,7 +654,7 @@ describe('group-by resolver (integration)', () => { }, { createdAt: { - lte: '2025-03-03T23:59:59.999Z', + lt: '2025-03-04T00:00:00.000Z', }, }, ], diff --git a/packages/twenty-shared/src/constants/GroupByDateGranularityThatRequireTimeZone.ts b/packages/twenty-shared/src/constants/GroupByDateGranularityThatRequireTimeZone.ts new file mode 100644 index 00000000000..63664b499db --- /dev/null +++ b/packages/twenty-shared/src/constants/GroupByDateGranularityThatRequireTimeZone.ts @@ -0,0 +1,9 @@ +import { ObjectRecordGroupByDateGranularity } from '@/types'; + +export const GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE = [ + ObjectRecordGroupByDateGranularity.DAY, + ObjectRecordGroupByDateGranularity.WEEK, + ObjectRecordGroupByDateGranularity.MONTH, + ObjectRecordGroupByDateGranularity.QUARTER, + ObjectRecordGroupByDateGranularity.YEAR, +]; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index fb4cac181bd..1f4db06df98 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -17,6 +17,7 @@ export { DEFAULT_RELATIVE_DATE_FILTER_VALUE } from './DefaultRelativeDateFilterV export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation'; export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay'; export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired'; +export { GROUP_BY_DATE_GRANULARITY_THAT_REQUIRE_TIME_ZONE } from './GroupByDateGranularityThatRequireTimeZone'; export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes'; export { MULTI_ITEM_FIELD_DEFAULT_MAX_VALUES } from './MultiItemFieldDefaultMaxValues'; export { MULTI_ITEM_FIELD_MIN_MAX_VALUES } from './MultiItemFieldMinMaxValues'; diff --git a/packages/twenty-shared/src/index.ts b/packages/twenty-shared/src/index.ts index fa453dd42df..5d375033adf 100644 --- a/packages/twenty-shared/src/index.ts +++ b/packages/twenty-shared/src/index.ts @@ -7,6 +7,5 @@ * |___/ */ -export { CalendarStartDay } from './constants'; export default {}; diff --git a/packages/twenty-shared/src/types/ArraySortDirection.ts b/packages/twenty-shared/src/types/ArraySortDirection.ts new file mode 100644 index 00000000000..d4dbfbab2c1 --- /dev/null +++ b/packages/twenty-shared/src/types/ArraySortDirection.ts @@ -0,0 +1 @@ +export type ArraySortDirection = 'asc' | 'desc'; diff --git a/packages/twenty-shared/src/types/RecordFilterValueDependencies.ts b/packages/twenty-shared/src/types/RecordFilterValueDependencies.ts index d7a59fdbd1c..0aa19736206 100644 --- a/packages/twenty-shared/src/types/RecordFilterValueDependencies.ts +++ b/packages/twenty-shared/src/types/RecordFilterValueDependencies.ts @@ -1,3 +1,4 @@ export interface RecordFilterValueDependencies { currentWorkspaceMemberId?: string; + timeZone?: string; } diff --git a/packages/twenty-shared/src/types/ViewFilterOperand.ts b/packages/twenty-shared/src/types/ViewFilterOperand.ts index 3ed29194cd3..7fde903c5ca 100644 --- a/packages/twenty-shared/src/types/ViewFilterOperand.ts +++ b/packages/twenty-shared/src/types/ViewFilterOperand.ts @@ -5,7 +5,7 @@ export enum ViewFilterOperand { LESS_THAN_OR_EQUAL = 'LESS_THAN_OR_EQUAL', // TODO: we could change this to 'lessThanOrEqual' for consistency but it would require a migration GREATER_THAN_OR_EQUAL = 'GREATER_THAN_OR_EQUAL', // TODO: we could change this to 'greaterThanOrEqual' for consistency but it would require a migration IS_BEFORE = 'IS_BEFORE', - IS_AFTER = 'IS_AFTER', + IS_AFTER = 'IS_AFTER', // TODO: migrate this to IS_AFTER_OR_EQUAL CONTAINS = 'CONTAINS', DOES_NOT_CONTAIN = 'DOES_NOT_CONTAIN', IS_EMPTY = 'IS_EMPTY', diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 3eedd2b2d41..4e242d4bd60 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -12,6 +12,7 @@ export { ALLOWED_ADDRESS_SUBFIELDS } from './AddressFieldsType'; export { AppBasePath } from './AppBasePath'; export { AppPath } from './AppPath'; export type { Arrayable } from './Arrayable'; +export type { ArraySortDirection } from './ArraySortDirection'; export type { ActorMetadata } from './composite-types/actor.composite-type'; export { FieldActorSource, diff --git a/packages/twenty-shared/src/utils/__tests__/rich-text-variable-resolver.test.ts b/packages/twenty-shared/src/utils/__tests__/rich-text-variable-resolver.test.ts index 1cffeb53544..4e2dc815e0c 100644 --- a/packages/twenty-shared/src/utils/__tests__/rich-text-variable-resolver.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/rich-text-variable-resolver.test.ts @@ -135,12 +135,6 @@ describe('resolveRichTextVariables', () => { expect(result).toBe(input); }); - it('should return null for null input', () => { - const result = resolveRichTextVariables(null, context); - - expect(result).toBeNull(); - }); - it('should return undefined for undefined input', () => { const result = resolveRichTextVariables(undefined, context); diff --git a/packages/twenty-shared/src/utils/__tests__/safeParseRelativeDateFilterValue.test.ts b/packages/twenty-shared/src/utils/__tests__/safeParseRelativeDateFilterValue.test.ts index 2b425e2111b..db6187c9f09 100644 --- a/packages/twenty-shared/src/utils/__tests__/safeParseRelativeDateFilterValue.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/safeParseRelativeDateFilterValue.test.ts @@ -231,47 +231,47 @@ describe('safeParseRelativeDateFilterJSONStringified', () => { }); describe('THIS direction', () => { - it('should parse THIS direction with SECOND unit (no amount)', () => { - const input = JSON.stringify({ - direction: 'THIS', - unit: 'SECOND', - }); + it('should parse THIS direction with SECOND unit (no amount)', () => { + const input = JSON.stringify({ + direction: 'THIS', + unit: 'SECOND', + }); - const result = safeParseRelativeDateFilterJSONStringified(input); + const result = safeParseRelativeDateFilterJSONStringified(input); - expect(result).toEqual({ - direction: 'THIS', - unit: 'SECOND', - }); - }); + expect(result).toEqual({ + direction: 'THIS', + unit: 'SECOND', + }); + }); - it('should parse THIS direction with MINUTE unit (no amount)', () => { - const input = JSON.stringify({ - direction: 'THIS', - unit: 'MINUTE', - }); + it('should parse THIS direction with MINUTE unit (no amount)', () => { + const input = JSON.stringify({ + direction: 'THIS', + unit: 'MINUTE', + }); - const result = safeParseRelativeDateFilterJSONStringified(input); + const result = safeParseRelativeDateFilterJSONStringified(input); - expect(result).toEqual({ - direction: 'THIS', - unit: 'MINUTE', - }); - }); + expect(result).toEqual({ + direction: 'THIS', + unit: 'MINUTE', + }); + }); - it('should parse THIS direction with HOUR unit (no amount)', () => { - const input = JSON.stringify({ - direction: 'THIS', - unit: 'HOUR', - }); + it('should parse THIS direction with HOUR unit (no amount)', () => { + const input = JSON.stringify({ + direction: 'THIS', + unit: 'HOUR', + }); - const result = safeParseRelativeDateFilterJSONStringified(input); + const result = safeParseRelativeDateFilterJSONStringified(input); - expect(result).toEqual({ - direction: 'THIS', - unit: 'HOUR', - }); - }); + expect(result).toEqual({ + direction: 'THIS', + unit: 'HOUR', + }); + }); it('should parse THIS direction with DAY unit (no amount)', () => { const input = JSON.stringify({ @@ -403,7 +403,7 @@ describe('safeParseRelativeDateFilterJSONStringified', () => { const input = JSON.stringify({ direction: 'NEXT', amount: 1, - unit: 'HOUR', + unit: 'ASD', }); const result = safeParseRelativeDateFilterJSONStringified(input); diff --git a/packages/twenty-shared/src/utils/date/__tests__/sortPlainDate.spec.ts b/packages/twenty-shared/src/utils/date/__tests__/sortPlainDate.spec.ts new file mode 100644 index 00000000000..7b7bc7f1854 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/__tests__/sortPlainDate.spec.ts @@ -0,0 +1,61 @@ +import { Temporal } from 'temporal-polyfill'; + +import { sortPlainDate } from '../sortPlainDate'; + +describe('sortPlainDate', () => { + const earlyDate = Temporal.PlainDate.from('2024-01-15'); + const middleDate = Temporal.PlainDate.from('2024-06-20'); + const lateDate = Temporal.PlainDate.from('2024-12-25'); + + describe('ascending order', () => { + const comparator = sortPlainDate('asc'); + + it('should return negative when first date is earlier', () => { + expect(comparator(earlyDate, lateDate)).toBeLessThan(0); + }); + + it('should return positive when first date is later', () => { + expect(comparator(lateDate, earlyDate)).toBeGreaterThan(0); + }); + + it('should return zero when dates are equal', () => { + const sameDate = Temporal.PlainDate.from('2024-06-20'); + + expect(comparator(middleDate, sameDate)).toBe(0); + }); + }); + + describe('descending order', () => { + const comparator = sortPlainDate('desc'); + + it('should return positive when first date is earlier', () => { + expect(comparator(earlyDate, lateDate)).toBeGreaterThan(0); + }); + + it('should return negative when first date is later', () => { + expect(comparator(lateDate, earlyDate)).toBeLessThan(0); + }); + + it('should return zero when dates are equal', () => { + const sameDate = Temporal.PlainDate.from('2024-06-20'); + + expect(comparator(middleDate, sameDate)).toStrictEqual(0); + }); + }); + + describe('array sorting', () => { + it('should sort dates in ascending order', () => { + const dates = [middleDate, lateDate, earlyDate]; + const sorted = [...dates].sort(sortPlainDate('asc')); + + expect(sorted).toEqual([earlyDate, middleDate, lateDate]); + }); + + it('should sort dates in descending order', () => { + const dates = [middleDate, lateDate, earlyDate]; + const sorted = [...dates].sort(sortPlainDate('desc')); + + expect(sorted).toEqual([lateDate, middleDate, earlyDate]); + }); + }); +}); diff --git a/packages/twenty-shared/src/utils/date/isPlainDateAfter.ts b/packages/twenty-shared/src/utils/date/isPlainDateAfter.ts new file mode 100644 index 00000000000..4f44844b105 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/isPlainDateAfter.ts @@ -0,0 +1,8 @@ +import { Temporal } from 'temporal-polyfill'; + +export const isPlainDateAfter = ( + a: Temporal.PlainDate, + b: Temporal.PlainDate, +) => { + return Temporal.PlainDate.compare(a, b) === 1; +}; diff --git a/packages/twenty-shared/src/utils/date/isPlainDateBefore.ts b/packages/twenty-shared/src/utils/date/isPlainDateBefore.ts new file mode 100644 index 00000000000..0c9e8d1a2c7 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/isPlainDateBefore.ts @@ -0,0 +1,8 @@ +import { Temporal } from 'temporal-polyfill'; + +export const isPlainDateBefore = ( + a: Temporal.PlainDate, + b: Temporal.PlainDate, +) => { + return Temporal.PlainDate.compare(a, b) === -1; +}; diff --git a/packages/twenty-shared/src/utils/date/isPlainDateBeforeOrEqual.ts b/packages/twenty-shared/src/utils/date/isPlainDateBeforeOrEqual.ts new file mode 100644 index 00000000000..54a4be41008 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/isPlainDateBeforeOrEqual.ts @@ -0,0 +1,10 @@ +import { Temporal } from 'temporal-polyfill'; + +export const isPlainDateBeforeOrEqual = ( + plainDateA: Temporal.PlainDate, + plainDateB: Temporal.PlainDate, +) => { + const comparisonResult = Temporal.PlainDate.compare(plainDateA, plainDateB); + + return comparisonResult <= 0; +}; diff --git a/packages/twenty-shared/src/utils/date/isPlainDateInSameMonth.ts b/packages/twenty-shared/src/utils/date/isPlainDateInSameMonth.ts new file mode 100644 index 00000000000..53241e7aa7c --- /dev/null +++ b/packages/twenty-shared/src/utils/date/isPlainDateInSameMonth.ts @@ -0,0 +1,10 @@ +import { type Temporal } from 'temporal-polyfill'; + +export const isPlainDateInSameMonth = ( + plainDateA: Temporal.PlainDate, + plainDateB: Temporal.PlainDate, +) => { + return ( + plainDateA.month === plainDateB.month && plainDateA.year === plainDateB.year + ); +}; diff --git a/packages/twenty-shared/src/utils/date/isPlainDateInWeekend.ts b/packages/twenty-shared/src/utils/date/isPlainDateInWeekend.ts new file mode 100644 index 00000000000..f7e692bd7ff --- /dev/null +++ b/packages/twenty-shared/src/utils/date/isPlainDateInWeekend.ts @@ -0,0 +1,5 @@ +import { type Temporal } from 'temporal-polyfill'; + +export const isPlainDateInWeekend = (plainDate: Temporal.PlainDate) => { + return plainDate.dayOfWeek > 5; +}; diff --git a/packages/twenty-shared/src/utils/date/isSamePlainDate.ts b/packages/twenty-shared/src/utils/date/isSamePlainDate.ts new file mode 100644 index 00000000000..7a8a8a0463f --- /dev/null +++ b/packages/twenty-shared/src/utils/date/isSamePlainDate.ts @@ -0,0 +1,8 @@ +import { Temporal } from 'temporal-polyfill'; + +export const isSamePlainDate = ( + plainDateA: Temporal.PlainDate, + plainDateB: Temporal.PlainDate, +) => { + return Temporal.PlainDate.compare(plainDateA, plainDateB) === 0; +}; diff --git a/packages/twenty-shared/src/utils/date/parseToPlainDateOrThrow.ts b/packages/twenty-shared/src/utils/date/parseToPlainDateOrThrow.ts new file mode 100644 index 00000000000..6c82c5e4ec5 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/parseToPlainDateOrThrow.ts @@ -0,0 +1,23 @@ +import { Temporal } from 'temporal-polyfill'; + +export const parseToPlainDateOrThrow = (stringDate: string) => { + try { + const parsedPlainDate = Temporal.Instant.from(stringDate) + .toZonedDateTimeISO('UTC') + .toPlainDate(); + + return parsedPlainDate; + } catch { + // + } + + try { + const parsedPlainDate = Temporal.PlainDate.from(stringDate); + + return parsedPlainDate; + } catch { + // + } + + throw new Error(`Cannot parse date string as PlainDate : "${stringDate}"`); +}; diff --git a/packages/twenty-shared/src/utils/date/sortPlainDate.ts b/packages/twenty-shared/src/utils/date/sortPlainDate.ts new file mode 100644 index 00000000000..f5163b93602 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/sortPlainDate.ts @@ -0,0 +1,14 @@ +import { type ArraySortDirection } from '@/types/ArraySortDirection'; +import { Temporal } from 'temporal-polyfill'; + +export const sortPlainDate = + (direction: ArraySortDirection) => + (plainDateA: Temporal.PlainDate, plainDateB: Temporal.PlainDate) => { + const comparisonResult = Temporal.PlainDate.compare(plainDateA, plainDateB); + + if (comparisonResult === 0) { + return 0; + } + + return direction === 'asc' ? comparisonResult : -comparisonResult; + }; diff --git a/packages/twenty-shared/src/utils/date/turnJSDateToPlainDate.ts b/packages/twenty-shared/src/utils/date/turnJSDateToPlainDate.ts new file mode 100644 index 00000000000..237d52587dc --- /dev/null +++ b/packages/twenty-shared/src/utils/date/turnJSDateToPlainDate.ts @@ -0,0 +1,11 @@ +import { Temporal } from 'temporal-polyfill'; + +export const turnJSDateToPlainDate = (date: Date) => { + const plainDate = Temporal.PlainDate.from({ + day: date.getDate(), + month: date.getMonth() + 1, + year: date.getFullYear(), + }); + + return plainDate; +}; diff --git a/packages/twenty-shared/src/utils/date/turnPlainDateIntoUserTimeZoneInstantString.ts b/packages/twenty-shared/src/utils/date/turnPlainDateIntoUserTimeZoneInstantString.ts new file mode 100644 index 00000000000..6965f71e961 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/turnPlainDateIntoUserTimeZoneInstantString.ts @@ -0,0 +1,8 @@ +import { type Temporal } from 'temporal-polyfill'; + +export const turnPlainDateIntoUserTimeZoneInstantString = ( + plainDate: Temporal.PlainDate, + userTimeZone: string, +) => { + return plainDate.toZonedDateTime(userTimeZone).toInstant().toString(); +}; diff --git a/packages/twenty-shared/src/utils/date/turnPlainDateToShiftedDateInSystemTimeZone.ts b/packages/twenty-shared/src/utils/date/turnPlainDateToShiftedDateInSystemTimeZone.ts new file mode 100644 index 00000000000..ed31d6cdcc2 --- /dev/null +++ b/packages/twenty-shared/src/utils/date/turnPlainDateToShiftedDateInSystemTimeZone.ts @@ -0,0 +1,16 @@ +import { type Temporal } from 'temporal-polyfill'; + +export const turnPlainDateToShiftedDateInSystemTimeZone = ( + plainDate: Temporal.PlainDate, +) => { + const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const dateShiftedToISOString = plainDate + .toZonedDateTime(systemTimeZone) + .toInstant() + .toString(); + + const dateForDatePicker = new Date(dateShiftedToISOString); + + return dateForDatePicker; +}; diff --git a/packages/twenty-shared/src/utils/filter/__tests__/computeRecordGqlOperationFilter.test.ts b/packages/twenty-shared/src/utils/filter/__tests__/computeRecordGqlOperationFilter.test.ts index ab02020562f..3e6f5beb477 100644 --- a/packages/twenty-shared/src/utils/filter/__tests__/computeRecordGqlOperationFilter.test.ts +++ b/packages/twenty-shared/src/utils/filter/__tests__/computeRecordGqlOperationFilter.test.ts @@ -30,7 +30,9 @@ describe('computeRecordGqlOperationFilter', () => { fields: [companyIdField], recordFilters, recordFilterGroups: [], - filterValueDependencies: {}, + filterValueDependencies: { + timeZone: 'UTC', + }, }); expect(filter).toEqual({ diff --git a/packages/twenty-shared/src/utils/filter/dates/types/DateTimePeriod.ts b/packages/twenty-shared/src/utils/filter/dates/types/DateTimePeriod.ts new file mode 100644 index 00000000000..7fc56955231 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/types/DateTimePeriod.ts @@ -0,0 +1,3 @@ +import { type RelativeDateFilterUnit } from '@/utils/filter/dates/utils/relativeDateFilterUnitSchema'; + +export type DateTimePeriod = RelativeDateFilterUnit | 'QUARTER'; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/getNextPeriodStart.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/getNextPeriodStart.test.ts new file mode 100644 index 00000000000..ef32df7a44e --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/getNextPeriodStart.test.ts @@ -0,0 +1,132 @@ +import { FirstDayOfTheWeek } from '@/types'; +import { getNextPeriodStart } from '@/utils/filter/dates/utils/getNextPeriodStart'; + +import { Temporal } from 'temporal-polyfill'; + +describe('getNextPeriodStart', () => { + const referenceDateTimeJanuary = Temporal.ZonedDateTime.from( + '2026-01-14T12:34:56[Europe/Paris]', + ); + + const referenceDateTimeJune = Temporal.ZonedDateTime.from( + '2026-06-14T12:34:56[Europe/Paris]', + ); + + it('should get next day start', () => { + const nextDayStart = getNextPeriodStart(referenceDateTimeJanuary, 'DAY'); + + expect(nextDayStart.toString()).toEqual( + '2026-01-15T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get next week start - no first day of the week given (should default to monday)', () => { + const nextWeekStart = getNextPeriodStart(referenceDateTimeJanuary, 'WEEK'); + + expect(nextWeekStart.toString()).toEqual( + '2026-01-19T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get next week start - Monday', () => { + const nextWeekStart = getNextPeriodStart( + referenceDateTimeJanuary, + 'WEEK', + FirstDayOfTheWeek.MONDAY, + ); + + expect(nextWeekStart.toString()).toEqual( + '2026-01-19T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get next week start - Saturday', () => { + const nextWeekStart = getNextPeriodStart( + referenceDateTimeJanuary, + 'WEEK', + FirstDayOfTheWeek.SATURDAY, + ); + + expect(nextWeekStart.toString()).toEqual( + '2026-01-17T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get next week start - Sunday', () => { + const nextWeekStart = getNextPeriodStart( + referenceDateTimeJanuary, + 'WEEK', + FirstDayOfTheWeek.SUNDAY, + ); + + expect(nextWeekStart.toString()).toEqual( + '2026-01-18T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get next month start', () => { + const nextMonthStart = getNextPeriodStart( + referenceDateTimeJanuary, + 'MONTH', + ); + + expect(nextMonthStart.toString()).toEqual( + '2026-02-01T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get next year start', () => { + const nextQuarterStart = getNextPeriodStart(referenceDateTimeJune, 'YEAR'); + + expect(nextQuarterStart.toString()).toEqual( + '2027-01-01T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get next start of quarter', () => { + const referenceDateTimeMarch = Temporal.ZonedDateTime.from( + '2026-03-14T12:34:56[Europe/Paris]', + ); + + const referenceDateTimeSeptember = Temporal.ZonedDateTime.from( + '2026-09-14T12:34:56[Europe/Paris]', + ); + + const referenceDateTimeDecember = Temporal.ZonedDateTime.from( + '2026-12-14T12:34:56[Europe/Paris]', + ); + + const nextStartOfQuarterForMarch = getNextPeriodStart( + referenceDateTimeMarch, + 'QUARTER', + ); + + const nextStartOfQuarterForJune = getNextPeriodStart( + referenceDateTimeJune, + 'QUARTER', + ); + + const nextStartOfQuarterForSeptember = getNextPeriodStart( + referenceDateTimeSeptember, + 'QUARTER', + ); + + const nextStartOfQuarterForDecember = getNextPeriodStart( + referenceDateTimeDecember, + 'QUARTER', + ); + + expect(nextStartOfQuarterForMarch.toString()).toEqual( + '2026-04-01T00:00:00+02:00[Europe/Paris]', + ); + expect(nextStartOfQuarterForJune.toString()).toEqual( + '2026-07-01T00:00:00+02:00[Europe/Paris]', + ); + expect(nextStartOfQuarterForSeptember.toString()).toEqual( + '2026-10-01T00:00:00+02:00[Europe/Paris]', + ); + expect(nextStartOfQuarterForDecember.toString()).toEqual( + '2027-01-01T00:00:00+01:00[Europe/Paris]', + ); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/getPeriodStart.test.ts b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/getPeriodStart.test.ts new file mode 100644 index 00000000000..b6520996051 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/__tests__/getPeriodStart.test.ts @@ -0,0 +1,137 @@ +import { FirstDayOfTheWeek } from '@/types'; +import { getPeriodStart } from '@/utils/filter/dates/utils/getPeriodStart'; + +import { Temporal } from 'temporal-polyfill'; + +describe('getPeriodStart', () => { + const referenceDateTimeJanuary = Temporal.ZonedDateTime.from( + '2026-01-14T12:34:56[Europe/Paris]', + ); + + const referenceDateTimeJune = Temporal.ZonedDateTime.from( + '2026-06-14T12:34:56[Europe/Paris]', + ); + + it('should get start of day', () => { + const startOfDay = getPeriodStart(referenceDateTimeJanuary, 'DAY'); + + expect(startOfDay.toString()).toEqual( + '2026-01-14T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get start of week - weekd start on Monday', () => { + const startOfWeek = getPeriodStart( + referenceDateTimeJanuary, + 'WEEK', + FirstDayOfTheWeek.MONDAY, + ); + + expect(startOfWeek.toString()).toEqual( + '2026-01-12T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get start of week - weekd start on Saturday', () => { + const startOfWeek = getPeriodStart( + referenceDateTimeJanuary, + 'WEEK', + FirstDayOfTheWeek.SATURDAY, + ); + + expect(startOfWeek.toString()).toEqual( + '2026-01-10T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get start of week - weekd start on Sunday', () => { + const startOfWeek = getPeriodStart( + referenceDateTimeJanuary, + 'WEEK', + FirstDayOfTheWeek.SUNDAY, + ); + + expect(startOfWeek.toString()).toEqual( + '2026-01-11T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get start of week - no first day of the week given (should default to monday)', () => { + const startOfWeek = getPeriodStart(referenceDateTimeJanuary, 'WEEK'); + + expect(startOfWeek.toString()).toEqual( + '2026-01-12T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get start of week - no first day of the week given (should default to monday)', () => { + const startOfWeek = getPeriodStart(referenceDateTimeJanuary, 'WEEK'); + + expect(startOfWeek.toString()).toEqual( + '2026-01-12T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get start of month', () => { + const startOfWeek = getPeriodStart(referenceDateTimeJanuary, 'MONTH'); + + expect(startOfWeek.toString()).toEqual( + '2026-01-01T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get start of year', () => { + const startOfWeek = getPeriodStart(referenceDateTimeJune, 'YEAR'); + + expect(startOfWeek.toString()).toEqual( + '2026-01-01T00:00:00+01:00[Europe/Paris]', + ); + }); + + it('should get start of quarter', () => { + const referenceDateTimeMarch = Temporal.ZonedDateTime.from( + '2026-03-14T12:34:56[Europe/Paris]', + ); + + const referenceDateTimeSeptember = Temporal.ZonedDateTime.from( + '2026-09-14T12:34:56[Europe/Paris]', + ); + + const referenceDateTimeDecember = Temporal.ZonedDateTime.from( + '2026-12-14T12:34:56[Europe/Paris]', + ); + + const startOfQuarterForMarch = getPeriodStart( + referenceDateTimeMarch, + 'QUARTER', + ); + + const startOfQuarterForJune = getPeriodStart( + referenceDateTimeJune, + 'QUARTER', + ); + + const startOfQuarterForSeptember = getPeriodStart( + referenceDateTimeSeptember, + 'QUARTER', + ); + + const startOfQuarterForDecember = getPeriodStart( + referenceDateTimeDecember, + 'QUARTER', + ); + + expect(startOfQuarterForMarch.toString()).toEqual( + '2026-01-01T00:00:00+01:00[Europe/Paris]', + ); + expect(startOfQuarterForJune.toString()).toEqual( + '2026-04-01T00:00:00+02:00[Europe/Paris]', + ); + expect(startOfQuarterForSeptember.toString()).toEqual( + '2026-07-01T00:00:00+02:00[Europe/Paris]', + ); + expect(startOfQuarterForDecember.toString()).toEqual( + '2026-10-01T00:00:00+02:00[Europe/Paris]', + ); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/addUnitToDateTime.ts b/packages/twenty-shared/src/utils/filter/dates/utils/addUnitToDateTime.ts index 8e5a5b3db05..04261b4f04b 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/addUnitToDateTime.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/addUnitToDateTime.ts @@ -9,6 +9,7 @@ import { addYears, } from 'date-fns'; +/** @deprecated Use addUnitToZonedDateTime */ export const addUnitToDateTime = ( dateTime: Date, amount: number, diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/addUnitToZonedDateTime.ts b/packages/twenty-shared/src/utils/filter/dates/utils/addUnitToZonedDateTime.ts new file mode 100644 index 00000000000..ef2d1cdc55f --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/addUnitToZonedDateTime.ts @@ -0,0 +1,43 @@ +import { assertUnreachable, type DateTimePeriod } from '@/utils'; +import { type Temporal } from 'temporal-polyfill'; + +export const addUnitToZonedDateTime = ( + zonedDateTime: Temporal.ZonedDateTime, + unit: DateTimePeriod, + amount: number, +) => { + switch (unit) { + case 'DAY': + return zonedDateTime.add({ days: amount }); + case 'WEEK': { + return zonedDateTime.add({ weeks: amount }); + } + case 'QUARTER': { + return zonedDateTime.add({ + months: amount * 3, + }); + } + case 'MONTH': + return zonedDateTime.add({ + months: amount, + }); + case 'YEAR': + return zonedDateTime.add({ + years: amount, + }); + case 'SECOND': + return zonedDateTime.add({ + seconds: amount, + }); + case 'MINUTE': + return zonedDateTime.add({ + minutes: amount, + }); + case 'HOUR': + return zonedDateTime.add({ + hours: amount, + }); + default: + return assertUnreachable(unit); + } +}; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek.ts b/packages/twenty-shared/src/utils/filter/dates/utils/convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek.ts new file mode 100644 index 00000000000..767e5b048d4 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek.ts @@ -0,0 +1,21 @@ +import { CalendarStartDay } from '@/constants'; +import { FirstDayOfTheWeek } from '@/types'; +import { assertUnreachable } from '@/utils/assertUnreachable'; + +export const convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek = ( + calendarStartDayNonIsoNumber: CalendarStartDay, + systemCalendarStartDay: FirstDayOfTheWeek, +): FirstDayOfTheWeek => { + switch (calendarStartDayNonIsoNumber) { + case CalendarStartDay.MONDAY: + return FirstDayOfTheWeek.MONDAY; + case CalendarStartDay.SATURDAY: + return FirstDayOfTheWeek.SATURDAY; + case CalendarStartDay.SUNDAY: + return FirstDayOfTheWeek.SUNDAY; + case CalendarStartDay.SYSTEM: + return systemCalendarStartDay; + default: + return assertUnreachable(calendarStartDayNonIsoNumber); + } +}; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/convertFirstDayOfTheWeekToCalendarStartDayNumber.ts b/packages/twenty-shared/src/utils/filter/dates/utils/convertFirstDayOfTheWeekToCalendarStartDayNumber.ts new file mode 100644 index 00000000000..2e19fae6559 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/convertFirstDayOfTheWeekToCalendarStartDayNumber.ts @@ -0,0 +1,15 @@ +import { CalendarStartDay } from '@/constants'; +import { FirstDayOfTheWeek } from '@/types'; + +export const convertFirstDayOfTheWeekToCalendarStartDayNumber = ( + firstDayOfTheWeek: FirstDayOfTheWeek, +): CalendarStartDay => { + switch (firstDayOfTheWeek) { + case FirstDayOfTheWeek.MONDAY: + return CalendarStartDay.MONDAY; + case FirstDayOfTheWeek.SATURDAY: + return CalendarStartDay.SATURDAY; + case FirstDayOfTheWeek.SUNDAY: + return CalendarStartDay.SUNDAY; + } +}; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/getDateFromPlainDate.ts b/packages/twenty-shared/src/utils/filter/dates/utils/getDateFromPlainDate.ts deleted file mode 100644 index d884c33f075..00000000000 --- a/packages/twenty-shared/src/utils/filter/dates/utils/getDateFromPlainDate.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { DATE_TYPE_FORMAT } from '@/constants'; -import { parse } from 'date-fns'; - -export const getDateFromPlainDate = (plainDate: string) => { - return parse(plainDate, DATE_TYPE_FORMAT, new Date()); -}; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/getEndUnitOfDateTime.ts b/packages/twenty-shared/src/utils/filter/dates/utils/getEndUnitOfDateTime.ts deleted file mode 100644 index cff850187b9..00000000000 --- a/packages/twenty-shared/src/utils/filter/dates/utils/getEndUnitOfDateTime.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type Nullable } from '@/types'; -import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema'; -import { getFirstDayOfTheWeekAsANumberForDateFNS } from '@/utils/filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS'; -import { type RelativeDateFilterUnit } from '@/utils/filter/dates/utils/relativeDateFilterUnitSchema'; -import { isDefined } from '@/utils/validation'; -import { - endOfDay, - endOfHour, - endOfMinute, - endOfMonth, - endOfSecond, - endOfWeek, - endOfYear, -} from 'date-fns'; - -export const getEndUnitOfDateTime = ( - dateTime: Date, - unit: RelativeDateFilterUnit, - firstDayOfTheWeek?: Nullable, -) => { - switch (unit) { - case 'SECOND': - return endOfSecond(dateTime); - case 'MINUTE': - return endOfMinute(dateTime); - case 'HOUR': - return endOfHour(dateTime); - case 'DAY': - return endOfDay(dateTime); - case 'WEEK': { - if (isDefined(firstDayOfTheWeek)) { - const firstDayOfTheWeekAsDateFNSNumber = - getFirstDayOfTheWeekAsANumberForDateFNS(firstDayOfTheWeek); - - return endOfWeek(dateTime, { - weekStartsOn: firstDayOfTheWeekAsDateFNSNumber, - }); - } else { - return endOfWeek(dateTime); - } - } - case 'MONTH': - return endOfMonth(dateTime); - case 'YEAR': - return endOfYear(dateTime); - } -}; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/getFirstDayOfTheWeekAsISONumber.ts b/packages/twenty-shared/src/utils/filter/dates/utils/getFirstDayOfTheWeekAsISONumber.ts new file mode 100644 index 00000000000..fc10a7fcfda --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/getFirstDayOfTheWeekAsISONumber.ts @@ -0,0 +1,18 @@ +import { FirstDayOfTheWeek as FirstDayOfTheWeekEnum } from '@/types/FirstDayOfTheWeek'; +import { assertUnreachable } from '@/utils/assertUnreachable'; +import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema'; + +export const getFirstDayOfTheWeekAsISONumber = ( + firstDayOfTheWeek: FirstDayOfTheWeek, +): 1 | 6 | 7 => { + switch (firstDayOfTheWeek) { + case FirstDayOfTheWeekEnum.MONDAY: + return 1; + case FirstDayOfTheWeekEnum.SATURDAY: + return 6; + case FirstDayOfTheWeekEnum.SUNDAY: + return 7; + default: + return assertUnreachable(firstDayOfTheWeek); + } +}; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/getNextPeriodStart.ts b/packages/twenty-shared/src/utils/filter/dates/utils/getNextPeriodStart.ts new file mode 100644 index 00000000000..4933478e78f --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/getNextPeriodStart.ts @@ -0,0 +1,44 @@ +import { type Nullable } from '@/types'; +import { assertUnreachable } from '@/utils/assertUnreachable'; +import { type DateTimePeriod } from '@/utils/filter/dates/types/DateTimePeriod'; +import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema'; +import { getPeriodStart } from '@/utils/filter/dates/utils/getPeriodStart'; +import { type Temporal } from 'temporal-polyfill'; + +export const FIRST_DAY_OF_WEEK_ISO_8601_MONDAY = 1; + +export const getNextPeriodStart = ( + dateTime: Temporal.ZonedDateTime, + unit: DateTimePeriod, + firstDayOfTheWeek?: Nullable, +) => { + switch (unit) { + case 'DAY': + return getPeriodStart(dateTime, 'DAY').add({ days: 1 }); + case 'WEEK': { + return getPeriodStart(dateTime, 'WEEK', firstDayOfTheWeek).add({ + weeks: 1, + }); + } + case 'MONTH': + return getPeriodStart(dateTime, 'MONTH', firstDayOfTheWeek).add({ + months: 1, + }); + case 'QUARTER': + return getPeriodStart(dateTime, 'QUARTER', firstDayOfTheWeek).add({ + months: 3, + }); + case 'YEAR': + return getPeriodStart(dateTime, 'YEAR', firstDayOfTheWeek).add({ + years: 1, + }); + case 'SECOND': + return getPeriodStart(dateTime, 'SECOND').add({ seconds: 1 }); + case 'MINUTE': + return getPeriodStart(dateTime, 'MINUTE').add({ minutes: 1 }); + case 'HOUR': + return getPeriodStart(dateTime, 'HOUR').add({ hours: 1 }); + default: + return assertUnreachable(unit); + } +}; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/getPeriodStart.ts b/packages/twenty-shared/src/utils/filter/dates/utils/getPeriodStart.ts new file mode 100644 index 00000000000..3eaac00739e --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/getPeriodStart.ts @@ -0,0 +1,59 @@ +import { type Nullable } from '@/types'; +import { assertUnreachable, type DateTimePeriod } from '@/utils'; + +import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema'; +import { getFirstDayOfTheWeekAsISONumber } from '@/utils/filter/dates/utils/getFirstDayOfTheWeekAsISONumber'; +import { FIRST_DAY_OF_WEEK_ISO_8601_MONDAY } from '@/utils/filter/dates/utils/getNextPeriodStart'; +import { isDefined } from '@/utils/validation'; +import { type Temporal } from 'temporal-polyfill'; + +export const getPeriodStart = ( + dateTime: Temporal.ZonedDateTime, + unit: DateTimePeriod, + firstDayOfTheWeek?: Nullable, +) => { + switch (unit) { + case 'DAY': + return dateTime.startOfDay(); + case 'WEEK': { + const firstDayOfTheWeekAsISONumber = isDefined(firstDayOfTheWeek) + ? getFirstDayOfTheWeekAsISONumber(firstDayOfTheWeek) + : FIRST_DAY_OF_WEEK_ISO_8601_MONDAY; + + const daysOffsetToSutract = + (dateTime.dayOfWeek - firstDayOfTheWeekAsISONumber + 7) % 7; + + return dateTime.startOfDay().subtract({ days: daysOffsetToSutract }); + } + case 'QUARTER': { + const firstMonthOfTheQuarter = Math.floor((dateTime.month - 1) / 3); + + return dateTime + .startOfDay() + .with({ day: 1, month: firstMonthOfTheQuarter * 3 + 1 }); + } + case 'MONTH': + return dateTime.startOfDay().with({ day: 1 }); + case 'YEAR': + return dateTime.startOfDay().with({ day: 1, month: 1 }); + case 'SECOND': + return dateTime.with({ nanosecond: 0, microsecond: 0, millisecond: 0 }); + case 'MINUTE': + return dateTime.with({ + second: 0, + nanosecond: 0, + microsecond: 0, + millisecond: 0, + }); + case 'HOUR': + return dateTime.with({ + minute: 0, + second: 0, + nanosecond: 0, + microsecond: 0, + millisecond: 0, + }); + default: + return assertUnreachable(unit); + } +}; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/getPlainDateFromDate.ts b/packages/twenty-shared/src/utils/filter/dates/utils/getPlainDateFromDate.ts deleted file mode 100644 index 0c5c5ece604..00000000000 --- a/packages/twenty-shared/src/utils/filter/dates/utils/getPlainDateFromDate.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { DATE_TYPE_FORMAT } from '@/constants'; -import { format } from 'date-fns'; - -export const getPlainDateFromDate = (date: Date) => { - return format(date, DATE_TYPE_FORMAT); -}; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/getStartUnitOfDateTime.ts b/packages/twenty-shared/src/utils/filter/dates/utils/getStartUnitOfDateTime.ts deleted file mode 100644 index 6017e48d125..00000000000 --- a/packages/twenty-shared/src/utils/filter/dates/utils/getStartUnitOfDateTime.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type Nullable } from '@/types'; -import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema'; -import { getFirstDayOfTheWeekAsANumberForDateFNS } from '@/utils/filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS'; -import { type RelativeDateFilterUnit } from '@/utils/filter/dates/utils/relativeDateFilterUnitSchema'; -import { isDefined } from '@/utils/validation'; -import { - startOfDay, - startOfHour, - startOfMinute, - startOfMonth, - startOfSecond, - startOfWeek, - startOfYear, -} from 'date-fns'; - -export const getStartUnitOfDateTime = ( - dateTime: Date, - unit: RelativeDateFilterUnit, - firstDayOfTheWeek?: Nullable, -) => { - switch (unit) { - case 'SECOND': - return startOfSecond(dateTime); - case 'MINUTE': - return startOfMinute(dateTime); - case 'HOUR': - return startOfHour(dateTime); - case 'DAY': - return startOfDay(dateTime); - case 'WEEK': { - if (isDefined(firstDayOfTheWeek)) { - const firstDayOfTheWeekAsDateFNSNumber = - getFirstDayOfTheWeekAsANumberForDateFNS(firstDayOfTheWeek); - - return startOfWeek(dateTime, { - weekStartsOn: firstDayOfTheWeekAsDateFNSNumber, - }); - } else { - return startOfWeek(dateTime); - } - } - case 'MONTH': - return startOfMonth(dateTime); - case 'YEAR': - return startOfYear(dateTime); - } -}; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/relativeDateFilterStringifiedSchema.ts b/packages/twenty-shared/src/utils/filter/dates/utils/relativeDateFilterStringifiedSchema.ts index 7af14aebf8f..fc9dbbca355 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/relativeDateFilterStringifiedSchema.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/relativeDateFilterStringifiedSchema.ts @@ -7,7 +7,7 @@ const REGEX_FOR_RELATIVE_DATE_FILTER_STRINGIFIED_PARSING = export const relativeDateFilterStringifiedSchema = z .string() - .transform((value) => { + .transform((value, context) => { const regexForParsingStringifiedRelativeDateFilter = new RegExp( REGEX_FOR_RELATIVE_DATE_FILTER_STRINGIFIED_PARSING, ); @@ -15,7 +15,11 @@ export const relativeDateFilterStringifiedSchema = z const result = regexForParsingStringifiedRelativeDateFilter.exec(value); if (!isNonEmptyArray(result)) { - throw new Error(`Cannot parse stringified relative date filter`); + context.addIssue( + `Cannot parse stringified inline relative date filter, value : "${value}"`, + ); + + return z.NEVER; } const [_, direction, amount, unit, timezone, firstDayOfTheWeek] = result; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/resolveDateTimeFilter.ts b/packages/twenty-shared/src/utils/filter/dates/utils/resolveDateTimeFilter.ts index 3fe176fe3ca..1361826a1ad 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/resolveDateTimeFilter.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/resolveDateTimeFilter.ts @@ -4,7 +4,7 @@ import { resolveRelativeDateTimeFilterStringified } from '@/utils/filter/dates/u export type ResolvedDateTimeFilterValue = O extends ViewFilterOperand.IS_RELATIVE ? ReturnType - : Date | null; + : string | null; type PartialViewFilter = { value: string; @@ -21,5 +21,5 @@ export const resolveDateTimeFilter = ( viewFilter.value, ) as ResolvedDateTimeFilterValue; } - return new Date(viewFilter.value) as ResolvedDateTimeFilterValue; + return viewFilter.value as ResolvedDateTimeFilterValue; }; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilter.ts b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilter.ts index c6136d6103a..5bab58f4c62 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilter.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilter.ts @@ -1,53 +1,86 @@ -import { addUnitToDateTime } from '@/utils/filter/dates/utils/addUnitToDateTime'; -import { getEndUnitOfDateTime } from '@/utils/filter/dates/utils/getEndUnitOfDateTime'; -import { getPlainDateFromDate } from '@/utils/filter/dates/utils/getPlainDateFromDate'; -import { getStartUnitOfDateTime } from '@/utils/filter/dates/utils/getStartUnitOfDateTime'; +import { addUnitToZonedDateTime } from '@/utils/filter/dates/utils/addUnitToZonedDateTime'; +import { getNextPeriodStart } from '@/utils/filter/dates/utils/getNextPeriodStart'; +import { getPeriodStart } from '@/utils/filter/dates/utils/getPeriodStart'; import { type RelativeDateFilter } from '@/utils/filter/dates/utils/relativeDateFilterSchema'; -import { subUnitFromDateTime } from '@/utils/filter/dates/utils/subUnitFromDateTime'; -import { isDefined } from '@/utils/validation'; -import { TZDate } from '@date-fns/tz'; +import { subUnitFromZonedDateTime } from '@/utils/filter/dates/utils/subUnitFromZonedDateTime'; +import { isDefined } from 'class-validator'; +import { type Temporal } from 'temporal-polyfill'; +// TODO: use this in workflows where there is duplicated logic export const resolveRelativeDateFilter = ( relativeDateFilter: RelativeDateFilter, + referenceTodayZonedDateTime: Temporal.ZonedDateTime, ) => { const { direction, amount, unit, firstDayOfTheWeek } = relativeDateFilter; - const referenceDate = new TZDate(); - switch (direction) { - case 'NEXT': + case 'NEXT': { if (!isDefined(amount)) { throw new Error('Amount is required'); } + const startOfNextDay = referenceTodayZonedDateTime + .startOfDay() + .add({ days: 1 }); + + const startOfNextPeriod = addUnitToZonedDateTime( + startOfNextDay, + unit, + amount, + ); + + const start = startOfNextDay.toPlainDate().toString(); + const end = startOfNextPeriod?.toPlainDate().toString(); + return { ...relativeDateFilter, - start: getPlainDateFromDate(referenceDate), - end: getPlainDateFromDate( - addUnitToDateTime(referenceDate, amount, unit), - ), + start, + end, }; - case 'PAST': + } + case 'PAST': { if (!isDefined(amount)) { throw new Error('Amount is required'); } + const startOfDay = referenceTodayZonedDateTime.startOfDay(); + + const startOfNextPeriod = subUnitFromZonedDateTime( + startOfDay, + unit, + amount, + ); + + const start = startOfNextPeriod?.toPlainDate().toString(); + const end = startOfDay.toPlainDate().toString(); + return { ...relativeDateFilter, - start: getPlainDateFromDate( - subUnitFromDateTime(referenceDate, amount, unit), - ), - end: getPlainDateFromDate(referenceDate), + start, + end, }; - case 'THIS': + } + case 'THIS': { + const startOfPeriod = getPeriodStart( + referenceTodayZonedDateTime, + unit, + firstDayOfTheWeek, + ); + + const endOfPeriod = getNextPeriodStart( + referenceTodayZonedDateTime, + unit, + firstDayOfTheWeek, + ); + + const start = startOfPeriod?.toPlainDate().toString(); + const end = endOfPeriod?.toPlainDate().toString(); + return { ...relativeDateFilter, - start: getPlainDateFromDate( - getStartUnitOfDateTime(referenceDate, unit, firstDayOfTheWeek), - ), - end: getPlainDateFromDate( - getEndUnitOfDateTime(referenceDate, unit, firstDayOfTheWeek), - ), + start, + end, }; + } } }; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilterStringified.ts b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilterStringified.ts index fb9daa5d349..030e3bb6516 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilterStringified.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateFilterStringified.ts @@ -1,6 +1,8 @@ import { relativeDateFilterStringifiedSchema } from '@/utils/filter/dates/utils/relativeDateFilterStringifiedSchema'; import { resolveRelativeDateFilter } from '@/utils/filter/dates/utils/resolveRelativeDateFilter'; import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'class-validator'; +import { Temporal } from 'temporal-polyfill'; export const resolveRelativeDateFilterStringified = ( relativeDateFilterStringified?: string | null, @@ -9,12 +11,25 @@ export const resolveRelativeDateFilterStringified = ( return null; } - const relativeDateFilter = relativeDateFilterStringifiedSchema.parse( - relativeDateFilterStringified, - ); + const relativeDateFilterParseResult = + relativeDateFilterStringifiedSchema.safeParse( + relativeDateFilterStringified, + ); - const relativeDateFilterWithDateRange = - resolveRelativeDateFilter(relativeDateFilter); + if (!relativeDateFilterParseResult.success) { + return null; + } + + const relativeDateFilter = relativeDateFilterParseResult.data; + + const referenceTodayZonedDateTime = isDefined(relativeDateFilter.timezone) + ? Temporal.Now.zonedDateTimeISO(relativeDateFilter.timezone) + : Temporal.Now.zonedDateTimeISO(); + + const relativeDateFilterWithDateRange = resolveRelativeDateFilter( + relativeDateFilter, + referenceTodayZonedDateTime, + ); return relativeDateFilterWithDateRange; }; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilter.ts b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilter.ts index 68c35c18ac9..bdd563a0566 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilter.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilter.ts @@ -1,51 +1,73 @@ -import { addUnitToDateTime } from '@/utils/filter/dates/utils/addUnitToDateTime'; -import { getEndUnitOfDateTime } from '@/utils/filter/dates/utils/getEndUnitOfDateTime'; -import { getStartUnitOfDateTime } from '@/utils/filter/dates/utils/getStartUnitOfDateTime'; +import { addUnitToZonedDateTime } from '@/utils/filter/dates/utils/addUnitToZonedDateTime'; +import { getNextPeriodStart } from '@/utils/filter/dates/utils/getNextPeriodStart'; +import { getPeriodStart } from '@/utils/filter/dates/utils/getPeriodStart'; import { type RelativeDateFilter } from '@/utils/filter/dates/utils/relativeDateFilterSchema'; -import { subUnitFromDateTime } from '@/utils/filter/dates/utils/subUnitFromDateTime'; +import { subUnitFromZonedDateTime } from '@/utils/filter/dates/utils/subUnitFromZonedDateTime'; import { isDefined } from '@/utils/validation'; -import { TZDate } from '@date-fns/tz'; -import { isNonEmptyString } from '@sniptt/guards'; -import { roundToNearestMinutes } from 'date-fns'; +import { type Temporal } from 'temporal-polyfill'; export const resolveRelativeDateTimeFilter = ( relativeDateFilter: RelativeDateFilter, + referenceZonedDateTime: Temporal.ZonedDateTime, ) => { - const { direction, amount, unit, timezone, firstDayOfTheWeek } = - relativeDateFilter; + const { direction, amount, unit, firstDayOfTheWeek } = relativeDateFilter; - const referenceDate = roundToNearestMinutes( - isNonEmptyString(timezone) - ? new TZDate().withTimeZone(timezone) - : new TZDate(), - ); + const isSubDayUnit = ['SECOND', 'MINUTE', 'HOUR'].includes(unit); switch (direction) { - case 'NEXT': + case 'NEXT': { if (!isDefined(amount)) { throw new Error('Amount is required'); } - return { - ...relativeDateFilter, - start: referenceDate, - end: addUnitToDateTime(referenceDate, amount, unit), - }; - case 'PAST': + if (isSubDayUnit) { + return { + ...relativeDateFilter, + start: referenceZonedDateTime, + end: addUnitToZonedDateTime(referenceZonedDateTime, unit, amount), + }; + } else { + const startOfNextDay = referenceZonedDateTime + .startOfDay() + .add({ days: 1 }); + + return { + ...relativeDateFilter, + start: startOfNextDay, + end: addUnitToZonedDateTime(startOfNextDay, unit, amount), + }; + } + } + case 'PAST': { if (!isDefined(amount)) { throw new Error('Amount is required'); } - return { - ...relativeDateFilter, - start: subUnitFromDateTime(referenceDate, amount, unit), - end: referenceDate, - }; + if (isSubDayUnit) { + return { + ...relativeDateFilter, + start: subUnitFromZonedDateTime(referenceZonedDateTime, unit, amount), + end: referenceZonedDateTime, + }; + } else { + const startOfDay = referenceZonedDateTime.startOfDay(); + + return { + ...relativeDateFilter, + start: subUnitFromZonedDateTime(startOfDay, unit, amount), + end: startOfDay, + }; + } + } case 'THIS': return { ...relativeDateFilter, - start: getStartUnitOfDateTime(referenceDate, unit, firstDayOfTheWeek), - end: getEndUnitOfDateTime(referenceDate, unit, firstDayOfTheWeek), + start: getPeriodStart(referenceZonedDateTime, unit, firstDayOfTheWeek), + end: getNextPeriodStart( + referenceZonedDateTime, + unit, + firstDayOfTheWeek, + ), }; } }; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilterStringified.ts b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilterStringified.ts index 23d0a1ca4c0..c83b465df16 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilterStringified.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/resolveRelativeDateTimeFilterStringified.ts @@ -1,44 +1,35 @@ import { relativeDateFilterStringifiedSchema } from '@/utils/filter/dates/utils/relativeDateFilterStringifiedSchema'; import { resolveRelativeDateTimeFilter } from '@/utils/filter/dates/utils/resolveRelativeDateTimeFilter'; -import { shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone } from '@/utils/filter/dates/utils/shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone'; -import { isDefined } from '@/utils/validation'; import { isNonEmptyString } from '@sniptt/guards'; +import { isDefined } from 'class-validator'; +import { Temporal } from 'temporal-polyfill'; export const resolveRelativeDateTimeFilterStringified = ( - relativeDateTimeFilterStringified?: string | null, + relativeDateTimeFilterStringified: string | null | undefined, ) => { if (!isNonEmptyString(relativeDateTimeFilterStringified)) { return null; } - const relativeDateFilter = relativeDateFilterStringifiedSchema.parse( - relativeDateTimeFilterStringified, - ); + const relativeDateFilterParseResult = + relativeDateFilterStringifiedSchema.safeParse( + relativeDateTimeFilterStringified, + ); - const relativeDateFilterWithDateRange = - resolveRelativeDateTimeFilter(relativeDateFilter); + if (relativeDateFilterParseResult.success) { + const relativeDateFilter = relativeDateFilterParseResult.data; - if (isDefined(relativeDateFilter.timezone)) { - const shiftedStartDate = - shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone( - relativeDateFilterWithDateRange.start, - relativeDateFilter.timezone, - 'add', - ); + const referenceTodayZonedDateTime = isDefined(relativeDateFilter.timezone) + ? Temporal.Now.zonedDateTimeISO(relativeDateFilter.timezone) + : Temporal.Now.zonedDateTimeISO(); - const shiftedEndDate = - shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone( - relativeDateFilterWithDateRange.end, - relativeDateFilter.timezone, - 'add', - ); + const relativeDateFilterWithDateRange = resolveRelativeDateTimeFilter( + relativeDateFilter, + referenceTodayZonedDateTime.round({ smallestUnit: 'second' }), + ); - return { - ...relativeDateFilterWithDateRange, - start: shiftedStartDate, - end: shiftedEndDate, - }; + return relativeDateFilterWithDateRange; + } else { + return null; } - - return relativeDateFilterWithDateRange; }; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone.ts b/packages/twenty-shared/src/utils/filter/dates/utils/shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone.ts deleted file mode 100644 index 482772d0067..00000000000 --- a/packages/twenty-shared/src/utils/filter/dates/utils/shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { computeTimezoneDifferenceInMinutes } from '@/utils/filter/utils/computeTimezoneDifferenceInMinutes'; -import { addMinutes, subMinutes } from 'date-fns'; - -export const shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone = - (pointInTime: Date, targetTimezone: string, direction: 'add' | 'sub') => { - const systemTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - - const timezoneDifferenceInMinutesFromSystemTimezone = - computeTimezoneDifferenceInMinutes( - targetTimezone, - systemTimeZone, - pointInTime, - ); - - if (direction === 'add') { - return addMinutes( - pointInTime, - timezoneDifferenceInMinutesFromSystemTimezone, - ); - } else { - return subMinutes( - pointInTime, - timezoneDifferenceInMinutesFromSystemTimezone, - ); - } - }; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/subUnitFromZonedDateTime.ts b/packages/twenty-shared/src/utils/filter/dates/utils/subUnitFromZonedDateTime.ts new file mode 100644 index 00000000000..63e64bd07a0 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/dates/utils/subUnitFromZonedDateTime.ts @@ -0,0 +1,43 @@ +import { assertUnreachable, type DateTimePeriod } from '@/utils'; +import { type Temporal } from 'temporal-polyfill'; + +export const subUnitFromZonedDateTime = ( + zonedDateTime: Temporal.ZonedDateTime, + unit: DateTimePeriod, + amount: number, +) => { + switch (unit) { + case 'DAY': + return zonedDateTime.subtract({ days: amount }); + case 'WEEK': { + return zonedDateTime.subtract({ weeks: amount }); + } + case 'QUARTER': { + return zonedDateTime.subtract({ + months: amount * 3, + }); + } + case 'MONTH': + return zonedDateTime.subtract({ + months: amount, + }); + case 'YEAR': + return zonedDateTime.subtract({ + years: amount, + }); + case 'SECOND': + return zonedDateTime.subtract({ + seconds: amount, + }); + case 'MINUTE': + return zonedDateTime.subtract({ + minutes: amount, + }); + case 'HOUR': + return zonedDateTime.subtract({ + hours: amount, + }); + default: + return assertUnreachable(unit); + } +}; diff --git a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts index dc517c6cd61..128b5e02c23 100644 --- a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts +++ b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts @@ -34,30 +34,24 @@ import { isExpectedSubFieldName, } from '@/utils/filter'; -import { - endOfDay, - endOfMinute, - roundToNearestMinutes, - startOfDay, - startOfMinute, -} from 'date-fns'; -import { z } from 'zod'; - import { type DateTimeFilter } from '@/types/RecordGqlOperationFilter'; import { checkIfShouldComputeEmptinessFilter, checkIfShouldSkipFiltering, CustomError, getFilterTypeFromFieldType, - getPlainDateFromDate, + getNextPeriodStart, + getPeriodStart, isDefined, resolveDateFilter, resolveDateTimeFilter, + resolveRelativeDateFilterStringified, type RecordFilter, } from '@/utils'; import { arrayOfStringsOrVariablesSchema } from '@/utils/filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema'; import { arrayOfUuidOrVariableSchema } from '@/utils/filter/utils/validation-schemas/arrayOfUuidsOrVariablesSchema'; import { jsonRelationFilterValueSchema } from '@/utils/filter/utils/validation-schemas/jsonRelationFilterValueSchema'; +import { Temporal } from 'temporal-polyfill'; type FieldShared = { id: string; @@ -67,7 +61,7 @@ type FieldShared = { }; type TurnRecordFilterIntoRecordGqlOperationFilterParams = { - filterValueDependencies?: RecordFilterValueDependencies; + filterValueDependencies: RecordFilterValueDependencies; recordFilter: RecordFilter; fieldMetadataItems: FieldShared[]; }; @@ -174,20 +168,53 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({ ); } case 'DATE': { - const resolvedFilterValue = resolveDateFilter(recordFilter); + if (recordFilter.operand === RecordFilterOperand.IS_RELATIVE) { + const relativeDateFilterValue = resolveRelativeDateFilterStringified( + recordFilter.value, + ); - const now = new Date(); + const defaultDateRange = resolveDateFilter({ + value: 'PAST_1_DAY', + operand: RecordFilterOperand.IS_RELATIVE, + }); - const plainDateFilter = - typeof resolvedFilterValue === 'string' ? resolvedFilterValue : null; + if (!defaultDateRange) { + throw new Error('Failed to resolve default date range'); + } - const nowAsPlainDate = getPlainDateFromDate(now); + const start = + relativeDateFilterValue?.start?.toString() ?? defaultDateRange.start; + + const end = + relativeDateFilterValue?.end?.toString() ?? defaultDateRange.end; + + return { + and: [ + { + [correspondingFieldMetadataItem.name]: { + gte: start, + } as DateFilter, + }, + { + [correspondingFieldMetadataItem.name]: { + lt: end, + } as DateFilter, + }, + ], + }; + } + + const nowAsPlainDate = Temporal.Now.plainDateISO( + filterValueDependencies.timeZone, + ).toString(); + + const plainDateFilter = recordFilter.value; switch (recordFilter.operand) { case RecordFilterOperand.IS_AFTER: { return { [correspondingFieldMetadataItem.name]: { - gt: plainDateFilter, + gte: plainDateFilter, } as DateFilter, }; } @@ -198,38 +225,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({ } as DateFilter, }; } - case RecordFilterOperand.IS_RELATIVE: { - const dateRange = z - .object({ start: z.string(), end: z.string() }) - .safeParse(resolvedFilterValue).data; - const defaultDateRange = resolveDateFilter({ - value: 'PAST_1_DAY', - operand: RecordFilterOperand.IS_RELATIVE, - }); - - if (!defaultDateRange) { - throw new Error('Failed to resolve default date range'); - } - - const { start: startPlainDate, end: endPlainDate } = - dateRange ?? defaultDateRange; - - return { - and: [ - { - [correspondingFieldMetadataItem.name]: { - gte: startPlainDate, - } as DateFilter, - }, - { - [correspondingFieldMetadataItem.name]: { - lte: endPlainDate, - } as DateFilter, - }, - ], - }; - } case RecordFilterOperand.IS: { return { [correspondingFieldMetadataItem.name]: { @@ -240,7 +236,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({ case RecordFilterOperand.IS_IN_PAST: return { [correspondingFieldMetadataItem.name]: { - lte: nowAsPlainDate, + lt: nowAsPlainDate, } as DateFilter, }; case RecordFilterOperand.IS_IN_FUTURE: @@ -263,71 +259,97 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({ } } case 'DATE_TIME': { - const resolvedFilterValue = resolveDateTimeFilter(recordFilter); - const now = roundToNearestMinutes(new Date()); - const date = - resolvedFilterValue instanceof Date ? resolvedFilterValue : now; + if (recordFilter.operand === RecordFilterOperand.IS_RELATIVE) { + const resolvedFilterValue = resolveDateTimeFilter(recordFilter); + + const parsedRelativeDateFilterValue = + isDefined(resolvedFilterValue) && + typeof resolvedFilterValue === 'object' + ? resolvedFilterValue + : null; + + if (!isDefined(parsedRelativeDateFilterValue)) { + throw new Error( + `Cannot parse relative date filter : "${recordFilter.value}"`, + ); + } + + const defaultDateRange = resolveDateTimeFilter({ + value: `PAST_1_DAY;;${filterValueDependencies.timeZone}`, + operand: RecordFilterOperand.IS_RELATIVE, + }); + + if ( + !isDefined(defaultDateRange?.start) || + !isDefined(defaultDateRange?.end) + ) { + throw new Error('Failed to resolve default date range'); + } + + const start = + parsedRelativeDateFilterValue?.start ?? defaultDateRange.start; + + const end = parsedRelativeDateFilterValue?.end ?? defaultDateRange.end; + + return { + and: [ + { + [correspondingFieldMetadataItem.name]: { + gte: start.toInstant().toString(), + } as DateTimeFilter, + }, + { + [correspondingFieldMetadataItem.name]: { + lt: end.toInstant().toString(), + } as DateTimeFilter, + }, + ], + }; + } + + if (!isNonEmptyString(recordFilter.value)) { + throw new Error(`Date filter is empty`); + } + + const resolvedDateTime = Temporal.Instant.from(recordFilter.value); + + const now = Temporal.Now.zonedDateTimeISO( + filterValueDependencies.timeZone, + ); switch (recordFilter.operand) { case RecordFilterOperand.IS_AFTER: { return { [correspondingFieldMetadataItem.name]: { - gt: date.toISOString(), + gte: resolvedDateTime.toString(), } as DateTimeFilter, }; } case RecordFilterOperand.IS_BEFORE: { return { [correspondingFieldMetadataItem.name]: { - lt: date.toISOString(), + lt: resolvedDateTime.toString(), } as DateTimeFilter, }; } - case RecordFilterOperand.IS_RELATIVE: { - const dateRange = z - .object({ start: z.date(), end: z.date() }) - .safeParse(resolvedFilterValue).data; - - const defaultDateRange = resolveDateTimeFilter({ - value: 'PAST_1_DAY', - operand: RecordFilterOperand.IS_RELATIVE, - }); - - if (!defaultDateRange) { - throw new Error('Failed to resolve default date range'); - } - - const { start, end } = dateRange ?? defaultDateRange; - - return { - and: [ - { - [correspondingFieldMetadataItem.name]: { - gte: start.toISOString(), - } as DateTimeFilter, - }, - { - [correspondingFieldMetadataItem.name]: { - lte: end.toISOString(), - } as DateTimeFilter, - }, - ], - }; - } case RecordFilterOperand.IS: { - const isValid = resolvedFilterValue instanceof Date; - const date = isValid ? resolvedFilterValue : now; + const start = resolvedDateTime + .toZonedDateTimeISO('UTC') + .with({ second: 0, millisecond: 0, microsecond: 0, nanosecond: 0 }) + .toInstant(); + + const end = start.add({ minutes: 1 }); return { and: [ { [correspondingFieldMetadataItem.name]: { - lte: endOfMinute(date).toISOString(), + lt: end.toString(), } as DateTimeFilter, }, { [correspondingFieldMetadataItem.name]: { - gte: startOfMinute(date).toISOString(), + gte: start.toString(), } as DateTimeFilter, }, ], @@ -336,13 +358,13 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({ case RecordFilterOperand.IS_IN_PAST: return { [correspondingFieldMetadataItem.name]: { - lte: now.toISOString(), + lt: now.toInstant().round('minute').toString(), } as DateTimeFilter, }; case RecordFilterOperand.IS_IN_FUTURE: return { [correspondingFieldMetadataItem.name]: { - gte: now.toISOString(), + gt: now.toInstant().round('minute').toString(), } as DateTimeFilter, }; case RecordFilterOperand.IS_TODAY: { @@ -350,22 +372,22 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({ and: [ { [correspondingFieldMetadataItem.name]: { - lte: endOfDay(now).toISOString(), + gte: getPeriodStart(now, 'DAY').toInstant().toString(), } as DateTimeFilter, }, { [correspondingFieldMetadataItem.name]: { - gte: startOfDay(now).toISOString(), + lt: getNextPeriodStart(now, 'DAY').toInstant().toString(), } as DateTimeFilter, }, ], }; } - default: - throw new Error( - `Unknown operand ${recordFilter.operand} for ${filterType} filter`, - ); } + + throw new Error( + `Unknown operand ${recordFilter.operand} for ${filterType} filter`, + ); } case 'RATING': switch (recordFilter.operand) { diff --git a/packages/twenty-shared/src/utils/filter/utils/computeTimezoneDifferenceInMinutes.ts b/packages/twenty-shared/src/utils/filter/utils/computeTimezoneDifferenceInMinutes.ts deleted file mode 100644 index 505ece3562d..00000000000 --- a/packages/twenty-shared/src/utils/filter/utils/computeTimezoneDifferenceInMinutes.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { tzOffset } from '@date-fns/tz'; - -export const computeTimezoneDifferenceInMinutes = ( - timezoneA: string, - timezoneB: string, - referenceDateForDST: Date, -) => { - const minutesOffsetA = tzOffset(timezoneA, referenceDateForDST); - const minutesOffsetB = tzOffset(timezoneB, referenceDateForDST); - - return minutesOffsetB - minutesOffsetA; -}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 96849f59076..d2e4a922caf 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -20,6 +20,17 @@ export { sumByProperty } from './array/sumByProperty'; export { upsertIntoArrayOfObjectsComparingId } from './array/upsertIntoArrayOfObjectComparingId'; export { assertUnreachable } from './assertUnreachable'; export { computeDiffBetweenObjects } from './compute-diff-between-objects'; +export { isPlainDateAfter } from './date/isPlainDateAfter'; +export { isPlainDateBefore } from './date/isPlainDateBefore'; +export { isPlainDateBeforeOrEqual } from './date/isPlainDateBeforeOrEqual'; +export { isPlainDateInSameMonth } from './date/isPlainDateInSameMonth'; +export { isPlainDateInWeekend } from './date/isPlainDateInWeekend'; +export { isSamePlainDate } from './date/isSamePlainDate'; +export { parseToPlainDateOrThrow } from './date/parseToPlainDateOrThrow'; +export { sortPlainDate } from './date/sortPlainDate'; +export { turnJSDateToPlainDate } from './date/turnJSDateToPlainDate'; +export { turnPlainDateIntoUserTimeZoneInstantString } from './date/turnPlainDateIntoUserTimeZoneInstantString'; +export { turnPlainDateToShiftedDateInSystemTimeZone } from './date/turnPlainDateToShiftedDateInSystemTimeZone'; export { deepMerge } from './deepMerge'; export { CustomError } from './errors/CustomError'; export { evalFromContext } from './evalFromContext'; @@ -33,14 +44,20 @@ export { computeGqlOperationFilterForLinks } from './filter/compute-record-gql-o export { computeEmptyGqlOperationFilterForEmails } from './filter/computeEmptyGqlOperationFilterForEmails'; export { computeEmptyGqlOperationFilterForLinks } from './filter/computeEmptyGqlOperationFilterForLinks'; export { computeRecordGqlOperationFilter } from './filter/computeRecordGqlOperationFilter'; +export type { DateTimePeriod } from './filter/dates/types/DateTimePeriod'; export { addUnitToDateTime } from './filter/dates/utils/addUnitToDateTime'; +export { addUnitToZonedDateTime } from './filter/dates/utils/addUnitToZonedDateTime'; +export { convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek } from './filter/dates/utils/convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek'; +export { convertFirstDayOfTheWeekToCalendarStartDayNumber } from './filter/dates/utils/convertFirstDayOfTheWeekToCalendarStartDayNumber'; export type { FirstDayOfTheWeek } from './filter/dates/utils/firstDayOfWeekSchema'; export { firstDayOfWeekSchema } from './filter/dates/utils/firstDayOfWeekSchema'; -export { getDateFromPlainDate } from './filter/dates/utils/getDateFromPlainDate'; -export { getEndUnitOfDateTime } from './filter/dates/utils/getEndUnitOfDateTime'; export { getFirstDayOfTheWeekAsANumberForDateFNS } from './filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS'; -export { getPlainDateFromDate } from './filter/dates/utils/getPlainDateFromDate'; -export { getStartUnitOfDateTime } from './filter/dates/utils/getStartUnitOfDateTime'; +export { getFirstDayOfTheWeekAsISONumber } from './filter/dates/utils/getFirstDayOfTheWeekAsISONumber'; +export { + FIRST_DAY_OF_WEEK_ISO_8601_MONDAY, + getNextPeriodStart, +} from './filter/dates/utils/getNextPeriodStart'; +export { getPeriodStart } from './filter/dates/utils/getPeriodStart'; export { relativeDateFilterAmountSchema } from './filter/dates/utils/relativeDateFilterAmountSchema'; export type { RelativeDateFilterDirection } from './filter/dates/utils/relativeDateFilterDirectionSchema'; export { relativeDateFilterDirectionSchema } from './filter/dates/utils/relativeDateFilterDirectionSchema'; @@ -57,8 +74,8 @@ export { resolveRelativeDateFilter } from './filter/dates/utils/resolveRelativeD export { resolveRelativeDateFilterStringified } from './filter/dates/utils/resolveRelativeDateFilterStringified'; export { resolveRelativeDateTimeFilter } from './filter/dates/utils/resolveRelativeDateTimeFilter'; export { resolveRelativeDateTimeFilterStringified } from './filter/dates/utils/resolveRelativeDateTimeFilterStringified'; -export { shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone } from './filter/dates/utils/shiftPointInTimeFromTimezoneDifferenceInMinutesWithSystemTimezone'; export { subUnitFromDateTime } from './filter/dates/utils/subUnitFromDateTime'; +export { subUnitFromZonedDateTime } from './filter/dates/utils/subUnitFromZonedDateTime'; export { isEmptinessOperand } from './filter/isEmptinessOperand'; export { turnAnyFieldFilterIntoRecordGqlFilter } from './filter/turnAnyFieldFilterIntoRecordGqlFilter'; export type { @@ -68,7 +85,6 @@ export type { export { turnRecordFilterGroupsIntoGqlOperationFilter } from './filter/turnRecordFilterGroupIntoGqlOperationFilter'; export { turnRecordFilterIntoRecordGqlOperationFilter } from './filter/turnRecordFilterIntoGqlOperationFilter'; export { combineFilters } from './filter/utils/combineFilters'; -export { computeTimezoneDifferenceInMinutes } from './filter/utils/computeTimezoneDifferenceInMinutes'; export { convertViewFilterOperandToCoreOperand } from './filter/utils/convert-view-filter-operand-to-core-operand.util'; export { convertViewFilterValueToString } from './filter/utils/convertViewFilterValueToString'; export { createAnyFieldRecordFilterBaseProperties } from './filter/utils/createAnyFieldRecordFilterBaseProperties'; diff --git a/packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts b/packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts index 2575b5fdc71..26a5bf48297 100644 --- a/packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts +++ b/packages/twenty-shared/src/utils/url/lowercaseUrlOriginAndRemoveTrailingSlash.ts @@ -11,7 +11,9 @@ export const lowercaseUrlOriginAndRemoveTrailingSlash = (rawUrl: string) => { const lowercaseOrigin = url.origin.toLowerCase(); const path = - safeDecodeURIComponent(url.pathname) + safeDecodeURIComponent(url.search) + url.hash; + safeDecodeURIComponent(url.pathname) + + safeDecodeURIComponent(url.search) + + url.hash; return (lowercaseOrigin + path).replace(/\/$/, ''); }; diff --git a/yarn.lock b/yarn.lock index fa0cb3c330e..9bc128a2f82 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4698,13 +4698,6 @@ __metadata: languageName: node linkType: hard -"@date-fns/tz@npm:^1.4.1": - version: 1.4.1 - resolution: "@date-fns/tz@npm:1.4.1" - checksum: 10c0/9033fdc4682fe3d4d147625ce04fa88a8792653594e2de8d5a438c8f3bfc0990ee28fe773f91cac6810b06d818b5b281ae0608752ba8337257d0279ded3f019a - languageName: node - linkType: hard - "@docsearch/css@npm:3.6.2": version: 3.6.2 resolution: "@docsearch/css@npm:3.6.2" @@ -55672,6 +55665,22 @@ __metadata: languageName: node linkType: hard +"temporal-polyfill@npm:^0.3.0": + version: 0.3.0 + resolution: "temporal-polyfill@npm:0.3.0" + dependencies: + temporal-spec: "npm:0.3.0" + checksum: 10c0/ea6a495fac64c19b194ec3536c2a5916a754c2bf7f645fac5c149fe6a43f266fe4493c6fd6a6b6dfa3e9813505bc48346cf0514c1631a401b354f4c6568b908d + languageName: node + linkType: hard + +"temporal-spec@npm:0.3.0": + version: 0.3.0 + resolution: "temporal-spec@npm:0.3.0" + checksum: 10c0/2f7c8530f10e628ff755618490352f6c45b9a20833518286a3c10a8301012696fe6f0bcaa795d5f6f20038777595f2a73dc3390ddea743803405173ff233f482 + languageName: node + linkType: hard + "terser-webpack-plugin@npm:^5.3.11": version: 5.3.14 resolution: "terser-webpack-plugin@npm:5.3.14" @@ -57085,7 +57094,6 @@ __metadata: "@babel/preset-react": "npm:^7.14.5" "@babel/preset-typescript": "npm:^7.24.6" "@chromatic-com/storybook": "npm:^3" - "@date-fns/tz": "npm:^1.4.1" "@emotion/react": "npm:^11.11.1" "@emotion/styled": "npm:^11.11.0" "@floating-ui/react": "npm:^0.24.3" @@ -57251,6 +57259,7 @@ __metadata: storybook-addon-mock-date: "npm:^0.6.0" storybook-addon-pseudo-states: "npm:^2.1.2" supertest: "npm:^6.1.3" + temporal-polyfill: "npm:^0.3.0" ts-jest: "npm:^29.1.1" ts-key-enum: "npm:^2.0.12" ts-loader: "npm:^9.2.3"