DEV Community

Cover image for Building an Interactive Time-Planner with RadzionKit: A Guide for Developers
Radzion Chachura
Radzion Chachura

Posted on • Originally published at radzion.com

Building an Interactive Time-Planner with RadzionKit: A Guide for Developers

🐙 GitHub | 🎮 Demo

In this article, we will construct a time-planner using TypeScript, with React handling the frontend and NodeJS on the backend. This tool enables you to allocate time among your projects, set goals to either increase or decrease the time spent on specific projects, and track your progress in real-time throughout the week. Additionally, you can review the actual time spent on each project compared to the planned time over the last eight weeks. Although the source code of the Increaser app, which includes the time-planner feature, is within a private repository, you can access all the reusable components and utilities that we will utilize today in the RadzionKit repository.

Projects Budget Increaser Page

Page Layout and Project Budget Management

Our page is divided into two main sections: the time-planner and the report, which compares the budgeted time with the actual time spent on each project. To arrange these sections into two equal columns that adapt responsively on smaller screens, we utilize the UniformColumnGrid component from RadzionKit. This component simplifies the implementation of one of the most common CSS Grid layouts—a row with equal-width columns.

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 { ManageProjectsBudget } from "./ManageProjectsBudget"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { ProjectsBudgetReport } from "./ProjectsBudgetReport"

const title = "Projects budget"

export const ProjectsBudgetPage: Page = () => {
  return (
    <FixedWidthContent>
      <PageTitle documentTitle={`🎯 ${title}`} title={title} />
      <UserStateOnly>
        <UniformColumnGrid fullWidth minChildrenWidth={320} gap={40}>
          <ManageProjectsBudget />
          <ProjectsBudgetReport />
        </UniformColumnGrid>
      </UserStateOnly>
    </FixedWidthContent>
  )
}
Enter fullscreen mode Exit fullscreen mode

The ManageProjectsBudget interface is structured into three sections: the overview, a conditional prompt for time management, and a list of projects along with their respective budgets. We employ the VStack component from RadzionKit to vertically stack these sections, ensuring a consistent gap of 24 pixels between each.

import { Page } from "@lib/next-ui/Page"
import { ProjectsBudgetOverview } from "./ProjectsBudgetOverview"
import { VStack } from "@lib/ui/layout/Stack"
import { ManageTimePrompt } from "./ManageTimePrompt"
import { ProjectsBudgetList } from "./ProjectsBudgetList"

