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 { 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 (
|
||||
<StyledContainer>
|
||||
{sortedMonthTimes.map((monthTime) => {
|
||||
const monthCalendarEvents = calendarEventsByMonthTime[monthTime];
|
||||
const year = getYear(monthTime);
|
||||
const isLastMonthOfYear = lastMonthTimeByYear[year] === monthTime;
|
||||
const monthLabel = format(monthTime, 'MMMM');
|
||||
<CalendarContext.Provider
|
||||
value={{
|
||||
calendarEventsByDayTime,
|
||||
currentCalendarEvent,
|
||||
getNextCalendarEvent,
|
||||
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 (
|
||||
!!monthCalendarEvents?.length && (
|
||||
return (
|
||||
<Section key={monthTime}>
|
||||
<H3Title
|
||||
title={
|
||||
@ -58,11 +64,11 @@ export const Calendar = () => {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<CalendarMonthCard calendarEvents={monthCalendarEvents} />
|
||||
<CalendarMonthCard dayTimes={monthDayTimes} />
|
||||
</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 { 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 (
|
||||
<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>
|
||||
<StyledWeekDay>{format(endOfDayDate, 'EE')}</StyledWeekDay>
|
||||
<StyledDateDay>{format(endOfDayDate, 'dd')}</StyledDateDay>
|
||||
<StyledWeekDay>{weekDayLabel}</StyledWeekDay>
|
||||
<StyledMonthDay>{monthDayLabel}</StyledMonthDay>
|
||||
</StyledDayContainer>
|
||||
<StyledEvents>
|
||||
{calendarEvents.map((calendarEvent) => (
|
||||
|
||||
@ -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 (
|
||||
<StyledContainer className={className}>
|
||||
<StyledAttendanceIndicator />
|
||||
<StyledLabels>
|
||||
<StyledTime>
|
||||
{calendarEvent.isFullDay ? (
|
||||
'All Day'
|
||||
) : (
|
||||
{startTimeLabel}
|
||||
{endTimeLabel && (
|
||||
<>
|
||||
{format(calendarEvent.startsAt, 'HH:mm')}
|
||||
{!!calendarEvent.endsAt && (
|
||||
<>
|
||||
<IconArrowRight size={theme.icon.size.sm} />
|
||||
{format(calendarEvent.endsAt, 'HH:mm')}
|
||||
</>
|
||||
)}
|
||||
<IconArrowRight size={theme.icon.size.sm} />
|
||||
{endTimeLabel}
|
||||
</>
|
||||
)}
|
||||
</StyledTime>
|
||||
{calendarEvent.visibility === 'METADATA' ? (
|
||||
<StyledVisibilityCard active={!hasEventEnded}>
|
||||
<StyledVisibilityCard active={!hasEnded}>
|
||||
<StyledVisibilityCardContent>
|
||||
<IconLock size={theme.icon.size.sm} />
|
||||
Not shared
|
||||
</StyledVisibilityCardContent>
|
||||
</StyledVisibilityCard>
|
||||
) : (
|
||||
<StyledTitle
|
||||
active={!hasEventEnded}
|
||||
canceled={!!calendarEvent.isCanceled}
|
||||
>
|
||||
<StyledTitle active={!hasEnded} canceled={!!calendarEvent.isCanceled}>
|
||||
{calendarEvent.title}
|
||||
</StyledTitle>
|
||||
)}
|
||||
</StyledLabels>
|
||||
<CalendarCurrentEventCursor calendarEvent={calendarEvent} />
|
||||
</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 { 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 (
|
||||
<Card fullWidth>
|
||||
{sortedDayTimes.map((dayTime, index) => {
|
||||
const dayCalendarEvents = calendarEventsByDayTime[dayTime];
|
||||
<StyledCard fullWidth>
|
||||
{dayTimes.map((dayTime, index) => {
|
||||
const dayCalendarEvents = calendarEventsByDayTime[dayTime] || [];
|
||||
|
||||
return (
|
||||
!!dayCalendarEvents?.length && (
|
||||
<CalendarDayCardContent
|
||||
key={dayTime}
|
||||
calendarEvents={dayCalendarEvents}
|
||||
divider={index < sortedDayTimes.length - 1}
|
||||
/>
|
||||
)
|
||||
<CalendarDayCardContent
|
||||
key={dayTime}
|
||||
calendarEvents={dayCalendarEvents}
|
||||
divider={index < dayTimes.length - 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</StyledCard>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user