DEV Community

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

Posted on • Edited on

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

Hi folks, have you ever needed to create or integrate a calendar or scheduler in your app to display and interact with events? If so, you probably faced the dilemma of choosing the right library for the task. There are many cool and useful libraries available on the internet, some free and some enterprise-level, such as react-big-calendar, blazor-scheduler, @react-admin/ra-calendar etc. Many of these libraries offer a wide range of features and functionality, and you are free to use them at any time. However, if you need a simple yet similar solution with more control over your code, you can follow this guide to create your own. So, let's get started!

For the base design, I will use the Google Calendar layout. This part of the guide covers the day view of the calendar. The next parts will cover the week view and month view, respectively. This part includes:

Also, I won't be covering features like drag and drop or event editing since the main goal of this guide is to show logic that stands behind calendar and events displaying.

There is a repo for your quick start, feel free to use it)


Toolbar header

Any calendar should have a toolbar to handle view and time switching, display the current time, etc. So I start with the toolbar header.



import { useState, useCallback } from "react";

import { ChevronLeft, ChevronRight } from "lucide-react";

import { cn } from "../utils";
import { add, sub, endOfWeek, startOfWeek, formatDate } from "date-fns";

type View = "day" | "week" | "month";

export type CalendarProps = {
  view?: View;
  events?: Event[];
  date: string | number | Date;
};

export const Calendar: React.FC<CalendarProps> = ({ date, view = "day" }) => {
  const [curView, setCurView] = useState<View>(view);
  const [curDate, setCurDate] = useState<Date>(new Date(date));

  const onPrev = useCallback(() => {
    if (curView === "day") {
      return setCurDate((prev) => sub(prev, { days: 1 }));
    }

    if (curView === "week") {
      return setCurDate((prev) => sub(prev, { weeks: 1 }));
    }

    return setCurDate((prev) => sub(prev, { months: 1 }));
  }, [curView]);

  const onNext = useCallback(() => {
    if (curView === "day") {
      return setCurDate((prev) => add(prev, { days: 1 }));
    }

    if (curView === "week") {
      return setCurDate((prev) => add(prev, { weeks: 1 }));
    }

    return setCurDate((prev) => add(prev, { months: 1 }));
  }, [curView]);

  const formatDateForView = useCallback(
    (date: Date) => {
      if (curView === "day") {
        return formatDate(date, "dd MMMM yyyy");
      }

      if (curView === "week") {
        const weekStart = startOfWeek(date);
        const weekEnd = endOfWeek(date);

        const startMonth = formatDate(weekStart, "MMM");
        const endMonth = formatDate(weekEnd, "MMM");
        const year = formatDate(weekStart, "yyyy");

        if (startMonth !== endMonth) {
          return `${startMonth} – ${endMonth} ${year}`;
        } else {
          return `${startMonth} ${year}`;
        }
      }

      return formatDate(date, "MMMM yyyy");
    },
    [curView]
  );

  return (
    <div id="calendar" className="w-full flex flex-col overflow-hidden">
      <section
        id="calendar-header"
        className="mb-6 w-full flex justify-between"
      >
        <div className="flex gap-2 items-center">
          <button
            aria-label="set date today"
            onClick={() => setCurDate(new Date())}
            className="py-2 px-3 border border-gray-200 rounded-md font-semibold hover:bg-blue-100 transition-colors duration-300"
          >
            Today
          </button>
          <button
            onClick={onPrev}
            aria-label={`prev ${curView}`}
            className="w-[42px] aspect-square border border-gray-200 rounded-md font-semibold flex justify-center items-center hover:bg-blue-100 transition-colors duration-300"
          >
            <ChevronLeft />
          </button>
          <button
            onClick={onNext}
            aria-label={`next ${curView}`}
            className="w-[42px] aspect-square border border-gray-200 rounded-md font-semibold flex justify-center items-center hover:bg-blue-100 transition-colors duration-300"
          >
            <ChevronRight />
          </button>
          <span className="ml-6 font-semibold text-xl">
            {formatDateForView(curDate)}
          </span>
        </div>
        <div className="flex gap-2">
          <button
            aria-label="set month view"
            onClick={() => setCurView("month")}
            className={cn(
              "py-2 px-3 border border-gray-200 rounded-md font-semibold hover:bg-blue-100 transition-colors duration-300",
              curView === "month" && "bg-blue-400 text-white hover:bg-blue-700"
            )}
          >
            Month
          </button>
          <button
            aria-label="set month week"
            onClick={() => setCurView("week")}
            className={cn(
              "py-2 px-3 border border-gray-200 rounded-md font-semibold hover:bg-blue-100 transition-colors duration-300",
              curView === "week" && "bg-blue-400 text-white hover:bg-blue-700"
            )}
          >
            Week
          </button>
          <button
            aria-label="set month day"
            onClick={() => setCurView("day")}
            className={cn(
              "py-2 px-3 border border-gray-200 rounded-md font-semibold hover:bg-blue-100 transition-colors duration-300",
              curView === "day" && "bg-blue-400 text-white hover:bg-blue-700"
            )}
          >
            Day
          </button>
        </div>
      </section>
      {curView === "day" && <>day view</>}
      {curView === "week" && <>week view</>}
      {curView === "month" && <>month view</>}
    </div>
  );
};



Enter fullscreen mode Exit fullscreen mode

I'm not really sure that there is much to explain since the code is pretty straightforward. The only part that requires more calculation is the date formatting for the week view, particularly when the start or end days of the week are part of another month, as shown above.


Day layout

