DEV Community

Cover image for Building a Dynamic Work Budget Feature with React and NodeJS
Radzion Chachura
Radzion Chachura

Posted on • Originally published at radzion.com

Building a Dynamic Work Budget Feature with React and NodeJS

๐Ÿ™ GitHub | ๐ŸŽฎ Demo

Introducing the Work Budget Feature in Increaser

In today's article, we're going to build an exciting new feature for the productivity app, Increaser. We're introducing a "Work Budget" feature that allows you to set weekly work targets, review previous weeks' work hours, and track average work durations for weekdays and weekends. This tool provides real-time updates on your current week's progress, making it an invaluable resource for monitoring your work habits and discovering strategies to boost your productivity by working smarter, not harder.

The front end of this feature is developed using React, while the back end is powered by NodeJS and DynamoDB. Although the Increaser source code is private, all reusable components and utilities are available through the RadzionKit repository.

Increaser Work Budget

Our feature is thoughtfully divided into two main parts. On the left side, you can adjust your work budget using sliders, which provide an immediate preview on a bar chart to visualize what your week might look like. On the right side, a detailed report is segmented into three sections. The first section compares your total hours worked this week to your preset budget. The second section displays the average workday and weekend hours over the last 30 days, using colored days for visual representation. The final section shows a bar chart of your work hours from the past four weeks.

import { FixedWidthContent } from "@increaser/app/components/reusable/fixed-width-content"

import { PageTitle } from "@increaser/app/ui/PageTitle"
import { Page } from "@lib/next-ui/Page"
import { UserStateOnly } from "../user/state/UserStateOnly"
import { ManageWorkBudget } from "./ManageWorkBudget"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { WorkBudgetReport } from "./WorkBudgetReport"

const title = "Work Budget"

export const WorkBudgetPage: Page = () => {
  return (
    <FixedWidthContent>
      <PageTitle documentTitle={`๐Ÿ‘ ${title}`} title={title} />
      <UserStateOnly>
        <UniformColumnGrid
          style={{ alignItems: "start" }}
          fullWidth
          minChildrenWidth={320}
          gap={40}
        >
          <ManageWorkBudget />
          <WorkBudgetReport />
        </UniformColumnGrid>
      </UserStateOnly>
    </FixedWidthContent>
  )
}
Enter fullscreen mode Exit fullscreen mode

Design and Structure of the Work Budget Interface

To ensure these components are evenly distributed across the interface, we are utilizing the UniformColumnGrid component from RadzionKit. By setting the minWidth attribute, we ensure that the layout remains aesthetically pleasing and functional on mobile screens, adapting to a single column layout as necessary.

import { VStack } from "@lib/ui/layout/Stack"
import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import { useTheme } from "styled-components"
import { WorkBudgetInput } from "@increaser/ui/workBudget/WorkBudgetInput"
import { getWorkdayColor } from "@increaser/ui/workBudget/getWorkdayColor"
import { getWeekendColor } from "@increaser/ui/workBudget/getWeekendColor"
import { useUpdateUserMutation } from "../user/mutations/useUpdateUserMutation"
import { BarChart } from "@lib/ui/charts/BarChart"
import { Text } from "@lib/ui/text"
import { getShortWeekday } from "@lib/utils/time"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { SectionTitle } from "@lib/ui/text/SectionTitle"
import { Panel } from "@lib/ui/panel/Panel"
import { InputDebounce } from "@lib/ui/inputs/InputDebounce"
import { getWorkBudgetTotal } from "@increaser/entities-utils/workBudget/getWorkBudgetTotal"
import { workdaysNumber } from "@lib/utils/time/workweek"
import { useDaysBudget } from "@increaser/ui/workBudget/hooks/useDaysBudget"

