feat: add next event indicator to Show Page Calendar tab (#4348)

* feat: add next event indicator to Show Page Calendar tab

Closes #4289

* feat: improve calendar animation

* refactor: add some utils and fix sorting edge case with full day

* refactor: rename CalendarCurrentEventIndicator to CalendarCurrentEventCursor

* fix: fix tests

* Fix lint

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Thaïs
2024-03-12 10:27:51 -03:00
committed by GitHub
parent 0d8e700239
commit ab4ab1dfba
17 changed files with 668 additions and 110 deletions

View File

@ -1,14 +1,13 @@
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { format, getYear, startOfMonth } from 'date-fns'; import { format, getYear } from 'date-fns';
import mapValues from 'lodash.mapvalues';
import { CalendarMonthCard } from '@/activities/calendar/components/CalendarMonthCard'; 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 { sortCalendarEventsDesc } from '@/activities/calendar/utils/sortCalendarEvents';
import { H3Title } from '@/ui/display/typography/components/H3Title'; import { H3Title } from '@/ui/display/typography/components/H3Title';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { mockedCalendarEvents } from '~/testing/mock-data/calendar'; import { mockedCalendarEvents } from '~/testing/mock-data/calendar';
import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy';
import { sortDesc } from '~/utils/sort';
const StyledContainer = styled.div` const StyledContainer = styled.div`
box-sizing: border-box; box-sizing: border-box;
@ -27,28 +26,35 @@ export const Calendar = () => {
const sortedCalendarEvents = [...mockedCalendarEvents].sort( const sortedCalendarEvents = [...mockedCalendarEvents].sort(
sortCalendarEventsDesc, sortCalendarEventsDesc,
); );
const calendarEventsByMonthTime = groupArrayItemsBy(
sortedCalendarEvents, const {
({ startsAt }) => startOfMonth(startsAt).getTime(), calendarEventsByDayTime,
); currentCalendarEvent,
const sortedMonthTimes = Object.keys(calendarEventsByMonthTime) daysByMonthTime,
.map(Number) getNextCalendarEvent,
.sort(sortDesc); monthTimes,
const monthTimesByYear = groupArrayItemsBy(sortedMonthTimes, getYear); monthTimesByYear,
const lastMonthTimeByYear = mapValues(monthTimesByYear, (monthTimes = []) => updateCurrentCalendarEvent,
Math.max(...monthTimes), } = useCalendarEvents(sortedCalendarEvents);
);
return ( return (
<StyledContainer> <CalendarContext.Provider
{sortedMonthTimes.map((monthTime) => { value={{
const monthCalendarEvents = calendarEventsByMonthTime[monthTime]; calendarEventsByDayTime,
const year = getYear(monthTime); currentCalendarEvent,
const isLastMonthOfYear = lastMonthTimeByYear[year] === monthTime; getNextCalendarEvent,
const monthLabel = format(monthTime, 'MMMM'); updateCurrentCalendarEvent,
}}
>
<StyledContainer>
{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 ( return (
!!monthCalendarEvents?.length && (
<Section key={monthTime}> <Section key={monthTime}>
<H3Title <H3Title
title={ title={
@ -58,11 +64,11 @@ export const Calendar = () => {
</> </>
} }
/> />
<CalendarMonthCard calendarEvents={monthCalendarEvents} /> <CalendarMonthCard dayTimes={monthDayTimes} />
</Section> </Section>
) );
); })}
})} </StyledContainer>
</StyledContainer> </CalendarContext.Provider>
); );
}; };

View File

@ -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 (
<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,
}}
/>
)}
</AnimatePresence>
);
};

View File

