DEV Community

Cover image for React Masterclass: Building an Interactive Calendar View
Radzion Chachura
Radzion Chachura

Posted on • Originally published at radzion.com

React Masterclass: Building an Interactive Calendar View

āœØ Watch on YouTube | šŸ™ GitHub

In this post, we'll conduct a React masterclass, during which I'll share how I've created this attractive timeline, alternatively known as a calendar view, to display work sessions overview. While the code for Increaser sits in a private repository, you can locate all the reusable components, hooks, and utilities featured in this article at the ReactKit repository. I trust you'll find lots of useful content in this masterclass, particularly when it comes to absolute positioning and time management.

Timeline from Increaser

DayOverviewProvider

Our component is constructed from four principal sections:

  • Navigation between days of the week
  • Summary of work sessions with a projects breakdown
  • A timeline featuring blocks of work, which also presents the current time, and shows the amount of a workday that remains
  • Lastly, a flow to add a work session, but we won't be covering this feature in this video.
import styled from "styled-components"
import { Panel } from "@increaser/ui/ui/Panel/Panel"
import { AmountOverview } from "./AmountOverview"
import { DayTimeline } from "./DayTimeline"
import { AddSession } from "./AddSession"
import { horizontalPaddingInPx } from "./config"
import { WeekNavigation } from "./WeekNavigation"
import { DayOverviewProvider } from "./DayOverviewProvider"

const Container = styled(Panel)`
  height: 100%;
`

