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"