Add day view support to record calendar (#22922)

## Summary
- Add a calendar day view and wire it into the record calendar layout
selection
- Update the top bar, time grid, and week/day drag and drop handling to
support the new view
- Extend supported layout logic and public feature flags for calendar
day view access
- Add coverage for calendar view content, calendar container behavior,
top bar behavior, day view rendering, supported layout resolution, and
week event drop handling

<img width="1276" height="852" alt="Screenshot 2026-07-15 at 17 48 33"
src="https://github.com/user-attachments/assets/b1d9d255-2d64-4adb-82b9-3e500cb0d561"
/>


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22922?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Weiko
2026-07-15 18:50:23 +02:00
committed by GitHub
parent 2dcf53619f
commit 8cf462d5f7
17 changed files with 1325 additions and 740 deletions
@@ -17,6 +17,7 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import { Pill } from 'twenty-ui/data-display';
import {
IconCalendarEvent,
IconCalendarMonth,
IconCalendarWeek,
IconChevronLeft,
@@ -28,6 +29,8 @@ import {
ViewCalendarLayout,
} from '~/generated-metadata/graphql';
const RECORD_CALENDAR_TIMELINE_VIEW_ID = 'record-calendar-timeline-view';
export const ObjectOptionsDropdownCalendarViewContent = () => {
const { resetContent } = useObjectOptionsDropdown();
const recordIndexCalendarLayout = useAtomStateValue(
@@ -53,16 +56,18 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
const { closeDropdown } = useObjectOptionsDropdown();
const selectableItemIdArray = [
ViewCalendarLayout.DAY,
ViewCalendarLayout.WEEK,
ViewCalendarLayout.MONTH,
ViewCalendarLayout.DAY,
RECORD_CALENDAR_TIMELINE_VIEW_ID,
];
const handleCalendarViewChange = async (calendarView: ViewCalendarLayout) => {
if (
calendarView === ViewCalendarLayout.WEEK &&
!isCalendarWeekViewEnabled
) {
const isTimeGridLayout =
calendarView === ViewCalendarLayout.DAY ||
calendarView === ViewCalendarLayout.WEEK;
if (isTimeGridLayout && !isCalendarWeekViewEnabled) {
return;
}
@@ -96,6 +101,31 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
focusId={OBJECT_OPTIONS_DROPDOWN_ID}
selectableItemIdArray={selectableItemIdArray}
>
<SelectableListItem
itemId={ViewCalendarLayout.DAY}
onEnter={() => {
if (isCalendarWeekViewEnabled) {
handleCalendarViewChange(ViewCalendarLayout.DAY);
}
}}
>
<MenuItemSelect
LeftIcon={IconCalendarEvent}
text={t`Day`}
selected={supportedCalendarLayout === ViewCalendarLayout.DAY}
onClick={
isCalendarWeekViewEnabled
? () => handleCalendarViewChange(ViewCalendarLayout.DAY)
: undefined
}
focused={selectedItemId === ViewCalendarLayout.DAY}
contextualText={
isCalendarWeekViewEnabled ? undefined : <Pill label={t`Soon`} />
}
contextualTextPosition="right"
disabled={!isCalendarWeekViewEnabled}
/>
</SelectableListItem>
<SelectableListItem
itemId={ViewCalendarLayout.WEEK}
onEnter={() => {
@@ -133,15 +163,12 @@ export const ObjectOptionsDropdownCalendarViewContent = () => {
focused={selectedItemId === ViewCalendarLayout.MONTH}
/>
</SelectableListItem>
<SelectableListItem
itemId={ViewCalendarLayout.DAY}
onEnter={() => handleCalendarViewChange(ViewCalendarLayout.DAY)}
>
<SelectableListItem itemId={RECORD_CALENDAR_TIMELINE_VIEW_ID}>
<MenuItemSelect
LeftIcon={IconTimelineEvent}
text={t`Timeline`}
selected={recordIndexCalendarLayout === ViewCalendarLayout.DAY}
focused={selectedItemId === ViewCalendarLayout.DAY}
selected={false}
focused={selectedItemId === RECORD_CALENDAR_TIMELINE_VIEW_ID}
contextualText={<Pill label={t`Soon`} />}
contextualTextPosition="right"
disabled
@@ -0,0 +1,140 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ObjectOptionsDropdownCalendarViewContent } from '@/object-record/object-options-dropdown/components/ObjectOptionsDropdownCalendarViewContent';
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
const mockCloseDropdown = jest.fn();
const mockResetContent = jest.fn();
const mockSetRecordIndexCalendarLayout = jest.fn();
const mockUpdateCurrentView = jest.fn();
const mockUseIsFeatureEnabled = jest.fn();
const mockUseAtomStateValue = jest.fn();
jest.mock(
'@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown',
() => ({
useObjectOptionsDropdown: jest.fn(() => ({
closeDropdown: mockCloseDropdown,
resetContent: mockResetContent,
})),
}),
);
jest.mock('@/ui/layout/dropdown/components/DropdownContent', () => ({
DropdownContent: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock(
'@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader',
() => ({
DropdownMenuHeader: ({ children }: { children: React.ReactNode }) =>
children,
}),
);
jest.mock(
'@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent',
() => ({ DropdownMenuHeaderLeftComponent: () => null }),
);
jest.mock('@/ui/layout/dropdown/components/DropdownMenuItemsContainer', () => ({
DropdownMenuItemsContainer: ({ children }: { children: React.ReactNode }) =>
children,
}));
jest.mock('@/ui/layout/selectable-list/components/SelectableList', () => ({
SelectableList: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock('@/ui/layout/selectable-list/components/SelectableListItem', () => ({
SelectableListItem: ({ children }: { children: React.ReactNode }) => children,
}));
jest.mock(
'@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue',
() => ({ useAtomComponentStateValue: jest.fn(() => null) }),
);
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
useAtomStateValue: (...args: unknown[]) => mockUseAtomStateValue(...args),
}));
jest.mock('@/ui/utilities/state/jotai/hooks/useSetAtomState', () => ({
useSetAtomState: jest.fn(() => mockSetRecordIndexCalendarLayout),
}));
jest.mock('@/views/hooks/useUpdateCurrentView', () => ({
useUpdateCurrentView: jest.fn(() => ({
updateCurrentView: mockUpdateCurrentView,
})),
}));
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: (...args: unknown[]) => mockUseIsFeatureEnabled(...args),
}));
jest.mock('twenty-ui/data-display', () => ({
Pill: ({ label }: { label: string }) => <span>{label}</span>,
}));
jest.mock('twenty-ui/navigation', () => ({
MenuItemSelect: ({
contextualText,
disabled,
onClick,
selected,
text,
}: {
contextualText?: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
selected: boolean;
text: string;
}) => (
<button data-selected={selected} disabled={disabled} onClick={onClick}>
<span>{text}</span>
{contextualText}
</button>
),
}));
describe('ObjectOptionsDropdownCalendarViewContent', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseAtomStateValue.mockReturnValue(ViewCalendarLayout.MONTH);
mockUseIsFeatureEnabled.mockReturnValue(true);
mockUpdateCurrentView.mockResolvedValue(undefined);
});
it('offers Day, Week, and Month while keeping Timeline disabled', () => {
render(<ObjectOptionsDropdownCalendarViewContent />);
expect(
screen
.getAllByRole('button')
.map((button) => button.textContent?.replace('Soon', '')),
).toEqual(['Day', 'Week', 'Month', 'Timeline']);
expect(screen.getByText('Day').closest('button')).toBeEnabled();
expect(screen.getByText('Timeline').closest('button')).toBeDisabled();
});
it('persists Day without treating it as Timeline', async () => {
render(<ObjectOptionsDropdownCalendarViewContent />);
fireEvent.click(screen.getByRole('button', { name: 'Day' }));
expect(mockSetRecordIndexCalendarLayout).toHaveBeenCalledWith(
ViewCalendarLayout.DAY,
);
expect(mockUpdateCurrentView).toHaveBeenCalledWith({
calendarLayout: ViewCalendarLayout.DAY,
});
await waitFor(() => expect(mockCloseDropdown).toHaveBeenCalled());
});
it('keeps Day and Week unavailable when the feature flag is disabled', () => {
mockUseIsFeatureEnabled.mockReturnValue(false);
render(<ObjectOptionsDropdownCalendarViewContent />);
const dayButton = screen.getByText('Day').closest('button');
const weekButton = screen.getByText('Week').closest('button');
expect(dayButton).toBeDisabled();
expect(weekButton).toBeDisabled();
if (dayButton !== null) {
fireEvent.click(dayButton);
}
expect(mockSetRecordIndexCalendarLayout).not.toHaveBeenCalled();
expect(mockUpdateCurrentView).not.toHaveBeenCalled();
});
});
@@ -4,6 +4,7 @@ import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/cons
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { RecordCalendarTopBar } from '@/object-record/record-calendar/components/RecordCalendarTopBar';
import { RECORD_CALENDAR_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-calendar/constants/RecordCalendarClickOutsideListenerId';
import { RecordCalendarDay } from '@/object-record/record-calendar/day/components/RecordCalendarDay';
import { RecordCalendarMonth } from '@/object-record/record-calendar/month/components/RecordCalendarMonth';
import { RecordCalendarWeek } from '@/object-record/record-calendar/week/components/RecordCalendarWeek';
import { RECORD_CALENDAR_CARD_CLICK_OUTSIDE_ID } from '@/object-record/record-calendar/record-calendar-card/constants/RecordCalendarCardClickOutsideId';
@@ -80,7 +81,9 @@ export const RecordCalendar = () => {
<ScrollWrapper
componentInstanceId={`scroll-wrapper-record-calendar-${recordCalendarId}`}
>
{supportedCalendarLayout === ViewCalendarLayout.WEEK ? (
{supportedCalendarLayout === ViewCalendarLayout.DAY ? (
<RecordCalendarDay />
) : supportedCalendarLayout === ViewCalendarLayout.WEEK ? (
<RecordCalendarWeek />
) : (
<RecordCalendarMonth />
@@ -1,3 +1,4 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
import { getSupportedRecordCalendarLayout } from '@/object-record/record-calendar/utils/getSupportedRecordCalendarLayout';
@@ -80,6 +81,7 @@ export const RecordCalendarTopBar = () => {
});
const dateLocale = useAtomStateValue(dateLocaleState);
const { timeZone } = useDateTimeFormat();
const { firstDayOfWeek, lastDayOfWeek } = useRecordCalendarWeekDaysRange(
recordCalendarSelectedDate,
);
@@ -97,26 +99,33 @@ export const RecordCalendarTopBar = () => {
};
const handlePreviousPeriod = () => {
setRecordCalendarSelectedDate(
supportedCalendarLayout === ViewCalendarLayout.WEEK
? recordCalendarSelectedDate.subtract({ weeks: 1 })
: recordCalendarSelectedDate.subtract({ months: 1 }),
);
const previousDate =
supportedCalendarLayout === ViewCalendarLayout.DAY
? recordCalendarSelectedDate.subtract({ days: 1 })
: supportedCalendarLayout === ViewCalendarLayout.WEEK
? recordCalendarSelectedDate.subtract({ weeks: 1 })
: recordCalendarSelectedDate.subtract({ months: 1 });
setRecordCalendarSelectedDate(previousDate);
};
const handleNextPeriod = () => {
setRecordCalendarSelectedDate(
supportedCalendarLayout === ViewCalendarLayout.WEEK
? recordCalendarSelectedDate.add({ weeks: 1 })
: recordCalendarSelectedDate.add({ months: 1 }),
);
const nextDate =
supportedCalendarLayout === ViewCalendarLayout.DAY
? recordCalendarSelectedDate.add({ days: 1 })
: supportedCalendarLayout === ViewCalendarLayout.WEEK
? recordCalendarSelectedDate.add({ weeks: 1 })
: recordCalendarSelectedDate.add({ months: 1 });
setRecordCalendarSelectedDate(nextDate);
};
const handleCalendarLayoutChange = (calendarLayout: ViewCalendarLayout) => {
if (
calendarLayout === ViewCalendarLayout.WEEK &&
!isCalendarWeekViewEnabled
) {
const isTimeGridLayout =
calendarLayout === ViewCalendarLayout.DAY ||
calendarLayout === ViewCalendarLayout.WEEK;
if (isTimeGridLayout && !isCalendarWeekViewEnabled) {
return;
}
@@ -125,23 +134,27 @@ export const RecordCalendarTopBar = () => {
};
const handleTodayClick = () => {
setRecordCalendarSelectedDate(Temporal.Now.plainDateISO());
setRecordCalendarSelectedDate(Temporal.Now.plainDateISO(timeZone));
};
const formattedDate =
supportedCalendarLayout === ViewCalendarLayout.WEEK
? formatRecordCalendarWeekRange({
firstDayOfWeek,
lastDayOfWeek,
locale: dateLocale.localeCatalog,
supportedCalendarLayout === ViewCalendarLayout.DAY
? recordCalendarSelectedDate.toLocaleString(dateLocale.locale, {
dateStyle: 'full',
})
: format(
turnPlainDateToShiftedDateInSystemTimeZone(
recordCalendarSelectedDate,
),
'MMMM yyyy',
{ locale: dateLocale.localeCatalog },
);
: supportedCalendarLayout === ViewCalendarLayout.WEEK
? formatRecordCalendarWeekRange({
firstDayOfWeek,
lastDayOfWeek,
locale: dateLocale.localeCatalog,
})
: format(
turnPlainDateToShiftedDateInSystemTimeZone(
recordCalendarSelectedDate,
),
'MMMM yyyy',
{ locale: dateLocale.localeCatalog },
);
const dropdownContentOffset = { x: 140, y: 0 } satisfies DropdownOffset;
@@ -153,6 +166,7 @@ export const RecordCalendarTopBar = () => {
dropdownId={`record-calendar-layout-${recordCalendarId}`}
value={supportedCalendarLayout}
options={[
{ label: t`Day`, value: ViewCalendarLayout.DAY },
{ label: t`Week`, value: ViewCalendarLayout.WEEK },
{ label: t`Month`, value: ViewCalendarLayout.MONTH },
]}
@@ -186,7 +200,7 @@ export const RecordCalendarTopBar = () => {
}
dropdownOffset={dropdownContentOffset}
/>
{supportedCalendarLayout !== ViewCalendarLayout.WEEK && (
{supportedCalendarLayout === ViewCalendarLayout.MONTH && (
<TimeZoneAbbreviation instant={Temporal.Now.instant()} />
)}
</StyledLeftSection>
@@ -194,6 +208,7 @@ export const RecordCalendarTopBar = () => {
<StyledNavigationSection>
<StyledNavigationButtonContainer>
<Button
ariaLabel={t`Previous period`}
size="small"
variant="tertiary"
Icon={IconChevronLeft}
@@ -208,6 +223,7 @@ export const RecordCalendarTopBar = () => {
/>
<StyledNavigationButtonContainer>
<Button
ariaLabel={t`Next period`}
size="small"
variant="tertiary"
Icon={IconChevronRight}
@@ -12,6 +12,12 @@ jest.mock(
RecordCalendarTopBar: () => <div data-testid="calendar-top-bar" />,
}),
);
jest.mock(
'@/object-record/record-calendar/day/components/RecordCalendarDay',
() => ({
RecordCalendarDay: () => <div data-testid="calendar-day" />,
}),
);
jest.mock(
'@/object-record/record-calendar/month/components/RecordCalendarMonth',
() => ({
@@ -64,16 +70,32 @@ describe('RecordCalendar', () => {
useAtomStateValueMock.mockReturnValue(ViewCalendarLayout.WEEK);
});
it('renders month when a persisted week layout is disabled', () => {
useIsFeatureEnabledMock.mockReturnValue(false);
it.each([ViewCalendarLayout.DAY, ViewCalendarLayout.WEEK])(
'renders month when a persisted %s layout is disabled',
(calendarLayout) => {
useAtomStateValueMock.mockReturnValue(calendarLayout);
useIsFeatureEnabledMock.mockReturnValue(false);
render(<RecordCalendar />);
expect(screen.getByTestId('calendar-month')).toBeInTheDocument();
expect(screen.queryByTestId('calendar-day')).not.toBeInTheDocument();
expect(screen.queryByTestId('calendar-week')).not.toBeInTheDocument();
expect(useIsFeatureEnabledMock).toHaveBeenCalledWith(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
},
);
it('renders day when the day layout is enabled', () => {
useAtomStateValueMock.mockReturnValue(ViewCalendarLayout.DAY);
useIsFeatureEnabledMock.mockReturnValue(true);
render(<RecordCalendar />);
expect(screen.getByTestId('calendar-month')).toBeInTheDocument();
expect(screen.getByTestId('calendar-day')).toBeInTheDocument();
expect(screen.queryByTestId('calendar-week')).not.toBeInTheDocument();
expect(useIsFeatureEnabledMock).toHaveBeenCalledWith(
FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
);
expect(screen.queryByTestId('calendar-month')).not.toBeInTheDocument();
});
it('renders week when the week layout is enabled', () => {
@@ -82,6 +104,7 @@ describe('RecordCalendar', () => {
render(<RecordCalendar />);
expect(screen.getByTestId('calendar-week')).toBeInTheDocument();
expect(screen.queryByTestId('calendar-day')).not.toBeInTheDocument();
expect(screen.queryByTestId('calendar-month')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,151 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { enUS } from 'date-fns/locale';
import { Temporal } from 'temporal-polyfill';
import { RecordCalendarTopBar } from '@/object-record/record-calendar/components/RecordCalendarTopBar';
import { ViewCalendarLayout } from '~/generated-metadata/graphql';
const mockSetRecordCalendarSelectedDate = jest.fn();
const mockSetRecordIndexCalendarLayout = jest.fn();
const mockUpdateCurrentView = jest.fn();
const mockUseAtomComponentState = jest.fn();
const mockUseAtomState = jest.fn();
const mockUseAtomStateValue = jest.fn();
const mockUseRecordCalendarWeekDaysRange = jest.fn();
jest.mock('@/localization/hooks/useDateTimeFormat', () => ({
useDateTimeFormat: jest.fn(() => ({ timeZone: 'UTC' })),
}));
jest.mock(
'@/object-record/record-calendar/week/hooks/useRecordCalendarWeekDaysRange',
() => ({
useRecordCalendarWeekDaysRange: (...args: unknown[]) =>
mockUseRecordCalendarWeekDaysRange(...args),
}),
);
jest.mock(
'@/ui/input/components/internal/date/components/DatePickerWithoutCalendar',
() => ({ DatePickerWithoutCalendar: () => null }),
);
jest.mock(
'@/ui/input/components/internal/date/components/TimeZoneAbbreviation',
() => ({
TimeZoneAbbreviation: () => <span data-testid="time-zone" />,
}),
);
jest.mock('@/ui/input/components/Select', () => ({
Select: ({ options }: { options: { label: string; value: string }[] }) => (
<select data-testid="layout-select">
{options.map(({ label, value }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
),
}));
jest.mock('@/ui/input/components/SelectControl', () => ({
SelectControl: ({
selectedOption,
}: {
selectedOption: { label: string };
}) => <span data-testid="selected-date">{selectedOption.label}</span>,
}));
jest.mock('@/ui/layout/dropdown/components/Dropdown', () => ({
Dropdown: ({ clickableComponent }: { clickableComponent: React.ReactNode }) =>
clickableComponent,
}));
jest.mock('@/ui/layout/dropdown/hooks/useCloseDropdown', () => ({
useCloseDropdown: jest.fn(() => ({ closeDropdown: jest.fn() })),
}));
jest.mock(
'@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow',
() => ({
useAvailableComponentInstanceIdOrThrow: jest.fn(() => 'calendar-id'),
}),
);
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomComponentState', () => ({
useAtomComponentState: (...args: unknown[]) =>
mockUseAtomComponentState(...args),
}));
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomState', () => ({
useAtomState: (...args: unknown[]) => mockUseAtomState(...args),
}));
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => ({
useAtomStateValue: (...args: unknown[]) => mockUseAtomStateValue(...args),
}));
jest.mock('@/views/hooks/useUpdateCurrentView', () => ({
useUpdateCurrentView: jest.fn(() => ({
updateCurrentView: mockUpdateCurrentView,
})),
}));
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: jest.fn(() => true),
}));
jest.mock('twenty-ui/input', () => ({
Button: ({
ariaLabel,
onClick,
title,
}: {
ariaLabel?: string;
onClick: () => void;
title?: string;
}) => (
<button aria-label={ariaLabel} onClick={onClick}>
{title}
</button>
),
}));
describe('RecordCalendarTopBar', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseAtomComponentState.mockReturnValue([
Temporal.PlainDate.from('2026-07-15'),
mockSetRecordCalendarSelectedDate,
]);
mockUseAtomState.mockReturnValue([
ViewCalendarLayout.DAY,
mockSetRecordIndexCalendarLayout,
]);
mockUseAtomStateValue.mockReturnValue({
locale: 'en-US',
localeCatalog: enUS,
});
mockUseRecordCalendarWeekDaysRange.mockReturnValue({
firstDayOfWeek: Temporal.PlainDate.from('2026-07-13'),
lastDayOfWeek: Temporal.PlainDate.from('2026-07-19'),
});
});
it('shows Day, Week, and Month with the selected full date', () => {
render(<RecordCalendarTopBar />);
expect(screen.getByTestId('selected-date')).toHaveTextContent(
'Wednesday, July 15, 2026',
);
expect(
Array.from(
screen.getByTestId('layout-select').querySelectorAll('option'),
).map((option) => option.textContent),
).toEqual(['Day', 'Week', 'Month']);
expect(screen.queryByTestId('time-zone')).not.toBeInTheDocument();
});
it('navigates the Day layout one day at a time', () => {
render(<RecordCalendarTopBar />);
fireEvent.click(screen.getByRole('button', { name: 'Previous period' }));
fireEvent.click(screen.getByRole('button', { name: 'Next period' }));
expect(mockSetRecordCalendarSelectedDate).toHaveBeenNthCalledWith(
1,
Temporal.PlainDate.from('2026-07-14'),
);
expect(mockSetRecordCalendarSelectedDate).toHaveBeenNthCalledWith(
2,
Temporal.PlainDate.from('2026-07-16'),
);
});
});
@@ -0,0 +1,37 @@
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
import { RecordCalendarTimeGrid } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { format } from 'date-fns';
import { turnPlainDateToShiftedDateInSystemTimeZone } from 'twenty-shared/utils';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
export const RecordCalendarDay = () => {
const recordCalendarId = useAvailableComponentInstanceIdOrThrow(
RecordCalendarComponentInstanceContext,
);
const recordCalendarSelectedDate = useAtomComponentStateValue(
recordCalendarSelectedDateComponentState,
recordCalendarId,
);
const dateLocale = useAtomStateValue(dateLocaleState);
return (
<RecordCalendarTimeGrid
days={[
{
date: recordCalendarSelectedDate,
label: format(
turnPlainDateToShiftedDateInSystemTimeZone(
recordCalendarSelectedDate,
),
'EEE',
{ locale: dateLocale.localeCatalog },
),
},
]}
/>
);
};
@@ -0,0 +1,53 @@
import { render, screen } from '@testing-library/react';
import { type Temporal } from 'temporal-polyfill';
import { RecordCalendarDay } from '@/object-record/record-calendar/day/components/RecordCalendarDay';
jest.mock(
'@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid',
() => ({
RecordCalendarTimeGrid: ({
days,
}: {
days: { date: Temporal.PlainDate; label: string }[];
}) => (
<div data-testid="time-grid" data-day-count={days.length}>
{days.map(({ date, label }) => `${date.toString()}:${label}`).join(',')}
</div>
),
}),
);
jest.mock(
'@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow',
() => ({
useAvailableComponentInstanceIdOrThrow: jest.fn(() => 'calendar-id'),
}),
);
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue', () => {
const { Temporal } = jest.requireActual('temporal-polyfill');
return {
useAtomComponentStateValue: jest.fn(() =>
Temporal.PlainDate.from('2026-07-15'),
),
};
});
jest.mock('@/ui/utilities/state/jotai/hooks/useAtomStateValue', () => {
const { enUS } = jest.requireActual('date-fns/locale');
return {
useAtomStateValue: jest.fn(() => ({ localeCatalog: enUS })),
};
});
describe('RecordCalendarDay', () => {
it('renders the selected date as the only time-grid day', () => {
render(<RecordCalendarDay />);
expect(screen.getByTestId('time-grid')).toHaveAttribute(
'data-day-count',
'1',
);
expect(screen.getByTestId('time-grid')).toHaveTextContent('2026-07-15:Wed');
});
});
@@ -0,0 +1,650 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { RecordCalendarAddNew } from '@/object-record/record-calendar/components/RecordCalendarAddNew';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
import { RecordCalendarTimeGridAllDayCell } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGridAllDayCell';
import { RecordCalendarWeekEvent } from '@/object-record/record-calendar/week/components/RecordCalendarWeekEvent';
import { RecordCalendarWeekDragDropContext } from '@/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext';
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
import { computeRecordCalendarWeekEventLayouts } from '@/object-record/record-calendar/week/utils/computeRecordCalendarWeekEventLayouts';
import { getRecordCalendarWeekTimedEventMetrics } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekTimedEventMetrics';
import { getRecordCalendarWeekSlotIndex } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekSlotIndex';
import {
type RecordCalendarWeekActiveSlot,
type RecordCalendarWeekSlotInteractionMode,
updateRecordCalendarWeekActiveSlot,
} from '@/object-record/record-calendar/week/utils/updateRecordCalendarWeekActiveSlot';
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { format } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import { useStore } from 'jotai';
import {
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { Temporal } from 'temporal-polyfill';
import {
isDefined,
isPlainDateInWeekend,
isSamePlainDate,
} from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const CURRENT_TIME_REFRESH_INTERVAL_IN_MILLISECONDS = 60_000;
const DEFAULT_KEYBOARD_SLOT_INDEX = 18;
const StyledContainer = styled.div<{ minWidthInPixels: number }>`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.sm};
border-top: 0;
min-width: ${({ minWidthInPixels }) => `${minWidthInPixels}px`};
overflow: clip;
`;
const StyledHeader = styled.div<{ dayCount: number }>`
background: ${themeCssVariables.background.primary};
border-bottom: 1px solid ${themeCssVariables.border.color.light};
border-top: 1px solid ${themeCssVariables.border.color.light};
display: grid;
grid-template-columns:
${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px
repeat(${({ dayCount }) => dayCount}, minmax(120px, 1fr));
position: sticky;
top: 0;
z-index: 3;
`;
const StyledHeaderGutter = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.light};
display: flex;
font-size: ${themeCssVariables.font.size.xs};
height: 32px;
justify-content: flex-end;
padding-right: ${themeCssVariables.spacing[1]};
`;
const StyledDayHeader = styled.div`
align-items: center;
border-left: 1px solid ${themeCssVariables.border.color.light};
color: ${themeCssVariables.font.color.light};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[1]};
height: 32px;
justify-content: center;
`;
const StyledDayNumber = styled.span<{ isToday: boolean }>`
align-items: center;
background: ${({ isToday }) =>
isToday ? themeCssVariables.color.blue : 'transparent'};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${({ isToday }) =>
isToday
? themeCssVariables.font.color.inverted
: themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${({ isToday }) =>
isToday
? themeCssVariables.font.weight.medium
: themeCssVariables.font.weight.regular};
height: 20px;
justify-content: center;
width: 20px;
`;
const StyledAllDayLabel = styled(StyledHeaderGutter)`
border-top: 1px solid ${themeCssVariables.border.color.light};
height: 28px;
`;
const StyledGrid = styled.div<{ dayCount: number }>`
display: grid;
grid-template-columns:
${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px
repeat(${({ dayCount }) => dayCount}, minmax(120px, 1fr));
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
position: relative;
`;
const StyledTimeGutter = styled.div`
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
position: relative;
`;
const StyledHourLabel = styled.span<{ topInPixels: number }>`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
position: absolute;
right: ${themeCssVariables.spacing[1]};
top: ${({ topInPixels }) => `${topInPixels}px`};
transform: translateY(-50%);
white-space: nowrap;
`;
const StyledDayColumn = styled.div<{ isWeekend: boolean }>`
background-color: ${({ isWeekend }) =>
isWeekend
? themeCssVariables.background.secondary
: themeCssVariables.background.primary};
background-image: repeating-linear-gradient(
to bottom,
${themeCssVariables.border.color.light} 0,
${themeCssVariables.border.color.light} 1px,
transparent 1px,
transparent ${RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight}px
);
border-left: 1px solid ${themeCssVariables.border.color.light};
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
isolation: isolate;
overflow: hidden;
position: relative;
`;
const StyledSlotAddNewPositioner = styled.div<{ topInPixels: number }>`
align-items: center;
display: flex;
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.slotHeight}px;
left: ${themeCssVariables.spacing['0.5']};
pointer-events: none;
position: absolute;
right: 0;
top: ${({ topInPixels }) => `${topInPixels}px`};
z-index: 0;
> div {
pointer-events: auto;
}
`;
const StyledCurrentTimeLine = styled.div<{ topInPixels: number }>`
background: ${themeCssVariables.color.red8};
height: 1px;
left: ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px;
pointer-events: none;
position: absolute;
right: 0;
top: ${({ topInPixels }) => `${topInPixels}px`};
z-index: 2;
&::after {
background: ${themeCssVariables.color.red8};
border-radius: 50%;
content: '';
height: 5px;
left: -2px;
position: absolute;
top: -2px;
width: 5px;
}
`;
const StyledCurrentTimeLabel = styled.span<{ topInPixels: number }>`
background: ${themeCssVariables.background.primary};
color: ${themeCssVariables.color.red9};
font-size: ${themeCssVariables.font.size.xs};
padding-right: ${themeCssVariables.spacing['0.5']};
position: absolute;
right: calc(100% - ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth - 1}px);
top: ${({ topInPixels }) => `${topInPixels}px`};
transform: translateY(-50%);
white-space: nowrap;
z-index: 3;
`;
const StyledScrollAnchor = styled.div<{ topInPixels: number }>`
height: 1px;
left: 0;
pointer-events: none;
position: absolute;
top: ${({ topInPixels }) => `${topInPixels}px`};
width: 1px;
`;
type WeekDayCellProps = {
calendarEndFieldName?: string;
calendarFieldName: string;
calendarFieldType: FieldMetadataType;
day: Temporal.PlainDate;
timeFormat: string;
timeZone: string;
};
type RecordCalendarWeekDayColumnProps = Omit<WeekDayCellProps, 'day'> & {
activeSlotIndex: number | null;
dayString: string;
onActiveSlotIndexChange: (
day: string,
slotIndex: number | null,
interactionMode: RecordCalendarWeekSlotInteractionMode,
) => void;
};
const RecordCalendarWeekDayColumn = memo(
({
activeSlotIndex,
calendarEndFieldName,
calendarFieldName,
calendarFieldType,
dayString,
onActiveSlotIndexChange,
timeFormat,
timeZone,
}: RecordCalendarWeekDayColumnProps) => {
const day = Temporal.PlainDate.from(dayString);
const store = useStore();
const recordIds = useAtomComponentFamilySelectorValue(
calendarDayRecordIdsComponentFamilySelector,
{ day, timeZone },
);
const eventLayoutInputs =
calendarFieldType === FieldMetadataType.DATE
? []
: recordIds
.map((recordId) => {
const record = store.get(
recordStoreFamilyState.atomFamily(recordId),
);
const recordDate = record?.[calendarFieldName];
const recordEndDate = isDefined(calendarEndFieldName)
? record?.[calendarEndFieldName]
: undefined;
const metrics = getRecordCalendarWeekTimedEventMetrics({
day,
startDateTime: recordDate,
endDateTime: recordEndDate,
timeZone,
});
if (metrics === null) {
return null;
}
return {
...metrics,
recordId,
};
})
.filter(isDefined);
const eventLayouts =
computeRecordCalendarWeekEventLayouts(eventLayoutInputs);
const handleMouseMove = (event: MouseEvent<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 type RecordCalendarTimeGridDay = {
date: Temporal.PlainDate;
label: string;
};
type RecordCalendarTimeGridProps = {
days: readonly RecordCalendarTimeGridDay[];
minWidthInPixels?: number;
};
export const RecordCalendarTimeGrid = ({
days,
minWidthInPixels = 0,
}: RecordCalendarTimeGridProps) => {
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
const { timeFormat, timeZone } = useDateTimeFormat();
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
recordIndexCalendarFieldMetadataIdState,
);
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
recordIndexCalendarEndFieldMetadataIdState,
);
const scrollAnchorRef = useRef<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 calendarFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === recordIndexCalendarFieldMetadataId,
);
const calendarEndFieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === recordIndexCalendarEndFieldMetadataId,
);
const now = currentInstant.toZonedDateTimeISO(timeZone);
const today = now.toPlainDate();
const isCurrentPeriod = days.some(({ date }) => isSamePlainDate(date, today));
const currentTimeTopInPixels =
(now.hour + now.minute / 60) * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight;
const initialScrollHour = isCurrentPeriod ? Math.max(now.hour - 2, 0) : 8;
const periodKey = days.map(({ date }) => date.toString()).join(',');
useEffect(() => {
const intervalId = window.setInterval(() => {
setCurrentInstant(Temporal.Now.instant());
}, CURRENT_TIME_REFRESH_INTERVAL_IN_MILLISECONDS);
return () => window.clearInterval(intervalId);
}, []);
const isAllDayView =
calendarFieldMetadataItem?.type === FieldMetadataType.DATE;
const isTimedView =
calendarFieldMetadataItem?.type === FieldMetadataType.DATE_TIME;
const compatibleCalendarEndFieldName =
isTimedView &&
calendarEndFieldMetadataItem?.type === FieldMetadataType.DATE_TIME
? calendarEndFieldMetadataItem.name
: undefined;
useEffect(() => {
if (!isTimedView) {
return;
}
scrollAnchorRef.current?.scrollIntoView({
block: 'center',
inline: 'nearest',
});
}, [isTimedView, periodKey]);
if (
!isDefined(calendarFieldMetadataItem) ||
(!isAllDayView && !isTimedView)
) {
return null;
}
return (
<RecordCalendarWeekDragDropContext
days={days.map(({ date }) => date)}
gridRef={gridRef}
>
<StyledContainer minWidthInPixels={minWidthInPixels}>
<StyledHeader dayCount={days.length}>
<StyledHeaderGutter>
{isTimedView && <TimeZoneAbbreviation instant={currentInstant} />}
</StyledHeaderGutter>
{days.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>
{days.map(({ date }) => (
<RecordCalendarTimeGridAllDayCell
key={`all-day-${date.toString()}`}
calendarFieldName={calendarFieldMetadataItem.name}
calendarFieldType={calendarFieldMetadataItem.type}
day={date}
timeFormat={timeFormat}
timeZone={timeZone}
/>
))}
</>
)}
</StyledHeader>
{isTimedView && (
<StyledGrid
ref={gridRef}
dayCount={days.length}
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>
{days.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}
/>
);
})}
{isCurrentPeriod && (
<>
<StyledCurrentTimeLine topInPixels={currentTimeTopInPixels} />
<StyledCurrentTimeLabel topInPixels={currentTimeTopInPixels}>
{formatInTimeZone(
new Date(currentInstant.toString()),
timeZone,
timeFormat,
)}
</StyledCurrentTimeLabel>
</>
)}
</StyledGrid>
)}
</StyledContainer>
</RecordCalendarWeekDragDropContext>
);
};
@@ -0,0 +1,59 @@
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
import { RecordCalendarWeekEvent } from '@/object-record/record-calendar/week/components/RecordCalendarWeekEvent';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { styled } from '@linaria/react';
import { type Temporal } from 'temporal-polyfill';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const StyledAllDayCell = styled.div`
border-left: 1px solid ${themeCssVariables.border.color.light};
border-top: 1px solid ${themeCssVariables.border.color.light};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing['0.5']};
min-height: 28px;
min-width: 0;
padding: ${themeCssVariables.spacing['0.5']};
`;
type RecordCalendarTimeGridAllDayCellProps = {
calendarFieldName: string;
calendarFieldType: FieldMetadataType;
day: Temporal.PlainDate;
timeFormat: string;
timeZone: string;
};
export const RecordCalendarTimeGridAllDayCell = ({
calendarFieldName,
calendarFieldType,
day,
timeFormat,
timeZone,
}: RecordCalendarTimeGridAllDayCellProps) => {
const recordIds = useAtomComponentFamilySelectorValue(
calendarDayRecordIdsComponentFamilySelector,
{ day, timeZone },
);
const allDayRecordIds =
calendarFieldType === FieldMetadataType.DATE ? recordIds : [];
return (
<StyledAllDayCell>
{allDayRecordIds.map((recordId) => (
<RecordCalendarWeekEvent
key={recordId}
calendarDay={day}
calendarFieldName={calendarFieldName}
calendarFieldType={calendarFieldType}
isAllDay
recordId={recordId}
timeFormat={timeFormat}
timeZone={timeZone}
/>
))}
</StyledAllDayCell>
);
};
@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react';
import { Temporal } from 'temporal-polyfill';
import { RecordCalendarTimeGridAllDayCell } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGridAllDayCell';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const mockUseAtomComponentFamilySelectorValue = jest.fn();
jest.mock(
'@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue',
() => ({
useAtomComponentFamilySelectorValue: (...args: unknown[]) =>
mockUseAtomComponentFamilySelectorValue(...args),
}),
);
jest.mock(
'@/object-record/record-calendar/week/components/RecordCalendarWeekEvent',
() => ({
RecordCalendarWeekEvent: ({
isAllDay,
recordId,
}: {
isAllDay: boolean;
recordId: string;
}) => (
<div
data-testid="all-day-event"
data-is-all-day={isAllDay}
data-record-id={recordId}
/>
),
}),
);
describe('RecordCalendarTimeGridAllDayCell', () => {
it('renders every all-day record in order', () => {
mockUseAtomComponentFamilySelectorValue.mockReturnValue([
'first',
'second',
'third',
'fourth',
'fifth',
]);
render(
<RecordCalendarTimeGridAllDayCell
calendarFieldName="date"
calendarFieldType={FieldMetadataType.DATE}
day={Temporal.PlainDate.from('2026-07-15')}
timeFormat="HH:mm"
timeZone="UTC"
/>,
);
expect(
screen.getAllByTestId('all-day-event').map((element) => ({
isAllDay: element.dataset.isAllDay,
recordId: element.dataset.recordId,
})),
).toEqual([
{
isAllDay: 'true',
recordId: 'first',
},
{
isAllDay: 'true',
recordId: 'second',
},
{
isAllDay: 'true',
recordId: 'third',
},
{
isAllDay: 'true',
recordId: 'fourth',
},
{
isAllDay: 'true',
recordId: 'fifth',
},
]);
expect(screen.queryByText(/^\+\d+$/)).not.toBeInTheDocument();
});
});
@@ -5,7 +5,7 @@ describe('getSupportedRecordCalendarLayout', () => {
it.each([
[ViewCalendarLayout.WEEK, ViewCalendarLayout.WEEK],
[ViewCalendarLayout.MONTH, ViewCalendarLayout.MONTH],
[ViewCalendarLayout.DAY, ViewCalendarLayout.MONTH],
[ViewCalendarLayout.DAY, ViewCalendarLayout.DAY],
[null, ViewCalendarLayout.MONTH],
[undefined, ViewCalendarLayout.MONTH],
])(
@@ -8,7 +8,12 @@ type GetSupportedRecordCalendarLayoutArgs = {
export const getSupportedRecordCalendarLayout = ({
calendarLayout,
isCalendarWeekViewEnabled,
}: GetSupportedRecordCalendarLayoutArgs) =>
isCalendarWeekViewEnabled && calendarLayout === ViewCalendarLayout.WEEK
? ViewCalendarLayout.WEEK
}: GetSupportedRecordCalendarLayoutArgs) => {
const isTimeGridLayout =
calendarLayout === ViewCalendarLayout.DAY ||
calendarLayout === ViewCalendarLayout.WEEK;
return isCalendarWeekViewEnabled && isTimeGridLayout
? calendarLayout
: ViewCalendarLayout.MONTH;
};
@@ -1,483 +1,13 @@
import { useDateTimeFormat } from '@/localization/hooks/useDateTimeFormat';
import { RecordCalendarAddNew } from '@/object-record/record-calendar/components/RecordCalendarAddNew';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { RecordCalendarComponentInstanceContext } from '@/object-record/record-calendar/states/contexts/RecordCalendarComponentInstanceContext';
import { recordCalendarSelectedDateComponentState } from '@/object-record/record-calendar/states/recordCalendarSelectedDateComponentState';
import { calendarDayRecordIdsComponentFamilySelector } from '@/object-record/record-calendar/states/selectors/calendarDayRecordsComponentFamilySelector';
import { RecordCalendarWeekEvent } from '@/object-record/record-calendar/week/components/RecordCalendarWeekEvent';
import { RecordCalendarWeekDragDropContext } from '@/object-record/record-calendar/week/components/RecordCalendarWeekDragDropContext';
import { RECORD_CALENDAR_WEEK_DIMENSIONS } from '@/object-record/record-calendar/week/constants/RecordCalendarWeekDimensions';
import { RecordCalendarTimeGrid } from '@/object-record/record-calendar/time-grid/components/RecordCalendarTimeGrid';
import { useRecordCalendarWeekDaysRange } from '@/object-record/record-calendar/week/hooks/useRecordCalendarWeekDaysRange';
import { computeRecordCalendarWeekEventLayouts } from '@/object-record/record-calendar/week/utils/computeRecordCalendarWeekEventLayouts';
import { getRecordCalendarWeekTimedEventMetrics } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekTimedEventMetrics';
import { getRecordCalendarWeekSlotIndex } from '@/object-record/record-calendar/week/utils/getRecordCalendarWeekSlotIndex';
import {
type RecordCalendarWeekActiveSlot,
type RecordCalendarWeekSlotInteractionMode,
updateRecordCalendarWeekActiveSlot,
} from '@/object-record/record-calendar/week/utils/updateRecordCalendarWeekActiveSlot';
import { recordIndexCalendarEndFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarEndFieldMetadataIdState';
import { recordIndexCalendarFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexCalendarFieldMetadataIdState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { TimeZoneAbbreviation } from '@/ui/input/components/internal/date/components/TimeZoneAbbreviation';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { format } from 'date-fns';
import { formatInTimeZone } from 'date-fns-tz';
import { useStore } from 'jotai';
import {
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
memo,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { Temporal } from 'temporal-polyfill';
import {
isDefined,
isPlainDateInWeekend,
isSamePlainDate,
} from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { FieldMetadataType } from '~/generated-metadata/graphql';
const CURRENT_TIME_REFRESH_INTERVAL_IN_MILLISECONDS = 60_000;
const DEFAULT_KEYBOARD_SLOT_INDEX = 18;
const StyledContainer = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.sm};
min-width: 1000px;
overflow: clip;
`;
const StyledHeader = styled.div`
background: ${themeCssVariables.background.primary};
border-bottom: 1px solid ${themeCssVariables.border.color.light};
display: grid;
grid-template-columns:
${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px
repeat(7, minmax(120px, 1fr));
position: sticky;
top: 0;
z-index: 3;
`;
const StyledHeaderGutter = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.light};
display: flex;
font-size: ${themeCssVariables.font.size.xs};
height: 32px;
justify-content: flex-end;
padding-right: ${themeCssVariables.spacing[1]};
`;
const StyledDayHeader = styled.div`
align-items: center;
border-left: 1px solid ${themeCssVariables.border.color.light};
color: ${themeCssVariables.font.color.light};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
gap: ${themeCssVariables.spacing[1]};
height: 32px;
justify-content: center;
`;
const StyledDayNumber = styled.span<{ isToday: boolean }>`
align-items: center;
background: ${({ isToday }) =>
isToday ? themeCssVariables.color.blue : 'transparent'};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${({ isToday }) =>
isToday
? themeCssVariables.font.color.inverted
: themeCssVariables.font.color.primary};
display: flex;
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${({ isToday }) =>
isToday
? themeCssVariables.font.weight.medium
: themeCssVariables.font.weight.regular};
height: 20px;
justify-content: center;
width: 20px;
`;
const StyledAllDayLabel = styled(StyledHeaderGutter)`
border-top: 1px solid ${themeCssVariables.border.color.light};
height: 28px;
`;
const StyledAllDayCell = styled.div`
align-items: center;
border-left: 1px solid ${themeCssVariables.border.color.light};
border-top: 1px solid ${themeCssVariables.border.color.light};
display: flex;
gap: ${themeCssVariables.spacing['0.5']};
height: 28px;
min-width: 0;
overflow: hidden;
padding: ${themeCssVariables.spacing['0.5']};
`;
const StyledAdditionalEventCount = styled.span`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
white-space: nowrap;
`;
const StyledGrid = styled.div`
display: grid;
grid-template-columns:
${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px
repeat(7, minmax(120px, 1fr));
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
position: relative;
`;
const StyledTimeGutter = styled.div`
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
position: relative;
`;
const StyledHourLabel = styled.span<{ topInPixels: number }>`
color: ${themeCssVariables.font.color.light};
font-size: ${themeCssVariables.font.size.xs};
position: absolute;
right: ${themeCssVariables.spacing[1]};
top: ${({ topInPixels }) => `${topInPixels}px`};
transform: translateY(-50%);
white-space: nowrap;
`;
const StyledDayColumn = styled.div<{ isWeekend: boolean }>`
background-color: ${({ isWeekend }) =>
isWeekend
? themeCssVariables.background.secondary
: themeCssVariables.background.primary};
background-image: repeating-linear-gradient(
to bottom,
${themeCssVariables.border.color.light} 0,
${themeCssVariables.border.color.light} 1px,
transparent 1px,
transparent ${RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight}px
);
border-left: 1px solid ${themeCssVariables.border.color.light};
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.gridHeight}px;
isolation: isolate;
overflow: hidden;
position: relative;
`;
const StyledSlotAddNewPositioner = styled.div<{ topInPixels: number }>`
align-items: center;
display: flex;
height: ${RECORD_CALENDAR_WEEK_DIMENSIONS.slotHeight}px;
left: ${themeCssVariables.spacing['0.5']};
pointer-events: none;
position: absolute;
right: 0;
top: ${({ topInPixels }) => `${topInPixels}px`};
z-index: 0;
> div {
pointer-events: auto;
}
`;
const StyledCurrentTimeLine = styled.div<{ topInPixels: number }>`
background: ${themeCssVariables.color.red8};
height: 1px;
left: ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth}px;
pointer-events: none;
position: absolute;
right: 0;
top: ${({ topInPixels }) => `${topInPixels}px`};
z-index: 2;
&::after {
background: ${themeCssVariables.color.red8};
border-radius: 50%;
content: '';
height: 5px;
left: -2px;
position: absolute;
top: -2px;
width: 5px;
}
`;
const StyledCurrentTimeLabel = styled.span<{ topInPixels: number }>`
background: ${themeCssVariables.background.primary};
color: ${themeCssVariables.color.red9};
font-size: ${themeCssVariables.font.size.xs};
padding-right: ${themeCssVariables.spacing['0.5']};
position: absolute;
right: calc(100% - ${RECORD_CALENDAR_WEEK_DIMENSIONS.timeGutterWidth - 1}px);
top: ${({ topInPixels }) => `${topInPixels}px`};
transform: translateY(-50%);
white-space: nowrap;
z-index: 3;
`;
const StyledScrollAnchor = styled.div<{ topInPixels: number }>`
height: 1px;
left: 0;
pointer-events: none;
position: absolute;
top: ${({ topInPixels }) => `${topInPixels}px`};
width: 1px;
`;
type WeekDayCellProps = {
calendarEndFieldName?: string;
calendarFieldName: string;
calendarFieldType: FieldMetadataType;
day: Temporal.PlainDate;
timeFormat: string;
timeZone: string;
};
type RecordCalendarWeekAllDayCellProps = WeekDayCellProps;
type RecordCalendarWeekDayColumnProps = Omit<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>
);
},
);
const RECORD_CALENDAR_WEEK_MIN_WIDTH_IN_PIXELS = 1000;
export const RecordCalendarWeek = () => {
const { objectMetadataItem } = useRecordCalendarContextOrThrow();
const { timeFormat, timeZone } = useDateTimeFormat();
const recordCalendarId = useAvailableComponentInstanceIdOrThrow(
RecordCalendarComponentInstanceContext,
);
@@ -485,220 +15,14 @@ export const RecordCalendarWeek = () => {
recordCalendarSelectedDateComponentState,
recordCalendarId,
);
const recordIndexCalendarFieldMetadataId = useAtomStateValue(
recordIndexCalendarFieldMetadataIdState,
const { weekDays } = useRecordCalendarWeekDaysRange(
recordCalendarSelectedDate,
);
const recordIndexCalendarEndFieldMetadataId = useAtomStateValue(
recordIndexCalendarEndFieldMetadataIdState,
);
const scrollAnchorRef = useRef<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>
<RecordCalendarTimeGrid
days={weekDays}
minWidthInPixels={RECORD_CALENDAR_WEEK_MIN_WIDTH_IN_PIXELS}
/>
);
};
@@ -32,14 +32,14 @@ type DragEndPayload = Parameters<
type RecordCalendarWeekDragDropContextProps = {
children: ReactNode;
days: Temporal.PlainDate[];
gridRef: RefObject<HTMLDivElement | null>;
weekDays: Temporal.PlainDate[];
};
export const RecordCalendarWeekDragDropContext = ({
children,
days,
gridRef,
weekDays,
}: RecordCalendarWeekDragDropContextProps) => {
const [grabOffsetY, setGrabOffsetY] = useState(0);
const { processRecordCalendarWeekEventDrop } =
@@ -76,7 +76,7 @@ export const RecordCalendarWeekDragDropContext = ({
const gridRect = gridRef.current.getBoundingClientRect();
const resolvedDrop = resolveRecordCalendarWeekEventDrop({
dayCount: weekDays.length,
dayCount: days.length,
grabOffsetY,
gridRect,
pointerX: operation.position.current.x,
@@ -87,7 +87,7 @@ export const RecordCalendarWeekDragDropContext = ({
return;
}
const destinationDay = weekDays[resolvedDrop.dayIndex];
const destinationDay = days[resolvedDrop.dayIndex];
if (!isDefined(destinationDay)) {
return;
@@ -28,6 +28,19 @@ describe('resolveRecordCalendarWeekEventDrop', () => {
});
});
it('resolves every valid horizontal position to the only visible day', () => {
expect(
resolveRecordCalendarWeekEventDrop({
dayCount: 1,
grabOffsetY: 0,
gridRect,
pointerX: gridRect.left + gridRect.width - 1,
pointerY:
gridRect.top + 14.25 * RECORD_CALENDAR_WEEK_DIMENSIONS.hourHeight,
}),
).toEqual({ dayIndex: 0, destinationMinutes: 14 * 60 });
});
it.each([
['time gutter', gridRect.left + 20, gridRect.top + 100],
['left of grid', gridRect.left - 1, gridRect.top + 100],
@@ -15,9 +15,9 @@ export const PUBLIC_FEATURE_FLAGS: PublicFeatureFlag[] = [
{
key: FeatureFlagKey.IS_CALENDAR_WEEK_VIEW_ENABLED,
metadata: {
label: 'Calendar Week View',
label: 'Calendar Day and Week Views',
description:
'Display calendar records in a weekly layout with optional end dates',
'Display calendar records in daily or weekly layouts with optional end dates',
},
},
{