export const ManageWorkBudget = () => {
  const { workdayHours, weekendHours } = useAssertUserState()

  const { mutate: updateUser } = useUpdateUserMutation()

  const theme = useTheme()

  const workBudgetTotal = getWorkBudgetTotal({
    workdayHours,
    weekendHours,
  })

  const formattedWorkdBudgetTotal = formatDuration(workBudgetTotal, "h", {
    maxUnit: "h",
    kind: "long",
  })

  const daysBudget = useDaysBudget()

  return (
    <Panel>
      <VStack gap={20}>
        <SectionTitle>
          My preference ~ {formattedWorkdBudgetTotal} / week
        </SectionTitle>
        <VStack gap={40}>
          <VStack gap={28}>
            <InputDebounce
              value={workdayHours}
              onChange={(workdayHours) => updateUser({ workdayHours })}
              render={({ value, onChange }) => (
                <WorkBudgetInput
                  value={value}
                  onChange={onChange}
                  color={getWorkdayColor(theme)}
                  name="Workday"
                />
              )}
            />
            <InputDebounce
              value={weekendHours}
              onChange={(weekendHours) => updateUser({ weekendHours })}
              render={({ value, onChange }) => (
                <WorkBudgetInput
                  value={value}
                  onChange={onChange}
                  color={getWeekendColor(theme)}
                  name="Weekend"
                />
              )}
            />
          </VStack>
          <BarChart
            height={160}
            items={daysBudget.map((value, index) => {
              const color =
                index < workdaysNumber
                  ? getWorkdayColor(theme)
                  : getWeekendColor(theme)
              return {
                value,
                label: <Text>{getShortWeekday(index)}</Text>,
                color,

                renderValue:
                  value > 0
                    ? () => (
                        <Text>
                          {formatDuration(value, "min", { maxUnit: "h" })}
                        </Text>
                      )
                    : undefined,
              }
            })}
          />
        </VStack>
      </VStack>
    </Panel>
  )
}
Enter fullscreen mode Exit fullscreen mode

We encapsulate both sections in a Panel component from RadzionKit for structured layout management. To visually separate them, we assign different 'kind' properties to each. The report section is set with a transparent background, effectively creating a subtle contrast that enhances the overall clarity of the interface.

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

import { toSizeUnit } from "../css/toSizeUnit"
import { getColor } from "../theme/getters"
import { match } from "@lib/utils/match"
import { borderRadius } from "../css/borderRadius"

type PanelKind = "regular" | "secondary"

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

  kind?: PanelKind

  withSections?: boolean
}

export const panelDefaultPadding = 20

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

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

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

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

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

          ${kind === "secondary"
            ? css`
                background: ${getColor("mist")};
                gap: 2px;
              `
            : css`
                gap: 1px;
              `}

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

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

At the top of the ManageWorkBudget component, we display a title that includes the work budget selected by the user. To convert minutes into a readable time format, we utilize the formatDuration utility from RadzionKit.

import { convertDuration } from "./convertDuration"
import { pluralize } from "../pluralize"
import { durationUnitName, DurationUnit, durationUnits } from "./DurationUnit"
import { match } from "../match"
import { padWithZero } from "../padWithZero"
import { isEmpty } from "../array/isEmpty"

type FormatDurationKind = "short" | "long" | "digitalClock"

interface FormatDurationOptions {
  maxUnit?: DurationUnit
  minUnit?: DurationUnit
  kind?: FormatDurationKind
}

export const formatDuration = (
  duration: number,
  durationUnit: DurationUnit,
  options: FormatDurationOptions = {}
) => {
  if (duration < 0) {
    formatDuration(Math.abs(duration), durationUnit, options)
  }

  const kind = options.kind ?? "short"
  const maxUnit = options.maxUnit || "d"
  const minUnit = options.minUnit || "min"

  const maxUnitIndex = durationUnits.indexOf(maxUnit)
  const minUnitIndex = durationUnits.indexOf(minUnit)
  if (maxUnitIndex < minUnitIndex) {
    throw new Error("maxUnit must be greater than minUnit")
  }

  const units = durationUnits.slice(minUnitIndex, maxUnitIndex + 1).reverse()
  const result: string[] = []
  units.forEach((unit, index) => {
    const convertedValue = convertDuration(duration, durationUnit, unit)
    const isLastUnit = index === units.length - 1

    const wholeValue = isLastUnit
      ? Math.round(convertedValue)
      : Math.floor(convertedValue)
    duration -= convertDuration(wholeValue, unit, durationUnit)

    if (wholeValue === 0) {
      if (kind === "digitalClock") {
        if (index < units.length - 2 && isEmpty(result)) {
          return
        }
      } else if (!isLastUnit || !isEmpty(result)) {
        return
      }
    }

    const value = match(kind, {
      short: () => `${wholeValue}${unit.slice(0, 1)}`,
      long: () => pluralize(wholeValue, durationUnitName[unit]),
      digitalClock: () => padWithZero(wholeValue),
    })

    result.push(value)
  })

  return result.join(kind === "digitalClock" ? ":" : " ")
}
Enter fullscreen mode Exit fullscreen mode

Customizing the WorkBudgetInput Slider for Enhanced Usability

Most users are unlikely to track more than 10 hours per day, aligning with Increaser's philosophy of not encouraging excessive work hours. To accommodate this, we use a slider component named WorkBudgetInput. This component accepts a value in hours, an onChange callback for real-time updates, a name attribute for labeling, and a color in HSLA format. For more details on HSLA color formatting, you can refer to this article.

