DEV Community

Cover image for React Masterclass: Building a Schedule Page for a Productivity App
Radzion Chachura
Radzion Chachura

Posted on • Originally published at radzion.com

React Masterclass: Building a Schedule Page for a Productivity App

πŸ™ GitHub

Mastering React: Building a Schedule Page for a Productivity App

In this post, we delve into a React masterclass to build a visually appealing schedule page for a productivity app. We'll leverage actual data to craft interesting visualizations, all without depending on external component libraries. You'll discover that, by decomposing the complex UIs into smaller components, the task becomes way easier. While the code for this specific application is in a private repository, all the reusable components can be found in the RadzionKit repository.

Schedule Page

Enhancing Remote Work: The Dual-Feature Schedule Page at Increaser

The Schedule Page at Increaser aims to assist remote workers in optimizing their daily routine for enhanced health and productivity. It comprises two main sections:

  1. Scheduler: This section includes essential events every user should plan, such as wake-up time, first meal, and end of the workday.
  2. Statistics: Here, we provide an analysis of the user's previous workdays, including average work blocks, total work hours on weekdays and weekends, and the start and end times of the workday. This feature enables users to compare their intended schedule with their actual work patterns.

By integrating these sections, the Schedule Page serves as a valuable tool for remote workers striving for a balanced and efficient daily routine.

Optimizing Data Structure for Scheduling in React: A DynamoDB and Day Moment Integration

Our code models the schedule as a record, where each key is a DayMoment type, representing various daily events like wakeUpAt, startWorkAt, firstMealAt, etc. The value of each key indicates the minutes elapsed since midnight. For instance, a wake-up time at 7 am is denoted by a wakeUpAt field with a value of 420 minutes. Additionally, we use a dayMomentShortName record to map DayMoment types to concise names suitable for UI display. The dayMomentStep variable sets the time increment for scheduling, allowing users to adjust events in 30-minute steps. Lastly, the dayMomentsDefaultValues record establishes default times for each scheduled event when creating a new user's profile.

import { convertDuration } from "@increaser/utils/time/convertDuration"
import { Minutes } from "@increaser/utils/time/types"

export const dayMoments = [
  "wakeUpAt",
  "firstMealAt",
  "startWorkAt",
  "lastMealAt",
  "finishWorkAt",
  "goToBedAt",
] as const

export type DayMoment = (typeof dayMoments)[number]

export type DayMoments = Record<DayMoment, Minutes>

export const dayMomentShortName: Record<DayMoment, string> = {
  wakeUpAt: "wake up",
  startWorkAt: "start work",
  finishWorkAt: "finish work",
  goToBedAt: "go to bed",
  firstMealAt: "first meal",
  lastMealAt: "last meal",
}

export const dayMomentStep: Minutes = 30

export const dayMomentsDefaultValues: Record<DayMoment, Minutes> = {
  wakeUpAt: convertDuration(7, "h", "min"),
  startWorkAt: convertDuration(7, "h", "min") + dayMomentStep,
  firstMealAt: convertDuration(10, "h", "min"),
  finishWorkAt: convertDuration(17, "h", "min") + dayMomentStep,
  lastMealAt: convertDuration(18, "h", "min"),
  goToBedAt: convertDuration(22, "h", "min"),
}
Enter fullscreen mode Exit fullscreen mode

Given our use of DynamoDB as the database, it's advantageous to maintain a flat data structure. Consequently, the User type extends the DayMoments type. This approach simplifies updates, as modifying a day moment entails a standard update operation on a user, eliminating the need for an additional endpoint in our API.

export type User = DayMoments & {
  id: string
  email: string
  country?: CountryCode
  name?: string
  /// ...
}
Enter fullscreen mode Exit fullscreen mode

Designing the ManageSchedule Component: Responsive Visualization for Varied Screen Sizes

Let's begin with the ManageSchedule component. This component aims to offer users a clear visualization of their events and enables them to manage their schedule effectively. We employ the ElementSizeAware component to accommodate both small and medium-sized screens. It provides the dimensions of the Wrapper element, guiding us in choosing the appropriate layout. For small screens, a flexbox with column direction is utilized, minimizing the spacing between events for optimal display. On medium-sized screens, we implement a CSS Grid for a more structured presentation.

import { useAssertUserState } from "user/state/UserStateContext"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import styled from "styled-components"
import { ElementSizeAware } from "@increaser/ui/base/ElementSizeAware"
import { VStack } from "@increaser/ui/layout/Stack"
import { ManageDayMoment } from "./ManageDayMoment"
import { TimeDistance } from "@increaser/ui/time/TimeDistance"

const Wrapper = styled.div`
  flex: 1;
`

const gridMinWidth = 520

const Grid = styled.div`
  display: grid;
  gap: 8px;
  grid-template-rows: min-content 80px min-content 80px min-content;
  grid-template-columns: min-content 1fr min-content;
  width: 100%;
`

