DEV Community

Cover image for How to build Google calendar clone with React (Week view)
Mykhailo Toporkov πŸ‡ΊπŸ‡¦
Mykhailo Toporkov πŸ‡ΊπŸ‡¦

Posted on • Edited on

How to build Google calendar clone with React (Week view)

Welcome back, folks! In the previous section, I explored how to create a day view for our custom calendar application. Now, it's time to expand our calendar's functionality to include a week view.

In this section, I'll cover:


Week layout

The Week layout is somewhat similar to the Day layout, with the key difference being that it is scaled to display 7 days. There isn't much else to explain, so just follow the code below:



import { isToday, format } from "date-fns";

import { cn } from "../../utils";

export type WeekDayLabelProps = {
  day: Date;
};

export const WeekDayLabel: React.FC<WeekDayLabelProps> = ({ day }) => {
  const isDayToday = isToday(day);

  return (
    <div className="flex-1 min-w-36 flex flex-col items-center">
      <span aria-hidden className="text-md text-gray-400">
        {format(day, "EEEEEE")}
      </span>
      <div
        aria-label={day.toDateString()}
        className={cn(
          "w-11 h-11  rounded-full flex items-center justify-center text-2xl font-medium text-gray-400",
          isDayToday && "text-white bg-blue-400"
        )}
      >
        <p className="leading-[44px]">{format(day, "d")}</p>
      </div>
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode


import { useState } from "react";

import { cn } from "../../utils";
import { endOfDay, startOfDay, eachHourOfInterval } from "date-fns";

import type { Event } from "../types";

type WeekDayViewProps = {
  day: Date;
  events?: Event[];
};

export const WeekDayView: React.FC<WeekDayViewProps> = ({
  day,
  events = [],
}) => {
  const [ref, setRef] = useState<HTMLDivElement | null>(null);

  const hours = eachHourOfInterval({
    start: startOfDay(day),
    end: endOfDay(day),
  });

  return (
    <div
      aria-label={"Events slot for " + day.toDateString()}
      className="min-w-36 h-full flex flex-1 relative"
    >
      <div className="w-[95%] h-full absolute">
        <div className="w-full h-full relative" ref={(ref) => setRef(ref)}>
        </div>
      </div>
      <div className="w-full flex flex-col">
        {hours.map((time, index) => (
          <div
            key={time.toISOString()}
            className={cn(
              "h-14 w-full border-l",
              index !== hours.length - 1 && "border-b"
            )}
          />
        ))}
      </div>
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode


import { WeekDayView } from "./week-day-view";
import { WeekDayLabel } from "./week-day-label";

import {
  format,
  endOfDay,
  endOfWeek,
  startOfDay,
  startOfWeek,
  eachDayOfInterval,
  eachHourOfInterval,
} from "date-fns";

import { Event } from "../types";

type WeekViewProps = {
  date: Date;
  events?: Event[];
};

export const WeekView: React.FC<WeekViewProps> = ({ date, events = [] }) => {
  const hours = eachHourOfInterval({
    start: startOfDay(date),
    end: endOfDay(date),
  });

  const days = eachDayOfInterval({
    start: startOfWeek(date),
    end: endOfWeek(date),
  });

  return (
    <section id="calendar-day-view" className="flex-1 h-full">
       <div className="min-w-[calc(96px+(144px*7))] flex border-b scrollbar-gutter-stable">
        <div className="min-w-24 h-14 flex justify-center items-center">
          <span className="text-xs">{format(new Date(), "z")}</span>
        </div>
        <div className="flex flex-col flex-1">
          <div className="relative flex flex-1">
            {days.map((day) => (
              <WeekDayLabel
                day={day}
                key={"week-day-label-" + day.toISOString()}
              />
            ))}
          </div>
          <div className="relative min-h-6">
            <div className="absolute inset-0 h-full flex flex-1">
              <div className="flex-1 min-w-36 border-l" />
              <div className="flex-1 min-w-36 border-l" />
              <div className="flex-1 min-w-36 border-l" />
              <div className="flex-1 min-w-36 border-l" />
              <div className="flex-1 min-w-36 border-l" />
              <div className="flex-1 min-w-36 border-l" />
              <div className="flex-1 min-w-36 border-l" />
            </div>
          </div>
        </div>
      </div>
     <div className="min-w-[calc(96px+(144px*7))] flex overflow-y-auto">
        <div className="h-fit flex flex-col">
          {hours.map((time, index) => (
            <div
              key={time.toISOString() + index}
              aria-label={format(time, "h a")}
              className="min-h-14 w-24 flex items-start justify-center"
            >
              <time
                className="text-xs -m-3 select-none"
                dateTime={format(time, "yyyy-MM-dd")}
              >
                {index === 0 ? "" : format(time, "h a")}
              </time>
            </div>
          ))}
        </div>
        <div className="flex flex-1 h-fit">
          {days.map((day) => {
            const iso = day.toISOString();
            return <WeekDayView day={day} key={iso} events={[]} />;
          })}
        </div>
      </div>
    </section>
  );
};


Enter fullscreen mode Exit fullscreen mode

Current time

The current time line is similar to the one used in the Day view. Considering this, I decided to implement it at the calendar level and add an option to customize its container styles.



import { useState, useEffect } from "react";

import { startOfDay, differenceInMinutes } from "date-fns";

import { cn } from "../utils";

const ONE_MINUTE = 60 * 1000;
const MINUTES_IN_DAY = 24 * 60;

type DayProgressProps = {
  className?: string;
  containerHeight: number;
};

export const DayProgress: React.FC<DayProgressProps> = ({
  className,
  containerHeight,
}) => {
  const [top, setTop] = useState(0);

  const today = new Date();
  const startOfToday = startOfDay(today);

  useEffect(() => {
    const updateTop = () => {
      const minutesPassed = differenceInMinutes(today, startOfToday);
      const percentage = minutesPassed / MINUTES_IN_DAY;
      const top = percentage * containerHeight;

      setTop(top);
    };

    updateTop();

    const interval = setInterval(() => updateTop(), ONE_MINUTE);

    return () => clearInterval(interval);
  }, [containerHeight]);

  return (
    <div
      aria-hidden
      style={{ top }}
      aria-label="day time progress"
      className={cn(
        "h-1 w-full absolute left-24 -translate-y-1/2 z-[1000000]",
        className
      )}
    >
      <div className="relative w-full h-full">
        <div
          aria-label="current time dot"
          className="w-4 aspect-square rounded-full absolute -left-2 top-1/2 -translate-y-1/2  bg-[rgb(234,67,53)]"
        />
        <div
          aria-label="current time line"
          className="h-[2px] w-full absolute top-1/2 -translate-y-1/2 bg-[rgb(234,67,53)]"
        />
      </div>
    </div>
  );
};



Enter fullscreen mode Exit fullscreen mode

After adding this to <WeekDayView /> component we will see layout with current time:

week-1


Day events grouping and displaying

For the Week view, we will have two major event groups: day groups, which contain events happening on a specific day, and week groups, which contain events lasting a day or more. Each day's group of events must also be grouped to display them as in the Day view, and the same goes for the week groups.

For this, I created simple functions. The first one handles events that have the same start and end date. I then reused and copied the grouping logic from the Day view.



import { add, isSameDay, startOfDay, isWithinInterval, differenceInMilliseconds} from "date-fns";

const MILLISECONDS_IN_DAY = 86399999;

export type GroupedEvents = {
  weekGroups: Event[];
  dayGroups: Record<string, Event[]>;
};

export const createGroups = (events: Event[]): GroupedEvents => {
  const weekGroups: Event[] = [];
  const dayGroups: Record<string, Event[]> = {};

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

    if (same && difference < MILLISECONDS_IN_DAY) {
      const key = startOfDay(start_date).toISOString();

      if (!dayGroups[key]) dayGroups[key] = [];

      dayGroups[key].push(event);
    } else {
      weekGroups.push(event);
    }
  }

  return { dayGroups, weekGroups };
};

export const createDayGroups = (
  events: Event[],
  groupedEvents: Event[][] = []
): Event[][] => {
  if (events.length <= 0) return groupedEvents;

  const [first, ...rest] = events;

  const eventsInRage = rest.filter((event) =>
    isWithinInterval(event.start_date, {
      start: first.start_date,
      end: add(first.end_date, { minutes: -1 }),
    })
  );

  const group = [first, ...eventsInRage];
  const sliced = rest.slice(eventsInRage.length);
  groupedEvents.push(group);

  return createDayGroups(sliced, groupedEvents);
};


Enter fullscreen mode Exit fullscreen mode

Now after adding createGroups to <WeekView /> and createDayGroups to <WeekDayView /> events will be displayed like this:

week-2


Week events grouping and displaying

Compared to grouping day events, grouping week events involves slightly more complex logic. First, events must be filtered and modified to ensure proper display. Filtering is straightforward: if at least the start or end date is not within the week, the event can be ignored. However, for events that have only one date within the week, the other date must be modified to display the event correctly. For this, I use display_start_date and display_end_date, where I store the dates that will be used for display.

After that, events must be sorted by start date and only then grouped.



export type WeekEvent = Event & {
  display_start_date: Date;
  display_end_date: Date;
};

export const createWeekGroups = (
  events: Event[],
  date = new Date()
): WeekEvent[][] => {
  const filteredEvents: WeekEvent[] = [];

  const weekEnd = endOfWeek(date);
  const weekStart = startOfWeek(date);

  for (let event of events) {
    const { end_date, start_date } = event;

    const isEnd = isSameWeek(end_date, date);
    const isStart = isSameWeek(start_date, date);
    const isMonth =
      isBefore(start_date, weekStart) && isAfter(end_date, weekEnd);

    if (!(isStart || isEnd || isMonth)) continue;

    const display_start_date = isBefore(start_date, weekStart)
      ? weekStart
      : start_date;
    const display_end_date = isAfter(end_date, weekEnd) ? weekEnd : end_date;

    filteredEvents.push({
      ...event,
      display_end_date,
      display_start_date,
    });
  }

  const sortedEvents = filteredEvents.sort(
    (a, b) => a.start_date.getTime() - b.start_date.getTime()
  );

  const groups: WeekEvent[][] = [];

  for (const event of sortedEvents) {
    let placed = false;

    for (const group of groups) {
      const lastEventInGroup = group[group.length - 1];

      if (lastEventInGroup.end_date.getTime() <= event.start_date.getTime()) {
        group.push(event);
        placed = true;
        break;
      }
    }

    if (!placed) {
      groups.push([event]);
    }
  }

  return groups;
};


Enter fullscreen mode Exit fullscreen mode

Since events are displayed horizontally, the main value for an event is its width, which is calculated based on the event's duration and the container's width. The event duration is easily accessible, but determining the container width requires more than just getting the value from a reference. Because the calendar size can be adjusted, the width needs to be recalculated on resize event and for this I used ResizeObserver.



import { startOfWeek, differenceInMinutes, format } from "date-fns";

import { WeekEvent as Event } from "./group-events";

const MINUTES_IN_WEEK = 7 * 24 * 60;

type WeekEventProps = {
  date: Date;
  event: Event;
  containerWidth: number;
};

export const WeekEvent: React.FC<WeekEventProps> = ({
  date,
  event,
  containerWidth,
}) => {
  const generateBoxStyle = () => {
    const week = startOfWeek(date);
    const eventDuration = differenceInMinutes(
      event.display_end_date,
      event.display_start_date
    );
    const minutesPassed = differenceInMinutes(event.display_start_date, week);

    const left = (minutesPassed / MINUTES_IN_WEEK) * containerWidth;
    const width = (eventDuration / MINUTES_IN_WEEK) * containerWidth;

    return { left, width: `calc(${width}px - 1px)` };
  };

  return (
    <div
      style={generateBoxStyle()}
      className="h-full px-2 absolute z-10 bg-blue-400 rounded cursor-pointer"
    >
      <h1 className="text-white text-sm text-ellipsis overflow-hidden">
        {`${format(event.start_date, "h:mm a")}, ${event.title}`}
      </h1>
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode


import { useRef, useState, useEffect } from "react";

import { WeekEvent } from "./week-event";

import { createWeekGroups } from "./group-events";

import { Event } from "../types";

type WeekEventsViewProps = {
  date: Date;
  events?: Event[];
};

export const WeekEventsView: React.FC<WeekEventsViewProps> = ({
  date,
  events = [],
}) => {
  const ref = useRef<HTMLDivElement | null>(null);
  const [containerWidth, setContainerWidth] = useState(1);

  const groups = createWeekGroups(events, date);

  useEffect(() => {
    if (!ref.current) return;

    const resizeObserver = new ResizeObserver((entries) => {
      for (let entry of entries) {
        setContainerWidth(entry.contentRect.width);
      }
    });

    resizeObserver.observe(ref.current);

    return () => resizeObserver.disconnect();
  }, [ref]);

  return (
    <div className="mt-2 space-y-1 overflow-hidden" ref={ref}>
      {groups.map((events, groupIndex) => (
        <div className="h-6 relative" key={"group-" + groupIndex}>
          {events.map((event) => (
            <WeekEvent
              date={date}
              event={event}
              key={event.id}
              containerWidth={containerWidth}
            />
          ))}
        </div>
      ))}
    </div>
  );
};



Enter fullscreen mode Exit fullscreen mode

After adding <WeekEventsView /> to <WeekView /> all week events will be displayed like this:

week-3


Conclusion

That's it for now. In this section, I covered the week view, delving into its layout, the current time display, and the grouping and displaying of both day and week events.

In the final part of this guide, I will explore the month view, completing our journey of creating a comprehensive calendar application. Stay tuned!

The complete week-view can be found here.

Top comments (0)