import { HSLA } from "@lib/ui/colors/HSLA"
import { InputContainer } from "@lib/ui/inputs/InputContainer"
import { LabelText } from "@lib/ui/inputs/LabelText"
import { InputProps } from "@lib/ui/props"
import { SegmentedSlider } from "@lib/ui/inputs/Slider/SegmentedSlider"

type WorkBudgetInputProps = InputProps<number> & {
  color: HSLA
  name: string
}

export const WorkBudgetInput = ({
  value,
  onChange,
  color,
  name,
}: WorkBudgetInputProps) => {
  return (
    <InputContainer as="div">
      <LabelText>{name}</LabelText>
      <SegmentedSlider
        max={10}
        value={value}
        onChange={onChange}
        color={color}
      />
    </InputContainer>
  )
}
Enter fullscreen mode Exit fullscreen mode

The WorkBudgetInput component acts primarily as a wrapper around the SegmentedSlider from RadzionKit. This variant of a slider is designed with clear segments, making it particularly suitable for scenarios with a relatively small range of values. Users can easily count the segments to gauge the value quickly. The WorkBudgetInput enhances this setup by adding a label to the slider, making it more user-friendly and informative.

import styled, { useTheme } from "styled-components"
import { PressTracker } from "../../base/PressTracker"
import { InputProps } from "../../props"
import { interactive } from "../../css/interactive"
import { centerContent } from "../../css/centerContent"
import { toSizeUnit } from "../../css/toSizeUnit"
import { defaultTransition } from "../../css/transition"
import { getColor } from "../../theme/getters"
import { InvisibleHTMLSlider } from "./InvisibleHtmlSlider"
import { PositionAbsolutelyCenterVertically } from "../../layout/PositionAbsolutelyCenterVertically"
import { toPercents } from "@lib/utils/toPercents"
import { Center } from "../../layout/Center"
import { range } from "@lib/utils/array/range"
import { HSLA } from "../../colors/HSLA"
import { UniformColumnGrid } from "../../layout/UniformColumnGrid"

type SegmentedSliderProps = InputProps<number> & {
  max: number
  color: HSLA
}

const sliderConfig = {
  railHeight: 20,
  controlSize: 24,
}

const Control = styled.div`
  transition: outline ${defaultTransition};
  outline: 4px solid transparent;
  width: 8px;
  height: ${toSizeUnit(sliderConfig.controlSize)};
  background: ${getColor("contrast")};
  border-radius: 2px;
`

const Container = styled.label`
  width: 100%;
  height: ${toSizeUnit(sliderConfig.controlSize + 4)};
  ${interactive};
  ${centerContent};
  position: relative;

  &:focus-within ${Control} {
    outline: 8px solid ${getColor("mistExtra")};
  }

  &:hover ${Control} {
    outline-color: ${getColor("mist")};
  }
`

const Line = styled(UniformColumnGrid)`
  width: 100%;
  height: ${toSizeUnit(sliderConfig.railHeight)};

  border-radius: 4px;
  position: relative;
  overflow: hidden;
`

const Section = styled.div``

