diff --git a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx index 97ad87b4e..a02ed3b73 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx @@ -1,14 +1,13 @@ import styled from '@emotion/styled'; -import { format, getYear, startOfMonth } from 'date-fns'; -import mapValues from 'lodash.mapvalues'; +import { format, getYear } from 'date-fns'; import { CalendarMonthCard } from '@/activities/calendar/components/CalendarMonthCard'; +import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; +import { useCalendarEvents } from '@/activities/calendar/hooks/useCalendarEvents'; import { sortCalendarEventsDesc } from '@/activities/calendar/utils/sortCalendarEvents'; import { H3Title } from '@/ui/display/typography/components/H3Title'; import { Section } from '@/ui/layout/section/components/Section'; import { mockedCalendarEvents } from '~/testing/mock-data/calendar'; -import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy'; -import { sortDesc } from '~/utils/sort'; const StyledContainer = styled.div` box-sizing: border-box; @@ -27,28 +26,35 @@ export const Calendar = () => { const sortedCalendarEvents = [...mockedCalendarEvents].sort( sortCalendarEventsDesc, ); - const calendarEventsByMonthTime = groupArrayItemsBy( - sortedCalendarEvents, - ({ startsAt }) => startOfMonth(startsAt).getTime(), - ); - const sortedMonthTimes = Object.keys(calendarEventsByMonthTime) - .map(Number) - .sort(sortDesc); - const monthTimesByYear = groupArrayItemsBy(sortedMonthTimes, getYear); - const lastMonthTimeByYear = mapValues(monthTimesByYear, (monthTimes = []) => - Math.max(...monthTimes), - ); + + const { + calendarEventsByDayTime, + currentCalendarEvent, + daysByMonthTime, + getNextCalendarEvent, + monthTimes, + monthTimesByYear, + updateCurrentCalendarEvent, + } = useCalendarEvents(sortedCalendarEvents); return ( - - {sortedMonthTimes.map((monthTime) => { - const monthCalendarEvents = calendarEventsByMonthTime[monthTime]; - const year = getYear(monthTime); - const isLastMonthOfYear = lastMonthTimeByYear[year] === monthTime; - const monthLabel = format(monthTime, 'MMMM'); + + + {monthTimes.map((monthTime) => { + const monthDayTimes = daysByMonthTime[monthTime] || []; + const year = getYear(monthTime); + const lastMonthTimeOfYear = monthTimesByYear[year]?.[0]; + const isLastMonthOfYear = lastMonthTimeOfYear === monthTime; + const monthLabel = format(monthTime, 'MMMM'); - return ( - !!monthCalendarEvents?.length && ( + return (
{ } /> - +
- ) - ); - })} -
+ ); + })} +
+ ); }; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx new file mode 100644 index 000000000..8280ccbef --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx @@ -0,0 +1,168 @@ +import { useContext, useMemo, useState } from 'react'; +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 { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate'; +import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded'; +import { hasCalendarEventStarted } from '@/activities/calendar/utils/hasCalendarEventStarted'; + +type CalendarCurrentEventCursorProps = { + calendarEvent: CalendarEvent; +}; + +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%); + + &::before { + background-color: ${({ theme }) => theme.font.color.danger}; + border-radius: 1px; + content: ''; + display: block; + height: ${({ theme }) => theme.spacing(1)}; + width: ${({ theme }) => theme.spacing(1)}; + } +`; + +export const CalendarCurrentEventCursor = ({ + calendarEvent, +}: CalendarCurrentEventCursorProps) => { + const theme = useTheme(); + const { + calendarEventsByDayTime, + currentCalendarEvent, + getNextCalendarEvent, + updateCurrentCalendarEvent, + } = useContext(CalendarContext); + + const nextCalendarEvent = getNextCalendarEvent(calendarEvent); + const isNextEventThisMonth = + !!nextCalendarEvent && isThisMonth(nextCalendarEvent.startsAt); + + 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(calendarEvent.startsAt).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(calendarEvent.startsAt, 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 && nextCalendarEvent + ? differenceInSeconds( + startOfMonth(nextCalendarEvent.startsAt), + 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 ( + + {isCurrent && ( + { + 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, + }} + /> + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx index 8dd80ef74..cec2b37e0 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx @@ -1,6 +1,6 @@ -import { css } from '@emotion/react'; +import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { endOfDay, format, isPast } from 'date-fns'; +import { differenceInSeconds, endOfDay, format } from 'date-fns'; import { CalendarEventRow } from '@/activities/calendar/components/CalendarEventRow'; import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; @@ -11,19 +11,13 @@ type CalendarDayCardContentProps = { divider?: boolean; }; -const StyledCardContent = styled(CardContent)<{ active: boolean }>` +const StyledCardContent = styled(CardContent)` align-items: flex-start; border-color: ${({ theme }) => theme.border.color.light}; display: flex; flex-direction: row; gap: ${({ theme }) => theme.spacing(3)}; padding: ${({ theme }) => theme.spacing(2, 3)}; - - ${({ active }) => - !active && - css` - background-color: transparent; - `} `; const StyledDayContainer = styled.div` @@ -37,7 +31,7 @@ const StyledWeekDay = styled.div` font-weight: ${({ theme }) => theme.font.weight.semiBold}; `; -const StyledDateDay = styled.div` +const StyledMonthDay = styled.div` font-weight: ${({ theme }) => theme.font.weight.medium}; `; @@ -57,14 +51,33 @@ export const CalendarDayCardContent = ({ calendarEvents, divider, }: CalendarDayCardContentProps) => { + const theme = useTheme(); + const endOfDayDate = endOfDay(calendarEvents[0].startsAt); - const isPastDay = isPast(endOfDayDate); + const endsIn = differenceInSeconds(endOfDayDate, Date.now()); + + const weekDayLabel = format(endOfDayDate, 'EE'); + const monthDayLabel = format(endOfDayDate, 'dd'); + + const upcomingDayCardContentVariants = { + upcoming: {}, + ended: { backgroundColor: theme.background.primary }, + }; return ( - + - {format(endOfDayDate, 'EE')} - {format(endOfDayDate, 'dd')} + {weekDayLabel} + {monthDayLabel} {calendarEvents.map((calendarEvent) => ( diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx index 1e61d9be6..8cd98d60e 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx @@ -1,8 +1,11 @@ import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { endOfDay, format, isPast } from 'date-fns'; +import { format } from 'date-fns'; +import { CalendarCurrentEventCursor } from '@/activities/calendar/components/CalendarCurrentEventCursor'; import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate'; +import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded'; import { IconArrowRight, IconLock } from '@/ui/display/icon'; import { Card } from '@/ui/layout/card/components/Card'; import { CardContent } from '@/ui/layout/card/components/CardContent'; @@ -17,12 +20,14 @@ const StyledContainer = styled.div` display: inline-flex; gap: ${({ theme }) => theme.spacing(3)}; height: ${({ theme }) => theme.spacing(6)}; + position: relative; `; const StyledAttendanceIndicator = styled.div<{ active?: boolean }>` background-color: ${({ theme }) => theme.tag.background.gray}; height: 100%; width: ${({ theme }) => theme.spacing(1)}; + border-radius: ${({ theme }) => theme.border.radius.xs}; ${({ active, theme }) => active && @@ -68,6 +73,7 @@ const StyledVisibilityCard = styled(Card)<{ active: boolean }>` active ? theme.font.color.primary : theme.font.color.light}; border-color: ${({ theme }) => theme.border.color.light}; flex: 1 0 auto; + transition: color ${({ theme }) => theme.animation.duration.normal} ease; `; const StyledVisibilityCardContent = styled(CardContent)` @@ -87,45 +93,41 @@ export const CalendarEventRow = ({ }: CalendarEventRowProps) => { const theme = useTheme(); - const hasEventEnded = calendarEvent.endsAt - ? isPast(calendarEvent.endsAt) - : calendarEvent.isFullDay && isPast(endOfDay(calendarEvent.startsAt)); + const endsAt = getCalendarEventEndDate(calendarEvent); + const hasEnded = hasCalendarEventEnded(calendarEvent); + + const startTimeLabel = calendarEvent.isFullDay + ? 'All day' + : format(calendarEvent.startsAt, 'HH:mm'); + const endTimeLabel = calendarEvent.isFullDay ? '' : format(endsAt, 'HH:mm'); return ( - {calendarEvent.isFullDay ? ( - 'All Day' - ) : ( + {startTimeLabel} + {endTimeLabel && ( <> - {format(calendarEvent.startsAt, 'HH:mm')} - {!!calendarEvent.endsAt && ( - <> - - {format(calendarEvent.endsAt, 'HH:mm')} - - )} + + {endTimeLabel} )} {calendarEvent.visibility === 'METADATA' ? ( - + Not shared ) : ( - + {calendarEvent.title} )} + ); }; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx index 45ccf37c8..e83adb4ed 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx @@ -1,41 +1,34 @@ -import { startOfDay } from 'date-fns'; +import { useContext } from 'react'; +import styled from '@emotion/styled'; import { CalendarDayCardContent } from '@/activities/calendar/components/CalendarDayCardContent'; -import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; import { Card } from '@/ui/layout/card/components/Card'; -import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy'; -import { sortDesc } from '~/utils/sort'; type CalendarMonthCardProps = { - calendarEvents: CalendarEvent[]; + dayTimes: number[]; }; -export const CalendarMonthCard = ({ - calendarEvents, -}: CalendarMonthCardProps) => { - const calendarEventsByDayTime = groupArrayItemsBy( - calendarEvents, - ({ startsAt }) => startOfDay(startsAt).getTime(), - ); - const sortedDayTimes = Object.keys(calendarEventsByDayTime) - .map(Number) - .sort(sortDesc); +const StyledCard = styled(Card)` + overflow: visible; +`; + +export const CalendarMonthCard = ({ dayTimes }: CalendarMonthCardProps) => { + const { calendarEventsByDayTime } = useContext(CalendarContext); return ( - - {sortedDayTimes.map((dayTime, index) => { - const dayCalendarEvents = calendarEventsByDayTime[dayTime]; + + {dayTimes.map((dayTime, index) => { + const dayCalendarEvents = calendarEventsByDayTime[dayTime] || []; return ( - !!dayCalendarEvents?.length && ( - - ) + ); })} - + ); }; diff --git a/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts b/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts new file mode 100644 index 000000000..c5a6e7ee5 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts @@ -0,0 +1,19 @@ +import { createContext } from 'react'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; + +type CalendarContextValue = { + calendarEventsByDayTime: Record; + currentCalendarEvent: CalendarEvent; + getNextCalendarEvent: ( + calendarEvent: CalendarEvent, + ) => CalendarEvent | undefined; + updateCurrentCalendarEvent: () => void; +}; + +export const CalendarContext = createContext({ + calendarEventsByDayTime: {}, + currentCalendarEvent: {} as CalendarEvent, + getNextCalendarEvent: () => undefined, + updateCurrentCalendarEvent: () => {}, +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts b/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts new file mode 100644 index 000000000..3621c942e --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts @@ -0,0 +1,75 @@ +import { useMemo, useState } from 'react'; +import { getYear, isThisMonth, startOfDay, startOfMonth } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { findUpcomingCalendarEvent } from '@/activities/calendar/utils/findUpcomingCalendarEvent'; +import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy'; +import { isDefined } from '~/utils/isDefined'; +import { sortDesc } from '~/utils/sort'; + +export const useCalendarEvents = (calendarEvents: CalendarEvent[]) => { + const calendarEventsByDayTime = groupArrayItemsBy( + calendarEvents, + ({ startsAt }) => startOfDay(startsAt).getTime(), + ); + + const sortedDayTimes = Object.keys(calendarEventsByDayTime) + .map(Number) + .sort(sortDesc); + + const daysByMonthTime = groupArrayItemsBy(sortedDayTimes, (dayTime) => + startOfMonth(dayTime).getTime(), + ); + + const sortedMonthTimes = Object.keys(daysByMonthTime) + .map(Number) + .sort(sortDesc); + + const monthTimesByYear = groupArrayItemsBy(sortedMonthTimes, getYear); + + const getPreviousCalendarEvent = (calendarEvent: CalendarEvent) => { + const calendarEventIndex = calendarEvents.indexOf(calendarEvent); + return calendarEventIndex < calendarEvents.length - 1 + ? calendarEvents[calendarEventIndex + 1] + : undefined; + }; + + const getNextCalendarEvent = (calendarEvent: CalendarEvent) => { + const calendarEventIndex = calendarEvents.indexOf(calendarEvent); + return calendarEventIndex > 0 + ? calendarEvents[calendarEventIndex - 1] + : undefined; + }; + + const initialUpcomingCalendarEvent = useMemo( + () => findUpcomingCalendarEvent(calendarEvents), + [calendarEvents], + ); + const lastEventInCalendar = calendarEvents[0]; + + const [currentCalendarEvent, setCurrentCalendarEvent] = useState( + (initialUpcomingCalendarEvent && + (isThisMonth(initialUpcomingCalendarEvent.startsAt) + ? initialUpcomingCalendarEvent + : getPreviousCalendarEvent(initialUpcomingCalendarEvent))) || + lastEventInCalendar, + ); + + const updateCurrentCalendarEvent = () => { + const nextCurrentCalendarEvent = getNextCalendarEvent(currentCalendarEvent); + + if (isDefined(nextCurrentCalendarEvent)) { + setCurrentCalendarEvent(nextCurrentCalendarEvent); + } + }; + + return { + calendarEventsByDayTime, + currentCalendarEvent, + daysByMonthTime, + getNextCalendarEvent, + monthTimes: sortedMonthTimes, + monthTimesByYear, + updateCurrentCalendarEvent, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/findUpcomingCalendarEvent.test.ts b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/findUpcomingCalendarEvent.test.ts new file mode 100644 index 000000000..d6d3d556f --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/findUpcomingCalendarEvent.test.ts @@ -0,0 +1,80 @@ +import { addDays, addHours, startOfDay, subDays, subHours } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; + +import { findUpcomingCalendarEvent } from '../findUpcomingCalendarEvent'; + +const pastEvent: Pick = { + startsAt: subHours(new Date(), 2), + endsAt: subHours(new Date(), 1), + isFullDay: false, +}; +const fullDayPastEvent: Pick = { + startsAt: subDays(new Date(), 1), + isFullDay: true, +}; + +const currentEvent: Pick = { + startsAt: addHours(new Date(), 1), + endsAt: addHours(new Date(), 2), + isFullDay: false, +}; +const currentFullDayEvent: Pick = { + startsAt: startOfDay(new Date()), + isFullDay: true, +}; + +const futureEvent: Pick = { + startsAt: addDays(new Date(), 1), + endsAt: addDays(new Date(), 2), + isFullDay: false, +}; +const fullDayFutureEvent: Pick = { + startsAt: addDays(new Date(), 2), + 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(); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventEnded.test.ts b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventEnded.test.ts new file mode 100644 index 000000000..0efcf75e4 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventEnded.test.ts @@ -0,0 +1,105 @@ +import { addDays, addHours, subDays, subHours } from 'date-fns'; + +import { hasCalendarEventEnded } from '../hasCalendarEventEnded'; + +describe('hasCalendarEventEnded', () => { + describe('Event with end date', () => { + it('returns true for an event with a past end date', () => { + // Given + const startsAt = subHours(new Date(), 2); + const endsAt = subHours(new Date(), 1); + const isFullDay = false; + + // When + const result = hasCalendarEventEnded({ + startsAt, + endsAt, + isFullDay, + }); + + // Then + expect(result).toBe(true); + }); + + it('returns false for an event if end date is now', () => { + // Given + const startsAt = subHours(new Date(), 1); + const endsAt = new Date(); + const isFullDay = false; + + // When + const result = hasCalendarEventEnded({ + startsAt, + endsAt, + isFullDay, + }); + + // Then + expect(result).toBe(false); + }); + + it('returns false for an event with a future end date', () => { + // Given + const startsAt = new Date(); + const endsAt = addHours(new Date(), 1); + const isFullDay = false; + + // When + const result = hasCalendarEventEnded({ + startsAt, + endsAt, + isFullDay, + }); + + // Then + expect(result).toBe(false); + }); + }); + + describe('Full day event', () => { + it('returns true for a past full day event', () => { + // Given + const startsAt = subDays(new Date(), 1); + const isFullDay = true; + + // When + const result = hasCalendarEventEnded({ + startsAt, + isFullDay, + }); + + // Then + expect(result).toBe(true); + }); + + it('returns false for a future full day event', () => { + // Given + const startsAt = addDays(new Date(), 1); + const isFullDay = true; + + // When + const result = hasCalendarEventEnded({ + startsAt, + isFullDay, + }); + + // Then + expect(result).toBe(false); + }); + + it('returns false if the full day event is today', () => { + // Given + const startsAt = new Date(); + const isFullDay = true; + + // When + const result = hasCalendarEventEnded({ + startsAt, + isFullDay, + }); + + // Then + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventStarted.test.ts b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventStarted.test.ts new file mode 100644 index 000000000..2dcc38876 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventStarted.test.ts @@ -0,0 +1,38 @@ +import { addHours, subHours } from 'date-fns'; + +import { hasCalendarEventStarted } from '../hasCalendarEventStarted'; + +describe('hasCalendarEventStarted', () => { + it('returns true for an event with a past start date', () => { + // Given + const startsAt = subHours(new Date(), 2); + + // When + const result = hasCalendarEventStarted({ startsAt }); + + // Then + expect(result).toBe(true); + }); + + it('returns false for an event if start date is now', () => { + // Given + const startsAt = new Date(); + + // When + const result = hasCalendarEventStarted({ startsAt }); + + // Then + expect(result).toBe(false); + }); + + it('returns false for an event with a future start date', () => { + // Given + const startsAt = addHours(new Date(), 1); + + // When + const result = hasCalendarEventStarted({ startsAt }); + + // Then + expect(result).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/sortCalendarEvents.test.ts b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/sortCalendarEvents.test.ts index c56b434c2..2e0208d6e 100644 --- a/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/sortCalendarEvents.test.ts +++ b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/sortCalendarEvents.test.ts @@ -16,10 +16,12 @@ describe('sortCalendarEventsAsc', () => { const eventA = { startsAt: someDate, endsAt: someDatePlusOneHour, + isFullDay: false, }; const eventB = { startsAt: someDatePlusTwoHours, endsAt: someDatePlusThreeHours, + isFullDay: false, }; // When @@ -36,10 +38,12 @@ describe('sortCalendarEventsAsc', () => { const eventA = { startsAt: someDate, endsAt: someDatePlusTwoHours, + isFullDay: false, }; const eventB = { startsAt: someDatePlusOneHour, endsAt: someDatePlusThreeHours, + isFullDay: false, }; // When @@ -56,10 +60,12 @@ describe('sortCalendarEventsAsc', () => { const eventA = { startsAt: someDate, endsAt: someDatePlusTwoHours, + isFullDay: false, }; const eventB = { startsAt: someDate, endsAt: someDatePlusThreeHours, + isFullDay: false, }; // When @@ -76,10 +82,12 @@ describe('sortCalendarEventsAsc', () => { const eventA = { startsAt: someDate, endsAt: someDatePlusThreeHours, + isFullDay: false, }; const eventB = { startsAt: someDatePlusOneHour, endsAt: someDatePlusThreeHours, + isFullDay: false, }; // When @@ -95,9 +103,11 @@ describe('sortCalendarEventsAsc', () => { // Given const eventA = { startsAt: someDate, + isFullDay: true, }; const eventB = { startsAt: someDatePlusOneHour, + isFullDay: true, }; // When @@ -109,13 +119,15 @@ describe('sortCalendarEventsAsc', () => { expect(invertedArgsResult).toBe(1); }); - it('returns 0 for events with same start date and no end date', () => { + it('returns 0 for full day events with the same start date', () => { // Given const eventA = { startsAt: someDate, + isFullDay: true, }; const eventB = { startsAt: someDate, + isFullDay: true, }; // When @@ -127,14 +139,16 @@ describe('sortCalendarEventsAsc', () => { expect(invertedArgsResult).toBe(0); }); - it('returns 0 for events with same start date if one of them has no end date', () => { + it('sorts the full day event last for two events with the same start date if one is full day', () => { // Given const eventA = { startsAt: someDate, endsAt: someDatePlusOneHour, + isFullDay: false, }; const eventB = { startsAt: someDate, + isFullDay: true, }; // When @@ -142,8 +156,8 @@ describe('sortCalendarEventsAsc', () => { const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); // Then - expect(result).toBe(0); - expect(invertedArgsResult).toBe(0); + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); }); }); @@ -153,10 +167,12 @@ describe('sortCalendarEventsDesc', () => { const eventA = { startsAt: someDate, endsAt: someDatePlusOneHour, + isFullDay: false, }; const eventB = { startsAt: someDatePlusTwoHours, endsAt: someDatePlusThreeHours, + isFullDay: false, }; // When @@ -173,10 +189,12 @@ describe('sortCalendarEventsDesc', () => { const eventA = { startsAt: someDate, endsAt: someDatePlusTwoHours, + isFullDay: false, }; const eventB = { startsAt: someDatePlusOneHour, endsAt: someDatePlusThreeHours, + isFullDay: false, }; // When @@ -193,10 +211,12 @@ describe('sortCalendarEventsDesc', () => { const eventA = { startsAt: someDate, endsAt: someDatePlusTwoHours, + isFullDay: false, }; const eventB = { startsAt: someDate, endsAt: someDatePlusThreeHours, + isFullDay: false, }; // When @@ -213,10 +233,12 @@ describe('sortCalendarEventsDesc', () => { const eventA = { startsAt: someDate, endsAt: someDatePlusThreeHours, + isFullDay: false, }; const eventB = { startsAt: someDatePlusOneHour, endsAt: someDatePlusThreeHours, + isFullDay: false, }; // When @@ -232,9 +254,11 @@ describe('sortCalendarEventsDesc', () => { // Given const eventA = { startsAt: someDate, + isFullDay: true, }; const eventB = { startsAt: someDatePlusOneHour, + isFullDay: true, }; // When @@ -246,13 +270,15 @@ describe('sortCalendarEventsDesc', () => { expect(invertedArgsResult).toBe(-1); }); - it('returns 0 for events with same start date and no end date', () => { + it('returns 0 for full day events with the same start date', () => { // Given const eventA = { startsAt: someDate, + isFullDay: true, }; const eventB = { startsAt: someDate, + isFullDay: true, }; // When @@ -264,14 +290,16 @@ describe('sortCalendarEventsDesc', () => { expect(invertedArgsResult === 0).toBe(true); }); - it('returns 0 for events with same start date if one of them has no end date', () => { + it('sorts the full day event first for two events with the same start date if one is full day', () => { // Given const eventA = { startsAt: someDate, endsAt: someDatePlusOneHour, + isFullDay: false, }; const eventB = { startsAt: someDate, + isFullDay: true, }; // When @@ -279,7 +307,7 @@ describe('sortCalendarEventsDesc', () => { const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); // Then - expect(result === 0).toBe(true); - expect(invertedArgsResult === 0).toBe(true); + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); }); }); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/findUpcomingCalendarEvent.ts b/packages/twenty-front/src/modules/activities/calendar/utils/findUpcomingCalendarEvent.ts new file mode 100644 index 000000000..250a45112 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/findUpcomingCalendarEvent.ts @@ -0,0 +1,12 @@ +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, +>( + calendarEvents: T[], +) => + [...calendarEvents] + .sort(sortCalendarEventsAsc) + .find((calendarEvent) => !hasCalendarEventEnded(calendarEvent)); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/getCalendarEventEndDate.ts b/packages/twenty-front/src/modules/activities/calendar/utils/getCalendarEventEndDate.ts new file mode 100644 index 000000000..0d25a605b --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/getCalendarEventEndDate.ts @@ -0,0 +1,7 @@ +import { endOfDay } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; + +export const getCalendarEventEndDate = ( + calendarEvent: Pick, +) => calendarEvent.endsAt ?? endOfDay(calendarEvent.startsAt); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventEnded.ts b/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventEnded.ts new file mode 100644 index 000000000..9800634db --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventEnded.ts @@ -0,0 +1,8 @@ +import { isPast } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate'; + +export const hasCalendarEventEnded = ( + calendarEvent: Pick, +) => isPast(getCalendarEventEndDate(calendarEvent)); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventStarted.ts b/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventStarted.ts new file mode 100644 index 000000000..ec7d7a2c5 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventStarted.ts @@ -0,0 +1,7 @@ +import { isPast } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; + +export const hasCalendarEventStarted = ( + calendarEvent: Pick, +) => isPast(calendarEvent.startsAt); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/sortCalendarEvents.ts b/packages/twenty-front/src/modules/activities/calendar/utils/sortCalendarEvents.ts index 2619d2887..fc3b77353 100644 --- a/packages/twenty-front/src/modules/activities/calendar/utils/sortCalendarEvents.ts +++ b/packages/twenty-front/src/modules/activities/calendar/utils/sortCalendarEvents.ts @@ -1,31 +1,27 @@ import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; -import { isDefined } from '~/utils/isDefined'; +import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate'; import { sortAsc } from '~/utils/sort'; export const sortCalendarEventsAsc = ( - calendarEventA: Pick, - calendarEventB: Pick, + calendarEventA: Pick, + calendarEventB: Pick, ) => { const startsAtSort = sortAsc( calendarEventA.startsAt.getTime(), calendarEventB.startsAt.getTime(), ); - if ( - startsAtSort === 0 && - isDefined(calendarEventA.endsAt) && - isDefined(calendarEventB.endsAt) - ) { - return sortAsc( - calendarEventA.endsAt.getTime(), - calendarEventB.endsAt.getTime(), - ); + if (startsAtSort === 0) { + const endsAtA = getCalendarEventEndDate(calendarEventA); + const endsAtB = getCalendarEventEndDate(calendarEventB); + + return sortAsc(endsAtA.getTime(), endsAtB.getTime()); } return startsAtSort; }; export const sortCalendarEventsDesc = ( - calendarEventA: Pick, - calendarEventB: Pick, + calendarEventA: Pick, + calendarEventB: Pick, ) => -sortCalendarEventsAsc(calendarEventA, calendarEventB); diff --git a/packages/twenty-front/src/modules/ui/layout/card/components/CardContent.tsx b/packages/twenty-front/src/modules/ui/layout/card/components/CardContent.tsx index 7b7fbb148..5c7c5fb94 100644 --- a/packages/twenty-front/src/modules/ui/layout/card/components/CardContent.tsx +++ b/packages/twenty-front/src/modules/ui/layout/card/components/CardContent.tsx @@ -1,7 +1,8 @@ import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import { motion } from 'framer-motion'; -const StyledCardContent = styled.div<{ divider?: boolean }>` +const StyledCardContent = styled(motion.div)<{ divider?: boolean }>` background-color: ${({ theme }) => theme.background.secondary}; padding: ${({ theme }) => theme.spacing(4)};