What’s up, folks! This is the concluding section of this series, and here I will discuss building the month view for the calendar. Now let’s begin.
In this section, I’ll cover:
Month layout
For the month layout, we first need to get an array of days from the first day of the week containing the first day of the month to the last day of the week containing the last day of the month. After that, the days can be grouped by weeks.
import { MonthWeekView } from "./month-week-view"; import { format, endOfDay, endOfWeek, endOfMonth, startOfDay, startOfWeek, startOfMonth, eachDayOfInterval,
} from "date-fns"; import { Event } from "../types"; type MonthViewProps = { date: Date; events?: Event[];
}; export const MonthView: React.FC<MonthViewProps> = ({ date, events = [] }) => { const days = eachDayOfInterval({ start: startOfWeek(date), end: endOfWeek(date), }); const weeks = eachDayOfInterval({ start: startOfWeek(startOfMonth(date)), end: endOfWeek(endOfMonth(date)), }).reduce((acc, cur, idx) => { const groupIndex = Math.floor(idx / 7); if (!acc[groupIndex]) { acc[groupIndex] = []; } acc[groupIndex].push(cur); return acc; }, [] as Date[][]); return ( <section id="calendar-month-view" className="flex-1 flex flex-col"> <div className="w-full flex"> {days.map((day) => ( <div key={day.toISOString()} className="flex-1 flex justify-center border-t border-l last:border-r" > <span className="mt-2 text-sm font-semibold text-gray-500"> {format(day, "iii")} </span> </div> ))} </div> <div className="flex-1 flex flex-col"> {weeks.map((week) => { const weekEndDate = endOfDay(week[week.length - 1]); const weekStartDate = startOfDay(week[0]); const weekKey = weekStartDate.toISOString() + "-" + weekEndDate.toISOString(); const props = { week }; return <MonthWeekView {...props} key={weekKey} />; })} </div> </section> );
};
Inside the <MonthWeekView />
, I used class-variance-authority
to simplify working with style variants. Since I used Google Calendar as the base design, it required additional styling to display day labels for certain days, such as the start of the month and the current day.
import { cn } from "../../utils";
import { cva } from "class-variance-authority";
import { format, isToday, isSameDay, startOfMonth } from "date-fns"; import { Event } from "../types";
import { WeekEvent } from "./group-events"; type MonthWeekViewProps = { week: Date[]; week_events: WeekEvent[]; week_day_events: Record<string, Event[]>;
}; const dayLabelVariants = cva( "my-2 flex justify-center items-center text-sm font-semibold", { variants: { variant: { default: "bg-transparent text-gray-500", today: "bg-blue-400 text-white", }, size: { default: "w-6 h-6 rounded-full", startOfMonth: "px-2 rounded-xl", }, }, defaultVariants: { variant: "default", size: "default", }, }
); export const MonthWeekView: React.FC<MonthWeekViewProps> = ({ week, week_events = [], week_day_events = {},
}) => { return ( <div className="w-full h-full relative"> <div className="w-full h-full flex"> {week.map((day) => { const isStartOfMonth = isSameDay(day, startOfMonth(day)); const variant = isToday(day) ? "today" : "default"; const size = isStartOfMonth ? "startOfMonth" : "default"; const text = isStartOfMonth ? format(day, "d, MMM") : format(day, "d"); const className = cn(dayLabelVariants({ variant, size })); return ( <div key={"day-label-" + day.toISOString()} className="flex-1 flex flex-col items-center border-b border-l last:border-r" > <h2 className={className}>{text}</h2> </div> ); })} </div> <div className="mt-10 mb-6 absolute inset-0 space-y-1 overflow-hidden"> </div> </div> );
};
After creating the base month layout, it will look like this:
Week events grouping and displaying
For convenience, I grouped all events by week, where the key of the group is the sum of the start and end ISO strings of the week. Each group also has subgroups for events that happen all day or more and events that are grouped by day and happen for less than a full day. As shown below.
import { endOfDay, endOfWeek, isSameDay, startOfDay, isSameWeek, startOfWeek, isWithinInterval, differenceInMilliseconds,
} from "date-fns"; import type { Event } from "../types"; const MILLISECONDS_IN_DAY = 86399999;
const MILLISECONDS_IN_WEEK = 604799999; export type WeekEvent = Event & { display_start_date: Date; display_end_date: Date;
}; type WeekEvents = { week_events: WeekEvent[]; week_day_events: Record<string, Event[]>;
}; export type GroupedEvents = Record<string, WeekEvents>; export const createMonthGroups = ( events: Event[], weeks: Date[][]
): GroupedEvents => { const groups: GroupedEvents = {}; for (let week of weeks) { const weekEndDate = endOfDay(week[week.length - 1]); const weekStartDate = startOfDay(week[0]); const weekKey = weekStartDate.toISOString() + "-" + weekEndDate.toISOString(); groups[weekKey] = { week_events: [], week_day_events: week.reduce((acc, cur) => { acc[cur.toISOString()] = []; return acc; }, {} as WeekEvents["week_day_events"]), }; } for (let event of events) { const { start_date, end_date } = event; const same = isSameDay(start_date, end_date); const difference = differenceInMilliseconds(end_date, start_date); const eventWeekEndDate = endOfWeek(end_date).toISOString(); const eventWeekStartDate = startOfWeek(start_date).toISOString(); if (same && difference < MILLISECONDS_IN_DAY) { const weekKey = `${eventWeekStartDate}-${eventWeekEndDate}`; const dayKey = startOfDay(start_date).toISOString(); groups[weekKey]?.week_day_events[dayKey]?.push(event); } if ( difference >= MILLISECONDS_IN_DAY && difference <= MILLISECONDS_IN_WEEK ) { const weekKey = `${eventWeekStartDate}-${eventWeekEndDate}`; const newEvent = { ...event, display_end_date: end_date, display_start_date: start_date, }; groups[weekKey]?.week_events.push(newEvent); } if (difference > MILLISECONDS_IN_WEEK) { for (let week of weeks) { const weekStartDate = startOfDay(week[0]); const weekEndDate = endOfDay(week[week.length - 1]); const weekKey = weekStartDate.toISOString() + "-" + weekEndDate.toISOString(); const isSameEnd = isSameWeek(end_date, weekEndDate); const isSameStart = isSameWeek(start_date, weekStartDate); const isInRageEnd = isWithinInterval(weekEndDate, { start: start_date, end: end_date, }); const isInRageStart = isWithinInterval(weekStartDate, { start: start_date, end: end_date, }); const newEvent = { ...event, display_end_date: end_date, display_start_date: start_date, }; if (!isSameEnd && isInRageEnd) newEvent.display_end_date = weekEndDate; if (!isSameStart && isInRageStart) newEvent.display_start_date = weekStartDate; groups[weekKey]?.week_events.push(newEvent); } } } return groups;
};
The <MonthWeekEventsView />
is almost an exact copy of <WeekEventsView />
from the previous section of the series. The same goes for event grouping and event displaying. The main point here is that week event groups need to be limited before being passed to <MonthWeekEventsView />
.
So after creating month groups inside <MonthView />
, week groups inside <MonthWeekView />
, and adding <MonthWeekEventsView />
, the result will be:
Day events grouping and displaying
Regarding limiting groups, my design allocates 6 slots per day for displaying events. Any remaining events from the week group should be passed to the day view. The same applies to the number of week events that are shown.
Knowing that the maximum number of day events that can be displayed is 6, and having the list of all events, including those not shown in the week events and those that are displayed, I can calculate the number of events that can be displayed and the number that need to be handled differently.
import { format, isWithinInterval } from "date-fns"; import { Event } from "../types"; const MAX_EVENTS_TO_DISPLAY = 6; type MonthDayViewProps = { day?: Date; events?: Event[]; restEvents?: Event[]; weekEventsShown?: number;
}; export const MonthDayView: React.FC<MonthDayViewProps> = ({ events = [], restEvents = [], day = new Date(), weekEventsShown = 0,
}) => { const filteredRestEvents = restEvents.filter((event) => isWithinInterval(day, { end: event.end_date, start: event.start_date, }) ); const canDisplayEvents = MAX_EVENTS_TO_DISPLAY - weekEventsShown; const allEvents = [...events, ...filteredRestEvents]; const allEventsNumber = allEvents.length; let eventsToDisplay: Event[] = []; let moreEventsNumber = 0; if (canDisplayEvents > 1) { eventsToDisplay = allEvents.slice(0, canDisplayEvents); moreEventsNumber = allEventsNumber - eventsToDisplay.length; } if (canDisplayEvents === 1 && allEventsNumber === 1) { eventsToDisplay = allEvents.slice(0, 1); moreEventsNumber = 0; } if (canDisplayEvents === 1 && allEventsNumber > 1) { moreEventsNumber = allEventsNumber; } return ( <ul className="pl-4 pr-6 flex-1 space-y-1 overflow-hidden"> {eventsToDisplay.map((event) => ( <li className="flex items-center" key={event.id}> <svg className="mr-2 min-w-2 w-2 h-2 text-blue-400 fill-blue-400"> <circle cx="4" cy="4" r="4" /> </svg> <p className="text-sm text-nowrap text-ellipsis"> {`${format(event.start_date, "h:mmaaa")}, ${event.title}`} </p> </li> ))} {moreEventsNumber > 0 && ( <li className="flex items-center"> <p className="text-sm text-nowrap text-ellipsis"> {moreEventsNumber} more </p> </li> )} </ul> );
};
The result will look like:
Conclusion
That’s it—the calendar is ready! I don’t expect it to be completely free of issues or perfect, but I hope I covered all the main aspects that guided me during this project. Thx for your attention and bye bye 👋👋👋!
The complete month-view can be found here.
For those who want to see the calendar in action, here is the to the deployed project: https://cookiemonsterdev.github.io/google-calendar-clone/
Discover more from Coursity
Subscribe to get the latest posts sent to your email.