DEV Community

Cover image for Building an Interactive Timeline with React and TypeScript: Managing Sessions Efficiently
Radzion Chachura
Radzion Chachura

Posted on • Originally published at radzion.com

Building an Interactive Timeline with React and TypeScript: Managing Sessions Efficiently

🐙 GitHub | 🎮 Demo

Building a Time-Tracking Feature with React and TypeScript

In this article, we'll build an exciting feature for a real productivity app using React and TypeScript. We'll create an interactive timeline that allows users to track their time seamlessly by adding, editing, and deleting sessions without traditional input fields. Instead, the interface will resemble a calendar app. Although the Increaser source code is in a private repository, you can still explore all the reusable components and utilities in the RadzionKit repository.

Adding a session

Setting Up the Initial React State and Context for Session Management

To start, let's set up the essential React state for this feature. Our mutable state will consist of two fields:

  • weekday: An index representing the selected day of the week where the user wants to add, edit, or delete a session.
  • currentSet: This field will be updated when a user clicks on an existing session or starts creating a new one.

We'll extend the Set type with an optional index field to identify which session is being edited. This will help us distinguish between editing an existing session and creating a new one.

Editing current session

import { Set } from "@increaser/entities/User"
import { createContextHook } from "@lib/ui/state/createContextHook"
import { Dispatch, SetStateAction, createContext } from "react"

import { Interval } from "@lib/utils/interval/Interval"

type TrackTimeSet = Set & {
  index?: number
}

export type TrackTimeMutableState = {
  weekday: number
  currentSet: TrackTimeSet | null
}

type TrackTimeState = TrackTimeMutableState & {
  setState: Dispatch<SetStateAction<TrackTimeMutableState>>

  sets: Set[]
  dayInterval: Interval
}

export const TrackTimeContext = createContext<TrackTimeState | undefined>(
  undefined
)

export const useTrackTime = createContextHook(TrackTimeContext, "TrackTime")
Enter fullscreen mode Exit fullscreen mode

In Increaser, we represent a work session as an interval where start and end times are stored as timestamps and projectId is a reference to the project the session belongs to.

export type Interval = {
  start: number
  end: number
}

export type Set = Interval & {
  projectId: string
}
Enter fullscreen mode Exit fullscreen mode

Our context state will include:

  • A mutable state to keep track of changes.
  • A setState function to modify the mutable state.
  • An array of sets representing sessions for the selected day.
  • An interval reflecting the timeframe of the selected day. For past days, the interval will span from the start to the end of the day. For the current day, the interval will range from the start of the day up to the current time.

To create a hook for accessing the context state, we use a small helper function called createContextHook. This function checks if the context is available and throws an error if it's 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

The TrackTimeContext provider will manage the mutable state, initializing weekday to the current day of the week and setting currentSet to null initially. To supply dayInterval and sets to the context, we manipulate the existing state provided by various hooks. This setup ensures that the relevant data is easily accessible and consistently updated.

import { useCurrentWeekSets } from "@increaser/ui/sets/hooks/useCurrentWeekSets"
import { useWeekday } from "@lib/ui/hooks/useWeekday"
import { ComponentWithChildrenProps } from "@lib/ui/props"
import { useMemo, useState } from "react"
import { getDaySets } from "../../sets/helpers/getDaySets"
import { useStartOfWeek } from "@lib/ui/hooks/useStartOfWeek"
import { convertDuration } from "@lib/utils/time/convertDuration"
import {
  TrackTimeContext,
  TrackTimeMutableState,
} from "./state/TrackTimeContext"