@ -1,6 +1,6 @@
import { css } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; 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 { CalendarEventRow } from '@/activities/calendar/components/CalendarEventRow';
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
@ -11,19 +11,13 @@ type CalendarDayCardContentProps = {
divider?: boolean; divider?: boolean;
}; };
const StyledCardContent = styled(CardContent)<{ active: boolean }>` const StyledCardContent = styled(CardContent)`
align-items: flex-start; align-items: flex-start;
border-color: ${({ theme }) => theme.border.color.light}; border-color: ${({ theme }) => theme.border.color.light};
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: ${({ theme }) => theme.spacing(3)}; gap: ${({ theme }) => theme.spacing(3)};
padding: ${({ theme }) => theme.spacing(2, 3)}; padding: ${({ theme }) => theme.spacing(2, 3)};
${({ active }) =>
!active &&
css`
background-color: transparent;
`}
`; `;
const StyledDayContainer = styled.div` const StyledDayContainer = styled.div`
@ -37,7 +31,7 @@ const StyledWeekDay = styled.div`
font-weight: ${({ theme }) => theme.font.weight.semiBold}; font-weight: ${({ theme }) => theme.font.weight.semiBold};
`; `;
const StyledDateDay = styled.div` const StyledMonthDay = styled.div`
font-weight: ${({ theme }) => theme.font.weight.medium}; font-weight: ${({ theme }) => theme.font.weight.medium};
`; `;
@ -57,14 +51,33 @@ export const CalendarDayCardContent = ({
calendarEvents, calendarEvents,
divider, divider,
}: CalendarDayCardContentProps) => { }: CalendarDayCardContentProps) => {
const theme = useTheme();
const endOfDayDate = endOfDay(calendarEvents[0].startsAt); 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 ( return (
<StyledCardContent active={!isPastDay} divider={divider}> <StyledCardContent
divider={divider}
initial="upcoming"
animate="ended"
variants={upcomingDayCardContentVariants}
transition={{
delay: Math.max(0, endsIn),
duration: theme.animation.duration.fast,
}}
>
<StyledDayContainer> <StyledDayContainer>
<StyledWeekDay>{format(endOfDayDate, 'EE')}</StyledWeekDay> <StyledWeekDay>{weekDayLabel}</StyledWeekDay>
<StyledDateDay>{format(endOfDayDate, 'dd')}</StyledDateDay> <StyledMonthDay>{monthDayLabel}</StyledMonthDay>
</StyledDayContainer> </StyledDayContainer>
<StyledEvents> <StyledEvents>
{calendarEvents.map((calendarEvent) => ( {calendarEvents.map((calendarEvent) => (

View File

@ -1,8 +1,11 @@
import { css, useTheme } from '@emotion/react'; import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled'; 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 { 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 { IconArrowRight, IconLock } from '@/ui/display/icon';
import { Card } from '@/ui/layout/card/components/Card'; import { Card } from '@/ui/layout/card/components/Card';
import { CardContent } from '@/ui/layout/card/components/CardContent'; import { CardContent } from '@/ui/layout/card/components/CardContent';
@ -17,12 +20,14 @@ const StyledContainer = styled.div`
display: inline-flex; display: inline-flex;
gap: ${({ theme }) => theme.spacing(3)}; gap: ${({ theme }) => theme.spacing(3)};
height: ${({ theme }) => theme.spacing(6)}; height: ${({ theme }) => theme.spacing(6)};
position: relative;
`; `;
const StyledAttendanceIndicator = styled.div<{ active?: boolean }>` const StyledAttendanceIndicator = styled.div<{ active?: boolean }>`
background-color: ${({ theme }) => theme.tag.background.gray}; background-color: ${({ theme }) => theme.tag.background.gray};
height: 100%; height: 100%;
width: ${({ theme }) => theme.spacing(1)}; width: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.border.radius.xs};
${({ active, theme }) => ${({ active, theme }) =>
active && active &&
@ -68,6 +73,7 @@ const StyledVisibilityCard = styled(Card)<{ active: boolean }>`
active ? theme.font.color.primary : theme.font.color.light}; active ? theme.font.color.primary : theme.font.color.light};
border-color: ${({ theme }) => theme.border.color.light}; border-color: ${({ theme }) => theme.border.color.light};
flex: 1 0 auto; flex: 1 0 auto;
transition: color ${({ theme }) => theme.animation.duration.normal} ease;
`; `;
const StyledVisibilityCardContent = styled(CardContent)` const StyledVisibilityCardContent = styled(CardContent)`
@ -87,45 +93,41 @@ export const CalendarEventRow = ({
}: CalendarEventRowProps) => { }: CalendarEventRowProps) => {
const theme = useTheme(); const theme = useTheme();
const hasEventEnded = calendarEvent.endsAt const endsAt = getCalendarEventEndDate(calendarEvent);
? isPast(calendarEvent.endsAt) const hasEnded = hasCalendarEventEnded(calendarEvent);
: calendarEvent.isFullDay && isPast(endOfDay(calendarEvent.startsAt));
const startTimeLabel = calendarEvent.isFullDay
? 'All day'
: format(calendarEvent.startsAt, 'HH:mm');
const endTimeLabel = calendarEvent.isFullDay ? '' : format(endsAt, 'HH:mm');
return ( return (
<StyledContainer className={className}> <StyledContainer className={className}>
<StyledAttendanceIndicator /> <StyledAttendanceIndicator />
<StyledLabels> <StyledLabels>
<StyledTime> <StyledTime>
{calendarEvent.isFullDay ? ( {startTimeLabel}
'All Day' {endTimeLabel && (
) : (
<> <>
{format(calendarEvent.startsAt, 'HH:mm')} <IconArrowRight size={theme.icon.size.sm} />
{!!calendarEvent.endsAt && ( {endTimeLabel}
<>
<IconArrowRight size={theme.icon.size.sm} />
{format(calendarEvent.endsAt, 'HH:mm')}
</>
)}
</> </>
)} )}
</StyledTime> </StyledTime>
{calendarEvent.visibility === 'METADATA' ? ( {calendarEvent.visibility === 'METADATA' ? (
<StyledVisibilityCard active={!hasEventEnded}> <StyledVisibilityCard active={!hasEnded}>
<StyledVisibilityCardContent> <StyledVisibilityCardContent>
<IconLock size={theme.icon.size.sm} /> <IconLock size={theme.icon.size.sm} />
Not shared Not shared
</StyledVisibilityCardContent> </StyledVisibilityCardContent>
</StyledVisibilityCard> </StyledVisibilityCard>
) : ( ) : (
<StyledTitle <StyledTitle active={!hasEnded} canceled={!!calendarEvent.isCanceled}>
active={!hasEventEnded}
canceled={!!calendarEvent.isCanceled}
>
{calendarEvent.title} {calendarEvent.title}
</StyledTitle> </StyledTitle>
)} )}
</StyledLabels> </StyledLabels>
<CalendarCurrentEventCursor calendarEvent={calendarEvent} />
</StyledContainer> </StyledContainer>
); );
}; };

View File

@ -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 { 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 { Card } from '@/ui/layout/card/components/Card';
import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy';
import { sortDesc } from '~/utils/sort';
type CalendarMonthCardProps = { type CalendarMonthCardProps = {
calendarEvents: CalendarEvent[]; dayTimes: number[];
}; };
export const CalendarMonthCard = ({ const StyledCard = styled(Card)`
calendarEvents, overflow: visible;
}: CalendarMonthCardProps) => { `;
const calendarEventsByDayTime = groupArrayItemsBy(
calendarEvents, export const CalendarMonthCard = ({ dayTimes }: CalendarMonthCardProps) => {
({ startsAt }) => startOfDay(startsAt).getTime(), const { calendarEventsByDayTime } = useContext(CalendarContext);
);
const sortedDayTimes = Object.keys(calendarEventsByDayTime)
.map(Number)
.sort(sortDesc);
return ( return (
<Card fullWidth> <StyledCard fullWidth>
{sortedDayTimes.map((dayTime, index) => { {dayTimes.map((dayTime, index) => {
const dayCalendarEvents = calendarEventsByDayTime[dayTime]; const dayCalendarEvents = calendarEventsByDayTime[dayTime] || [];
return ( return (
!!dayCalendarEvents?.length && ( <CalendarDayCardContent
<CalendarDayCardContent key={dayTime}
key={dayTime} calendarEvents={dayCalendarEvents}
calendarEvents={dayCalendarEvents} divider={index < dayTimes.length - 1}
divider={index < sortedDayTimes.length - 1} />
/>
)
); );
})} })}
</Card> </StyledCard>
); );
}; };

View File

@ -0,0 +1,19 @@
import { createContext } from 'react';
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
type CalendarContextValue = {
calendarEventsByDayTime: Record<number, CalendarEvent[] | undefined>;
currentCalendarEvent: CalendarEvent;
getNextCalendarEvent: (
calendarEvent: CalendarEvent,
) => CalendarEvent | undefined;
updateCurrentCalendarEvent: () => void;
};
export const CalendarContext = createContext<CalendarContextValue>({
calendarEventsByDayTime: {},
currentCalendarEvent: {} as CalendarEvent,
getNextCalendarEvent: () => undefined,
updateCurrentCalendarEvent: () => {},
});

View File

@ -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,
};
};

View File

@ -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<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'> = {
startsAt: subHours(new Date(), 2),
endsAt: subHours(new Date(), 1),
isFullDay: false,
};
const fullDayPastEvent: Pick<CalendarEvent, 'startsAt' | 'isFullDay'> = {
startsAt: subDays(new Date(), 1),
isFullDay: true,
};
const currentEvent: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'> = {
startsAt: addHours(new Date(), 1),
endsAt: addHours(new Date(), 2),
isFullDay: false,
};
const currentFullDayEvent: Pick<CalendarEvent, 'startsAt' | 'isFullDay'> = {
startsAt: startOfDay(new Date()),
isFullDay: true,
};
const futureEvent: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'> = {
startsAt: addDays(new Date(), 1),
endsAt: addDays(new Date(), 2),
isFullDay: false,
};
const fullDayFutureEvent: Pick<CalendarEvent, 'startsAt' | 'isFullDay'> = {
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();
});
});

View File

@ -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);
});
});
});

View File

@ -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);
});
});

View File

@ -16,10 +16,12 @@ describe('sortCalendarEventsAsc', () => {
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusOneHour, endsAt: someDatePlusOneHour,
isFullDay: false,
}; };
const eventB = { const eventB = {
startsAt: someDatePlusTwoHours, startsAt: someDatePlusTwoHours,
endsAt: someDatePlusThreeHours, endsAt: someDatePlusThreeHours,
isFullDay: false,
}; };
// When // When
@ -36,10 +38,12 @@ describe('sortCalendarEventsAsc', () => {
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusTwoHours, endsAt: someDatePlusTwoHours,
isFullDay: false,
}; };
const eventB = { const eventB = {
startsAt: someDatePlusOneHour, startsAt: someDatePlusOneHour,
endsAt: someDatePlusThreeHours, endsAt: someDatePlusThreeHours,
isFullDay: false,
}; };
// When // When
@ -56,10 +60,12 @@ describe('sortCalendarEventsAsc', () => {
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusTwoHours, endsAt: someDatePlusTwoHours,
isFullDay: false,
}; };
const eventB = { const eventB = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusThreeHours, endsAt: someDatePlusThreeHours,
isFullDay: false,
}; };
// When // When
@ -76,10 +82,12 @@ describe('sortCalendarEventsAsc', () => {
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusThreeHours, endsAt: someDatePlusThreeHours,
isFullDay: false,
}; };
const eventB = { const eventB = {
startsAt: someDatePlusOneHour, startsAt: someDatePlusOneHour,
endsAt: someDatePlusThreeHours, endsAt: someDatePlusThreeHours,
isFullDay: false,
}; };
// When // When
@ -95,9 +103,11 @@ describe('sortCalendarEventsAsc', () => {
// Given // Given
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
isFullDay: true,
}; };
const eventB = { const eventB = {
startsAt: someDatePlusOneHour, startsAt: someDatePlusOneHour,
isFullDay: true,
}; };
// When // When
@ -109,13 +119,15 @@ describe('sortCalendarEventsAsc', () => {
expect(invertedArgsResult).toBe(1); 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 // Given
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
isFullDay: true,
}; };
const eventB = { const eventB = {
startsAt: someDate, startsAt: someDate,
isFullDay: true,
}; };
// When // When
@ -127,14 +139,16 @@ describe('sortCalendarEventsAsc', () => {
expect(invertedArgsResult).toBe(0); 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 // Given
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusOneHour, endsAt: someDatePlusOneHour,
isFullDay: false,
}; };
const eventB = { const eventB = {
startsAt: someDate, startsAt: someDate,
isFullDay: true,
}; };
// When // When
@ -142,8 +156,8 @@ describe('sortCalendarEventsAsc', () => {
const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA);
// Then // Then
expect(result).toBe(0); expect(result).toBe(-1);
expect(invertedArgsResult).toBe(0); expect(invertedArgsResult).toBe(1);
}); });
}); });
@ -153,10 +167,12 @@ describe('sortCalendarEventsDesc', () => {
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusOneHour, endsAt: someDatePlusOneHour,
isFullDay: false,
}; };
const eventB = { const eventB = {
startsAt: someDatePlusTwoHours, startsAt: someDatePlusTwoHours,
endsAt: someDatePlusThreeHours, endsAt: someDatePlusThreeHours,
isFullDay: false,
}; };
// When // When
@ -173,10 +189,12 @@ describe('sortCalendarEventsDesc', () => {
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusTwoHours, endsAt: someDatePlusTwoHours,
isFullDay: false,
}; };
const eventB = { const eventB = {
startsAt: someDatePlusOneHour, startsAt: someDatePlusOneHour,
endsAt: someDatePlusThreeHours, endsAt: someDatePlusThreeHours,
isFullDay: false,
}; };
// When // When
@ -193,10 +211,12 @@ describe('sortCalendarEventsDesc', () => {
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusTwoHours, endsAt: someDatePlusTwoHours,
isFullDay: false,
}; };
const eventB = { const eventB = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusThreeHours, endsAt: someDatePlusThreeHours,
isFullDay: false,
}; };
// When // When
@ -213,10 +233,12 @@ describe('sortCalendarEventsDesc', () => {
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusThreeHours, endsAt: someDatePlusThreeHours,
isFullDay: false,
}; };
const eventB = { const eventB = {
startsAt: someDatePlusOneHour, startsAt: someDatePlusOneHour,
endsAt: someDatePlusThreeHours, endsAt: someDatePlusThreeHours,
isFullDay: false,
}; };
// When // When
@ -232,9 +254,11 @@ describe('sortCalendarEventsDesc', () => {
// Given // Given
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
isFullDay: true,
}; };
const eventB = { const eventB = {
startsAt: someDatePlusOneHour, startsAt: someDatePlusOneHour,
isFullDay: true,
}; };
// When // When
@ -246,13 +270,15 @@ describe('sortCalendarEventsDesc', () => {
expect(invertedArgsResult).toBe(-1); 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 // Given
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
isFullDay: true,
}; };
const eventB = { const eventB = {
startsAt: someDate, startsAt: someDate,
isFullDay: true,
}; };
// When // When
@ -264,14 +290,16 @@ describe('sortCalendarEventsDesc', () => {
expect(invertedArgsResult === 0).toBe(true); 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 // Given
const eventA = { const eventA = {
startsAt: someDate, startsAt: someDate,
endsAt: someDatePlusOneHour, endsAt: someDatePlusOneHour,
isFullDay: false,
}; };
const eventB = { const eventB = {
startsAt: someDate, startsAt: someDate,
isFullDay: true,
}; };
// When // When
@ -279,7 +307,7 @@ describe('sortCalendarEventsDesc', () => {
const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA);
// Then // Then
expect(result === 0).toBe(true); expect(result).toBe(1);
expect(invertedArgsResult === 0).toBe(true); expect(invertedArgsResult).toBe(-1);
}); });
}); });

View File

@ -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<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'>,
>(
calendarEvents: T[],
) =>
[...calendarEvents]
.sort(sortCalendarEventsAsc)
.find((calendarEvent) => !hasCalendarEventEnded(calendarEvent));

View File

@ -0,0 +1,7 @@
import { endOfDay } from 'date-fns';
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
export const getCalendarEventEndDate = (
calendarEvent: Pick<CalendarEvent, 'endsAt' | 'isFullDay' | 'startsAt'>,
) => calendarEvent.endsAt ?? endOfDay(calendarEvent.startsAt);

View File

@ -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<CalendarEvent, 'endsAt' | 'isFullDay' | 'startsAt'>,
) => isPast(getCalendarEventEndDate(calendarEvent));

View File

@ -0,0 +1,7 @@
import { isPast } from 'date-fns';
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
export const hasCalendarEventStarted = (
calendarEvent: Pick<CalendarEvent, 'startsAt'>,
) => isPast(calendarEvent.startsAt);

View File

@ -1,31 +1,27 @@
import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
import { isDefined } from '~/utils/isDefined'; import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate';
import { sortAsc } from '~/utils/sort'; import { sortAsc } from '~/utils/sort';
export const sortCalendarEventsAsc = ( export const sortCalendarEventsAsc = (
calendarEventA: Pick<CalendarEvent, 'startsAt' | 'endsAt'>, calendarEventA: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'>,
calendarEventB: Pick<CalendarEvent, 'startsAt' | 'endsAt'>, calendarEventB: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'>,
) => { ) => {
const startsAtSort = sortAsc( const startsAtSort = sortAsc(
calendarEventA.startsAt.getTime(), calendarEventA.startsAt.getTime(),
calendarEventB.startsAt.getTime(), calendarEventB.startsAt.getTime(),
); );
if ( if (startsAtSort === 0) {
startsAtSort === 0 && const endsAtA = getCalendarEventEndDate(calendarEventA);
isDefined(calendarEventA.endsAt) && const endsAtB = getCalendarEventEndDate(calendarEventB);
isDefined(calendarEventB.endsAt)
) { return sortAsc(endsAtA.getTime(), endsAtB.getTime());
return sortAsc(
calendarEventA.endsAt.getTime(),
calendarEventB.endsAt.getTime(),
);
} }
return startsAtSort; return startsAtSort;
}; };
export const sortCalendarEventsDesc = ( export const sortCalendarEventsDesc = (
calendarEventA: Pick<CalendarEvent, 'startsAt' | 'endsAt'>, calendarEventA: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'>,
calendarEventB: Pick<CalendarEvent, 'startsAt' | 'endsAt'>, calendarEventB: Pick<CalendarEvent, 'startsAt' | 'endsAt' | 'isFullDay'>,
) => -sortCalendarEventsAsc(calendarEventA, calendarEventB); ) => -sortCalendarEventsAsc(calendarEventA, calendarEventB);

View File

@ -1,7 +1,8 @@
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import styled from '@emotion/styled'; 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}; background-color: ${({ theme }) => theme.background.secondary};
padding: ${({ theme }) => theme.spacing(4)}; padding: ${({ theme }) => theme.spacing(4)};