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:
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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) => (
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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: () => {},
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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));
|
||||||
@ -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);
|
||||||
@ -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));
|
||||||
@ -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);
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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)};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user