Add weekly layout to record calendar (#22819)

## Summary

- Add a week layout to record calendar views and persist the selected
layout.
- Render `DATE` calendars as an all-day week and `DATE_TIME` calendars
as an hourly week.
- Add an optional end date field across calendar configuration,
metadata, persistence, and complete-view upserts.
- Use configured end values for ranged and multi-day events, with a
one-hour fallback when a `DATE_TIME` end is absent or invalid.
- Keep calendar cards consistent with the existing compact view,
including checkbox selection and whole-card record opening.
- Gate the weekly layout and end-date behavior behind the public Labs
`IS_CALENDAR_WEEK_VIEW_ENABLED` workspace feature flag.

## Week interactions

- Show overlapping timed events side by side and cap the visible records
at two per day.
- Display start and end times on timed cards, enforce a readable
30-minute minimum height, and keep today’s text contrast stronger.
- Drag timed events between days and times with 30-minute snapping while
preserving their duration, including zero-duration events.
- Show a create button when hovering a 30-minute slot; keyboard users
can focus a day, move the slot with the arrow keys, and reach the same
contextual action.
- Initialize new records with the selected slot time and a compatible
writable end value one hour later.
- Show the workspace time zone and current-time indicator in timed
weeks; date-only weeks keep the all-day section without an hourly grid.

## Configuration and data loading

- Only allow end fields that match the start field type, and prevent
selecting the same field for both boundaries.
- Load records whose ranges overlap the visible period so month and week
layouts display the same relevant records.
- Resolve and persist calendar end fields when updating existing views
through `upsert_complete_view`.
- Fall back to Month and ignore the configured end field while the flag
is disabled, without overwriting either persisted setting, so
re-enabling restores the previous configuration.
- Expose the flag in Labs and keep it default-off for workspaces without
a stored value; enable it in the development seeder.

<img width="1285" height="808" alt="Screenshot 2026-07-15 at 15 50 17"
src="https://github.com/user-attachments/assets/b7e3f7f1-ca77-492f-8cce-cca186ebca0b"
/>
This commit is contained in:
Weiko
2026-07-15 16:30:18 +02:00
committed by GitHub
parent 0dbae2eda3
commit 25bd2897a3
158 changed files with 5446 additions and 214 deletions
@@ -0,0 +1,109 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useGetAvailableFieldsForCalendar';
import { getAvailableCalendarEndFieldMetadataItems } from '@/views/view-picker/utils/getAvailableCalendarEndFieldMetadataItems';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, useIcons } from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const ObjectOptionsDropdownCalendarEndFieldsContent = () => {
const { t } = useLingui();
const { getIcon } = useIcons();
const [searchInput, setSearchInput] = useState('');
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
const { resetContent, closeDropdown } = useObjectOptionsDropdown();
const { currentView } = useGetCurrentViewOnly();
const { updateCurrentView } = useUpdateCurrentView();
const { availableFieldsForCalendar } = useGetAvailableFieldsForCalendar();
const setRecordIndexCalendarEndFieldMetadataId = useSetAtomState(
recordIndexCalendarEndFieldMetadataIdState,
);
const availableCalendarEndFieldMetadataItems =
getAvailableCalendarEndFieldMetadataItems({
availableFieldsForCalendar,
calendarFieldMetadataId: currentView?.calendarFieldMetadataId,
});
const filteredCalendarEndFields =
availableCalendarEndFieldMetadataItems.filter((field) =>
field.label.toLowerCase().includes(searchInput.toLowerCase()),
);
const handleCalendarEndFieldChange = async (
fieldMetadataItem: FieldMetadataItem | null,
) => {
if (!isCalendarWeekViewEnabled) {
return;
}
const calendarEndFieldMetadataId = fieldMetadataItem?.id ?? null;
setRecordIndexCalendarEndFieldMetadataId(calendarEndFieldMetadataId);
await updateCurrentView({ calendarEndFieldMetadataId });
closeDropdown();
};
if (!isCalendarWeekViewEnabled) {
return null;
}
return (
<DropdownContent>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={() => resetContent()}
Icon={IconChevronLeft}
/>
}
>
{t`End date field`}
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
value={searchInput}
placeholder={t`Search fields`}
onChange={(event) => setSearchInput(event.target.value)}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
<MenuItemSelect
selected={!isDefined(currentView?.calendarEndFieldMetadataId)}
onClick={() => handleCalendarEndFieldChange(null)}
text={t`None`}
/>
{filteredCalendarEndFields.map((fieldMetadataItem) => (
<MenuItemSelect
key={fieldMetadataItem.id}
selected={
fieldMetadataItem.id === currentView?.calendarEndFieldMetadataId
}
onClick={() => handleCalendarEndFieldChange(fieldMetadataItem)}
LeftIcon={getIcon(fieldMetadataItem.icon)}
text={fieldMetadataItem.label}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
);
};
@@ -1,5 +1,6 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
@@ -13,6 +14,7 @@ import { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useG
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, IconSettings, useIcons } from 'twenty-ui/icon';
import { MenuItem, MenuItemSelect } from 'twenty-ui/navigation';
@@ -32,6 +34,9 @@ export const ObjectOptionsDropdownCalendarFieldsContent = () => {
const setRecordIndexCalendarFieldMetadataId = useSetAtomState(
recordIndexCalendarFieldMetadataIdState,
);
const setRecordIndexCalendarEndFieldMetadataId = useSetAtomState(
recordIndexCalendarEndFieldMetadataIdState,
);
const calendarFieldMetadata = currentView?.calendarFieldMetadataId
? objectMetadataItem.fields.find(
@@ -39,6 +44,12 @@ export const ObjectOptionsDropdownCalendarFieldsContent = () => {
)
: undefined;
const calendarEndFieldMetadata = currentView?.calendarEndFieldMetadataId
? objectMetadataItem.fields.find(
(field) => field.id === currentView.calendarEndFieldMetadataId,
)
: undefined;
const filteredCalendarFields = availableFieldsForCalendar.filter((field) =>
field.label.toLowerCase().includes(searchInput.toLowerCase()),
);
@@ -46,9 +57,22 @@ export const ObjectOptionsDropdownCalendarFieldsContent = () => {
const handleCalendarFieldChange = async (
fieldMetadataItem: FieldMetadataItem,
) => {
const shouldClearCalendarEndField =
isDefined(currentView?.calendarEndFieldMetadataId) &&
(!isDefined(calendarEndFieldMetadata) ||
calendarEndFieldMetadata.id === fieldMetadataItem.id ||
calendarEndFieldMetadata.type !== fieldMetadataItem.type);
setRecordIndexCalendarFieldMetadataId(fieldMetadataItem.id);
if (shouldClearCalendarEndField) {
setRecordIndexCalendarEndFieldMetadataId(null);
}
await updateCurrentView({
calendarFieldMetadataId: fieldMetadataItem.id,
...(shouldClearCalendarEndField
? { calendarEndFieldMetadataId: null }
: {}),
});
closeDropdown();
};
@@ -1,5 +1,6 @@
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
@@ -12,6 +13,7 @@ import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/use
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { Pill } from 'twenty-ui/data-display';
import {
@@ -21,13 +23,23 @@ import {
IconTimelineEvent,
} from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
import {
FeatureFlagKey,
ViewCalendarLayout,
} from '~/generated-metadata/graphql';
export const ObjectOptionsDropdownCalendarViewContent = () => {
const { resetContent } = useObjectOptionsDropdown();
const recordIndexCalendarLayout = useAtomStateValue(
recordIndexCalendarLayoutState,
);
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
const supportedCalendarLayout = getSupportedRecordCalendarLayout({
calendarLayout: recordIndexCalendarLayout,
isCalendarWeekViewEnabled,
});
const setRecordIndexCalendarLayout = useSetAtomState(
recordIndexCalendarLayoutState,
);
@@ -47,6 +59,18 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
];
const handleCalendarViewChange = async (calendarView: ViewCalendarLayout) => {
if (
calendarView === ViewCalendarLayout.WEEK &&
!isCalendarWeekViewEnabled
) {
return;
}
if (calendarView === supportedCalendarLayout) {
closeDropdown();
return;
}
setRecordIndexCalendarLayout(calendarView);
await updateCurrentView({
calendarLayout: calendarView,
@@ -75,17 +99,26 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
<SelectableListItem
itemId={ViewCalendarLayout.WEEK}
onEnter={() => {
handleCalendarViewChange(ViewCalendarLayout.WEEK);
if (isCalendarWeekViewEnabled) {
handleCalendarViewChange(ViewCalendarLayout.WEEK);
}
}}
>
<MenuItemSelect
LeftIcon={IconCalendarWeek}
text={t`Week`}
selected={recordIndexCalendarLayout === ViewCalendarLayout.WEEK}
selected={supportedCalendarLayout === ViewCalendarLayout.WEEK}
onClick={
isCalendarWeekViewEnabled
? () => handleCalendarViewChange(ViewCalendarLayout.WEEK)
: undefined
}
focused={selectedItemId === ViewCalendarLayout.WEEK}
contextualText={<Pill label={t`Soon`} />}
contextualText={
isCalendarWeekViewEnabled ? undefined : <Pill label={t`Soon`} />
}
contextualTextPosition="right"
disabled
disabled={!isCalendarWeekViewEnabled}
/>
</SelectableListItem>
<SelectableListItem
@@ -95,7 +128,7 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
<MenuItemSelect
LeftIcon={IconCalendarMonth}
text={t`Month`}
selected={recordIndexCalendarLayout === ViewCalendarLayout.MONTH}
selected={supportedCalendarLayout === ViewCalendarLayout.MONTH}
onClick={() => handleCalendarViewChange(ViewCalendarLayout.MONTH)}
focused={selectedItemId === ViewCalendarLayout.MONTH}
/>
@@ -1,5 +1,6 @@
import { ObjectOptionsDropdownAddRecordGroupContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownAddRecordGroupContent';
import { ObjectOptionsDropdownCalendarFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarFieldsContent';
import { ObjectOptionsDropdownCalendarEndFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarEndFieldsContent';
import { ObjectOptionsDropdownCalendarViewContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent';
import { ObjectOptionsDropdownFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownFieldsContent';
import { ObjectOptionsDropdownHiddenFieldsContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownHiddenFieldsContent';
@@ -12,9 +13,14 @@ import { ObjectOptionsDropdownRecordGroupsContent } from '@/object-record/object
import { ObjectOptionsDropdownRecordGroupSortContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownRecordGroupSortContent';
import { ObjectOptionsDropdownVisibilityContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownVisibilityContent';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const ObjectOptionsDropdownContent = () => {
const { currentContentId } = useObjectOptionsDropdown();
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
switch (currentContentId) {
case 'layout':
@@ -39,6 +45,12 @@ export const ObjectOptionsDropdownContent = () => {
return <ObjectOptionsDropdownCalendarViewContent />;
case 'calendarFields':
return <ObjectOptionsDropdownCalendarFieldsContent />;
case 'calendarEndFields':
return isCalendarWeekViewEnabled ? (
<ObjectOptionsDropdownCalendarEndFieldsContent />
) : (
<ObjectOptionsDropdownMenuContent />
);
case 'visibility':
return <ObjectOptionsDropdownVisibilityContent />;
default:
@@ -2,6 +2,7 @@ import { ObjectOptionsDropdownMenuViewName } from '@/object-record/object-option
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -22,6 +23,7 @@ import {
viewTypeIconMapping,
} from '@/views/types/ViewType';
import { useDestroyViewFromCurrentState } from '@/views/view-picker/hooks/useDestroyViewFromCurrentState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { viewPickerReferenceViewIdComponentState } from '@/views/view-picker/states/viewPickerReferenceViewIdComponentState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
@@ -37,7 +39,10 @@ import {
} from 'twenty-ui/icon';
import { AppTooltip } from 'twenty-ui/surfaces';
import { MenuItem } from 'twenty-ui/navigation';
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
import {
FeatureFlagKey,
ViewCalendarLayout,
} from '~/generated-metadata/graphql';
interface ObjectOptionsDropdownCustomViewProps {
onBackToDefault?: () => void;
@@ -70,6 +75,12 @@ export const ObjectOptionsDropdownCustomView = ({
)
: undefined;
const calendarEndFieldMetadata = currentView?.calendarEndFieldMetadataId
? objectMetadataItem.fields.find(
(field) => field.id === currentView.calendarEndFieldMetadataId,
)
: undefined;
const viewsOnCurrentObject = useAtomFamilySelectorValue(
viewsFromObjectMetadataItemFamilySelector,
{ objectMetadataItemId: objectMetadataItem.id },
@@ -81,6 +92,13 @@ export const ObjectOptionsDropdownCustomView = ({
const recordIndexCalendarLayout = useAtomStateValue(
recordIndexCalendarLayoutState,
);
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
const supportedCalendarLayout = getSupportedRecordCalendarLayout({
calendarLayout: recordIndexCalendarLayout,
isCalendarWeekViewEnabled,
});
const { visibleBoardFields } = useObjectOptionsForBoard({
objectNameSingular: objectMetadataItem.nameSingular,
@@ -111,7 +129,11 @@ export const ObjectOptionsDropdownCustomView = ({
'Visibility',
'Fields',
...(customViewData?.type === ViewType.CALENDAR
? ['CalendarDateField', 'CalendarView']
? [
'CalendarDateField',
...(isCalendarWeekViewEnabled ? ['CalendarEndDateField'] : []),
'CalendarView',
]
: []),
...(customViewData?.type !== ViewType.CALENDAR ? ['Group'] : []),
'Delete view',
@@ -196,6 +218,29 @@ export const ObjectOptionsDropdownCustomView = ({
/>
</SelectableListItem>
</div>
{isCalendarWeekViewEnabled && (
<div id="calendar-end-date-field-picker-menu-item">
<SelectableListItem
itemId="CalendarEndDateField"
onEnter={() => onContentChange('calendarEndFields')}
>
<MenuItem
focused={selectedItemId === 'CalendarEndDateField'}
onClick={() => onContentChange('calendarEndFields')}
LeftIcon={IconCalendar}
text={t`End date field`}
contextualText={
isDefaultView
? t`Not available on Default View`
: (calendarEndFieldMetadata?.label ?? t`None`)
}
contextualTextPosition="right"
hasSubMenu
disabled={isDefaultView}
/>
</SelectableListItem>
</div>
)}
<SelectableListItem
itemId="CalendarView"
onEnter={() => onContentChange('calendarView')}
@@ -206,9 +251,9 @@ export const ObjectOptionsDropdownCustomView = ({
LeftIcon={IconCalendarWeek}
text={t`Calendar view`}
contextualText={
recordIndexCalendarLayout === ViewCalendarLayout.MONTH
supportedCalendarLayout === ViewCalendarLayout.MONTH
? t`Month`
: recordIndexCalendarLayout === ViewCalendarLayout.WEEK
: supportedCalendarLayout === ViewCalendarLayout.WEEK
? t`Week`
: t`Day`
}
@@ -1,6 +1,7 @@
import { OBJECT_OPTIONS_DROPDOWN_ID } from '@/object-record/object-options-dropdown/constants/ObjectOptionsDropdownId';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { useSetViewTypeFromLayoutOptionsMenu } from '@/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu';
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
import { recordIndexOpenRecordInState } from '@/object-record/record-index/states/recordIndexOpenRecordInState';
@@ -24,6 +25,7 @@ import {
} from '@/views/types/ViewType';
import { useGetAvailableFieldsForCalendar } from '@/views/view-picker/hooks/useGetAvailableFieldsForCalendar';
import { useGetAvailableFieldsToGroupRecordsBy } from '@/views/view-picker/hooks/useGetAvailableFieldsToGroupRecordsBy';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useLingui } from '@lingui/react/macro';
import { useCallback } from 'react';
@@ -41,6 +43,7 @@ import {
import { OverflowingTextWithTooltip } from 'twenty-ui/surfaces';
import { MenuItem, MenuItemSelect, MenuItemToggle } from 'twenty-ui/navigation';
import {
FeatureFlagKey,
ViewCalendarLayout,
ViewOpenRecordIn,
} from '~/generated-metadata/graphql';
@@ -72,6 +75,13 @@ export const ObjectOptionsDropdownLayoutContent = () => {
const recordIndexCalendarLayout = useAtomStateValue(
recordIndexCalendarLayoutState,
);
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
const supportedCalendarLayout = getSupportedRecordCalendarLayout({
calendarLayout: recordIndexCalendarLayout,
isCalendarWeekViewEnabled,
});
const recordIndexGroupFieldMetadataItem = useAtomComponentStateValue(
recordIndexGroupFieldMetadataItemComponentState,
);
@@ -82,6 +92,12 @@ export const ObjectOptionsDropdownLayoutContent = () => {
)
: undefined;
const calendarEndFieldMetadata = currentView?.calendarEndFieldMetadataId
? objectMetadataItem.fields.find(
(field) => field.id === currentView.calendarEndFieldMetadataId,
)
: undefined;
const { setAndPersistViewType } = useSetViewTypeFromLayoutOptionsMenu();
const { availableFieldsForGrouping, navigateToSelectSettings } =
useGetAvailableFieldsToGroupRecordsBy();
@@ -127,7 +143,11 @@ export const ObjectOptionsDropdownLayoutContent = () => {
ViewOpenRecordIn.SIDE_PANEL,
...(currentView?.type === ViewType.KANBAN ? ['Group'] : []),
...(currentView?.type === ViewType.CALENDAR
? ['CalendarView', 'CalendarDateField']
? [
'CalendarView',
'CalendarDateField',
...(isCalendarWeekViewEnabled ? ['CalendarEndDateField'] : []),
]
: []),
...(currentView?.type !== ViewType.TABLE ? ['Compact view'] : []),
];
@@ -236,6 +256,24 @@ export const ObjectOptionsDropdownLayoutContent = () => {
hasSubMenu
/>
</SelectableListItem>
{isCalendarWeekViewEnabled && (
<SelectableListItem
itemId="CalendarEndDateField"
onEnter={() => onContentChange('calendarEndFields')}
>
<MenuItem
focused={selectedItemId === 'CalendarEndDateField'}
onClick={() => onContentChange('calendarEndFields')}
LeftIcon={IconCalendar}
text={t`End date field`}
contextualText={
calendarEndFieldMetadata?.label ?? t`None`
}
contextualTextPosition="right"
hasSubMenu
/>
</SelectableListItem>
)}
<SelectableListItem
itemId="CalendarView"
onEnter={() => onContentChange('calendarView')}
@@ -246,9 +284,9 @@ export const ObjectOptionsDropdownLayoutContent = () => {
LeftIcon={IconCalendarWeek}
text={t`Calendar view`}
contextualText={
recordIndexCalendarLayout === ViewCalendarLayout.MONTH
supportedCalendarLayout === ViewCalendarLayout.MONTH
? t`Month`
: recordIndexCalendarLayout === ViewCalendarLayout.WEEK
: supportedCalendarLayout === ViewCalendarLayout.WEEK
? t`Week`
: t`Day`
}
@@ -97,6 +97,7 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
...currentView,
type: viewType,
calendarFieldMetadataId,
calendarEndFieldMetadataId: null,
calendarLayout: ViewCalendarLayout.MONTH,
},
objectMetadataItem,
@@ -109,6 +110,7 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => {
updateCurrentViewParams.calendarLayout = ViewCalendarLayout.MONTH;
updateCurrentViewParams.calendarFieldMetadataId =
calendarFieldMetadataId;
updateCurrentViewParams.calendarEndFieldMetadataId = null;
updateCurrentViewParams.mainGroupByFieldMetadataId = null;
return await updateCurrentView(updateCurrentViewParams);
}
@@ -9,5 +9,6 @@ export type ObjectOptionsContentId =
| 'recordGroupSort'
| 'addRecordGroup'
| 'calendarFields'
| 'calendarEndFields'
| 'calendarView'
| 'visibility';
@@ -5,16 +5,26 @@ import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandM
import { RecordCalendarTopBar } from '@/object-record/record-calendar/components/RecordCalendarTopBar';
import { RECORD_CALENDAR_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-calendar/constants/RecordCalendarClickOutsideListenerId';
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';
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { useRecordCalendarSelection } from '@/object-record/record-calendar/states/selectors/useRecordCalendarSelection';
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useEffect } from 'react';
import { LINK_CHIP_CLICK_OUTSIDE_ID } from 'twenty-ui/data-display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
FeatureFlagKey,
ViewCalendarLayout,
} from '~/generated-metadata/graphql';
const StyledContainerContainer = styled.div`
box-sizing: border-box;
@@ -33,6 +43,21 @@ export const RecordCalendar = () => {
const { resetRecordSelection } = useRecordCalendarSelection(recordCalendarId);
const recordIndexCalendarLayout = useAtomStateValue(
recordIndexCalendarLayoutState,
);
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
const supportedCalendarLayout = getSupportedRecordCalendarLayout({
calendarLayout: recordIndexCalendarLayout,
isCalendarWeekViewEnabled,
});
useEffect(() => {
resetRecordSelection();
}, [resetRecordSelection, supportedCalendarLayout]);
useListenClickOutside({
excludedClickOutsideIds: [
COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID,
@@ -55,7 +80,11 @@ export const RecordCalendar = () => {
<ScrollWrapper
componentInstanceId={`scroll-wrapper-record-calendar-${recordCalendarId}`}
>
<RecordCalendarMonth />
{supportedCalendarLayout === ViewCalendarLayout.WEEK ? (
<RecordCalendarWeek />
) : (
<RecordCalendarMonth />
)}
</ScrollWrapper>
</StyledContainerContainer>
);
@@ -2,6 +2,7 @@ import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPe
import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView';
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord';
import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem';
@@ -9,24 +10,30 @@ import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUs
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { type Temporal } from 'temporal-polyfill';
import { FieldMetadataType } from 'twenty-shared/types';
import { IconPlus } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledButtonContainer = styled.div`
const StyledButtonContainer = styled.div<{ compact: boolean }>`
height: auto;
min-width: unset;
padding: ${themeCssVariables.spacing['0.5']};
padding: ${({ compact }) => (compact ? 0 : themeCssVariables.spacing['0.5'])};
`;
type RecordCalendarAddNewProps = {
cardDate: Temporal.PlainDate;
cardTime?: Temporal.PlainTime;
compact?: boolean;
};
export const RecordCalendarAddNew = ({
cardDate,
cardTime,
compact = false,
}: RecordCalendarAddNewProps) => {
const { theme } = useContext(ThemeContext);
const { userTimezone } = useUserTimezone();
@@ -46,18 +53,33 @@ export const RecordCalendarAddNew = ({
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
recordIndexCalendarFieldMetadataIdState,
);
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
recordIndexCalendarEndFieldMetadataIdState,
);
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === recordIndexCalendarFieldMetadataId,
);
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === recordIndexCalendarEndFieldMetadataId,
);
const isCalendarFieldReadOnly = calendarFieldMetadataItem
? isFieldMetadataReadOnlyByPermissions({
? calendarFieldMetadataItem.isUIEditable === false ||
isFieldMetadataReadOnlyByPermissions({
objectPermissions,
fieldMetadataId: calendarFieldMetadataItem.id,
})
: false;
const isCalendarEndFieldReadOnly = calendarEndFieldMetadataItem
? calendarEndFieldMetadataItem.isUIEditable === false ||
isFieldMetadataReadOnlyByPermissions({
objectPermissions,
fieldMetadataId: calendarEndFieldMetadataItem.id,
})
: false;
if (
hasAnySoftDeleteFilterOnView === true ||
!canCreateRecordsForObjectMetadataItem({
@@ -70,17 +92,49 @@ export const RecordCalendarAddNew = ({
return null;
}
const createRecordAriaLabel = cardTime
? t`Create record on ${cardDate.toLocaleString(undefined, {
dateStyle: 'full',
})} at ${cardTime.toLocaleString(undefined, { timeStyle: 'short' })}`
: t`Create record`;
return (
<StyledButtonContainer>
<StyledButtonContainer compact={compact}>
<Button
onClick={async () => {
ariaLabel={createRecordAriaLabel}
onClick={async (event) => {
event.stopPropagation();
const startDateTime = cardDate.toZonedDateTime({
timeZone: userTimezone,
plainTime: cardTime,
});
const startValue =
calendarFieldMetadataItem.type === FieldMetadataType.DATE
? cardDate.toString()
: startDateTime.toInstant().toString();
await createNewIndexRecord({
[calendarFieldMetadataItem.name]: cardDate
.toZonedDateTime(userTimezone)
.toInstant()
.toString(),
[calendarFieldMetadataItem.name]: startValue,
...(calendarFieldMetadataItem.type === FieldMetadataType.DATE &&
isCalendarEndFieldReadOnly === false &&
calendarEndFieldMetadataItem?.type === FieldMetadataType.DATE && {
[calendarEndFieldMetadataItem.name]: cardDate.toString(),
}),
...(calendarFieldMetadataItem.type ===
FieldMetadataType.DATE_TIME &&
isCalendarEndFieldReadOnly === false &&
calendarEndFieldMetadataItem?.type ===
FieldMetadataType.DATE_TIME && {
[calendarEndFieldMetadataItem.name]: startDateTime
.add({ hours: 1 })
.toInstant()
.toString(),
}),
});
}}
size={compact ? 'small' : 'medium'}
type="button"
variant="tertiary"
Icon={() => <IconPlus size={theme.icon.size.sm} />}
/>
@@ -1,7 +1,12 @@
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';
import { useRecordCalendarWeekDaysRange } from '@/object-record/record-calendar/week/hooks/useRecordCalendarWeekDaysRange';
import { formatRecordCalendarWeekRange } from '@/object-record/record-calendar/week/utils/formatRecordCalendarWeekRange';
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
import { DatePickerWithoutCalendar } from '@/ui/input/components/internal/date/components/DatePickerWithoutCalendar';
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
import { Select } from '@/ui/input/components/Select';
import { SelectControl } from '@/ui/input/components/SelectControl';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
@@ -9,6 +14,10 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { type DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useUpdateCurrentView } from '@/views/hooks/useUpdateCurrentView';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { format } from 'date-fns';
@@ -21,6 +30,11 @@ import {
import { IconChevronLeft, IconChevronRight } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import {
FeatureFlagKey,
ViewCalendarLayout,
} from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
const StyledContainer = styled.div`
align-items: center;
@@ -55,6 +69,23 @@ export const RecordCalendarTopBar = () => {
const [recordCalendarSelectedDate, setRecordCalendarSelectedDate] =
useAtomComponentState(recordCalendarSelectedDateComponentState);
const [recordIndexCalendarLayout, setRecordIndexCalendarLayout] =
useAtomState(recordIndexCalendarLayoutState);
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
const supportedCalendarLayout = getSupportedRecordCalendarLayout({
calendarLayout: recordIndexCalendarLayout,
isCalendarWeekViewEnabled,
});
const dateLocale = useAtomStateValue(dateLocaleState);
const { firstDayOfWeek, lastDayOfWeek } = useRecordCalendarWeekDaysRange(
recordCalendarSelectedDate,
);
const { updateCurrentView } = useUpdateCurrentView();
const datePickerDropdownId = `record-calendar-date-picker-${recordCalendarId}`;
const { closeDropdown } = useCloseDropdown();
@@ -65,32 +96,71 @@ export const RecordCalendarTopBar = () => {
closeDropdown(datePickerDropdownId);
};
const handlePreviousMonth = () => {
const handlePreviousPeriod = () => {
setRecordCalendarSelectedDate(
recordCalendarSelectedDate.subtract({ months: 1 }),
supportedCalendarLayout === ViewCalendarLayout.WEEK
? recordCalendarSelectedDate.subtract({ weeks: 1 })
: recordCalendarSelectedDate.subtract({ months: 1 }),
);
};
const handleNextMonth = () => {
const handleNextPeriod = () => {
setRecordCalendarSelectedDate(
recordCalendarSelectedDate?.add({ months: 1 }),
supportedCalendarLayout === ViewCalendarLayout.WEEK
? recordCalendarSelectedDate.add({ weeks: 1 })
: recordCalendarSelectedDate.add({ months: 1 }),
);
};
const handleCalendarLayoutChange = (calendarLayout: ViewCalendarLayout) => {
if (
calendarLayout === ViewCalendarLayout.WEEK &&
!isCalendarWeekViewEnabled
) {
return;
}
setRecordIndexCalendarLayout(calendarLayout);
void updateCurrentView({ calendarLayout });
};
const handleTodayClick = () => {
setRecordCalendarSelectedDate(Temporal.Now.plainDateISO());
};
const formattedDate = format(
turnPlainDateToShiftedDateInSystemTimeZone(recordCalendarSelectedDate),
'MMMM yyyy',
);
const formattedDate =
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;
return (
<StyledContainer>
<StyledLeftSection>
{isCalendarWeekViewEnabled && (
<Select
dropdownId={`record-calendar-layout-${recordCalendarId}`}
value={supportedCalendarLayout}
options={[
{ label: t`Week`, value: ViewCalendarLayout.WEEK },
{ label: t`Month`, value: ViewCalendarLayout.MONTH },
]}
selectSizeVariant="small"
dropdownWidth={120}
onChange={handleCalendarLayoutChange}
/>
)}
<Dropdown
dropdownId={datePickerDropdownId}
clickableComponent={
@@ -116,7 +186,9 @@ export const RecordCalendarTopBar = () => {
}
dropdownOffset={dropdownContentOffset}
/>
<TimeZoneAbbreviation instant={Temporal.Now.instant()} />
{supportedCalendarLayout !== ViewCalendarLayout.WEEK && (
<TimeZoneAbbreviation instant={Temporal.Now.instant()} />
)}
</StyledLeftSection>
<StyledNavigationSection>
@@ -125,7 +197,7 @@ export const RecordCalendarTopBar = () => {
size="small"
variant="tertiary"
Icon={IconChevronLeft}
onClick={handlePreviousMonth}
onClick={handlePreviousPeriod}
/>
</StyledNavigationButtonContainer>
<Button
@@ -139,7 +211,7 @@ export const RecordCalendarTopBar = () => {
size="small"
variant="tertiary"
Icon={IconChevronRight}
onClick={handleNextMonth}
onClick={handleNextPeriod}
/>
</StyledNavigationButtonContainer>
</StyledNavigationSection>
@@ -0,0 +1,87 @@
import { render, screen } from '@testing-library/react';
import { RecordCalendar } from '@/object-record/record-calendar/components/RecordCalendar';
import {
FeatureFlagKey,
ViewCalendarLayout,
} from '~/generated-metadata/graphql';
jest.mock(
'@/object-record/record-calendar/components/RecordCalendarTopBar',
() => ({
RecordCalendarTopBar: () => <div data-testid="calendar-top-bar" />,
}),
);
jest.mock(
'@/object-record/record-calendar/month/components/RecordCalendarMonth',
() => ({
RecordCalendarMonth: () => <div data-testid="calendar-month" />,
}),
);
jest.mock(
'@/object-record/record-calendar/week/components/RecordCalendarWeek',
() => ({
RecordCalendarWeek: () => <div data-testid="calendar-week" />,
}),
);
jest.mock('@/ui/utilities/scroll/components/ScrollWrapper', () => ({
ScrollWrapper: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock(
'@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow',
() => ({
useAvailableComponentInstanceIdOrThrow: jest.fn(() => 'calendar-id'),
}),
);
jest.mock(
'@/object-record/record-calendar/states/selectors/useRecordCalendarSelection',
() => ({
useRecordCalendarSelection: jest.fn(() => ({
resetRecordSelection: jest.fn(),
})),
}),
);
jest.mock('@/ui/utilities/pointer-event/hooks/useListenClickOutside', () => ({
useListenClickOutside: jest.fn(),
}));
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
useAtomStateValue: jest.fn(),
}));
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: jest.fn(),
}));
const useAtomStateValueMock = jest.requireMock(
'@/ui/utilities/state/jotai/hooks/useAtomStateValue',
).useAtomStateValue;
const useIsFeatureEnabledMock = jest.requireMock(
'@/workspace/hooks/useIsFeatureEnabled',
).useIsFeatureEnabled;
describe('RecordCalendar', () => {
beforeEach(() => {
jest.clearAllMocks();
useAtomStateValueMock.mockReturnValue(ViewCalendarLayout.WEEK);
});
it('renders month when a persisted week layout is disabled', () => {
useIsFeatureEnabledMock.mockReturnValue(false);
render(<RecordCalendar />);
expect(screen.getByTestId('calendar-month')).toBeInTheDocument();
expect(screen.queryByTestId('calendar-week')).not.toBeInTheDocument();
expect(useIsFeatureEnabledMock).toHaveBeenCalledWith(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
});
it('renders week when the week layout is enabled', () => {
useIsFeatureEnabledMock.mockReturnValue(true);
render(<RecordCalendar />);
expect(screen.getByTestId('calendar-week')).toBeInTheDocument();
expect(screen.queryByTestId('calendar-month')).not.toBeInTheDocument();
});
});
@@ -0,0 +1 @@
export const RECORD_CALENDAR_MONTH_VISIBLE_RECORD_LIMIT = 5;
@@ -5,6 +5,7 @@ import { useGroupByRecordsQuery } from '@/object-record/hooks/useGroupByRecordsQ
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { useRecordCalendarQueryDateRangeFilter } from '@/object-record/record-calendar/month/hooks/useRecordCalendarQueryDateRangeFilter';
import { useRelevantRecordsGqlFields } from '@/object-record/record-field/hooks/useRelevantRecordsGqlFields';
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject';
@@ -29,10 +30,16 @@ export const useRecordCalendarGroupByRecords = (
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
recordIndexCalendarFieldMetadataIdState,
);
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
recordIndexCalendarEndFieldMetadataIdState,
);
const recordGqlFields = useRelevantRecordsGqlFields({
objectMetadataItem,
additionalFieldMetadataId: recordIndexCalendarFieldMetadataId,
additionalFieldMetadataIds: [
recordIndexCalendarFieldMetadataId,
recordIndexCalendarEndFieldMetadataId,
],
});
const { dateRangeFilter } =
@@ -3,6 +3,7 @@ import { RecordCalendarMonthHeader } from '@/object-record/record-calendar/month
import { RecordCalendarMonthContextProvider } from '@/object-record/record-calendar/month/contexts/RecordCalendarMonthContext';
import { useRecordCalendarMonthDaysRange } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange';
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
import { getRecordIdFromRecordCalendarCardDraggableId } from '@/object-record/record-calendar/record-calendar-card/utils/getRecordCalendarCardDraggableId';
import { useEndRecordDrag } from '@/object-record/record-drag/hooks/useEndRecordDrag';
import { useProcessCalendarCardDrop } from '@/object-record/record-drag/hooks/useProcessCalendarCardDrop';
import { useStartRecordDrag } from '@/object-record/record-drag/hooks/useStartRecordDrag';
@@ -46,7 +47,15 @@ export const RecordCalendarMonth = () => {
} = useRecordCalendarMonthDaysRange(recordCalendarSelectedDate);
const handleDragStart = (start: DragStart) => {
startRecordDrag(start, []);
startRecordDrag(
{
...start,
draggableId: getRecordIdFromRecordCalendarCardDraggableId(
start.draggableId,
),
},
[],
);
};
const handleDragEnd: OnDragEndResponder = (result) => {
@@ -1,4 +1,5 @@
import { RecordCalendarCardDraggableContainer } from '@/object-record/record-calendar/record-calendar-card/components/RecordCalendarCardDraggableContainer';
import { RECORD_CALENDAR_MONTH_VISIBLE_RECORD_LIMIT } from '@/object-record/record-calendar/constants/RecordCalendarMonthVisibleRecordLimit';
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
@@ -151,13 +152,16 @@ export const RecordCalendarMonthBodyDay = ({
ref={droppableProvided.innerRef}
isDraggedOver={droppableSnapshot.isDraggingOver}
>
{recordIds.slice(0, 5).map((recordId, index) => (
<RecordCalendarCardDraggableContainer
key={recordId}
recordId={recordId}
index={index}
/>
))}
{recordIds
.slice(0, RECORD_CALENDAR_MONTH_VISIBLE_RECORD_LIMIT)
.map((recordId, index) => (
<RecordCalendarCardDraggableContainer
key={`${recordId}-${dayKey}`}
calendarDay={dayKey}
recordId={recordId}
index={index}
/>
))}
{droppableProvided.placeholder}
</StyledCardsContainer>
)}
@@ -26,14 +26,12 @@ export const useRecordCalendarMonthDaysRange = (
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const dateLocale = useAtomStateValue(dateLocaleState);
if (!currentWorkspaceMember) {
throw new Error('Current workspace member not found');
}
const calendarStartDay =
currentWorkspaceMember?.calendarStartDay ?? CalendarStartDay.SYSTEM;
const weekStartsOnDayIndex = (
currentWorkspaceMember?.calendarStartDay === CalendarStartDay.SYSTEM
calendarStartDay === CalendarStartDay.SYSTEM
? CalendarStartDay[detectCalendarStartDay()]
: (currentWorkspaceMember?.calendarStartDay ?? 0)
: calendarStartDay
) as 0 | 1 | 2 | 3 | 4 | 5 | 6;
const firstDayOfMonth = selectedDate.with({ day: 1 });
@@ -1,6 +1,8 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { useRecordCalendarMonthDaysRange } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange';
import { getRecordCalendarDateRangeOverlapFilter } from '@/object-record/record-calendar/month/utils/getRecordCalendarDateRangeOverlapFilter';
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
@@ -20,6 +22,7 @@ import {
turnAnyFieldFilterIntoRecordGqlFilter,
turnPlainDateIntoUserTimeZoneInstantString,
} from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const DATE_RANGE_FILTER_AFTER_ID = 'DATE_RANGE_FILTER_AFTER_ID';
const DATE_RANGE_FILTER_BEFORE_ID = 'DATE_RANGE_FILTER_BEFORE_ID';
@@ -51,6 +54,9 @@ export const useRecordCalendarQueryDateRangeFilter = (
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
recordIndexCalendarEndFieldMetadataIdState,
);
const anyFieldFilterValue = useAtomComponentStateValue(
anyFieldFilterValueComponentState,
@@ -80,6 +86,31 @@ export const useRecordCalendarQueryDateRangeFilter = (
const dateRangeFilterFieldMetadataId = currentView.calendarFieldMetadataId;
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === currentView.calendarFieldMetadataId,
);
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === recordIndexCalendarEndFieldMetadataId,
);
const dateRangeOverlapFilter = isDefined(calendarFieldMetadataItem)
? getRecordCalendarDateRangeOverlapFilter({
calendarField: calendarFieldMetadataItem,
calendarEndField: calendarEndFieldMetadataItem,
firstDayOfRange:
calendarFieldMetadataItem.type === FieldMetadataType.DATE_TIME
? firstDayOfFirstWeekISOString
: firstDayOfFirstWeek.toString(),
nextDayAfterLastDayOfRange:
calendarFieldMetadataItem.type === FieldMetadataType.DATE_TIME
? nextDayAfterLastDayOfLastWeekISOString
: lastDayOfLastWeek.add({ days: 1 }).toString(),
})
: undefined;
const dateRangeFilterAfter: RecordFilter = {
id: DATE_RANGE_FILTER_AFTER_ID,
fieldMetadataId: dateRangeFilterFieldMetadataId,
@@ -100,11 +131,9 @@ export const useRecordCalendarQueryDateRangeFilter = (
displayValue: `${lastDayOfLastWeek.toString()}`,
};
const calendarRecordFilters = [
...currentRecordFilters,
dateRangeFilterAfter,
dateRangeFilterBefore,
];
const calendarRecordFilters = isDefined(dateRangeOverlapFilter)
? currentRecordFilters
: [...currentRecordFilters, dateRangeFilterAfter, dateRangeFilterBefore];
const dateRangeFilter = computeRecordGqlOperationFilter({
filterValueDependencies,
@@ -119,7 +148,11 @@ export const useRecordCalendarQueryDateRangeFilter = (
filterValue: anyFieldFilterValue,
});
const combinedFilter = combineFilters([dateRangeFilter, anyFieldFilter]);
const combinedFilter = combineFilters([
dateRangeFilter,
dateRangeOverlapFilter ?? {},
anyFieldFilter,
]);
return {
dateRangeFilter: combinedFilter,
@@ -0,0 +1,113 @@
import { getRecordCalendarDateRangeOverlapFilter } from '@/object-record/record-calendar/month/utils/getRecordCalendarDateRangeOverlapFilter';
import { FieldMetadataType } from 'twenty-shared/types';
describe('getRecordCalendarDateRangeOverlapFilter', () => {
it('matches DATE ranges that overlap the visible range', () => {
expect(
getRecordCalendarDateRangeOverlapFilter({
calendarField: {
name: 'startDate',
type: FieldMetadataType.DATE,
},
calendarEndField: {
name: 'endDate',
type: FieldMetadataType.DATE,
},
firstDayOfRange: '2026-06-29',
nextDayAfterLastDayOfRange: '2026-08-10',
}),
).toEqual({
and: [
{
startDate: {
lt: '2026-08-10',
},
},
{
or: [
{
startDate: {
gte: '2026-06-29',
},
},
{
endDate: {
gte: '2026-06-29',
},
},
],
},
],
});
});
it('returns undefined when no end field is configured', () => {
expect(
getRecordCalendarDateRangeOverlapFilter({
calendarField: {
name: 'startDate',
type: FieldMetadataType.DATE,
},
calendarEndField: undefined,
firstDayOfRange: '2026-06-29',
nextDayAfterLastDayOfRange: '2026-08-10',
}),
).toBeUndefined();
});
it('matches DATE_TIME ranges that overlap the visible range', () => {
expect(
getRecordCalendarDateRangeOverlapFilter({
calendarField: {
name: 'startsAt',
type: FieldMetadataType.DATE_TIME,
},
calendarEndField: {
name: 'endsAt',
type: FieldMetadataType.DATE_TIME,
},
firstDayOfRange: '2026-06-28T22:00:00Z',
nextDayAfterLastDayOfRange: '2026-08-09T22:00:00Z',
}),
).toEqual({
and: [
{
startsAt: {
lt: '2026-08-09T22:00:00Z',
},
},
{
or: [
{
startsAt: {
gte: '2026-06-28T22:00:00Z',
},
},
{
endsAt: {
gte: '2026-06-28T22:00:00Z',
},
},
],
},
],
});
});
it('returns undefined for incompatible calendar field types', () => {
expect(
getRecordCalendarDateRangeOverlapFilter({
calendarField: {
name: 'startsAt',
type: FieldMetadataType.DATE_TIME,
},
calendarEndField: {
name: 'endDate',
type: FieldMetadataType.DATE,
},
firstDayOfRange: '2026-06-28T22:00:00Z',
nextDayAfterLastDayOfRange: '2026-08-09T22:00:00Z',
}),
).toBeUndefined();
});
});
@@ -0,0 +1,59 @@
import {
FieldMetadataType,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
type CalendarDateField = {
name: string;
type: FieldMetadataType;
};
type GetRecordCalendarDateRangeOverlapFilterParams = {
calendarField: CalendarDateField;
calendarEndField: CalendarDateField | undefined;
firstDayOfRange: string;
nextDayAfterLastDayOfRange: string;
};
export const getRecordCalendarDateRangeOverlapFilter = ({
calendarField,
calendarEndField,
firstDayOfRange,
nextDayAfterLastDayOfRange,
}: GetRecordCalendarDateRangeOverlapFilterParams):
| RecordGqlOperationFilter
| undefined => {
const hasCompatibleDateRangeFields =
(calendarField.type === FieldMetadataType.DATE &&
calendarEndField?.type === FieldMetadataType.DATE) ||
(calendarField.type === FieldMetadataType.DATE_TIME &&
calendarEndField?.type === FieldMetadataType.DATE_TIME);
if (!hasCompatibleDateRangeFields) {
return undefined;
}
return {
and: [
{
[calendarField.name]: {
lt: nextDayAfterLastDayOfRange,
},
},
{
or: [
{
[calendarField.name]: {
gte: firstDayOfRange,
},
},
{
[calendarEndField.name]: {
gte: firstDayOfRange,
},
},
],
},
],
};
};
@@ -11,10 +11,13 @@ import { RecordCalendarCardComponentInstanceContext } from '@/object-record/reco
import { isRecordCalendarCardSelectedComponentFamilyState } from '@/object-record/record-calendar/record-calendar-card/states/isRecordCalendarCardSelectedComponentFamilyState';
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { RecordCard } from '@/object-record/record-card/components/RecordCard';
import { isDraggingRecordComponentState } from '@/object-record/record-drag/states/isDraggingRecordComponentState';
import { RecordFieldsScopeContextProvider } from '@/object-record/record-field-list/contexts/RecordFieldsScopeContext';
import { useOpenRecordFromIndexView } from '@/object-record/record-index/hooks/useOpenRecordFromIndexView';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { styled } from '@linaria/react';
@@ -36,6 +39,12 @@ export const RecordCalendarCard = ({ recordId }: RecordCalendarCardProps) => {
const { currentView } = useGetCurrentViewOnly();
const isCompactModeActive = currentView?.isCompact ?? false;
const isDraggingRecord = useAtomComponentStateValue(
isDraggingRecordComponentState,
);
const { openRecordFromIndexView } = useOpenRecordFromIndexView();
const [isRecordCalendarCardSelected, setIsRecordCalendarCardSelected] =
useAtomComponentFamilyState(
isRecordCalendarCardSelectedComponentFamilyState,
@@ -58,6 +67,14 @@ export const RecordCalendarCard = ({ recordId }: RecordCalendarCardProps) => {
const { openDropdown } = useOpenDropdown();
const handleCardClick = () => {
if (isDraggingRecord) {
return;
}
openRecordFromIndexView({ recordId });
};
const handleContextMenuOpen = (event: React.MouseEvent) => {
event.preventDefault();
setIsRecordCalendarCardSelected(true);
@@ -88,6 +105,7 @@ export const RecordCalendarCard = ({ recordId }: RecordCalendarCardProps) => {
<RecordCard
data-selected={isRecordCalendarCardSelected}
data-click-outside-id={RECORD_CALENDAR_CARD_CLICK_OUTSIDE_ID}
onClick={isCompactModeActive ? handleCardClick : undefined}
>
<RecordCalendarCardHeader recordId={recordId} />
<AnimatedEaseInOut isOpen={!isCompactModeActive} initial={false}>
@@ -2,15 +2,10 @@ import { styled } from '@linaria/react';
import { Draggable } from '@hello-pangea/dnd';
import { getCssCompatibleDraggableProps } from '@/ui/layout/draggable-list/utils/getCssCompatibleDraggableProps';
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { RecordCalendarCard } from '@/object-record/record-calendar/record-calendar-card/components/RecordCalendarCard';
import { useIsRecordCalendarCardDragDisabled } from '@/object-record/record-calendar/record-calendar-card/hooks/useIsRecordCalendarCardDragDisabled';
import { RecordCalendarCardComponentInstanceContext } from '@/object-record/record-calendar/record-calendar-card/states/contexts/RecordCalendarCardComponentInstanceContext';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { getRecordCalendarCardDraggableId } from '@/object-record/record-calendar/record-calendar-card/utils/getRecordCalendarCardDraggableId';
const StyledDraggableContainer = styled.div`
position: relative;
@@ -20,63 +15,34 @@ const StyledDraggableContainer = styled.div`
`;
export const RecordCalendarCardDraggableContainer = ({
calendarDay,
recordId,
index,
}: {
calendarDay: string;
recordId: string;
index: number;
}) => {
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
const dragIsDisabled = useIsRecordCalendarCardDragDisabled(recordId);
const recordIsReadOnly = useIsRecordReadOnly({
const draggableId = getRecordCalendarCardDraggableId({
calendarDay,
recordId,
objectMetadataId: objectMetadataItem.id,
});
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
recordIndexCalendarFieldMetadataIdState,
);
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === recordIndexCalendarFieldMetadataId,
);
const calendarFieldMetadataItemIsUIReadOnly =
calendarFieldMetadataItem?.isUIEditable === false;
const calendarFieldMetadataItemIsRestrictedForUpdate = isDefined(
calendarFieldMetadataItem,
)
? isFieldMetadataReadOnlyByPermissions({
objectPermissions,
fieldMetadataId: calendarFieldMetadataItem.id,
})
: false;
const calendarFieldMetadataItemIsReadOnly =
calendarFieldMetadataItemIsUIReadOnly ||
calendarFieldMetadataItemIsRestrictedForUpdate;
const dragIsDisabled =
recordIsReadOnly || calendarFieldMetadataItemIsReadOnly;
return (
<RecordCalendarCardComponentInstanceContext.Provider
value={{ instanceId: recordId }}
>
<Draggable
key={recordId}
draggableId={recordId}
key={draggableId}
draggableId={draggableId}
index={index}
isDragDisabled={dragIsDisabled}
>
{(draggableProvided) => (
<StyledDraggableContainer
id={`record-calendar-card-${recordId}`}
id={`record-calendar-card-${recordId}-${calendarDay}`}
ref={draggableProvided?.innerRef}
// oxlint-disable-next-line react/jsx-props-no-spreading
{...draggableProvided?.dragHandleProps}
@@ -69,16 +69,27 @@ export const RecordCalendarCardHeader = ({
padding={themeCssVariables.spacing[1]}
>
<StyledRecordChipContainer>
<StopPropagationContainer>
{isCompactModeActive ? (
<RecordChip
objectNameSingular={objectMetadataItem.nameSingular}
record={recordStore}
variant={ChipVariant.Transparent}
isIconHidden={true}
onClick={handleChipClick}
forceDisableClick
triggerEvent={'CLICK'}
/>
</StopPropagationContainer>
) : (
<StopPropagationContainer>
<RecordChip
objectNameSingular={objectMetadataItem.nameSingular}
record={recordStore}
variant={ChipVariant.Transparent}
isIconHidden={true}
onClick={handleChipClick}
triggerEvent={'CLICK'}
/>
</StopPropagationContainer>
)}
</StyledRecordChipContainer>
<StyledCheckboxContainer className="checkbox-container">
<StopPropagationContainer>
@@ -0,0 +1,51 @@
import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject';
import { useIsRecordReadOnly } from '@/object-record/read-only/hooks/useIsRecordReadOnly';
import { isFieldMetadataReadOnlyByPermissions } from '@/object-record/read-only/utils/internal/isFieldMetadataReadOnlyByPermissions';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
export const useIsRecordCalendarCardDragDisabled = (recordId: string) => {
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
const recordIsReadOnly = useIsRecordReadOnly({
recordId,
objectMetadataId: objectMetadataItem.id,
});
const objectPermissions = useObjectPermissionsForObject(
objectMetadataItem.id,
);
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
recordIndexCalendarFieldMetadataIdState,
);
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
recordIndexCalendarEndFieldMetadataIdState,
);
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === recordIndexCalendarFieldMetadataId,
);
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === recordIndexCalendarEndFieldMetadataId,
);
const calendarFieldIsReadOnly =
calendarFieldMetadataItem?.isUIEditable === false ||
(isDefined(calendarFieldMetadataItem) &&
isFieldMetadataReadOnlyByPermissions({
objectPermissions,
fieldMetadataId: calendarFieldMetadataItem.id,
}));
const calendarEndFieldIsReadOnly =
calendarEndFieldMetadataItem?.isUIEditable === false ||
(isDefined(calendarEndFieldMetadataItem) &&
isFieldMetadataReadOnlyByPermissions({
objectPermissions,
fieldMetadataId: calendarEndFieldMetadataItem.id,
}));
return (
recordIsReadOnly || calendarFieldIsReadOnly || calendarEndFieldIsReadOnly
);
};
@@ -0,0 +1,42 @@
import {
getRecordCalendarCardDraggableId,
getRecordIdFromRecordCalendarCardDraggableId,
} from '@/object-record/record-calendar/record-calendar-card/utils/getRecordCalendarCardDraggableId';
describe('getRecordCalendarCardDraggableId', () => {
it('creates a different draggable id for each rendered day', () => {
const firstDayDraggableId = getRecordCalendarCardDraggableId({
calendarDay: '2026-07-08',
recordId: 'record-id',
});
const secondDayDraggableId = getRecordCalendarCardDraggableId({
calendarDay: '2026-07-09',
recordId: 'record-id',
});
expect(firstDayDraggableId).not.toBe(secondDayDraggableId);
expect(
getRecordIdFromRecordCalendarCardDraggableId(firstDayDraggableId),
).toBe('record-id');
expect(
getRecordIdFromRecordCalendarCardDraggableId(secondDayDraggableId),
).toBe('record-id');
});
it('preserves record ids containing separator characters', () => {
const draggableId = getRecordCalendarCardDraggableId({
calendarDay: '2026-07-08',
recordId: 'record:id/with special characters',
});
expect(getRecordIdFromRecordCalendarCardDraggableId(draggableId)).toBe(
'record:id/with special characters',
);
});
it('accepts legacy draggable ids', () => {
expect(
getRecordIdFromRecordCalendarCardDraggableId('legacy-record-id'),
).toBe('legacy-record-id');
});
});
@@ -0,0 +1,36 @@
const RECORD_CALENDAR_CARD_DRAGGABLE_ID_PREFIX = 'calendar-card:';
export const getRecordCalendarCardDraggableId = ({
calendarDay,
recordId,
}: {
calendarDay: string;
recordId: string;
}) =>
`${RECORD_CALENDAR_CARD_DRAGGABLE_ID_PREFIX}${encodeURIComponent(recordId)}:${calendarDay}`;
export const getRecordIdFromRecordCalendarCardDraggableId = (
draggableId: string,
) => {
if (!draggableId.startsWith(RECORD_CALENDAR_CARD_DRAGGABLE_ID_PREFIX)) {
return draggableId;
}
const encodedRecordIdAndCalendarDay = draggableId.slice(
RECORD_CALENDAR_CARD_DRAGGABLE_ID_PREFIX.length,
);
const calendarDaySeparatorIndex =
encodedRecordIdAndCalendarDay.lastIndexOf(':');
if (calendarDaySeparatorIndex <= 0) {
return draggableId;
}
try {
return decodeURIComponent(
encodedRecordIdAndCalendarDay.slice(0, calendarDaySeparatorIndex),
);
} catch {
return draggableId;
}
};
@@ -3,6 +3,9 @@ import { hasObjectMetadataItemPositionField } from '@/object-metadata/utils/hasO
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { recordCalendarRecordIdsComponentState } from '@/object-record/record-calendar/states/recordCalendarRecordIdsComponentState';
import { isRecordCalendarDayInDateRange } from '@/object-record/record-calendar/utils/isRecordCalendarDayInDateRange';
import { isRecordCalendarDayInDateTimeRange } from '@/object-record/record-calendar/utils/isRecordCalendarDayInDateTimeRange';
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 { createAtomComponentFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomComponentFamilySelector';
@@ -25,6 +28,9 @@ export const calendarDayRecordIdsComponentFamilySelector =
const calendarFieldMetadataId = get(
recordIndexCalendarFieldMetadataIdState,
);
const calendarEndFieldMetadataId = get(
recordIndexCalendarEndFieldMetadataIdState,
);
const objectMetadataItems = get(objectMetadataItemsSelector);
const objectMetadataItem = objectMetadataItems.find(
@@ -48,6 +54,11 @@ export const calendarDayRecordIdsComponentFamilySelector =
return [];
}
const endFieldMetadataItem = objectMetadataItem.fields.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === calendarEndFieldMetadataId,
);
const allRecordIds = get(recordCalendarRecordIdsComponentState, {
instanceId,
});
@@ -61,14 +72,43 @@ export const calendarDayRecordIdsComponentFamilySelector =
return false;
}
const recordDateAsPlainDateInTimeZone =
fieldMetadataItem.type === FieldMetadataType.DATE
? Temporal.PlainDate.from(recordDate)
: Temporal.Instant.from(recordDate)
.toZonedDateTimeISO(timeZone)
.toPlainDate();
if (fieldMetadataItem.type === FieldMetadataType.DATE) {
const recordStartDate = Temporal.PlainDate.from(recordDate);
const recordEndDateValue = isDefined(endFieldMetadataItem)
? record?.[endFieldMetadataItem.name]
: undefined;
return isSamePlainDate(day, recordDateAsPlainDateInTimeZone);
if (
endFieldMetadataItem?.type === FieldMetadataType.DATE &&
isNonEmptyString(recordEndDateValue)
) {
try {
return isRecordCalendarDayInDateRange({
day,
startDate: recordStartDate,
endDate: Temporal.PlainDate.from(recordEndDateValue),
});
} catch {
return isSamePlainDate(day, recordStartDate);
}
}
return isSamePlainDate(day, recordStartDate);
}
const recordEndDateValue = isDefined(endFieldMetadataItem)
? record?.[endFieldMetadataItem.name]
: undefined;
return isRecordCalendarDayInDateTimeRange({
day,
startDateTime: recordDate,
endDateTime:
endFieldMetadataItem?.type === FieldMetadataType.DATE_TIME
? recordEndDateValue
: undefined,
timeZone,
});
});
if (
@@ -0,0 +1,33 @@
import { getEffectiveRecordCalendarEndFieldMetadataId } from '@/object-record/record-calendar/utils/getEffectiveRecordCalendarEndFieldMetadataId';
describe('getEffectiveRecordCalendarEndFieldMetadataId', () => {
it('keeps the configured end field when the week view is enabled', () => {
expect(
getEffectiveRecordCalendarEndFieldMetadataId({
calendarEndFieldMetadataId: 'end-field-id',
isCalendarWeekViewEnabled: true,
}),
).toBe('end-field-id');
});
it.each([null, undefined])(
'normalizes %s to null when the week view is enabled',
(calendarEndFieldMetadataId) => {
expect(
getEffectiveRecordCalendarEndFieldMetadataId({
calendarEndFieldMetadataId,
isCalendarWeekViewEnabled: true,
}),
).toBeNull();
},
);
it('makes a configured end field inert when the week view is disabled', () => {
expect(
getEffectiveRecordCalendarEndFieldMetadataId({
calendarEndFieldMetadataId: 'end-field-id',
isCalendarWeekViewEnabled: false,
}),
).toBeNull();
});
});
@@ -0,0 +1,40 @@
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
describe('getSupportedRecordCalendarLayout', () => {
it.each([
[ViewCalendarLayout.WEEK, ViewCalendarLayout.WEEK],
[ViewCalendarLayout.MONTH, ViewCalendarLayout.MONTH],
[ViewCalendarLayout.DAY, ViewCalendarLayout.MONTH],
[null, ViewCalendarLayout.MONTH],
[undefined, ViewCalendarLayout.MONTH],
])(
'normalizes %s to %s when the week view is enabled',
(calendarLayout, expectedLayout) => {
expect(
getSupportedRecordCalendarLayout({
calendarLayout,
isCalendarWeekViewEnabled: true,
}),
).toBe(expectedLayout);
},
);
it.each([
ViewCalendarLayout.WEEK,
ViewCalendarLayout.MONTH,
ViewCalendarLayout.DAY,
null,
undefined,
])(
'normalizes %s to month when the week view is disabled',
(calendarLayout) => {
expect(
getSupportedRecordCalendarLayout({
calendarLayout,
isCalendarWeekViewEnabled: false,
}),
).toBe(ViewCalendarLayout.MONTH);
},
);
});
@@ -0,0 +1,43 @@
import { isRecordCalendarDayInDateRange } from '@/object-record/record-calendar/utils/isRecordCalendarDayInDateRange';
import { Temporal } from 'temporal-polyfill';
describe('isRecordCalendarDayInDateRange', () => {
const startDate = Temporal.PlainDate.from('2026-07-06');
const endDate = Temporal.PlainDate.from('2026-07-08');
it.each([
['2026-07-05', false],
['2026-07-06', true],
['2026-07-07', true],
['2026-07-08', true],
['2026-07-09', false],
])('checks whether %s is inside the inclusive range', (day, expected) => {
expect(
isRecordCalendarDayInDateRange({
day: Temporal.PlainDate.from(day),
startDate,
endDate,
}),
).toBe(expected);
});
it.each([undefined, null, Temporal.PlainDate.from('2026-07-05')])(
'falls back to the start day for an unusable end date',
(unusableEndDate) => {
expect(
isRecordCalendarDayInDateRange({
day: startDate,
startDate,
endDate: unusableEndDate,
}),
).toBe(true);
expect(
isRecordCalendarDayInDateRange({
day: startDate.add({ days: 1 }),
startDate,
endDate: unusableEndDate,
}),
).toBe(false);
},
);
});
@@ -0,0 +1,55 @@
import { isRecordCalendarDayInDateTimeRange } from '@/object-record/record-calendar/utils/isRecordCalendarDayInDateTimeRange';
import { Temporal } from 'temporal-polyfill';
const timeZone = 'Europe/Paris';
describe('isRecordCalendarDayInDateTimeRange', () => {
it.each([
['2026-07-07', false],
['2026-07-08', true],
['2026-07-09', true],
['2026-07-10', true],
['2026-07-11', false],
])('matches the overlapping days of a multi-day event: %s', (day, result) => {
expect(
isRecordCalendarDayInDateTimeRange({
day: Temporal.PlainDate.from(day),
startDateTime: '2026-07-08T15:59:00Z',
endDateTime: '2026-07-10T18:59:00Z',
timeZone,
}),
).toBe(result);
});
it('does not include the next day when an event ends exactly at midnight', () => {
expect(
isRecordCalendarDayInDateTimeRange({
day: Temporal.PlainDate.from('2026-07-10'),
startDateTime: '2026-07-08T15:59:00Z',
endDateTime: '2026-07-09T22:00:00Z',
timeZone,
}),
).toBe(false);
});
it('uses the one-hour fallback when the end is unusable', () => {
expect(
isRecordCalendarDayInDateTimeRange({
day: Temporal.PlainDate.from('2026-07-09'),
startDateTime: '2026-07-08T21:30:00Z',
endDateTime: 'not-a-date',
timeZone,
}),
).toBe(true);
});
it('returns false when the start is unusable', () => {
expect(
isRecordCalendarDayInDateTimeRange({
day: Temporal.PlainDate.from('2026-07-08'),
startDateTime: 'not-a-date',
timeZone,
}),
).toBe(false);
});
});
@@ -0,0 +1,10 @@
type GetEffectiveRecordCalendarEndFieldMetadataIdArgs = {
calendarEndFieldMetadataId: string | null | undefined;
isCalendarWeekViewEnabled: boolean;
};
export const getEffectiveRecordCalendarEndFieldMetadataId = ({
calendarEndFieldMetadataId,
isCalendarWeekViewEnabled,
}: GetEffectiveRecordCalendarEndFieldMetadataIdArgs) =>
isCalendarWeekViewEnabled ? (calendarEndFieldMetadataId ?? null) : null;
@@ -0,0 +1,45 @@
import { Temporal } from 'temporal-polyfill';
type GetRecordCalendarDateTimeRangeArgs = {
endDateTime?: unknown;
startDateTime: unknown;
timeZone: string;
};
export type RecordCalendarDateTimeRange = {
end: Temporal.ZonedDateTime;
isEndDateTimeFallback: boolean;
start: Temporal.ZonedDateTime;
};
const getZonedDateTimeFromValue = (value: unknown, timeZone: string) => {
if (typeof value !== 'string') {
return null;
}
try {
return Temporal.Instant.from(value).toZonedDateTimeISO(timeZone);
} catch {
return null;
}
};
export const getRecordCalendarDateTimeRange = ({
endDateTime,
startDateTime,
timeZone,
}: GetRecordCalendarDateTimeRangeArgs): RecordCalendarDateTimeRange | null => {
const start = getZonedDateTimeFromValue(startDateTime, timeZone);
if (start === null) {
return null;
}
const configuredEnd = getZonedDateTimeFromValue(endDateTime, timeZone);
const isEndDateTimeFallback =
configuredEnd === null ||
Temporal.Instant.compare(configuredEnd.toInstant(), start.toInstant()) <= 0;
const end = isEndDateTimeFallback ? start.add({ hours: 1 }) : configuredEnd;
return { end, isEndDateTimeFallback, start };
};
@@ -0,0 +1,14 @@
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
type GetSupportedRecordCalendarLayoutArgs = {
calendarLayout: ViewCalendarLayout | null | undefined;
isCalendarWeekViewEnabled: boolean;
};
export const getSupportedRecordCalendarLayout = ({
calendarLayout,
isCalendarWeekViewEnabled,
}: GetSupportedRecordCalendarLayoutArgs) =>
isCalendarWeekViewEnabled && calendarLayout === ViewCalendarLayout.WEEK
? ViewCalendarLayout.WEEK
: ViewCalendarLayout.MONTH;
@@ -0,0 +1,25 @@
import { Temporal } from 'temporal-polyfill';
type IsRecordCalendarDayInDateRangeArgs = {
day: Temporal.PlainDate;
endDate?: Temporal.PlainDate | null;
startDate: Temporal.PlainDate;
};
export const isRecordCalendarDayInDateRange = ({
day,
endDate,
startDate,
}: IsRecordCalendarDayInDateRangeArgs) => {
const validEndDate =
endDate !== null &&
endDate !== undefined &&
Temporal.PlainDate.compare(endDate, startDate) >= 0
? endDate
: startDate;
return (
Temporal.PlainDate.compare(day, startDate) >= 0 &&
Temporal.PlainDate.compare(day, validEndDate) <= 0
);
};
@@ -0,0 +1,37 @@
import { getRecordCalendarDateTimeRange } from '@/object-record/record-calendar/utils/getRecordCalendarDateTimeRange';
import { Temporal } from 'temporal-polyfill';
type IsRecordCalendarDayInDateTimeRangeArgs = {
day: Temporal.PlainDate;
endDateTime?: unknown;
startDateTime: unknown;
timeZone: string;
};
export const isRecordCalendarDayInDateTimeRange = ({
day,
endDateTime,
startDateTime,
timeZone,
}: IsRecordCalendarDayInDateTimeRangeArgs) => {
const range = getRecordCalendarDateTimeRange({
endDateTime,
startDateTime,
timeZone,
});
if (range === null) {
return false;
}
const dayStart = day.toZonedDateTime({ timeZone }).toInstant();
const nextDayStart = day
.add({ days: 1 })
.toZonedDateTime({ timeZone })
.toInstant();
return (
Temporal.Instant.compare(range.start.toInstant(), nextDayStart) < 0 &&
Temporal.Instant.compare(range.end.toInstant(), dayStart) > 0
);
};
@@ -0,0 +1,704 @@
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 { 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<WeekDayCellProps, 'day'> & {
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 (
<StyledAllDayCell>
{allDayRecordIds.slice(0, 1).map((recordId) => (
<RecordCalendarWeekEvent
key={recordId}
calendarDay={day}
calendarFieldName={calendarFieldName}
calendarFieldType={calendarFieldType}
isAllDay
recordId={recordId}
timeFormat={timeFormat}
timeZone={timeZone}
/>
))}
{allDayRecordIds.length > 1 && (
<StyledAdditionalEventCount>
+{allDayRecordIds.length - 1}
</StyledAdditionalEventCount>
)}
</StyledAllDayCell>
);
};
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
if (
event.target === event.currentTarget &&
event.currentTarget.matches(':focus-visible')
) {
onActiveSlotIndexChange(
dayString,
activeSlotIndex ?? DEFAULT_KEYBOARD_SLOT_INDEX,
'keyboard',
);
}
};
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
onActiveSlotIndexChange(dayString, null, 'keyboard');
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
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 (
<StyledDayColumn
aria-label={day.toLocaleString(undefined, { dateStyle: 'full' })}
isWeekend={isPlainDateInWeekend(day)}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
onMouseLeave={() => onActiveSlotIndexChange(dayString, null, 'pointer')}
onMouseMove={handleMouseMove}
tabIndex={0}
>
{isDefined(activeSlotIndex) && (
<StyledSlotAddNewPositioner
data-testid="record-calendar-week-slot-add"
topInPixels={
activeSlotIndex * RECORD_CALENDAR_WEEK_DIMENSIONS.slotHeight
}
>
<RecordCalendarAddNew
cardDate={day}
cardTime={Temporal.PlainTime.from('00:00').add({
minutes:
activeSlotIndex *
RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes,
})}
compact
/>
</StyledSlotAddNewPositioner>
)}
{eventLayouts.map(
({
columnCount,
columnIndex,
endInPixels,
recordId,
startInPixels,
}) => (
<RecordCalendarWeekEvent
key={recordId}
calendarDay={day}
calendarEndFieldName={calendarEndFieldName}
calendarFieldName={calendarFieldName}
calendarFieldType={calendarFieldType}
columnCount={columnCount}
columnIndex={columnIndex}
endInPixels={endInPixels}
isAllDay={false}
recordId={recordId}
startInPixels={startInPixels}
timeFormat={timeFormat}
timeZone={timeZone}
/>
),
)}
</StyledDayColumn>
);
},
);
export const RecordCalendarWeek = () => {
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
const { timeFormat, timeZone } = useDateTimeFormat();
const recordCalendarId = useAvailableComponentInstanceIdOrThrow(
RecordCalendarComponentInstanceContext,
);
const recordCalendarSelectedDate = useAtomComponentStateValue(
recordCalendarSelectedDateComponentState,
recordCalendarId,
);
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
recordIndexCalendarFieldMetadataIdState,
);
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
recordIndexCalendarEndFieldMetadataIdState,
);
const scrollAnchorRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const [currentInstant, setCurrentInstant] = useState(() =>
Temporal.Now.instant(),
);
const [activeSlot, setActiveSlot] =
useState<RecordCalendarWeekActiveSlot | null>(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 (
<RecordCalendarWeekDragDropContext
gridRef={gridRef}
weekDays={weekDays.map(({ date }) => date)}
>
<StyledContainer>
<StyledHeader>
<StyledHeaderGutter>
{isTimedView && <TimeZoneAbbreviation instant={currentInstant} />}
</StyledHeaderGutter>
{weekDays.map(({ date, label }) => {
const isToday = isSamePlainDate(date, today);
return (
<StyledDayHeader key={date.toString()}>
<span>{label}</span>
<StyledDayNumber isToday={isToday}>{date.day}</StyledDayNumber>
</StyledDayHeader>
);
})}
{isAllDayView && (
<>
<StyledAllDayLabel>{t`All day`}</StyledAllDayLabel>
{weekDays.map(({ date }) => (
<RecordCalendarWeekAllDayCell
key={`all-day-${date.toString()}`}
calendarFieldName={calendarFieldMetadataItem.name}
calendarFieldType={calendarFieldMetadataItem.type}
day={date}
timeFormat={timeFormat}
timeZone={timeZone}
/>
))}
</>
)}
</StyledHeader>
{isTimedView && (
<StyledGrid ref={gridRef} onMouseLeave={clearActivePointerSlot}>
<StyledTimeGutter>
{Array.from(
{ length: RECORD_CALENDAR_WEEK_DIMENSIONS.hoursInDay },
(_, hour) => (
<StyledHourLabel
key={hour}
topInPixels={
hour * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight
}
>
{format(new Date(2000, 0, 1, hour), timeFormat)}
</StyledHourLabel>
),
)}
<StyledScrollAnchor
ref={scrollAnchorRef}
topInPixels={
initialScrollHour * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight
}
/>
</StyledTimeGutter>
{weekDays.map(({ date }) => {
const dateString = date.toString();
return (
<RecordCalendarWeekDayColumn
key={dateString}
activeSlotIndex={
activeSlot?.day === dateString ? activeSlot.slotIndex : null
}
calendarEndFieldName={compatibleCalendarEndFieldName}
calendarFieldName={calendarFieldMetadataItem.name}
calendarFieldType={calendarFieldMetadataItem.type}
dayString={dateString}
onActiveSlotIndexChange={handleActiveSlotIndexChange}
timeFormat={timeFormat}
timeZone={timeZone}
/>
);
})}
{isCurrentWeek && (
<>
<StyledCurrentTimeLine topInPixels={currentTimeTopInPixels} />
<StyledCurrentTimeLabel topInPixels={currentTimeTopInPixels}>
{formatInTimeZone(
new Date(currentInstant.toString()),
timeZone,
timeFormat,
)}
</StyledCurrentTimeLabel>
</>
)}
</StyledGrid>
)}
</StyledContainer>
</RecordCalendarWeekDragDropContext>
);
};
@@ -0,0 +1,115 @@
import { useProcessRecordCalendarWeekEventDrop } from '@/object-record/record-calendar/week/hooks/useProcessRecordCalendarWeekEventDrop';
import { type RecordCalendarWeekDndData } from '@/object-record/record-calendar/week/types/RecordCalendarWeekDndData';
import { resolveRecordCalendarWeekEventDrop } from '@/object-record/record-calendar/week/utils/resolveRecordCalendarWeekEventDrop';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors';
import { DragDropProvider } from '@dnd-kit/react';
import { t } from '@lingui/core/macro';
import {
type ComponentProps,
type ReactNode,
type RefObject,
useState,
} from 'react';
import type { Temporal } from 'temporal-polyfill';
import { isDefined } from 'twenty-shared/utils';
import { logError } from '~/utils/logError';
type DragStartPayload = Parameters<
NonNullable<
ComponentProps<
typeof DragDropProvider<RecordCalendarWeekDndData>
>['onDragStart']
>
>[0];
type DragEndPayload = Parameters<
NonNullable<
ComponentProps<
typeof DragDropProvider<RecordCalendarWeekDndData>
>['onDragEnd']
>
>[0];
type RecordCalendarWeekDragDropContextProps = {
children: ReactNode;
gridRef: RefObject<HTMLDivElement | null>;
weekDays: Temporal.PlainDate[];
};
export const RecordCalendarWeekDragDropContext = ({
children,
gridRef,
weekDays,
}: RecordCalendarWeekDragDropContextProps) => {
const [grabOffsetY, setGrabOffsetY] = useState(0);
const { processRecordCalendarWeekEventDrop } =
useProcessRecordCalendarWeekEventDrop();
const { enqueueErrorSnackBar } = useSnackBar();
const handleDragStart = ({ operation }: DragStartPayload) => {
const sourceElement = operation.source?.element;
if (!isDefined(sourceElement)) {
setGrabOffsetY(0);
return;
}
setGrabOffsetY(
operation.position.current.y - sourceElement.getBoundingClientRect().top,
);
};
const handleDragEnd = ({ canceled, operation }: DragEndPayload) => {
if (
canceled ||
!isDefined(operation.source) ||
!isDefined(gridRef.current)
) {
return;
}
const sourceData = operation.source.data;
if (sourceData.kind !== 'record-calendar-week-event') {
return;
}
const gridRect = gridRef.current.getBoundingClientRect();
const resolvedDrop = resolveRecordCalendarWeekEventDrop({
dayCount: weekDays.length,
grabOffsetY,
gridRect,
pointerX: operation.position.current.x,
pointerY: operation.position.current.y,
});
if (!isDefined(resolvedDrop)) {
return;
}
const destinationDay = weekDays[resolvedDrop.dayIndex];
if (!isDefined(destinationDay)) {
return;
}
void processRecordCalendarWeekEventDrop({
destinationDay,
destinationMinutes: resolvedDrop.destinationMinutes,
recordId: sourceData.recordId,
}).catch((error) => {
logError(error);
enqueueErrorSnackBar({ message: t`Failed to move record` });
});
};
return (
<DragDropProvider<RecordCalendarWeekDndData>
sensors={DND_KIT_SENSORS}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{children}
</DragDropProvider>
);
};
@@ -0,0 +1,319 @@
import { RecordChip } from '@/object-record/components/RecordChip';
import { StopPropagationContainer } from '@/object-record/record-board/record-board-card/components/StopPropagationContainer';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { RECORD_CALENDAR_CARD_CLICK_OUTSIDE_ID } from '@/object-record/record-calendar/record-calendar-card/constants/RecordCalendarCardClickOutsideId';
import { useIsRecordCalendarCardDragDisabled } from '@/object-record/record-calendar/record-calendar-card/hooks/useIsRecordCalendarCardDragDisabled';
import { isRecordCalendarCardSelectedComponentFamilyState } from '@/object-record/record-calendar/record-calendar-card/states/isRecordCalendarCardSelectedComponentFamilyState';
import { getRecordCalendarCardDraggableId } from '@/object-record/record-calendar/record-calendar-card/utils/getRecordCalendarCardDraggableId';
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
import { type RecordCalendarWeekDndData } from '@/object-record/record-calendar/week/types/RecordCalendarWeekDndData';
import { formatRecordCalendarWeekEventTimes } from '@/object-record/record-calendar/week/utils/formatRecordCalendarWeekEventTimes';
import { getRecordCalendarWeekEventHorizontalPosition } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekEventHorizontalPosition';
import { getRecordCalendarWeekTimedEventHeight } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekTimedEventMetrics';
import { RecordCard } from '@/object-record/record-card/components/RecordCard';
import { useOpenRecordFromIndexView } from '@/object-record/record-index/hooks/useOpenRecordFromIndexView';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useAtomComponentFamilyState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyState';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { useDraggable } from '@dnd-kit/react';
import { styled } from '@linaria/react';
import { type Temporal } from 'temporal-polyfill';
import { isDefined } from 'twenty-shared/utils';
import { ChipVariant } from 'twenty-ui/data-display';
import { Checkbox, CheckboxVariant } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const RECORD_CALENDAR_WEEK_EVENT_TIME_ROW_HEIGHT = 14;
const RECORD_CALENDAR_WEEK_EVENT_EXPANDED_MIN_HEIGHT =
RECORD_CALENDAR_WEEK_DIMENSIONS.minimumEventSlotHeight +
RECORD_CALENDAR_WEEK_EVENT_TIME_ROW_HEIGHT;
const StyledEventPositioner = styled.div<{
columnCount: number;
columnIndex: number;
heightInPixels: number;
isAllDay: boolean;
topInPixels: number;
}>`
box-sizing: border-box;
height: ${({ heightInPixels, isAllDay }) =>
isAllDay ? '22px' : `${heightInPixels}px`};
left: ${({ columnCount, columnIndex, isAllDay }) =>
isAllDay
? 'auto'
: getRecordCalendarWeekEventHorizontalPosition({
columnCount,
columnIndex,
}).left};
min-width: 0;
overflow: hidden;
position: ${({ isAllDay }) => (isAllDay ? 'relative' : 'absolute')};
right: auto;
top: ${({ isAllDay, topInPixels }) =>
isAllDay
? 'auto'
: `${topInPixels + RECORD_CALENDAR_WEEK_DIMENSIONS.eventVerticalGap / 2}px`};
width: ${({ columnCount, columnIndex, isAllDay }) =>
isAllDay
? '100%'
: getRecordCalendarWeekEventHorizontalPosition({
columnCount,
columnIndex,
}).width};
z-index: ${({ columnCount, columnIndex, isAllDay }) =>
isAllDay
? 1
: getRecordCalendarWeekEventHorizontalPosition({
columnCount,
columnIndex,
}).stackingOrder};
&:focus-within,
&:hover {
z-index: ${({ columnCount, columnIndex, isAllDay }) =>
isAllDay
? 1
: getRecordCalendarWeekEventHorizontalPosition({
columnCount,
columnIndex,
}).hoverStackingOrder};
}
> div {
height: 100%;
}
`;
const StyledEventContent = styled.div<{ isAllDay: boolean }>`
align-items: ${({ isAllDay }) => (isAllDay ? 'center' : 'flex-start')};
box-sizing: border-box;
display: flex;
flex-direction: ${({ isAllDay }) => (isAllDay ? 'row' : 'column')};
height: 100%;
min-width: 0;
overflow: hidden;
padding: ${({ isAllDay }) =>
isAllDay
? `0 ${themeCssVariables.spacing['0.5']}`
: `${themeCssVariables.spacing['0.5']} ${themeCssVariables.spacing[1]}`};
position: relative;
width: 100%;
`;
const StyledEventHeader = styled.div`
align-items: center;
display: flex;
height: 20px;
min-width: 0;
width: 100%;
`;
const StyledEventLabel = styled.div`
align-items: center;
display: flex;
flex: 1;
font-size: ${themeCssVariables.font.size.xs};
height: 20px;
min-width: 0;
overflow: hidden;
`;
const StyledRecordChipContainer = styled.div`
display: flex;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
[data-testid='chip'],
[data-testid='chip'] > * {
color: ${themeCssVariables.font.color.primary};
}
[data-testid='chip'] {
min-width: 0;
padding-right: 0;
}
`;
const StyledCompactEventStartTime = styled.span`
color: ${themeCssVariables.font.color.tertiary};
flex-shrink: 0;
font-size: ${themeCssVariables.font.size.xxs};
font-weight: ${themeCssVariables.font.weight.medium};
line-height: 12px;
white-space: nowrap;
`;
const StyledCheckboxContainer = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
height: 20px;
margin-left: auto;
`;
const StyledEventTimeRow = styled.div`
align-items: center;
display: flex;
height: ${RECORD_CALENDAR_WEEK_EVENT_TIME_ROW_HEIGHT}px;
min-width: 0;
overflow: hidden;
width: 100%;
`;
const StyledEventTime = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.xxs};
line-height: 12px;
white-space: nowrap;
`;
type RecordCalendarWeekEventProps = {
calendarDay: Temporal.PlainDate;
calendarEndFieldName?: string;
calendarFieldName: string;
calendarFieldType: FieldMetadataType;
columnCount?: number;
columnIndex?: number;
endInPixels?: number;
isAllDay: boolean;
recordId: string;
startInPixels?: number;
timeFormat: string;
timeZone: string;
};
export const RecordCalendarWeekEvent = ({
calendarDay,
calendarEndFieldName,
calendarFieldName,
calendarFieldType,
columnCount = 1,
columnIndex = 0,
endInPixels = 0,
isAllDay,
recordId,
startInPixels = 0,
timeFormat,
timeZone,
}: RecordCalendarWeekEventProps) => {
const { objectNameSingular } = useRecordCalendarContextOrThrow();
const { openRecordFromIndexView } = useOpenRecordFromIndexView();
const dragIsDisabled = useIsRecordCalendarCardDragDisabled(recordId);
const draggableId = getRecordCalendarCardDraggableId({
calendarDay: calendarDay.toString(),
recordId,
});
const { isDragSource, ref: draggableRef } =
useDraggable<RecordCalendarWeekDndData>({
id: draggableId,
data: {
kind: 'record-calendar-week-event',
recordId,
},
disabled: isAllDay || dragIsDisabled,
feedback: 'clone',
});
const recordStore = useAtomFamilyStateValue(recordStoreFamilyState, recordId);
const recordDate = recordStore?.[calendarFieldName];
const recordEndDate = isDefined(calendarEndFieldName)
? recordStore?.[calendarEndFieldName]
: undefined;
const [isRecordCalendarCardSelected, setIsRecordCalendarCardSelected] =
useAtomComponentFamilyState(
isRecordCalendarCardSelectedComponentFamilyState,
recordId,
);
const isDateOnly = calendarFieldType === FieldMetadataType.DATE;
if (
!isDefined(recordStore) ||
typeof recordDate !== 'string' ||
isAllDay !== isDateOnly
) {
return null;
}
const eventTimes = !isDateOnly
? formatRecordCalendarWeekEventTimes({
startDateTime: recordDate,
endDateTime: recordEndDate,
timeFormat,
timeZone,
})
: null;
const heightInPixels = getRecordCalendarWeekTimedEventHeight({
endInPixels,
startInPixels,
});
const isCompactTimedEvent =
!isAllDay &&
heightInPixels < RECORD_CALENDAR_WEEK_EVENT_EXPANDED_MIN_HEIGHT;
const expandedEventTime = eventTimes?.timeRange;
return (
<StyledEventPositioner
ref={draggableRef}
columnCount={columnCount}
columnIndex={columnIndex}
heightInPixels={heightInPixels}
isAllDay={isAllDay}
topInPixels={startInPixels}
data-selectable-id={recordId}
>
<RecordCard
data-click-outside-id={RECORD_CALENDAR_CARD_CLICK_OUTSIDE_ID}
data-selected={isRecordCalendarCardSelected}
onClick={() => {
if (!isDragSource) {
openRecordFromIndexView({ recordId });
}
}}
>
<StyledEventContent isAllDay={isAllDay}>
<StyledEventHeader>
<StyledEventLabel>
<StyledRecordChipContainer>
<RecordChip
objectNameSingular={objectNameSingular}
record={recordStore}
variant={ChipVariant.Transparent}
isIconHidden
forceDisableClick
triggerEvent="CLICK"
/>
</StyledRecordChipContainer>
{isCompactTimedEvent && isDefined(eventTimes) && (
<StyledCompactEventStartTime>
{`, ${eventTimes.startTime}`}
</StyledCompactEventStartTime>
)}
</StyledEventLabel>
<StyledCheckboxContainer className="checkbox-container">
<StopPropagationContainer>
<Checkbox
hoverable
checked={isRecordCalendarCardSelected}
onChange={(event) => {
setIsRecordCalendarCardSelected(event.target.checked);
}}
variant={CheckboxVariant.Secondary}
/>
</StopPropagationContainer>
</StyledCheckboxContainer>
</StyledEventHeader>
{!isAllDay &&
!isCompactTimedEvent &&
isDefined(expandedEventTime) && (
<StyledEventTimeRow>
<StyledEventTime>{expandedEventTime}</StyledEventTime>
</StyledEventTimeRow>
)}
</StyledEventContent>
</RecordCard>
</StyledEventPositioner>
);
};
@@ -0,0 +1,27 @@
function createRecordCalendarWeekDimensions({
hourHeight,
hoursInDay,
snapIntervalInMinutes,
}: {
hourHeight: number;
hoursInDay: number;
snapIntervalInMinutes: number;
}) {
return {
eventVerticalGap: 8,
gridHeight: hoursInDay * hourHeight,
hourHeight,
hoursInDay,
minimumEventSlotHeight: 24,
slotHeight: (hourHeight * snapIntervalInMinutes) / 60,
snapIntervalInMinutes,
timeGutterWidth: 56,
} as const;
}
export const RECORD_CALENDAR_WEEK_DIMENSIONS =
createRecordCalendarWeekDimensions({
hourHeight: 48,
hoursInDay: 24,
snapIntervalInMinutes: 30,
});
@@ -0,0 +1,88 @@
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { getRecordCalendarWeekEventDropDateTime } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekEventDropDateTime';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import type { Temporal } from 'temporal-polyfill';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
type ProcessRecordCalendarWeekEventDropArgs = {
destinationDay: Temporal.PlainDate;
destinationMinutes: number;
recordId: string;
};
export const useProcessRecordCalendarWeekEventDrop = () => {
const store = useStore();
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
const { currentView } = useGetCurrentViewOnly();
const { updateOneRecord } = useUpdateOneRecord();
const { userTimezone } = useUserTimezone();
const processRecordCalendarWeekEventDrop = useCallback(
async ({
destinationDay,
destinationMinutes,
recordId,
}: ProcessRecordCalendarWeekEventDropArgs) => {
const calendarFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === currentView?.calendarFieldMetadataId,
);
if (calendarFieldMetadataItem?.type !== FieldMetadataType.DATE_TIME) {
return;
}
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === currentView?.calendarEndFieldMetadataId,
);
const record = store.get(recordStoreFamilyState.atomFamily(recordId));
if (!isDefined(record)) {
return;
}
const shiftedDateTime = getRecordCalendarWeekEventDropDateTime({
destinationDay,
destinationMinutes,
startDateTime: record[calendarFieldMetadataItem.name],
endDateTime:
calendarEndFieldMetadataItem?.type === FieldMetadataType.DATE_TIME
? record[calendarEndFieldMetadataItem.name]
: undefined,
timeZone: userTimezone,
});
if (shiftedDateTime === null) {
return;
}
await updateOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
idToUpdate: recordId,
updateOneRecordInput: {
[calendarFieldMetadataItem.name]: shiftedDateTime.startDateTime,
...(isDefined(calendarEndFieldMetadataItem) &&
isDefined(shiftedDateTime.endDateTime) && {
[calendarEndFieldMetadataItem.name]: shiftedDateTime.endDateTime,
}),
},
});
},
[
currentView?.calendarEndFieldMetadataId,
currentView?.calendarFieldMetadataId,
objectMetadataItem.fields,
objectMetadataItem.nameSingular,
store,
updateOneRecord,
userTimezone,
],
);
return { processRecordCalendarWeekEventDrop };
};
@@ -0,0 +1,48 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { detectCalendarStartDay } from '@/localization/utils/detection/detectCalendarStartDay';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { format } from 'date-fns';
import { type Temporal } from 'temporal-polyfill';
import { CalendarStartDay } from 'twenty-shared/constants';
import { turnPlainDateToShiftedDateInSystemTimeZone } from 'twenty-shared/utils';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
export const useRecordCalendarWeekDaysRange = (
selectedDate: Temporal.PlainDate,
) => {
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
const dateLocale = useAtomStateValue(dateLocaleState);
const calendarStartDay =
currentWorkspaceMember?.calendarStartDay ?? CalendarStartDay.SYSTEM;
const weekStartsOnDayIndex = (
calendarStartDay === CalendarStartDay.SYSTEM
? CalendarStartDay[detectCalendarStartDay()]
: calendarStartDay
) as 0 | 1 | 2 | 3 | 4 | 5 | 6;
const selectedDayIndex = selectedDate.dayOfWeek % 7;
const daysSinceStartOfWeek =
(selectedDayIndex - weekStartsOnDayIndex + 7) % 7;
const firstDayOfWeek = selectedDate.subtract({
days: daysSinceStartOfWeek,
});
const weekDays = Array.from({ length: 7 }, (_, index) => {
const date = firstDayOfWeek.add({ days: index });
return {
date,
label: format(turnPlainDateToShiftedDateInSystemTimeZone(date), 'EEE', {
locale: dateLocale.localeCatalog,
}),
};
});
return {
firstDayOfWeek,
lastDayOfWeek: firstDayOfWeek.add({ days: 6 }),
weekDays,
};
};
@@ -0,0 +1,4 @@
export type RecordCalendarWeekDndData = {
kind: 'record-calendar-week-event';
recordId: string;
};
@@ -0,0 +1,141 @@
import { computeRecordCalendarWeekEventLayouts } from '@/object-record/record-calendar/week/utils/computeRecordCalendarWeekEventLayouts';
describe('computeRecordCalendarWeekEventLayouts', () => {
it('gives non-overlapping events the full column width', () => {
const layouts = computeRecordCalendarWeekEventLayouts([
{ recordId: 'first', startInPixels: 0, endInPixels: 44 },
{ recordId: 'second', startInPixels: 44, endInPixels: 88 },
]);
expect(layouts).toEqual([
{
recordId: 'first',
startInPixels: 0,
endInPixels: 44,
columnIndex: 0,
columnCount: 1,
},
{
recordId: 'second',
startInPixels: 44,
endInPixels: 88,
columnIndex: 0,
columnCount: 1,
},
]);
});
it('places overlapping events in adjacent columns', () => {
const layouts = computeRecordCalendarWeekEventLayouts([
{ recordId: 'first', startInPixels: 0, endInPixels: 44 },
{ recordId: 'second', startInPixels: 10, endInPixels: 54 },
{ recordId: 'third', startInPixels: 20, endInPixels: 64 },
]);
expect(
layouts.map(({ recordId, columnIndex, columnCount }) => ({
recordId,
columnIndex,
columnCount,
})),
).toEqual([
{ recordId: 'first', columnIndex: 0, columnCount: 3 },
{ recordId: 'second', columnIndex: 1, columnCount: 3 },
{ recordId: 'third', columnIndex: 2, columnCount: 3 },
]);
});
it('keeps every event in a larger overlapping group', () => {
const layouts = computeRecordCalendarWeekEventLayouts([
{ recordId: 'first', startInPixels: 0, endInPixels: 60 },
{ recordId: 'second', startInPixels: 0, endInPixels: 60 },
{ recordId: 'third', startInPixels: 0, endInPixels: 60 },
{ recordId: 'fourth', startInPixels: 15, endInPixels: 75 },
{ recordId: 'fifth', startInPixels: 30, endInPixels: 90 },
]);
expect(
layouts.map(({ recordId, columnIndex, columnCount }) => ({
recordId,
columnIndex,
columnCount,
})),
).toEqual([
{ recordId: 'first', columnIndex: 0, columnCount: 5 },
{ recordId: 'second', columnIndex: 1, columnCount: 5 },
{ recordId: 'third', columnIndex: 2, columnCount: 5 },
{ recordId: 'fourth', columnIndex: 3, columnCount: 5 },
{ recordId: 'fifth', columnIndex: 4, columnCount: 5 },
]);
});
it('reuses a free column within an overlapping group', () => {
const layouts = computeRecordCalendarWeekEventLayouts([
{ recordId: 'first', startInPixels: 0, endInPixels: 44 },
{ recordId: 'second', startInPixels: 10, endInPixels: 54 },
{ recordId: 'third', startInPixels: 44, endInPixels: 88 },
]);
expect(
layouts.map(({ recordId, columnIndex, columnCount }) => ({
recordId,
columnIndex,
columnCount,
})),
).toEqual([
{ recordId: 'first', columnIndex: 0, columnCount: 2 },
{ recordId: 'second', columnIndex: 1, columnCount: 2 },
{ recordId: 'third', columnIndex: 0, columnCount: 2 },
]);
});
it('keeps a long event in the first column while shorter events reuse the overlay column', () => {
const layouts = computeRecordCalendarWeekEventLayouts([
{ recordId: 'long', startInPixels: 0, endInPixels: 180 },
{ recordId: 'first-short', startInPixels: 30, endInPixels: 60 },
{ recordId: 'second-short', startInPixels: 60, endInPixels: 90 },
]);
expect(
layouts.map(({ recordId, columnIndex, columnCount }) => ({
recordId,
columnIndex,
columnCount,
})),
).toEqual([
{ recordId: 'long', columnIndex: 0, columnCount: 2 },
{ recordId: 'first-short', columnIndex: 1, columnCount: 2 },
{ recordId: 'second-short', columnIndex: 1, columnCount: 2 },
]);
});
it('places the longer event behind shorter events with the same start', () => {
const layouts = computeRecordCalendarWeekEventLayouts([
{ recordId: 'short', startInPixels: 0, endInPixels: 60 },
{ recordId: 'long', startInPixels: 0, endInPixels: 180 },
]);
expect(
layouts.map(({ recordId, columnIndex, columnCount }) => ({
recordId,
columnIndex,
columnCount,
})),
).toEqual([
{ recordId: 'long', columnIndex: 0, columnCount: 2 },
{ recordId: 'short', columnIndex: 1, columnCount: 2 },
]);
});
it('sorts events before assigning columns', () => {
const layouts = computeRecordCalendarWeekEventLayouts([
{ recordId: 'second', startInPixels: 10, endInPixels: 54 },
{ recordId: 'first', startInPixels: 0, endInPixels: 44 },
]);
expect(layouts.map(({ recordId }) => recordId)).toEqual([
'first',
'second',
]);
});
});
@@ -0,0 +1,50 @@
import { formatRecordCalendarWeekEventTimes } from '@/object-record/record-calendar/week/utils/formatRecordCalendarWeekEventTimes';
const timeZone = 'Europe/Paris';
const timeFormat = 'HH:mm';
describe('formatRecordCalendarWeekEventTimes', () => {
it('formats the start time and configured time range', () => {
expect(
formatRecordCalendarWeekEventTimes({
startDateTime: '2026-07-08T15:59:00Z',
endDateTime: '2026-07-10T18:59:00Z',
timeFormat,
timeZone,
}),
).toEqual({
startTime: '17:59',
timeRange: '17:59 - 20:59',
});
});
it.each([
['missing', undefined],
['null', null],
['invalid', 'not-a-date'],
['equal to the start', '2026-07-08T15:59:00Z'],
['before the start', '2026-07-08T14:59:00Z'],
])('uses only the start time when the end is %s', (_label, endDateTime) => {
expect(
formatRecordCalendarWeekEventTimes({
startDateTime: '2026-07-08T15:59:00Z',
endDateTime,
timeFormat,
timeZone,
}),
).toEqual({
startTime: '17:59',
timeRange: '17:59',
});
});
it('returns null when the start is unusable', () => {
expect(
formatRecordCalendarWeekEventTimes({
startDateTime: 'not-a-date',
timeFormat,
timeZone,
}),
).toBeNull();
});
});
@@ -0,0 +1,35 @@
import { formatRecordCalendarWeekRange } from '@/object-record/record-calendar/week/utils/formatRecordCalendarWeekRange';
import { enUS } from 'date-fns/locale';
import { Temporal } from 'temporal-polyfill';
describe('formatRecordCalendarWeekRange', () => {
it('formats a week within one month', () => {
expect(
formatRecordCalendarWeekRange({
firstDayOfWeek: Temporal.PlainDate.from('2026-07-06'),
lastDayOfWeek: Temporal.PlainDate.from('2026-07-12'),
locale: enUS,
}),
).toBe('Jul 6 12, 2026');
});
it('formats a week spanning two months', () => {
expect(
formatRecordCalendarWeekRange({
firstDayOfWeek: Temporal.PlainDate.from('2026-06-29'),
lastDayOfWeek: Temporal.PlainDate.from('2026-07-05'),
locale: enUS,
}),
).toBe('Jun 29 Jul 5, 2026');
});
it('formats a week spanning two years', () => {
expect(
formatRecordCalendarWeekRange({
firstDayOfWeek: Temporal.PlainDate.from('2025-12-29'),
lastDayOfWeek: Temporal.PlainDate.from('2026-01-04'),
locale: enUS,
}),
).toBe('Dec 29, 2025 Jan 4, 2026');
});
});
@@ -0,0 +1,93 @@
import { getRecordCalendarWeekEventDropDateTime } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekEventDropDateTime';
import { Temporal } from 'temporal-polyfill';
describe('getRecordCalendarWeekEventDropDateTime', () => {
it('moves a multi-day event start to the drop position from any fragment', () => {
expect(
getRecordCalendarWeekEventDropDateTime({
destinationDay: Temporal.PlainDate.from('2026-07-10'),
destinationMinutes: 10 * 60,
startDateTime: '2026-07-07T07:00:00Z',
endDateTime: '2026-07-09T10:00:00Z',
timeZone: 'Europe/Paris',
}),
).toEqual({
startDateTime: '2026-07-10T08:00:00Z',
endDateTime: '2026-07-12T11:00:00Z',
});
});
it('lands at the drop time when the original start is the later repeated DST time', () => {
expect(
getRecordCalendarWeekEventDropDateTime({
destinationDay: Temporal.PlainDate.from('2026-10-26'),
destinationMinutes: 10 * 60,
startDateTime: '2026-10-25T01:30:00Z',
endDateTime: '2026-10-25T02:30:00Z',
timeZone: 'Europe/Paris',
}),
).toEqual({
startDateTime: '2026-10-26T09:00:00Z',
endDateTime: '2026-10-26T10:00:00Z',
});
});
it('shifts an end that is equal to the start', () => {
expect(
getRecordCalendarWeekEventDropDateTime({
destinationDay: Temporal.PlainDate.from('2026-07-10'),
destinationMinutes: 10 * 60,
startDateTime: '2026-07-07T07:00:00Z',
endDateTime: '2026-07-07T07:00:00Z',
timeZone: 'Europe/Paris',
}),
).toEqual({
startDateTime: '2026-07-10T08:00:00Z',
endDateTime: '2026-07-10T08:00:00Z',
});
});
it.each([undefined, null, 42])(
'returns null for a non-string start: %s',
(startDateTime) => {
expect(
getRecordCalendarWeekEventDropDateTime({
destinationDay: Temporal.PlainDate.from('2026-07-10'),
destinationMinutes: 10 * 60,
startDateTime,
timeZone: 'Europe/Paris',
}),
).toBeNull();
},
);
it('returns null for a malformed start', () => {
expect(
getRecordCalendarWeekEventDropDateTime({
destinationDay: Temporal.PlainDate.from('2026-07-10'),
destinationMinutes: 10 * 60,
startDateTime: 'not-a-date',
timeZone: 'Europe/Paris',
}),
).toBeNull();
});
it.each([
['missing', undefined],
['non-string', 42],
['malformed', 'not-a-date'],
['before the start', '2026-07-07T06:59:00Z'],
])('moves only the start when the end is %s', (_label, endDateTime) => {
expect(
getRecordCalendarWeekEventDropDateTime({
destinationDay: Temporal.PlainDate.from('2026-07-10'),
destinationMinutes: 10 * 60,
startDateTime: '2026-07-07T07:00:00Z',
endDateTime,
timeZone: 'Europe/Paris',
}),
).toEqual({
startDateTime: '2026-07-10T08:00:00Z',
});
});
});
@@ -0,0 +1,59 @@
import { getRecordCalendarWeekEventHorizontalPosition } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekEventHorizontalPosition';
describe('getRecordCalendarWeekEventHorizontalPosition', () => {
it('uses the available width for a non-overlapping event', () => {
expect(
getRecordCalendarWeekEventHorizontalPosition({
columnCount: 1,
columnIndex: 0,
}),
).toEqual({
hoverStackingOrder: 2,
left: 'calc(0% + 4px)',
stackingOrder: 1,
width: 'calc(100% - 8px)',
});
});
it('cascades overlapping events from their column to the right edge', () => {
expect(
Array.from({ length: 5 }, (_, columnIndex) =>
getRecordCalendarWeekEventHorizontalPosition({
columnCount: 5,
columnIndex,
}),
),
).toEqual([
{
hoverStackingOrder: 6,
left: 'calc(0% + 4px)',
stackingOrder: 1,
width: 'calc(100% - 8px)',
},
{
hoverStackingOrder: 6,
left: 'calc(20% + 2.8px)',
stackingOrder: 2,
width: 'calc(80% - 6.8px)',
},
{
hoverStackingOrder: 6,
left: 'calc(40% + 1.6px)',
stackingOrder: 3,
width: 'calc(60% - 5.6px)',
},
{
hoverStackingOrder: 6,
left: 'calc(60% + 0.4px)',
stackingOrder: 4,
width: 'calc(40% - 4.4px)',
},
{
hoverStackingOrder: 6,
left: 'calc(80% - 0.8px)',
stackingOrder: 5,
width: 'calc(20% - 3.2px)',
},
]);
});
});
@@ -0,0 +1,35 @@
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
import { getRecordCalendarWeekSlotIndex } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekSlotIndex';
const columnTop = 100;
const columnHeight = RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight;
describe('getRecordCalendarWeekSlotIndex', () => {
it.each([
[columnTop, 0],
[columnTop + 23.9, 0],
[columnTop + 24, 1],
[columnTop + columnHeight - 0.1, 47],
])('maps pointer position %s to slot %s', (pointerY, expectedSlot) => {
expect(
getRecordCalendarWeekSlotIndex({
columnHeight,
columnTop,
pointerY,
}),
).toBe(expectedSlot);
});
it.each([columnTop - 0.1, columnTop + columnHeight])(
'returns null outside the day column at %s',
(pointerY) => {
expect(
getRecordCalendarWeekSlotIndex({
columnHeight,
columnTop,
pointerY,
}),
).toBeNull();
},
);
});
@@ -0,0 +1,159 @@
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
import {
getRecordCalendarWeekTimedEventHeight,
getRecordCalendarWeekTimedEventMetrics,
} from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekTimedEventMetrics';
import { Temporal } from 'temporal-polyfill';
const timeZone = 'Europe/Paris';
const day = Temporal.PlainDate.from('2026-07-06');
describe('getRecordCalendarWeekTimedEventMetrics', () => {
it('uses the configured duration', () => {
expect(
getRecordCalendarWeekTimedEventMetrics({
day,
startDateTime: '2026-07-06T07:00:00Z',
endDateTime: '2026-07-06T08:30:00Z',
timeZone,
}),
).toEqual({
startInPixels: 9 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
endInPixels: 10.5 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
});
});
it('renders a one-hour event with the vertical inset from the design', () => {
const metrics = getRecordCalendarWeekTimedEventMetrics({
day,
startDateTime: '2026-07-06T07:00:00Z',
timeZone,
});
expect(getRecordCalendarWeekTimedEventHeight(metrics!)).toBe(40);
});
it.each([undefined, null, 'not-a-date', '2026-07-06T06:00:00Z'])(
'falls back to one hour for an unusable end value: %s',
(endDateTime) => {
expect(
getRecordCalendarWeekTimedEventMetrics({
day,
startDateTime: '2026-07-06T07:00:00Z',
endDateTime,
timeZone,
}),
).toEqual({
startInPixels: 9 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
endInPixels: 10 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
});
},
);
it('keeps short events large enough to interact with', () => {
const metrics = getRecordCalendarWeekTimedEventMetrics({
day,
startDateTime: '2026-07-06T07:00:00Z',
endDateTime: '2026-07-06T07:05:00Z',
timeZone,
});
expect(metrics).toEqual({
startInPixels: 9 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
endInPixels: 9.5 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
});
expect(getRecordCalendarWeekTimedEventHeight(metrics!)).toBe(
RECORD_CALENDAR_WEEK_DIMENSIONS.minimumEventSlotHeight,
);
});
it('renders the start-day fragment of a multi-day event to midnight', () => {
expect(
getRecordCalendarWeekTimedEventMetrics({
day: Temporal.PlainDate.from('2026-07-08'),
startDateTime: '2026-07-08T15:59:00Z',
endDateTime: '2026-07-10T18:59:00Z',
timeZone,
}),
).toEqual({
startInPixels:
(17 + 59 / 60) * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
endInPixels: RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight,
});
});
it('renders a full-day middle fragment of a multi-day event', () => {
expect(
getRecordCalendarWeekTimedEventMetrics({
day: Temporal.PlainDate.from('2026-07-09'),
startDateTime: '2026-07-08T15:59:00Z',
endDateTime: '2026-07-10T18:59:00Z',
timeZone,
}),
).toEqual({
startInPixels: 0,
endInPixels: RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight,
});
});
it('renders the end-day fragment of a multi-day event to its end time', () => {
expect(
getRecordCalendarWeekTimedEventMetrics({
day: Temporal.PlainDate.from('2026-07-10'),
startDateTime: '2026-07-08T15:59:00Z',
endDateTime: '2026-07-10T18:59:00Z',
timeZone,
}),
).toEqual({
startInPixels: 0,
endInPixels: (20 + 59 / 60) * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
});
});
it('does not render a multi-day event outside its range', () => {
expect(
getRecordCalendarWeekTimedEventMetrics({
day: Temporal.PlainDate.from('2026-07-11'),
startDateTime: '2026-07-08T15:59:00Z',
endDateTime: '2026-07-10T18:59:00Z',
timeZone,
}),
).toBeNull();
});
it('clips the one-hour fallback at the grid boundary', () => {
expect(
getRecordCalendarWeekTimedEventMetrics({
day,
startDateTime: '2026-07-06T21:30:00Z',
timeZone,
}),
).toEqual({
startInPixels: 23.5 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
endInPixels: RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight,
});
});
it('positions instants using the configured timezone', () => {
expect(
getRecordCalendarWeekTimedEventMetrics({
day,
startDateTime: '2026-07-06T09:00:00Z',
timeZone,
}),
).toEqual({
startInPixels: 11 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
endInPixels: 12 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
});
});
it('returns null for an unusable start value', () => {
expect(
getRecordCalendarWeekTimedEventMetrics({
day,
startDateTime: 'not-a-date',
timeZone,
}),
).toBeNull();
});
});
@@ -0,0 +1,81 @@
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
import { resolveRecordCalendarWeekEventDrop } from '@/object-record/record-calendar/week/utils/resolveRecordCalendarWeekEventDrop';
const gridRect = {
height: RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight,
left: 100,
top: 200,
width: 756,
};
describe('resolveRecordCalendarWeekEventDrop', () => {
it('resolves a day and snaps the event start to 30 minutes', () => {
expect(
resolveRecordCalendarWeekEventDrop({
dayCount: 7,
grabOffsetY: 10,
gridRect,
pointerX:
gridRect.left +
RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth +
2.5 * 100,
pointerY:
gridRect.top + 10 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight + 17,
}),
).toEqual({
dayIndex: 2,
destinationMinutes: 10 * 60,
});
});
it.each([
['time gutter', gridRect.left + 20, gridRect.top + 100],
['left of grid', gridRect.left - 1, gridRect.top + 100],
['right of grid', gridRect.left + gridRect.width, gridRect.top + 100],
[
'above grid',
gridRect.left + RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth + 10,
gridRect.top - 1,
],
[
'below grid',
gridRect.left + RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth + 10,
gridRect.top + gridRect.height,
],
])('rejects a drop in the %s', (_label, pointerX, pointerY) => {
expect(
resolveRecordCalendarWeekEventDrop({
dayCount: 7,
grabOffsetY: 0,
gridRect,
pointerX,
pointerY,
}),
).toBeNull();
});
it('clamps the first event start to midnight', () => {
expect(
resolveRecordCalendarWeekEventDrop({
dayCount: 7,
grabOffsetY: 20,
gridRect,
pointerX:
gridRect.left + RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth + 10,
pointerY: gridRect.top + 1,
}),
).toMatchObject({ destinationMinutes: 0 });
});
it('clamps the last event start to 23:30', () => {
expect(
resolveRecordCalendarWeekEventDrop({
dayCount: 7,
grabOffsetY: 0,
gridRect,
pointerX: gridRect.left + gridRect.width - 1,
pointerY: gridRect.top + gridRect.height - 1,
}),
).toEqual({ dayIndex: 6, destinationMinutes: 23.5 * 60 });
});
});
@@ -0,0 +1,105 @@
import { updateRecordCalendarWeekActiveSlot } from '@/object-record/record-calendar/week/utils/updateRecordCalendarWeekActiveSlot';
describe('updateRecordCalendarWeekActiveSlot', () => {
it('replaces the active slot when another day becomes active', () => {
expect(
updateRecordCalendarWeekActiveSlot({
currentActiveSlot: {
day: '2026-07-13',
interactionMode: 'pointer',
slotIndex: 18,
},
day: '2026-07-14',
interactionMode: 'pointer',
slotIndex: 20,
}),
).toEqual({
day: '2026-07-14',
interactionMode: 'pointer',
slotIndex: 20,
});
});
it('clears the active day', () => {
expect(
updateRecordCalendarWeekActiveSlot({
currentActiveSlot: {
day: '2026-07-13',
interactionMode: 'pointer',
slotIndex: 18,
},
day: '2026-07-13',
interactionMode: 'pointer',
slotIndex: null,
}),
).toBeNull();
});
it('does not clear another active day', () => {
const currentActiveSlot = {
day: '2026-07-14',
interactionMode: 'pointer' as const,
slotIndex: 20,
};
expect(
updateRecordCalendarWeekActiveSlot({
currentActiveSlot,
day: '2026-07-13',
interactionMode: 'pointer',
slotIndex: null,
}),
).toBe(currentActiveSlot);
});
it('preserves the active slot reference when the pointer stays in one slot', () => {
const currentActiveSlot = {
day: '2026-07-13',
interactionMode: 'pointer' as const,
slotIndex: 18,
};
expect(
updateRecordCalendarWeekActiveSlot({
currentActiveSlot,
day: '2026-07-13',
interactionMode: 'pointer',
slotIndex: 18,
}),
).toBe(currentActiveSlot);
});
it('keeps a keyboard slot active while the pointer moves', () => {
const currentActiveSlot = {
day: '2026-07-13',
interactionMode: 'keyboard' as const,
slotIndex: 18,
};
expect(
updateRecordCalendarWeekActiveSlot({
currentActiveSlot,
day: '2026-07-14',
interactionMode: 'pointer',
slotIndex: 20,
}),
).toBe(currentActiveSlot);
});
it('does not clear a keyboard slot on pointer leave', () => {
const currentActiveSlot = {
day: '2026-07-13',
interactionMode: 'keyboard' as const,
slotIndex: 18,
};
expect(
updateRecordCalendarWeekActiveSlot({
currentActiveSlot,
day: '2026-07-13',
interactionMode: 'pointer',
slotIndex: null,
}),
).toBe(currentActiveSlot);
});
});
@@ -0,0 +1,81 @@
export type RecordCalendarWeekEventLayoutInput = {
endInPixels: number;
recordId: string;
startInPixels: number;
};
export type RecordCalendarWeekEventLayout =
RecordCalendarWeekEventLayoutInput & {
columnCount: number;
columnIndex: number;
};
const layoutOverlappingEventGroup = (
events: RecordCalendarWeekEventLayoutInput[],
): RecordCalendarWeekEventLayout[] => {
const columnEndPositions: number[] = [];
const layouts = events.map((event) => {
const availableColumnIndex = columnEndPositions.findIndex(
(endInPixels) => endInPixels <= event.startInPixels,
);
const columnIndex =
availableColumnIndex === -1
? columnEndPositions.length
: availableColumnIndex;
columnEndPositions[columnIndex] = event.endInPixels;
return {
...event,
columnCount: 0,
columnIndex,
};
});
const columnCount = columnEndPositions.length;
return layouts.map((layout) => ({
...layout,
columnCount,
}));
};
export const computeRecordCalendarWeekEventLayouts = (
events: RecordCalendarWeekEventLayoutInput[],
): RecordCalendarWeekEventLayout[] => {
const sortedEvents = [...events].sort(
(eventA, eventB) =>
eventA.startInPixels - eventB.startInPixels ||
eventB.endInPixels - eventA.endInPixels ||
eventA.recordId.localeCompare(eventB.recordId),
);
const overlappingEventGroups: RecordCalendarWeekEventLayoutInput[][] = [];
let currentGroup: RecordCalendarWeekEventLayoutInput[] = [];
let currentGroupEndInPixels = Number.NEGATIVE_INFINITY;
for (const event of sortedEvents) {
if (
currentGroup.length > 0 &&
event.startInPixels >= currentGroupEndInPixels
) {
overlappingEventGroups.push(currentGroup);
currentGroup = [];
currentGroupEndInPixels = Number.NEGATIVE_INFINITY;
}
currentGroup.push(event);
currentGroupEndInPixels = Math.max(
currentGroupEndInPixels,
event.endInPixels,
);
}
if (currentGroup.length > 0) {
overlappingEventGroups.push(currentGroup);
}
return overlappingEventGroups.flatMap(layoutOverlappingEventGroup);
};
@@ -0,0 +1,49 @@
import { getRecordCalendarDateTimeRange } from '@/object-record/record-calendar/utils/getRecordCalendarDateTimeRange';
import { formatInTimeZone } from 'date-fns-tz';
type FormatRecordCalendarWeekEventTimesArgs = {
endDateTime?: unknown;
startDateTime: unknown;
timeFormat: string;
timeZone: string;
};
export type RecordCalendarWeekEventTimes = {
startTime: string;
timeRange: string;
};
export const formatRecordCalendarWeekEventTimes = ({
endDateTime,
startDateTime,
timeFormat,
timeZone,
}: FormatRecordCalendarWeekEventTimesArgs): RecordCalendarWeekEventTimes | null => {
const range = getRecordCalendarDateTimeRange({
endDateTime,
startDateTime,
timeZone,
});
if (range === null) {
return null;
}
const startTime = formatInTimeZone(
range.start.epochMilliseconds,
timeZone,
timeFormat,
);
if (range.isEndDateTimeFallback) {
return { startTime, timeRange: startTime };
}
const endTime = formatInTimeZone(
range.end.epochMilliseconds,
timeZone,
timeFormat,
);
return { startTime, timeRange: `${startTime} - ${endTime}` };
};
@@ -0,0 +1,41 @@
import { format, type Locale } from 'date-fns';
import { type Temporal } from 'temporal-polyfill';
import { turnPlainDateToShiftedDateInSystemTimeZone } from 'twenty-shared/utils';
type FormatRecordCalendarWeekRangeArgs = {
firstDayOfWeek: Temporal.PlainDate;
lastDayOfWeek: Temporal.PlainDate;
locale: Locale;
};
export const formatRecordCalendarWeekRange = ({
firstDayOfWeek,
lastDayOfWeek,
locale,
}: FormatRecordCalendarWeekRangeArgs) => {
const firstDay = turnPlainDateToShiftedDateInSystemTimeZone(firstDayOfWeek);
const lastDay = turnPlainDateToShiftedDateInSystemTimeZone(lastDayOfWeek);
const formatOptions = { locale };
if (firstDayOfWeek.year !== lastDayOfWeek.year) {
return `${format(firstDay, 'MMM d, yyyy', formatOptions)} ${format(
lastDay,
'MMM d, yyyy',
formatOptions,
)}`;
}
if (firstDayOfWeek.month !== lastDayOfWeek.month) {
return `${format(firstDay, 'MMM d', formatOptions)} ${format(
lastDay,
'MMM d, yyyy',
formatOptions,
)}`;
}
return `${format(firstDay, 'MMM d', formatOptions)} ${format(
lastDay,
'd, yyyy',
formatOptions,
)}`;
};
@@ -0,0 +1,48 @@
import { getShiftedRecordCalendarEndDateTime } from '@/object-record/record-drag/utils/getShiftedRecordCalendarEndDateTime';
import { Temporal } from 'temporal-polyfill';
type GetRecordCalendarWeekEventDropDateTimeArgs = {
destinationDay: Temporal.PlainDate;
destinationMinutes: number;
endDateTime?: unknown;
startDateTime?: unknown;
timeZone: string;
};
export const getRecordCalendarWeekEventDropDateTime = ({
destinationDay,
destinationMinutes,
endDateTime,
startDateTime,
timeZone,
}: GetRecordCalendarWeekEventDropDateTimeArgs) => {
if (typeof startDateTime !== 'string') {
return null;
}
try {
const currentStartInstant = Temporal.Instant.from(startDateTime);
const shiftedStartInstant = destinationDay
.toZonedDateTime({
timeZone,
plainTime: Temporal.PlainTime.from('00:00').add({
nanoseconds: Math.round(destinationMinutes * 60 * 1_000_000_000),
}),
})
.toInstant();
const shiftedEndDateTime = getShiftedRecordCalendarEndDateTime({
endDateTime,
originalStartInstant: currentStartInstant,
shiftedStartInstant,
});
return {
startDateTime: shiftedStartInstant.toString(),
...(shiftedEndDateTime !== undefined && {
endDateTime: shiftedEndDateTime,
}),
};
} catch {
return null;
}
};
@@ -0,0 +1,57 @@
const RECORD_CALENDAR_WEEK_EVENT_HORIZONTAL_INSET = 4;
const RECORD_CALENDAR_WEEK_EVENT_COLUMN_GAP = 2;
const RECORD_CALENDAR_WEEK_EVENT_POSITION_ROUNDING_FACTOR = 10_000;
type GetRecordCalendarWeekEventHorizontalPositionArgs = {
columnCount: number;
columnIndex: number;
};
export type RecordCalendarWeekEventHorizontalPosition = {
hoverStackingOrder: number;
left: string;
stackingOrder: number;
width: string;
};
const roundPositionValue = (value: number) =>
Math.round(value * RECORD_CALENDAR_WEEK_EVENT_POSITION_ROUNDING_FACTOR) /
RECORD_CALENDAR_WEEK_EVENT_POSITION_ROUNDING_FACTOR;
const formatPercentageWithPixelOffset = (
percentage: number,
pixelOffset: number,
) =>
pixelOffset < 0
? `calc(${percentage}% - ${Math.abs(pixelOffset)}px)`
: `calc(${percentage}% + ${pixelOffset}px)`;
export const getRecordCalendarWeekEventHorizontalPosition = ({
columnCount,
columnIndex,
}: GetRecordCalendarWeekEventHorizontalPositionArgs): RecordCalendarWeekEventHorizontalPosition => {
const columnSpan = columnCount - columnIndex;
const leftPercentage = roundPositionValue((columnIndex * 100) / columnCount);
const leftPixelOffset = roundPositionValue(
RECORD_CALENDAR_WEEK_EVENT_HORIZONTAL_INSET -
((RECORD_CALENDAR_WEEK_EVENT_HORIZONTAL_INSET * 2 -
RECORD_CALENDAR_WEEK_EVENT_COLUMN_GAP) *
columnIndex) /
columnCount,
);
const widthPercentage = roundPositionValue((columnSpan * 100) / columnCount);
const widthPixelReduction = roundPositionValue(
RECORD_CALENDAR_WEEK_EVENT_COLUMN_GAP +
((RECORD_CALENDAR_WEEK_EVENT_HORIZONTAL_INSET * 2 -
RECORD_CALENDAR_WEEK_EVENT_COLUMN_GAP) *
columnSpan) /
columnCount,
);
return {
hoverStackingOrder: columnCount + 1,
left: formatPercentageWithPixelOffset(leftPercentage, leftPixelOffset),
stackingOrder: columnIndex + 1,
width: `calc(${widthPercentage}% - ${widthPixelReduction}px)`,
};
};
@@ -0,0 +1,28 @@
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
type GetRecordCalendarWeekSlotIndexArgs = {
columnHeight: number;
columnTop: number;
pointerY: number;
};
export const getRecordCalendarWeekSlotIndex = ({
columnHeight,
columnTop,
pointerY,
}: GetRecordCalendarWeekSlotIndexArgs): number | null => {
if (pointerY < columnTop || pointerY >= columnTop + columnHeight) {
return null;
}
const slotCount =
(RECORD_CALENDAR_WEEK_DIMENSIONS.hoursInDay * 60) /
RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes;
return Math.min(
slotCount - 1,
Math.floor(
(pointerY - columnTop) / RECORD_CALENDAR_WEEK_DIMENSIONS.slotHeight,
),
);
};
@@ -0,0 +1,83 @@
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
import { getRecordCalendarDateTimeRange } from '@/object-record/record-calendar/utils/getRecordCalendarDateTimeRange';
import { Temporal } from 'temporal-polyfill';
type GetRecordCalendarWeekTimedEventMetricsArgs = {
day: Temporal.PlainDate;
endDateTime?: unknown;
startDateTime: unknown;
timeZone: string;
};
export type RecordCalendarWeekTimedEventMetrics = {
endInPixels: number;
startInPixels: number;
};
export const getRecordCalendarWeekTimedEventHeight = ({
endInPixels,
startInPixels,
}: RecordCalendarWeekTimedEventMetrics) =>
Math.max(
RECORD_CALENDAR_WEEK_DIMENSIONS.minimumEventSlotHeight,
endInPixels -
startInPixels -
RECORD_CALENDAR_WEEK_DIMENSIONS.eventVerticalGap,
);
const getTimeOfDayInPixels = (dateTime: Temporal.ZonedDateTime) =>
(dateTime.hour + dateTime.minute / 60 + dateTime.second / 3600) *
RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight;
export const getRecordCalendarWeekTimedEventMetrics = ({
day,
endDateTime,
startDateTime,
timeZone,
}: GetRecordCalendarWeekTimedEventMetricsArgs): RecordCalendarWeekTimedEventMetrics | null => {
const range = getRecordCalendarDateTimeRange({
endDateTime,
startDateTime,
timeZone,
});
if (range === null) {
return null;
}
const dayStart = day.toZonedDateTime({ timeZone });
const nextDayStart = day.add({ days: 1 }).toZonedDateTime({ timeZone });
if (
Temporal.Instant.compare(
range.start.toInstant(),
nextDayStart.toInstant(),
) >= 0 ||
Temporal.Instant.compare(range.end.toInstant(), dayStart.toInstant()) <= 0
) {
return null;
}
const startInPixels =
Temporal.Instant.compare(range.start.toInstant(), dayStart.toInstant()) <= 0
? 0
: getTimeOfDayInPixels(range.start);
let endInPixels =
Temporal.Instant.compare(range.end.toInstant(), nextDayStart.toInstant()) >=
0
? RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight
: getTimeOfDayInPixels(range.end);
endInPixels = Math.min(
RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight,
Math.max(
endInPixels,
startInPixels + RECORD_CALENDAR_WEEK_DIMENSIONS.minimumEventSlotHeight,
),
);
return {
endInPixels,
startInPixels,
};
};
@@ -0,0 +1,65 @@
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
type CalendarGridRect = {
height: number;
left: number;
top: number;
width: number;
};
type ResolveRecordCalendarWeekEventDropArgs = {
dayCount: number;
grabOffsetY: number;
gridRect: CalendarGridRect;
pointerX: number;
pointerY: number;
};
export type RecordCalendarWeekEventDrop = {
dayIndex: number;
destinationMinutes: number;
};
export const resolveRecordCalendarWeekEventDrop = ({
dayCount,
grabOffsetY,
gridRect,
pointerX,
pointerY,
}: ResolveRecordCalendarWeekEventDropArgs): RecordCalendarWeekEventDrop | null => {
const daysLeft =
gridRect.left + RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth;
const daysWidth =
gridRect.width - RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth;
if (
dayCount <= 0 ||
pointerX < daysLeft ||
pointerX >= daysLeft + daysWidth ||
pointerY < gridRect.top ||
pointerY >= gridRect.top + gridRect.height
) {
return null;
}
const dayIndex = Math.floor((pointerX - daysLeft) / (daysWidth / dayCount));
const eventTopInPixels =
pointerY -
gridRect.top -
grabOffsetY -
RECORD_CALENDAR_WEEK_DIMENSIONS.eventVerticalGap / 2;
const unsnappedMinutes =
(eventTopInPixels / RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight) * 60;
const destinationMinutes = Math.min(
24 * 60 - RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes,
Math.max(
0,
Math.round(
unsnappedMinutes /
RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes,
) * RECORD_CALENDAR_WEEK_DIMENSIONS.snapIntervalInMinutes,
),
);
return { dayIndex, destinationMinutes };
};
@@ -0,0 +1,45 @@
export type RecordCalendarWeekActiveSlot = {
day: string;
interactionMode: RecordCalendarWeekSlotInteractionMode;
slotIndex: number;
};
export type RecordCalendarWeekSlotInteractionMode = 'keyboard' | 'pointer';
type UpdateRecordCalendarWeekActiveSlotArgs = {
currentActiveSlot: RecordCalendarWeekActiveSlot | null;
day: string;
interactionMode: RecordCalendarWeekSlotInteractionMode;
slotIndex: number | null;
};
export const updateRecordCalendarWeekActiveSlot = ({
currentActiveSlot,
day,
interactionMode,
slotIndex,
}: UpdateRecordCalendarWeekActiveSlotArgs): RecordCalendarWeekActiveSlot | null => {
if (slotIndex === null) {
return currentActiveSlot?.day === day &&
currentActiveSlot.interactionMode === interactionMode
? null
: currentActiveSlot;
}
if (
currentActiveSlot?.interactionMode === 'keyboard' &&
interactionMode === 'pointer'
) {
return currentActiveSlot;
}
if (
currentActiveSlot?.day === day &&
currentActiveSlot.interactionMode === interactionMode &&
currentActiveSlot.slotIndex === slotIndex
) {
return currentActiveSlot;
}
return { day, interactionMode, slotIndex };
};
@@ -5,12 +5,16 @@ import { useStore } from 'jotai';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
import { getRecordIdFromRecordCalendarCardDraggableId } from '@/object-record/record-calendar/record-calendar-card/utils/getRecordCalendarCardDraggableId';
import { extractRecordPositions } from '@/object-record/record-drag/utils/extractRecordPositions';
import { getShiftedRecordCalendarDateTime } from '@/object-record/record-drag/utils/getShiftedRecordCalendarDateTime';
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { computeNewPositionOfDraggedRecord } from '@/object-record/utils/computeNewPositionOfDraggedRecord';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useAtomComponentFamilySelectorCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorCallbackState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { Temporal } from 'temporal-polyfill';
import { FieldMetadataType } from 'twenty-shared/types';
@@ -21,6 +25,9 @@ export const useProcessCalendarCardDrop = () => {
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
const { currentView } = useGetCurrentViewOnly();
const { updateOneRecord } = useUpdateOneRecord();
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
recordIndexCalendarEndFieldMetadataIdState,
);
const { userTimezone } = useUserTimezone();
@@ -37,11 +44,15 @@ export const useProcessCalendarCardDrop = () => {
)
return;
const { draggableId: recordId } = calendarCardDropResult;
const recordId = getRecordIdFromRecordCalendarCardDraggableId(
calendarCardDropResult.draggableId,
);
const destinationDate = calendarCardDropResult.destination.droppableId;
const destinationIndex = calendarCardDropResult.destination.index;
const sourceDate = calendarCardDropResult.source.droppableId;
const destinationPlainDate = Temporal.PlainDate.from(destinationDate);
const sourcePlainDate = Temporal.PlainDate.from(sourceDate);
const record = store.get(recordStoreFamilyState.atomFamily(recordId));
@@ -50,16 +61,34 @@ export const useProcessCalendarCardDrop = () => {
const calendarFieldMetadata = objectMetadataItem.fields.find(
(field) => field.id === currentView.calendarFieldMetadataId,
);
const calendarEndFieldMetadata = objectMetadataItem.fields.find(
(field) => field.id === recordIndexCalendarEndFieldMetadataId,
);
if (!calendarFieldMetadata) return;
const destinationRecordIds = store.get(
const destinationRecordIdsIncludingDraggedRecord = store.get(
calendarDayRecordIdsSelector({
day: destinationPlainDate,
timeZone: userTimezone,
}),
);
const isCrossDayDrop = sourceDate !== destinationDate;
const draggedRecordIndexInDestination =
destinationRecordIdsIncludingDraggedRecord.indexOf(recordId);
const destinationRecordIds = isCrossDayDrop
? destinationRecordIdsIncludingDraggedRecord.filter(
(destinationRecordId) => destinationRecordId !== recordId,
)
: destinationRecordIdsIncludingDraggedRecord;
const adjustedDestinationIndex =
isCrossDayDrop &&
draggedRecordIndexInDestination !== -1 &&
draggedRecordIndexInDestination < destinationIndex
? destinationIndex - 1
: destinationIndex;
const targetDayIsEmpty = destinationRecordIds.length === 0;
let newPosition: number;
@@ -77,13 +106,13 @@ export const useProcessCalendarCardDrop = () => {
const isDroppedAfterList =
(recordsWithPosition.length === 2 &&
destinationIndex === 1 &&
adjustedDestinationIndex === 1 &&
!droppedRecordIsFromAnotherList) ||
destinationIndex === recordsWithPosition.length;
adjustedDestinationIndex === recordsWithPosition.length;
const targetRecord = isDroppedAfterList
? recordsWithPosition.at(-1)
: recordsWithPosition.at(destinationIndex);
: recordsWithPosition.at(adjustedDestinationIndex);
if (!isDefined(targetRecord)) {
throw new Error(
@@ -104,32 +133,89 @@ export const useProcessCalendarCardDrop = () => {
| undefined;
if (calendarFieldMetadata.type === FieldMetadataType.DATE) {
await updateOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
idToUpdate: recordId,
updateOneRecordInput: {
[calendarFieldMetadata.name]: destinationPlainDate.toString(),
position: newPosition,
},
});
} else if (calendarFieldMetadata.type === FieldMetadataType.DATE_TIME) {
const newDate = isDefined(currentFieldValue)
? Temporal.Instant.from(currentFieldValue)
.toZonedDateTimeISO(userTimezone)
.with({
day: destinationPlainDate.day,
month: destinationPlainDate.month,
year: destinationPlainDate.year,
})
: Temporal.PlainDate.from(destinationPlainDate).toZonedDateTime(
userTimezone,
);
let shiftedStartDate = destinationPlainDate.toString();
let shiftedEndDate: string | undefined;
if (isDefined(currentFieldValue)) {
try {
const currentStartDate = Temporal.PlainDate.from(currentFieldValue);
const dayOffset = sourcePlainDate.until(destinationPlainDate).days;
shiftedStartDate = currentStartDate
.add({ days: dayOffset })
.toString();
if (calendarEndFieldMetadata?.type === FieldMetadataType.DATE) {
const currentEndFieldValue = record[
calendarEndFieldMetadata.name
] as string | undefined;
if (isDefined(currentEndFieldValue)) {
const currentEndDate =
Temporal.PlainDate.from(currentEndFieldValue);
if (
Temporal.PlainDate.compare(
currentEndDate,
currentStartDate,
) >= 0
) {
shiftedEndDate = currentEndDate
.add({ days: dayOffset })
.toString();
}
}
}
} catch {
shiftedStartDate = destinationPlainDate.toString();
shiftedEndDate = undefined;
}
}
await updateOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
idToUpdate: recordId,
updateOneRecordInput: {
[calendarFieldMetadata.name]: newDate.toInstant().toString(),
[calendarFieldMetadata.name]: shiftedStartDate,
...(isDefined(calendarEndFieldMetadata) &&
isDefined(shiftedEndDate) && {
[calendarEndFieldMetadata.name]: shiftedEndDate,
}),
position: newPosition,
},
});
} else if (calendarFieldMetadata.type === FieldMetadataType.DATE_TIME) {
let shiftedDateTime = null;
if (isDefined(currentFieldValue)) {
shiftedDateTime = getShiftedRecordCalendarDateTime({
sourceDay: sourcePlainDate,
destinationDay: destinationPlainDate,
startDateTime: currentFieldValue,
endDateTime:
calendarEndFieldMetadata?.type === FieldMetadataType.DATE_TIME
? record[calendarEndFieldMetadata.name]
: undefined,
timeZone: userTimezone,
});
}
const shiftedStartDateTime =
shiftedDateTime?.startDateTime ??
destinationPlainDate
.toZonedDateTime({ timeZone: userTimezone })
.toInstant()
.toString();
await updateOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
idToUpdate: recordId,
updateOneRecordInput: {
[calendarFieldMetadata.name]: shiftedStartDateTime,
...(isDefined(calendarEndFieldMetadata) &&
isDefined(shiftedDateTime?.endDateTime) && {
[calendarEndFieldMetadata.name]: shiftedDateTime.endDateTime,
}),
position: newPosition,
},
});
@@ -143,6 +229,7 @@ export const useProcessCalendarCardDrop = () => {
calendarDayRecordIdsSelector,
userTimezone,
updateOneRecord,
recordIndexCalendarEndFieldMetadataId,
],
);
@@ -0,0 +1,121 @@
import { getShiftedRecordCalendarDateTime } from '@/object-record/record-drag/utils/getShiftedRecordCalendarDateTime';
import { Temporal } from 'temporal-polyfill';
const timeZone = 'Europe/Paris';
describe('getShiftedRecordCalendarDateTime', () => {
it('moves a timed event by the rendered day offset and preserves duration', () => {
expect(
getShiftedRecordCalendarDateTime({
sourceDay: Temporal.PlainDate.from('2026-07-08'),
destinationDay: Temporal.PlainDate.from('2026-07-11'),
startDateTime: '2026-07-08T15:59:00Z',
endDateTime: '2026-07-10T18:59:00Z',
timeZone,
}),
).toEqual({
startDateTime: '2026-07-11T15:59:00Z',
endDateTime: '2026-07-13T18:59:00Z',
});
});
it('moves a continuation fragment by its visual anchor', () => {
expect(
getShiftedRecordCalendarDateTime({
sourceDay: Temporal.PlainDate.from('2026-07-10'),
destinationDay: Temporal.PlainDate.from('2026-07-11'),
startDateTime: '2026-07-08T15:59:00Z',
endDateTime: '2026-07-10T18:59:00Z',
timeZone,
}),
).toEqual({
startDateTime: '2026-07-09T15:59:00Z',
endDateTime: '2026-07-11T18:59:00Z',
});
});
it('lands on the requested local time and preserves duration across DST', () => {
expect(
getShiftedRecordCalendarDateTime({
sourceDay: Temporal.PlainDate.from('2026-03-28'),
destinationDay: Temporal.PlainDate.from('2026-03-29'),
startDateTime: '2026-03-28T09:00:00Z',
endDateTime: '2026-03-28T11:00:00Z',
timeZone,
}),
).toEqual({
startDateTime: '2026-03-29T08:00:00Z',
endDateTime: '2026-03-29T10:00:00Z',
});
});
it('preserves the later offset when the source start is a repeated DST time', () => {
expect(
getShiftedRecordCalendarDateTime({
sourceDay: Temporal.PlainDate.from('2026-10-25'),
destinationDay: Temporal.PlainDate.from('2026-10-26'),
startDateTime: '2026-10-25T01:30:00Z',
endDateTime: '2026-10-25T02:30:00Z',
timeZone,
}),
).toEqual({
startDateTime: '2026-10-26T01:30:00Z',
endDateTime: '2026-10-26T02:30:00Z',
});
});
it('keeps the local start time when a continuation moves across DST', () => {
expect(
getShiftedRecordCalendarDateTime({
sourceDay: Temporal.PlainDate.from('2026-03-29'),
destinationDay: Temporal.PlainDate.from('2026-03-30'),
startDateTime: '2026-03-28T09:00:00Z',
endDateTime: '2026-03-30T10:00:00Z',
timeZone,
}),
).toEqual({
startDateTime: '2026-03-29T08:00:00Z',
endDateTime: '2026-03-31T09:00:00Z',
});
});
it('does not synthesize an end when it is unusable', () => {
expect(
getShiftedRecordCalendarDateTime({
sourceDay: Temporal.PlainDate.from('2026-07-08'),
destinationDay: Temporal.PlainDate.from('2026-07-09'),
startDateTime: '2026-07-08T15:59:00Z',
endDateTime: 'not-a-date',
timeZone,
}),
).toEqual({
startDateTime: '2026-07-09T15:59:00Z',
});
});
it('shifts an end that is equal to the start', () => {
expect(
getShiftedRecordCalendarDateTime({
sourceDay: Temporal.PlainDate.from('2026-07-08'),
destinationDay: Temporal.PlainDate.from('2026-07-09'),
startDateTime: '2026-07-08T15:59:00Z',
endDateTime: '2026-07-08T15:59:00Z',
timeZone,
}),
).toEqual({
startDateTime: '2026-07-09T15:59:00Z',
endDateTime: '2026-07-09T15:59:00Z',
});
});
it('returns null for an unusable start', () => {
expect(
getShiftedRecordCalendarDateTime({
sourceDay: Temporal.PlainDate.from('2026-07-08'),
destinationDay: Temporal.PlainDate.from('2026-07-09'),
startDateTime: 'not-a-date',
timeZone,
}),
).toBeNull();
});
});
@@ -0,0 +1,43 @@
import { getShiftedRecordCalendarEndDateTime } from '@/object-record/record-drag/utils/getShiftedRecordCalendarEndDateTime';
import { Temporal } from 'temporal-polyfill';
const originalStartInstant = Temporal.Instant.from('2026-07-07T07:00:00Z');
const shiftedStartInstant = Temporal.Instant.from('2026-07-10T08:00:00Z');
describe('getShiftedRecordCalendarEndDateTime', () => {
it('preserves the original elapsed duration from the shifted start', () => {
expect(
getShiftedRecordCalendarEndDateTime({
endDateTime: '2026-07-09T10:00:00Z',
originalStartInstant,
shiftedStartInstant,
}),
).toBe('2026-07-12T11:00:00Z');
});
it('preserves a zero-duration end', () => {
expect(
getShiftedRecordCalendarEndDateTime({
endDateTime: originalStartInstant.toString(),
originalStartInstant,
shiftedStartInstant,
}),
).toBe(shiftedStartInstant.toString());
});
it.each([
['missing', undefined],
['null', null],
['non-string', 42],
['malformed', 'not-a-date'],
['before the start', '2026-07-07T06:59:00Z'],
])('returns undefined when the end is %s', (_label, endDateTime) => {
expect(
getShiftedRecordCalendarEndDateTime({
endDateTime,
originalStartInstant,
shiftedStartInstant,
}),
).toBeUndefined();
});
});
@@ -0,0 +1,51 @@
import { getShiftedRecordCalendarEndDateTime } from '@/object-record/record-drag/utils/getShiftedRecordCalendarEndDateTime';
import { Temporal } from 'temporal-polyfill';
type GetShiftedRecordCalendarDateTimeArgs = {
destinationDay: Temporal.PlainDate;
endDateTime?: unknown;
sourceDay: Temporal.PlainDate;
startDateTime?: unknown;
timeZone: string;
};
export type ShiftedRecordCalendarDateTime = {
endDateTime?: string;
startDateTime: string;
};
export const getShiftedRecordCalendarDateTime = ({
destinationDay,
endDateTime,
sourceDay,
startDateTime,
timeZone,
}: GetShiftedRecordCalendarDateTimeArgs): ShiftedRecordCalendarDateTime | null => {
if (typeof startDateTime !== 'string') {
return null;
}
try {
const currentStartInstant = Temporal.Instant.from(startDateTime);
const dayOffset = sourceDay.until(destinationDay).days;
const shiftedStartInstant = currentStartInstant
.toZonedDateTimeISO(timeZone)
.add({ days: dayOffset })
.toInstant();
const shiftedEndDateTime = getShiftedRecordCalendarEndDateTime({
endDateTime,
originalStartInstant: currentStartInstant,
shiftedStartInstant,
});
return {
startDateTime: shiftedStartInstant.toString(),
...(shiftedEndDateTime !== undefined && {
endDateTime: shiftedEndDateTime,
}),
};
} catch {
return null;
}
};
@@ -0,0 +1,35 @@
import { Temporal } from 'temporal-polyfill';
type GetShiftedRecordCalendarEndDateTimeArgs = {
endDateTime?: unknown;
originalStartInstant: Temporal.Instant;
shiftedStartInstant: Temporal.Instant;
};
export const getShiftedRecordCalendarEndDateTime = ({
endDateTime,
originalStartInstant,
shiftedStartInstant,
}: GetShiftedRecordCalendarEndDateTimeArgs): string | undefined => {
if (typeof endDateTime !== 'string') {
return undefined;
}
try {
const originalEndInstant = Temporal.Instant.from(endDateTime);
if (
Temporal.Instant.compare(originalEndInstant, originalStartInstant) < 0
) {
return undefined;
}
return Temporal.Instant.fromEpochNanoseconds(
shiftedStartInstant.epochNanoseconds +
(originalEndInstant.epochNanoseconds -
originalStartInstant.epochNanoseconds),
).toString();
} catch {
return undefined;
}
};
@@ -17,12 +17,12 @@ import { filterDuplicatesById, isDefined } from 'twenty-shared/utils';
type UseRecordsUsefulGqlFields = {
objectMetadataItem: EnrichedObjectMetadataItem;
additionalFieldMetadataId?: string | null;
additionalFieldMetadataIds?: Array<string | null | undefined>;
};
export const useRelevantRecordsGqlFields = ({
objectMetadataItem,
additionalFieldMetadataId,
additionalFieldMetadataIds = [],
}: UseRecordsUsefulGqlFields) => {
const visibleRecordFields = useAtomComponentSelectorValue(
visibleRecordFieldsComponentSelector,
@@ -52,16 +52,18 @@ export const useRelevantRecordsGqlFields = ({
)
.filter(isDefined);
const additionalFieldMetadataItem = isDefined(additionalFieldMetadataId)
? fieldMetadataItemByFieldMetadataItemId[additionalFieldMetadataId]
: undefined;
const additionalFieldMetadataItems = additionalFieldMetadataIds
.filter(isDefined)
.map(
(fieldMetadataId) =>
fieldMetadataItemByFieldMetadataItemId[fieldMetadataId],
)
.filter(isDefined);
const fieldMetadataItemsToUse = [
...visibleRecordFieldMetadataItems,
...(recordFilterFields ?? []),
...(isDefined(additionalFieldMetadataItem)
? [additionalFieldMetadataItem]
: []),
...additionalFieldMetadataItems,
].filter(filterDuplicatesById);
const allDepthOneGqlFields = generateDepthRecordGqlFieldsFromFields({
@@ -4,19 +4,26 @@ import { useLoadRecordIndexStates } from '@/object-record/record-index/hooks/use
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { viewFromViewIdFamilySelector } from '@/views/states/selectors/viewFromViewIdFamilySelector';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const RecordIndexLoadBaseOnContextStoreEffect = () => {
const { loadRecordIndexStates } = useLoadRecordIndexStates();
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
);
const [loadedViewId, setLoadedViewId] = useState<string | undefined>(
undefined,
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
const currentViewLoadKey = isDefined(contextStoreCurrentViewId)
? `${contextStoreCurrentViewId}-${isCalendarWeekViewEnabled}`
: undefined;
const [loadedViewKey, setLoadedViewKey] = useState<string | undefined>();
const view = useAtomFamilySelectorValue(viewFromViewIdFamilySelector, {
viewId: contextStoreCurrentViewId ?? '',
});
@@ -24,10 +31,7 @@ export const RecordIndexLoadBaseOnContextStoreEffect = () => {
const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
useEffect(() => {
if (
isDefined(contextStoreCurrentViewId) &&
loadedViewId === contextStoreCurrentViewId
) {
if (isDefined(currentViewLoadKey) && loadedViewKey === currentViewLoadKey) {
return;
}
@@ -37,12 +41,12 @@ export const RecordIndexLoadBaseOnContextStoreEffect = () => {
if (isDefined(view)) {
loadRecordIndexStates(view, objectMetadataItem);
setLoadedViewId(contextStoreCurrentViewId);
setLoadedViewKey(currentViewLoadKey);
}
}, [
contextStoreCurrentViewId,
currentViewLoadKey,
loadRecordIndexStates,
loadedViewId,
loadedViewKey,
objectMetadataItem,
view,
]);
@@ -0,0 +1,87 @@
import { render } from '@testing-library/react';
import { RecordIndexLoadBaseOnContextStoreEffect } from '@/object-record/record-index/components/RecordIndexLoadBaseOnContextStoreEffect';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
jest.mock(
'@/object-record/record-index/hooks/useLoadRecordIndexStates',
() => ({
useLoadRecordIndexStates: jest.fn(),
}),
);
jest.mock(
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
() => ({
useAtomComponentStateValue: jest.fn(),
}),
);
jest.mock(
'@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue',
() => ({
useAtomFamilySelectorValue: jest.fn(),
}),
);
jest.mock(
'@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow',
() => ({
useContextStoreObjectMetadataItemOrThrow: jest.fn(),
}),
);
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: jest.fn(),
}));
const useLoadRecordIndexStatesMock = jest.requireMock(
'@/object-record/record-index/hooks/useLoadRecordIndexStates',
).useLoadRecordIndexStates;
const useAtomComponentStateValueMock = jest.requireMock(
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
).useAtomComponentStateValue;
const useAtomFamilySelectorValueMock = jest.requireMock(
'@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue',
).useAtomFamilySelectorValue;
const useContextStoreObjectMetadataItemOrThrowMock = jest.requireMock(
'@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow',
).useContextStoreObjectMetadataItemOrThrow;
const useIsFeatureEnabledMock = jest.requireMock(
'@/workspace/hooks/useIsFeatureEnabled',
).useIsFeatureEnabled;
describe('RecordIndexLoadBaseOnContextStoreEffect', () => {
const loadRecordIndexStates = jest.fn();
const view = { id: 'view-id' };
const objectMetadataItem = { id: 'object-metadata-id' };
beforeEach(() => {
jest.clearAllMocks();
useLoadRecordIndexStatesMock.mockReturnValue({ loadRecordIndexStates });
useAtomComponentStateValueMock.mockReturnValue('view-id');
useAtomFamilySelectorValueMock.mockReturnValue(view);
useContextStoreObjectMetadataItemOrThrowMock.mockReturnValue({
objectMetadataItem,
});
useIsFeatureEnabledMock.mockReturnValue(false);
});
it('reloads the persisted view state when the week-view flag changes', () => {
const { rerender } = render(<RecordIndexLoadBaseOnContextStoreEffect />);
expect(loadRecordIndexStates).toHaveBeenCalledTimes(1);
expect(loadRecordIndexStates).toHaveBeenLastCalledWith(
view,
objectMetadataItem,
);
rerender(<RecordIndexLoadBaseOnContextStoreEffect />);
expect(loadRecordIndexStates).toHaveBeenCalledTimes(1);
useIsFeatureEnabledMock.mockReturnValue(true);
rerender(<RecordIndexLoadBaseOnContextStoreEffect />);
expect(loadRecordIndexStates).toHaveBeenCalledTimes(2);
expect(useIsFeatureEnabledMock).toHaveBeenCalledWith(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
});
});
@@ -11,10 +11,14 @@ import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldM
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useSetRecordGroups } from '@/object-record/record-group/hooks/useSetRecordGroups';
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
import { getEffectiveRecordCalendarEndFieldMetadataId } from '@/object-record/record-calendar/utils/getEffectiveRecordCalendarEndFieldMetadataId';
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { recordIndexCalendarLayoutState } from '@/object-record/record-index/states/recordIndexCalendarLayoutState';
import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth';
import { clampRecordBoardColumnWidth } from '@/object-record/record-board/utils/clampRecordBoardColumnWidth';
import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState';
@@ -38,13 +42,18 @@ import { mapViewFieldToRecordField } from '@/views/utils/mapViewFieldToRecordFie
import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions';
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { atom, useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { isDeeplyEqual } from '~/utils/isDeeplyEqual';
export const useLoadRecordIndexStates = () => {
const store = useStore();
const isCalendarWeekViewEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
const contextStoreTargetedRecordsRuleAtom =
useAtomComponentStateCallbackState(
@@ -323,6 +332,20 @@ export const useLoadRecordIndexStates = () => {
recordIndexCalendarFieldMetadataIdState.atom,
view.calendarFieldMetadataId ?? null,
);
batchSet(
recordIndexCalendarEndFieldMetadataIdState.atom,
getEffectiveRecordCalendarEndFieldMetadataId({
calendarEndFieldMetadataId: view.calendarEndFieldMetadataId,
isCalendarWeekViewEnabled,
}),
);
batchSet(
recordIndexCalendarLayoutState.atom,
getSupportedRecordCalendarLayout({
calendarLayout: view.calendarLayout,
isCalendarWeekViewEnabled,
}),
);
}
batchSet(
@@ -379,6 +402,7 @@ export const useLoadRecordIndexStates = () => {
getFieldMetadataItemByIdOrThrow,
setRecordGroupsFromViewGroups,
syncRecordIndexViewFields,
isCalendarWeekViewEnabled,
],
);
@@ -71,7 +71,7 @@ export const useRecordIndexGroupCommonQueryVariables = () => {
const recordGqlFields = useRelevantRecordsGqlFields({
objectMetadataItem,
additionalFieldMetadataId: recordIndexGroupFieldMetadataItem?.id,
additionalFieldMetadataIds: [recordIndexGroupFieldMetadataItem?.id],
});
const recordGroupDefinitions = useAtomComponentSelectorValue(
@@ -0,0 +1,8 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const recordIndexCalendarEndFieldMetadataIdState = createAtomState<
string | null
>({
key: 'recordIndexCalendarEndFieldMetadataIdState',
defaultValue: null,
});