How to Make Calendar View with React

July 25, 2022

5 min read

How to Make Calendar View with React
Watch on YouTube

Let's check a calendar view made with React at increaser.org so that you can find interesting bits for your project.

calendar

Here we have an interface showing work sessions for the last seven days, the start and end of work for a selected day, and a green area highlighting time for rest.

I called the component GroupedByDaySessions. At the top level, it has two sections - navigation between days and the calendar view itself. The container with lines and hours is a separate component that is also used to display work sessions of the current day. We pass the start and end hours to the component, width of hour labels, and content to show under the lines. In our case, this content is the green area at the bottom.

import { TimePerformance } from "focus/components/TodayTimeline/TimePerformance"
import { getProjectColor } from "projects/utils/getProjectColor"
import { Fragment, useMemo } from "react"
import { getSetDuration } from "sets/helpers/getSetDuration"
import { toPercents } from "shared/utils/toPercents"
import styled from "styled-components"
import { defaultTransitionCSS } from "ui/animations/transitions"
import { centerContentCSS } from "ui/helpers/centerContentCSS"
import { HourSpace } from "ui/HourSpace"
import { RadioGroup } from "ui/Input/RadioGroup"
import { VStack } from "ui/Stack"
import { getLast } from "utils/generic"
import { getCSSUnit } from "utils/getCSSUnit"
import { MIN_IN_HOUR, MS_IN_HOUR, MS_IN_MIN, offsetedUtils } from "utils/time"

import { useDailyReport } from "./DailyReportContext"
import { RestArea } from "./RestArea"

const hourLabelWidthInPx = 20

const NavigationWr = styled.div`
  padding-left: ${hourLabelWidthInPx}px;
`

const toOrderedHours = (timestamps: number[]) =>
  timestamps.map((t) => offsetedUtils.toTime(t).hour).sort((a, b) => a - b)

const SessionsContainer = styled.div`
  position: relative;
  height: 100%;
  flex: 1;
  cursor: pointer;
  ${centerContentCSS}
`

const Session = styled.div`
  position: absolute;
  left: 4px;
  width: calc(100% - 8px);
  border-radius: 2px;
  ${defaultTransitionCSS}
`

const Hours = styled(HourSpace)`
  flex: 1;
`

const Container = styled(VStack)`
  margin-left: ${getCSSUnit(-hourLabelWidthInPx)};
  flex: 1;
  width: calc(100% + ${getCSSUnit(hourLabelWidthInPx)});
`

const TimePerformanceWr = styled(VStack)`
  position: absolute;
  pointer-events: none;
  width: 100px;

  align-items: center;
  justify-content: center;
`

const StartLine = styled.div`
  position: absolute;
  width: 100%;
  border-bottom: 2px dashed ${({ theme }) => theme.colors.text.toCssValue()};
`