export const SegmentedSlider = ({
  value,
  onChange,
  max,
  color,
}: SegmentedSliderProps) => {
  const { colors } = useTheme()

  const xPosition = toPercents(value / max)

  return (
    <PressTracker
      onChange={({ position }) => {
        if (position) {
          const newValue = Math.round(position.x * max)
          onChange(newValue)
        }
      }}
      render={({ props }) => (
        <Container {...props}>
          <InvisibleHTMLSlider
            step={1}
            value={value}
            onChange={onChange}
            min={0}
            max={max}
          />
          <Line gap={1}>
            {range(max).map((index) => (
              <Section
                style={{
                  background: (index < value
                    ? color
                    : colors.mist
                  ).toCssValue(),
                }}
                key={index}
              />
            ))}
          </Line>
          <PositionAbsolutelyCenterVertically left={xPosition} fullHeight>
            <Center>
              <Control />
            </Center>
          </PositionAbsolutelyCenterVertically>
        </Container>
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

To construct this custom segmented slider, we utilize several components from RadzionKit. At the core is the PressTracker component, which accurately tracks the user's press position on the slider. For more in-depth information on PressTracker, you can refer to this article. Additionally, the InvisibleHTMLSlider component is employed to enable native keyboard interactions while remaining visually concealed.

The visual segmentation of the slider is achieved using the UniformColumnGrid component. This component forms a CSS grid with a 1px gap between each section. Sections are dynamically colored based on the current value of the slider, creating a clear and intuitive visual representation of selected values.

Optimizing Interaction and Data Visualization in the Work Budget Feature

To efficiently manage slider interactions without overloading the server, we use the InputDebounce component from RadzionKit. This component delays the onChange callback until the user stops interacting with the slider for a specified interval, typically 300 milliseconds. This approach ensures server updates are only made after the user has finished adjusting, reducing unnecessary network traffic and enhancing responsiveness.

import { ReactNode, useEffect, useState } from "react"
import { InputProps } from "../props"

type InputDebounceProps<T> = InputProps<T> & {
  render: (props: InputProps<T>) => ReactNode
  interval?: number
}

export function InputDebounce<T>({
  value,
  onChange,
  interval = 300,
  render,
}: InputDebounceProps<T>) {
  const [currentValue, setCurrentValue] = useState<T>(value)

  useEffect(() => {
    if (currentValue === value) return

    const timeout = setTimeout(() => {
      onChange(currentValue)
    }, interval)

    return () => clearTimeout(timeout)
  }, [currentValue, interval, onChange, value])

  return (
    <>
      {render({
        value: currentValue,
        onChange: setCurrentValue,
      })}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Below the sliders we want to display a bar chart with seven days of the week starting from Monday, we fill workdays bars with the same color as the workday slider and weekend bars with the same color as the weekend slider. So it becomes clear to the user how changes in the sliders affect the overall work budget. To learn more about BarChart implementation you can refer to this article.

import { ReactNode } from "react"
import styled from "styled-components"
import { Spacer } from "../../layout/Spacer"
import { HStack, VStack } from "../../layout/Stack"
import { HSLA } from "../../colors/HSLA"
import { toSizeUnit } from "../../css/toSizeUnit"
import { Text } from "../../text"
import { getColor } from "../../theme/getters"
import { toPercents } from "@lib/utils/toPercents"
import { centerContent } from "../../css/centerContent"
import { transition } from "../../css/transition"

export interface BarChartItem {
  label?: ReactNode
  value: number
  color: HSLA
  renderValue?: (value: number) => ReactNode
}

interface BarChartProps {
  items: BarChartItem[]
  height: React.CSSProperties["height"]
  expectedValueHeight?: React.CSSProperties["height"]
  expectedLabelHeight?: React.CSSProperties["height"]
  minBarWidth?: number
}

const barValueGap = "4px"
const barLabelGap = "4px"
const defaultLabelSize = 12

const Bar = styled.div`
  border-radius: 4px;
  width: 100%;
  ${transition};
`

const RelativeWrapper = styled.div`
  position: relative;
  ${centerContent};
`

export const BarPlaceholder = styled(Bar)`
  height: 2px;
  background: ${getColor("mist")};
`

const Value = styled(Text)`
  position: absolute;
  white-space: nowrap;
  line-height: 1;
  bottom: ${barValueGap};
  color: ${getColor("textSupporting")};
`

const Label = styled(Value)`
  top: ${barLabelGap};
`

const Content = styled(HStack)`
  flex: 1;
`

const Column = styled(VStack)`
  height: 100%;
  justify-content: end;
  flex: 1;
`

export const BarChart = ({
  items,
  height,
  expectedValueHeight = defaultLabelSize,
  expectedLabelHeight = defaultLabelSize,
  minBarWidth,
}: BarChartProps) => {
  const maxValue = Math.max(...items.map((item) => item.value))

  const hasLabels = items.some((item) => item.label)

  return (
    <VStack style={{ height }}>
      <Spacer
        height={`calc(${toSizeUnit(expectedValueHeight)} + ${barValueGap})`}
      />
      <Content gap={4}>
        {items.map(({ value, color, renderValue, label }, index) => {
          return (
            <Column
              style={minBarWidth ? { minWidth: minBarWidth } : undefined}
              key={index}
            >
              {renderValue && (
                <RelativeWrapper>
                  <Value style={{ fontSize: defaultLabelSize }} as="div">
                    {renderValue(value)}
                  </Value>
                </RelativeWrapper>
              )}
              <Bar
                style={{
                  background: color.toCssValue(),
                  height: value ? toPercents(value / maxValue) : "2px",
                }}
              />
              {label && (
                <RelativeWrapper>
                  <Label style={{ fontSize: defaultLabelSize }} as="div">
                    {label}
                  </Label>
                </RelativeWrapper>
              )}
            </Column>
          )
        })}
      </Content>
      {hasLabels && (
        <Spacer
          height={`calc(${toSizeUnit(expectedLabelHeight)} + ${barLabelGap})`}
        />
      )}
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

Streamlining Data Updates and Optimistic UI Responses

The work budget in our system comprises two fields: workdayHours and weekendHours. These are stored within the User entity in DynamoDB in a flat structure. This design choice simplifies the process of updating individual fields, allowing for more efficient and straightforward database operations.

export type WorkBudget = {
  workdayHours: number
  weekendHours: number
}

export type User = DayMoments &
  WorkBudget & {
    id: string
    email: string
    country?: CountryCode
    name?: string
    sets: Set[]
    registrationDate: number
    projects: Project[]
    habits: Record<string, Habit>
    tasks: Record<string, Task>
    freeTrialEnd: number

    isAnonymous: boolean

    appSumo?: AppSumo

    ignoreEmails?: boolean
    timeZone: number

    lastSyncedMonthEndedAt?: number
    lastSyncedWeekEndedAt?: number

    focusSounds: FocusSound[]

    updatedAt: number

    sumbittedHabitsAt?: number

    finishedOnboardingAt?: number

    subscription?: Subscription
    lifeTimeDeal?: LifeTimeDeal
  }
Enter fullscreen mode Exit fullscreen mode

The front-end updates the workdayHours and weekendHours fields, along with other User fields, using the useUpdateUserMutation hook. This hook performs an optimistic update to the React state before sending the request to the server through the updateUser operation on the API. This method ensures a smooth and responsive user experience by reflecting changes immediately in the UI. For a deeper understanding of how to efficiently build backends within a monorepo, you can refer to this article.

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

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

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

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

Detailed Visualization and Layout Management in Work Budget Reporting

With the work budget management configured, we can now turn our attention to the detailed three-section report, visually delineated using the SeparatedByLine component from RadzionKit for clear separation. The first section, encapsulated within the CurrentWeekVsBudget component, displays two cumulative lines on a chart: a half-transparent line represents the expected work hours based on the budget from Monday to Sunday, and a solid line shows the actual work hours, corresponding to the current day of the week. Users can hover over the chart to view detailed stats for a specific day, with default stats presented for the current day, ensuring a cohesive and intuitive user experience.

import { Panel } from "@lib/ui/panel/Panel"
import { WorkBudgetDaysReport } from "./WorkBudgetDaysReport"
import { WorkBudgetWeeksReport } from "./WorkBudgetWeeksReport"
import { CurrentWeekVsBudget } from "./CurrentWeekVsBudget"
import { SeparatedByLine } from "@lib/ui/layout/SeparatedByLine"

export const WorkBudgetReport = () => {
  return (
    <Panel kind="secondary">
      <SeparatedByLine gap={40}>
        <CurrentWeekVsBudget />
        <WorkBudgetDaysReport />
        <WorkBudgetWeeksReport />
      </SeparatedByLine>
    </Panel>
  )
}
Enter fullscreen mode Exit fullscreen mode

To maintain consistency in titles across the page, we use the SectionTitle component in the CurrentWeekVsBudget component. The chart requires a fixed width, so we measure the width of the parent element using the ElementSizeAware component, which ensures the chart fits perfectly within its allocated space. You can learn more about how this component works in this article. To improve the alignment further, we add a small spacer to the right of the chart, providing a balanced visual layout.

import { HStack, VStack } from "@lib/ui/layout/Stack"
import { SectionTitle } from "@lib/ui/text/SectionTitle"

import { ElementSizeAware } from "@lib/ui/base/ElementSizeAware"
import { Spacer } from "@lib/ui/layout/Spacer"

import { chartConfig } from "./config"
import { ComparisonChart } from "./ComparisonChart"

export const CurrentWeekVsBudget = () => {
  return (
    <VStack gap={20}>
      <SectionTitle>Current week vs budget</SectionTitle>
      <HStack>
        <ElementSizeAware
          render={({ setElement, size }) => (
            <VStack fullWidth gap={8} ref={setElement}>
              {size && <ComparisonChart width={size.width} />}
            </VStack>
          )}
        />
        <Spacer width={chartConfig.expectedXLabelWidth / 2} />
      </HStack>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

Data Handling and Visualization Techniques in Work Budget Reporting

Our ComparisonChart component leverages a reusable component designed for creating line charts. While we wonโ€™t delve into each component's specifics here, you can find a comprehensive guide on how to construct line charts without relying on external charting libraries in this article.

import { HStack, VStack } from "@lib/ui/layout/Stack"
import { useState } from "react"
import { useWeekday } from "@lib/ui/hooks/useWeekday"
import { getLastItem } from "@lib/utils/array/getLastItem"
import { ChartYAxis } from "@lib/ui/charts/ChartYAxis"
import { Text } from "@lib/ui/text"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { ChartHorizontalGridLines } from "@lib/ui/charts/ChartHorizontalGridLines"
import { D_IN_WEEK } from "@lib/utils/time"
import { Spacer } from "@lib/ui/layout/Spacer"
import { HoverTracker } from "@lib/ui/base/HoverTracker"
import { getClosestItemIndex } from "@lib/utils/math/getClosestItemIndex"
import { useCurrentWeekVsBudgetColors } from "./useCurrentWeekVsBudgetColors"
import { chartConfig } from "./config"
import { useWorkBudgetData } from "./useWorkBudgetData"
import { useWorkDoneData } from "./useWorkDoneData"
import { normalizeDataArrays } from "@lib/utils/math/normalizeDataArrays"
import { SelectedDayInfo } from "./SelectedDayInfo"
import { WeekChartXAxis } from "./WeekChartXAxis"
import { TakeWholeSpaceAbsolutely } from "@lib/ui/css/takeWholeSpaceAbsolutely"
import { CurrentDayLine } from "./CurrentDayLine"
import { ComparisonChartLines } from "./ComparisonChartLines"
import { ComponentWithWidthProps } from "@lib/ui/props"

export const ComparisonChart = ({ width }: ComponentWithWidthProps) => {
  const weekday = useWeekday()

  const colors = useCurrentWeekVsBudgetColors()

  const workBudgetData = useWorkBudgetData()

  const workDoneData = useWorkDoneData()

  const [selectedDataPoint, setSelectedDataPoint] = useState<number>(weekday)

  const yData = [workBudgetData[0], getLastItem(workBudgetData)]
  const normalized = normalizeDataArrays({
    y: yData,
    workBudget: workBudgetData,
    workDone: workDoneData,
  })

  const contentWidth = width - chartConfig.expectedYAxisLabelWidth

  return (
    <>
      <HStack>
        <Spacer width={chartConfig.expectedYAxisLabelWidth} />
        <SelectedDayInfo
          expectedValue={workBudgetData[selectedDataPoint]}
          doneValue={workDoneData[selectedDataPoint]}
          width={contentWidth}
          index={selectedDataPoint}
        />
      </HStack>

      <HStack>
        <ChartYAxis
          expectedLabelWidth={chartConfig.expectedYAxisLabelWidth}
          renderLabel={(index) => (
            <Text key={index} size={12} color="supporting">
              {formatDuration(yData[index], "min", {
                maxUnit: "h",
                minUnit: "h",
              })}
            </Text>
          )}
          data={normalized.y}
        />
        <VStack
          style={{
            position: "relative",
            minHeight: chartConfig.chartHeight,
          }}
          fullWidth
        >
          <ChartHorizontalGridLines data={yData} />
          <ComparisonChartLines
            value={[
              { data: normalized.workBudget, color: colors.budget },
              { data: normalized.workDone, color: colors.done },
            ]}
            width={contentWidth}
          />
          <HoverTracker
            onChange={({ position }) => {
              setSelectedDataPoint(
                position ? getClosestItemIndex(D_IN_WEEK, position.x) : weekday
              )
            }}
            render={({ props }) => <TakeWholeSpaceAbsolutely {...props} />}
          />
          <CurrentDayLine value={selectedDataPoint} />
        </VStack>
      </HStack>

      <HStack>
        <Spacer width={chartConfig.expectedYAxisLabelWidth} />
        <WeekChartXAxis value={selectedDataPoint} />
      </HStack>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Before displaying the report, we need to fetch data for both lines. The useWorkBudgetData hook retrieves a cumulative array of expected or budgeted work hours for each day of the week. To ensure consistency in time format across both lines, we convert the data to minutes using the convertDuration utility from RadzionKit.

import { useDaysBudget } from "@increaser/ui/workBudget/hooks/useDaysBudget"
import { cumulativeSum } from "@lib/utils/math/cumulativeSum"
import { convertDuration } from "@lib/utils/time/convertDuration"

export const useWorkBudgetData = () => {
  const daysBudget = useDaysBudget()

  return cumulativeSum(daysBudget).map((value) =>
    convertDuration(value, "h", "min")
  )
}
Enter fullscreen mode Exit fullscreen mode

User's tracked data is structured as an array of sets, each containing a project ID and start and end timestamps. To calculate the total work hours for each day, we employ the useCurrentWeekMinutesWorkedByDay hook, which iterates over these sets and tallies the total work hours for each day of the week. For those interested in a deeper dive into the time-tracking implementation at Increaser, you can explore this article.

import { useCurrentWeekMinutesWorkedByDay } from "@increaser/ui/sets/hooks/useCurrentWeekMinutesWorkedByDay"
import { cumulativeSum } from "@lib/utils/math/cumulativeSum"

export const useWorkDoneData = () => {
  const days = useCurrentWeekMinutesWorkedByDay()

  return cumulativeSum(days)
}
Enter fullscreen mode Exit fullscreen mode

The selectedDataPoint represents the currently highlighted weekday, which defaults to the current weekday. We use the HoverTracker component to monitor the user's mouse position and update the selectedDataPoint state accordingly. To clearly indicate which day is selected, a vertical line is displayed on the chart using the CurrentDayLine component, and the corresponding weekday label on the X-axis is highlighted.

import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { PositionAbsolutelyCenterVertically } from "@lib/ui/layout/PositionAbsolutelyCenterVertically"
import { ComponentWithValueProps } from "@lib/ui/props"
import { getColor } from "@lib/ui/theme/getters"
import { D_IN_WEEK } from "@lib/utils/time"
import { toPercents } from "@lib/utils/toPercents"
import styled from "styled-components"

const Line = styled.div`
  height: 100%;
  border-left: ${toSizeUnit(2)} dashed;
  color: ${getColor("mistExtra")};
`

export const CurrentDayLine = ({ value }: ComponentWithValueProps<number>) => (
  <PositionAbsolutelyCenterVertically
    fullHeight
    style={{
      pointerEvents: "none",
    }}
    left={toPercents(value / (D_IN_WEEK - 1))}
  >
    <Line />
  </PositionAbsolutelyCenterVertically>
)
Enter fullscreen mode Exit fullscreen mode

To accurately position Y-axis labels and align two line charts, we must normalize the data using the normalizeDataArrays utility from RadzionKit. This utility takes an object containing arrays of numbers and outputs the same object with normalized arrays. The normalization process entails finding the maximum and minimum values across the arrays, calculating the range, and then scaling each value to fit within a normalized range between 0 and 1. This ensures that all elements are properly aligned and displayed correctly on the chart.

export const normalizeDataArrays = <T extends Record<string, number[]>>(
  input: T
): T => {
  const values = Object.values(input).flat()
  const max = Math.max(...values)
  const min = Math.min(...values)
  const range = max - min
  return Object.fromEntries(
    Object.entries(input).map(([key, value]) => [
      key,
      value.map((v) => (v - min) / range),
    ])
  ) as T
}
Enter fullscreen mode Exit fullscreen mode

Enhancing User Understanding with Detailed Work Budget Visualization

To assist users in setting a realistic work budget, the second section of the report displays the average work hours for each day of the week over the last 30 days. We differentiate workdays and weekends with distinct colors to simplify identification for the user. For visualizing this data, we employ the BarChart component once again, this time omitting the labels to maintain a clean and focused presentation.

import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import { useStartOfDay } from "@lib/ui/hooks/useStartOfDay"
import { ShyInfoBlock } from "@lib/ui/info/ShyInfoBlock"
import { VStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import { range } from "@lib/utils/array/range"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { startOfDay } from "date-fns"
import { useMemo } from "react"
import { splitBy } from "@lib/utils/array/splitBy"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { AvgDay } from "./AvgDay"
import { BarChart } from "@lib/ui/charts/BarChart"
import { getWorkdayColor } from "@increaser/ui/workBudget/getWorkdayColor"
import { getWeekendColor } from "@increaser/ui/workBudget/getWeekendColor"
import { useTheme } from "styled-components"
import { isWorkday } from "@lib/utils/time/workweek"
import { getSetDuration } from "@increaser/entities-utils/set/getSetDuration"

const maxDays = 30
const minDays = 7

export const WorkBudgetDaysReport = () => {
  const todayStartedAt = useStartOfDay()

  const { sets } = useAssertUserState()

  const lastDayStartedAt = todayStartedAt - convertDuration(1, "d", "ms")

  const firstDayStartedAt = useMemo(() => {
    if (!sets.length) return todayStartedAt

    const firstSetDayStartedAt = startOfDay(sets[0].start).getTime()

    return Math.max(
      lastDayStartedAt - maxDays * convertDuration(1, "d", "ms"),
      firstSetDayStartedAt
    )
  }, [lastDayStartedAt, sets, todayStartedAt])

  const days =
    Math.round(lastDayStartedAt - firstDayStartedAt) /
    convertDuration(1, "d", "ms")

  const totals = useMemo(() => {
    const result = range(days).map(() => 0)

    sets.forEach((set) => {
      const setDayStartedAt = startOfDay(set.start).getTime()
      const dayIndex = Math.round(
        (setDayStartedAt - firstDayStartedAt) / convertDuration(1, "d", "ms")
      )

      if (dayIndex < 0 || dayIndex >= days) return

      result[dayIndex] += getSetDuration(set)
    })

    return result
  }, [days, firstDayStartedAt, sets])

  const [workdays, weekends] = useMemo(() => {
    return splitBy(totals, (total, index) => {
      const timestamp =
        firstDayStartedAt + index * convertDuration(1, "d", "ms")
      return isWorkday(timestamp) ? 0 : 1
    })
  }, [firstDayStartedAt, totals])

  const theme = useTheme()

  if (days < minDays) {
    return (
      <ShyInfoBlock>
        After {minDays} days of using the app, you'll access a report that shows
        your average work hours on weekdays and weekends.
      </ShyInfoBlock>
    )
  }

  return (
    <VStack gap={20}>
      <Text color="contrast" weight="semibold">
        Last {days} days report
      </Text>
      <UniformColumnGrid gap={20}>
        <AvgDay value={workdays} name="workday" />
        <AvgDay value={weekends} name="weekend" />
      </UniformColumnGrid>
      <BarChart
        expectedLabelHeight={0}
        expectedValueHeight={0}
        height={60}
        items={totals.map((value, index) => {
          const dayStartedAt =
            firstDayStartedAt + index * convertDuration(1, "d", "ms")
          return {
            value,
            color: isWorkday(dayStartedAt)
              ? getWorkdayColor(theme)
              : getWeekendColor(theme),
          }
        })}
      />
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

While the second section of the report highlights the average work hours for workdays and weekends, the third section presents an average of the entire week along with a bar chart depicting the total hours for the last four weeks. In both sections, if there is insufficient data to display a comprehensive report, a ShyInfoBlock will appear, subtly informing the user of the lack of data.

import { ShyInfoBlock } from "@lib/ui/info/ShyInfoBlock"
import { VStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import { range } from "@lib/utils/array/range"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { useMemo } from "react"

import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"

import { useTheme } from "styled-components"
import { useStartOfWeek } from "@lib/ui/hooks/useStartOfWeek"
import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { fromWeek, toWeek } from "@lib/utils/time/Week"
import { order } from "@lib/utils/array/order"
import { LabeledValue } from "@lib/ui/text/LabeledValue"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { sum } from "@lib/utils/array/sum"
import { BarChart } from "@lib/ui/charts/BarChart"

const maxWeeks = 4
const minWeeks = 2

export const WorkBudgetWeeksReport = () => {
  const weekStartedAt = useStartOfWeek()

  const { projects } = useProjects()

  const lastWeekStartedAt = weekStartedAt - convertDuration(1, "w", "ms")

  const firstWeekStartedAt = useMemo(() => {
    const allWeeks = projects.flatMap((project) => project.weeks).map(fromWeek)
    if (!allWeeks.length) return lastWeekStartedAt

    return Math.max(
      lastWeekStartedAt - convertDuration(maxWeeks, "w", "ms"),
      order(allWeeks, (v) => v, "asc")[0]
    )
  }, [lastWeekStartedAt, projects])

  const weeks =
    Math.round(lastWeekStartedAt - firstWeekStartedAt) /
    convertDuration(1, "w", "ms")

  const totals = useMemo(() => {
    const result = range(weeks).map(() => 0)

    projects
      .flatMap((project) => project.weeks)
      .forEach(({ week, year, seconds }) => {
        const weekStartedAt = fromWeek({ week, year })
        const weekIndex = Math.round(
          (weekStartedAt - firstWeekStartedAt) / convertDuration(1, "w", "ms")
        )

        if (weekIndex < 0 || weekIndex >= weeks) return

        result[weekIndex] += seconds
      })

    return result
  }, [firstWeekStartedAt, projects, weeks])

  const theme = useTheme()

  if (weeks < minWeeks) {
    return (
      <ShyInfoBlock>
        After {minWeeks} weeks of using the app, you'll access a report that
        shows your average work week.
      </ShyInfoBlock>
    )
  }

  return (
    <VStack gap={20}>
      <Text color="contrast" weight="semibold">
        Last {weeks} weeks report
      </Text>
      <UniformColumnGrid gap={20}>
        <LabeledValue labelColor="supporting" name={`Avg. week`}>
          <Text as="span" color="contrast">
            {formatDuration(sum(totals) / weeks, "s", { maxUnit: "h" })}
          </Text>
        </LabeledValue>
      </UniformColumnGrid>
      <BarChart
        height={120}
        items={totals.map((value, index) => {
          const weekStartedAt =
            firstWeekStartedAt + index * convertDuration(1, "w", "ms")
          return {
            value,
            label: <Text>week #{toWeek(weekStartedAt).week + 1}</Text>,
            color: theme.colors.mist,

            renderValue:
              value > 0
                ? () => (
                    <Text>{formatDuration(value, "s", { maxUnit: "h" })}</Text>
                  )
                : undefined,
          }
        })}
      />
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)