export const ManageProjectsBudget: Page = () => {
  return (
    <VStack gap={24} style={{ maxWidth: 480 }}>
      <ProjectsBudgetOverview />
      <ManageTimePrompt />
      <ProjectsBudgetList />
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

Project Budget Overview and Visualization

The ProjectBudgetOverview component displays the total allocated time, the remaining free time, and the total work budget. If you're curious about the source of the "work budget" value, be sure to check out a dedicated article that covers this feature in detail. To present these figures, we utilize the LabeledValue component from RadzionKit, which pairs a label with its corresponding value and inserts a colon between them.

import { HStack, VStack } from "@lib/ui/layout/Stack"
import styled from "styled-components"
import { ProjectsBudgetVisualization } from "./ProjectsBudgetVisualization"
import { LabeledValue } from "@lib/ui/text/LabeledValue"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { Text } from "@lib/ui/text"
import { useWorkBudgetTotal } from "@increaser/ui/workBudget/hooks/useWorkBudgetTotal"
import { useFreeHours } from "./hooks/useFreeHours"
import { useProjectsBudgetedHours } from "./hooks/useProjectsBudgetedHours"

const Container = styled(VStack)`
  gap: 8px;
  font-size: 14px;
`

export const ProjectsBudgetOverview = () => {
  const workBudetTotal = useWorkBudgetTotal()

  const budgetedHours = useProjectsBudgetedHours()
  const freeHours = useFreeHours()

  return (
    <Container>
      <HStack
        wrap="wrap"
        alignItems="center"
        fullWidth
        justifyContent="space-between"
      >
        <HStack gap={20}>
          <LabeledValue labelColor="supporting" name="Allocated">
            <Text color="contrast">
              {budgetedHours
                ? formatDuration(budgetedHours, "h", {
                    maxUnit: "h",
                  })
                : "-"}
            </Text>
          </LabeledValue>
          {freeHours > 0 && (
            <LabeledValue labelColor="supporting" name="Free">
              <Text color="contrast">
                {formatDuration(freeHours, "h", {
                  maxUnit: "h",
                })}
              </Text>
            </LabeledValue>
          )}
        </HStack>
        <LabeledValue labelColor="supporting" name="Work budget">
          <Text color="contrast">
            {formatDuration(workBudetTotal, "h", {
              maxUnit: "h",
            })}
          </Text>
        </LabeledValue>
      </HStack>
      <ProjectsBudgetVisualization />
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

The budgetedHours and freeHours values are derived from the user state, which is maintained in React Context. At Increaser, we load all essential user data at the application's launch. Although this is a blocking operation, it does not impact the user experience, as we cache the data in local storage using the react-query persistor.

The ProjectsBudgetVisualization component acts as a wrapper around the CountableItemsVisualization from RadzionKit. It takes budgeted projects and their allocated times and transforms them into a list of HSLA colors, with each color representing an hour of work. For more insights on how to effectively use the HSLA color format in React, check out this article.

import { range } from "@lib/utils/array/range"
import { useTheme } from "styled-components"
import { CountableItemsVisualization } from "@lib/ui/visual/CountableItemsVisualization"
import { MIN_IN_HOUR } from "@lib/utils/time"
import { useMemo } from "react"
import { useBudgetedProjects } from "./hooks/useBudgetedProjects"
import { useWorkBudgetTotal } from "@increaser/ui/workBudget/hooks/useWorkBudgetTotal"

export const ProjectsBudgetVisualization = () => {
  const workBudgetTotal = useWorkBudgetTotal()
  const { colors } = useTheme()

  const projects = useBudgetedProjects()

  const items = useMemo(() => {
    const result = projects
      .map(({ hslaColor, allocatedMinutesPerWeek }) => {
        const hours = allocatedMinutesPerWeek / MIN_IN_HOUR
        return range(hours).map(() => hslaColor)
      })
      .flat()
    if (result.length < workBudgetTotal) {
      result.push(
        ...range(workBudgetTotal - result.length).map(() => colors.mist)
      )
    }

    return result
  }, [colors.mist, projects, workBudgetTotal])

  return <CountableItemsVisualization value={items} />
}
Enter fullscreen mode Exit fullscreen mode

Time Allocation and Project Budget Adjustment

The ManageTimePrompt component checks the status of free hours, and if available, prompts the user to allocate time to a project or adjust the budget for the projects listed below. If the user has exceeded the work budget, the component displays a warning message. Upon clicking on the prompt, the AddBudgetForm component opens, allowing the user to allocate time to a project that currently has no budget. For this conditional rendering, we utilize the Opener component from RadzionKit, which I personally prefer over using the useState hook in similar scenarios.

import { Opener } from "@lib/ui/base/Opener"
import { AddBudgetForm } from "./AddBudgetForm"
import { useFreeHours } from "./hooks/useFreeHours"
import { pluralize } from "@lib/utils/pluralize"
import { PanelPrompt } from "@lib/ui/panel/PanelPrompt"
import { ShyWarningBlock } from "@lib/ui/status/ShyWarningBlock"
import Link from "next/link"
import { AppPath } from "@increaser/ui/navigation/AppPath"
import { getColor } from "@lib/ui/theme/getters"
import styled from "styled-components"
import { transition } from "@lib/ui/css/transition"

const BudgetLink = styled(Link)`
  color: ${getColor("text")};
  font-weight: 500;
  border-bottom: 1px dashed ${getColor("text")};
  ${transition};
  &:hover {
    color: ${getColor("contrast")};
  }
`

export const ManageTimePrompt = () => {
  const freeHours = useFreeHours()

  if (freeHours === 0) {
    return null
  }

  if (freeHours < 0) {
    return (
      <ShyWarningBlock title="Exceeding Work Budget">
        You have allocated {pluralize(-freeHours, "hour")} more than your
        current work budget allows. Please adjust the budget for the projects
        listed below or{" "}
        <BudgetLink href={AppPath.WorkBudget}>
          expand your overall work budget
        </BudgetLink>{" "}
        to accommodate your plans.
      </ShyWarningBlock>
    )
  }

  return (
    <Opener
      renderOpener={({ onOpen, isOpen }) =>
        !isOpen && (
          <PanelPrompt
            title={`You have ${pluralize(freeHours, "free hour")}!`}
            onClick={onOpen}
          >
            Tap here to allocate time for a project with no budget or adjust the
            budget for the projects listed below.
          </PanelPrompt>
        )
      }
      renderContent={({ onClose }) => <AddBudgetForm onFinish={onClose} />}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

The state of the AddBudgetForm includes four fields:

  • projectId — the ID of the project to which the user wants to allocate time.
  • hours — the number of hours the user wishes to allocate.
  • goal — the user's goal for the project, which is optional and can be either doMore or doLess.
  • workingDays — the days of the week on which the user plans to work on the project, selectable as either workdays or everyday.

Add Budget Form

Upon submitting the form, we call the mutate function from the useUpdateProjectMutation hook. This function performs an optimistic update of the project and triggers a corresponding API endpoint to update the project in the database. For a deeper understanding of how to efficiently build backends within a monorepo, refer to this article.

import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { Panel } from "@lib/ui/panel/Panel"
import { preventDefault } from "@lib/ui/utils/preventDefault"
import { Button } from "@lib/ui/buttons/Button"
import { useCallback, useMemo, useState } from "react"
import { Fields } from "@lib/ui/inputs/Fields"
import { ProjectInput } from "@increaser/ui/projects/ProjectInput"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { Field } from "@lib/ui/inputs/Field"
import { useUpdateProjectMutation } from "../api/useUpdateProjectMutation"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { ProjectGoal, ProjectWorkingDays } from "@increaser/entities/Project"
import { InputContainer } from "@lib/ui/inputs/InputContainer"
import { LabelText } from "@lib/ui/inputs/LabelText"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { FinishableComponentProps } from "@lib/ui/props"
import { useFreeHours } from "./hooks/useFreeHours"
import { BudgetHoursInput } from "./BudgetHoursInput"
import { WorkdingDaysInput } from "./WorkingDaysInput"
import { ProjectGoalInput } from "./ProjectGoalInput"

type WeeklyGoalShape = {
  projectId: string | null
  hours: number | null
  goal: ProjectGoal | null
  workingDays: ProjectWorkingDays
}

export const AddBudgetForm = ({ onFinish }: FinishableComponentProps) => {
  const { activeProjects, projectsRecord } = useProjects()

  const options = useMemo(
    () => activeProjects.filter((p) => !p.allocatedMinutesPerWeek),
    [activeProjects]
  )

  const getInitialValue = useCallback(
    (): WeeklyGoalShape => ({
      projectId: options[0]?.id ?? null,
      hours: null,
      goal: null,
      workingDays: "everyday",
    }),
    [options]
  )

  const [value, setValue] = useState<WeeklyGoalShape>(getInitialValue)

  const errorMessage = useMemo(() => {
    if (!value.projectId) return "Please select a project"
    if (!value.hours) return "Please enter a budget"
  }, [value.projectId, value.hours])

  const freeHours = useFreeHours()

  const { mutate: updateProject } = useUpdateProjectMutation()

  return (
    <InputContainer style={{ gap: 8 }} as="div">
      <LabelText>Add project budget</LabelText>
      <Panel
        as="form"
        onSubmit={preventDefault(() => {
          if (errorMessage) return

          updateProject({
            id: shouldBePresent(value.projectId),
            fields: {
              allocatedMinutesPerWeek: convertDuration(
                value.hours ?? 0,
                "h",
                "min"
              ),
              goal: value.goal,
              workingDays: value.workingDays,
            },
          })

          onFinish()
        })}
        withSections
        style={value.hours ? undefined : { background: "transparent" }}
        kind="secondary"
      >
        <Fields>
          <Field>
            <ProjectInput
              label="Project"
              options={options}
              value={value.projectId ? projectsRecord[value.projectId] : null}
              onChange={(project) =>
                setValue((prev) => ({
                  ...prev,
                  projectId: project?.id ?? null,
                }))
              }
            />
          </Field>

          <Field>
            <BudgetHoursInput
              max={freeHours}
              value={value.hours}
              onChange={(hours) => setValue((prev) => ({ ...prev, hours }))}
            />
          </Field>
          <Field>
            <WorkdingDaysInput
              value={value.workingDays}
              onChange={(workingDays) =>
                setValue((prev) => ({ ...prev, workingDays }))
              }
            />
          </Field>
        </Fields>

        {value.projectId && value.hours && (
          <ProjectGoalInput
            value={value.goal}
            onChange={(goal) => setValue((prev) => ({ ...prev, goal }))}
            project={projectsRecord[value.projectId]}
            hours={value.hours}
          />
        )}
        <UniformColumnGrid gap={20}>
          <Button type="button" onClick={onFinish} kind="secondary" size="l">
            Cancel
          </Button>
          <Button isDisabled={errorMessage} size="l">
            Submit
          </Button>
        </UniformColumnGrid>
      </Panel>
    </InputContainer>
  )
}
Enter fullscreen mode Exit fullscreen mode

To implement the ProjectInput, we leverage the FixedOptionsInput, which functions as a combobox with a fixed set of options. While it is a relatively complex component, those interested in understanding its mechanics can check out this article.

import {
  FixedOptionsInput,
  FixedOptionsInputWrapperProps,
} from "@lib/ui/inputs/dropdown/FixedOptionsInput"
import { EnhancedProject } from "../EnhancedProject"
import { IdentifierPlaceholder } from "@lib/ui/inputs/dropdown/FixedOptionsInput/IdentifierPlaceholder"
import { ProjectOption } from "./ProjectOption"
import { ProjectIdentifier } from "./ProjectIdentifier"

type ProjectInputProps = FixedOptionsInputWrapperProps<EnhancedProject>

export function ProjectInput({
  value,
  onChange,
  label,
  options,
  ...rest
}: ProjectInputProps) {
  return (
    <FixedOptionsInput
      value={value}
      label={label}
      onChange={onChange}
      placeholder="Search for a project"
      options={options}
      getOptionSearchStrings={(option) => [option.name]}
      getOptionName={(option) => option.name}
      getOptionKey={(option) => option.id}
      renderOptionIdentifier={(project) => (
        <ProjectIdentifier>{project.emoji}</ProjectIdentifier>
      )}
      optionIdentifierPlaceholder={<IdentifierPlaceholder />}
      renderOption={(project) => <ProjectOption value={project} />}
      {...rest}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

The BudgetHoursInput component is a straightforward input field designed to restrict users from entering a value greater than the number of free hours available. It wraps around the HoursInput component, an integer-only input adorned with a clock icon at the front. To clarify the purpose of the input field as being for budgeting, we add an info icon. When hovered over, this icon displays a tooltip with an explanation, utilizing the WithHint component from RadzionKit.

import { WithHint } from "@lib/ui/tooltips/WithHint"
import { HoursInput } from "@increaser/ui/weeklyGoals/HoursInput"
import { InputProps } from "@lib/ui/props"

type BudgetHoursInputProps = InputProps<number | null> & {
  max: number
}

export const BudgetHoursInput = ({
  value,
  onChange,
  max,
}: BudgetHoursInputProps) => (
  <HoursInput
    autoFocus
    label={
      <WithHint hint="Select the number of hours you aim to spend on this project each week.">
        Budget
      </WithHint>
    }
    placeholder="Enter hours"
    max={max}
    value={value}
    onChange={onChange}
  />
)
Enter fullscreen mode Exit fullscreen mode

The WorkingDaysInput is a wrapper around the RadioInput component from RadzionKit. It receives an array of options, a function to render an option, the selected value, and an onChange callback. The RadioInput displays a horizontal list of options, where the selected option is marked by a highlighted circle on the left side.

import {
  ProjectWorkingDays,
  projectWorkingDays,
  workingDayOptionName,
} from "@increaser/entities/Project"
import { RadioInput } from "@lib/ui/inputs/RadioInput"
import { InputContainer } from "@lib/ui/inputs/InputContainer"
import { LabelText } from "@lib/ui/inputs/LabelText"
import { InputProps } from "@lib/ui/props"

export const WorkdingDaysInput = ({
  value,
  onChange,
}: InputProps<ProjectWorkingDays>) => {
  return (
    <InputContainer>
      <LabelText>Working days</LabelText>
      <RadioInput
        options={projectWorkingDays}
        renderOption={(option) => workingDayOptionName[option]}
        value={value}
        onChange={onChange}
      />
    </InputContainer>
  )
}
Enter fullscreen mode Exit fullscreen mode

To avoid overloading the user, we display the interface for setting a goal only after the user has selected a project and entered the budget. The ProjectGoalInput features a MinimalisticSwitch from RadzionKit, which toggles between setting a goal and not setting one. If a goal is set, the component then displays a RadioInput with available options, alongside text that formulates the goal in a human-readable format.

import { VStack } from "@lib/ui/layout/Stack"
import {
  ProjectGoal,
  goalOptionName,
  projectGoals,
} from "@increaser/entities/Project"
import { RadioInput } from "@lib/ui/inputs/RadioInput"
import { pluralize } from "@lib/utils/pluralize"
import { MinimalisticSwitch } from "@lib/ui/inputs/Switch/MinimalisticSwitch"
import { Text } from "@lib/ui/text"
import { capitalizeFirstLetter } from "@lib/utils/capitalizeFirstLetter"
import { InputProps } from "@lib/ui/props"
import { EnhancedProject } from "@increaser/ui/projects/EnhancedProject"

type ProjectGoalInputProps = InputProps<ProjectGoal | null> & {
  project: EnhancedProject
  hours: number
}

export const ProjectGoalInput = ({
  value,
  onChange,
  project,
  hours,
}: ProjectGoalInputProps) => {
  return (
    <VStack gap={20}>
      <MinimalisticSwitch
        onChange={() => onChange(value ? null : "doMore")}
        value={value !== null}
        label={`Set a goal ${value ? "to work" : "..."}`}
      />
      {value !== null && (
        <>
          <RadioInput
            options={projectGoals}
            renderOption={(goal) => capitalizeFirstLetter(goalOptionName[goal])}
            value={value}
            onChange={(goal) => onChange(goal)}
          />
          <Text color="regular" size={14}>
            <Text as="span" weight="bold" color="contrast">
              {capitalizeFirstLetter(goalOptionName[value])}
            </Text>{" "}
            {pluralize(hours, "hour")} of {project.name} per week
          </Text>
        </>
      )}
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

Managing and Displaying Project Budgets

Once the user submits the form, it appears in the ProjectsBudgetList component, which iterates over each project sorted by the time allocated. Each project is rendered using the ProjectBudgetItem component, which is divided into two parts. The left part describes the project budget, while the right part includes two buttons for editing and deleting the project budget.

import { EnhancedProject } from "@increaser/ui/projects/EnhancedProject"
import { HStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import styled from "styled-components"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { Panel } from "@lib/ui/panel/Panel"
import { ProjectGoalShyIndicator } from "./ProjectGoalShyIndicator"
import { TrashBinIcon } from "@lib/ui/icons/TrashBinIcon"
import { UnstyledButton } from "@lib/ui/buttons/UnstyledButton"
import { centerContent } from "@lib/ui/css/centerContent"
import { transition } from "@lib/ui/css/transition"
import { getHoverVariant } from "@lib/ui/theme/getHoverVariant"
import { getColor } from "@lib/ui/theme/getters"
import { EditIcon } from "@lib/ui/icons/EditIcon"
import { useUpdateProjectMutation } from "../api/useUpdateProjectMutation"
import { Opener } from "@lib/ui/base/Opener"
import { ManageProjectBudget } from "./ManageProjectBudget"

type WeeklyGoalItemProps = {
  value: EnhancedProject
}

const Container = styled(Panel)`
  font-size: 14px;
`

const PanelButton = styled(UnstyledButton)`
  height: 100%;
  font-size: 20px;
  ${centerContent};
  ${transition};
  &:hover {
    background: ${getHoverVariant("foreground")};
    color: ${getColor("contrast")};
  }
`

export const ProjectBudgetItem = ({ value }: WeeklyGoalItemProps) => {
  const { mutate } = useUpdateProjectMutation()

  return (
    <Opener
      renderOpener={({ onOpen, isOpen }) =>
        isOpen ? null : (
          <Container withSections direction="row">
            <HStack
              fullWidth
              alignItems="center"
              justifyContent="space-between"
            >
              <HStack alignItems="center" gap={8}>
                {<ProjectGoalShyIndicator value={value.goal ?? null} />}

                <Text color="contrast" cropped weight="semibold">
                  {value.name}
                </Text>
              </HStack>
              <HStack alignItems="center" gap={4}>
                <Text weight="bold" color="contrast">
                  {formatDuration(value.allocatedMinutesPerWeek, "min", {
                    kind: "long",
                    minUnit: "h",
                    maxUnit: "h",
                  })}{" "}
                </Text>
              </HStack>
            </HStack>
            <PanelButton onClick={onOpen}>
              <EditIcon />
            </PanelButton>
            <PanelButton
              onClick={() => {
                mutate({
                  id: value.id,
                  fields: {
                    allocatedMinutesPerWeek: 0,
                    goal: null,
                  },
                })
              }}
            >
              <TrashBinIcon />
            </PanelButton>
          </Container>
        )
      }
      renderContent={({ onClose }) => (
        <ManageProjectBudget onFinish={onClose} project={value} />
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

To visually indicate whether a project has a goal, we use the ProjectGoalShyIndicator. This component displays a gray circle if the project has no goal, a green plus icon if the goal is to "do more," and an orange minus icon if the goal is to "do less." For this type of conditional rendering, we utilize the match utility function and the Match component from RadzionKit, which effectively serve as a switch-case replacement.

import { ComponentWithValueProps } from "@lib/ui/props"
import { ProjectGoal } from "@increaser/entities/Project"
import { IconWrapper } from "@lib/ui/icons/IconWrapper"
import { PlusIcon } from "@lib/ui/icons/PlusIcon"
import { Match } from "@lib/ui/base/Match"
import styled, { useTheme } from "styled-components"
import { getColor } from "@lib/ui/theme/getters"
import { round } from "@lib/ui/css/round"
import { MinusIcon } from "@lib/ui/icons/MinusIcon"
import { sameDimensions } from "@lib/ui/css/sameDimensions"
import { match } from "@lib/utils/match"

const Container = styled(IconWrapper)`
  background: ${getColor("mist")};
  ${round};
  ${sameDimensions("1.4em")};
`

export const ProjectGoalShyIndicator = ({
  value,
}: ComponentWithValueProps<ProjectGoal | null>) => {
  const { colors } = useTheme()

  return (
    <Container
      style={
        value
          ? {
              background: match(value, {
                doMore: () => colors.success,
                doLess: () => colors.idle,
              })
                .getVariant({ a: () => 0.08 })
                .toCssValue(),
              color: match(value, {
                doMore: () => colors.success,
                doLess: () => colors.idle,
              }).toCssValue(),
            }
          : undefined
      }
    >
      {value ? (
        <Match
          value={value}
          doMore={() => <PlusIcon />}
          doLess={() => <MinusIcon />}
        />
      ) : undefined}
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

We utilize the Panel component from RadzionKit as a container. By setting the withSections prop to true, we implement a design pattern where background and padding are applied to the children, thereby creating transparent borders between them. As the Panel is a flexbox element, we can also pass direction="row" to align the children horizontally.

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

Since the Panel component applies padding to its children, all that is required for the PanelButton component is setting the height to 100%, centering the content, specifying the size for the icons inside, and adding hover effects. To trigger the opening of the ManageProjectBudget component, we utilize the Opener component once again.

import { VStack } from "@lib/ui/layout/Stack"
import { Button } from "@lib/ui/buttons/Button"
import { useCallback, useMemo, useState } from "react"
import { Fields } from "@lib/ui/inputs/Fields"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { Field } from "@lib/ui/inputs/Field"
import { useUpdateProjectMutation } from "../api/useUpdateProjectMutation"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { ProjectGoal, ProjectWorkingDays } from "@increaser/entities/Project"
import { Text } from "@lib/ui/text"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { FinishableComponentProps } from "@lib/ui/props"
import { EnhancedProject } from "@increaser/ui/projects/EnhancedProject"
import { useFreeHours } from "./hooks/useFreeHours"
import { Panel } from "@lib/ui/panel/Panel"
import { preventDefault } from "@lib/ui/utils/preventDefault"
import { BudgetHoursInput } from "./BudgetHoursInput"
import { WorkdingDaysInput } from "./WorkingDaysInput"
import { ProjectGoalInput } from "./ProjectGoalInput"

type WeeklyGoalShape = {
  hours: number | null
  goal: ProjectGoal | null
  workingDays: ProjectWorkingDays
}

type ManageProjectBudgetOverlayProps = FinishableComponentProps & {
  project: EnhancedProject
}

export const ManageProjectBudget = ({
  onFinish,
  project,
}: ManageProjectBudgetOverlayProps) => {
  const getInitialValue = useCallback(
    (): WeeklyGoalShape => ({
      hours: convertDuration(project.allocatedMinutesPerWeek, "min", "h"),
      goal: project.goal ?? null,
      workingDays: project.workingDays,
    }),
    [project.allocatedMinutesPerWeek, project.goal, project.workingDays]
  )

  const [value, setValue] = useState<WeeklyGoalShape>(getInitialValue)

  const freeHours =
    useFreeHours() +
    convertDuration(project.allocatedMinutesPerWeek, "min", "h")

  const { mutate: updateProject } = useUpdateProjectMutation()

  const errorMessage = useMemo(() => {
    if (!value.hours) return "Please enter a budget"
  }, [value.hours])

  return (
    <Panel
      as="form"
      onSubmit={preventDefault(() => {
        updateProject({
          id: project.id,
          fields: {
            allocatedMinutesPerWeek: convertDuration(
              shouldBePresent(value.hours),
              "h",
              "min"
            ),
            goal: value.goal,
            workingDays: value.workingDays,
          },
        })

        onFinish()
      })}
      withSections
      style={value.hours ? undefined : { background: "transparent" }}
      kind="secondary"
    >
      <VStack gap={20}>
        <Text weight="semibold" color="contrast">
          {project.emoji} {project.name}
        </Text>
        <Fields>
          <Field>
            <BudgetHoursInput
              max={freeHours}
              value={value.hours}
              onChange={(hours) => setValue((prev) => ({ ...prev, hours }))}
            />
          </Field>
          <Field>
            <WorkdingDaysInput
              value={value.workingDays}
              onChange={(workingDays) =>
                setValue((prev) => ({ ...prev, workingDays }))
              }
            />
          </Field>
        </Fields>
      </VStack>
      {value.hours && (
        <ProjectGoalInput
          value={value.goal}
          onChange={(goal) => setValue((prev) => ({ ...prev, goal }))}
          project={project}
          hours={value.hours}
        />
      )}
      <UniformColumnGrid gap={20}>
        <Button
          onClick={() => {
            onFinish()
          }}
          type="button"
          kind="secondary"
          size="l"
        >
          Cancel
        </Button>
        <Button isDisabled={errorMessage} size="l">
          Submit
        </Button>
      </UniformColumnGrid>
    </Panel>
  )
}
Enter fullscreen mode Exit fullscreen mode

The ManageProjectBudget component functions similarly to the AddBudgetForm, but with a key difference: it receives the project prop. As a result, there is no need to display the project selection field. Instead, we display the project name and emoji at the top of the form, providing a clear and immediate reference to the project being managed.

import { VStack } from "@lib/ui/layout/Stack"
import { Button } from "@lib/ui/buttons/Button"
import { useCallback, useMemo, useState } from "react"
import { Fields } from "@lib/ui/inputs/Fields"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { Field } from "@lib/ui/inputs/Field"
import { useUpdateProjectMutation } from "../api/useUpdateProjectMutation"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { ProjectGoal, ProjectWorkingDays } from "@increaser/entities/Project"
import { Text } from "@lib/ui/text"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { FinishableComponentProps } from "@lib/ui/props"
import { EnhancedProject } from "@increaser/ui/projects/EnhancedProject"
import { useFreeHours } from "./hooks/useFreeHours"
import { Panel } from "@lib/ui/panel/Panel"
import { preventDefault } from "@lib/ui/utils/preventDefault"
import { BudgetHoursInput } from "./BudgetHoursInput"
import { WorkdingDaysInput } from "./WorkingDaysInput"
import { ProjectGoalInput } from "./ProjectGoalInput"

type WeeklyGoalShape = {
  hours: number | null
  goal: ProjectGoal | null
  workingDays: ProjectWorkingDays
}

type ManageProjectBudgetOverlayProps = FinishableComponentProps & {
  project: EnhancedProject
}

export const ManageProjectBudget = ({
  onFinish,
  project,
}: ManageProjectBudgetOverlayProps) => {
  const getInitialValue = useCallback(
    (): WeeklyGoalShape => ({
      hours: convertDuration(project.allocatedMinutesPerWeek, "min", "h"),
      goal: project.goal ?? null,
      workingDays: project.workingDays,
    }),
    [project.allocatedMinutesPerWeek, project.goal, project.workingDays]
  )

  const [value, setValue] = useState<WeeklyGoalShape>(getInitialValue)

  const freeHours =
    useFreeHours() +
    convertDuration(project.allocatedMinutesPerWeek, "min", "h")

  const { mutate: updateProject } = useUpdateProjectMutation()

  const errorMessage = useMemo(() => {
    if (!value.hours) return "Please enter a budget"
  }, [value.hours])

  return (
    <Panel
      as="form"
      onSubmit={preventDefault(() => {
        updateProject({
          id: project.id,
          fields: {
            allocatedMinutesPerWeek: convertDuration(
              shouldBePresent(value.hours),
              "h",
              "min"
            ),
            goal: value.goal,
            workingDays: value.workingDays,
          },
        })

        onFinish()
      })}
      withSections
      style={value.hours ? undefined : { background: "transparent" }}
      kind="secondary"
    >
      <VStack gap={20}>
        <Text weight="semibold" color="contrast">
          {project.emoji} {project.name}
        </Text>
        <Fields>
          <Field>
            <BudgetHoursInput
              max={freeHours}
              value={value.hours}
              onChange={(hours) => setValue((prev) => ({ ...prev, hours }))}
            />
          </Field>
          <Field>
            <WorkdingDaysInput
              value={value.workingDays}
              onChange={(workingDays) =>
                setValue((prev) => ({ ...prev, workingDays }))
              }
            />
          </Field>
        </Fields>
      </VStack>
      {value.hours && (
        <ProjectGoalInput
          value={value.goal}
          onChange={(goal) => setValue((prev) => ({ ...prev, goal }))}
          project={project}
          hours={value.hours}
        />
      )}
      <UniformColumnGrid gap={20}>
        <Button
          onClick={() => {
            onFinish()
          }}
          type="button"
          kind="secondary"
          size="l"
        >
          Cancel
        </Button>
        <Button isDisabled={errorMessage} size="l">
          Submit
        </Button>
      </UniformColumnGrid>
    </Panel>
  )
}
Enter fullscreen mode Exit fullscreen mode

Transition to Reporting and Current Week Progress Overview

With budget management addressed, we can transition to the report section. If you're curious about how time tracking is implemented at Increaser, you can explore this article. We display the report within the Panel component. By assigning the kind="secondary" prop, we make the background transparent, adding more contrast to the content. To delineate the various segments of the report, we use the SeparatedByLine component from RadzionKit.

import { SeparatedByLine } from "@lib/ui/layout/SeparatedByLine"
import { Panel } from "@lib/ui/panel/Panel"
import { CurrentWeekProgress } from "./CurrentWeekProgress"
import { PreviousWeeksProgress } from "./PreviousWeeks/PreviousWeeksProgress"

export const ProjectsBudgetReport = () => (
  <Panel kind="secondary">
    <SeparatedByLine gap={40}>
      <CurrentWeekProgress />
      <PreviousWeeksProgress />
    </SeparatedByLine>
  </Panel>
)
Enter fullscreen mode Exit fullscreen mode

The CurrentWeekProgress component displays a breakdown of the progress for each budgeted project in the current week. We wrap it with the GoalsRequired component, which displays an info message if no projects are budgeted. By wrapping the ProjectBudgetWidget component with the CurrentProjectProvider we eliminate the need for prop drilling, making all the child components aware of the current project.

import { ProjectBudgetWidget } from "./ProjectBudgetWidget"
import { CurrentProjectProvider } from "../components/ProjectView/CurrentProjectProvider"
import { VStack } from "@lib/ui/layout/Stack"
import { GoalsRequired } from "./GoalsRequired"
import { useBudgetedProjects } from "./hooks/useBudgetedProjects"
import { SectionTitle } from "@lib/ui/text/SectionTitle"

export const CurrentWeekProgress = () => {
  const projects = useBudgetedProjects()

  return (
    <VStack gap={20}>
      <SectionTitle>This week</SectionTitle>
      <GoalsRequired>
        <>
          {projects.map((project) => (
            <CurrentProjectProvider value={project} key={project.id}>
              <ProjectBudgetWidget />
            </CurrentProjectProvider>
          ))}
        </>
      </GoalsRequired>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

Given the common pattern of passing a single value down the component tree, I've introduced a small helper, getValueProviderSetup, to RadzionKit. This utility receives the name of the entity and returns a hook for accessing the value, along with a Provider component for setting the value.

import { EnhancedProject } from "@increaser/ui/projects/EnhancedProject"
import { getValueProviderSetup } from "@lib/ui/state/getValueProviderSetup"

export const { useValue: useCurrentProject, provider: CurrentProjectProvider } =
  getValueProviderSetup<EnhancedProject>("Project")
Enter fullscreen mode Exit fullscreen mode

Project Budget Widget: Tracking Goals and Daily Allocation

The ProjectBudgetWidget renders a visualization that helps you access if you are on track with your goal compred to the current day of the week. It's not only used in this report, we also use it in the UI for starting focused session to motivate users to work on their project to reach the goal or in the case doLess goal to caution them about overworking.

Start Focused Session

import { useCurrentProject } from "@increaser/app/projects/components/ProjectView/CurrentProjectProvider"
import { VStack } from "@lib/ui/layout/Stack"
import { ProjectBudgetWidgetHeader } from "./ProjectBudgetWidgetHeader"
import { ProjectBudgetOverview } from "./ProjectBudgetOverview"

export const ProjectBudgetWidget = () => {
  const { allocatedMinutesPerWeek } = useCurrentProject()

  return (
    <VStack gap={4}>
      <ProjectBudgetWidgetHeader />
      <VStack style={{ height: 28 }}>
        {allocatedMinutesPerWeek > 0 && <ProjectBudgetOverview />}
      </VStack>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

In the widget header, we display the goal indicator and project name on the left side, and the worked time alongside the allocated time on the right side. To insert a slash between the two values, we utilize the HStackSeparatedBy component from RadzionKit. Thanks to the setup of the project provider, we can easily retrieve the current project and display its data using the useCurrentProject hook.

import { HStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import styled from "styled-components"
import { useCurrentProject } from "../../components/ProjectView/CurrentProjectProvider"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { AppPath } from "@increaser/ui/navigation/AppPath"
import Link from "next/link"
import { ProjectGoalShyIndicator } from "../ProjectGoalShyIndicator"
import { HStackSeparatedBy } from "@lib/ui/layout/StackSeparatedBy"

const Container = styled(HStack)`
  width: 100%;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  font-size: 14px;
`

export const ProjectBudgetWidgetHeader = () => {
  const { allocatedMinutesPerWeek, doneMinutesThisWeek, goal, name } =
    useCurrentProject()

  return (
    <Container>
      <HStack alignItems="center" gap={4}>
        <ProjectGoalShyIndicator value={goal ?? null} />
        <Text weight="semibold" color="contrast">
          {name}
        </Text>
      </HStack>
      <Link href={AppPath.ProjectsBudget}>
        <HStackSeparatedBy separator="/">
          <Text weight="semibold" color="contrast">
            {doneMinutesThisWeek > 0
              ? formatDuration(doneMinutesThisWeek, "min", {
                  maxUnit: "h",
                })
              : "-"}
          </Text>
          {allocatedMinutesPerWeek > 0 && (
            <Text color="supporting" weight="semibold">
              {formatDuration(allocatedMinutesPerWeek, "min", {
                maxUnit: "h",
              })}
            </Text>
          )}
        </HStackSeparatedBy>
      </Link>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

In the ProjectBudgetOverview component, it's essential to accurately visualize whether the user has met or failed to meet their goal. Additionally, if the final day of the week has not yet arrived, we display an offset. This offset appears in red if the user is behind the target and in green if they are ahead. To achieve this, we employ the Offset styled component, which is absolutely positioned and has its width and left value set based on the user's progress. The Fill component is utilized to represent the proportion of the user's worked time relative to the allocated time.

import styled, { useTheme } from "styled-components"
import { transition } from "@lib/ui/css/transition"
import { borderRadius } from "@lib/ui/css/borderRadius"
import { getColor } from "@lib/ui/theme/getters"
import { useCurrentProject } from "../../components/ProjectView/CurrentProjectProvider"
import { toPercents } from "@lib/utils/toPercents"
import { useMemo } from "react"
import { useCurrentDayTarget } from "../hooks/useCurrentDayTarget"
import { ProjectBudgetWidgetDays } from "./ProjectBudgetWidgetDays"
import { useHasReachedFinalWorkday } from "../hooks/useHasReachedFinalWorkday"
import { match } from "@lib/utils/match"

const Container = styled.div`
  position: relative;
  display: flex;
  align-items: center;
  ${borderRadius.m};
  ${transition};
  height: 100%;
  background: ${getColor("foreground")};
  border: 1px solid ${getColor("mist")};
  overflow: hidden;
`

const Fill = styled.div`
  height: 100%;
  ${transition};
  background: ${getColor("background")};
`

const Offset = styled.div`
  position: absolute;
  top: 0;
  height: 100%;
  ${transition};
`

export const ProjectBudgetOverview = () => {
  const { goal, allocatedMinutesPerWeek, doneMinutesThisWeek } =
    useCurrentProject()

  const { colors } = useTheme()

  const hasReachedFinalDay = useHasReachedFinalWorkday()
  const hasReachedGoal = useMemo(() => {
    if (!goal) return false

    if (goal === "doMore") {
      return doneMinutesThisWeek >= allocatedMinutesPerWeek
    }

    return hasReachedFinalDay && doneMinutesThisWeek <= allocatedMinutesPerWeek
  }, [allocatedMinutesPerWeek, doneMinutesThisWeek, goal, hasReachedFinalDay])

  const target = useCurrentDayTarget()

  const isUnderTarget = doneMinutesThisWeek < target

  return (
    <Container
      style={
        goal && (hasReachedFinalDay || hasReachedGoal)
          ? {
              borderColor: (hasReachedGoal
                ? colors.success
                : colors.alert
              ).toCssValue(),
            }
          : {}
      }
    >
      <Fill
        style={{
          width: toPercents(
            Math.min(doneMinutesThisWeek / allocatedMinutesPerWeek, 1)
          ),
        }}
      />
      {goal && !(hasReachedFinalDay || hasReachedGoal) && (
        <>
          <Offset
            style={{
              left: toPercents(
                isUnderTarget
                  ? doneMinutesThisWeek / allocatedMinutesPerWeek
                  : target / allocatedMinutesPerWeek
              ),
              width: toPercents(
                isUnderTarget
                  ? (target - doneMinutesThisWeek) / allocatedMinutesPerWeek
                  : (doneMinutesThisWeek - target) / allocatedMinutesPerWeek
              ),
              background: (isUnderTarget
                ? match(goal, {
                    doMore: () => colors.alert,
                    doLess: () => colors.success,
                  })
                : match(goal, {
                    doMore: () => colors.success,
                    doLess: () => colors.alert,
                  })
              ).toCssValue(),
            }}
          />
        </>
      )}
      <ProjectBudgetWidgetDays />
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

The useHasReachedFinalWorkday hook determines if the current day aligns with the end of the work period. It returns true if today is Friday, Saturday, or Sunday for projects with "workdays" set as their working days, or if today is Sunday for projects designated to "everyday" working days.

import { useWeekday } from "@lib/ui/hooks/useWeekday"
import { useProjectDaysAllocation } from "./useProjectDaysAllocation"

export const useHasReachedFinalWorkday = () => {
  const weekday = useWeekday()
  const allocation = useProjectDaysAllocation()

  return weekday + 1 >= allocation.length
}
Enter fullscreen mode Exit fullscreen mode

The ProjectBudgetWidgetDays component displays the days of the week when the user hovers over the progress bar. The useProjectDaysAllocation hook returns the proportion of time allocated to each day, ensuring that the total for all days equals one. The Container is an absolutely positioned flexbox element set to a row direction. To populate this container, we iterate over each day, rendering a segment with the weekday name centered and a border on the right side.

import { takeWholeSpaceAbsolutely } from "@lib/ui/css/takeWholeSpaceAbsolutely"
import { HStack, VStack } from "@lib/ui/layout/Stack"
import styled, { useTheme } from "styled-components"
import { Text } from "@lib/ui/text"
import { useWeekday } from "@lib/ui/hooks/useWeekday"
import { toPercents } from "@lib/utils/toPercents"
import { getShortWeekday } from "@lib/utils/time"
import { useProjectDaysAllocation } from "../hooks/useProjectDaysAllocation"
import { transition } from "@lib/ui/css/transition"

const DayName = styled(Text)`
  font-size: 12px;
  font-weight: 500;
`

const Day = styled(VStack)`
  height: 100%;
  border-right: 1px dashed;
  align-items: center;
  justify-content: center;
  padding-right: 4px;
  opacity: 0;
  ${transition};
`

const Container = styled(HStack)`
  ${takeWholeSpaceAbsolutely};
  &:hover ${Day} {
    opacity: 1;
  }
`

export const ProjectBudgetWidgetDays = () => {
  const weekday = useWeekday()
  const { colors } = useTheme()

  const segments = useProjectDaysAllocation()

  return (
    <Container>
      {segments.map((value, index) => {
        if (value === 0) return null

        return (
          <Day
            key={index}
            style={{
              width: toPercents(value / 1),
              color: (index === weekday
                ? colors.contrast
                : colors.textSupporting
              ).toCssValue(),
              borderWidth: index === segments.length - 1 ? 0 : 1,
            }}
          >
            <DayName>{getShortWeekday(index)}</DayName>
          </Day>
        )
      })}
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Analyzing Previous Weeks' Performance with Project Charts

In the PreviousWeeksProgress component, we iterate over each budgeted project and display the ProjectPreviousWeeks component, which presents a chart to help visualize how the previous weeks' performance compares to the allocated time. To streamline data management and avoid prop drilling, we utilize the CurrentProjectProvider. Additionally, if no projects are budgeted, the GoalsRequired component displays an informational message to alert the user.

import { CurrentProjectProvider } from "../../components/ProjectView/CurrentProjectProvider"
import { VStack } from "@lib/ui/layout/Stack"
import { GoalsRequired } from "../GoalsRequired"
import { ProjectPreviousWeeks } from "../ProjectPreviousWeeks"
import { useBudgetedProjects } from "../hooks/useBudgetedProjects"
import { pluralize } from "@lib/utils/pluralize"
import { previousWeeksConfig } from "./previousWeeksConfig"
import { SectionTitle } from "@lib/ui/text/SectionTitle"

export const PreviousWeeksProgress = () => {
  const projectsWithGoals = useBudgetedProjects()

  return (
    <VStack gap={20}>
      <SectionTitle>
        Previous {pluralize(previousWeeksConfig.weeks, "week")}
      </SectionTitle>
      <GoalsRequired>
        <>
          {projectsWithGoals.map((project) => (
            <CurrentProjectProvider value={project} key={project.id}>
              <ProjectPreviousWeeks />
            </CurrentProjectProvider>
          ))}
        </>
      </GoalsRequired>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

In the ProjectPreviousWeeks component, we first extract the tracked hours from the previous eight weeks and verify if there's at least one week with tracked hours before proceeding to draw the line chart. To link the project with its respective chart, we display the project name alongside a colored circle, enhancing visual association and clarity.

import { HStack, VStack } from "@lib/ui/layout/Stack"
import { useCurrentProject } from "../components/ProjectView/CurrentProjectProvider"
import { Text } from "@lib/ui/text"
import { ProjectGoalChart } from "./PreviousWeeks/ProjectGoalChart"
import { useCurrentProjectPrevWeeks } from "../hooks/useCurrentProjectPrevWeeks"
import { ShyInfoBlock } from "@lib/ui/info/ShyInfoBlock"
import { previousWeeksConfig } from "./PreviousWeeks/previousWeeksConfig"
import { Circle } from "@lib/ui/layout/Circle"
import styled from "styled-components"
import { round } from "@lib/ui/css/round"
import { getColor } from "@lib/ui/theme/getters"

const Pill = styled.div`
  ${round};
  padding: 4px 8px;
  background: ${getColor("mist")};
`

export const ProjectPreviousWeeks = () => {
  const { name, hslaColor } = useCurrentProject()
  const weeks = useCurrentProjectPrevWeeks(previousWeeksConfig.weeks)

  return (
    <VStack gap={12}>
      {weeks.some((week) => week.seconds > 0) ? (
        <ProjectGoalChart value={weeks} />
      ) : (
        <ShyInfoBlock>No time tracked in the previous weeks</ShyInfoBlock>
      )}
      <HStack alignItems="center" justifyContent="center" fullWidth gap={6}>
        <Pill>
          <HStack alignItems="center" fullWidth gap={8}>
            <Circle size={8} background={hslaColor} />
            <Text size={14} weight="semibold">
              {name}
            </Text>
          </HStack>
        </Pill>
      </HStack>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

Our ProjectGoalChart component utilizes reusable components specifically designed for creating line charts. While we won't explore the specifics of each component in this article, you can find a comprehensive guide on how to construct line charts without relying on external charting libraries in this article.

import { ProjectWeek } from "@increaser/entities/timeTracking"
import { ComponentWithValueProps } from "@lib/ui/props"
import { useState } from "react"
import { useCurrentProject } from "../../components/ProjectView/CurrentProjectProvider"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { ElementSizeAware } from "@lib/ui/base/ElementSizeAware"
import { HStack, VStack } from "@lib/ui/layout/Stack"
import { Spacer } from "@lib/ui/layout/Spacer"
import { LineChartItemInfo } from "@lib/ui/charts/LineChart/LineChartItemInfo"
import { Text } from "@lib/ui/text"
import { EmphasizeNumbers } from "@lib/ui/text/EmphasizeNumbers"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { format } from "date-fns"
import { fromWeek } from "@lib/utils/time/Week"
import { ChartYAxis } from "@lib/ui/charts/ChartYAxis"
import { LineChart } from "@lib/ui/charts/LineChart"
import { LineChartPositionTracker } from "@lib/ui/charts/LineChart/LineChartPositionTracker"
import { PositionAbsolutelyCenterHorizontally } from "@lib/ui/layout/PositionAbsolutelyCenterHorizontally"
import { toPercents } from "@lib/utils/toPercents"
import styled from "styled-components"
import { getColor } from "@lib/ui/theme/getters"
import { normalizeDataArrays } from "@lib/utils/math/normalizeDataArrays"

export const lineChartConfig = {
  chartHeight: 80,
  expectedYAxisLabelWidth: 32,
  expectedLabelWidth: 58,
  expectedLabelHeight: 18,
  labelsMinDistance: 20,
}

const Line = styled.div`
  border-bottom: 1px dashed ${getColor("textShy")};
  width: 100%;
  pointer-events: none;
`

export const ProjectGoalChart = ({
  value,
}: ComponentWithValueProps<ProjectWeek[]>) => {
  const { allocatedMinutesPerWeek, hslaColor } = useCurrentProject()
  const targets = [convertDuration(allocatedMinutesPerWeek, "min", "s")]
  const normalized = normalizeDataArrays({
    done: value.map((week) => week.seconds),
    targets,
  })

  const [selectedDataPoint, setSelectedDataPoint] = useState<number>(
    value.length - 1
  )
  const [isSelectedDataPointVisible, setIsSelectedDataPointVisible] =
    useState<boolean>(false)

  const selectedDataPointStartedAt = fromWeek(value[selectedDataPoint])

  return (
    <ElementSizeAware
      render={({ setElement, size }) => {
        return (
          <VStack fullWidth gap={20} ref={setElement}>
            {size && (
              <>
                <HStack>
                  <Spacer width={lineChartConfig.expectedYAxisLabelWidth} />
                  <LineChartItemInfo
                    itemIndex={selectedDataPoint}
                    isVisible={isSelectedDataPointVisible}
                    containerWidth={
                      size.width - lineChartConfig.expectedYAxisLabelWidth
                    }
                    dataPointsNumber={value.length}
                  >
                    <VStack>
                      <Text color="contrast" weight="semibold">
                        <EmphasizeNumbers
                          value={formatDuration(
                            value[selectedDataPoint].seconds,
                            "s",
                            {
                              maxUnit: "h",
                            }
                          )}
                        />
                      </Text>
                      <Text color="supporting" size={14} weight="semibold">
                        {`${format(
                          selectedDataPointStartedAt,
                          "d MMM"
                        )} - ${format(
                          selectedDataPointStartedAt +
                            convertDuration(1, "w", "ms"),
                          "d MMM"
                        )}`}
                      </Text>
                    </VStack>
                  </LineChartItemInfo>
                </HStack>
                <HStack>
                  <ChartYAxis
                    expectedLabelWidth={lineChartConfig.expectedYAxisLabelWidth}
                    renderLabel={(index) => (
                      <Text key={index} size={12} color="supporting">
                        {formatDuration(targets[index], "s", {
                          maxUnit: "h",
                          minUnit: "h",
                        })}
                      </Text>
                    )}
                    data={normalized.targets}
                  />
                  <VStack
                    style={{
                      position: "relative",
                      minHeight: lineChartConfig.chartHeight,
                    }}
                    fullWidth
                  >
                    <PositionAbsolutelyCenterHorizontally
                      top={toPercents(1 - normalized.targets[0])}
                      fullWidth
                    >
                      <Line />
                    </PositionAbsolutelyCenterHorizontally>
                    <LineChart
                      dataPointsConnectionKind="sharp"
                      fillKind={"gradient"}
                      data={normalized.done}
                      width={
                        size.width - lineChartConfig.expectedYAxisLabelWidth
                      }
                      height={lineChartConfig.chartHeight}
                      color={hslaColor}
                    />
                    <LineChartPositionTracker
                      data={normalized.done}
                      color={hslaColor}
                      onChange={(index) => {
                        if (index === null) {
                          setIsSelectedDataPointVisible(false)
                        } else {
                          setIsSelectedDataPointVisible(true)
                          setSelectedDataPoint(index)
                        }
                      }}
                    />
                  </VStack>
                </HStack>
              </>
            )}
          </VStack>
        )
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Before rendering the chart and the line, it's crucial to normalize the data to ensure all data points fit within the range from 0 to 1, as required by our charting components. To accomplish this, we utilize the normalizeDataArrays utility from RadzionKit. We input the tracked time across previous weeks and an array of the budgeted time into this utility. To display the budget line accurately, we simply take the first element from the normalized target array and subtract it from 1 to determine the correct top attribute.

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

If a user wishes to determine precisely how much they worked on any specific week, they simply need to hover over the chart. The LineChartPositionTracker will activate isSelectedDataPointVisible, and the LineChartItemInfo will take the data point by index. It then converts it to a readable format using the formatDuration utility from RadzionKit, appending the week's start and end dates beneath the tracked time value.

Top comments (0)