export const GroupedByDaySessions = () => {
  const {
    days,
    selectedDayIndex,
    selectDay,
    projectsRecord,
    goalToStartWorkAt,
    goalToGoToBedAt,
  } = useDailyReport()

  const [startHour, endHour] = useMemo(() => {
    const groupedSets = days
      .map(({ sets }) => sets)
      .filter((sets) => sets.length > 0)

    const starts = groupedSets.map((sets) => sets[0].start)
    if (starts.length < 1) return [6, 18]

    const ends = groupedSets.map((sets) => getLast(sets).end)
    const startHour = Math.max(
      Math.min(
        toOrderedHours(starts)[0],
        Math.floor(goalToStartWorkAt / MIN_IN_HOUR)
      ) - 1,
      0
    )

    const endHour = Math.min(
      24,
      Math.max(
        getLast(toOrderedHours(ends)),
        Math.ceil(goalToGoToBedAt / MIN_IN_HOUR)
      )
    )

    return [startHour, endHour]
  }, [days, goalToGoToBedAt, goalToStartWorkAt])

  const hoursNumber = endHour - startHour
  const timelineInMs = hoursNumber * MS_IN_HOUR

  return (
    <Container fullHeight fullWidth gap={24}>
      <NavigationWr>
        <RadioGroup
          groupName="weekdays"
          options={days.map((day, index) => index)}
          onChange={selectDay}
          optionToString={(i) => i.toString()}
          renderItem={(i) => {
            const { startsAt } = days[i]

            return new Date(startsAt).toLocaleDateString(undefined, {
              weekday: "short",
            })
          }}
          isFullWidth
          value={selectedDayIndex}
        />
      </NavigationWr>
      <Hours
        start={startHour}
        end={endHour}
        hourLabelWidthInPx={hourLabelWidthInPx}
        underLinesContent={
          <RestArea hoursNumber={hoursNumber} startHour={startHour} />
        }
      >
        {days.map(({ sets, startsAt }, dayIndex) => {
          const timelineStart = startsAt + startHour * MS_IN_HOUR

          const isSelectedDay = selectedDayIndex === dayIndex

          return (
            <SessionsContainer
              onClick={() => selectDay(dayIndex)}
              key={dayIndex}
            >
              {sets.map((set, index) => {
                const isFirst = index === 0
                const isLast = index === sets.length - 1
                const height = toPercents(getSetDuration(set) / timelineInMs)

                const top = toPercents(
                  (set.start - timelineStart) / timelineInMs
                )
                const project = projectsRecord[set.projectId]

                const background = getProjectColor(projectsRecord, project.id)
                  .getVariant({ a: () => (isSelectedDay ? 1 : 0.64) })
                  .toCssValue()
                return (
                  <Fragment key={index}>
                    <Session style={{ height, top, background }}>
                      {isSelectedDay && (
                        <VStack
                          style={{ position: "relative" }}
                          fullWidth
                          alignItems="center"
                        >
                          {isFirst && (
                            <TimePerformanceWr
                              style={{
                                bottom: 4,
                              }}
                            >
                              <TimePerformance timestamp={set.start} />
                            </TimePerformanceWr>
                          )}
                        </VStack>
                      )}
                    </Session>
                    {selectedDayIndex === dayIndex && isLast && (
                      <TimePerformanceWr
                        style={{
                          top: `calc(${top} + ${height} + 4px)`,
                        }}
                      >
                        <TimePerformance timestamp={set.end} />
                      </TimePerformanceWr>
                    )}
                  </Fragment>
                )
              })}
            </SessionsContainer>
          )
        })}
        <StartLine
          style={{
            top: toPercents(
              ((goalToStartWorkAt - startHour * MIN_IN_HOUR) * MS_IN_MIN) /
                timelineInMs
            ),
          }}
        />
      </Hours>
    </Container>
  )
}

The container of the HourSpace component is a relatively positioned flexbox component. We render under-lines content and children in absolutely positioned wrappers with a left offset preventing content from overlaying hours labels.

import styled from "styled-components"
import { Text } from "ui/Text"
import { getRange } from "utils/generic"

import { VStack } from "./Stack"

interface Props {
  start: number
  end: number
  className?: string
  hourLabelWidthInPx?: number
  children?: React.ReactNode
  underLinesContent?: React.ReactNode
}

const Container = styled(VStack)`
  position: relative;
`

const HourWr = styled.div`
  position: relative;
  display: flex;
  align-items: center;
`

const HourContainer = styled.div`
  position: absolute;
  width: 100%;
`

const HourContent = styled.div<{ labelWidth: number }>`
  width: 100%;
  display: grid;
  gap: 4px;
  grid-template-columns: ${({ labelWidth }) => labelWidth}px 1fr;
  align-items: center;
`

const HourLine = styled.div`
  background: ${({ theme }) => theme.colors.backgroundGlass.toCssValue()};
  height: 1px;
  width: 100%;
`

const Content = styled.div<{ leftOffset: number }>`
  position: absolute;
  width: calc(100% - ${(p) => p.leftOffset}px);
  height: 100%;
  left: ${(p) => p.leftOffset}px;
  display: flex;
`

export const HourSpace = ({
  start,
  end,
  className,
  hourLabelWidthInPx = 20,
  children,
  underLinesContent,
}: Props) => {
  const hours = getRange(end + 1 - start).map((index) => start + index)

  return (
    <Container
      className={className}
      justifyContent="space-between"
      fullHeight
      fullWidth
    >
      {underLinesContent && (
        <Content leftOffset={hourLabelWidthInPx}>{underLinesContent}</Content>
      )}
      {hours.map((hour) => {
        return (
          <HourWr key={hour}>
            <HourContainer>
              <HourContent labelWidth={hourLabelWidthInPx}>
                <Text size={14} color="supporting">
                  {hour}
                </Text>
                <HourLine />
              </HourContent>
            </HourContainer>
          </HourWr>
        )
      })}
      {children && (
        <Content leftOffset={hourLabelWidthInPx}>{children}</Content>
      )}
    </Container>
  )
}

Once we passed all the props to the HourSpace component, we can iterate over every day and display work sessions. The SessionsContainer component takes full height and has flex set to one so that every other one will have the same width.

Every session is an absolutely positioned element with a color of a corresponding project. To calculate the top and height attributes, we don't need to know the parent's height in px. We know the number of hours we display, and our sessions have start and end denominated in timestamps. Therefore we can convert everything to milliseconds and divide it by the timelineInMs variable.