export const ManageSchedule = () => {
  const {
    wakeUpAt,
    startWorkAt,
    finishWorkAt,
    goToBedAt,
    firstMealAt,
    lastMealAt,
  } = useAssertUserState()

  const manageWakeUp = (
    <ManageDayMoment
      dayMoment="wakeUpAt"
      min={convertDuration(4, "h", "min")}
      max={startWorkAt}
    />
  )

  const manageStartWork = (
    <ManageDayMoment
      dayMoment="startWorkAt"
      min={wakeUpAt}
      max={finishWorkAt}
    />
  )

  const manageFirstMeal = (
    <ManageDayMoment dayMoment="firstMealAt" min={wakeUpAt} max={lastMealAt} />
  )

  const manageFinishWork = (
    <ManageDayMoment
      dayMoment="finishWorkAt"
      min={startWorkAt}
      max={goToBedAt}
    />
  )

  const manageLastMeal = (
    <ManageDayMoment dayMoment="lastMealAt" min={firstMealAt} max={goToBedAt} />
  )

  const manageBedTime = (
    <ManageDayMoment dayMoment="goToBedAt" min={finishWorkAt} max={goToBedAt} />
  )

  return (
    <ElementSizeAware
      render={({ setElement, size }) => (
        <Wrapper ref={setElement}>
          {size && size.width < gridMinWidth ? (
            <VStack gap={16}>
              {manageWakeUp}
              {manageStartWork}
              {manageFirstMeal}
              {manageFinishWork}
              {manageLastMeal}
              {manageBedTime}
            </VStack>
          ) : (
            <VStack fullWidth gap={40}>
              <Grid>
                {manageStartWork}
                <TimeDistance
                  direction="right"
                  value={finishWorkAt - startWorkAt}
                />
                {manageFinishWork}
                <TimeDistance direction="up" value={startWorkAt - wakeUpAt} />
                <div />
                <TimeDistance
                  direction="down"
                  value={goToBedAt - finishWorkAt}
                />
                {manageWakeUp}
                <TimeDistance
                  direction="left"
                  value={
                    convertDuration(1, "d", "min") - (goToBedAt - wakeUpAt)
                  }
                />
                {manageBedTime}
                <TimeDistance direction="down" value={firstMealAt - wakeUpAt} />
                <div />
                <TimeDistance direction="up" value={goToBedAt - lastMealAt} />
                {manageFirstMeal}
                <TimeDistance
                  direction="right"
                  value={lastMealAt - firstMealAt}
                />
                {manageLastMeal}
              </Grid>
            </VStack>
          )}
        </Wrapper>
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Implementing the ManageDayMoment Component: Interactive Scheduling with Floating-UI in React

The ManageDayMoment component adeptly handles the dayMoment prop, which includes the event's name and accompanying minimum and maximum parameters. These parameters are instrumental in defining a specific time range for an event. For example, in a "start of work" event, the minimum will be set to the wake-up time and the maximum to the work finishing time. The container of this component smartly showcases an icon associated with the event, the event's abbreviated name, and the time in a formatted manner. Upon user interaction, such as clicking on the event, a dropdown appears, presenting various time options. Selecting a new time from this dropdown will promptly update the schedule to reflect this change.

import { borderRadius } from "@increaser/ui/css/borderRadius"
import { transition } from "@increaser/ui/css/transition"
import { HStack } from "@increaser/ui/layout/Stack"
import { Text } from "@increaser/ui/text"
import { getColor } from "@increaser/ui/theme/getters"
import styled, { useTheme } from "styled-components"
import { css } from "styled-components"
import { absoluteOutline } from "@increaser/ui/css/absoluteOutline"
import { interactive } from "@increaser/ui/css/interactive"
import { TimeOption } from "./TimeOption"
import { getDayMomentColor } from "sets/utils/getDayMomentColor"
import { IconWrapper } from "@increaser/ui/icons/IconWrapper"
import { dayMomentIcon } from "../../dayMomentIcon"
import {
  DayMoment,
  dayMomentShortName,
  dayMomentStep,
} from "@increaser/entities/DayMoments"
import { range } from "@increaser/utils/array/range"
import { useUpdateUserMutation } from "user/mutations/useUpdateUserMutation"
import { useAssertUserState } from "user/state/UserStateContext"
import { useFloatingOptions } from "@increaser/ui/floating/useFloatingOptions"
import { formatDailyEventTime } from "@increaser/utils/time/formatDailyEventTime"
import { FloatingOptionsContainer } from "@increaser/ui/floating/FloatingOptionsContainer"

interface ManageDayMomentProps {
  min: number
  max: number
  dayMoment: DayMoment
}

const Container = styled.div<{ isActive: boolean }>`
  ${borderRadius.m};
  ${interactive};
  outline: none;

  display: flex;
  justify-content: space-between;
  flex-direction: row;
  gap: 1px;
  overflow: hidden;
  padding: 8px 12px;
  background: ${getColor("foreground")};
  ${transition};

  ${({ isActive }) =>
    isActive &&
    css`
      background: ${getColor("mist")};
    `}

  :hover {
    background: ${getColor("mist")};
    color: ${getColor("contrast")};
  }
`

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

export const ManageDayMoment = ({
  min,
  max,
  dayMoment,
}: ManageDayMomentProps) => {
  const user = useAssertUserState()
  const value = user[dayMoment]

  const { mutate: updateUser } = useUpdateUserMutation()

  const options = range(Math.round((max - min) / dayMomentStep) + 1).map(
    (step) => min + dayMomentStep * step
  )

  const {
    getReferenceProps,
    getFloatingProps,
    getOptionProps,
    isOpen,
    setIsOpen,
    activeIndex,
  } = useFloatingOptions({
    selectedIndex: options.indexOf(value),
  })

  const theme = useTheme()
  const color = getDayMomentColor(dayMoment, theme)

  return (
    <>
      <Container isActive={isOpen} {...getReferenceProps()}>
        <HStack style={{ minWidth: 132 }} alignItems="center" gap={8}>
          <IconWrapper style={{ color: color.toCssValue() }}>
            {dayMomentIcon[dayMoment]}
          </IconWrapper>
          <Text as="div">{dayMomentShortName[dayMoment]}</Text>
        </HStack>
        <Text weight="bold">{formatDailyEventTime(value)}</Text>
      </Container>
      {isOpen && (
        <FloatingOptionsContainer {...getFloatingProps()}>
          {options.map((option, index) => (
            <TimeOption
              isActive={activeIndex === index}
              key={option}
              {...getOptionProps({
                index,
                onSelect: () => {
                  updateUser({ [dayMoment]: option })
                  setIsOpen(false)
                },
              })}
            >
              {formatDailyEventTime(option)}
              {option === value && <Outline />}
            </TimeOption>
          ))}
        </FloatingOptionsContainer>
      )}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

For positioning time options and facilitating keyboard selection, we leverage the floating-ui library within the useFloatingOptions hook. This hook sets the dropdown's appearance via useFloating and orchestrates navigation between the options with useListNavigation. The useDismiss hook enables closing options using the Escape key, while useClick allows for toggling options on a click event. The seamless integration of these functionalities is achieved through the useInteractions hook. To further enhance convenience for useFloatingOptions consumers, we provide upgraded versions of the getReferenceProps, getFloatingProps, and getOptionProps functions. For example getOptionPropsEnhanced accepts an onSelect callback and incorporates both click and enter key handlers. Although initially challenging to navigate, getting a hang of the floating-ui library unlocks its potential for creating complex user interfaces.

import {
  useFloating,
  useInteractions,
  useListNavigation,
  useRole,
  useClick,
  useDismiss,
} from "@floating-ui/react"
import { autoUpdate, offset, size } from "@floating-ui/dom"

import { useRef, useState } from "react"
import { toSizeUnit } from "../css/toSizeUnit"

interface FloatingOptionsParams {
  selectedIndex: number
}

interface GetOptionsPropsParams {
  index: number
  onSelect: () => void
}

export const useFloatingOptions = ({
  selectedIndex,
}: FloatingOptionsParams) => {
  const [isOpen, setIsOpen] = useState(false)

  const { refs, context, floatingStyles } = useFloating({
    placement: "bottom-end",
    open: isOpen,
    onOpenChange: setIsOpen,
    whileElementsMounted: autoUpdate,
    middleware: [
      offset(4),
      size({
        apply({ elements, availableHeight, rects }) {
          Object.assign(elements.floating.style, {
            maxHeight: `${toSizeUnit(Math.min(availableHeight, 320))}`,
            width: toSizeUnit(rects.reference.width),
          })
        },
      }),
    ],
  })

  const optionsRef = useRef<Array<HTMLElement | null>>([])
  const [activeIndex, setActiveIndex] = useState<number | null>(null)

  const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions(
    [
      useClick(context, { event: "mousedown" }),
      useRole(context, { role: "listbox" }),
      useDismiss(context),
      useListNavigation(context, {
        listRef: optionsRef,
        activeIndex,
        selectedIndex,
        onNavigate: setActiveIndex,
        loop: true,
      }),
    ]
  )

  const getReferencePropsEnhanced = () => {
    return getReferenceProps({
      ref: refs.setReference,
      tabIndex: 0,
      "aria-autocomplete": "none",
      "aria-labelledby": "select-label",
    })
  }

  const getFloatingPropsEnhanced = () => {
    return getFloatingProps({
      ref: refs.setFloating,
      style: floatingStyles,
    })
  }

  const getOptionPropsEnhanced = ({
    index,
    onSelect,
  }: GetOptionsPropsParams) => {
    return getItemProps({
      role: "option",
      tabIndex: activeIndex === index ? 0 : -1,
      "aria-selected": index === selectedIndex && index === activeIndex,
      onClick: onSelect,
      ref: (element) => {
        optionsRef.current[index] = element
      },
      onKeyDown: (event) => {
        if (event.key === "Enter") {
          event.preventDefault()
          onSelect()
        }
      },
    })
  }

  return {
    isOpen,
    setIsOpen,
    getReferenceProps: getReferencePropsEnhanced,
    getFloatingProps: getFloatingPropsEnhanced,
    getOptionProps: getOptionPropsEnhanced,
    activeIndex,
    floatingStyles,
  } as const
}
Enter fullscreen mode Exit fullscreen mode

When options are presented, they are encapsulated within the OptionsContainer component. This component is designed to mirror the reference element in both color and width, ensuring a cohesive visual experience. Within OptionsContainer, we render the time options. We alter options background and text color when in an active state, enhancing user interaction feedback.

import styled from "styled-components"
import { borderRadius } from "../css/borderRadius"
import { getColor } from "../theme/getters"

export const FloatingOptionsContainer = styled.div`
  ${borderRadius.m};
  overflow-y: auto;
  outline: none;

  display: flex;
  flex-direction: column;
  gap: 4px;

  padding: 4px;
  background: ${getColor("foreground")};
  z-index: 1;
`
Enter fullscreen mode Exit fullscreen mode

To emphasize the currently selected option, we employ the Outline component, positioned absolutely. This component is frequently used due to its effectiveness in delineating the boundaries of an element. The absoluteOutline function is pivotal in positioning the outline relative to the parent element while also allowing for offset adjustments to the outline itself.

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

Upon user selection of a new time, the useUpdateUserMutation hook is utilized. It performs an optimistic update, creating the illusion of real-time changes, and concurrently calls an API endpoint to update the user's schedule in the database. For more insights into streamlining back-end development in a monorepo setup, refer to this post.

import { User } from "@increaser/entities/User"
import { useApi } from "api/hooks/useApi"
import { useMutation } from "react-query"
import { useUserState } from "user/state/UserStateContext"

export const useUpdateUserMutation = () => {
  const api = useApi()
  const { updateState } = useUserState()

  return useMutation(async (input: Partial<User>) => {
    updateState(input)

    return api.call("updateUser", input)
  })
}
Enter fullscreen mode Exit fullscreen mode

Creating the TimeDistance Component: Flexbox and Chevron Icons for Event Duration Display in React

The TimeDistance component is used to display the time duration between two events. It accepts a time value in minutes and a direction as props. This component is housed in a flexbox container, which expands to occupy the full available space and centers its children. The flexbox's direction is determined by the direction prop: up and down set a column layout, while left and right create a row layout. Additionally, the DashedLine component, a simple div with dashed borders, is used. Chevron icons are utilized to create arrows pointing in the specified direction.

import { ChevronDownIcon } from "@increaser/ui/icons/ChevronDownIcon"
import { ChevronRightIcon } from "@increaser/ui/icons/ChevronRightIcon"
import { ChevronUpIcon } from "@increaser/ui/icons/ChevronUpIcon"
import { ChevronLeftIcon } from "@increaser/ui/icons/ChevronLeftIcon"
import { Text } from "@increaser/ui/text"
import { getColor } from "@increaser/ui/theme/getters"
import { formatDuration } from "@increaser/utils/time/formatDuration"
import styled from "styled-components"
import { Direction } from "@increaser/utils/Direction"
import { Stack } from "@increaser/ui/layout/Stack"
import { Minutes } from "@increaser/utils/time/types"

interface TimeDistanceProps {
  value: Minutes
  direction: Direction
}

const DashedLine = styled.div`
  border-top: 1px dashed;
  border-left: 1px dashed;
  height: 0px;
  flex: 1;
`

const Container = styled(Stack)`
  flex: 1;
  color: ${getColor("textShy")};
  align-items: center;
  font-size: 14px;
`

const Content = styled(Text)`
  padding: 4px;
`

const Connector = styled(Stack)`
  flex: 1;
  align-items: center;
`

export const TimeDistance = ({ value, direction }: TimeDistanceProps) => {
  const content = (
    <Content color="supporting">{formatDuration(value, "min")}</Content>
  )
  const contentDirection = ["up", "down"].includes(direction) ? "column" : "row"
  return (
    <Container direction={contentDirection}>
      <Connector direction={contentDirection}>
        {direction === "up" && <ChevronUpIcon />}
        {direction === "left" && <ChevronLeftIcon />}
        <DashedLine />
      </Connector>
      {content}
      <Connector direction={contentDirection}>
        <DashedLine />
        {direction === "down" && <ChevronDownIcon />}
        {direction === "right" && <ChevronRightIcon />}
      </Connector>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Integrating Science-Backed Habits into Your Daily Routine with the ScheduleReview Component

To enhance user engagement and practicality, the page includes a ScheduleReview component adjacent to the scheduler. This component functions as a checklist for maintaining a healthy schedule. Each item on the list is backed by scientific evidence for its health benefits. Key examples include practicing 16 hours of intermittent fasting and ensuring 8 hours of sleep.

import { useAssertUserState } from "user/state/UserStateContext"
import { ScheduleCheckItem } from "./ScheduleCheckItem"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { VStack } from "@increaser/ui/layout/Stack"
import { Text } from "@increaser/ui/text"

const sleepHoursTarget = 8
const intermittentFastingHoursTarget = 16
const wakeUpFirstMealMinutesTarget = 60
const lastMealBedTimeHoursTarget = 3

export const ScheduleReview = () => {
  const { finishWorkAt, goToBedAt, wakeUpAt, firstMealAt, lastMealAt } =
    useAssertUserState()

  return (
    <VStack style={{ maxWidth: 320, minWidth: 240 }} gap={16}>
      <Text weight="bold">Healthy schedule</Text>
      <VStack gap={12}>
        <ScheduleCheckItem
          value={
            convertDuration(1, "d", "min") - (lastMealAt - firstMealAt) >=
            convertDuration(intermittentFastingHoursTarget, "h", "min")
          }
          label={`${intermittentFastingHoursTarget} hours of intermittent fasting`}
        />
        <ScheduleCheckItem
          value={firstMealAt - wakeUpAt >= wakeUpFirstMealMinutesTarget}
          label={`No food ${wakeUpFirstMealMinutesTarget} minutes after wake up`}
        />
        <ScheduleCheckItem
          value={
            goToBedAt - lastMealAt >=
            convertDuration(lastMealBedTimeHoursTarget, "h", "min")
          }
          label={`No food ${lastMealBedTimeHoursTarget} hours before sleep`}
        />
        <ScheduleCheckItem
          value={goToBedAt - finishWorkAt >= convertDuration(2, "h", "min")}
          label="2 hours of relaxation before bed"
        />
        <ScheduleCheckItem
          value={
            convertDuration(1, "d", "min") - goToBedAt + wakeUpAt >=
            convertDuration(sleepHoursTarget, "h", "min")
          }
          label={`Optimal ${sleepHoursTarget} hours of sleep for health`}
        />
      </VStack>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

The ScheduleCheckItem component presents the name of a habit and displays a check icon when its value is set to true. For styling, we employ CSS helpers such as round and sameDimensions to create a circular element, and centerContent for converting it into a flexbox element with centered content. The color of the element is determined using the getColor function, which retrieves the appropriate color from the theme.

import { centerContent } from "@increaser/ui/css/centerContent"
import { round } from "@increaser/ui/css/round"
import { sameDimensions } from "@increaser/ui/css/sameDimensions"
import { CheckIcon } from "@increaser/ui/icons/CheckIcon"
import { HStack } from "@increaser/ui/layout/Stack"
import { Text } from "@increaser/ui/text"
import { getColor } from "@increaser/ui/theme/getters"
import { ReactNode } from "react"
import styled from "styled-components"

interface ScheduleCheckItemProps {
  value: boolean
  label: ReactNode
}

const CheckContainer = styled.div`
  background: ${getColor("mist")};
  ${round};
  ${sameDimensions(24)};
  ${centerContent};
  font-size: 14px;
  color: ${getColor("success")};
`

export const ScheduleCheckItem = ({ value, label }: ScheduleCheckItemProps) => {
  return (
    <HStack alignItems="start" gap={8}>
      <CheckContainer>{value && <CheckIcon />}</CheckContainer>
      <Text height="large" color={value ? "supporting" : "shy"} as="div">
        {label}
      </Text>
    </HStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

As Increaser primarily functions as a time tracker, numerous time conversions are essential. The convertDuration function is particularly useful in this context. It takes three parameters: a value, the unit to convert from, and the unit to convert to. Utilizing a record of each time unit in milliseconds, the function facilitates seamless conversion between different time units.

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

Analyzing Work Sessions Over Time with the SetsExplorer Component

In the second section of the page, the SetsExplorer component is featured. This component showcases numeric statistics at the top, followed by a visualization of work sessions below. For this report, the SetsExplorerYAxis is used to display time labels, while SetsExplorerDays presents work sessions grouped by days.

import { HStack, VStack } from "@increaser/ui/layout/Stack"
import { SetsExplorerProvider } from "./SetsExplorerProvider"
import { SetsExplorerYAxis } from "./SetsExplorerYAxis"
import { SetsExplorerDays } from "./SetsExplorerDays"
import { SetsExplorerStats } from "./SetsExplorerStats"

export const SetsExplorer = () => {
  return (
    <SetsExplorerProvider>
      <VStack gap={40}>
        <SetsExplorerStats />
        <HStack fullWidth gap={8}>
          <SetsExplorerYAxis />
          <SetsExplorerDays />
        </HStack>
      </VStack>
    </SetsExplorerProvider>
  )
}
Enter fullscreen mode Exit fullscreen mode

We wrap everything within a SetsExplorerProvider that will provide sets organized in days, a start and end hour for the visualization and a boolean indicating whether today should be included in the report. To respect user's preference for today's inclusion in the report, we store the value in local storage. To learn more about a bulletproof implementation behind usePersistentState hook, refer to this post.

import { Set } from "@increaser/entities/User"
import { ComponentWithChildrenProps } from "@increaser/ui/props"
import { createContext, useMemo } from "react"
import { useAssertUserState } from "user/state/UserStateContext"
import { startOfDay } from "date-fns"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { range } from "@increaser/utils/array/range"
import { getLastItem } from "@increaser/utils/array/getLastItem"
import { createContextHook } from "@increaser/ui/state/createContextHook"
import { PersistentStateKey, usePersistentState } from "state/persistentState"

export interface SetsExplorerDay {
  startedAt: number
  sets: Set[]
}

interface SetsExplorerState {
  days: SetsExplorerDay[]
  startHour: number
  endHour: number

  includesToday: boolean
  setIncludesToday: (value: boolean) => void
}

const SetsExplorerContext = createContext<SetsExplorerState | undefined>(
  undefined
)

export const SetsExplorerProvider = ({
  children,
}: ComponentWithChildrenProps) => {
  const { sets, startWorkAt, finishWorkAt } = useAssertUserState()
  const [includesToday, setIncludesToday] = usePersistentState(
    PersistentStateKey.IncludeTodayInSetsExplorer,
    false
  )

  const days = useMemo(() => {
    const todayStartedAt = startOfDay(Date.now()).getTime()
    const firstDayStartedAt = startOfDay(sets[0].start).getTime()
    const daysCount =
      Math.round(
        convertDuration(todayStartedAt - firstDayStartedAt, "ms", "d")
      ) + 1

    const result: SetsExplorerDay[] = range(daysCount).map((dayIndex) => {
      const startedAt = firstDayStartedAt + convertDuration(dayIndex, "d", "ms")
      return {
        startedAt,
        sets: [],
      }
    })
    sets.forEach((set) => {
      const setDayStartedAt = startOfDay(set.start).getTime()
      const dayIndex = result.findIndex(
        ({ startedAt }) => startedAt >= setDayStartedAt
      )
      result[dayIndex].sets.push(set)
    })

    if (includesToday) return result

    return result.slice(0, -1)
  }, [includesToday, sets])

  const [startHour, endHour] = useMemo(() => {
    const daysWithSets = days.filter(({ sets }) => sets.length > 0)
    return [
      Math.min(
        Math.floor(convertDuration(startWorkAt, "min", "h")),
        ...daysWithSets.map(({ sets, startedAt }) => {
          return Math.floor(
            convertDuration(sets[0].start - startedAt, "ms", "h")
          )
        })
      ),
      Math.max(
        Math.ceil(convertDuration(finishWorkAt, "min", "h")),
        ...daysWithSets.map(({ sets, startedAt }) => {
          return Math.ceil(
            convertDuration(getLastItem(sets).end - startedAt, "ms", "h")
          )
        })
      ),
    ]
  }, [days, finishWorkAt, startWorkAt])

  return (
    <SetsExplorerContext.Provider
      value={{ days, startHour, endHour, includesToday, setIncludesToday }}
    >
      {children}
    </SetsExplorerContext.Provider>
  )
}

export const useSetsExplorer = createContextHook(
  SetsExplorerContext,
  "SetsExplorerContext"
)
Enter fullscreen mode Exit fullscreen mode

The first set in the dataset is used to ascertain the start date of the report. By calculating the time difference between the start of today and the first day, we determine the report's duration in days. For the Y-axis of our report, it's crucial to identify the earliest start hour and the latest end hour of workdays. Additionally, to efficiently utilize our context within a React component, I employ a utility function named createContextHook. This function acts as a wrapper around the useContext hook, with added functionality to throw an error if the context is not provided.

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

Displaying Key Work Session Statistics with SetsExplorerStats and StatisticPanel

We begin by presenting the SetsExplorerStats component, which features a title indicating the number of days encompassed in the report, as well as a toggle option to include the current day.

import { HStack, VStack } from "@increaser/ui/layout/Stack"
import { SetsExplorerDay, useSetsExplorer } from "./SetsExplorerProvider"
import { Text } from "@increaser/ui/text"
import { pluralize } from "@increaser/utils/pluralize"
import { UniformColumnGrid } from "@increaser/ui/layout/UniformColumnGrid"
import { MinimalisticToggle } from "@increaser/ui/inputs/MinimalisticToggle"
import { useMemo } from "react"
import { isEmpty } from "@increaser/utils/array/isEmpty"
import { getAverage } from "@increaser/utils/math/getAverage"
import { formatDailyEventTime } from "@increaser/utils/time/formatDailyEventTime"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { StatisticPanel } from "@increaser/ui/panel/StatisticPanel"
import { formatDuration } from "@increaser/utils/time/formatDuration"
import { getSetsSum } from "sets/helpers/getSetsSum"
import { getBlocks } from "sets/Block"
import { isWorkday } from "@increaser/utils/time/isWorkday"

const getFormattedAvgWorkdayStart = (days: SetsExplorerDay[]) => {
  const ms = getAverage(days.map((day) => day.sets[0].start - day.startedAt))

  return formatDailyEventTime(convertDuration(ms, "ms", "min"))
}

const getFormattedAvgWorkdayEnd = (days: SetsExplorerDay[]) => {
  const ms = getAverage(
    days.map((day) => day.sets[day.sets.length - 1].end - day.startedAt)
  )

  return formatDailyEventTime(convertDuration(ms, "ms", "min"))
}

const getFormattedAvgBlock = (days: SetsExplorerDay[]) => {
  const sets = days.flatMap((day) => day.sets)
  const blocks = getBlocks(sets)
  const total = getSetsSum(sets)

  return formatDuration(total / blocks.length, "ms")
}

const getFormattedAvgWorkday = (days: SetsExplorerDay[]) => {
  const workdays = days.filter((day) => isWorkday(day.startedAt))
  if (!workdays.length) return

  const sets = workdays.flatMap((day) => day.sets)

  return formatDuration(getSetsSum(sets) / workdays.length, "ms")
}

const getFormattedAvgWeekend = (days: SetsExplorerDay[]) => {
  const weekends = days.filter((day) => !isWorkday(day.startedAt))
  if (!weekends.length) return

  const sets = weekends.flatMap((day) => day.sets)

  return formatDuration(getSetsSum(sets) / weekends.length, "ms")
}

export const SetsExplorerStats = () => {
  const { days, includesToday, setIncludesToday } = useSetsExplorer()

  const daysWithSets = useMemo(
    () => days.filter((day) => !isEmpty(day.sets)),
    [days]
  )

  return (
    <VStack gap={16}>
      <HStack fullWidth alignItems="center" justifyContent="space-between">
        <Text weight="bold">
          {includesToday ? "" : "Past "}
          {pluralize(days.length, "day")} statistics
        </Text>
        <MinimalisticToggle
          value={includesToday}
          onChange={setIncludesToday}
          label="include today"
        />
      </HStack>
      <UniformColumnGrid gap={16} maxColumns={5} minChildrenWidth={160}>
        <StatisticPanel
          title="Start work"
          value={
            isEmpty(daysWithSets)
              ? undefined
              : getFormattedAvgWorkdayStart(daysWithSets)
          }
        />
        <StatisticPanel
          title="End work"
          value={
            isEmpty(daysWithSets)
              ? undefined
              : getFormattedAvgWorkdayEnd(daysWithSets)
          }
        />
        <StatisticPanel
          title="Block"
          value={
            isEmpty(daysWithSets)
              ? undefined
              : getFormattedAvgBlock(daysWithSets)
          }
        />
        <StatisticPanel
          title="Workday"
          value={
            isEmpty(daysWithSets)
              ? undefined
              : getFormattedAvgWorkday(daysWithSets)
          }
        />
        <StatisticPanel
          title="Weekend"
          value={
            isEmpty(daysWithSets)
              ? undefined
              : getFormattedAvgWeekend(daysWithSets)
          }
        />
      </UniformColumnGrid>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

We showcase the statistics using a CSS Grid layout, implemented with the UniformColumnGrid component. This component is designed to ensure uniform width for the content within. Given its frequent use, creating a component that simplifies interactions with CSS Grid is valuable.

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

interface UniformColumnGridProps {
  gap: number
  minChildrenWidth?: number
  maxChildrenWidth?: 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} * ${toSizeUnit(gap)})`

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

const getColumnWidth = ({
  minChildrenWidth,
  maxChildrenWidth,
  maxColumns,
  gap,
  childrenWidth,
}: UniformColumnGridProps) => {
  if (childrenWidth !== undefined) {
    return toSizeUnit(childrenWidth)
  }

  return `
    minmax(
      max(
        ${toSizeUnit(minChildrenWidth || 0)},
        ${getColumnMax(maxColumns, gap)}
      ),
      ${maxChildrenWidth ? toSizeUnit(maxChildrenWidth) : "1fr"}
  )`
}

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

To display a statistic, we utilize the StatisticPanel component, which presents a title in a smaller font while accentuating the value. If data is unavailable, it displays a dash in place of a formatted value. Given the availability of numerous helpers to manage work sessions and time, calculating data like the average start of a workday or the average amount of work on a weekend becomes quite straightforward.

import { VStack } from "@radzionkit/ui/layout/Stack"
import { Panel } from "@radzionkit/ui/panel/Panel"
import { Text } from "@radzionkit/ui/text"
import { ReactNode } from "react"

interface StatisticPanelProps {
  title: ReactNode
  value?: ReactNode
}
export const StatisticPanel = ({ title, value }: StatisticPanelProps) => (
  <Panel>
    <VStack gap={8}>
      <Text as="div" size={14}>
        {title}
      </Text>
      <Text as="div" size={20} weight="bold">
        {value ?? "-"}
      </Text>
    </VStack>
  </Panel>
)
Enter fullscreen mode Exit fullscreen mode

Enhancing User Experience with Dynamic Visualization in SetsExplorerDays

The most challenging aspect is the visualization of sessions. Given the potential for numerous days in the report, fitting all content without a horizontal scroll becomes impractical. To address this, we ensure the Y axis remains fixed while enabling horizontal scrolling. This is achieved by employing a flexbox with row direction and setting overflow-x: auto on the container of the SetsExplorerDays.

import { Spacer } from "@increaser/ui/layout/Spacer"
import { VStack } from "@increaser/ui/layout/Stack"
import { setsExplorerConfig } from "./config"
import { useSetsExplorer } from "./SetsExplorerProvider"
import { DayTimeLabels } from "../DayTimeLabels"

export const SetsExplorerYAxis = () => {
  const { startHour, endHour } = useSetsExplorer()

  return (
    <VStack>
      <Spacer height={setsExplorerConfig.headerHeight} />
      <DayTimeLabels
        startHour={startHour}
        endHour={endHour}
        style={{
          height: (endHour - startHour) * setsExplorerConfig.hourHeight,
        }}
      />
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

To align the Y axis accurately with time labels and daily content, we use a set of predefined constants in a record named setsExplorerConfig. Specifically, we retrieve the hourHeight value from this configuration and multiply it by the total number of hours to determine the height of the time labels container.

export const setsExplorerConfig = {
  headerHeight: 60,
  hourHeight: 30,
  dayWith: 100,
  daysGap: 8,
}
Enter fullscreen mode Exit fullscreen mode

The DayTimeLabels component plays a key role in displaying time labels alongside daily events, represented as small icons. This component is not limited to the schedule page; it is also utilized on the dashboard and focus pages to create a timeline. The component calculates the number of labels required based on the start and end hours, ensuring their even distribution along the Y-axis.

import styled, { useTheme } from "styled-components"
import { range } from "@increaser/utils/array/range"
import { PositionAbsolutelyCenterHorizontally } from "@increaser/ui/layout/PositionAbsolutelyCenterHorizontally"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { Text } from "@increaser/ui/text"
import { getColor } from "@increaser/ui/theme/getters"
import { useAssertUserState } from "user/state/UserStateContext"
import { IconWrapper } from "@increaser/ui/icons/IconWrapper"
import { getDayMomentColor } from "sets/utils/getDayMomentColor"
import { HStack } from "@increaser/ui/layout/Stack"
import { dayMomentIcon } from "./dayMomentIcon"
import { toSizeUnit } from "@increaser/ui/css/toSizeUnit"
import { toPercents } from "@increaser/utils/toPercents"
import { dayMomentStep, dayMoments } from "@increaser/entities/DayMoments"
import { formatDailyEventTime } from "@increaser/utils/time/formatDailyEventTime"
import { UIComponentProps } from "@increaser/ui/props"

export const dayTimeLabelsWidthInPx = 48
export const dayTimeLabelTimeWidthInPx = 32

const Container = styled.div`
  position: relative;
  width: ${toSizeUnit(dayTimeLabelsWidthInPx)};
  height: 100%;
`

const MarkContainer = styled(HStack)`
  display: grid;
  align-items: center;
  grid-template-columns: ${toSizeUnit(dayTimeLabelTimeWidthInPx)} ${toSizeUnit(
      dayTimeLabelsWidthInPx - dayTimeLabelTimeWidthInPx
    )};
  gap: 4px;
`

const Mark = styled.div`
  height: 1px;
  background: ${getColor("textShy")};
  justify-self: end;
`

interface DayTimeLabelsProps extends UIComponentProps {
  startHour: number
  endHour: number
}

export const DayTimeLabels = ({
  startHour,
  endHour,
  ...rest
}: DayTimeLabelsProps) => {
  const user = useAssertUserState()

  const marksCount =
    (endHour - startHour) / convertDuration(dayMomentStep, "min", "h") + 1

  const theme = useTheme()

  return (
    <Container {...rest}>
      {range(marksCount).map((markIndex) => {
        const minutesSinceStart = markIndex * dayMomentStep
        const minutes =
          minutesSinceStart + convertDuration(startHour, "h", "min")
        const top = toPercents(markIndex / (marksCount - 1))
        const isPrimaryMark = minutes % 60 === 0
        const moment = dayMoments.find((moment) => user[moment] === minutes)

        const color = moment
          ? getDayMomentColor(moment, theme).toCssValue()
          : undefined

        return (
          <PositionAbsolutelyCenterHorizontally
            key={markIndex}
            fullWidth
            top={top}
          >
            <MarkContainer>
              {isPrimaryMark ? (
                <Text size={12} color="supporting">
                  {formatDailyEventTime(minutes)}
                </Text>
              ) : moment ? (
                <Mark
                  style={{
                    width: isPrimaryMark ? "100%" : "32%",
                    background: color,
                  }}
                />
              ) : (
                <div />
              )}
              {moment && (
                <IconWrapper style={{ color, fontSize: 14 }}>
                  {dayMomentIcon[moment]}
                </IconWrapper>
              )}
            </MarkContainer>
          </PositionAbsolutelyCenterHorizontally>
        )
      })}
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

When positioning an element absolutely, there are only top, left, right, and bottom properties. There's no center option for this purpose. To address this, we use the PositionAbsolutelyCenterHorizontally component. This component accepts a top property to set the vertical position and an optional fullWidth property to decide if the element should span the full width of its parent. We achieve the centering effect by strategically using nested divs with absolute positioning.

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

Streamlining Productivity Analysis with SetsExplorerDayView and Work Block Visualization

The SetsExplorerDays component presents days and their corresponding work blocks, along with a chart that highlights variations in work hours. Given the horizontal scrolling feature and the days being sorted in ascending order, the component is designed to automatically scroll to the chart's end. This functionality is accomplished using the useEffect hook, where the scrollLeft property of the container is set to match the container's scrollWidth.

import styled from "styled-components"
import { useSetsExplorer } from "./SetsExplorerProvider"
import { SetsExplorerDayView } from "./SetsExplorerDayView"
import { useEffect, useRef } from "react"
import { horizontalPadding } from "@increaser/ui/css/horizontalPadding"
import { toSizeUnit } from "@increaser/ui/css/toSizeUnit"
import { VStack } from "@increaser/ui/layout/Stack"
import { SetsExplorerDaysChart } from "./SetsExplorerDaysChart"
import { setsExplorerConfig } from "./config"

const Wrapper = styled(VStack)`
  ${horizontalPadding(4)};
  gap: ${toSizeUnit(setsExplorerConfig.daysGap)};
  overflow-x: auto;
  flex: 1;
  &::-webkit-scrollbar {
    height: 8px;
  }
`

const Container = styled.div`
  display: flex;
  flex-direction: row;
  gap: ${toSizeUnit(setsExplorerConfig.daysGap)};
`

export const SetsExplorerDays = () => {
  const { days } = useSetsExplorer()
  const container = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const element = container.current
    if (element) {
      const scrollWidth = element.scrollWidth
      element.scrollLeft = scrollWidth
    }
  }, [days.length])

  return (
    <Wrapper ref={container}>
      <Container>
        {days.map((day) => (
          <SetsExplorerDayView key={day.startedAt} day={day} />
        ))}
      </Container>
      <SetsExplorerDaysChart />
    </Wrapper>
  )
}
Enter fullscreen mode Exit fullscreen mode

The SetsExplorerDayView component features a header displaying the date and weekday, with work blocks forming the main content. Work sessions are organized into blocks, defined as collections of sessions separated by no more than 15 minutes. Targeting 90-minute blocks has proven effective in enhancing productivity.

import styled from "styled-components"
import { SetsExplorerDay, useSetsExplorer } from "./SetsExplorerProvider"
import { setsExplorerConfig } from "./config"
import { toSizeUnit } from "@increaser/ui/css/toSizeUnit"
import { VStack } from "@increaser/ui/layout/Stack"
import { Text } from "@increaser/ui/text"
import { format } from "date-fns"
import { useMemo } from "react"
import { getBlocks } from "sets/Block"
import { SetsExplorerWorkBlock } from "./SetsExplorerWorkBlock"

interface SetsExplorerDayViewProps {
  day: SetsExplorerDay
}

const Container = styled.div`
  min-width: ${toSizeUnit(setsExplorerConfig.dayWith)};
  width: ${toSizeUnit(setsExplorerConfig.dayWith)};
`

const Content = styled.div`
  position: relative;
`

export const SetsExplorerDayView = ({ day }: SetsExplorerDayViewProps) => {
  const { startHour, endHour } = useSetsExplorer()
  const height = (endHour - startHour) * setsExplorerConfig.hourHeight

  const blocks = useMemo(() => getBlocks(day.sets), [day.sets])

  return (
    <Container>
      <VStack
        alignItems="center"
        gap={4}
        style={{ height: setsExplorerConfig.headerHeight }}
      >
        <Text size={14} weight="semibold" color="supporting">
          {format(day.startedAt, "dd MMM")}
        </Text>
        <Text size={14} color="shy">
          {format(day.startedAt, "EEEE")}
        </Text>
      </VStack>
      <Content style={{ height }}>
        {blocks.map((block, index) => (
          <SetsExplorerWorkBlock
            key={index}
            block={block}
            dayStartedAt={day.startedAt}
          />
        ))}
      </Content>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

After organizing sessions into blocks, we iterate over them to display each SetsExplorerWorkBlock component. This component employs a familiar technique of absolute positioning to add an outline. It then iterates over each session within the block for rendering. Given the known start and end times of the timeline, calculating the height and top position of each block becomes straightforward.

import { Block } from "@increaser/entities/Block"
import styled from "styled-components"
import { getBlockBoundaries } from "@increaser/entities-utils/block"
import { toPercents } from "@increaser/utils/toPercents"
import { takeWholeSpace } from "@increaser/ui/css/takeWholeSpace"
import { getColor } from "@increaser/ui/theme/getters"

import { convertDuration } from "@increaser/utils/time/convertDuration"
import { getSetDuration } from "@increaser/entities-utils/set/getSetDuration"
import { transition } from "@increaser/ui/css/transition"
import { absoluteOutline } from "@increaser/ui/css/absoluteOutline"
import { useSetsExplorer } from "./SetsExplorerProvider"
import { WorkSession } from "../DayOverview/WorkBlocks/WorkSession"

interface SetsExplorerWorkBlockProps {
  block: Block
  dayStartedAt: number
}

const Container = styled.div`
  width: 100%;
  position: absolute;
  ${transition};
`

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

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

export const SetsExplorerWorkBlock = ({
  block,
  dayStartedAt,
}: SetsExplorerWorkBlockProps) => {
  const { startHour, endHour } = useSetsExplorer()
  const timelineStartsAt = dayStartedAt + convertDuration(startHour, "h", "ms")
  const timespan = convertDuration(endHour - startHour, "h", "ms")
  const { start, end } = getBlockBoundaries(block)
  const blockDuration = end - start

  return (
    <Container
      style={{
        top: toPercents((start - timelineStartsAt) / timespan),
        height: toPercents(blockDuration / timespan),
      }}
    >
      <Content>
        <Outline />
        {block.sets.map((set, index) => (
          <WorkSession
            key={index}
            set={set}
            showIdentifier={false}
            style={{
              top: toPercents((set.start - start) / blockDuration),
              height: toPercents(getSetDuration(set) / blockDuration),
            }}
          />
        ))}
      </Content>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Visualizing Work Hours with SplineChart: Integrating SVG for Enhanced Data Representation

To illustrate the correlation between the structure of the blocks and total work hours, we incorporate a small chart. The width of this chart's container is determined based on our configuration settings. We then compute an array representing the total hours, which serves as the data for the chart. This data array is subsequently passed to the SplineChart component.

import { useSetsExplorer } from "./SetsExplorerProvider"
import { getSetsSum } from "sets/helpers/getSetsSum"
import { SplineChart } from "@increaser/ui/charts/SplineChart"
import { normalize } from "@increaser/utils/math/normalize"
import { VStack } from "@increaser/ui/layout/Stack"
import styled from "styled-components"
import { verticalPadding } from "@increaser/ui/css/verticalPadding"
import { setsExplorerConfig } from "./config"

const Container = styled(VStack)`
  ${verticalPadding(4)};
`

export const SetsExplorerDaysChart = () => {
  const { days } = useSetsExplorer()
  const data = days.map((day) => getSetsSum(day.sets))
  const width =
    (days.length - 1) *
    (setsExplorerConfig.dayWith + setsExplorerConfig.daysGap)

  return (
    <Container
      alignItems="center"
      style={{ width: width + setsExplorerConfig.dayWith }}
    >
      <SplineChart width={width} height={40} data={normalize(data)} />
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

We use an SVG element comprising two paths: the first for drawing a curved line and the second for adding a background beneath it. This is where ChatGPT becomes valuable. Although it might not write the entire component flawlessly at first, understanding the building blocks of the problem is beneficial. Then, you can request specific functions like calculateControlPoints or createPath from the AI, which would be more tedious to deduce independently.

import { normalize } from "@radzionkit/utils/math/normalize"
import { useMemo } from "react"
import { Point } from "../../entities/Point"
import { useTheme } from "styled-components"

interface SplineChartProps {
  data: number[]
  height: number
  width: number
}

const calculateControlPoints = (dataPoints: Point[]) => {
  const controlPoints = []
  for (let i = 0; i < dataPoints.length - 1; i++) {
    const current = dataPoints[i]
    const next = dataPoints[i + 1]
    controlPoints.push({
      x: (current.x + next.x) / 2,
      y: (current.y + next.y) / 2,
    })
  }
  return controlPoints
}

const createPath = (
  points: Point[],
  controlPoints: Point[],
  width: number,
  height: number
) => {
  let path = `M${points[0].x * width} ${height - points[0].y * height}`
  for (let i = 0; i < points.length - 1; i++) {
    const current = points[i]
    const next = points[i + 1]
    const control = controlPoints[i]
    path +=
      ` C${control.x * width} ${height - current.y * height},` +
      `${control.x * width} ${height - next.y * height},` +
      `${next.x * width} ${height - next.y * height}`
  }
  return path
}

const createClosedPath = (
  points: Point[],
  controlPoints: Point[],
  width: number,
  height: number
) => {
  let path = `M${points[0].x * width} ${height}`
  path += ` L${points[0].x * width} ${height - points[0].y * height}`

  for (let i = 0; i < points.length - 1; i++) {
    const current = points[i]
    const next = points[i + 1]
    const control = controlPoints[i]
    path +=
      ` C${control.x * width} ${height - current.y * height},` +
      `${control.x * width} ${height - next.y * height},` +
      `${next.x * width} ${height - next.y * height}`
  }

  path += ` L${points[points.length - 1].x * width} ${height}`
  path += " Z"

  return path
}

export const SplineChart = ({ data, width, height }: SplineChartProps) => {
  const [path, closedPath] = useMemo(() => {
    if (data.length === 0) return ["", ""]

    const normalizedData = normalize(data)
    const points = normalizedData.map((value, index) => ({
      x: index / (normalizedData.length - 1),
      y: value,
    }))

    const controlPoints = calculateControlPoints(points)
    return [
      createPath(points, controlPoints, width, height),
      createClosedPath(points, controlPoints, width, height),
    ]
  }, [data, height, width])

  const theme = useTheme()

  return (
    <svg
      style={{ minWidth: width, overflow: "visible" }}
      width={width}
      height={height}
      viewBox={`0 0 ${width} ${height}`}
    >
      <path
        d={path}
        fill="none"
        stroke={theme.colors.primary.toCssValue()}
        strokeWidth="2"
      />
      <path
        d={closedPath}
        fill={theme.colors.primary.getVariant({ a: () => 0.1 }).toCssValue()}
        strokeWidth="0"
      />
    </svg>
  )
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)