The layout of the day is quite simple. The built-in utility from date-fns allows you to retrieve an array of hours for a day, which is then used for grid building.



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

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

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

export const DayView: React.FC<DayViewProps> = ({ date }) => {
  const hours = eachHourOfInterval({
    start: startOfDay(date),
    end: endOfDay(date),
  });

  return (
    <section id="calendar-day-view" className="flex-1 h-full">
      <div className="border-b flex">
        <div className="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 justify-center items-center border-l"></div>
      </div>
      <div className="flex-1 max-h-full overflow-y-scroll pb-28">
        <div className="relative">
          {hours.map((time, index) => (
            <div className="h-14 flex" key={time.toISOString() + index}>
              <div className="h-full 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 className="flex-1 relative border-b border-l" />
            </div>
          ))}
        </div>
      </div>
    </section>
  );
};


Enter fullscreen mode Exit fullscreen mode

For now the results looks like:

day-view-1


Current time

Now let's show the current time for today. To do this, we need to calculate the top offset of the line, which requires knowing the container height that includes all time slots and the time that has passed since the beginning of the day.

The formula looks sth like:

day-view-2

With this formula, we can build a component that accepts the container height and calculates the top offset within the interval every minute.



import { useState, useEffect } from "react";

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

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

type DayProgressProps = {
  containerHeight: number;
};

export const DayProgress: React.FC<DayProgressProps> = ({
  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="h-1 w-full absolute left-24 -translate-y-1/2"
    >
      <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

Events grouping and displaying

I will separate events into two groups: those that last all day or longer and those that occur only during this day, the logic behind this is shown below.



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

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

const MILLISECONDS_IN_DAY = 86399999;

export type GroupedEvents = {
  allDayEvents: Event[];
  eventGroups: Event[][];
};

const createGroups = (
  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 createGroups(sliced, groupedEvents);
};

export const groupEvents = (date: Date, events: Event[]): GroupedEvents => {
  const eventsPresentToday = events.filter((event) => {
    const startBeforeEndToday =
      isBefore(event.start_date, date) && isSameDay(event.end_date, date);
    const startTodayEndAfter =
      isSameDay(event.start_date, date) && isAfter(event.end_date, date);
    const startTodayEndToday =
      isSameDay(event.start_date, date) && isSameDay(event.end_date, date);
    const startBeforeEndAfter =
      isBefore(event.start_date, date) && isAfter(event.end_date, date);
    return (
      startTodayEndAfter ||
      startTodayEndToday ||
      startBeforeEndToday ||
      startBeforeEndAfter
    );
  });

  const [allDayEvents, thisDayEvents]: Event[][] = eventsPresentToday.reduce(
    (acc: Event[][], cur) => {
      const { end_date, start_date } = cur;

      const same = isSameDay(start_date, end_date);
      const difference = differenceInMilliseconds(end_date, start_date);

      if (same && difference < MILLISECONDS_IN_DAY) {
        acc[1].push(cur);
      } else {
        acc[0].push(cur);
      }

      return acc;
    },
    [[], []]
  );

  const eventGroups = createGroups(thisDayEvents);

  return { eventGroups, allDayEvents };
};


Enter fullscreen mode Exit fullscreen mode

Now that the events are grouped, they need to be displayed properly. Here are a few parameters that are required: group length, container height, and event index. All of these are important for calculating the event's width, top offset, and z-index to ensure the proper position.



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

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

const MINUTES_IN_DAY = 24 * 60;

type DayEventProps = {
  day: Date;
  event: Event;
  index: number;
  grouplength: number;
  containerHeight: number;
};

export const DayEvent: React.FC<DayEventProps> = ({
  day,
  event,
  index,
  grouplength,
  containerHeight,
}) => {
  const today = startOfDay(day);

  const eventDuration = differenceInMinutes(event.end_date, event.start_date);

  const generateBoxStyle = () => {
    const minutesPassed = differenceInMinutes(event.start_date, today);

    const percentage = minutesPassed / MINUTES_IN_DAY;

    const top = percentage * containerHeight;
    const height = (eventDuration / MINUTES_IN_DAY) * containerHeight;

    const isLast = index === grouplength - 1;
    let widthPercentage = grouplength === 1 ? 1 : (1 / grouplength) * 1.7;

    if (isLast) {
      widthPercentage = 1 / grouplength;
    }

    const styles = {
      top,
      height,
      padding: "2px 8px",
      zIndex: 100 + index,
      width: `calc((100% - 96px) * ${widthPercentage})`,
    };

    if (isLast) {
      return { ...styles, right: 0 };
    }

    return {
      ...styles,
      left: `calc(100px + 100% * ${(1 / grouplength) * index})`,
    };
  };

  return (
    <div
      style={generateBoxStyle()}
      className="bg-blue-400 border border-white rounded cursor-pointer absolute"
    >
      <h1 className="text-white text-xs">
        {`${event.title}, 
        ${format(event.start_date, "h:mm a")} - 
        ${format(event.end_date, "h:mm a")}`}
      </h1>
    </div>
  );
};



Enter fullscreen mode Exit fullscreen mode

Regarding events that last all day or longer, they are presented as a simple container with some CSS, so it is not worth mentioning here.

The end result looks like:

day-view-3


Conclusion

That's it for now. In the next part, I will cover the week view. However, I'm not sure when that will be, as the terrorist state of Russia is still attacking our cities and people, that is why I have some trouble with electricity and also a have more workload. So, see you in a while (maybe).

The complete day-view can be found here.

Top comments (1)

Collapse
 
taranka profile image
Sivak Ihor

Divine code