From 9c9a01d55ab54b3d320bd971730f5074368bebb2 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Thu, 27 Nov 2025 11:22:57 +0100 Subject: [PATCH] [groupBy][Requires cache flush] Add WEEK date granularity (#16099) Closes https://github.com/twentyhq/core-team-issues/issues/1921 https://github.com/user-attachments/assets/bf400ec1-ce25-4d9e-b875-774168452514 --- .../src/generated-metadata/graphql.ts | 3 +- .../twenty-front/src/generated/graphql.ts | 3 +- ...ateGranularitySelectionDropdownContent.tsx | 1 + .../utils/getDateGranularityLabel.ts | 2 + .../ObjectFilterDropdownDateInput.tsx | 9 +- .../ObjectFilterDropdownDateTimeInput.tsx | 9 +- .../utils/generateDateGroupsInRange.ts | 5 + .../utils/getDateGroupsFromData.ts | 3 +- .../graph/utils/formatDateByGranularity.ts | 7 + .../stringifyRelativeDateFilter.test.ts | 9 +- .../date-field-group-by-definition.type.ts | 2 + .../resolvers/types/group-by-field.types.ts | 3 + .../utils/get-group-by-expression.util.ts | 16 +- .../utils/parse-group-by-args.util.ts | 1 + .../parse-group-by-relation-field.util.ts | 15 +- .../interfaces/object-record.interface.ts | 2 + ...te-granularity-gql-input-type.generator.ts | 29 +- .../enums/date-granularity.enum.ts | 2 +- ...evaluate-relative-date-filter.util.spec.ts | 3 +- .../group-by-resolver.integration-spec.ts | 334 +++++++++++++++++- .../src/types/FirstDayOfTheWeek.ts | 5 + .../ObjectRecordGroupByDateGranularity.ts | 1 + packages/twenty-shared/src/types/index.ts | 1 + .../dates/utils/firstDayOfWeekSchema.ts | 7 +- ...getFirstDayOfTheWeekAsANumberForDateFNS.ts | 7 +- 25 files changed, 437 insertions(+), 42 deletions(-) create mode 100644 packages/twenty-shared/src/types/FirstDayOfTheWeek.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 1e90440a87..dfcf69a632 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2994,7 +2994,7 @@ export type ObjectRecordFilterInput = { updatedAt?: InputMaybe; }; -/** Date granularity options (e.g. DAY, MONTH, QUARTER, YEAR, DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR) */ +/** Date granularity options (e.g. DAY, MONTH, QUARTER, YEAR, WEEK, DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR) */ export enum ObjectRecordGroupByDateGranularity { DAY = 'DAY', DAY_OF_THE_WEEK = 'DAY_OF_THE_WEEK', @@ -3003,6 +3003,7 @@ export enum ObjectRecordGroupByDateGranularity { NONE = 'NONE', QUARTER = 'QUARTER', QUARTER_OF_THE_YEAR = 'QUARTER_OF_THE_YEAR', + WEEK = 'WEEK', YEAR = 'YEAR' } diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 042bcee9f5..2af4bec455 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -2915,7 +2915,7 @@ export type ObjectRecordFilterInput = { updatedAt?: InputMaybe; }; -/** Date granularity options (e.g. DAY, MONTH, QUARTER, YEAR, DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR) */ +/** Date granularity options (e.g. DAY, MONTH, QUARTER, YEAR, WEEK, DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR) */ export enum ObjectRecordGroupByDateGranularity { DAY = 'DAY', DAY_OF_THE_WEEK = 'DAY_OF_THE_WEEK', @@ -2924,6 +2924,7 @@ export enum ObjectRecordGroupByDateGranularity { NONE = 'NONE', QUARTER = 'QUARTER', QUARTER_OF_THE_YEAR = 'QUARTER_OF_THE_YEAR', + WEEK = 'WEEK', YEAR = 'YEAR' } diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartDateGranularitySelectionDropdownContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartDateGranularitySelectionDropdownContent.tsx index 66fa05d4bd..a8be9f9a1c 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartDateGranularitySelectionDropdownContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartDateGranularitySelectionDropdownContent.tsx @@ -86,6 +86,7 @@ export const ChartDateGranularitySelectionDropdownContent = ({ const dateGranularityOptions: ObjectRecordGroupByDateGranularity[] = [ ObjectRecordGroupByDateGranularity.DAY, + ObjectRecordGroupByDateGranularity.WEEK, ObjectRecordGroupByDateGranularity.MONTH, ObjectRecordGroupByDateGranularity.QUARTER, ObjectRecordGroupByDateGranularity.YEAR, diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getDateGranularityLabel.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getDateGranularityLabel.ts index dc2220eeb2..885476f620 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getDateGranularityLabel.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getDateGranularityLabel.ts @@ -7,6 +7,8 @@ export const getDateGranularityLabel = ( switch (granularity) { case ObjectRecordGroupByDateGranularity.DAY: return t`Day`; + case ObjectRecordGroupByDateGranularity.WEEK: + return t`Week`; case ObjectRecordGroupByDateGranularity.MONTH: return t`Month`; case ObjectRecordGroupByDateGranularity.QUARTER: 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 0113cc10e1..8935e9d7bc 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,10 +1,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CalendarStartDay } from 'twenty-shared'; -import { - detectCalendarStartDay, - type NonSystemCalendarStartDay, -} from '@/localization/utils/detection/detectCalendarStartDay'; +import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue'; import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState'; import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; @@ -15,7 +12,7 @@ import { UserContext } from '@/users/contexts/UserContext'; import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter'; import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; -import { ViewFilterOperand } from 'twenty-shared/types'; +import { type FirstDayOfTheWeek, ViewFilterOperand } from 'twenty-shared/types'; import { isDefined, type RelativeDateFilter, @@ -74,7 +71,7 @@ export const ObjectFilterDropdownDateInput = ({ userDefinedCalendarStartDay === CalendarStartDay[CalendarStartDay.SYSTEM] ? defaultSystemCalendarStartDay : userDefinedCalendarStartDay - ) as NonSystemCalendarStartDay; + ) as FirstDayOfTheWeek; const newFilterValue = relativeDate ? stringifyRelativeDateFilter({ 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 a16195c2f2..959f92ea0b 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,10 +1,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CalendarStartDay } from 'twenty-shared'; -import { - detectCalendarStartDay, - type NonSystemCalendarStartDay, -} from '@/localization/utils/detection/detectCalendarStartDay'; +import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue'; import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState'; import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue'; @@ -14,7 +11,7 @@ import { UserContext } from '@/users/contexts/UserContext'; import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/stringifyRelativeDateFilter'; import { useContext, useState } from 'react'; import { useRecoilValue } from 'recoil'; -import { ViewFilterOperand } from 'twenty-shared/types'; +import { ViewFilterOperand, type FirstDayOfTheWeek } from 'twenty-shared/types'; import { isDefined, resolveDateTimeFilter, @@ -81,7 +78,7 @@ export const ObjectFilterDropdownDateTimeInput = ({ userDefinedCalendarStartDay === CalendarStartDay[CalendarStartDay.SYSTEM] ? defaultSystemCalendarStartDay : userDefinedCalendarStartDay - ) as NonSystemCalendarStartDay; + ) as FirstDayOfTheWeek; const newFilterValue = relativeDate ? stringifyRelativeDateFilter({ 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 ea62f34913..a294b60fe2 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 @@ -7,6 +7,7 @@ type GenerateDateRangeParams = { endDate: Date; granularity: | ObjectRecordGroupByDateGranularity.DAY + | ObjectRecordGroupByDateGranularity.WEEK | ObjectRecordGroupByDateGranularity.MONTH | ObjectRecordGroupByDateGranularity.QUARTER | ObjectRecordGroupByDateGranularity.YEAR; @@ -43,6 +44,10 @@ export const generateDateGroupsInRange = ({ currentDateCursor.setDate(currentDateCursor.getDate() + 1); break; + case ObjectRecordGroupByDateGranularity.WEEK: + currentDateCursor.setDate(currentDateCursor.getDate() + 1); + break; + case ObjectRecordGroupByDateGranularity.MONTH: currentDateCursor.setMonth(currentDateCursor.getMonth() + 1); break; 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 6c1605066c..ac2c6efd8c 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 @@ -5,7 +5,8 @@ export type SupportedDateGranularity = | ObjectRecordGroupByDateGranularity.DAY | ObjectRecordGroupByDateGranularity.MONTH | ObjectRecordGroupByDateGranularity.QUARTER - | ObjectRecordGroupByDateGranularity.YEAR; + | ObjectRecordGroupByDateGranularity.YEAR + | ObjectRecordGroupByDateGranularity.WEEK; export const getDateGroupsFromData = ( parsedDates: Date[], 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 a7efb04be7..1a1a826fc6 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 @@ -7,6 +7,7 @@ export const formatDateByGranularity = ( | ObjectRecordGroupByDateGranularity.MONTH | ObjectRecordGroupByDateGranularity.QUARTER | ObjectRecordGroupByDateGranularity.YEAR + | ObjectRecordGroupByDateGranularity.WEEK | ObjectRecordGroupByDateGranularity.NONE, ): string => { switch (granularity) { @@ -16,6 +17,12 @@ export const formatDateByGranularity = ( month: 'short', day: 'numeric', }); + case ObjectRecordGroupByDateGranularity.WEEK: + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); case ObjectRecordGroupByDateGranularity.MONTH: return date.toLocaleDateString(undefined, { year: 'numeric', 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 a65133bb82..c5d28a5726 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 @@ -1,4 +1,5 @@ import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay'; +import { FirstDayOfTheWeek } from 'twenty-shared/types'; import { type RelativeDateFilter } from 'twenty-shared/utils'; import { stringifyRelativeDateFilter } from '../stringifyRelativeDateFilter'; @@ -213,7 +214,7 @@ describe('stringifyRelativeDateFilter', () => { amount: 5, unit: 'DAY', timezone: 'America/New_York', - firstDayOfTheWeek: 'SUNDAY', + firstDayOfTheWeek: FirstDayOfTheWeek.SUNDAY, }; expect(stringifyRelativeDateFilter(filter)).toBe( @@ -259,7 +260,7 @@ describe('stringifyRelativeDateFilter', () => { direction: 'PAST', amount: 5, unit: 'DAY', - firstDayOfTheWeek: 'SUNDAY', + firstDayOfTheWeek: FirstDayOfTheWeek.SUNDAY, }; expect(stringifyRelativeDateFilter(filter)).toBe('PAST_5_DAY'); @@ -289,7 +290,7 @@ describe('stringifyRelativeDateFilter', () => { amount: 10, unit: 'MONTH', timezone: 'Europe/London', - firstDayOfTheWeek: 'MONDAY', + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }; expect(stringifyRelativeDateFilter(filter)).toBe( @@ -303,7 +304,7 @@ describe('stringifyRelativeDateFilter', () => { amount: 7, unit: 'WEEK', timezone: 'Asia/Tokyo', - firstDayOfTheWeek: 'SUNDAY', + firstDayOfTheWeek: FirstDayOfTheWeek.SUNDAY, }; expect(stringifyRelativeDateFilter(filter)).toBe( 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 664feefb77..8d611165c6 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 @@ -1,5 +1,7 @@ import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { type FirstDayOfTheWeek } from 'twenty-shared/utils'; export type DateFieldGroupByDefinition = { granularity: ObjectRecordGroupByDateGranularity; + weekStartDay?: FirstDayOfTheWeek; }; 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 34c642eba1..944953e6c4 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 @@ -1,4 +1,5 @@ import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { type FirstDayOfTheWeek } from 'twenty-shared/utils'; import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @@ -10,12 +11,14 @@ export type GroupByDateField = { fieldMetadata: FieldMetadataEntity; subFieldName?: string; dateGranularity: ObjectRecordGroupByDateGranularity; + weekStartDay?: FirstDayOfTheWeek; }; export type GroupByRelationField = { fieldMetadata: FieldMetadataEntity; nestedFieldMetadata: FieldMetadataEntity; nestedSubFieldName?: string; dateGranularity?: ObjectRecordGroupByDateGranularity; + weekStartDay?: FirstDayOfTheWeek; }; export type GroupByField = | GroupByRegularField 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 683822e88b..d44598a544 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 @@ -33,11 +33,25 @@ export const getGroupByExpression = ({ return `TRIM(TO_CHAR(${columnNameWithQuotes}, 'TMMonth'))`; case ObjectRecordGroupByDateGranularity.QUARTER_OF_THE_YEAR: return `TRIM(TO_CHAR(${columnNameWithQuotes}, '"Q"Q'))`; + case ObjectRecordGroupByDateGranularity.WEEK: { + const weekStartDay = groupByField.weekStartDay; + let shiftedExpression = `DATE_TRUNC('week', ${columnNameWithQuotes})`; + + if (isDefined(weekStartDay)) { + if (weekStartDay === 'SUNDAY') { + shiftedExpression = `DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '1 day') - INTERVAL '1 day'`; + } else if (weekStartDay === 'SATURDAY') { + shiftedExpression = `DATE_TRUNC('week', ${columnNameWithQuotes} + INTERVAL '2 days') - INTERVAL '2 days'`; + } + } + + return `TO_CHAR(${shiftedExpression}, 'YYYY-MM-DD"T"HH24:MI:SSTZH:TZM')`; + } case ObjectRecordGroupByDateGranularity.DAY: case ObjectRecordGroupByDateGranularity.MONTH: case ObjectRecordGroupByDateGranularity.QUARTER: case ObjectRecordGroupByDateGranularity.YEAR: - return `DATE_TRUNC('${dateGranularity}', ${columnNameWithQuotes})`; + return `TO_CHAR(DATE_TRUNC('${dateGranularity}', ${columnNameWithQuotes}), 'YYYY-MM-DD"T"HH24:MI:SSTZH:TZM')`; 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 623ed015d7..65e0c62352 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 @@ -72,6 +72,7 @@ export const parseGroupByArgs = ( groupByFields.push({ fieldMetadata, dateGranularity: fieldGroupByDefinition.granularity, + weekStartDay: fieldGroupByDefinition.weekStartDay, }); 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 724bf6fdee..bc1ed4326b 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 @@ -1,9 +1,6 @@ import { UserInputError } from 'apollo-server-core'; import { isDefined } from 'class-validator'; -import { - FieldMetadataType, - type ObjectRecordGroupByDateGranularity, -} from 'twenty-shared/types'; +import { FieldMetadataType } from 'twenty-shared/types'; import { GraphqlQueryRunnerException, @@ -153,14 +150,14 @@ export const parseGroupByRelationField = ({ nestedFieldMetadata.type === FieldMetadataType.DATE_TIME) && isGroupByDateFieldDefinition(nestedFieldGroupByDefinition) ) { + const dateFieldDefinition = + nestedFieldGroupByDefinition as DateFieldGroupByDefinition; + groupByFields.push({ fieldMetadata, nestedFieldMetadata, - dateGranularity: ( - nestedFieldGroupByDefinition as { - granularity: ObjectRecordGroupByDateGranularity; - } - ).granularity, + dateGranularity: dateFieldDefinition.granularity, + weekStartDay: dateFieldDefinition.weekStartDay, }); 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 90ba536427..5ba490fe7b 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 @@ -4,6 +4,7 @@ import { type ObjectRecordOrderByForCompositeField, type ObjectRecordOrderByForScalarField, } from 'twenty-shared/types'; +import { type FirstDayOfTheWeek } from 'twenty-shared/utils'; export type ObjectRecordFilter = Partial<{ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -27,6 +28,7 @@ export type ObjectRecordGroupByForCompositeField = Partial<{ export type ObjectRecordGroupByForDateField = Partial<{ [Property in keyof ObjectRecord]: { granularity: ObjectRecordGroupByDateGranularity; + weekStartDay?: FirstDayOfTheWeek; }; }>; 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 b5edc34922..ab7fefc460 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,7 +1,10 @@ import { Injectable } from '@nestjs/common'; import { GraphQLEnumType, GraphQLInputObjectType } from 'graphql'; -import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; +import { + FirstDayOfTheWeek, + ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { OrderByDirectionType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/enum'; @@ -30,7 +33,7 @@ export class GroupByDateGranularityInputTypeGenerator { {} as Record, ), description: - 'Date granularity (e.g. day, month, quarter, year, day of the week, quarter of the year, month of the year)', + 'Date granularity (e.g. day, month, quarter, year, week, day of the week, quarter of the year, month of the year)', }), ); @@ -42,13 +45,33 @@ export class GroupByDateGranularityInputTypeGenerator { throw new Error('DateGranularityEnum not found'); } + const firstDayOfWeekEnum = new GraphQLEnumType({ + name: 'FirstDayOfTheWeek', + values: Object.values(FirstDayOfTheWeek).reduce( + (acc, option) => { + acc[option] = { value: option }; + + return acc; + }, + {} as Record, + ), + description: 'First day of the week (MONDAY, SUNDAY, SATURDAY)', + }); + + this.gqlTypesStorage.addGqlType('FirstDayOfTheWeek', firstDayOfWeekEnum); + const groupByDateField = new GraphQLInputObjectType({ name: GROUP_BY_DATE_GRANULARITY_INPUT_KEY, fields: { granularity: { type: dateGranularityEnum, description: - 'Date granularity (e.g. day, month, quarter, year, day of the week, quarter of the year, month of the year)', + 'Date granularity (e.g. day, month, quarter, year, week, day of the week, quarter of the year, month of the year)', + }, + weekStartDay: { + type: firstDayOfWeekEnum, + description: + 'First day of the week (only applicable when granularity is WEEK). Defaults to MONDAY if not specified.', }, }, }); diff --git a/packages/twenty-server/src/engine/core-modules/page-layout/enums/date-granularity.enum.ts b/packages/twenty-server/src/engine/core-modules/page-layout/enums/date-granularity.enum.ts index 75254febfd..af3b191620 100644 --- a/packages/twenty-server/src/engine/core-modules/page-layout/enums/date-granularity.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/page-layout/enums/date-granularity.enum.ts @@ -5,7 +5,7 @@ import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; registerEnumType(ObjectRecordGroupByDateGranularity, { name: 'ObjectRecordGroupByDateGranularity', description: - 'Date granularity options (e.g. DAY, MONTH, QUARTER, YEAR, DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR)', + 'Date granularity options (e.g. DAY, MONTH, QUARTER, YEAR, WEEK, DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR)', }); export { ObjectRecordGroupByDateGranularity }; 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 008f7ecd04..7a089d06aa 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 @@ -8,6 +8,7 @@ import { subWeeks, subYears, } from 'date-fns'; +import { FirstDayOfTheWeek } from 'twenty-shared/types'; import { type RelativeDateFilter } from 'twenty-shared/utils'; import { @@ -468,7 +469,7 @@ describe('Relative Date Filter Utils', () => { const relativeDateFilterValue: RelativeDateFilter = { direction: 'THIS', unit: 'WEEK', - firstDayOfTheWeek: 'MONDAY', + firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY, }; expect( 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 2cae998635..691b54b08b 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 @@ -247,11 +247,11 @@ describe('group-by resolver (integration)', () => { const testPerson2Id = randomUUID(); const testPerson3Id = randomUUID(); - beforeAll(async () => { - const idJan2 = testPersonId; - const idJan8 = testPerson2Id; - const idMar3 = testPerson3Id; + const idJan2 = testPersonId; + const idJan8 = testPerson2Id; + const idMar3 = testPerson3Id; + beforeAll(async () => { await makeGraphqlAPIRequest( createOneOperationFactory({ objectMetadataSingularName: 'person', @@ -336,6 +336,299 @@ describe('group-by resolver (integration)', () => { expect(marGroup.totalCount).toBe(1); }); + describe('group by week', () => { + const idMarch1st = randomUUID(); + const idMarch2nd = randomUUID(); + const idMarch3rd = randomUUID(); + + beforeAll(async () => { + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { id: idMarch3rd, createdAt: '2025-03-03T09:30:00.000Z' }, // monday, march, Q1, 2025 + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { id: idMarch2nd, createdAt: '2025-03-02T09:30:00.000Z' }, // sunday, march, Q1, 2025 + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { id: idMarch1st, createdAt: '2025-03-01T09:30:00.000Z' }, // saturday, march, Q1, 2025 + }), + ); + }); + + afterAll(async () => { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + recordId: idMarch1st, + }), + ); + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + recordId: idMarch2nd, + }), + ); + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + recordId: idMarch3rd, + }), + ); + }); + + it('datetime field - groups by createdAt WEEK with default (MONDAY)', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [{ createdAt: { granularity: 'WEEK' } }], + gqlFields: ` + edges { + node { + id + } + } + `, + filter: filter2025, + }), + ); + + const groups = response.body.data.peopleGroupBy; + + expect(groups).toBeDefined(); + expect(groups.length).toBe(4); + + // Group starting week of monday dec 30th, 2024 + const mondayDec30thGroup = groups.find((group: any) => + group.groupByDimensionValues[0].startsWith('2024-12-30T00:00:00'), + ); + + expect(mondayDec30thGroup).toBeDefined(); + expect(mondayDec30thGroup.edges[0].node.id).toBe(idJan2); + expect(mondayDec30thGroup.totalCount).toBe(1); + + // Group starting week of monday jan 6th, 2025 + const mondayJan6thGroup = groups.find((group: any) => + group.groupByDimensionValues[0].startsWith('2025-01-06T00:00:00'), + ); + + expect(mondayJan6thGroup).toBeDefined(); + expect(mondayJan6thGroup.edges[0].node.id).toBe(idJan8); + expect(mondayJan6thGroup.totalCount).toBe(1); + + // Group starting week of monday feb 24th, 2025 + const mondayFeb24thGroup = groups.find((group: any) => + group.groupByDimensionValues[0].startsWith('2025-02-24T00:00:00'), + ); + + expect(mondayFeb24thGroup).toBeDefined(); + expect(mondayFeb24thGroup.edges.length).toBe(2); + expect( + mondayFeb24thGroup.edges.find( + (edge: any) => edge.node.id === idMarch2nd, + ), + ).toBeDefined; + expect( + mondayFeb24thGroup.edges.find( + (edge: any) => edge.node.id === idMarch1st, + ), + ).toBeDefined; + + // Group starting week of monday march 3rd, 2025 + const mondayMarch3rdGroup = groups.find((group: any) => + group.groupByDimensionValues[0].startsWith('2025-03-03T00:00:00'), + ); + + expect(mondayMarch3rdGroup).toBeDefined(); + expect(mondayMarch3rdGroup.edges.length).toBe(2); + expect( + mondayMarch3rdGroup.edges.find( + (edge: any) => edge.node.id === idMarch3rd, + ), + ).toBeDefined; + expect( + mondayMarch3rdGroup.edges.find( + (edge: any) => edge.node.id === idMar3, + ), + ).toBeDefined; + }); + + it('datetime field - groups by createdAt WEEK with weekStartDay SUNDAY', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [ + { createdAt: { granularity: 'WEEK', weekStartDay: 'SUNDAY' } }, + ], + gqlFields: ` + edges { + node { + id + } + } + `, + filter: filter2025, + }), + ); + + const groups = response.body.data.peopleGroupBy; + + expect(groups).toBeDefined(); + expect(groups.length).toBe(4); + + // Group starting week of sunday dec 29th, 2024 + const sundayDec29thGroup = groups.find((group: any) => + group.groupByDimensionValues[0].startsWith('2024-12-29T00:00:00'), + ); + + expect(sundayDec29thGroup).toBeDefined(); + expect(sundayDec29thGroup.totalCount).toBe(1); + expect( + sundayDec29thGroup.edges.find((edge: any) => edge.node.id === idJan2), + ).toBeDefined(); + + // Group starting week of sunday jan 5th, 2025 + const sundayJan5thGroup = groups.find((group: any) => + group.groupByDimensionValues[0].startsWith('2025-01-05T00:00:00'), + ); + + expect(sundayJan5thGroup).toBeDefined(); + expect(sundayJan5thGroup.totalCount).toBe(1); + expect( + sundayJan5thGroup.edges.find((edge: any) => edge.node.id === idJan8), + ).toBeDefined(); + + // Group starting week of sunday feb 23rd, 2025 + const sundayFeb23rdGroup = groups.find((group: any) => + group.groupByDimensionValues[0].startsWith('2025-02-23T00:00:00'), + ); + + expect(sundayFeb23rdGroup).toBeDefined(); + expect(sundayFeb23rdGroup.totalCount).toBe(1); + expect( + sundayFeb23rdGroup.edges.find( + (edge: any) => edge.node.id === idMarch1st, + ), + ).toBeDefined(); + + // Group starting week of sunday march 2nd, 2025 + const sundayMarch2ndGroup = groups.find((group: any) => + group.groupByDimensionValues[0].startsWith('2025-03-02T00:00:00'), + ); + + expect(sundayMarch2ndGroup).toBeDefined(); + expect(sundayMarch2ndGroup.totalCount).toBe(3); + expect( + sundayMarch2ndGroup.edges.find( + (edge: any) => edge.node.id === idMarch2nd, + ), + ).toBeDefined(); + expect( + sundayMarch2ndGroup.edges.find( + (edge: any) => edge.node.id === idMarch3rd, + ), + ).toBeDefined(); + expect( + sundayMarch2ndGroup.edges.find( + (edge: any) => edge.node.id === idMar3, + ), + ).toBeDefined(); + }); + + it('datetime field - groups by createdAt WEEK with weekStartDay SATURDAY', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [ + { createdAt: { granularity: 'WEEK', weekStartDay: 'SATURDAY' } }, + ], + gqlFields: ` + edges { + node { + id + } + } + `, + filter: filter2025, + }), + ); + + const groups = response.body.data.peopleGroupBy; + + expect(groups).toBeDefined(); + expect(groups.length).toBe(3); + + // Group starting week of saturday dec 28th, 2024 + const saturdayDec28thGroup = groups.find((group: any) => + group.groupByDimensionValues[0].startsWith('2024-12-28T00:00:00'), + ); + + expect(saturdayDec28thGroup).toBeDefined(); + expect(saturdayDec28thGroup.totalCount).toBe(1); + expect( + saturdayDec28thGroup.edges.find( + (edge: any) => edge.node.id === idJan2, + ), + ).toBeDefined(); + + // Group starting week of saturday jan 4th, 2025 + const saturdayJan4thGroup = groups.find((group: any) => + group.groupByDimensionValues[0].startsWith('2025-01-04T00:00:00'), + ); + + expect(saturdayJan4thGroup).toBeDefined(); + expect(saturdayJan4thGroup.totalCount).toBe(1); + expect( + saturdayJan4thGroup.edges.find( + (edge: any) => edge.node.id === idJan8, + ), + ).toBeDefined(); + + // Group starting week of saturday march 1st, 2025 + const saturdayMarch1stGroup = groups.find((group: any) => + group.groupByDimensionValues[0].startsWith('2025-03-01T00:00:00'), + ); + + expect(saturdayMarch1stGroup).toBeDefined(); + expect(saturdayMarch1stGroup.totalCount).toBe(4); + expect( + saturdayMarch1stGroup.edges.find( + (edge: any) => edge.node.id === idMarch1st, + ), + ).toBeDefined(); + expect( + saturdayMarch1stGroup.edges.find( + (edge: any) => edge.node.id === idMarch2nd, + ), + ).toBeDefined(); + expect( + saturdayMarch1stGroup.edges.find( + (edge: any) => edge.node.id === idMarch3rd, + ), + ).toBeDefined(); + expect( + saturdayMarch1stGroup.edges.find( + (edge: any) => edge.node.id === idMar3, + ), + ).toBeDefined(); + }); + }); + describe('cyclic date', () => { const filter2024And2025 = { and: [ @@ -838,6 +1131,39 @@ describe('group-by resolver (integration)', () => { expect(wednesdayGroup.totalCount).toBe(1); }); + it('groups by one relation field - company createdAt with WEEK granularity and weekStartDay SUNDAY', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [ + { + company: { + createdAt: { + granularity: 'WEEK', + weekStartDay: 'SUNDAY', + }, + }, + }, + ], + filter: filter2025, + }), + ); + + const groups = response.body.data.peopleGroupBy; + + expect(groups).toBeDefined(); + expect(Array.isArray(groups)).toBe(true); + + // Verify that all records are grouped + const totalCount = groups.reduce( + (sum: number, group: any) => sum + group.totalCount, + 0, + ); + + expect(totalCount).toBe(3); + }); + it('groups by two relation fields from the same joined table', async () => { const response = await makeGraphqlAPIRequest( groupByOperationFactory({ diff --git a/packages/twenty-shared/src/types/FirstDayOfTheWeek.ts b/packages/twenty-shared/src/types/FirstDayOfTheWeek.ts new file mode 100644 index 0000000000..28e026cce5 --- /dev/null +++ b/packages/twenty-shared/src/types/FirstDayOfTheWeek.ts @@ -0,0 +1,5 @@ +export enum FirstDayOfTheWeek { + MONDAY = 'MONDAY', + SUNDAY = 'SUNDAY', + SATURDAY = 'SATURDAY', +} diff --git a/packages/twenty-shared/src/types/ObjectRecordGroupByDateGranularity.ts b/packages/twenty-shared/src/types/ObjectRecordGroupByDateGranularity.ts index 4a9513bcc6..571203aa5e 100644 --- a/packages/twenty-shared/src/types/ObjectRecordGroupByDateGranularity.ts +++ b/packages/twenty-shared/src/types/ObjectRecordGroupByDateGranularity.ts @@ -3,6 +3,7 @@ export enum ObjectRecordGroupByDateGranularity { MONTH = 'MONTH', QUARTER = 'QUARTER', YEAR = 'YEAR', + WEEK = 'WEEK', DAY_OF_THE_WEEK = 'DAY_OF_THE_WEEK', MONTH_OF_THE_YEAR = 'MONTH_OF_THE_YEAR', QUARTER_OF_THE_YEAR = 'QUARTER_OF_THE_YEAR', diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index 053120c409..82c4bb0d0b 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -109,6 +109,7 @@ export type { FilterableAndTSVectorFieldType, } from './FilterableFieldType'; export { FILTERABLE_FIELD_TYPES } from './FilterableFieldType'; +export { FirstDayOfTheWeek } from './FirstDayOfTheWeek'; export type { FromTo } from './FromToType'; export type { IsEmptyRecord } from './IsEmptyRecord.type'; export type { IsExactly } from './IsExactly'; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/firstDayOfWeekSchema.ts b/packages/twenty-shared/src/utils/filter/dates/utils/firstDayOfWeekSchema.ts index 8b9bc067d8..f18afa4049 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/firstDayOfWeekSchema.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/firstDayOfWeekSchema.ts @@ -1,5 +1,10 @@ +import { FirstDayOfTheWeek as FirstDayOfTheWeekEnum } from '@/types/FirstDayOfTheWeek'; import z from 'zod'; -export const firstDayOfWeekSchema = z.enum(['MONDAY', 'SATURDAY', 'SUNDAY']); +export const firstDayOfWeekSchema = z.enum([ + FirstDayOfTheWeekEnum.MONDAY, + FirstDayOfTheWeekEnum.SATURDAY, + FirstDayOfTheWeekEnum.SUNDAY, +]); export type FirstDayOfTheWeek = z.infer; diff --git a/packages/twenty-shared/src/utils/filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS.ts b/packages/twenty-shared/src/utils/filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS.ts index 0eb66e549a..af1ca254aa 100644 --- a/packages/twenty-shared/src/utils/filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS.ts +++ b/packages/twenty-shared/src/utils/filter/dates/utils/getFirstDayOfTheWeekAsANumberForDateFNS.ts @@ -1,3 +1,4 @@ +import { FirstDayOfTheWeek as FirstDayOfTheWeekEnum } from '@/types/FirstDayOfTheWeek'; import { assertUnreachable } from '@/utils/assertUnreachable'; import { type FirstDayOfTheWeek } from '@/utils/filter/dates/utils/firstDayOfWeekSchema'; @@ -5,11 +6,11 @@ export const getFirstDayOfTheWeekAsANumberForDateFNS = ( firstDayOfTheWeek: FirstDayOfTheWeek, ): 0 | 1 | 6 => { switch (firstDayOfTheWeek) { - case 'MONDAY': + case FirstDayOfTheWeekEnum.MONDAY: return 1; - case 'SATURDAY': + case FirstDayOfTheWeekEnum.SATURDAY: return 6; - case 'SUNDAY': + case FirstDayOfTheWeekEnum.SUNDAY: return 0; default: return assertUnreachable(firstDayOfTheWeek);