From 8cf462d5f7f710a8d4b09a967a54a3c48c50efbf Mon Sep 17 00:00:00 2001 From: Weiko Date: Wed, 15 Jul 2026 18:50:23 +0200 Subject: [PATCH] Add day view support to record calendar (#22922) ## Summary - Add a calendar day view and wire it into the record calendar layout selection - Update the top bar, time grid, and week/day drag and drop handling to support the new view - Extend supported layout logic and public feature flags for calendar day view access - Add coverage for calendar view content, calendar container behavior, top bar behavior, day view rendering, supported layout resolution, and week event drop handling Screenshot 2026-07-15 at 17 48 33 Review in cubic --- ...jectOptionsDropdownCalendarViewContent.tsx | 49 +- ...ptionsDropdownCalendarViewContent.test.tsx | 140 ++++ .../components/RecordCalendar.tsx | 5 +- .../components/RecordCalendarTopBar.tsx | 72 +- .../__tests__/RecordCalendar.test.tsx | 35 +- .../__tests__/RecordCalendarTopBar.test.tsx | 151 ++++ .../day/components/RecordCalendarDay.tsx | 37 + .../__tests__/RecordCalendarDay.test.tsx | 53 ++ .../components/RecordCalendarTimeGrid.tsx | 650 ++++++++++++++++ .../RecordCalendarTimeGridAllDayCell.tsx | 59 ++ .../RecordCalendarTimeGridAllDayCell.test.tsx | 84 +++ .../getSupportedRecordCalendarLayout.test.ts | 2 +- .../utils/getSupportedRecordCalendarLayout.ts | 11 +- .../week/components/RecordCalendarWeek.tsx | 692 +----------------- .../RecordCalendarWeekDragDropContext.tsx | 8 +- ...resolveRecordCalendarWeekEventDrop.test.ts | 13 + .../constants/public-feature-flag.const.ts | 4 +- 17 files changed, 1325 insertions(+), 740 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__tests__/ObjectOptionsDropdownCalendarViewContent.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar/components/__tests__/RecordCalendarTopBar.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar/day/components/RecordCalendarDay.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar/day/components/__tests__/RecordCalendarDay.test.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGridAllDayCell.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/__tests__/RecordCalendarTimeGridAllDayCell.test.tsx diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent.tsx index 8ee168efad..6e14cf8f65 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent.tsx @@ -17,6 +17,7 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { t } from '@lingui/core/macro'; import { Pill } from 'twenty-ui/data-display'; import { + IconCalendarEvent, IconCalendarMonth, IconCalendarWeek, IconChevronLeft, @@ -28,6 +29,8 @@ import { ViewCalendarLayout, } from '~/generated-metadata/graphql'; +const RECORD_CALENDAR_TIMELINE_VIEW_ID = 'record-calendar-timeline-view'; + export const ObjectOptionsDropdownCalendarViewContent = () => { const { resetContent } = useObjectOptionsDropdown(); const recordIndexCalendarLayout = useAtomStateValue( @@ -53,16 +56,18 @@ export const ObjectOptionsDropdownCalendarViewContent = () => { const { closeDropdown } = useObjectOptionsDropdown(); const selectableItemIdArray = [ + ViewCalendarLayout.DAY, ViewCalendarLayout.WEEK, ViewCalendarLayout.MONTH, - ViewCalendarLayout.DAY, + RECORD_CALENDAR_TIMELINE_VIEW_ID, ]; const handleCalendarViewChange = async (calendarView: ViewCalendarLayout) => { - if ( - calendarView === ViewCalendarLayout.WEEK && - !isCalendarWeekViewEnabled - ) { + const isTimeGridLayout = + calendarView === ViewCalendarLayout.DAY || + calendarView === ViewCalendarLayout.WEEK; + + if (isTimeGridLayout && !isCalendarWeekViewEnabled) { return; } @@ -96,6 +101,31 @@ export const ObjectOptionsDropdownCalendarViewContent = () => { focusId={OBJECT_OPTIONS_DROPDOWN_ID} selectableItemIdArray={selectableItemIdArray} > + { + if (isCalendarWeekViewEnabled) { + handleCalendarViewChange(ViewCalendarLayout.DAY); + } + }} + > + handleCalendarViewChange(ViewCalendarLayout.DAY) + : undefined + } + focused={selectedItemId === ViewCalendarLayout.DAY} + contextualText={ + isCalendarWeekViewEnabled ? undefined : + } + contextualTextPosition="right" + disabled={!isCalendarWeekViewEnabled} + /> + { @@ -133,15 +163,12 @@ export const ObjectOptionsDropdownCalendarViewContent = () => { focused={selectedItemId === ViewCalendarLayout.MONTH} /> - handleCalendarViewChange(ViewCalendarLayout.DAY)} - > + } contextualTextPosition="right" disabled diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__tests__/ObjectOptionsDropdownCalendarViewContent.test.tsx b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__tests__/ObjectOptionsDropdownCalendarViewContent.test.tsx new file mode 100644 index 0000000000..57c6f8b179 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/components/__tests__/ObjectOptionsDropdownCalendarViewContent.test.tsx @@ -0,0 +1,140 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { ObjectOptionsDropdownCalendarViewContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent'; +import { ViewCalendarLayout } from '~/generated-metadata/graphql'; + +const mockCloseDropdown = jest.fn(); +const mockResetContent = jest.fn(); +const mockSetRecordIndexCalendarLayout = jest.fn(); +const mockUpdateCurrentView = jest.fn(); +const mockUseIsFeatureEnabled = jest.fn(); +const mockUseAtomStateValue = jest.fn(); + +jest.mock( + '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown', + () => ({ + useObjectOptionsDropdown: jest.fn(() => ({ + closeDropdown: mockCloseDropdown, + resetContent: mockResetContent, + })), + }), +); +jest.mock('@/ui/layout/dropdown/components/DropdownContent', () => ({ + DropdownContent: ({ children }: { children: React.ReactNode }) => children, +})); +jest.mock( + '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader', + () => ({ + DropdownMenuHeader: ({ children }: { children: React.ReactNode }) => + children, + }), +); +jest.mock( + '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent', + () => ({ DropdownMenuHeaderLeftComponent: () => null }), +); +jest.mock('@/ui/layout/dropdown/components/DropdownMenuItemsContainer', () => ({ + DropdownMenuItemsContainer: ({ children }: { children: React.ReactNode }) => + children, +})); +jest.mock('@/ui/layout/selectable-list/components/SelectableList', () => ({ + SelectableList: ({ children }: { children: React.ReactNode }) => children, +})); +jest.mock('@/ui/layout/selectable-list/components/SelectableListItem', () => ({ + SelectableListItem: ({ children }: { children: React.ReactNode }) => children, +})); +jest.mock( + '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue', + () => ({ useAtomComponentStateValue: jest.fn(() => null) }), +); +jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({ + useAtomStateValue: (...args: unknown[]) => mockUseAtomStateValue(...args), +})); +jest.mock('@/ui/utilities/state/jotai/hooks/useSetAtomState', () => ({ + useSetAtomState: jest.fn(() => mockSetRecordIndexCalendarLayout), +})); +jest.mock('@/views/hooks/useUpdateCurrentView', () => ({ + useUpdateCurrentView: jest.fn(() => ({ + updateCurrentView: mockUpdateCurrentView, + })), +})); +jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({ + useIsFeatureEnabled: (...args: unknown[]) => mockUseIsFeatureEnabled(...args), +})); +jest.mock('twenty-ui/data-display', () => ({ + Pill: ({ label }: { label: string }) => {label}, +})); +jest.mock('twenty-ui/navigation', () => ({ + MenuItemSelect: ({ + contextualText, + disabled, + onClick, + selected, + text, + }: { + contextualText?: React.ReactNode; + disabled?: boolean; + onClick?: () => void; + selected: boolean; + text: string; + }) => ( + + ), +})); + +describe('ObjectOptionsDropdownCalendarViewContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAtomStateValue.mockReturnValue(ViewCalendarLayout.MONTH); + mockUseIsFeatureEnabled.mockReturnValue(true); + mockUpdateCurrentView.mockResolvedValue(undefined); + }); + + it('offers Day, Week, and Month while keeping Timeline disabled', () => { + render(); + + expect( + screen + .getAllByRole('button') + .map((button) => button.textContent?.replace('Soon', '')), + ).toEqual(['Day', 'Week', 'Month', 'Timeline']); + expect(screen.getByText('Day').closest('button')).toBeEnabled(); + expect(screen.getByText('Timeline').closest('button')).toBeDisabled(); + }); + + it('persists Day without treating it as Timeline', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Day' })); + + expect(mockSetRecordIndexCalendarLayout).toHaveBeenCalledWith( + ViewCalendarLayout.DAY, + ); + expect(mockUpdateCurrentView).toHaveBeenCalledWith({ + calendarLayout: ViewCalendarLayout.DAY, + }); + await waitFor(() => expect(mockCloseDropdown).toHaveBeenCalled()); + }); + + it('keeps Day and Week unavailable when the feature flag is disabled', () => { + mockUseIsFeatureEnabled.mockReturnValue(false); + + render(); + + const dayButton = screen.getByText('Day').closest('button'); + const weekButton = screen.getByText('Week').closest('button'); + + expect(dayButton).toBeDisabled(); + expect(weekButton).toBeDisabled(); + + if (dayButton !== null) { + fireEvent.click(dayButton); + } + + expect(mockSetRecordIndexCalendarLayout).not.toHaveBeenCalled(); + expect(mockUpdateCurrentView).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendar.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendar.tsx index c8f2e909ad..60cdbdc3a2 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendar.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/components/RecordCalendar.tsx @@ -4,6 +4,7 @@ import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/cons import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId'; import { RecordCalendarTopBar } from '@/object-record/record-calendar/components/RecordCalendarTopBar'; import { RECORD_CALENDAR_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-calendar/constants/RecordCalendarClickOutsideListenerId'; +import { RecordCalendarDay } from '@/object-record/record-calendar/day/components/RecordCalendarDay'; import { RecordCalendarMonth } from '@/object-record/record-calendar/month/components/RecordCalendarMonth'; import { RecordCalendarWeek } from '@/object-record/record-calendar/week/components/RecordCalendarWeek'; import { RECORD_CALENDAR_CARD_CLICK_OUTSIDE_ID } from '@/object-record/record-calendar/record-calendar-card/constants/RecordCalendarCardClickOutsideId'; @@ -80,7 +81,9 @@ export const RecordCalendar = () => { - {supportedCalendarLayout === ViewCalendarLayout.WEEK ? ( + {supportedCalendarLayout === ViewCalendarLayout.DAY ? ( + + ) : supportedCalendarLayout === ViewCalendarLayout.WEEK ? ( ) : ( 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 a9f5a2c325..2014764288 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,3 +1,4 @@ +import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat'; import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState'; import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout'; @@ -80,6 +81,7 @@ export const RecordCalendarTopBar = () => { }); const dateLocale = useAtomStateValue(dateLocaleState); + const { timeZone } = useDateTimeFormat(); const { firstDayOfWeek, lastDayOfWeek } = useRecordCalendarWeekDaysRange( recordCalendarSelectedDate, ); @@ -97,26 +99,33 @@ export const RecordCalendarTopBar = () => { }; const handlePreviousPeriod = () => { - setRecordCalendarSelectedDate( - supportedCalendarLayout === ViewCalendarLayout.WEEK - ? recordCalendarSelectedDate.subtract({ weeks: 1 }) - : recordCalendarSelectedDate.subtract({ months: 1 }), - ); + const previousDate = + supportedCalendarLayout === ViewCalendarLayout.DAY + ? recordCalendarSelectedDate.subtract({ days: 1 }) + : supportedCalendarLayout === ViewCalendarLayout.WEEK + ? recordCalendarSelectedDate.subtract({ weeks: 1 }) + : recordCalendarSelectedDate.subtract({ months: 1 }); + + setRecordCalendarSelectedDate(previousDate); }; const handleNextPeriod = () => { - setRecordCalendarSelectedDate( - supportedCalendarLayout === ViewCalendarLayout.WEEK - ? recordCalendarSelectedDate.add({ weeks: 1 }) - : recordCalendarSelectedDate.add({ months: 1 }), - ); + const nextDate = + supportedCalendarLayout === ViewCalendarLayout.DAY + ? recordCalendarSelectedDate.add({ days: 1 }) + : supportedCalendarLayout === ViewCalendarLayout.WEEK + ? recordCalendarSelectedDate.add({ weeks: 1 }) + : recordCalendarSelectedDate.add({ months: 1 }); + + setRecordCalendarSelectedDate(nextDate); }; const handleCalendarLayoutChange = (calendarLayout: ViewCalendarLayout) => { - if ( - calendarLayout === ViewCalendarLayout.WEEK && - !isCalendarWeekViewEnabled - ) { + const isTimeGridLayout = + calendarLayout === ViewCalendarLayout.DAY || + calendarLayout === ViewCalendarLayout.WEEK; + + if (isTimeGridLayout && !isCalendarWeekViewEnabled) { return; } @@ -125,23 +134,27 @@ export const RecordCalendarTopBar = () => { }; const handleTodayClick = () => { - setRecordCalendarSelectedDate(Temporal.Now.plainDateISO()); + setRecordCalendarSelectedDate(Temporal.Now.plainDateISO(timeZone)); }; const formattedDate = - supportedCalendarLayout === ViewCalendarLayout.WEEK - ? formatRecordCalendarWeekRange({ - firstDayOfWeek, - lastDayOfWeek, - locale: dateLocale.localeCatalog, + supportedCalendarLayout === ViewCalendarLayout.DAY + ? recordCalendarSelectedDate.toLocaleString(dateLocale.locale, { + dateStyle: 'full', }) - : format( - turnPlainDateToShiftedDateInSystemTimeZone( - recordCalendarSelectedDate, - ), - 'MMMM yyyy', - { locale: dateLocale.localeCatalog }, - ); + : supportedCalendarLayout === ViewCalendarLayout.WEEK + ? formatRecordCalendarWeekRange({ + firstDayOfWeek, + lastDayOfWeek, + locale: dateLocale.localeCatalog, + }) + : format( + turnPlainDateToShiftedDateInSystemTimeZone( + recordCalendarSelectedDate, + ), + 'MMMM yyyy', + { locale: dateLocale.localeCatalog }, + ); const dropdownContentOffset = { x: 140, y: 0 } satisfies DropdownOffset; @@ -153,6 +166,7 @@ export const RecordCalendarTopBar = () => { dropdownId={`record-calendar-layout-${recordCalendarId}`} value={supportedCalendarLayout} options={[ + { label: t`Day`, value: ViewCalendarLayout.DAY }, { label: t`Week`, value: ViewCalendarLayout.WEEK }, { label: t`Month`, value: ViewCalendarLayout.MONTH }, ]} @@ -186,7 +200,7 @@ export const RecordCalendarTopBar = () => { } dropdownOffset={dropdownContentOffset} /> - {supportedCalendarLayout !== ViewCalendarLayout.WEEK && ( + {supportedCalendarLayout === ViewCalendarLayout.MONTH && ( )} @@ -194,6 +208,7 @@ export const RecordCalendarTopBar = () => { + ), +})); + +describe('RecordCalendarTopBar', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseAtomComponentState.mockReturnValue([ + Temporal.PlainDate.from('2026-07-15'), + mockSetRecordCalendarSelectedDate, + ]); + mockUseAtomState.mockReturnValue([ + ViewCalendarLayout.DAY, + mockSetRecordIndexCalendarLayout, + ]); + mockUseAtomStateValue.mockReturnValue({ + locale: 'en-US', + localeCatalog: enUS, + }); + mockUseRecordCalendarWeekDaysRange.mockReturnValue({ + firstDayOfWeek: Temporal.PlainDate.from('2026-07-13'), + lastDayOfWeek: Temporal.PlainDate.from('2026-07-19'), + }); + }); + + it('shows Day, Week, and Month with the selected full date', () => { + render(); + + expect(screen.getByTestId('selected-date')).toHaveTextContent( + 'Wednesday, July 15, 2026', + ); + expect( + Array.from( + screen.getByTestId('layout-select').querySelectorAll('option'), + ).map((option) => option.textContent), + ).toEqual(['Day', 'Week', 'Month']); + expect(screen.queryByTestId('time-zone')).not.toBeInTheDocument(); + }); + + it('navigates the Day layout one day at a time', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Previous period' })); + fireEvent.click(screen.getByRole('button', { name: 'Next period' })); + + expect(mockSetRecordCalendarSelectedDate).toHaveBeenNthCalledWith( + 1, + Temporal.PlainDate.from('2026-07-14'), + ); + expect(mockSetRecordCalendarSelectedDate).toHaveBeenNthCalledWith( + 2, + Temporal.PlainDate.from('2026-07-16'), + ); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/day/components/RecordCalendarDay.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/day/components/RecordCalendarDay.tsx new file mode 100644 index 0000000000..258e04dffe --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar/day/components/RecordCalendarDay.tsx @@ -0,0 +1,37 @@ +import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; +import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState'; +import { RecordCalendarTimeGrid } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { format } from 'date-fns'; +import { turnPlainDateToShiftedDateInSystemTimeZone } from 'twenty-shared/utils'; +import { dateLocaleState } from '~/localization/states/dateLocaleState'; + +export const RecordCalendarDay = () => { + const recordCalendarId = useAvailableComponentInstanceIdOrThrow( + RecordCalendarComponentInstanceContext, + ); + const recordCalendarSelectedDate = useAtomComponentStateValue( + recordCalendarSelectedDateComponentState, + recordCalendarId, + ); + const dateLocale = useAtomStateValue(dateLocaleState); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/day/components/__tests__/RecordCalendarDay.test.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/day/components/__tests__/RecordCalendarDay.test.tsx new file mode 100644 index 0000000000..543b65c152 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar/day/components/__tests__/RecordCalendarDay.test.tsx @@ -0,0 +1,53 @@ +import { render, screen } from '@testing-library/react'; +import { type Temporal } from 'temporal-polyfill'; + +import { RecordCalendarDay } from '@/object-record/record-calendar/day/components/RecordCalendarDay'; + +jest.mock( + '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid', + () => ({ + RecordCalendarTimeGrid: ({ + days, + }: { + days: { date: Temporal.PlainDate; label: string }[]; + }) => ( +
+ {days.map(({ date, label }) => `${date.toString()}:${label}`).join(',')} +
+ ), + }), +); +jest.mock( + '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow', + () => ({ + useAvailableComponentInstanceIdOrThrow: jest.fn(() => 'calendar-id'), + }), +); +jest.mock('@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue', () => { + const { Temporal } = jest.requireActual('temporal-polyfill'); + + return { + useAtomComponentStateValue: jest.fn(() => + Temporal.PlainDate.from('2026-07-15'), + ), + }; +}); +jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => { + const { enUS } = jest.requireActual('date-fns/locale'); + + return { + useAtomStateValue: jest.fn(() => ({ localeCatalog: enUS })), + }; +}); + +describe('RecordCalendarDay', () => { + it('renders the selected date as the only time-grid day', () => { + render(); + + expect(screen.getByTestId('time-grid')).toHaveAttribute( + 'data-day-count', + '1', + ); + expect(screen.getByTestId('time-grid')).toHaveTextContent('2026-07-15:Wed'); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid.tsx new file mode 100644 index 0000000000..f1fc0ffb64 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid.tsx @@ -0,0 +1,650 @@ +import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat'; +import { RecordCalendarAddNew } from '@/object-record/record-calendar/components/RecordCalendarAddNew'; +import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext'; +import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector'; +import { RecordCalendarTimeGridAllDayCell } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGridAllDayCell'; +import { RecordCalendarWeekEvent } from '@/object-record/record-calendar/week/components/RecordCalendarWeekEvent'; +import { RecordCalendarWeekDragDropContext } from '@/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext'; +import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions'; +import { computeRecordCalendarWeekEventLayouts } from '@/object-record/record-calendar/week/utils/computeRecordCalendarWeekEventLayouts'; +import { getRecordCalendarWeekTimedEventMetrics } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekTimedEventMetrics'; +import { getRecordCalendarWeekSlotIndex } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekSlotIndex'; +import { + type RecordCalendarWeekActiveSlot, + type RecordCalendarWeekSlotInteractionMode, + updateRecordCalendarWeekActiveSlot, +} from '@/object-record/record-calendar/week/utils/updateRecordCalendarWeekActiveSlot'; +import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState'; +import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation'; +import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { styled } from '@linaria/react'; +import { t } from '@lingui/core/macro'; +import { format } from 'date-fns'; +import { formatInTimeZone } from 'date-fns-tz'; +import { useStore } from 'jotai'; +import { + type FocusEvent, + type KeyboardEvent, + type MouseEvent, + memo, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { Temporal } from 'temporal-polyfill'; +import { + isDefined, + isPlainDateInWeekend, + isSamePlainDate, +} from 'twenty-shared/utils'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +const CURRENT_TIME_REFRESH_INTERVAL_IN_MILLISECONDS = 60_000; +const DEFAULT_KEYBOARD_SLOT_INDEX = 18; + +const StyledContainer = styled.div<{ minWidthInPixels: number }>` + background: ${themeCssVariables.background.primary}; + border: 1px solid ${themeCssVariables.border.color.light}; + border-radius: ${themeCssVariables.border.radius.sm}; + border-top: 0; + min-width: ${({ minWidthInPixels }) => `${minWidthInPixels}px`}; + overflow: clip; +`; + +const StyledHeader = styled.div<{ dayCount: number }>` + background: ${themeCssVariables.background.primary}; + border-bottom: 1px solid ${themeCssVariables.border.color.light}; + border-top: 1px solid ${themeCssVariables.border.color.light}; + display: grid; + grid-template-columns: + ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px + repeat(${({ dayCount }) => dayCount}, minmax(120px, 1fr)); + position: sticky; + top: 0; + z-index: 3; +`; + +const StyledHeaderGutter = styled.div` + align-items: center; + color: ${themeCssVariables.font.color.light}; + display: flex; + font-size: ${themeCssVariables.font.size.xs}; + height: 32px; + justify-content: flex-end; + padding-right: ${themeCssVariables.spacing[1]}; +`; + +const StyledDayHeader = styled.div` + align-items: center; + border-left: 1px solid ${themeCssVariables.border.color.light}; + color: ${themeCssVariables.font.color.light}; + display: flex; + font-size: ${themeCssVariables.font.size.sm}; + gap: ${themeCssVariables.spacing[1]}; + height: 32px; + justify-content: center; +`; + +const StyledDayNumber = styled.span<{ isToday: boolean }>` + align-items: center; + background: ${({ isToday }) => + isToday ? themeCssVariables.color.blue : 'transparent'}; + border-radius: ${themeCssVariables.border.radius.sm}; + color: ${({ isToday }) => + isToday + ? themeCssVariables.font.color.inverted + : themeCssVariables.font.color.primary}; + display: flex; + font-size: ${themeCssVariables.font.size.sm}; + font-weight: ${({ isToday }) => + isToday + ? themeCssVariables.font.weight.medium + : themeCssVariables.font.weight.regular}; + height: 20px; + justify-content: center; + width: 20px; +`; + +const StyledAllDayLabel = styled(StyledHeaderGutter)` + border-top: 1px solid ${themeCssVariables.border.color.light}; + height: 28px; +`; + +const StyledGrid = styled.div<{ dayCount: number }>` + display: grid; + grid-template-columns: + ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px + repeat(${({ dayCount }) => dayCount}, minmax(120px, 1fr)); + height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px; + position: relative; +`; + +const StyledTimeGutter = styled.div` + height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px; + position: relative; +`; + +const StyledHourLabel = styled.span<{ topInPixels: number }>` + color: ${themeCssVariables.font.color.light}; + font-size: ${themeCssVariables.font.size.xs}; + position: absolute; + right: ${themeCssVariables.spacing[1]}; + top: ${({ topInPixels }) => `${topInPixels}px`}; + transform: translateY(-50%); + white-space: nowrap; +`; + +const StyledDayColumn = styled.div<{ isWeekend: boolean }>` + background-color: ${({ isWeekend }) => + isWeekend + ? themeCssVariables.background.secondary + : themeCssVariables.background.primary}; + background-image: repeating-linear-gradient( + to bottom, + ${themeCssVariables.border.color.light} 0, + ${themeCssVariables.border.color.light} 1px, + transparent 1px, + transparent ${RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight}px + ); + border-left: 1px solid ${themeCssVariables.border.color.light}; + height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px; + isolation: isolate; + overflow: hidden; + position: relative; +`; + +const StyledSlotAddNewPositioner = styled.div<{ topInPixels: number }>` + align-items: center; + display: flex; + height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.slotHeight}px; + left: ${themeCssVariables.spacing['0.5']}; + pointer-events: none; + position: absolute; + right: 0; + top: ${({ topInPixels }) => `${topInPixels}px`}; + z-index: 0; + + > div { + pointer-events: auto; + } +`; + +const StyledCurrentTimeLine = styled.div<{ topInPixels: number }>` + background: ${themeCssVariables.color.red8}; + height: 1px; + left: ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px; + pointer-events: none; + position: absolute; + right: 0; + top: ${({ topInPixels }) => `${topInPixels}px`}; + z-index: 2; + + &::after { + background: ${themeCssVariables.color.red8}; + border-radius: 50%; + content: ''; + height: 5px; + left: -2px; + position: absolute; + top: -2px; + width: 5px; + } +`; + +const StyledCurrentTimeLabel = styled.span<{ topInPixels: number }>` + background: ${themeCssVariables.background.primary}; + color: ${themeCssVariables.color.red9}; + font-size: ${themeCssVariables.font.size.xs}; + padding-right: ${themeCssVariables.spacing['0.5']}; + position: absolute; + right: calc(100% - ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth - 1}px); + top: ${({ topInPixels }) => `${topInPixels}px`}; + transform: translateY(-50%); + white-space: nowrap; + z-index: 3; +`; + +const StyledScrollAnchor = styled.div<{ topInPixels: number }>` + height: 1px; + left: 0; + pointer-events: none; + position: absolute; + top: ${({ topInPixels }) => `${topInPixels}px`}; + width: 1px; +`; + +type WeekDayCellProps = { + calendarEndFieldName?: string; + calendarFieldName: string; + calendarFieldType: FieldMetadataType; + day: Temporal.PlainDate; + timeFormat: string; + timeZone: string; +}; + +type RecordCalendarWeekDayColumnProps = Omit & { + activeSlotIndex: number | null; + dayString: string; + onActiveSlotIndexChange: ( + day: string, + slotIndex: number | null, + interactionMode: RecordCalendarWeekSlotInteractionMode, + ) => void; +}; + +const RecordCalendarWeekDayColumn = memo( + ({ + activeSlotIndex, + calendarEndFieldName, + calendarFieldName, + calendarFieldType, + dayString, + onActiveSlotIndexChange, + timeFormat, + timeZone, + }: RecordCalendarWeekDayColumnProps) => { + const day = Temporal.PlainDate.from(dayString); + const store = useStore(); + const recordIds = useAtomComponentFamilySelectorValue( + calendarDayRecordIdsComponentFamilySelector, + { day, timeZone }, + ); + + const eventLayoutInputs = + calendarFieldType === FieldMetadataType.DATE + ? [] + : recordIds + .map((recordId) => { + const record = store.get( + recordStoreFamilyState.atomFamily(recordId), + ); + const recordDate = record?.[calendarFieldName]; + const recordEndDate = isDefined(calendarEndFieldName) + ? record?.[calendarEndFieldName] + : undefined; + const metrics = getRecordCalendarWeekTimedEventMetrics({ + day, + startDateTime: recordDate, + endDateTime: recordEndDate, + timeZone, + }); + + if (metrics === null) { + return null; + } + + return { + ...metrics, + recordId, + }; + }) + .filter(isDefined); + + const eventLayouts = + computeRecordCalendarWeekEventLayouts(eventLayoutInputs); + + const handleMouseMove = (event: MouseEvent) => { + if ( + event.target instanceof Element && + event.target.closest('[data-selectable-id]') !== null + ) { + onActiveSlotIndexChange(dayString, null, 'pointer'); + return; + } + + const columnRect = event.currentTarget.getBoundingClientRect(); + + onActiveSlotIndexChange( + dayString, + getRecordCalendarWeekSlotIndex({ + columnHeight: columnRect.height, + columnTop: columnRect.top, + pointerY: event.clientY, + }), + 'pointer', + ); + }; + + const handleFocus = (event: FocusEvent) => { + if ( + event.target === event.currentTarget && + event.currentTarget.matches(':focus-visible') + ) { + onActiveSlotIndexChange( + dayString, + activeSlotIndex ?? DEFAULT_KEYBOARD_SLOT_INDEX, + 'keyboard', + ); + } + }; + + const handleBlur = (event: FocusEvent) => { + if (!event.currentTarget.contains(event.relatedTarget)) { + onActiveSlotIndexChange(dayString, null, 'keyboard'); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.target !== event.currentTarget) { + return; + } + + const slotCount = + (RECORD_CALENDAR_WEEK_DIMENSIONS.hoursInDay * 60) / + RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes; + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + + const slotDelta = event.key === 'ArrowDown' ? 1 : -1; + + onActiveSlotIndexChange( + dayString, + Math.min( + slotCount - 1, + Math.max( + 0, + (activeSlotIndex ?? DEFAULT_KEYBOARD_SLOT_INDEX) + slotDelta, + ), + ), + 'keyboard', + ); + } + }; + + return ( + onActiveSlotIndexChange(dayString, null, 'pointer')} + onMouseMove={handleMouseMove} + tabIndex={0} + > + {isDefined(activeSlotIndex) && ( + + + + )} + {eventLayouts.map( + ({ + columnCount, + columnIndex, + endInPixels, + recordId, + startInPixels, + }) => ( + + ), + )} + + ); + }, +); + +export type RecordCalendarTimeGridDay = { + date: Temporal.PlainDate; + label: string; +}; + +type RecordCalendarTimeGridProps = { + days: readonly RecordCalendarTimeGridDay[]; + minWidthInPixels?: number; +}; + +export const RecordCalendarTimeGrid = ({ + days, + minWidthInPixels = 0, +}: RecordCalendarTimeGridProps) => { + const { objectMetadataItem } = useRecordCalendarContextOrThrow(); + const { timeFormat, timeZone } = useDateTimeFormat(); + const recordIndexCalendarFieldMetadataId = useAtomStateValue( + recordIndexCalendarFieldMetadataIdState, + ); + const recordIndexCalendarEndFieldMetadataId = useAtomStateValue( + recordIndexCalendarEndFieldMetadataIdState, + ); + const scrollAnchorRef = useRef(null); + const gridRef = useRef(null); + const [currentInstant, setCurrentInstant] = useState(() => + Temporal.Now.instant(), + ); + const [activeSlot, setActiveSlot] = + useState(null); + + const handleActiveSlotIndexChange = useCallback( + ( + day: string, + slotIndex: number | null, + interactionMode: RecordCalendarWeekSlotInteractionMode, + ) => { + setActiveSlot((currentActiveSlot) => + updateRecordCalendarWeekActiveSlot({ + currentActiveSlot, + day, + interactionMode, + slotIndex, + }), + ); + }, + [], + ); + + const clearActivePointerSlot = useCallback(() => { + setActiveSlot((currentActiveSlot) => + currentActiveSlot?.interactionMode === 'pointer' + ? null + : currentActiveSlot, + ); + }, []); + + useEffect(() => { + const handleDocumentMouseMove = (event: globalThis.MouseEvent) => { + const eventTarget = event.target; + + if ( + !(eventTarget instanceof Node) || + gridRef.current?.contains(eventTarget) !== true + ) { + clearActivePointerSlot(); + } + }; + + const clearActiveSlot = () => setActiveSlot(null); + + document.addEventListener('mousemove', handleDocumentMouseMove); + document.addEventListener('mouseleave', clearActivePointerSlot); + window.addEventListener('blur', clearActiveSlot); + + return () => { + document.removeEventListener('mousemove', handleDocumentMouseMove); + document.removeEventListener('mouseleave', clearActivePointerSlot); + window.removeEventListener('blur', clearActiveSlot); + }; + }, [clearActivePointerSlot]); + + const calendarFieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.id === recordIndexCalendarFieldMetadataId, + ); + const calendarEndFieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.id === recordIndexCalendarEndFieldMetadataId, + ); + + const now = currentInstant.toZonedDateTimeISO(timeZone); + const today = now.toPlainDate(); + const isCurrentPeriod = days.some(({ date }) => isSamePlainDate(date, today)); + const currentTimeTopInPixels = + (now.hour + now.minute / 60) * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight; + const initialScrollHour = isCurrentPeriod ? Math.max(now.hour - 2, 0) : 8; + const periodKey = days.map(({ date }) => date.toString()).join(','); + + useEffect(() => { + const intervalId = window.setInterval(() => { + setCurrentInstant(Temporal.Now.instant()); + }, CURRENT_TIME_REFRESH_INTERVAL_IN_MILLISECONDS); + + return () => window.clearInterval(intervalId); + }, []); + + const isAllDayView = + calendarFieldMetadataItem?.type === FieldMetadataType.DATE; + const isTimedView = + calendarFieldMetadataItem?.type === FieldMetadataType.DATE_TIME; + const compatibleCalendarEndFieldName = + isTimedView && + calendarEndFieldMetadataItem?.type === FieldMetadataType.DATE_TIME + ? calendarEndFieldMetadataItem.name + : undefined; + + useEffect(() => { + if (!isTimedView) { + return; + } + + scrollAnchorRef.current?.scrollIntoView({ + block: 'center', + inline: 'nearest', + }); + }, [isTimedView, periodKey]); + + if ( + !isDefined(calendarFieldMetadataItem) || + (!isAllDayView && !isTimedView) + ) { + return null; + } + + return ( + date)} + gridRef={gridRef} + > + + + + {isTimedView && } + + {days.map(({ date, label }) => { + const isToday = isSamePlainDate(date, today); + + return ( + + {label} + {date.day} + + ); + })} + {isAllDayView && ( + <> + {t`All day`} + {days.map(({ date }) => ( + + ))} + + )} + + {isTimedView && ( + + + {Array.from( + { length: RECORD_CALENDAR_WEEK_DIMENSIONS.hoursInDay }, + (_, hour) => ( + + {format(new Date(2000, 0, 1, hour), timeFormat)} + + ), + )} + + + {days.map(({ date }) => { + const dateString = date.toString(); + + return ( + + ); + })} + {isCurrentPeriod && ( + <> + + + {formatInTimeZone( + new Date(currentInstant.toString()), + timeZone, + timeFormat, + )} + + + )} + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGridAllDayCell.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGridAllDayCell.tsx new file mode 100644 index 0000000000..7728c12d43 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/RecordCalendarTimeGridAllDayCell.tsx @@ -0,0 +1,59 @@ +import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector'; +import { RecordCalendarWeekEvent } from '@/object-record/record-calendar/week/components/RecordCalendarWeekEvent'; +import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; +import { styled } from '@linaria/react'; +import { type Temporal } from 'temporal-polyfill'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +const StyledAllDayCell = styled.div` + border-left: 1px solid ${themeCssVariables.border.color.light}; + border-top: 1px solid ${themeCssVariables.border.color.light}; + display: flex; + flex-direction: column; + gap: ${themeCssVariables.spacing['0.5']}; + min-height: 28px; + min-width: 0; + padding: ${themeCssVariables.spacing['0.5']}; +`; + +type RecordCalendarTimeGridAllDayCellProps = { + calendarFieldName: string; + calendarFieldType: FieldMetadataType; + day: Temporal.PlainDate; + timeFormat: string; + timeZone: string; +}; + +export const RecordCalendarTimeGridAllDayCell = ({ + calendarFieldName, + calendarFieldType, + day, + timeFormat, + timeZone, +}: RecordCalendarTimeGridAllDayCellProps) => { + const recordIds = useAtomComponentFamilySelectorValue( + calendarDayRecordIdsComponentFamilySelector, + { day, timeZone }, + ); + + const allDayRecordIds = + calendarFieldType === FieldMetadataType.DATE ? recordIds : []; + + return ( + + {allDayRecordIds.map((recordId) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/__tests__/RecordCalendarTimeGridAllDayCell.test.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/__tests__/RecordCalendarTimeGridAllDayCell.test.tsx new file mode 100644 index 0000000000..82376c2e18 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-calendar/time-grid/components/__tests__/RecordCalendarTimeGridAllDayCell.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from '@testing-library/react'; +import { Temporal } from 'temporal-polyfill'; + +import { RecordCalendarTimeGridAllDayCell } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGridAllDayCell'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +const mockUseAtomComponentFamilySelectorValue = jest.fn(); + +jest.mock( + '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue', + () => ({ + useAtomComponentFamilySelectorValue: (...args: unknown[]) => + mockUseAtomComponentFamilySelectorValue(...args), + }), +); +jest.mock( + '@/object-record/record-calendar/week/components/RecordCalendarWeekEvent', + () => ({ + RecordCalendarWeekEvent: ({ + isAllDay, + recordId, + }: { + isAllDay: boolean; + recordId: string; + }) => ( +
+ ), + }), +); + +describe('RecordCalendarTimeGridAllDayCell', () => { + it('renders every all-day record in order', () => { + mockUseAtomComponentFamilySelectorValue.mockReturnValue([ + 'first', + 'second', + 'third', + 'fourth', + 'fifth', + ]); + + render( + , + ); + + expect( + screen.getAllByTestId('all-day-event').map((element) => ({ + isAllDay: element.dataset.isAllDay, + recordId: element.dataset.recordId, + })), + ).toEqual([ + { + isAllDay: 'true', + recordId: 'first', + }, + { + isAllDay: 'true', + recordId: 'second', + }, + { + isAllDay: 'true', + recordId: 'third', + }, + { + isAllDay: 'true', + recordId: 'fourth', + }, + { + isAllDay: 'true', + recordId: 'fifth', + }, + ]); + expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/utils/__tests__/getSupportedRecordCalendarLayout.test.ts b/packages/twenty-front/src/modules/object-record/record-calendar/utils/__tests__/getSupportedRecordCalendarLayout.test.ts index 36dcffbf4a..b6fcd05143 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/utils/__tests__/getSupportedRecordCalendarLayout.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/utils/__tests__/getSupportedRecordCalendarLayout.test.ts @@ -5,7 +5,7 @@ describe('getSupportedRecordCalendarLayout', () => { it.each([ [ViewCalendarLayout.WEEK, ViewCalendarLayout.WEEK], [ViewCalendarLayout.MONTH, ViewCalendarLayout.MONTH], - [ViewCalendarLayout.DAY, ViewCalendarLayout.MONTH], + [ViewCalendarLayout.DAY, ViewCalendarLayout.DAY], [null, ViewCalendarLayout.MONTH], [undefined, ViewCalendarLayout.MONTH], ])( diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/utils/getSupportedRecordCalendarLayout.ts b/packages/twenty-front/src/modules/object-record/record-calendar/utils/getSupportedRecordCalendarLayout.ts index b0b4d8fe84..2ccff378b8 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/utils/getSupportedRecordCalendarLayout.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/utils/getSupportedRecordCalendarLayout.ts @@ -8,7 +8,12 @@ type GetSupportedRecordCalendarLayoutArgs = { export const getSupportedRecordCalendarLayout = ({ calendarLayout, isCalendarWeekViewEnabled, -}: GetSupportedRecordCalendarLayoutArgs) => - isCalendarWeekViewEnabled && calendarLayout === ViewCalendarLayout.WEEK - ? ViewCalendarLayout.WEEK +}: GetSupportedRecordCalendarLayoutArgs) => { + const isTimeGridLayout = + calendarLayout === ViewCalendarLayout.DAY || + calendarLayout === ViewCalendarLayout.WEEK; + + return isCalendarWeekViewEnabled && isTimeGridLayout + ? calendarLayout : ViewCalendarLayout.MONTH; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeek.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeek.tsx index d941d51502..f5d6e78559 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeek.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeek.tsx @@ -1,483 +1,13 @@ -import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat'; -import { RecordCalendarAddNew } from '@/object-record/record-calendar/components/RecordCalendarAddNew'; -import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext'; import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext'; import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState'; -import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector'; -import { RecordCalendarWeekEvent } from '@/object-record/record-calendar/week/components/RecordCalendarWeekEvent'; -import { RecordCalendarWeekDragDropContext } from '@/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext'; -import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions'; +import { RecordCalendarTimeGrid } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid'; import { useRecordCalendarWeekDaysRange } from '@/object-record/record-calendar/week/hooks/useRecordCalendarWeekDaysRange'; -import { computeRecordCalendarWeekEventLayouts } from '@/object-record/record-calendar/week/utils/computeRecordCalendarWeekEventLayouts'; -import { getRecordCalendarWeekTimedEventMetrics } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekTimedEventMetrics'; -import { getRecordCalendarWeekSlotIndex } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekSlotIndex'; -import { - type RecordCalendarWeekActiveSlot, - type RecordCalendarWeekSlotInteractionMode, - updateRecordCalendarWeekActiveSlot, -} from '@/object-record/record-calendar/week/utils/updateRecordCalendarWeekActiveSlot'; -import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState'; -import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; -import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; -import { styled } from '@linaria/react'; -import { t } from '@lingui/core/macro'; -import { format } from 'date-fns'; -import { formatInTimeZone } from 'date-fns-tz'; -import { useStore } from 'jotai'; -import { - type FocusEvent, - type KeyboardEvent, - type MouseEvent, - memo, - useCallback, - useEffect, - useRef, - useState, -} from 'react'; -import { Temporal } from 'temporal-polyfill'; -import { - isDefined, - isPlainDateInWeekend, - isSamePlainDate, -} from 'twenty-shared/utils'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; -const CURRENT_TIME_REFRESH_INTERVAL_IN_MILLISECONDS = 60_000; -const DEFAULT_KEYBOARD_SLOT_INDEX = 18; - -const StyledContainer = styled.div` - background: ${themeCssVariables.background.primary}; - border: 1px solid ${themeCssVariables.border.color.light}; - border-radius: ${themeCssVariables.border.radius.sm}; - min-width: 1000px; - overflow: clip; -`; - -const StyledHeader = styled.div` - background: ${themeCssVariables.background.primary}; - border-bottom: 1px solid ${themeCssVariables.border.color.light}; - display: grid; - grid-template-columns: - ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px - repeat(7, minmax(120px, 1fr)); - position: sticky; - top: 0; - z-index: 3; -`; - -const StyledHeaderGutter = styled.div` - align-items: center; - color: ${themeCssVariables.font.color.light}; - display: flex; - font-size: ${themeCssVariables.font.size.xs}; - height: 32px; - justify-content: flex-end; - padding-right: ${themeCssVariables.spacing[1]}; -`; - -const StyledDayHeader = styled.div` - align-items: center; - border-left: 1px solid ${themeCssVariables.border.color.light}; - color: ${themeCssVariables.font.color.light}; - display: flex; - font-size: ${themeCssVariables.font.size.sm}; - gap: ${themeCssVariables.spacing[1]}; - height: 32px; - justify-content: center; -`; - -const StyledDayNumber = styled.span<{ isToday: boolean }>` - align-items: center; - background: ${({ isToday }) => - isToday ? themeCssVariables.color.blue : 'transparent'}; - border-radius: ${themeCssVariables.border.radius.sm}; - color: ${({ isToday }) => - isToday - ? themeCssVariables.font.color.inverted - : themeCssVariables.font.color.primary}; - display: flex; - font-size: ${themeCssVariables.font.size.sm}; - font-weight: ${({ isToday }) => - isToday - ? themeCssVariables.font.weight.medium - : themeCssVariables.font.weight.regular}; - height: 20px; - justify-content: center; - width: 20px; -`; - -const StyledAllDayLabel = styled(StyledHeaderGutter)` - border-top: 1px solid ${themeCssVariables.border.color.light}; - height: 28px; -`; - -const StyledAllDayCell = styled.div` - align-items: center; - border-left: 1px solid ${themeCssVariables.border.color.light}; - border-top: 1px solid ${themeCssVariables.border.color.light}; - display: flex; - gap: ${themeCssVariables.spacing['0.5']}; - height: 28px; - min-width: 0; - overflow: hidden; - padding: ${themeCssVariables.spacing['0.5']}; -`; - -const StyledAdditionalEventCount = styled.span` - color: ${themeCssVariables.font.color.light}; - font-size: ${themeCssVariables.font.size.xs}; - white-space: nowrap; -`; - -const StyledGrid = styled.div` - display: grid; - grid-template-columns: - ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px - repeat(7, minmax(120px, 1fr)); - height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px; - position: relative; -`; - -const StyledTimeGutter = styled.div` - height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px; - position: relative; -`; - -const StyledHourLabel = styled.span<{ topInPixels: number }>` - color: ${themeCssVariables.font.color.light}; - font-size: ${themeCssVariables.font.size.xs}; - position: absolute; - right: ${themeCssVariables.spacing[1]}; - top: ${({ topInPixels }) => `${topInPixels}px`}; - transform: translateY(-50%); - white-space: nowrap; -`; - -const StyledDayColumn = styled.div<{ isWeekend: boolean }>` - background-color: ${({ isWeekend }) => - isWeekend - ? themeCssVariables.background.secondary - : themeCssVariables.background.primary}; - background-image: repeating-linear-gradient( - to bottom, - ${themeCssVariables.border.color.light} 0, - ${themeCssVariables.border.color.light} 1px, - transparent 1px, - transparent ${RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight}px - ); - border-left: 1px solid ${themeCssVariables.border.color.light}; - height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px; - isolation: isolate; - overflow: hidden; - position: relative; -`; - -const StyledSlotAddNewPositioner = styled.div<{ topInPixels: number }>` - align-items: center; - display: flex; - height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.slotHeight}px; - left: ${themeCssVariables.spacing['0.5']}; - pointer-events: none; - position: absolute; - right: 0; - top: ${({ topInPixels }) => `${topInPixels}px`}; - z-index: 0; - - > div { - pointer-events: auto; - } -`; - -const StyledCurrentTimeLine = styled.div<{ topInPixels: number }>` - background: ${themeCssVariables.color.red8}; - height: 1px; - left: ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px; - pointer-events: none; - position: absolute; - right: 0; - top: ${({ topInPixels }) => `${topInPixels}px`}; - z-index: 2; - - &::after { - background: ${themeCssVariables.color.red8}; - border-radius: 50%; - content: ''; - height: 5px; - left: -2px; - position: absolute; - top: -2px; - width: 5px; - } -`; - -const StyledCurrentTimeLabel = styled.span<{ topInPixels: number }>` - background: ${themeCssVariables.background.primary}; - color: ${themeCssVariables.color.red9}; - font-size: ${themeCssVariables.font.size.xs}; - padding-right: ${themeCssVariables.spacing['0.5']}; - position: absolute; - right: calc(100% - ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth - 1}px); - top: ${({ topInPixels }) => `${topInPixels}px`}; - transform: translateY(-50%); - white-space: nowrap; - z-index: 3; -`; - -const StyledScrollAnchor = styled.div<{ topInPixels: number }>` - height: 1px; - left: 0; - pointer-events: none; - position: absolute; - top: ${({ topInPixels }) => `${topInPixels}px`}; - width: 1px; -`; - -type WeekDayCellProps = { - calendarEndFieldName?: string; - calendarFieldName: string; - calendarFieldType: FieldMetadataType; - day: Temporal.PlainDate; - timeFormat: string; - timeZone: string; -}; - -type RecordCalendarWeekAllDayCellProps = WeekDayCellProps; -type RecordCalendarWeekDayColumnProps = Omit & { - activeSlotIndex: number | null; - dayString: string; - onActiveSlotIndexChange: ( - day: string, - slotIndex: number | null, - interactionMode: RecordCalendarWeekSlotInteractionMode, - ) => void; -}; - -const RecordCalendarWeekAllDayCell = ({ - calendarFieldName, - calendarFieldType, - day, - timeFormat, - timeZone, -}: RecordCalendarWeekAllDayCellProps) => { - const recordIds = useAtomComponentFamilySelectorValue( - calendarDayRecordIdsComponentFamilySelector, - { day, timeZone }, - ); - - const allDayRecordIds = - calendarFieldType === FieldMetadataType.DATE ? recordIds : []; - - return ( - - {allDayRecordIds.slice(0, 1).map((recordId) => ( - - ))} - {allDayRecordIds.length > 1 && ( - - +{allDayRecordIds.length - 1} - - )} - - ); -}; - -const RecordCalendarWeekDayColumn = memo( - ({ - activeSlotIndex, - calendarEndFieldName, - calendarFieldName, - calendarFieldType, - dayString, - onActiveSlotIndexChange, - timeFormat, - timeZone, - }: RecordCalendarWeekDayColumnProps) => { - const day = Temporal.PlainDate.from(dayString); - const store = useStore(); - const recordIds = useAtomComponentFamilySelectorValue( - calendarDayRecordIdsComponentFamilySelector, - { day, timeZone }, - ); - - const eventLayoutInputs = - calendarFieldType === FieldMetadataType.DATE - ? [] - : recordIds - .map((recordId) => { - const record = store.get( - recordStoreFamilyState.atomFamily(recordId), - ); - const recordDate = record?.[calendarFieldName]; - const recordEndDate = isDefined(calendarEndFieldName) - ? record?.[calendarEndFieldName] - : undefined; - const metrics = getRecordCalendarWeekTimedEventMetrics({ - day, - startDateTime: recordDate, - endDateTime: recordEndDate, - timeZone, - }); - - if (metrics === null) { - return null; - } - - return { - ...metrics, - recordId, - }; - }) - .filter(isDefined); - - const eventLayouts = - computeRecordCalendarWeekEventLayouts(eventLayoutInputs); - - const handleMouseMove = (event: MouseEvent) => { - if ( - event.target instanceof Element && - event.target.closest('[data-selectable-id]') !== null - ) { - onActiveSlotIndexChange(dayString, null, 'pointer'); - return; - } - - const columnRect = event.currentTarget.getBoundingClientRect(); - - onActiveSlotIndexChange( - dayString, - getRecordCalendarWeekSlotIndex({ - columnHeight: columnRect.height, - columnTop: columnRect.top, - pointerY: event.clientY, - }), - 'pointer', - ); - }; - - const handleFocus = (event: FocusEvent) => { - if ( - event.target === event.currentTarget && - event.currentTarget.matches(':focus-visible') - ) { - onActiveSlotIndexChange( - dayString, - activeSlotIndex ?? DEFAULT_KEYBOARD_SLOT_INDEX, - 'keyboard', - ); - } - }; - - const handleBlur = (event: FocusEvent) => { - if (!event.currentTarget.contains(event.relatedTarget)) { - onActiveSlotIndexChange(dayString, null, 'keyboard'); - } - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.target !== event.currentTarget) { - return; - } - - const slotCount = - (RECORD_CALENDAR_WEEK_DIMENSIONS.hoursInDay * 60) / - RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes; - - if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { - event.preventDefault(); - - const slotDelta = event.key === 'ArrowDown' ? 1 : -1; - - onActiveSlotIndexChange( - dayString, - Math.min( - slotCount - 1, - Math.max( - 0, - (activeSlotIndex ?? DEFAULT_KEYBOARD_SLOT_INDEX) + slotDelta, - ), - ), - 'keyboard', - ); - } - }; - - return ( - onActiveSlotIndexChange(dayString, null, 'pointer')} - onMouseMove={handleMouseMove} - tabIndex={0} - > - {isDefined(activeSlotIndex) && ( - - - - )} - {eventLayouts.map( - ({ - columnCount, - columnIndex, - endInPixels, - recordId, - startInPixels, - }) => ( - - ), - )} - - ); - }, -); +const RECORD_CALENDAR_WEEK_MIN_WIDTH_IN_PIXELS = 1000; export const RecordCalendarWeek = () => { - const { objectMetadataItem } = useRecordCalendarContextOrThrow(); - const { timeFormat, timeZone } = useDateTimeFormat(); const recordCalendarId = useAvailableComponentInstanceIdOrThrow( RecordCalendarComponentInstanceContext, ); @@ -485,220 +15,14 @@ export const RecordCalendarWeek = () => { recordCalendarSelectedDateComponentState, recordCalendarId, ); - const recordIndexCalendarFieldMetadataId = useAtomStateValue( - recordIndexCalendarFieldMetadataIdState, + const { weekDays } = useRecordCalendarWeekDaysRange( + recordCalendarSelectedDate, ); - const recordIndexCalendarEndFieldMetadataId = useAtomStateValue( - recordIndexCalendarEndFieldMetadataIdState, - ); - const scrollAnchorRef = useRef(null); - const gridRef = useRef(null); - const [currentInstant, setCurrentInstant] = useState(() => - Temporal.Now.instant(), - ); - const [activeSlot, setActiveSlot] = - useState(null); - - const handleActiveSlotIndexChange = useCallback( - ( - day: string, - slotIndex: number | null, - interactionMode: RecordCalendarWeekSlotInteractionMode, - ) => { - setActiveSlot((currentActiveSlot) => - updateRecordCalendarWeekActiveSlot({ - currentActiveSlot, - day, - interactionMode, - slotIndex, - }), - ); - }, - [], - ); - - const clearActivePointerSlot = useCallback(() => { - setActiveSlot((currentActiveSlot) => - currentActiveSlot?.interactionMode === 'pointer' - ? null - : currentActiveSlot, - ); - }, []); - - useEffect(() => { - const handleDocumentMouseMove = (event: globalThis.MouseEvent) => { - const eventTarget = event.target; - - if ( - !(eventTarget instanceof Node) || - gridRef.current?.contains(eventTarget) !== true - ) { - clearActivePointerSlot(); - } - }; - - const clearActiveSlot = () => setActiveSlot(null); - - document.addEventListener('mousemove', handleDocumentMouseMove); - document.addEventListener('mouseleave', clearActivePointerSlot); - window.addEventListener('blur', clearActiveSlot); - - return () => { - document.removeEventListener('mousemove', handleDocumentMouseMove); - document.removeEventListener('mouseleave', clearActivePointerSlot); - window.removeEventListener('blur', clearActiveSlot); - }; - }, [clearActivePointerSlot]); - - const { firstDayOfWeek, lastDayOfWeek, weekDays } = - useRecordCalendarWeekDaysRange(recordCalendarSelectedDate); - - const calendarFieldMetadataItem = objectMetadataItem.fields.find( - (field) => field.id === recordIndexCalendarFieldMetadataId, - ); - const calendarEndFieldMetadataItem = objectMetadataItem.fields.find( - (field) => field.id === recordIndexCalendarEndFieldMetadataId, - ); - - const now = currentInstant.toZonedDateTimeISO(timeZone); - const today = now.toPlainDate(); - const isCurrentWeek = - Temporal.PlainDate.compare(today, firstDayOfWeek) >= 0 && - Temporal.PlainDate.compare(today, lastDayOfWeek) <= 0; - const currentTimeTopInPixels = - (now.hour + now.minute / 60) * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight; - const initialScrollHour = isCurrentWeek ? Math.max(now.hour - 2, 0) : 8; - const firstDayOfWeekString = firstDayOfWeek.toString(); - - useEffect(() => { - const intervalId = window.setInterval(() => { - setCurrentInstant(Temporal.Now.instant()); - }, CURRENT_TIME_REFRESH_INTERVAL_IN_MILLISECONDS); - - return () => window.clearInterval(intervalId); - }, []); - - const isAllDayView = - calendarFieldMetadataItem?.type === FieldMetadataType.DATE; - const isTimedView = - calendarFieldMetadataItem?.type === FieldMetadataType.DATE_TIME; - const compatibleCalendarEndFieldName = - isTimedView && - calendarEndFieldMetadataItem?.type === FieldMetadataType.DATE_TIME - ? calendarEndFieldMetadataItem.name - : undefined; - - useEffect(() => { - if (!isTimedView) { - return; - } - - scrollAnchorRef.current?.scrollIntoView({ - block: 'center', - inline: 'nearest', - }); - }, [firstDayOfWeekString, isTimedView]); - - if ( - !isDefined(calendarFieldMetadataItem) || - (!isAllDayView && !isTimedView) - ) { - return null; - } return ( - date)} - > - - - - {isTimedView && } - - {weekDays.map(({ date, label }) => { - const isToday = isSamePlainDate(date, today); - - return ( - - {label} - {date.day} - - ); - })} - {isAllDayView && ( - <> - {t`All day`} - {weekDays.map(({ date }) => ( - - ))} - - )} - - {isTimedView && ( - - - {Array.from( - { length: RECORD_CALENDAR_WEEK_DIMENSIONS.hoursInDay }, - (_, hour) => ( - - {format(new Date(2000, 0, 1, hour), timeFormat)} - - ), - )} - - - {weekDays.map(({ date }) => { - const dateString = date.toString(); - - return ( - - ); - })} - {isCurrentWeek && ( - <> - - - {formatInTimeZone( - new Date(currentInstant.toString()), - timeZone, - timeFormat, - )} - - - )} - - )} - - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx b/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx index 72499c5d71..44bd18c289 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx +++ b/packages/twenty-front/src/modules/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext.tsx @@ -32,14 +32,14 @@ type DragEndPayload = Parameters< type RecordCalendarWeekDragDropContextProps = { children: ReactNode; + days: Temporal.PlainDate[]; gridRef: RefObject; - weekDays: Temporal.PlainDate[]; }; export const RecordCalendarWeekDragDropContext = ({ children, + days, gridRef, - weekDays, }: RecordCalendarWeekDragDropContextProps) => { const [grabOffsetY, setGrabOffsetY] = useState(0); const { processRecordCalendarWeekEventDrop } = @@ -76,7 +76,7 @@ export const RecordCalendarWeekDragDropContext = ({ const gridRect = gridRef.current.getBoundingClientRect(); const resolvedDrop = resolveRecordCalendarWeekEventDrop({ - dayCount: weekDays.length, + dayCount: days.length, grabOffsetY, gridRect, pointerX: operation.position.current.x, @@ -87,7 +87,7 @@ export const RecordCalendarWeekDragDropContext = ({ return; } - const destinationDay = weekDays[resolvedDrop.dayIndex]; + const destinationDay = days[resolvedDrop.dayIndex]; if (!isDefined(destinationDay)) { return; diff --git a/packages/twenty-front/src/modules/object-record/record-calendar/week/utils/__tests__/resolveRecordCalendarWeekEventDrop.test.ts b/packages/twenty-front/src/modules/object-record/record-calendar/week/utils/__tests__/resolveRecordCalendarWeekEventDrop.test.ts index 3ef5c5832d..b662fd3b01 100644 --- a/packages/twenty-front/src/modules/object-record/record-calendar/week/utils/__tests__/resolveRecordCalendarWeekEventDrop.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-calendar/week/utils/__tests__/resolveRecordCalendarWeekEventDrop.test.ts @@ -28,6 +28,19 @@ describe('resolveRecordCalendarWeekEventDrop', () => { }); }); + it('resolves every valid horizontal position to the only visible day', () => { + expect( + resolveRecordCalendarWeekEventDrop({ + dayCount: 1, + grabOffsetY: 0, + gridRect, + pointerX: gridRect.left + gridRect.width - 1, + pointerY: + gridRect.top + 14.25 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight, + }), + ).toEqual({ dayIndex: 0, destinationMinutes: 14 * 60 }); + }); + it.each([ ['time gutter', gridRect.left + 20, gridRect.top + 100], ['left of grid', gridRect.left - 1, gridRect.top + 100], diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts index f278a993e7..847c4016d1 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/constants/public-feature-flag.const.ts @@ -15,9 +15,9 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [ { key: FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED, metadata: { - label: 'Calendar Week View', + label: 'Calendar Day and Week Views', description: - 'Display calendar records in a weekly layout with optional end dates', + 'Display calendar records in daily or weekly layouts with optional end dates', }, }, {