export const DayOverview = () => {
  return (
    <DayOverviewProvider>
      <Container padding={horizontalPaddingInPx} withSections kind="secondary">
        <WeekNavigation />
        <AmountOverview />
        <DayTimeline />
        <AddSession />
      </Container>
    </DayOverviewProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

We'll need to reuse some state in multiple components within DayOverview, so let's create a provider using React Context.

DayOverviewProvider will effectively deliver:

  • Sets for the current day
  • Current time
  • Start and end times of the timeline
  • Start of the current day
  • End of the workday
  • A function to change the current day
import { Set } from "@increaser/entities/User"
import { ComponentWithChildrenProps } from "@increaser/ui/props"
import { createContextHook } from "@increaser/ui/state/createContextHook"
import { useRhythmicRerender } from "@increaser/ui/hooks/useRhythmicRerender"
import { getLastItem } from "@increaser/utils/array/getLastItem"
import { MS_IN_MIN } from "@increaser/utils/time"
import { startOfHour, endOfHour, isToday } from "date-fns"
import { useFocus } from "focus/hooks/useFocus"
import { createContext, useEffect, useMemo, useState } from "react"
import { startOfDay } from "date-fns"
import { useAssertUserState } from "user/state/UserStateContext"
import { getDaySets } from "sets/helpers/getDaySets"
import { useStartOfDay } from "@increaser/ui/hooks/useStartOfDay"

interface DayOverviewContextState {
  sets: Set[]
  currentTime: number

  timelineStartsAt: number
  timelineEndsAt: number

  dayStartedAt: number
  workdayEndsAt: number
  setCurrentDay: (timestamp: number) => void
}

const DayOverviewContext = createContext<DayOverviewContextState | undefined>(
  undefined
)

export const useDayOverview = createContextHook(
  DayOverviewContext,
  "DayOverview"
)

export const DayOverviewProvider = ({
  children,
}: ComponentWithChildrenProps) => {
  const currentTime = useRhythmicRerender()
  const todayStartedAt = useStartOfDay()
  const [currentDay, setCurrentDay] = useState(todayStartedAt)

  const dayStartedAt = startOfDay(currentDay).getTime()

  const { currentSet } = useFocus()
  useEffect(() => {
    if (currentSet && dayStartedAt !== todayStartedAt) {
      setCurrentDay(todayStartedAt)
    }
  }, [currentSet, dayStartedAt, todayStartedAt])

  const { sets: allSets } = useAssertUserState()

  const sets = useMemo(() => {
    const result = getDaySets(allSets, dayStartedAt)
    if (currentSet && isToday(dayStartedAt)) {
      result.push({
        start: currentSet.startedAt,
        end: currentTime,
        projectId: currentSet.projectId,
      })
    }

    return result
  }, [allSets, currentSet, currentTime, dayStartedAt])

  const { goalToStartWorkAt, goalToFinishWorkBy } = useAssertUserState()

  const workdayEndsAt = dayStartedAt + goalToFinishWorkBy * MS_IN_MIN

  const timelineStartsAt = useMemo(() => {
    if (sets.length) {
      return startOfHour(sets[0].start).getTime()
    }

    const workdayStartsAt = dayStartedAt + goalToStartWorkAt * MS_IN_MIN

    if (currentTime < workdayStartsAt) {
      return startOfHour(currentTime).getTime()
    }

    return workdayStartsAt
  }, [currentTime, dayStartedAt, goalToStartWorkAt, sets])

  const timelineEndsAt = useMemo(() => {
    if (!sets.length) {
      return workdayEndsAt
    }
    const lastSetEnd = getLastItem(sets).end
    if (workdayEndsAt > lastSetEnd) {
      return workdayEndsAt
    }

    return endOfHour(lastSetEnd).getTime()
  }, [sets, workdayEndsAt])

  return (
    <DayOverviewContext.Provider
      value={{
        sets,
        currentTime,
        timelineStartsAt,
        timelineEndsAt,
        workdayEndsAt,
        dayStartedAt,
        setCurrentDay,
      }}
    >
      {children}
    </DayOverviewContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

We will interact with the context using the useDayOverview hook, which will generate an error if we attempt to utilize it outside of the provider.

import { Context as ReactContext, useContext } from "react"

export function createContextHook<T>(
  Context: ReactContext<T | undefined>,
  contextName: string
) {
  return () => {
    const context = useContext(Context)

    if (!context) {
      throw new Error(`${contextName} is not provided`)
    }

    return context
  }
}
Enter fullscreen mode Exit fullscreen mode

To obtain the current time and rerender the component, we will employ the useRhythmicRerender hook.

import { useEffect, useState } from "react"

export const useRhythmicRerender = (durationInMs = 1000) => {
  const [time, setTime] = useState<number>(Date.now())

  useEffect(() => {
    const interval = setInterval(() => setTime(Date.now()), durationInMs)
    return () => clearInterval(interval)
  }, [setTime, durationInMs])

  return time
}
Enter fullscreen mode Exit fullscreen mode

We will record the current day as a timestamp in the currentDay state and distribute the setCurrentDay function to provider consumer to modify it.

We will filter the sets to include only those within the current day, and if the user is in focus mode, we will add a current set to the list.

Based on the current time, sets, and the user's preferred start and end of the workday, we will compute the start and end of the timeline.

Panel Container

We encapsulate our component in a Panel container, which spaces out the content inside and outlines the component with a border.

import styled, { css } from "styled-components"

import { defaultBorderRadiusCSS } from "../borderRadius"
import { getCSSUnit } from "../utils/getCSSUnit"
import { getColor } from "../theme/getters"
import { match } from "@increaser/utils/match"

type PanelKind = "regular" | "secondary"

export interface PanelProps {
  width?: React.CSSProperties["width"]
  padding?: React.CSSProperties["padding"]
  direction?: React.CSSProperties["flexDirection"]

  kind?: PanelKind

  withSections?: boolean
}

const panelPaddingCSS = css<{ padding?: React.CSSProperties["padding"] }>`
  padding: ${({ padding }) => getCSSUnit(padding || 20)};
`

export const Panel = styled.div<PanelProps>`
  ${defaultBorderRadiusCSS};
  width: ${({ width }) => (width ? getCSSUnit(width) : undefined)};
  overflow: hidden;

  ${({ withSections, direction = "column", kind = "regular", theme }) => {
    const contentBackground = match(kind, {
      secondary: () => theme.colors.background.toCssValue(),
      regular: () => theme.colors.mist.toCssValue(),
    })

    const contentCSS = css`
      ${panelPaddingCSS}
      background: ${contentBackground};
    `

    return withSections
      ? css`
          display: flex;
          flex-direction: ${direction};
          gap: 1px;

          > * {
            ${contentCSS}
          }
        `
      : contentCSS
  }}

  ${({ kind }) =>
    kind === "secondary" &&
    css`
      border: 2px solid ${getColor("mist")};
    `}
`
Enter fullscreen mode Exit fullscreen mode

We will continue to maintain all the hardcoded sizes and distances in the config.ts file.

export const horizontalPaddingInPx = 20
export const timeLabelWidthInPx = 40
export const timeLabelGapInPx = 8
export const botomPlaceholderHeightInPx = 28
export const topPlaceholderHeightInPx = horizontalPaddingInPx
export const minimumHourHeightInPx = 40
Enter fullscreen mode Exit fullscreen mode

WeekNavigation

The WeekNavigation component presents the specific day of the week and enables the user to navigate between days.

import { WEEKDAYS } from "@increaser/utils/time"
import styled from "styled-components"

import { useDayOverview } from "../DayOverviewProvider"
import { useStartOfDay } from "@increaser/ui/hooks/useStartOfDay"
import { useStartOfWeek } from "@increaser/ui/hooks/useStartOfWeek"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { useFocus } from "focus/hooks/useFocus"
import { verticalPadding } from "@increaser/ui/css/verticalPadding"
import { horizontalPadding } from "@increaser/ui/css/horizontalPadding"
import { SameWidthChildrenRow } from "@increaser/ui/ui/Layout/SameWidthChildrenRow"
import { horizontalPaddingInPx } from "../config"
import { WeekdayOption } from "./WeekdayOption"
import { InvisibleHTMLRadio } from "@increaser/ui/ui/inputs/InvisibleHTMLRadio"

const Container = styled(SameWidthChildrenRow)`
  ${verticalPadding(2)}
  ${horizontalPadding(horizontalPaddingInPx * 0.6)}
`

export const WeekNavigation = () => {
  const todayStartedAt = useStartOfDay()
  const weekStartedAt = useStartOfWeek()
  const { setCurrentDay, dayStartedAt } = useDayOverview()
  const { currentSet } = useFocus()

  if (currentSet) {
    return null
  }

  return (
    <Container rowHeight={horizontalPaddingInPx * 1.6} gap={1} fullWidth>
      {WEEKDAYS.map((weekday, index) => {
        const weekdayStartsAt =
          weekStartedAt + convertDuration(index, "d", "ms")
        const isActive = dayStartedAt === weekdayStartsAt
        const isEnabled = weekdayStartsAt <= todayStartedAt
        return (
          <WeekdayOption key={index} isActive={isActive} isEnabled={isEnabled}>
            {isEnabled && (
              <InvisibleHTMLRadio
                isSelected={isActive}
                groupName="week-navigation"
                value={weekdayStartsAt}
                onSelect={() => setCurrentDay(weekdayStartsAt)}
              />
            )}
            {weekday.slice(0, 3)}
          </WeekdayOption>
        )
      })}
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

We will position the weekdays inside a CSS Grid container abstracted behind the SameWidthChildrenRow component.

import styled, { css } from "styled-components"
import { getCSSUnit } from "../utils/getCSSUnit"

interface Props {
  gap: number
  minChildrenWidth?: number
  childrenWidth?: number
  rowHeight?: number
  fullWidth?: boolean
  maxColumns?: number
}

const getColumnMax = (maxColumns: number | undefined, gap: number) => {
  if (!maxColumns) return `0px`

  const gapCount = maxColumns - 1
  const totalGapWidth = `calc(${gapCount} * ${getCSSUnit(gap)})`

  return `calc((100% - ${totalGapWidth}) / ${maxColumns})`
}

const getColumnWidth = ({
  minChildrenWidth,
  maxColumns,
  gap,
  childrenWidth,
}: Props) => {
  if (childrenWidth !== undefined) {
    return getCSSUnit(childrenWidth)
  }

  return `
    minmax(
      max(
        ${getCSSUnit(minChildrenWidth || 0)},
        ${getColumnMax(maxColumns, gap)}
      ),
      1fr
  )`
}

export const SameWidthChildrenRow = styled.div<Props>`
  display: grid;
  grid-template-columns: repeat(auto-fit, ${getColumnWidth});
  gap: ${({ gap }) => getCSSUnit(gap)};
  ${({ rowHeight }) =>
    rowHeight &&
    css`
      grid-auto-rows: ${getCSSUnit(rowHeight)};
    `}
  ${({ fullWidth }) =>
    fullWidth &&
    css`
      width: 100%;
    `}
`
Enter fullscreen mode Exit fullscreen mode

To cycle over weekdays, we will utilize the WEEKDAYS array that contains the names of the days of the week. To procure the commencement of a weekday, we do simple math by adding days converted to milliseconds to the start of the week timestamp.

The active day will be the one that corresponds with the dayStartedAt timestamp, and we will disable all the days that fall in the future.

For a better accessibility experience and keyword support, we will render an invisible radio input inside every option.

import { centerContent } from "@increaser/ui/css/centerContent"
import { interactive } from "@increaser/ui/css/interactive"
import { transition } from "@increaser/ui/css/transition"
import { getColor } from "@increaser/ui/ui/theme/getters"
import styled, { css } from "styled-components"

interface WeekdayOptionProps {
  isActive: boolean
  isEnabled: boolean
}

export const WeekdayOption = styled.label<WeekdayOptionProps>`
  ${centerContent}
  ${transition}
  font-size: 12px;
  font-weight: 500;
  border-radius: 4px;
  border: 2px solid transparent;

  ${({ isEnabled }) =>
    isEnabled
      ? css`
          ${interactive}
          color: ${getColor("textSupporting")};
        `
      : css`
          pointer-events: none;
          color: ${getColor("textShy")};
        `}

  ${({ isActive }) =>
    isActive
      ? css`
          color: ${getColor("contrast")};
          background: ${getColor("mist")};
        `
      : css`
          :hover {
            color: ${getColor("text")};
            border-color: ${getColor("mist")};
          }
        `}
`
Enter fullscreen mode Exit fullscreen mode

The WeekdayOption component will style the label component, changing the text border and background color based on the isActive and isEnabled props.

AmountOverview

The AmountOverview component provides an overview of the current day's work sessions and projects breakdown.

import { HStack, VStack } from "@increaser/ui/ui/Stack"
import {
  HStackSeparatedBy,
  slashSeparator,
} from "@increaser/ui/ui/StackSeparatedBy"
import { WEEKDAYS } from "@increaser/utils/time"
import { ProjectTotal } from "projects/components/ProjectTotal"
import { ProjectsAllocationLine } from "projects/components/ProjectsAllocationLine"
import { getProjectColor } from "projects/utils/getProjectColor"
import { getProjectName } from "projects/utils/getProjectName"
import { useDayOverview } from "./DayOverviewProvider"
import { Text } from "@increaser/ui/ui/Text"
import { formatDuration } from "@increaser/utils/time/formatDuration"
import { getSetsSum } from "sets/helpers/getSetsSum"
import { useWeekTimeAllocation } from "weekTimeAllocation/hooks/useWeekTimeAllocation"
import { useProjects } from "projects/hooks/useProjects"
import { getProjectsTotalRecord } from "projects/helpers/getProjectsTotalRecord"
import { useTheme } from "styled-components"
import { getWeekday } from "@increaser/utils/time/getWeekday"

export const AmountOverview = () => {
  const theme = useTheme()
  const { sets, dayStartedAt } = useDayOverview()
  const setsTotal = getSetsSum(sets)
  const { allocation } = useWeekTimeAllocation()
  const weekday = getWeekday(new Date(dayStartedAt))
  const { projectsRecord } = useProjects()

  const allocatedMinutes = allocation ? allocation[weekday] : 0
  const projectsTotal = getProjectsTotalRecord(sets)

  return (
    <VStack fullWidth gap={8}>
      <VStack gap={4}>
        <HStack alignItems="center" justifyContent="space-between">
          <Text weight="semibold" color="supporting" size={14}>
            {WEEKDAYS[weekday]}
          </Text>
          <HStackSeparatedBy
            separator={<Text color="shy">{slashSeparator}</Text>}
          >
            <Text size={14} weight="semibold">
              {formatDuration(setsTotal, "ms")}
            </Text>
            <Text size={14} weight="semibold" color="shy">
              {formatDuration(allocatedMinutes, "min")}
            </Text>
          </HStackSeparatedBy>
        </HStack>
        <ProjectsAllocationLine
          projectsRecord={projectsRecord}
          sets={sets}
          allocatedMinutes={allocatedMinutes}
        />
      </VStack>

      <VStack gap={4} fullWidth>
        {Object.entries(projectsTotal)
          .sort((a, b) => b[1] - a[1])
          .map(([projectId]) => (
            <ProjectTotal
              key={projectId}
              name={getProjectName(projectsRecord, projectId)}
              color={getProjectColor(projectsRecord, theme, projectId)}
              value={projectsTotal[projectId]}
            />
          ))}
      </VStack>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

First, we display the name of the current day along with the total time spent on work sessions compared to the designated time for the day. Normally, you would opt to work less over the weekends, so the allocated time will be different for Saturday and Sunday.

To visualize the breakdown of projects, we will employ the ProjectsAllocationLine component.

To estimate the total time spent on each project, we will exploit the getProjectsTotalRecord helper.

import { getSetDuration } from "sets/helpers/getSetDuration"
import { Set } from "sets/Set"

export const getProjectsTotalRecord = (sets: Set[]) =>
  sets.reduce(
    (acc, set) => ({
      ...acc,
      [set.projectId]: (acc[set.projectId] || 0) + getSetDuration(set),
    }),
    {} as Record<string, number>
  )
Enter fullscreen mode Exit fullscreen mode

DayTimeline

The DayTimeline component forms the most complex part of our display, heavily stocking up on the absolutely positioned elements. The major segments of the component are:

  • A block highlighting the time left before the end of the workday
  • A timeline marker showing the hours of the day
  • A current time indicator
  • A workday end status showing how much time left before the end of the workday
  • Work blocks displaying the work sessions
  • A floating button permitting edits to the last work session
import styled from "styled-components"

import { TimelineMarks } from "./TimelineMarks"
import { WorkdayEndStatus } from "./WorkdayEndStatus"
import { CurrentTime } from "./CurrentTime"
import { WorkdayLeftBlock } from "./WorkdayLeftBlock"
import { WorkBlocks } from "./WorkBlocks"
import { ManageLastSession } from "./ManageLastSession"
import {
  botomPlaceholderHeightInPx,
  minimumHourHeightInPx,
  topPlaceholderHeightInPx,
} from "./config"
import { useDayOverview } from "./DayOverviewProvider"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { takeWholeSpaceAbsolutely } from "@increaser/ui/css/takeWholeSpaceAbsolutely"
import { takeWholeSpace } from "@increaser/ui/css/takeWholeSpace"

const Wrapper = styled.div`
  flex: 1;
  padding: 0;
  position: relative;
  min-height: 320px;
`

const Container = styled.div`
  ${takeWholeSpaceAbsolutely}
  overflow: auto;
  padding-bottom: ${botomPlaceholderHeightInPx}px;
  padding-top: ${topPlaceholderHeightInPx}px;
`

const Content = styled.div`
  ${takeWholeSpace};
  position: relative;
`

export const DayTimeline = () => {
  const { timelineStartsAt, timelineEndsAt } = useDayOverview()
  const timespan = timelineEndsAt - timelineStartsAt
  const minHeight = convertDuration(timespan, "ms", "h") * minimumHourHeightInPx

  return (
    <Wrapper>
      <Container>
        <Content style={{ minHeight }}>
          <WorkdayLeftBlock />
          <TimelineMarks />
          <CurrentTime />
          <WorkdayEndStatus />
          <WorkBlocks />
          <ManageLastSession />
        </Content>
      </Container>
    </Wrapper>
  )
}
Enter fullscreen mode Exit fullscreen mode

The Wrapper uses flex: 1 to accommodate all the available space, and we use min-height to guarantee the component is at least 320px tall.

The Container will be an absolutely positioned element with overflow: auto to permit scrolling when space is inadequate.

The Content will be a relatively positioned element occupying all available space within the Container.

Since a lengthy timeline may not fit into the screen, we set a min-height for the Content element based on the timeline duration so that one hour is at least 40px tall.

WorkdayLeftBlock

The WorkdayLeftBlock component will visualize the remaining time until the end of the workday. It only makes sense for today, so it will hide if the user is looking at a past weekday.

import styled from "styled-components"
import { useDayOverview } from "./DayOverviewProvider"
import { getColor } from "@increaser/ui/ui/theme/getters"
import { toPercents } from "@increaser/utils/toPercents"

const Container = styled.div`
  width: 100%;
  background: ${getColor("foreground")};
  position: absolute;
  left: 0;
`

export const WorkdayLeftBlock = () => {
  const { workdayEndsAt, timelineEndsAt, timelineStartsAt, currentTime } =
    useDayOverview()
  const workEndsIn = workdayEndsAt - currentTime
  const timespan = timelineEndsAt - timelineStartsAt

  if (currentTime > workdayEndsAt) {
    return null
  }

  return (
    <Container
      style={{
        top: toPercents((currentTime - timelineStartsAt) / timespan),
        height: toPercents(workEndsIn / timespan),
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

To convert ratios for the top and height attributes to percentages, we employ the toPercents helper.

type Format = "round"

export const toPercents = (value: number, format?: Format) => {
  const number = value * 100

  return `${format === "round" ? Math.round(number) : number}%`
}
Enter fullscreen mode Exit fullscreen mode

TimelineMarks

The TimelineMarks component will portray the hours of the day.

import { useMemo } from "react"
import { useDayOverview } from "./DayOverviewProvider"
import { PositionAbsolutelyCenterHorizontally } from "@increaser/ui/ui/PositionAbsolutelyCenterHorizontally"
import { toPercents } from "@increaser/utils/toPercents"
import styled from "styled-components"
import {
  horizontalPaddingInPx,
  timeLabelGapInPx,
  timeLabelWidthInPx,
} from "./config"
import { Text } from "@increaser/ui/ui/Text"
import { formatTime } from "@increaser/utils/time/formatTime"
import { getColor } from "@increaser/ui/ui/theme/getters"
import { getHoursInRange } from "@increaser/utils/time/getHoursInRange"
import { takeWholeSpace } from "@increaser/ui/css/takeWholeSpace"
import { horizontalPadding } from "@increaser/ui/css/horizontalPadding"
import { centerContent } from "@increaser/ui/css/centerContent"
import { transition } from "@increaser/ui/css/transition"

const Container = styled.div`
  display: grid;
  grid-template-columns: ${timeLabelWidthInPx}px 1fr;
  align-items: center;
  ${horizontalPadding(horizontalPaddingInPx)};
  gap: ${timeLabelGapInPx}px;
`

const Time = styled.div`
  ${centerContent};
  ${takeWholeSpace};
`

const Line = styled.div`
  width: 100%;
  height: 1px;
  background: ${getColor("mist")};
  ${transition};
`

export const TimelineMarks = () => {
  const { timelineStartsAt, timelineEndsAt } = useDayOverview()
  const marks = useMemo(() => {
    return getHoursInRange(timelineStartsAt, timelineEndsAt)
  }, [timelineEndsAt, timelineStartsAt])

  const timespan = timelineEndsAt - timelineStartsAt

  return (
    <>
      {marks.map((mark) => {
        return (
          <PositionAbsolutelyCenterHorizontally
            fullWidth
            top={toPercents((mark - timelineStartsAt) / timespan)}
            key={mark}
          >
            <Container>
              <Time>
                <Text color="shy" size={14}>
                  {formatTime(mark)}
                </Text>
              </Time>
              <Line />
            </Container>
          </PositionAbsolutelyCenterHorizontally>
        )
      })}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

We rely on a recursive function getHoursInRange to acquire the hours of the day between two timestamps.

import { startOfHour } from "date-fns"
import { MS_IN_HOUR } from "."

export const getHoursInRange = (start: number, end: number) => {
  const recursive = (time: number): number[] => {
    if (time > end) {
      return []
    }

    const nextHour = time + MS_IN_HOUR
    if (time < start) {
      return recursive(nextHour)
    }

    return [time, ...recursive(nextHour)]
  }

  return recursive(startOfHour(start).getTime())
}
Enter fullscreen mode Exit fullscreen mode

To center the text with the time and line, we use the PositionAbsolutelyCenterHorizontally abstract component.

import styled from "styled-components"
import { ComponentWithChildrenProps } from "../props"

interface PositionAbsolutelyCenterHorizontallyProps
  extends ComponentWithChildrenProps {
  top: React.CSSProperties["top"]
  fullWidth?: boolean
}

const Wrapper = styled.div`
  position: absolute;
  left: 0;
`

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

const Content = styled.div`
  position: absolute;
  left: 0;
`

export const PositionAbsolutelyCenterHorizontally = ({
  top,
  children,
  fullWidth,
}: PositionAbsolutelyCenterHorizontallyProps) => {
  const width = fullWidth ? "100%" : undefined
  return (
    <Wrapper style={{ top, width }}>
      <Container style={{ width }}>
        <Content style={{ width }}>{children}</Content>
      </Container>
    </Wrapper>
  )
}
Enter fullscreen mode Exit fullscreen mode

It combines a Wrapper with 0 height, Container with horizontally aligned content, and Content with absolute positioning. Briefly, it involves a lot of wrapping, but as a user of the component, you access a pleasing API for absolute positioning.

CurrentTime

The CurrentTime component strongly resembles one of the markers in the TimelineMarks component.

import { PositionAbsolutelyCenterHorizontally } from "@increaser/ui/ui/PositionAbsolutelyCenterHorizontally"
import { toPercents } from "@increaser/utils/toPercents"
import { useDayOverview } from "./DayOverviewProvider"
import styled from "styled-components"
import { getColor } from "@increaser/ui/ui/theme/getters"
import { horizontalPaddingInPx, timeLabelWidthInPx } from "./config"
import { formatTime } from "@increaser/utils/time/formatTime"
import { Text } from "@increaser/ui/ui/Text"
import { useStartOfDay } from "@increaser/ui/hooks/useStartOfDay"
import { centerContent } from "@increaser/ui/css/centerContent"
import { absoluteOutline } from "@increaser/ui/css/absoluteOutline"

const Line = styled.div`
  width: 100%;
  height: 2px;
  background: ${getColor("primary")};
`

const Wrapper = styled.div`
  width: ${timeLabelWidthInPx}px;
  margin-left: ${horizontalPaddingInPx}px;
  position: relative;
  ${centerContent}
  height: 20px;
`

const Time = styled(Text)`
  position: absolute;
`

const Outline = styled.div`
  ${absoluteOutline(6, 6)};
  background: ${getColor("background")};
  border-radius: 8px;
  border: 2px solid ${getColor("primary")};
`

export const CurrentTime = () => {
  const {
    currentTime,
    timelineStartsAt,
    timelineEndsAt,
    workdayEndsAt,
    dayStartedAt,
  } = useDayOverview()

  const todayStartedAt = useStartOfDay()
  if (dayStartedAt !== todayStartedAt) {
    return null
  }

  if (currentTime > workdayEndsAt && timelineEndsAt === workdayEndsAt) {
    return null
  }

  const timespan = timelineEndsAt - timelineStartsAt

  const top = toPercents((currentTime - timelineStartsAt) / timespan)

  return (
    <>
      <PositionAbsolutelyCenterHorizontally fullWidth top={top}>
        <Line />
      </PositionAbsolutelyCenterHorizontally>
      <PositionAbsolutelyCenterHorizontally fullWidth top={top}>
        <Wrapper>
          <Outline />
          <Time size={14} weight="bold">
            {formatTime(currentTime)}
          </Time>
        </Wrapper>
      </PositionAbsolutelyCenterHorizontally>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

An interesting CSS trick here involves the absoluteOutline helper to create a border around the current time.

import { css } from "styled-components"
import { toSizeUnit } from "./toSizeUnit"

export const absoluteOutline = (
  horizontalOffset: number | string,
  verticalOffset: number | string
) => {
  return css`
    pointer-events: none;
    position: absolute;
    left: -${toSizeUnit(horizontalOffset)};
    top: -${toSizeUnit(verticalOffset)};
    width: calc(100% + ${toSizeUnit(horizontalOffset)} * 2);
    height: calc(100% + ${toSizeUnit(verticalOffset)} * 2);
  `
}
Enter fullscreen mode Exit fullscreen mode

As the Outline is an absolutely positioned element, we have to turn the Time component into an absolute as well.

WorkdayEndStatus

When the user observes the current day and it's not yet the end of the workday, we present the WorkdayEndStatus component to display how much time remains before work concludes.

import { Text } from "@increaser/ui/ui/Text"
import { formatDuration } from "@increaser/utils/time/formatDuration"
import styled from "styled-components"
import { useDayOverview } from "./DayOverviewProvider"
import { useStartOfDay } from "@increaser/ui/hooks/useStartOfDay"

const Container = styled.div`
  position: absolute;
  bottom: -20px;
  width: 100%;
  display: flex;
  justify-content: center;
  font-size: 14px;
  line-height: 1;
`

export const WorkdayEndStatus = () => {
  const { workdayEndsAt, timelineEndsAt, currentTime, dayStartedAt } =
    useDayOverview()
  const workEndsIn = workdayEndsAt - currentTime

  const todayStartedAt = useStartOfDay()
  if (dayStartedAt !== todayStartedAt) {
    return null
  }

  if (timelineEndsAt > workdayEndsAt) {
    return null
  }

  return (
    <Container>
      {currentTime < workdayEndsAt && (
        <Text color="contrast" weight="semibold" size={14}>
          <Text as="span" color="supporting">
            workday ends in
          </Text>{" "}
          {formatDuration(workEndsIn, "ms")}
        </Text>
      )}
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

To format, we use the proficient formatDuration helper.

import { padWithZero } from "../padWithZero"
import { H_IN_DAY, MIN_IN_HOUR, S_IN_HOUR, S_IN_MIN } from "."
import { DurationUnit, convertDuration } from "./convertDuration"

export const formatDuration = (duration: number, unit: DurationUnit) => {
  const minutes = Math.round(convertDuration(duration, unit, "min"))

  if (minutes < MIN_IN_HOUR) return `${minutes}m`

  const hours = Math.floor(minutes / S_IN_MIN)

  if (hours < H_IN_DAY) {
    const minutesPart = Math.round(minutes % S_IN_MIN)
    if (!minutesPart) {
      return `${hours}h`
    }
    return `${hours}h ${minutesPart}m`
  }

  const days = Math.floor(hours / H_IN_DAY)
  const hoursPart = Math.round(hours % H_IN_DAY)
  if (!hoursPart) {
    return `${days}d`
  }

  return `${days}d ${hoursPart}h`
}
Enter fullscreen mode Exit fullscreen mode

WorkBlocks

A work block is an assembly of sets that are close together. As per Andrew Huberman, one of the productivity secrets involves organizing work into 90-minute blocks. That's why we desire to categorize sessions into blocks and highlight their duration to the user.

import { useDayOverview } from "./DayOverviewProvider"
import { getBlocks } from "@increaser/entities-utils/block"
import { WorkBlock } from "./WorkBlock"

export const WorkBlocks = () => {
  const { sets } = useDayOverview()
  const blocks = getBlocks(sets)

  return (
    <>
      {blocks.map((block, index) => (
        <WorkBlock key={index} block={block} />
      ))}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Since I also incorporate blocks on the server-side to create a scoreboard of the most productive users, the getBlocks function is contained in the entities-utils package.

export const getBlocks = (sets: Set[]): Block[] => {
  const blocks: Block[] = []

  sets.forEach((set, index) => {
    const prevSet = sets[index - 1]

    if (!prevSet) {
      blocks.push({ sets: [set] })
      return
    }

    const distance = getDistanceBetweenSets(prevSet, set)
    if (distance > blockDistanceInMinutes * MS_IN_MIN) {
      blocks.push({ sets: [set] })
      return
    }

    getLastItem(blocks).sets.push(set)
  })

  return blocks
}
Enter fullscreen mode Exit fullscreen mode

WorkBlock

To show a dashed line around the block, we'll once again use the absoluteOutline helper.

import { Block } from "@increaser/entities/Block"
import styled from "styled-components"
import { useDayOverview } from "../DayOverviewProvider"
import {
  getBlockBoundaries,
  getBlockWorkDuration,
} from "@increaser/entities-utils/block"
import { toPercents } from "@increaser/utils/toPercents"
import { takeWholeSpace } from "@increaser/ui/css/takeWholeSpace"
import { getColor } from "@increaser/ui/ui/theme/getters"
import {
  horizontalPaddingInPx,
  timeLabelWidthInPx,
  timeLabelGapInPx,
} from "../config"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { Text } from "@increaser/ui/ui/Text"
import { formatDuration } from "@increaser/utils/time/formatDuration"
import { WorkSession } from "./WorkSession"
import { getSetDuration } from "@increaser/entities-utils/set/getSetDuration"
import { transition } from "@increaser/ui/css/transition"
import { absoluteOutline } from "@increaser/ui/css/absoluteOutline"

interface WorkBlockProps {
  block: Block
}

const leftOffset =
  horizontalPaddingInPx + timeLabelWidthInPx + timeLabelGapInPx * 2

const Container = styled.div`
  width: calc(100% - ${leftOffset}px - ${horizontalPaddingInPx}px);
  left: ${leftOffset}px;
  position: absolute;
  ${transition};
`

const Content = styled.div`
  position: relative;
  ${takeWholeSpace}
`

const Outline = styled.div`
  ${absoluteOutline(2, 2)};
  border-radius: 4px;
  border: 1px dashed ${getColor("textSupporting")};
`

const Duration = styled(Text)`
  position: absolute;
  top: 1px;
  right: 4px;
`

export const WorkBlock = ({ block }: WorkBlockProps) => {
  const { timelineStartsAt, timelineEndsAt } = useDayOverview()
  const timespan = timelineEndsAt - timelineStartsAt
  const { start, end } = getBlockBoundaries(block)
  const blockDuration = end - start
  const showDuration = blockDuration > convertDuration(25, "min", "ms")

  return (
    <Container
      style={{
        top: toPercents((start - timelineStartsAt) / timespan),
        height: toPercents(blockDuration / timespan),
      }}
    >
      <Content>
        <Outline />
        {block.sets.map((set) => (
          <WorkSession
            set={set}
            style={{
              top: toPercents((set.start - start) / blockDuration),
              height: toPercents(getSetDuration(set) / blockDuration),
            }}
          />
        ))}
        {showDuration && (
          <Duration color="supporting" size={14} weight="semibold">
            {formatDuration(getBlockWorkDuration(block), "ms")}
          </Duration>
        )}
      </Content>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

If the session is too short, we won't display the duration. To alternate between different time units, we use the convertDuration helper.

import { MS_IN_DAY, MS_IN_HOUR, MS_IN_MIN, MS_IN_SEC, MS_IN_WEEK } from "."

export type DurationUnit = "ms" | "s" | "min" | "h" | "d" | "w"

const msInUnit: Record<DurationUnit, number> = {
  ms: 1,
  s: MS_IN_SEC,
  min: MS_IN_MIN,
  h: MS_IN_HOUR,
  d: MS_IN_DAY,
  w: MS_IN_WEEK,
}

export const convertDuration = (
  value: number,
  from: DurationUnit,
  to: DurationUnit
) => {
  const result = value * (msInUnit[from] / msInUnit[to])

  return result
}
Enter fullscreen mode Exit fullscreen mode

To illustrate a work session, we have a designated component.

import { Set } from "@increaser/entities/User"
import { transition } from "@increaser/ui/css/transition"
import { UIComponentProps } from "@increaser/ui/props"
import { getColor } from "@increaser/ui/ui/theme/getters"
import { useFocus } from "focus/hooks/useFocus"
import { useProjects } from "projects/hooks/useProjects"
import { getProjectColor } from "projects/utils/getProjectColor"
import styled, { useTheme } from "styled-components"

interface WorkSessionProps extends UIComponentProps {
  set: Set
}

const Container = styled.div`
  border-radius: 2px;
  background: ${getColor("mist")};
  overflow: hidden;
  position: absolute;
  width: 100%;
  ${transition};
`

const Identifier = styled.div`
  width: 4px;
  height: 100%;
  ${transition};
`

export const WorkSession = ({ set, ...rest }: WorkSessionProps) => {
  const { projectsRecord } = useProjects()
  const { currentSet } = useFocus()

  const theme = useTheme()

  const color = getProjectColor(projectsRecord, theme, set.projectId)

  return (
    <Container {...rest}>
      {!currentSet && <Identifier style={{ background: color.toCssValue() }} />}
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

We assign a session a mist background using the getColor helper, which simplifies interaction with styled components themes.

import { DefaultTheme } from "styled-components"
import { ThemeColors } from "./ThemeColors"

interface ThemeGetterParams {
  theme: DefaultTheme
}

type ColorName = keyof Omit<ThemeColors, "getLabelColor">

export const getColor =
  (color: ColorName) =>
  ({ theme }: ThemeGetterParams) => {
    return theme.colors[color].toCssValue()
  }
Enter fullscreen mode Exit fullscreen mode

To link a session with a project, we'll display a small identifier in the form of a vertical line. Here we utilize the getProjectColor helper to retrieve the project's color.

import { EnhancedProject } from "projects/Project"
import { DefaultTheme } from "styled-components"

export const getProjectColor = (
  projectsRecord: Record<string, EnhancedProject>,
  theme: DefaultTheme,
  projectId = ""
) => {
  const project = projectsRecord[projectId]

  if (!project) return theme.colors.mist

  return theme.colors.getLabelColor(project.color)
}
Enter fullscreen mode Exit fullscreen mode

ManageLastSession

Lastly, we'll introduce a floating button to modify the last work session. The ManageLastSession component will generate a menu component with options to either edit or remove the last session.

import { useStartOfDay } from "@increaser/ui/hooks/useStartOfDay"
import { useFocus } from "focus/hooks/useFocus"
import { useDayOverview } from "../DayOverviewProvider"
import { getLastItem } from "@increaser/utils/array/getLastItem"
import { Menu } from "@increaser/ui/ui/Menu"
import { MenuOptionProps, MenuOption } from "@increaser/ui/ui/Menu/MenuOption"
import { EditIcon } from "@increaser/ui/ui/icons/EditIcon"
import { MoveIcon } from "@increaser/ui/ui/icons/MoveIcon"
import { TrashBinIcon } from "@increaser/ui/ui/icons/TrashBinIcon"
import { useState } from "react"
import { Match } from "@increaser/ui/ui/Match"
import { ChangeLastSetIntervalOverlay } from "focus/components/ChangeLastSetInvervalOverlay"
import { ChangeLastSetProjectOverlay } from "focus/components/ChangeLastSetProjectOverlay"
import { useDeleteLastSetMutation } from "sets/hooks/useDeleteLastSetMutation"
import { ManageSetOpener } from "./ManageSetOpener"

type MenuOptionType = "editInterval" | "changeProject"

export const ManageLastSession = () => {
  const [selectedOption, setSelectedOption] = useState<MenuOptionType | null>(
    null
  )

  const { currentSet } = useFocus()
  const todayStartedAt = useStartOfDay()
  const { dayStartedAt, sets } = useDayOverview()

  const { mutate: deleteLastSet } = useDeleteLastSetMutation()

  if (currentSet || dayStartedAt !== todayStartedAt || !sets.length) {
    return null
  }

  return (
    <>
      <Menu
        title="Manage last session"
        renderContent={({ view, onClose }) => {
          const options: MenuOptionProps[] = [
            {
              icon: <EditIcon />,
              text: "Change project",
              onSelect: () => {
                setSelectedOption("changeProject")
              },
            },
            {
              icon: <MoveIcon />,
              text: "Edit interval",
              onSelect: () => {
                setSelectedOption("editInterval")
              },
            },
            {
              icon: <TrashBinIcon />,
              text: "Delete session",
              kind: "alert",
              onSelect: () => {
                deleteLastSet()
              },
            },
          ]

          return options.map(({ text, icon, onSelect, kind }) => (
            <MenuOption
              text={text}
              key={text}
              icon={icon}
              view={view}
              kind={kind}
              onSelect={() => {
                onClose()
                onSelect()
              }}
            />
          ))
        }}
        renderOpener={(openerProps) => (
          <ManageSetOpener set={getLastItem(sets)} openerProps={openerProps} />
        )}
      />
      {selectedOption && (
        <Match
          value={selectedOption}
          editInterval={() => (
            <ChangeLastSetIntervalOverlay
              onClose={() => setSelectedOption(null)}
            />
          )}
          changeProject={() => (
            <ChangeLastSetProjectOverlay
              onClose={() => setSelectedOption(null)}
            />
          )}
        />
      )}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

To display the opener, we use the ManageSetOpener component which retrieves a set and essential props for the Menu component, to activate and position the menu. Here we position the opener absolutely with a 4px offset from the end of the session.

import { interactive } from "@increaser/ui/css/interactive"
import { HStack } from "@increaser/ui/ui/Stack"
import { defaultTransitionCSS } from "@increaser/ui/ui/animations/transitions"
import { MoreHorizontalIcon } from "@increaser/ui/ui/icons/MoreHorizontalIcon"
import { getColor } from "@increaser/ui/ui/theme/getters"
import { centerContentCSS } from "@increaser/ui/ui/utils/centerContentCSS"
import { toPercents } from "@increaser/utils/toPercents"
import { getProjectEmoji } from "projects/utils/getProjectEmoji"
import styled, { useTheme } from "styled-components"
import { horizontalPaddingInPx } from "../config"
import { Text } from "@increaser/ui/ui/Text"
import { useProjects } from "projects/hooks/useProjects"
import { RenderOpenerProps } from "@increaser/ui/ui/Menu/PopoverMenu"
import { Set } from "@increaser/entities/User"
import { useDayOverview } from "../DayOverviewProvider"
import { formatDuration } from "@increaser/utils/time/formatDuration"
import { getProjectColor } from "projects/utils/getProjectColor"

const offsetInPx = 4

const Container = styled.div`
  ${interactive}
  position: absolute;
  right: ${horizontalPaddingInPx}px;
  border-radius: 8px;
  padding: 4px 8px;
  ${centerContentCSS};
  font-size: 14px;
  border: 2px solid;
  background: ${getColor("background")};
  color: ${getColor("text")};
  ${defaultTransitionCSS};
  :hover {
    color: ${getColor("contrast")};
  }
`

interface ManageSetOpener {
  set: Set
  openerProps: RenderOpenerProps
}

export const ManageSetOpener = ({ set, openerProps }: ManageSetOpener) => {
  const { projectsRecord } = useProjects()
  const { start, end, projectId } = set
  const { timelineEndsAt, timelineStartsAt } = useDayOverview()
  const timespan = timelineEndsAt - timelineStartsAt

  const theme = useTheme()
  const color = getProjectColor(projectsRecord, theme, projectId)

  return (
    <Container
      {...openerProps}
      style={{
        top: `calc(${toPercents(
          (end - timelineStartsAt) / timespan
        )} + ${offsetInPx}px)`,
        borderColor: color.toCssValue(),
      }}
    >
      <HStack alignItems="center" gap={8}>
        <Text>{getProjectEmoji(projectsRecord, projectId)}</Text>
        <Text weight="semibold">{formatDuration(end - start, "ms")}</Text>
        <MoreHorizontalIcon />
      </HStack>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
artydev profile image
artydev

Thank you

Collapse
 
dshaw0004 profile image
Dipankar Shaw

Nice work