Remove calendar cursor (#12200)

Fixes https://github.com/twentyhq/twenty/issues/12125

The root cause of the infinite loop was the calendar cursor. In some
cases, it was not properly displayed and was causing the loop because of
its animation that was always restarting.

We agreed with @FelixMalfait and @Bonapara that given the current
importance of the feature and the amount of issues associated, we remove
the cursor for now.
This commit is contained in:
Thomas Trompette
2025-05-22 13:35:30 +02:00
committed by GitHub
parent 9753637693
commit 4e7a7ce893
10 changed files with 13 additions and 363 deletions

View File

@ -12,7 +12,7 @@ import { SkeletonLoader } from '@/activities/components/SkeletonLoader';
import { useCustomResolver } from '@/activities/hooks/useCustomResolver';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { TimelineCalendarEventsWithTotal } from '~/generated/graphql';
import { H3Title } from 'twenty-ui/display';
import {
AnimatedPlaceholder,
AnimatedPlaceholderEmptyContainer,
@ -22,7 +22,7 @@ import {
EMPTY_PLACEHOLDER_TRANSITION_PROPS,
Section,
} from 'twenty-ui/layout';
import { H3Title } from 'twenty-ui/display';
import { TimelineCalendarEventsWithTotal } from '~/generated/graphql';
const StyledContainer = styled.div`
box-sizing: border-box;
@ -82,12 +82,9 @@ export const Calendar = ({
const {
calendarEventsByDayTime,
currentCalendarEvent,
daysByMonthTime,
getNextCalendarEvent,
monthTimes,
monthTimesByYear,
updateCurrentCalendarEvent,
} = useCalendarEvents(timelineCalendarEvents || []);
if (firstQueryLoading) {
@ -119,10 +116,6 @@ export const Calendar = ({
<CalendarContext.Provider
value={{
calendarEventsByDayTime,
currentCalendarEvent,
displayCurrentEventCursor: true,
getNextCalendarEvent,
updateCurrentCalendarEvent,
}}
>
<StyledContainer>

View File

@ -1,180 +0,0 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
differenceInSeconds,
isThisMonth,
startOfDay,
startOfMonth,
} from 'date-fns';
import { AnimatePresence, motion } from 'framer-motion';
import { useContext, useMemo, useState } from 'react';
import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext';
import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate';
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded';
import { hasCalendarEventStarted } from '@/activities/calendar/utils/hasCalendarEventStarted';
import { TimelineCalendarEvent } from '~/generated/graphql';
type CalendarCurrentEventCursorProps = {
calendarEvent: TimelineCalendarEvent;
};
const StyledDot = styled(motion.div)`
background-color: ${({ theme }) => theme.font.color.danger};
border-radius: 1px;
height: ${({ theme }) => theme.spacing(1)};
width: ${({ theme }) => theme.spacing(1)};
`;
const StyledCurrentEventCursor = styled(motion.div)`
align-items: center;
background-color: ${({ theme }) => theme.font.color.danger};
display: inline-flex;
height: 1.5px;
left: 0;
position: absolute;
right: 0;
border-radius: ${({ theme }) => theme.border.radius.sm};
transform: translateY(-50%);
`;
export const CalendarCurrentEventCursor = ({
calendarEvent,
}: CalendarCurrentEventCursorProps) => {
const theme = useTheme();
const {
calendarEventsByDayTime,
currentCalendarEvent,
getNextCalendarEvent,
updateCurrentCalendarEvent,
} = useContext(CalendarContext);
const nextCalendarEvent = getNextCalendarEvent(calendarEvent);
const nextCalendarEventStartsAt = nextCalendarEvent
? getCalendarEventStartDate(nextCalendarEvent)
: undefined;
const isNextEventThisMonth =
!!nextCalendarEventStartsAt && isThisMonth(nextCalendarEventStartsAt);
const calendarEventStartsAt = getCalendarEventStartDate(calendarEvent);
const calendarEventEndsAt = getCalendarEventEndDate(calendarEvent);
const isCurrent = currentCalendarEvent?.id === calendarEvent.id;
const [hasStarted, setHasStarted] = useState(
hasCalendarEventStarted(calendarEvent),
);
const [hasEnded, setHasEnded] = useState(
hasCalendarEventEnded(calendarEvent),
);
const [isWaiting, setIsWaiting] = useState(hasEnded && !isNextEventThisMonth);
const dayTime = startOfDay(calendarEventStartsAt).getTime();
const dayEvents = calendarEventsByDayTime[dayTime];
const isFirstEventOfDay = dayEvents?.slice(-1)[0] === calendarEvent;
const isLastEventOfDay = dayEvents?.[0] === calendarEvent;
const topOffset = isLastEventOfDay ? 9 : 6;
const bottomOffset = isFirstEventOfDay ? 9 : 6;
const currentEventCursorVariants = {
beforeEvent: { top: `calc(100% + ${bottomOffset}px)` },
eventStart: {
top: 'calc(100% + 3px)',
transition: {
delay: Math.max(
0,
differenceInSeconds(calendarEventStartsAt, new Date()),
),
},
},
eventEnd: {
top: `-${topOffset}px`,
transition: {
delay: Math.max(
0,
differenceInSeconds(calendarEventEndsAt, new Date()) + 1,
),
},
},
fadeAway: {
opacity: 0,
top: `-${topOffset}px`,
transition: {
delay:
isWaiting && nextCalendarEventStartsAt
? differenceInSeconds(
startOfMonth(nextCalendarEventStartsAt),
new Date(),
)
: 0,
},
},
};
const animationSequence = useMemo(() => {
if (!hasStarted) return { initial: 'beforeEvent', animate: 'eventStart' };
if (!hasEnded) {
return { initial: 'eventStart', animate: 'eventEnd' };
}
if (!isWaiting) {
return { initial: undefined, animate: 'eventEnd' };
}
return { initial: 'eventEnd', animate: 'fadeAway' };
}, [hasEnded, hasStarted, isWaiting]);
return (
<AnimatePresence>
{isCurrent && (
<StyledCurrentEventCursor
key={calendarEvent.id}
initial={animationSequence.initial}
animate={animationSequence.animate}
exit="fadeAway"
variants={currentEventCursorVariants}
onAnimationComplete={(stage) => {
if (stage === 'eventStart') {
setHasStarted(true);
}
if (stage === 'eventEnd') {
setHasEnded(true);
if (isNextEventThisMonth) {
updateCurrentCalendarEvent();
}
// If the next event is not the same month as the current event,
// we don't want the cursor to jump to the next month until the next month starts.
// => Wait for the upcoming event's month to start before moving the cursor.
// Example: we're in March. The previous event is February 15th, and the next event is April 10th.
// We want the cursor to stay in February until April starts.
else {
setIsWaiting(true);
}
}
if (isWaiting && stage === 'fadeAway') {
setIsWaiting(false);
updateCurrentCalendarEvent();
}
}}
transition={{ duration: theme.animation.duration.normal }}
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
delay: theme.animation.duration.normal,
duration: theme.animation.duration.normal,
}}
>
<StyledDot />
</motion.div>
</StyledCurrentEventCursor>
)}
</AnimatePresence>
);
};

View File

@ -4,8 +4,8 @@ import { differenceInSeconds, endOfDay, format } from 'date-fns';
import { CalendarEventRow } from '@/activities/calendar/components/CalendarEventRow';
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
import { TimelineCalendarEvent } from '~/generated/graphql';
import { CardContent } from 'twenty-ui/layout';
import { TimelineCalendarEvent } from '~/generated/graphql';
type CalendarDayCardContentProps = {
calendarEvents: TimelineCalendarEvent[];

View File

@ -1,13 +1,10 @@
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { format } from 'date-fns';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { CalendarCurrentEventCursor } from '@/activities/calendar/components/CalendarCurrentEventCursor';
import { CalendarEventNotSharedContent } from '@/activities/calendar/components/CalendarEventNotSharedContent';
import { CalendarEventParticipantsAvatarGroup } from '@/activities/calendar/components/CalendarEventParticipantsAvatarGroup';
import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext';
import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate';
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded';
@ -88,7 +85,6 @@ export const CalendarEventRow = ({
}: CalendarEventRowProps) => {
const theme = useTheme();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { displayCurrentEventCursor = false } = useContext(CalendarContext);
const { openCalendarEventInCommandMenu } =
useOpenCalendarEventInCommandMenu();
@ -143,9 +139,6 @@ export const CalendarEventRow = ({
participants={calendarEvent.participants}
/>
)}
{displayCurrentEventCursor && (
<CalendarCurrentEventCursor calendarEvent={calendarEvent} />
)}
</StyledContainer>
);
};

View File

@ -4,16 +4,8 @@ import { TimelineCalendarEvent } from '~/generated/graphql';
type CalendarContextValue = {
calendarEventsByDayTime: Record<number, TimelineCalendarEvent[] | undefined>;
currentCalendarEvent?: TimelineCalendarEvent;
displayCurrentEventCursor?: boolean;
getNextCalendarEvent: (
calendarEvent: TimelineCalendarEvent,
) => TimelineCalendarEvent | undefined;
updateCurrentCalendarEvent: () => void;
};
export const CalendarContext = createContext<CalendarContextValue>({
calendarEventsByDayTime: {},
getNextCalendarEvent: () => undefined,
updateCurrentCalendarEvent: () => {},
});

View File

@ -1,4 +1,4 @@
import { act, renderHook } from '@testing-library/react';
import { renderHook } from '@testing-library/react';
import { useCalendarEvents } from '@/activities/calendar/hooks/useCalendarEvents';
import {
@ -85,20 +85,13 @@ const calendarEvents: TimelineCalendarEvent[] = [
},
];
describe('useCalendar', () => {
it('returns calendar events', () => {
describe('useCalendarEvents', () => {
it('returns calendar events grouped by day time', () => {
const { result } = renderHook(() => useCalendarEvents(calendarEvents));
expect(result.current.currentCalendarEvent).toBe(calendarEvents[0]);
expect(result.current.getNextCalendarEvent(calendarEvents[1])).toBe(
calendarEvents[0],
);
act(() => {
result.current.updateCurrentCalendarEvent();
});
expect(result.current.currentCalendarEvent).toBe(calendarEvents[0]);
expect(result.current.calendarEventsByDayTime).toBeDefined();
expect(result.current.daysByMonthTime).toBeDefined();
expect(result.current.monthTimes).toBeDefined();
expect(result.current.monthTimesByYear).toBeDefined();
});
});

View File

@ -1,12 +1,9 @@
import { getYear, isThisMonth, startOfDay, startOfMonth } from 'date-fns';
import { useMemo, useState } from 'react';
import { getYear, startOfDay, startOfMonth } from 'date-fns';
import { findUpcomingCalendarEvent } from '@/activities/calendar/utils/findUpcomingCalendarEvent';
import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate';
import { TimelineCalendarEvent } from '~/generated/graphql';
import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy';
import { sortDesc } from '~/utils/sort';
import { isDefined } from 'twenty-shared/utils';
export const useCalendarEvents = (calendarEvents: TimelineCalendarEvent[]) => {
const calendarEventsByDayTime = groupArrayItemsBy(
@ -29,53 +26,10 @@ export const useCalendarEvents = (calendarEvents: TimelineCalendarEvent[]) => {
const monthTimesByYear = groupArrayItemsBy(sortedMonthTimes, getYear);
const getPreviousCalendarEvent = (calendarEvent: TimelineCalendarEvent) => {
const calendarEventIndex = calendarEvents.indexOf(calendarEvent);
return calendarEventIndex < calendarEvents.length - 1
? calendarEvents[calendarEventIndex + 1]
: undefined;
};
const getNextCalendarEvent = (calendarEvent: TimelineCalendarEvent) => {
const calendarEventIndex = calendarEvents.indexOf(calendarEvent);
return calendarEventIndex > 0
? calendarEvents[calendarEventIndex - 1]
: undefined;
};
const initialUpcomingCalendarEvent = useMemo(
() => findUpcomingCalendarEvent(calendarEvents),
[calendarEvents],
);
const lastEventInCalendar = calendarEvents.length
? calendarEvents[0]
: undefined;
const [currentCalendarEvent, setCurrentCalendarEvent] = useState(
(initialUpcomingCalendarEvent &&
(isThisMonth(getCalendarEventStartDate(initialUpcomingCalendarEvent))
? initialUpcomingCalendarEvent
: getPreviousCalendarEvent(initialUpcomingCalendarEvent))) ||
lastEventInCalendar,
);
const updateCurrentCalendarEvent = () => {
if (!currentCalendarEvent) return;
const nextCurrentCalendarEvent = getNextCalendarEvent(currentCalendarEvent);
if (isDefined(nextCurrentCalendarEvent)) {
setCurrentCalendarEvent(nextCurrentCalendarEvent);
}
};
return {
calendarEventsByDayTime,
currentCalendarEvent,
daysByMonthTime,
getNextCalendarEvent,
monthTimes: sortedMonthTimes,
monthTimesByYear,
updateCurrentCalendarEvent,
};
};

View File

@ -1,80 +0,0 @@
import { addDays, addHours, startOfDay, subDays, subHours } from 'date-fns';
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
import { findUpcomingCalendarEvent } from '../findUpcomingCalendarEvent';
const pastEvent: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'> = {
startsAt: subHours(new Date(), 2).toISOString(),
endsAt: subHours(new Date(), 1).toISOString(),
isFullDay: false,
};
const fullDayPastEvent: Pick<CalendarEvent, 'startsAt' | 'isFullDay'> = {
startsAt: subDays(new Date(), 1).toISOString(),
isFullDay: true,
};
const currentEvent: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'> = {
startsAt: addHours(new Date(), 1).toISOString(),
endsAt: addHours(new Date(), 2).toISOString(),
isFullDay: false,
};
const currentFullDayEvent: Pick<CalendarEvent, 'startsAt' | 'isFullDay'> = {
startsAt: startOfDay(new Date()).toISOString(),
isFullDay: true,
};
const futureEvent: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'> = {
startsAt: addDays(new Date(), 1).toISOString(),
endsAt: addDays(new Date(), 2).toISOString(),
isFullDay: false,
};
const fullDayFutureEvent: Pick<CalendarEvent, 'startsAt' | 'isFullDay'> = {
startsAt: addDays(new Date(), 2).toISOString(),
isFullDay: false,
};
describe('findUpcomingCalendarEvent', () => {
it('returns the first current event by chronological order', () => {
// Given
const calendarEvents = [
futureEvent,
currentFullDayEvent,
pastEvent,
currentEvent,
];
// When
const result = findUpcomingCalendarEvent(calendarEvents);
// Then
expect(result).toEqual(currentFullDayEvent);
});
it('returns the next future event by chronological order', () => {
// Given
const calendarEvents = [
fullDayPastEvent,
fullDayFutureEvent,
futureEvent,
pastEvent,
];
// When
const result = findUpcomingCalendarEvent(calendarEvents);
// Then
expect(result).toEqual(futureEvent);
});
it('returns undefined if all events are in the past', () => {
// Given
const calendarEvents = [pastEvent, fullDayPastEvent];
// When
const result = findUpcomingCalendarEvent(calendarEvents);
// Then
expect(result).toBeUndefined();
});
});

View File

@ -1,12 +0,0 @@
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded';
import { sortCalendarEventsAsc } from '@/activities/calendar/utils/sortCalendarEvents';
export const findUpcomingCalendarEvent = <
T extends Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'>,
>(
calendarEvents: T[],
) =>
[...calendarEvents]
.sort(sortCalendarEventsAsc)
.find((calendarEvent) => !hasCalendarEventEnded(calendarEvent));

View File

@ -3,15 +3,15 @@ import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { SettingsAccountsCalendarDisplaySettings } from '@/settings/accounts/components/SettingsAccountsCalendarDisplaySettings';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { Section } from '@react-email/components';
import { addMinutes, endOfDay, min, startOfDay } from 'date-fns';
import { useRecoilValue } from 'recoil';
import { H2Title } from 'twenty-ui/display';
import {
CalendarChannelVisibility,
TimelineCalendarEvent,
} from '~/generated/graphql';
import { t } from '@lingui/core/macro';
import { H2Title } from 'twenty-ui/display';
const StyledGeneralContainer = styled.div`
display: flex;
@ -78,12 +78,9 @@ export const SettingsAccountsCalendarChannelsGeneral = () => {
/>
<CalendarContext.Provider
value={{
currentCalendarEvent: exampleCalendarEvent,
calendarEventsByDayTime: {
[exampleDayTime]: [exampleCalendarEvent],
},
getNextCalendarEvent: () => undefined,
updateCurrentCalendarEvent: () => {},
}}
>
<CalendarMonthCard dayTimes={[exampleDayTime]} />