export const TrackTimeProvider = ({ children }: ComponentWithChildrenProps) => {
  const currentWeekday = useWeekday()
  const [state, setState] = useState<TrackTimeMutableState>({
    weekday: currentWeekday,
    currentSet: null,
  })

  const { weekday } = state

  const currentWeekSets = useCurrentWeekSets()
  const weekStartedAt = useStartOfWeek()

  const dayInterval = useMemo(() => {
    const start = weekStartedAt + convertDuration(weekday, "d", "ms")
    const end =
      weekday === currentWeekday
        ? Date.now()
        : start + convertDuration(1, "d", "ms")

    return { start, end }
  }, [currentWeekday, weekStartedAt, weekday])

  const sets = useMemo(() => {
    return getDaySets(currentWeekSets, dayInterval.start)
  }, [currentWeekSets, dayInterval.start])

  return (
    <TrackTimeContext.Provider
      value={{ ...state, dayInterval, sets, setState }}
    >
      {children}
    </TrackTimeContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Structuring the Time-Tracking Feature: Header, Content, and Footer

Our time-tracking feature comprises three key parts: a header, a content area where users interact with sessions, and a footer with action buttons. By applying flex: 1 to all containers, the content area occupies the entire available vertical space. The Panel component from RadzionKit helps organize the content and footer, separating them with a line for a clear visual distinction.

import { Panel } from "@lib/ui/panel/Panel"
import { VStack } from "@lib/ui/layout/Stack"
import styled from "styled-components"
import { TrackTimeFooter } from "./TrackTimeFooter"
import { TrackTimeHeader } from "./TrackTimeHeader"
import { TrackTimeContent } from "./TrackTimeContent"

const Container = styled(VStack)`
  max-width: 440px;
  gap: 16px;
  flex: 1;
`

export const TrackTime = () => (
  <Container>
    <TrackTimeHeader />
    <Panel style={{ flex: 1 }} kind="secondary" withSections>
      <TrackTimeContent />
      <TrackTimeFooter />
    </Panel>
  </Container>
)
Enter fullscreen mode Exit fullscreen mode

Our header is a flexbox row container with the title aligned to the left and two selectors positioned on the right. The ProjectSelector is displayed only when the user is either creating or editing a session.

import { useTrackTime } from "./state/TrackTimeContext"
import { WeekdaySelector } from "./WeekdaySelector"
import { HStack } from "@lib/ui/layout/Stack"
import { ProjectSelector } from "./ProjectSelector"
import { TrackTimeTitle } from "./TrackTimeTitle"

export const TrackTimeHeader = () => {
  const { currentSet } = useTrackTime()

  return (
    <HStack
      fullWidth
      alignItems="center"
      justifyContent="space-between"
      gap={20}
      wrap="wrap"
    >
      <TrackTimeTitle />
      <HStack alignItems="center" gap={8}>
        {currentSet && <ProjectSelector />}
        <WeekdaySelector />
      </HStack>
    </HStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

The TrackTimeTitle component displays a title based on the current state. If no session is selected, the title will read "Manage sessions." If a session has an index, indicating that an existing session is being edited, the title will be "Edit session." Otherwise, the title will be "Add session."

import { useTrackTime } from "./state/TrackTimeContext"
import { SectionTitle } from "@lib/ui/text/SectionTitle"

import { useMemo } from "react"

export const TrackTimeTitle = () => {
  const { currentSet } = useTrackTime()
  const title = useMemo(() => {
    if (!currentSet) {
      return "Manage sessions"
    }
    if (currentSet.index !== undefined) {
      return "Edit session"
    }

    return "Add session"
  }, [currentSet])

  return <SectionTitle>{title}</SectionTitle>
}
Enter fullscreen mode Exit fullscreen mode

Selecting a weekday

Both the ProjectSelector and WeekdaySelector components are dropdowns built on the ExpandableSelector component from RadzionKit. Although currentSet may sometimes be empty, we know that when the ProjectSelector is displayed, currentSet is populated. Thus, we use the shouldBePresent utility to confirm that the value is not null.

import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { ExpandableSelector } from "@lib/ui/select/ExpandableSelector"
import { Text } from "@lib/ui/text"
import { useTrackTime } from "./state/TrackTimeContext"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"

export const ProjectSelector = () => {
  const { currentSet, setState } = useTrackTime()
  const { projectId } = shouldBePresent(currentSet)
  const { activeProjects, projectsRecord } = useProjects()

  return (
    <ExpandableSelector
      style={{ width: 142 }}
      value={projectId}
      onChange={(projectId) =>
        setState((state) => ({
          ...state,
          currentSet: {
            ...shouldBePresent(state.currentSet),
            projectId,
          },
        }))
      }
      options={activeProjects.map((project) => project.id)}
      getOptionKey={(option) => option}
      renderOption={(option) => (
        <>
          <Text color="contrast">{projectsRecord[option].emoji}</Text>
          <Text>{option ? projectsRecord[option].name : "All projects"}</Text>
        </>
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

At the end of each week and month Increaser sets the week and month totals for each projects and they can't be changed. Therefore in the WeekdaySelector component we only give user the option to select days that belong to the current week and month. We also disable the selector when the user edits or creates a session.

import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import { useStartOfWeek } from "@lib/ui/hooks/useStartOfWeek"
import { useWeekday } from "@lib/ui/hooks/useWeekday"
import { ExpandableSelector } from "@lib/ui/select/ExpandableSelector"
import { range } from "@lib/utils/array/range"
import { WEEKDAYS } from "@lib/utils/time"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { useMemo } from "react"
import { useTrackTime } from "./state/TrackTimeContext"

export const WeekdaySelector = () => {
  const { weekday, setState, currentSet } = useTrackTime()
  const { lastSyncedMonthEndedAt, lastSyncedWeekEndedAt } = useAssertUserState()
  const currentWeekday = useWeekday()
  const weekStartedAt = useStartOfWeek()

  const options = useMemo(() => {
    const weekdays = range(currentWeekday + 1)

    const minStartedAt = Math.max(
      lastSyncedMonthEndedAt ?? 0,
      lastSyncedWeekEndedAt ?? 0
    )

    return weekdays.filter((weekday) => {
      const dayStartedAt = weekStartedAt + convertDuration(weekday, "d", "ms")
      return dayStartedAt >= minStartedAt
    })
  }, [
    currentWeekday,
    lastSyncedMonthEndedAt,
    lastSyncedWeekEndedAt,
    weekStartedAt,
  ])

  return (
    <ExpandableSelector
      style={{ width: 142 }}
      isDisabled={currentSet !== null}
      value={weekday}
      onChange={(weekday) => setState((state) => ({ ...state, weekday }))}
      options={options.toReversed()}
      getOptionKey={(option) => option.toString()}
      renderOption={(option) => {
        if (option === currentWeekday) {
          return "Today"
        }
        if (option === currentWeekday - 1) {
          return "Yesterday"
        }

        return WEEKDAYS[option]
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

In the TrackTimeFooter component, we include the DeleteSetAction component when the user is editing an existing session. If the user is creating or editing a session, we show "Submit" and "Cancel" buttons. Otherwise, the AddSetPrompt component is displayed.

import { Button } from "@lib/ui/buttons/Button"
import { HStack } from "@lib/ui/layout/Stack"

import { useTrackTime } from "./state/TrackTimeContext"
import { DeleteSetAction } from "./DeleteSetAction"
import { AddSetPrompt } from "./AddSetPrompt"
import { SubmitSetAction } from "./SubmitSetAction"

export const TrackTimeFooter = () => {
  const { setState, currentSet } = useTrackTime()

  return (
    <HStack gap={12} wrap="wrap" fullWidth justifyContent="space-between">
      {currentSet?.index === undefined ? <div /> : <DeleteSetAction />}
      {currentSet ? (
        <HStack gap={12}>
          <Button
            onClick={() =>
              setState((state) => ({
                ...state,
                currentSet: null,
              }))
            }
            kind="secondary"
          >
            Cancel
          </Button>
          <SubmitSetAction />
        </HStack>
      ) : (
        <AddSetPrompt />
      )}
    </HStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

When the user clicks the "Delete" button, we invoke the useDeleteSetMutation hook to remove the session and set currentSet to null, thereby concluding the editing process.

import { Button } from "@lib/ui/buttons/Button"

import { analytics } from "../../analytics"
import { useTrackTime } from "./state/TrackTimeContext"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { useDeleteSetMutation } from "../../sets/hooks/useDeleteSetMutation"

export const DeleteSetAction = () => {
  const { sets, setState, currentSet } = useTrackTime()

  const { mutate: deleteSet } = useDeleteSetMutation()

  return (
    <Button
      onClick={() => {
        deleteSet(sets[shouldBePresent(currentSet?.index)])
        analytics.trackEvent("Delete session")
        setState((state) => ({
          ...state,
          currentSet: null,
        }))
      }}
      kind="alert"
    >
      Delete
    </Button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Deleting Sessions with useDeleteSetMutation

The useDeleteSetMutation hook optimistically updates the client state and then calls the API to delete the session, using the interval as the input.

import { useMutation } from "@tanstack/react-query"
import { useApi } from "@increaser/api-ui/hooks/useApi"
import {
  useAssertUserState,
  useUserState,
} from "@increaser/ui/user/UserStateContext"
import { deleteSet } from "@increaser/entities-utils/set/deleteSet"
import { Interval } from "@lib/utils/interval/Interval"

export const useDeleteSetMutation = () => {
  const api = useApi()
  const { updateState } = useUserState()
  const { sets } = useAssertUserState()

  return useMutation({
    mutationFn: (value: Interval) => {
      updateState({ sets: deleteSet({ sets, value }) })

      return api.call("deleteSet", value)
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

On the server side, we'll use the same deleteSet function from the @increaser/entities-utils package to generate a new array of sets that excludes the specified session. For a deeper dive into building backends in a TypeScript monorepo, check out this article.

import { getUser, updateUser } from "@increaser/db/user"
import { assertUserId } from "../../auth/assertUserId"
import { ApiResolver } from "../../resolvers/ApiResolver"
import { deleteSet as remove } from "@increaser/entities-utils/set/deleteSet"

export const deleteSet: ApiResolver<"deleteSet"> = async ({
  input,
  context,
}) => {
  const userId = assertUserId(context)
  const { sets } = await getUser(userId, ["sets"])

  await updateUser(userId, {
    sets: remove({ sets, value: input }),
  })
}
Enter fullscreen mode Exit fullscreen mode

Since no two sessions can share identical start and end timestamps, we identify sessions by their intervals. To compare intervals, we'll use the areEqualIntervals function, which wraps around the haveEqualFields utility from RadzionKit. This utility checks whether two objects share the same values for specified fields.

import { Set } from "@increaser/entities/User"
import { Interval } from "@lib/utils/interval/Interval"
import { areEqualIntervals } from "@lib/utils/interval/areEqualIntervals"

type DeleteSetInput = {
  sets: Set[]
  value: Interval
}

export const deleteSet = ({ sets, value }: DeleteSetInput) =>
  sets.filter((set) => !areEqualIntervals(set, value))
Enter fullscreen mode Exit fullscreen mode

Adding and Updating Sessions with SubmitSetAction

The SubmitSetAction component follows a single validation rule: a session cannot overlap with another session. Depending on whether the index field is present, we call either the addSet or updateSet mutation. The respective hooks follow the same pattern as the deleteSet mutation we covered earlier.

import { Button } from "@lib/ui/buttons/Button"
import { useMemo } from "react"

import { useAddSetMutation } from "../../sets/hooks/useAddSetMutation"
import { analytics } from "../../analytics"
import { areIntersecting } from "@lib/utils/interval/areIntersecting"
import { useTrackTime } from "./state/TrackTimeContext"
import { useUpdateSetMutation } from "../../sets/hooks/useUpdateSetMutation"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"

export const SubmitSetAction = () => {
  const { sets, setState, currentSet: potentialCurrentSet } = useTrackTime()
  const currentSet = shouldBePresent(potentialCurrentSet)

  const isDisabled = useMemo(() => {
    const hasIntersection = sets.some((set, index) =>
      currentSet.index === index ? false : areIntersecting(set, currentSet)
    )
    if (hasIntersection) {
      return "This session intersects with another session"
    }

    return false
  }, [currentSet, sets])

  const { mutate: addSet } = useAddSetMutation()
  const { mutate: updateSet } = useUpdateSetMutation()

  const onSubmit = () => {
    if (currentSet.index === undefined) {
      addSet(currentSet)
      analytics.trackEvent("Add session")
    } else {
      updateSet({
        old: sets[currentSet.index],
        new: currentSet,
      })
      analytics.trackEvent("Update session")
    }

    setState((state) => ({
      ...state,
      currentSet: null,
    }))
  }

  return (
    <Button onClick={onSubmit} isDisabled={isDisabled}>
      Submit
    </Button>
  )
}
Enter fullscreen mode Exit fullscreen mode

When the user clicks the "Add Session" button, we update the currentSet field to a new object. The start time is set to the end of the dayInterval minus the default duration, and the end time is set to the end of the dayInterval. The projectId is initialized with the ID of the first active project. To convert minutes into milliseconds, we use the convertDuration utility from RadzionKit.

import { Button } from "@lib/ui/buttons/Button"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { defaultIntervalDuration } from "./config"
import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { useTrackTime } from "./state/TrackTimeContext"

export const AddSetPrompt = () => {
  const { activeProjects } = useProjects()
  const { setState, dayInterval } = useTrackTime()

  return (
    <Button
      onClick={() =>
        setState((state) => ({
          ...state,
          currentSet: {
            start:
              dayInterval.end -
              convertDuration(defaultIntervalDuration, "min", "ms"),
            end: dayInterval.end,
            projectId: activeProjects[0].id,
          },
        }))
      }
    >
      Add Session
    </Button>
  )
}
Enter fullscreen mode Exit fullscreen mode

Building the Timeline with TimeSpace

The core of our time-tracking feature is the timeline itself. The layout uses a relative wrapper that occupies all available space with flex: 1, and an absolutely positioned container with overflow-y: auto. This arrangement ensures that the feature fills the available space, while the content area remains scrollable.

import { panelDefaultPadding } from "@lib/ui/panel/Panel"
import styled from "styled-components"
import { TakeWholeSpaceAbsolutely } from "@lib/ui/css/takeWholeSpaceAbsolutely"
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { useTrackTime } from "./state/TrackTimeContext"
import { TimeSpace } from "@lib/ui/timeline/TimeSpace"
import { msToPx } from "./config"
import { Sessions } from "./Sessions"
import { ScrollIntoViewOnFirstAppearance } from "@lib/ui/base/ScrollIntoViewOnFirstAppearance"
import { SetEditor } from "./SetEditor"

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

const Container = styled(TakeWholeSpaceAbsolutely)`
  overflow-y: auto;
  padding: ${toSizeUnit(panelDefaultPadding)};
`

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

export const TrackTimeContent = () => {
  const { currentSet, dayInterval } = useTrackTime()

  return (
    <Wrapper>
      <Container>
        <TimeSpace
          msToPx={msToPx}
          startsAt={dayInterval.start}
          endsAt={dayInterval.end}
        >
          <Sessions />
          {currentSet && <SetEditor />}
          <ScrollIntoViewOnFirstAppearance<HTMLDivElement>
            render={(props) => <DefaultScrollPosition {...props} />}
          />
        </TimeSpace>
      </Container>
    </Wrapper>
  )
}
Enter fullscreen mode Exit fullscreen mode

The TimeSpace component draws lines with hourly time markers within the range specified by the startsAt and endsAt timestamps. Using the msToPx function, we determine how many pixels should represent one millisecond, ensuring accurate scaling.

import { MS_IN_HOUR } from "@lib/utils/time"

export const pxInHour = 100
export const pxInMs = pxInHour / MS_IN_HOUR
export const msToPx = (ms: number) => ms * pxInMs
export const pxToMs = (px: number) => px / pxInMs

export const defaultIntervalDuration = 30
Enter fullscreen mode Exit fullscreen mode

The TimeSpace component has a fixed height, which is calculated by passing the difference between the endsAt and startsAt timestamps through the msToPx function. The getHoursInRange utility generates an array of hourly timestamps within the specified range. We use the PositionAbsolutelyCenterHorizontally component from RadzionKit to vertically center the time labels and lines.

import styled from "styled-components"
import { getColor } from "../theme/getters"
import { formatTime } from "@lib/utils/time/formatTime"
import { getHoursInRange } from "@lib/utils/time/getHoursInRange"
import { Fragment } from "react"
import { PositionAbsolutelyCenterHorizontally } from "../layout/PositionAbsolutelyCenterHorizontally"
import { HStack, VStack } from "../layout/Stack"
import { ComponentWithChildrenProps } from "../props"
import { Text } from "../text"
import { toSizeUnit } from "../css/toSizeUnit"
import { verticalPadding } from "../css/verticalPadding"

interface TimeSpaceProps extends ComponentWithChildrenProps {
  startsAt: number
  endsAt: number
  msToPx: (ms: number) => number
}

const labelSize = 12

const Label = styled(Text)`
  font-size: ${toSizeUnit(labelSize)};
  line-height: 1;
  color: ${getColor("textSupporting")};
`

const Wrapper = styled.div`
  ${verticalPadding(labelSize / 2)};
  user-select: none;
`

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

const Transparent = styled.div`
  opacity: 0;
`
const Line = styled.div`
  background: ${getColor("mistExtra")};
  height: 1px;
  width: 100%;
`

export const TimeSpace = ({
  startsAt,
  endsAt,
  msToPx,
  children,
}: TimeSpaceProps) => {
  const height = msToPx(endsAt - startsAt)

  const marks = getHoursInRange(startsAt, endsAt)

  return (
    <Wrapper>
      <Container style={{ height }}>
        <HStack fullHeight gap={8}>
          <VStack style={{ position: "relative" }} fullHeight>
            {marks.map((mark, index) => {
              const top = msToPx(mark - startsAt)

              const label = <Label>{formatTime(mark)}</Label>

              return (
                <Fragment key={index}>
                  <Transparent>{label}</Transparent>
                  <PositionAbsolutelyCenterHorizontally top={top}>
                    {label}
                  </PositionAbsolutelyCenterHorizontally>
                </Fragment>
              )
            })}
          </VStack>
          <VStack fullWidth fullHeight style={{ position: "relative" }}>
            {marks.map((mark, index) => {
              const top = msToPx(mark - startsAt)

              return (
                <PositionAbsolutelyCenterHorizontally
                  key={index}
                  fullWidth
                  top={top}
                >
                  <Line />
                </PositionAbsolutelyCenterHorizontally>
              )
            })}
            {children}
          </VStack>
        </HStack>
      </Container>
    </Wrapper>
  )
}
Enter fullscreen mode Exit fullscreen mode

To initially scroll the content area to the bottom of the timeline (representing the current time when the current day is selected), we use the ScrollIntoViewOnFirstAppearance component from RadzionKit. This component ensures the provided element scrolls into view upon its initial appearance.

import React, { useRef, useEffect } from "react"

type ScrollIntoViewOnFirstAppearanceProps<T extends HTMLElement> = {
  render: (props: { ref: React.RefObject<T> }) => React.ReactNode
}

export const ScrollIntoViewOnFirstAppearance = <T extends HTMLElement>({
  render,
}: ScrollIntoViewOnFirstAppearanceProps<T>) => {
  const element = useRef<T>(null)
  const hasScrolled = useRef(false)

  useEffect(() => {
    if (element.current && !hasScrolled.current) {
      element.current.scrollIntoView({ behavior: "smooth", block: "start" })
      hasScrolled.current = true
    }
  }, [])

  return <>{render({ ref: element })}</>
}
Enter fullscreen mode Exit fullscreen mode

Managing Sessions in the Sessions Component

In the Sessions component, we iterate through each set for the current day, rendering a Session component for each. We position each session using top and height attributes, which are calculated from the session's start and end timestamps in conjunction with the msToPx function.

import { useTrackTime } from "./state/TrackTimeContext"
import { msToPx } from "./config"
import { Session } from "./Session"
import { getSetHash } from "../../sets/helpers/getSetHash"

export const Sessions = () => {
  const { dayInterval, sets } = useTrackTime()

  return (
    <>
      {sets.map((value, index) => (
        <Session
          key={getSetHash(value)}
          value={value}
          index={index}
          style={{
            top: msToPx(value.start - dayInterval.start),
            height: msToPx(value.end - value.start),
          }}
        />
      ))}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

To prevent overlapping with the session currently being edited and rendered by the SetEditor component, we check if the current session is being edited and avoid rendering it within the Session component. Sessions are only clickable when no session is being edited. When the user clicks on a session, the currentSet field is updated with that session and its index.

import { transition } from "@lib/ui/css/transition"
import { ComponentWithValueProps, UIComponentProps } from "@lib/ui/props"
import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { getProjectColor } from "@increaser/ui/projects/utils/getProjectColor"
import styled, { css, useTheme } from "styled-components"
import { Set } from "@increaser/entities/User"
import { HSLA } from "@lib/ui/colors/HSLA"
import { useTrackTime } from "./state/TrackTimeContext"
import { borderRadius } from "@lib/ui/css/borderRadius"
import { LinesFiller } from "@lib/ui/visual/LinesFiller"

const Container = styled.div<{ isInteractive: boolean; $color: HSLA }>`
  position: absolute;
  overflow: hidden;
  width: 100%;

  ${borderRadius.xs};
  ${transition};

  color: ${({ $color }) => $color.getVariant({ a: () => 0.4 }).toCssValue()};
  background: ${({ $color }) =>
    $color.getVariant({ a: () => 0.1 }).toCssValue()};

  border: 2px solid ${({ $color }) =>
      $color.getVariant({ a: () => 0.6 }).toCssValue()};

  ${({ isInteractive, $color }) =>
    isInteractive &&
    css`
      cursor: pointer;
      &:hover {
        border-color: ${$color.toCssValue()};
        color: ${$color.toCssValue()};
      }
    `}
`

type SessionProps = ComponentWithValueProps<Set> &
  UIComponentProps & {
    index: number
  }

export const Session = ({ value, index, ...rest }: SessionProps) => {
  const { setState, currentSet } = useTrackTime()
  const { projectsRecord } = useProjects()

  const theme = useTheme()

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

  if (currentSet?.index === index) {
    return null
  }

  return (
    <Container
      onClick={
        !currentSet
          ? () => {
              setState((state) => ({
                ...state,
                currentSet: {
                  ...value,
                  index,
                },
              }))
            }
          : undefined
      }
      isInteractive={!currentSet}
      $color={color}
      {...rest}
    >
      <LinesFiller density={0.28} rotation={45 * (index % 2 === 0 ? 1 : -1)} />
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

We determine the session's color using the getProjectColor utility, which returns a project's color in HSLA format. If you're curious about managing colors with HSLA in a React app, check out this article. By using the getVariant method on an HSLA instance, we can conveniently adjust the color's alpha channel.

import { TakeWholeSpaceAbsolutely } from "@lib/ui/css/takeWholeSpaceAbsolutely"
import { HStack } from "@lib/ui/layout/Stack"
import { range } from "@lib/utils/array/range"
import styled from "styled-components"
import { ElementSizeAware } from "../base/ElementSizeAware"
import { toSizeUnit } from "../css/toSizeUnit"
import { ElementSize } from "../hooks/useElementSize"
import { calculateRightAngleTriangleSide } from "@lib/utils/math/calculateRightAngleTriangleSide"
import { calculateHypotenuse } from "@lib/utils/math/calculateHypotenuse"
import { degreesToRadians } from "@lib/utils/degreesToRadians"

const Wrapper = styled(TakeWholeSpaceAbsolutely)`
  overflow: hidden;
`

const Container = styled(HStack)`
  height: 100%;
  justify-content: space-between;
  align-items: center;
`

type LinesFillerProps = {
  rotation?: number
  density?: number
  lineWidth?: number
}

export const LinesFiller = ({
  rotation = 45,
  lineWidth = 2,
  density = 0.32,
}: LinesFillerProps) => {
  return (
    <ElementSizeAware
      render={({ setElement, size }) => {
        const fill = ({ width, height }: ElementSize) => {
          const offset = calculateRightAngleTriangleSide({
            givenSideLength: height,
            angleInRadians: degreesToRadians(Math.abs(rotation)),
            knownSide: "adjacent",
          })
          const totalWidth = width + offset

          const count = Math.round((totalWidth / lineWidth) * density)

          const lineSize = calculateHypotenuse(offset, height)

          return (
            <Container
              style={{
                width: totalWidth,
                marginLeft: -(offset / 2),
              }}
            >
              {range(count).map((index) => (
                <div
                  style={{
                    height: lineSize,
                    transform: `rotate(${rotation}deg)`,
                    borderLeft: `${toSizeUnit(lineWidth)} solid`,
                  }}
                  key={index}
                />
              ))}
            </Container>
          )
        }
        return <Wrapper ref={setElement}>{size && fill(size)}</Wrapper>
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

For a better visual appeal, we fill the session with diagonal lines using the LinesFiller component from RadzionKit. To create the pattern, we use a component that occupies the entire space of its parent and measures its size. We then apply trigonometric calculations to determine the appropriate length of the lines and an offset for the container. This container is wider than the parent, ensuring it covers the whole area with diagonal lines. The lines are rotated using the transform property and positioned within a flexbox row container with evenly spaced lines. To allow the component's color to be customized, we use the border property so that it inherits the border color from the parent's color property, providing full flexibility in visual styling.

import { TakeWholeSpace } from "@lib/ui/css/takeWholeSpace"
import { IntervalEditorControl } from "@lib/ui/timeline/IntervalEditorControl"
import { useEffect, useMemo, useRef, useState } from "react"
import { useEvent } from "react-use"
import { useTrackTime } from "./state/TrackTimeContext"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { getIntervalDuration } from "@lib/utils/interval/getIntervalDuration"
import { msToPx, pxToMs } from "./config"
import { enforceRange } from "@lib/utils/enforceRange"
import { MoveIcon } from "@lib/ui/icons/MoveIcon"
import { PositionAbsolutelyCenterHorizontally } from "@lib/ui/layout/PositionAbsolutelyCenterHorizontally"
import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { InteractiveBoundaryArea } from "@lib/ui/timeline/InteractiveBoundaryArea"
import { FloatingIntervalDuration } from "@lib/ui/timeline/FloatingIntervalDuration"
import { InteractiveDragArea } from "@lib/ui/timeline/InteractiveDragArea"
import { CurrentIntervalRect } from "@lib/ui/timeline/CurrentIntervalRect"
import { IconWrapper } from "@lib/ui/icons/IconWrapper"

export const SetEditor = () => {
  const { projectsRecord } = useProjects()
  const { currentSet, dayInterval, setState } = useTrackTime()
  const value = shouldBePresent(currentSet)
  const [activeControl, setActiveControl] =
    useState<IntervalEditorControl | null>(null)

  useEvent("pointerup", () => setActiveControl(null))
  useEvent("pointercancel", () => setActiveControl(null))

  const containerElement = useRef<HTMLDivElement | null>(null)
  const intervalElement = useRef<HTMLDivElement | null>(null)
  useEffect(() => {
    intervalElement.current?.scrollIntoView({
      block: "nearest",
      inline: "start",
    })
  }, [value])

  useEvent("pointermove", ({ clientY }) => {
    if (!activeControl) return

    const containerRect = containerElement?.current?.getBoundingClientRect()
    if (!containerRect) return

    const timestamp = dayInterval.start + pxToMs(clientY - containerRect.top)

    const getNewInterval = () => {
      if (activeControl === "position") {
        const halfDuration = valueDuration / 2
        const oldCenter = value.start + halfDuration

        const newCenter = enforceRange(
          timestamp,
          dayInterval.start + halfDuration,
          dayInterval.end - halfDuration
        )

        const offset = newCenter - oldCenter

        return {
          start: value.start + offset,
          end: value.end + offset,
        }
      } else {
        return {
          start:
            activeControl === "start"
              ? enforceRange(timestamp, dayInterval.start, value.end)
              : value.start,
          end:
            activeControl === "end"
              ? enforceRange(timestamp, value.start, dayInterval.end)
              : value.end,
        }
      }
    }

    const interval = getNewInterval()

    setState((state) => ({
      ...state,
      currentSet: {
        ...shouldBePresent(state.currentSet),
        ...interval,
      },
    }))
  })

  const cursor = useMemo(() => {
    if (!activeControl) return undefined

    if (activeControl === "position") return "grabbing"

    return "row-resize"
  }, [activeControl])

  const valueDuration = getIntervalDuration(value)
  const intervalStartInPx = msToPx(value.start - dayInterval.start)
  const intervalEndInPx = msToPx(value.end - dayInterval.start)
  const intervalDurationInPx = msToPx(valueDuration)

  return (
    <TakeWholeSpace style={{ cursor }} ref={containerElement}>
      <CurrentIntervalRect
        $color={projectsRecord[value.projectId].hslaColor}
        ref={intervalElement}
        style={{
          top: intervalStartInPx,
          height: intervalDurationInPx,
        }}
      >
        <IconWrapper style={{ opacity: activeControl ? 0 : 1 }}>
          <MoveIcon />
        </IconWrapper>
      </CurrentIntervalRect>

      <FloatingIntervalDuration
        style={{
          top: intervalEndInPx + 2,
        }}
        value={value}
      />

      {!activeControl && (
        <>
          <InteractiveDragArea
            style={{
              top: intervalStartInPx,
              height: intervalDurationInPx,
            }}
            onPointerDown={() => setActiveControl("position")}
          />

          <PositionAbsolutelyCenterHorizontally
            fullWidth
            top={intervalStartInPx}
          >
            <InteractiveBoundaryArea
              onPointerDown={() => setActiveControl("start")}
            />
          </PositionAbsolutelyCenterHorizontally>

          <PositionAbsolutelyCenterHorizontally fullWidth top={intervalEndInPx}>
            <InteractiveBoundaryArea
              onPointerDown={() => setActiveControl("end")}
            />
          </PositionAbsolutelyCenterHorizontally>
        </>
      )}
    </TakeWholeSpace>
  )
}
Enter fullscreen mode Exit fullscreen mode

Editing Sessions with the SetEditor Component

To facilitate session editing, we have the SetEditor component. When the user interacts with a session, the activeControl state is set to one of three values: position, start, or end. For each of these states, we render interactive areas that primarily serve to change the cursor appearance to reflect their purpose. These interactive areas capture the pointerdown event and adjust the activeControl state appropriately. They don't have any specific visual design beyond signaling the active control to the user through the cursor change.

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

The PositionAbsolutelyCenterHorizontally component simplifies the placement of an absolutely positioned element by its horizontal center. It wraps the content in nested styled components to ensure accurate alignment: a Wrapper that is absolutely positioned and extends to the left edge as a base positioning layer, a Container that aligns its content with a flex layout for vertical centering, and an absolute positioning Content div that centers the content horizontally. By specifying the top property to align it vertically and optionally setting fullWidth to stretch it across the available width, this component handles horizontal centering efficiently, providing a clear and reusable way to position elements absolutely.

To accurately calculate the new position and size of the session, we maintain a reference to the container element. This allows us to extract its bounding box and compute coordinates relative to it. We also keep a reference to the edited session, ensuring it remains visible by listening for changes and calling the scrollIntoView method. This approach keeps the edited session centered in the viewport.

export const enforceRange = (value: number, min: number, max: number) =>
  Math.max(min, Math.min(max, value))
Enter fullscreen mode Exit fullscreen mode

On the pointerup and pointercancel events, we reset the activeControl state to null, signifying that the user is no longer interacting with the session. During the pointermove event, we verify if the user is actively engaged with the session. If so, we calculate the new timestamp based on the pointer's position, adjusting the session's start and end timestamps accordingly. The enforceRange utility ensures the session interval remains within the day's bounds.

import styled from "styled-components"
import { VStack } from "../layout/Stack"
import { getColor } from "../theme/getters"
import { ComponentWithValueProps, UIComponentProps } from "../props"
import { HStackSeparatedBy, dotSeparator } from "../layout/StackSeparatedBy"
import { Text } from "../text"
import { formatTime } from "@lib/utils/time/formatTime"
import { Interval } from "@lib/utils/interval/Interval"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { getIntervalDuration } from "@lib/utils/interval/getIntervalDuration"

export type FloatingIntervalDurationProps = UIComponentProps &
  ComponentWithValueProps<Interval>

const Container = styled(VStack)`
  position: absolute;
  width: 100%;
  align-items: center;
  font-size: 14px;
  font-weight: 500;
  color: ${getColor("contrast")};
`

export const FloatingIntervalDuration = ({
  value,
  ...rest
}: FloatingIntervalDurationProps) => (
  <Container as="div" {...rest}>
    <HStackSeparatedBy separator={dotSeparator}>
      <Text>
        {formatTime(value.start)} - {formatTime(value.end)}
      </Text>
      <Text>
        {formatDuration(getIntervalDuration(value), "ms", { kind: "long" })}
      </Text>
    </HStackSeparatedBy>
  </Container>
)
Enter fullscreen mode Exit fullscreen mode

To assist the user in setting a precise interval, we display the FloatingIntervalDuration component beneath the session. This component shows the session's formatted start and end times, along with its duration, all separated by a dot using the HStackSeparatedBy component from RadzionKit.

Top comments (0)