DEV Community

Cover image for Creating an Interactive Time-Tracking Report with React and TypeScript
Radzion Chachura
Radzion Chachura

Posted on • Originally published at radzion.com

Creating an Interactive Time-Tracking Report with React and TypeScript

🐙 GitHub | 🎮 Demo

Building a Comprehensive Report for a Time-Tracking Application Using React, TypeScript, and CSS

In this article, we'll construct a stunning report featuring filters, a table, a pie chart, and a line chart for an existing time-tracking application, all without the use of component libraries. Utilizing React, TypeScript, and CSS, we'll develop reusable components that simplify the creation of complex UIs with minimal effort. Although the Increaser codebase is private, you can find all the reusable components and utilities in the RadzionKit repository.

Increaser Time Tracking

Time Tracking and Data Management in Increaser

At Increaser, users track their time by either starting a focus session or adding a manual entry. Each session is represented as an object with a start and end timestamp, along with a project ID.

export type Set = {
  start: number
  end: number
  projectId: string
}
Enter fullscreen mode Exit fullscreen mode

Our objective is to transform these sessions into a valuable report that aids users in understanding how they allocate their time over different periods. For instance, users might ask, "How much time am I dedicating to my remote job over the last eight months? Is the number of work hours increasing or decreasing? If I'm spending too much time on my job, what steps can I take to enhance my productivity and gain more free time?" Or, "How consistent am I in working on my business project? Am I investing 10 hours of quality work each week to advance it?"

Storing every session in the database would lead to an excessive amount of data. To manage this, Increaser retains only up to two months of session data at any given time. At the start of each month and week, the application analyzes all sessions to calculate the total time spent on each project during the previous period. These totals are then stored in the weeks or months arrays within the respective project's object. If no time has been tracked for a project during a specific period, there will be no update to its arrays. To represent a week or a month, we use a combination of the year and the week or month number.

export type Month = {
  year: number
  month: number
}

export type Week = {
  year: number
  week: number
}

export type EntityWithSeconds = {
  seconds: number
}

export type ProjectWeek = Week & EntityWithSeconds

export type ProjectMonth = Month & EntityWithSeconds
Enter fullscreen mode Exit fullscreen mode

Data Handling and User Preferences in Increaser's Front-End

Currently, Increaser's front-end receives all the user data in one go. Given the modest amount of data and a robust caching mechanism that makes previous visit data immediately available upon subsequent app launches, this approach is feasible. However, we will eventually need to segment the data, as the week and month data will accumulate years of information and increase in size.

Our report code should not be concerned with the concept of "Session" or the specific method of organizing data at the start of each week and month. Instead, it should receive a record of projects containing only the data necessary for the report.

The essential project data includes:

  • id: A unique identifier for the project.
  • hslaColor: The project's color in HSLA format. You can learn more about HSLA colors here.
  • name: The name of the project.
  • weeks, months, and days: Arrays of objects representing the total time tracked for the project during specific periods.

Additionally, in terms of data handling, we aim to introduce a feature that allows users to hide project names in the report. This way, they can share their workload without revealing the specific projects they are involved in.

To accomplish this, we'll introduce a top-level context called TrackedTimeContext that will store projects in our desired format and a preference for hiding project names. The mutable state, containing the user preference, will be maintained in a separate type called TrackedTimePreference. To maintain a flat structure for our context data, we'll place the preference fields alongside the project records. Additionally, we'll include a setState function in the context to enable consumers to update the preference.

import { EnhancedProject } from "@increaser/ui/projects/EnhancedProject"
import { createContextHook } from "@lib/ui/state/createContextHook"
import { Dispatch, SetStateAction, createContext } from "react"
import { ProjectDay } from "@increaser/entities/timeTracking"

export type TrackedTimePreference = {
  shouldHideProjectNames: boolean
}

export type TimeTrackingProjectData = Pick<
  EnhancedProject,
  "hslaColor" | "name" | "weeks" | "months" | "id"
> & {
  days: ProjectDay[]
}

type TrackedTimeState = TrackedTimePreference & {
  setState: Dispatch<SetStateAction<TrackedTimePreference>>
  projects: Record<string, TimeTrackingProjectData>
}

export const TrackedTimeContext = createContext<TrackedTimeState | undefined>(
  undefined
)

export const useTrackedTime = createContextHook(
  TrackedTimeContext,
  "useTrackedTime"
)
Enter fullscreen mode Exit fullscreen mode

For an improved user experience, it's advantageous to keep user preferences persistent. For preferences that are not highly critical, we can utilize local storage to store them. If you're interested in learning how local storage is used to maintain state across user sessions, you can explore my other article here.

import {
  PersistentStateKey,
  usePersistentState,
} from "../../state/persistentState"
import { TrackedTimePreference } from "./TrackedTimeContext"

export const useTrackedTimePreference = () => {
  return usePersistentState<TrackedTimePreference>(
    PersistentStateKey.TrackedTimeReportPreferences,
    {
      shouldHideProjectNames: false,
    }
  )
}
Enter fullscreen mode Exit fullscreen mode

Organizing Data with the TrackedTimeProvider

The TrackedTimeProvider will transform our raw data into organized buckets of days, weeks, and months.

import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import { pick } from "@lib/utils/record/pick"
import { useMemo } from "react"
import { areSameDay, toDay } from "@lib/utils/time/Day"
import { getSetDuration } from "@increaser/entities-utils/set/getSetDuration"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { ComponentWithChildrenProps } from "@lib/ui/props"
import { useStartOfWeek } from "@lib/ui/hooks/useStartOfWeek"
import { useStartOfMonth } from "@lib/ui/hooks/useStartOfMonth"
import { toWeek } from "@lib/utils/time/toWeek"
import { areSameWeek } from "@lib/utils/time/Week"
import { toMonth } from "@lib/utils/time/toMonth"
import { areSameMonth } from "@lib/utils/time/Month"
import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { useTrackedTimePreference } from "./useTrackedTimePreference"
import {
  TimeTrackingProjectData,
  TrackedTimeContext,
} from "./TrackedTimeContext"
import { hideProjectNames } from "./utils/hideProjectNames"
import { mergeTrackedDataPoint } from "./utils/mergeTrackedDataPoint"

export const TrackedTimeProvider = ({
  children,
}: ComponentWithChildrenProps) => {
  const { projects: allProjects } = useProjects()
  const { sets } = useAssertUserState()

  const weekStartedAt = useStartOfWeek()
  const monthStartedAt = useStartOfMonth()

  const [state, setState] = useTrackedTimePreference()
  const { shouldHideProjectNames } = state

  const projects = useMemo(() => {
    const result: Record<string, TimeTrackingProjectData> = {}

    allProjects.forEach((project) => {
      result[project.id] = {
        ...pick(project, ["id", "hslaColor", "name", "weeks", "months"]),
        days: [],
      }
    })

    sets.forEach((set) => {
      const project = result[set.projectId]

      if (!project) return

      const seconds = convertDuration(getSetDuration(set), "ms", "s")

      const day = toDay(set.start)

      project.days = mergeTrackedDataPoint({
        groups: project.days,
        dataPoint: {
          ...day,
          seconds,
        },
        areSameGroup: areSameDay,
      })

      if (set.start > weekStartedAt) {
        const week = toWeek(set.start)
        project.weeks = mergeTrackedDataPoint({
          groups: project.weeks,
          dataPoint: {
            ...week,
            seconds,
          },
          areSameGroup: areSameWeek,
        })
      }

      if (set.start > monthStartedAt) {
        const month = toMonth(set.start)
        project.months = mergeTrackedDataPoint({
          groups: project.months,
          dataPoint: {
            ...month,
            seconds,
          },
          areSameGroup: areSameMonth,
        })
      }
    })

    return shouldHideProjectNames ? hideProjectNames(result) : result
  }, [allProjects, monthStartedAt, sets, shouldHideProjectNames, weekStartedAt])

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

To allocate a session's duration to the appropriate bucket, we utilize the mergeTrackedDataPoint utility function. Although using classes to represent Week, Month, and Day objects could simplify comparison and merging operations, I generally avoid this for data originating from the server or being serialized for local storage. This is due to the constant need to convert the data back to class instances. Instead, I prefer employing plain objects and utility functions for management. In this scenario, as each group will possess a seconds field, we employ the EntityWithSeconds type to ensure the accuracy of the data type.

import { EntityWithSeconds } from "@increaser/entities/timeTracking"
import { updateAtIndex } from "@lib/utils/array/updateAtIndex"

type MergeTrackedDataPoint<T extends EntityWithSeconds> = {
  groups: T[]
  dataPoint: T
  areSameGroup: (a: T, b: T) => boolean
}

export const mergeTrackedDataPoint = <T extends EntityWithSeconds>({
  groups,
  dataPoint,
  areSameGroup,
}: MergeTrackedDataPoint<T>) => {
  const existingGroupIndex = groups.findIndex((item) =>
    areSameGroup(item, dataPoint)
  )
  if (existingGroupIndex > -1) {
    return updateAtIndex(groups, existingGroupIndex, (existingGroup) => ({
      ...existingGroup,
      seconds: existingGroup.seconds + dataPoint.seconds,
    }))
  } else {
    return [...groups, dataPoint]
  }
}
Enter fullscreen mode Exit fullscreen mode

To compare two periods, we verify if their descriptive fields are equal, such as the year and week fields for a week. To represent a period as a timestamp, we determine the time at which the period commenced. This can be easily achieved using date-fns helpers, as demonstrated in the fromWeek example. Additionally, we'll need to convert the timestamp back to the period format, as illustrated in the toWeek example.

import { haveEqualFields } from "../record/haveEqualFields"
import { getYear, setWeek, setYear } from "date-fns"
import { getWeekIndex } from "./getWeekIndex"
import { getWeekStartedAt } from "./getWeekStartedAt"

export type Week = {
  year: number
  // week index starts from 0
  week: number
}

export const areSameWeek = <T extends Week>(a: T, b: T): boolean =>
  haveEqualFields(["year", "week"], a, b)

export const toWeek = (timestamp: number): Week => {
  const weekStartedAt = getWeekStartedAt(timestamp)

  return {
    year: getYear(new Date(weekStartedAt)),
    week: getWeekIndex(weekStartedAt),
  }
}

export const fromWeek = ({ year, week }: Week): number => {
  let date = new Date(year, 0, 1)
  date = setWeek(date, week)
  date = setYear(date, year)

  return getWeekStartedAt(date.getTime())
}
Enter fullscreen mode Exit fullscreen mode

The hideProjectNames utility function will assign a unique name to each project based on the order of the project's total time tracked. To iterate over a record and return a new object with the same keys, we utilize the recordMap function from RadzionKit.

import { order } from "@lib/utils/array/order"
import { TimeTrackingProjectData } from "../TrackedTimeContext"
import { sum } from "@lib/utils/array/sum"
import { recordMap } from "@lib/utils/record/recordMap"

export const hideProjectNames = (
  projects: Record<string, TimeTrackingProjectData>
) => {
  const orderedProjects = order(
    Object.values(projects),
    (p) => sum(p.months.map((m) => m.seconds)),
    "desc"
  )

  return recordMap(projects, (project) => {
    const projectIndex = orderedProjects.findIndex((p) => p.id === project.id)
    const name = `Project #${projectIndex + 1}`

    return {
      ...project,
      name,
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

Implementing the TrackedTimeReportProvider for Enhanced Reporting

With the TrackedTimeProvider established, we can now access the project data and user preferences within our report components. Next, we require another provider to manage the report's filters and date range.

import { createContextHook } from "@lib/ui/state/createContextHook"
import { Dispatch, SetStateAction, createContext } from "react"
import { TimeFrame, TimeGrouping } from "./TimeGrouping"

export type ProjectsTimeSeries = Record<string, number[]>

type TrackedTimeReportPreferences = {
  activeProjectId: string | null
  timeGrouping: TimeGrouping
  includeCurrentPeriod: boolean
  timeFrame: TimeFrame
}

type TrackedTimeReportProviderState = TrackedTimeReportPreferences & {
  setState: Dispatch<SetStateAction<TrackedTimeReportPreferences>>
  projectsTimeSeries: ProjectsTimeSeries
  firstTimeGroupStartedAt: number
  lastTimeGroupStartedAt: number
}

export const TrackedTimeReportContext = createContext<
  TrackedTimeReportProviderState | undefined
>(undefined)

export const useTrackedTimeReport = createContextHook(
  TrackedTimeReportContext,
  "useTrackedTimeReport"
)
Enter fullscreen mode Exit fullscreen mode

When the user wants to highlight a specific project in the report, this will be reflected in the activeProjectId field. The timeGrouping field will determine how the data is grouped in the report, such as by day, week, or month. The includeCurrentPeriod field will allow users to decide whether to include the current period in the report. Since the current period may not have concluded yet, some users may prefer to exclude it. The timeFrame field will determine how many time groups are displayed in the report. Given that we maintain a limited amount of daily data, the maximum time frame for the day grouping will be 30 days, while for weeks and months it will be null, which translates to all available data.

Increaser report view with selected project

import { capitalizeFirstLetter } from "@lib/utils/capitalizeFirstLetter"

export const timeGroupings = ["day", "week", "month"] as const
export type TimeGrouping = (typeof timeGroupings)[number]

export const formatTimeGrouping = (grouping: TimeGrouping) =>
  `${capitalizeFirstLetter(grouping)}s`

export type TimeFrame = number | null

export const timeFrames: Record<TimeGrouping, TimeFrame[]> = {
  day: [7, 14, 30],
  week: [4, 8, 12, null],
  month: [4, 8, 12, null],
}
Enter fullscreen mode Exit fullscreen mode

Similar to the previous provider, we'll maintain the user preferences persistently using local storage. However, this time the hook will include additional logic for validating the active project ID. In the event that a project is deleted, the active project ID will be reset to null.

import {
  usePersistentState,
  PersistentStateKey,
} from "../../state/persistentState"
import { timeFrames } from "./TimeGrouping"
import { useTrackedTime } from "./TrackedTimeContext"
import { TrackedTimeReportState } from "./TrackedTimeReportState"
import { useEffect } from "react"

const defaultTimeGrouping = "week"

export const useTrackedTimeReportPreferences = () => {
  const [state, setState] = usePersistentState<TrackedTimeReportState>(
    PersistentStateKey.TrackedTimeReportPreferences,
    {
      activeProjectId: null,
      timeGrouping: defaultTimeGrouping,
      includeCurrentPeriod: false,
      timeFrame: timeFrames[defaultTimeGrouping][0],
    }
  )

  const { projects } = useTrackedTime()

  const hasWrongActiveProjectId =
    state.activeProjectId !== null && !projects[state.activeProjectId]

  useEffect(() => {
    if (hasWrongActiveProjectId) {
      setState((state) => ({ ...state, activeProjectId: null }))
    }
  }, [hasWrongActiveProjectId, setState])

  return [
    {
      ...state,
      activeProjectId: hasWrongActiveProjectId ? null : state.activeProjectId,
    },
    setState,
  ] as const
}
Enter fullscreen mode Exit fullscreen mode

In addition to user preferences, the TrackedTimeReportProvider will also provide a function to change the preferences and supply the data necessary for the report. As the project data is already organized in the previous provider, here we'll maintain an array of the total time tracked for each project based on the selected time grouping. To determine the first and last time groups, we'll utilize the firstTimeGroupStartedAt and lastTimeGroupStartedAt timestamps.

import { ComponentWithChildrenProps } from "@lib/ui/props"
import { useEffect, useMemo } from "react"
import { TimeGrouping, timeFrames } from "./TimeGrouping"
import {
  differenceInDays,
  differenceInMonths,
  differenceInWeeks,
} from "date-fns"
import { range } from "@lib/utils/array/range"
import { match } from "@lib/utils/match"
import { isEmpty } from "@lib/utils/array/isEmpty"
import { order } from "@lib/utils/array/order"
import { fromWeek, toWeek } from "@lib/utils/time/Week"
import { areSameWeek } from "@lib/utils/time/Week"
import { fromMonth, toMonth } from "@lib/utils/time/Month"
import { areSameMonth } from "@lib/utils/time/Month"
import { useTrackedTimeReportPreferences } from "./state/useTrackedTimeReportPreferences"
import { useTrackedTime } from "./state/TrackedTimeContext"
import { areSameDay, fromDay, toDay } from "@lib/utils/time/Day"
import { EntityWithSeconds } from "@increaser/entities/timeTracking"
import { TrackedTimeReportContext } from "./state/TrackedTimeReportContext"
import { useCurrentPeriodStartedAt } from "./hooks/useCurrentPeriodStartedAt"
import { subtractPeriod } from "./utils/subtractPeriod"
import { recordMap } from "@lib/utils/record/recordMap"

export const TrackedTimeReportProvider = ({
  children,
}: ComponentWithChildrenProps) => {
  const [state, setState] = useTrackedTimeReportPreferences()
  const { projects } = useTrackedTime()

  const { includeCurrentPeriod, timeFrame, timeGrouping } = state

  const currentPeriodStartedAt = useCurrentPeriodStartedAt(timeGrouping)

  const previousPeriodStartedAt = useMemo(
    () =>
      subtractPeriod({
        value: currentPeriodStartedAt,
        period: timeGrouping,
        amount: 1,
      }),
    [timeGrouping, currentPeriodStartedAt]
  )

  const firstTimeGroupStartedAt = useMemo(() => {
    const items = Object.values(projects).flatMap((project) =>
      match(timeGrouping, {
        day: () => project.days.map(fromDay),
        week: () => project.weeks.map(fromWeek),
        month: () => project.months.map(fromMonth),
      })
    )

    return isEmpty(items)
      ? currentPeriodStartedAt
      : order(items, (v) => v, "asc")[0]
  }, [currentPeriodStartedAt, projects, timeGrouping])

  const lastTimeGroupStartedAt = includeCurrentPeriod
    ? currentPeriodStartedAt
    : previousPeriodStartedAt

  const projectsTimeSeries = useMemo(() => {
    const totalDataPointsAvailable =
      match(timeGrouping, {
        day: () =>
          differenceInDays(lastTimeGroupStartedAt, firstTimeGroupStartedAt),
        week: () =>
          differenceInWeeks(lastTimeGroupStartedAt, firstTimeGroupStartedAt),
        month: () =>
          differenceInMonths(lastTimeGroupStartedAt, firstTimeGroupStartedAt),
      }) + 1

    const dataPointsCount =
      timeFrame === null
        ? totalDataPointsAvailable
        : Math.min(totalDataPointsAvailable, timeFrame)

    return recordMap(projects, ({ days, weeks, months }) =>
      range(dataPointsCount)
        .map((index) => {
          const startedAt = subtractPeriod({
            value: lastTimeGroupStartedAt,
            period: timeGrouping,
            amount: index,
          })

          return (
            match<TimeGrouping, EntityWithSeconds | undefined>(timeGrouping, {
              day: () => days.find((day) => areSameDay(day, toDay(startedAt))),
              week: () =>
                weeks.find((week) => areSameWeek(week, toWeek(startedAt))),
              month: () =>
                months.find((month) => areSameMonth(month, toMonth(startedAt))),
            })?.seconds || 0
          )
        })
        .reverse()
    )
  }, [
    firstTimeGroupStartedAt,
    lastTimeGroupStartedAt,
    projects,
    timeFrame,
    timeGrouping,
  ])

  useEffect(() => {
    if (!timeFrames[timeGrouping].includes(timeFrame)) {
      setState((state) => ({
        ...state,
        timeFrame: timeFrames[timeGrouping][0],
      }))
    }
  }, [setState, timeFrame, timeGrouping])

  return (
    <TrackedTimeReportContext.Provider
      value={{
        ...state,
        setState,
        projectsTimeSeries,
        firstTimeGroupStartedAt,
        lastTimeGroupStartedAt,
      }}
    >
      {children}
    </TrackedTimeReportContext.Provider>
  )
}
Enter fullscreen mode Exit fullscreen mode

Our primary goal in the TrackedTimeReportProvider is to construct projectsTimeSeries, an object that contains the total time tracked for each project based on the selected time grouping. To achieve this, we first need to find the timestamp of the current period using the helper hook useCurrentPeriodStartedAt. Although it could have been a helper function, we've chosen to keep it as a hook in case we want to be aware of real-time changes to the current period in the future.

import { useMemo } from "react"
import { TimeGrouping } from "../TimeGrouping"
import { getWeekStartedAt } from "@lib/utils/time/getWeekStartedAt"
import { startOfDay, startOfMonth } from "date-fns"
import { match } from "@lib/utils/match"

export const useCurrentPeriodStartedAt = (group: TimeGrouping) => {
  return useMemo(() => {
    const now = new Date()

    return match(group, {
      day: () => startOfDay(now).getTime(),
      week: () => getWeekStartedAt(now.getTime()),
      month: () => startOfMonth(now).getTime(),
    })
  }, [group])
}
Enter fullscreen mode Exit fullscreen mode

To determine the timestamp of the previous period, we'll employ the subtractPeriod utility function, which subtracts one period from the current period's timestamp. For days and weeks, we use the convertDuration utility from RadzionKit. However, for months, we'll utilize the subMonths function from date-fns due to the variable duration of months.

import { match } from "@lib/utils/match"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { subMonths } from "date-fns"
import { TimeGrouping } from "../TimeGrouping"

type SubtractPeriodInpu = {
  value: number
  period: TimeGrouping
  amount: number
}

export const subtractPeriod = ({
  value,
  period,
  amount,
}: SubtractPeriodInpu) => {
  return match(period, {
    day: () => value - convertDuration(amount, "d", "ms"),
    week: () => value - convertDuration(amount, "w", "ms"),
    month: () => subMonths(value, amount).getTime(),
  })
}
Enter fullscreen mode Exit fullscreen mode

Recall the includeCurrentPeriod user preference? Based on this, we will determine the lastTimeGroupStartedAt. To find the firstTimeGroupStartedAt, we'll iterate over all projects to identify the earliest time group. These two timestamps will define the range of the report. To ascertain the exact number of data points to display, we will calculate the difference between the firstTimeGroupStartedAt and lastTimeGroupStartedAt in weeks, days, or months, depending on the selected time grouping. If the timeFrame is set to null, we'll display all available data points. Otherwise, we'll show the lesser of the timeFrame and the total data points available.

To construct projectsTimeSeries, we'll iterate over all projects and generate an array of the total time tracked for each project based on the selected time grouping. We'll reverse the array to display the most recent data first. If a data point is missing, we'll default to 0. To iterate over the record, we'll use the recordMap function from RadzionKit.

export const recordMap = <K extends string | number, T, V>(
  record: Record<K, T>,
  fn: (value: T) => V
): Record<K, V> => {
  return Object.fromEntries(
    Object.entries(record).map(([key, value]) => [key, fn(value as T)])
  ) as Record<K, V>
}
Enter fullscreen mode Exit fullscreen mode

Finally, we'll validate the timeFrame preference within the useEffect hook to ensure it falls within the available range. If the user changes the time group and the selected timeFrame is not available in the new group—for example, the "All time" option is unavailable in the days time group—we'll reset it to the first available value.

Designing the Report Layout and Filters for Responsiveness

Now that both providers are established, we can implement the report itself. The root component will comprise a header and a content section. The header will display the report title and filters, while the content will house the report data. To conserve space on smaller screens, we'll avoid wrapping the content in a panel. For this responsive design, we'll utilize the BasedOnScreenWidth component from RadzionKit.

import { VStack } from "@lib/ui/layout/Stack"
import { Panel } from "@lib/ui/panel/Panel"
import { TrackedTimeReportHeader } from "./TrackedTimeReportHeader"
import { BasedOnScreenWidth } from "@lib/ui/layout/BasedOnScreenWidth"
import { TrackedTimeReportContent } from "./TrackedTimeReportContent"

export const TrackedTimeReport = () => {
  return (
    <VStack gap={16}>
      <TrackedTimeReportHeader />
      <BasedOnScreenWidth
        value={600}
        more={() => (
          <Panel kind="secondary">
            <TrackedTimeReportContent />
          </Panel>
        )}
        less={() => <TrackedTimeReportContent />}
      />
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

To ensure the filters are comfortably displayed on various screen sizes, we utilize two components for responsive design from RadzionKit:

  • ElementSizeAware to determine the width of the parent element.
  • BasedOnNumber is a simple helper component that I prefer for better readability.
import { ElementSizeAware } from "@lib/ui/base/ElementSizeAware"
import { BasedOnNumber } from "@lib/ui/layout/BasedOnNumber"
import { HStack, VStack } from "@lib/ui/layout/Stack"
import { TrackedTimeReportTitle } from "./TrackedTimeReportTitle"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { ReportFilters } from "./ReportFilters"
import { ManageProjectsNamesVisibility } from "./ManageProjectsNamesVisibility"
import styled from "styled-components"

const FiltersRow = styled.div`
  display: grid;
  grid-template-columns: repeat(auto-fill, 180px);
  gap: 8px;
  flex: 1;
  justify-content: end;
`

export const TrackedTimeReportHeader = () => {
  return (
    <ElementSizeAware
      render={({ setElement, size }) => (
        <VStack ref={setElement} fullWidth>
          {size && (
            <BasedOnNumber
              value={size.width}
              compareTo={800}
              lessOrEqual={() => (
                <VStack gap={16}>
                  <HStack
                    justifyContent="space-between"
                    alignItems="center"
                    fullWidth
                  >
                    <TrackedTimeReportTitle />
                    <ManageProjectsNamesVisibility />
                  </HStack>
                  <BasedOnNumber
                    value={size.width}
                    compareTo={600}
                    more={() => (
                      <UniformColumnGrid gap={8} fullWidth>
                        <ReportFilters />
                      </UniformColumnGrid>
                    )}
                    lessOrEqual={() => (
                      <VStack gap={8}>
                        <ReportFilters />
                      </VStack>
                    )}
                  />
                </VStack>
              )}
              more={() => (
                <HStack alignItems="center" fullWidth gap={8}>
                  <HStack
                    justifyContent="space-between"
                    alignItems="center"
                    gap={20}
                    style={{ flex: 1 }}
                  >
                    <TrackedTimeReportTitle />
                    <FiltersRow>
                      <ReportFilters />
                    </FiltersRow>
                  </HStack>
                  <ManageProjectsNamesVisibility />
                </HStack>
              )}
            />
          )}
        </VStack>
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

To inform the user about the number of actual data points displayed in the report, we'll incorporate the TrackedTimeReportTitle component. This component will show the number of data points based on the selected time grouping. If no data is available, it will display a message indicating the absence of data.

import { Text } from "@lib/ui/text"
import { useTrackedTimeReport } from "./state/TrackedTimeReportContext"
import { isEmpty } from "@lib/utils/array/isEmpty"
import { getRecordKeys } from "@lib/utils/record/getRecordKeys"
import { pluralize } from "@lib/utils/pluralize"

export const TrackedTimeReportTitle = () => {
  const { timeGrouping, projectsTimeSeries } = useTrackedTimeReport()

  return (
    <Text weight="semibold" color="contrast">
      {isEmpty(getRecordKeys(projectsTimeSeries))
        ? "No data available"
        : `Last ${pluralize(
            Object.values(projectsTimeSeries)[0].length,
            timeGrouping
          )} report`}
    </Text>
  )
}
Enter fullscreen mode Exit fullscreen mode

Implementing Interactive Filters for the Report

For both the TimeGroupingSelector and TimeFrameSelector, we are using the ExpandableSelector component, which is very handy for these types of filters. We retrieve the preference from the tracked time report context, and when the user changes the preference, we update the context state through the setState function.

import { ExpandableSelector } from "@lib/ui/select/ExpandableSelector"
import { useTrackedTimeReport } from "./state/TrackedTimeReportContext"
import { Text } from "@lib/ui/text"
import { formatTimeGrouping, timeGroupings } from "./TimeGrouping"

export const TimeGroupingSelector = () => {
  const { timeGrouping, setState } = useTrackedTimeReport()

  return (
    <ExpandableSelector
      value={timeGrouping}
      onChange={(timeGrouping) =>
        setState((state) => ({ ...state, timeGrouping }))
      }
      options={timeGroupings}
      getOptionKey={formatTimeGrouping}
      renderOption={(option) => <Text>{formatTimeGrouping(option)}</Text>}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

To make the IncludeCurrentPeriodSelector resemble the ExpandableSelector, we'll use the SelectContainer component from RadzionKit, which is also utilized in the ExpandableSelector. To signify that the current period is included, we'll display a round checkmark that will change color based on the isActive prop.

import { useTrackedTimeReport } from "./state/TrackedTimeReportContext"
import { TimeGrouping } from "./TimeGrouping"
import styled from "styled-components"
import { getColor, matchColor } from "@lib/ui/theme/getters"
import { HStack } from "@lib/ui/layout/Stack"
import { interactive } from "@lib/ui/css/interactive"
import { getHoverVariant } from "@lib/ui/theme/getHoverVariant"
import { round } from "@lib/ui/css/round"
import { sameDimensions } from "@lib/ui/css/sameDimensions"
import { Text } from "@lib/ui/text"
import { SelectContainer } from "@lib/ui/select/SelectContainer"

const currentPeriodName: Record<TimeGrouping, string> = {
  day: "today",
  week: "this week",
  month: "this month",
}

const Container = styled(SelectContainer)`
  ${interactive};

  &:hover {
    background: ${getHoverVariant("foreground")};
  }
`

const Check = styled.div<{ isActive?: boolean }>`
  ${round};
  border: 1px solid ${getColor("textShy")};
  background: ${matchColor("isActive", {
    true: "primary",
    false: "mist",
  })};
  ${sameDimensions(16)};
`

export const IncludeCurrentPeriodSelector = () => {
  const { includeCurrentPeriod, setState, timeGrouping } =
    useTrackedTimeReport()

  return (
    <Container
      onClick={() =>
        setState((state) => ({
          ...state,
          includeCurrentPeriod: !includeCurrentPeriod,
        }))
      }
    >
      <HStack fullWidth alignItems="center" justifyContent="space-between">
        <Text>Include {currentPeriodName[timeGrouping]}</Text>
        <Check isActive={includeCurrentPeriod} />
      </HStack>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Finally, we have the ManageProjectsNamesVisibility component. Here, we use a combination of the IconButton and Tooltip components from RadzionKit. To indicate the selected value, we will use either the EyeIcon or EyeOffIcon. To ensure the button is the same size as the other filters, we will use the sameDimensions CSS utility function with the selectContainerMinHeight value.

import { IconButton } from "@lib/ui/buttons/IconButton"
import { useTrackedTime } from "./state/TrackedTimeContext"
import { EyeOffIcon } from "@lib/ui/icons/EyeOffIcon"
import { EyeIcon } from "@lib/ui/icons/EyeIcon"
import { Tooltip } from "@lib/ui/tooltips/Tooltip"
import { sameDimensions } from "@lib/ui/css/sameDimensions"
import styled from "styled-components"
import { selectContainerMinHeight } from "@lib/ui/select/SelectContainer"

const Container = styled(IconButton)`
  ${sameDimensions(selectContainerMinHeight)}
`

export const ManageProjectsNamesVisibility = () => {
  const { shouldHideProjectNames, setState } = useTrackedTime()

  const title = shouldHideProjectNames
    ? "Show project names"
    : "Hide project names"

  return (
    <Tooltip
      content={title}
      renderOpener={(props) => (
        <div {...props}>
          <Container
            title={title}
            onClick={() =>
              setState((state) => ({
                ...state,
                shouldHideProjectNames: !state.shouldHideProjectNames,
              }))
            }
            icon={shouldHideProjectNames ? <EyeOffIcon /> : <EyeIcon />}
          />
        </div>
      )}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

Structuring the Report Content with Key Components

The report content comprises three primary components: ProjectsDistributionBreakdown, ProjectsDistributionChart, and TimeChart. However, to display the first two components, we need to ensure that the user has projects and has tracked some time within the selected period.

import { HStack, VStack } from "@lib/ui/layout/Stack"
import { ProjectsDistributionChart } from "./ProjectsDistributionChart"
import { ProjectsDistributionBreakdown } from "./ProjectsDistributionBreakdown"
import { RequiresTrackedTime } from "./RequiresTrackedTime"
import { RequiresTwoDataPoints } from "./RequiresTwoDataPoints"
import { RequiresProjects } from "./RequiresProjects"
import { ProjectsTimeSeriesChart } from "./ProjectsTimeSeriesChart/ProjectsTimeSeriesChart"

export const TrackedTimeReportContent = () => (
  <VStack gap={20}>
    <RequiresProjects>
      <RequiresTrackedTime>
        <HStack
          justifyContent="space-between"
          gap={40}
          fullWidth
          wrap="wrap"
          alignItems="center"
        >
          <ProjectsDistributionBreakdown />
          <VStack
            style={{ flex: 1 }}
            fullHeight
            justifyContent="center"
            alignItems="center"
          >
            <ProjectsDistributionChart />
          </VStack>
        </HStack>
        <RequiresTwoDataPoints>
          <ProjectsTimeSeriesChart />
        </RequiresTwoDataPoints>
      </RequiresTrackedTime>
    </RequiresProjects>
  </VStack>
)
Enter fullscreen mode Exit fullscreen mode

The RequiresProjects component will check if the projectsTimeSeries object is empty. If it is, the component will display a message indicating that the user should create projects and track time to see the report.

import { useTrackedTimeReport } from "./state/TrackedTimeReportContext"
import { ComponentWithChildrenProps } from "@lib/ui/props"
import { ShyInfoBlock } from "@lib/ui/info/ShyInfoBlock"
import { isEmpty } from "@lib/utils/array/isEmpty"
import { getRecordKeys } from "@lib/utils/record/getRecordKeys"

export const RequiresProjects = ({ children }: ComponentWithChildrenProps) => {
  const { projectsTimeSeries } = useTrackedTimeReport()

  const hasData = !isEmpty(getRecordKeys(projectsTimeSeries))

  if (hasData) {
    return <>{children}</>
  }

  return (
    <ShyInfoBlock>
      Create projects and track time to see the report.
    </ShyInfoBlock>
  )
}
Enter fullscreen mode Exit fullscreen mode

The RequiresTrackedTime component will take the active time series and will check that at least one data point is greater than zero. If there is no tracked time for the selected period, the component will display a message indicating this.

import { ComponentWithChildrenProps } from "@lib/ui/props"
import { ShyInfoBlock } from "@lib/ui/info/ShyInfoBlock"
import { useActiveTimeSeries } from "./hooks/useActiveTimeSeries"

export const RequiresTrackedTime = ({
  children,
}: ComponentWithChildrenProps) => {
  const totals = useActiveTimeSeries()

  const hasData = totals.some((total) => total > 0)

  if (hasData) {
    return <>{children}</>
  }

  return (
    <ShyInfoBlock>
      There is no tracked time for the selected period.
    </ShyInfoBlock>
  )
}
Enter fullscreen mode Exit fullscreen mode

The active time series will either be the time series of the active project or the merged time series of all projects. To combine the data arrays, we'll use the mergeSameSizeDataArrays utility from RadzionKit.

import { mergeSameSizeDataArrays } from "@lib/utils/math/mergeSameSizeDataArrays"
import { useMemo } from "react"
import { useTrackedTimeReport } from "../state/TrackedTimeReportContext"

export const useActiveTimeSeries = () => {
  const { projectsTimeSeries, activeProjectId } = useTrackedTimeReport()

  return useMemo(() => {
    if (activeProjectId) {
      return projectsTimeSeries[activeProjectId]
    }

    return mergeSameSizeDataArrays(Object.values(projectsTimeSeries))
  }, [activeProjectId, projectsTimeSeries])
}
Enter fullscreen mode Exit fullscreen mode

We also need to wrap our TimeChart component in a similar component, as we need at least two points to display a line chart.

import { useTrackedTimeReport } from "./state/TrackedTimeReportContext"
import { ComponentWithChildrenProps } from "@lib/ui/props"
import { ShyInfoBlock } from "@lib/ui/info/ShyInfoBlock"

export const RequiresTwoDataPoints = ({
  children,
}: ComponentWithChildrenProps) => {
  const { projectsTimeSeries, timeGrouping } = useTrackedTimeReport()

  const hasTwoDataPoints = Object.values(projectsTimeSeries)[0].length > 1

  if (hasTwoDataPoints) {
    return <>{children}</>
  }

  return (
    <ShyInfoBlock>
      You'll gain access to the chart after tracking time for at least two{" "}
      {timeGrouping}s.
    </ShyInfoBlock>
  )
}
Enter fullscreen mode Exit fullscreen mode

Designing the Projects Distribution Breakdown Component

In the ProjectsDistributionBreakdown component, we display a list of projects along with their total time tracked during the period, average per day, week, or month, and the percentage of the total time tracked. At the bottom of the list, we display the sum of all projects.

import React from "react"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { sum } from "@lib/utils/array/sum"
import { useTheme } from "styled-components"
import { Text } from "@lib/ui/text"
import { useTrackedTimeReport } from "../state/TrackedTimeReportContext"
import { SeparatedByLine } from "@lib/ui/layout/SeparatedByLine"
import { VStack } from "@lib/ui/layout/Stack"
import { toPercents } from "@lib/utils/toPercents"
import { useTrackedTime } from "../state/TrackedTimeContext"
import { useOrderedTimeSeries } from "../hooks/useOrderedTimeSeries"
import { useCurrentFrameTotalTracked } from "../hooks/useCurrentFrameTotalTracked"
import { BreakdownContainer } from "./BreakdownContainer"
import { InteractiveRow } from "./InteractiveRow"
import { BreakdownRowContent } from "./BreakdownRowContent"
import { ProjectIndicator } from "./ProjectIndicator"
import { BreakdownHeader } from "./BreakdownHeader"
import { BreakdownValue } from "./BreakdownValue"

export const ProjectsDistributionBreakdown = () => {
  const { projects } = useTrackedTime()
  const { projectsTimeSeries, activeProjectId, setState } =
    useTrackedTimeReport()

  const { colors } = useTheme()

  const items = useOrderedTimeSeries()

  const total = useCurrentFrameTotalTracked()

  return (
    <BreakdownContainer>
      <BreakdownHeader />
      <SeparatedByLine alignItems="start" fullWidth gap={12}>
        <VStack gap={2}>
          {items.map(({ id, data }) => {
            const seconds = sum(data)
            const isPrimary = !activeProjectId || activeProjectId === id
            return (
              <InteractiveRow
                onClick={() =>
                  setState((state) => ({
                    ...state,
                    activeProjectId: id,
                  }))
                }
                isActive={activeProjectId === id}
              >
                <BreakdownRowContent key={id}>
                  <ProjectIndicator
                    style={{
                      background: (isPrimary
                        ? projects[id].hslaColor
                        : colors.mist
                      ).toCssValue(),
                    }}
                  />
                  <Text cropped>{projects[id].name}</Text>
                  <BreakdownValue
                    value={formatDuration(seconds, "s", {
                      maxUnit: "h",
                    })}
                  />
                  <BreakdownValue
                    value={formatDuration(seconds / data.length, "s", {
                      maxUnit: "h",
                      kind: "short",
                    })}
                  />
                  <BreakdownValue
                    value={toPercents(seconds / total, "round")}
                  />
                </BreakdownRowContent>
              </InteractiveRow>
            )
          })}
        </VStack>
        <InteractiveRow
          onClick={() =>
            setState((state) => ({
              ...state,
              activeProjectId: null,
            }))
          }
          isActive={!activeProjectId}
        >
          <BreakdownRowContent>
            <div />
            <Text>All projects</Text>
            <BreakdownValue
              value={formatDuration(total, "s", {
                maxUnit: "h",
              })}
            />
            <BreakdownValue
              value={formatDuration(
                total / Object.values(projectsTimeSeries)[0].length,
                "s",
                {
                  maxUnit: "h",
                }
              )}
            />
            <BreakdownValue value="100%" />
          </BreakdownRowContent>
        </InteractiveRow>
      </SeparatedByLine>
    </BreakdownContainer>
  )
}
Enter fullscreen mode Exit fullscreen mode

To align the header and value, we display each row within the BreakdownRowContent, which arranges the content in a grid. We allocate 8 px for the project indicator circle, 120 px for the project name, and 92 px for the remaining columns. The last three columns are aligned to the end of the row.

import { horizontalPadding } from "@lib/ui/css/horizontalPadding"
import { verticalPadding } from "@lib/ui/css/verticalPadding"
import styled from "styled-components"

export const BreakdownRowContent = styled.div`
  display: grid;
  grid-gap: 8px;
  grid-template-columns: 8px 120px repeat(3, 92px);
  align-items: center;
  font-size: 14px;
  ${verticalPadding(6)};
  ${horizontalPadding(8)};

  > * {
    &:last-child,
    &:nth-last-child(2),
    &:nth-last-child(3) {
      justify-self: end;
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

Therefore, for example, we don't need any additional styling to arrange elements in the header. The only customization required is changing the color to textShy, as we don't want the header to be as prominent as the content.

import { Text } from "@lib/ui/text"
import { BreakdownRowContent } from "./BreakdownRowContent"
import { useTrackedTimeReport } from "../state/TrackedTimeReportContext"
import styled from "styled-components"
import { getColor } from "@lib/ui/theme/getters"

const Container = styled(BreakdownRowContent)`
  color: ${getColor("textShy")};
`

export const BreakdownHeader = () => {
  const { timeGrouping } = useTrackedTimeReport()

  return (
    <Container>
      <div />
      <Text>Project</Text>
      <Text>Total</Text>
      <Text>Avg. {timeGrouping}</Text>
      <Text>Share</Text>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Enhancing Functionality with Sorting and Interactive Features

Before listing the projects, we want to sort them by the total time tracked. Since we'll also need this ordering for the pie chart, we'll create a small helper hook called useOrderedTimeSeries.

import { order } from "@lib/utils/array/order"
import { useTrackedTimeReport } from "../state/TrackedTimeReportContext"
import { sum } from "@lib/utils/array/sum"
import { useMemo } from "react"

export const useOrderedTimeSeries = () => {
  const { projectsTimeSeries } = useTrackedTimeReport()

  return useMemo(
    () =>
      order(Object.entries(projectsTimeSeries), ([, data]) => sum(data), "desc")
        .filter(([, data]) => sum(data) > 0)
        .map(([id, data]) => ({
          id,
          data,
        })),
    [projectsTimeSeries]
  )
}
Enter fullscreen mode Exit fullscreen mode

For the Share column of our table, we need to know the total time tracked across all projects. We can calculate this using the useCurrentFrameTotalTracked hook.

import { sum } from "@lib/utils/array/sum"
import { useOrderedTimeSeries } from "./useOrderedTimeSeries"

export const useCurrentFrameTotalTracked = () => {
  const timeseries = useOrderedTimeSeries()

  return sum(timeseries.flatMap(({ data }) => data))
}
Enter fullscreen mode Exit fullscreen mode

Since we want to allow the user to highlight a specific project, we wrap every row in the InteractiveRow component. When the user clicks on a row, we update the active project ID in the context state. If the user clicks on the "All projects" row, we reset the active project ID to null.

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

export const InteractiveRow = styled.div<{ isActive: boolean }>`
  ${transition}
  ${interactive}
  ${borderRadius.s};

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

To format durations, we'll use the formatDuration utility, and for formatting percentages, we'll use the toPercents utility. Both utilities are available in RadzionKit. To emphasize the numeric portions of values in our table, we use the EmphasizeNumbers function. It will split the string into parts and make the non-numeric parts smaller and thinner.

import { ComponentWithValueProps } from "@lib/ui/props"
import { CSSProperties, Fragment } from "react"
import { Text } from "."

function parseString(input: string): (string | number)[] {
  const regex = /(\d+|\D+)/g
  const matches = input.match(regex)
  if (!matches) {
    return []
  }
  return matches.map((match) => {
    return isNaN(parseInt(match)) ? match : parseInt(match)
  })
}

export const EmphasizeNumbers = ({
  value,
}: ComponentWithValueProps<string>) => {
  const parts = parseString(value)

  return (
    <>
      {parts.map((part, index) => {
        if (typeof part === "number") {
          return <Fragment key={index}>{part}</Fragment>
        }

        const style: CSSProperties = {
          fontSize: "0.8em",
          marginLeft: "0.1em",
        }

        if (index !== parts.length - 1) {
          style.marginRight = "0.4em"
        }

        return (
          <Text weight="regular" style={style} as="span" key={index}>
            {part}
          </Text>
        )
      })}
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Incorporating a Minimalistic Pie Chart for Visual Representation

Next to the breakdown, we display a pie chart. Here, we also use the useOrderedTimeSeries hook to sort the projects by the total time tracked. We then map the sorted projects to the pie chart data structure, which consists of the value and color of each segment. Since all the information is already displayed in the breakdown, we aim to keep the pie chart simple by using its light version, called MinimalisticPieChart. To learn more about its implementation, you can check out this article.

import { useTrackedTimeReport } from "./state/TrackedTimeReportContext"
import styled, { useTheme } from "styled-components"
import { sum } from "@lib/utils/array/sum"
import { VStack } from "@lib/ui/layout/Stack"
import { MinimalisticPieChart } from "@lib/ui/charts/PieChart/MinimalisticPieChart"
import { useTrackedTime } from "./state/TrackedTimeContext"
import { useOrderedTimeSeries } from "./hooks/useOrderedTimeSeries"
import { sameDimensions } from "@lib/ui/css/sameDimensions"

const Container = styled(VStack)`
  ${sameDimensions(200)}
`

export const ProjectsDistributionChart = () => {
  const { activeProjectId } = useTrackedTimeReport()
  const { colors } = useTheme()
  const { projects } = useTrackedTime()

  const items = useOrderedTimeSeries()

  return (
    <Container>
      <MinimalisticPieChart
        value={items.map(({ id, data }) => {
          const seconds = sum(data)
          const shouldShow = !activeProjectId || activeProjectId === id
          return {
            value: seconds,
            color: shouldShow ? projects[id].hslaColor : colors.mist,
            labelColor: shouldShow ? colors.contrast : colors.transparent,
          }
        })}
      />
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

Implementing the ProjectTimeSeriesChart for Detailed Visualization

The final piece of our report is the ProjectTimeSeriesChart. Instead of using a single component that handles everything, we rely on a number of smaller chart components. While this approach may result in more code, it allows for greater flexibility.

import { HStack, VStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import { useTheme } from "styled-components"
import { useTrackedTimeReport } from "../state/TrackedTimeReportContext"
import { useMemo, useState } from "react"
import { addMonths, format } from "date-fns"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { ElementSizeAware } from "@lib/ui/base/ElementSizeAware"
import { normalize } from "@lib/utils/math/normalize"
import { LineChartItemInfo } from "@lib/ui/charts/LineChart/LineChartItemInfo"
import { ChartXAxis } from "@lib/ui/charts/ChartXAxis"
import { LineChartPositionTracker } from "@lib/ui/charts/LineChart/LineChartPositionTracker"
import { match } from "@lib/utils/match"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { EmphasizeNumbers } from "@lib/ui/text/EmphasizeNumbers"
import { ChartYAxis } from "@lib/ui/charts/ChartYAxis"
import { Spacer } from "@lib/ui/layout/Spacer"
import { ChartHorizontalGridLines } from "@lib/ui/charts/ChartHorizontalGridLines"
import { lineChartConfig } from "./lineChartConfig"
import { ProjectsLineCharts } from "./ProjectsLineCharts"
import { useTrackedTime } from "../state/TrackedTimeContext"
import { useActiveTimeSeries } from "../hooks/useActiveTimeSeries"

export const ProjectsTimeSeriesChart = () => {
  const { firstTimeGroupStartedAt, timeGrouping, activeProjectId } =
    useTrackedTimeReport()

  const { projects } = useTrackedTime()

  const totals = useActiveTimeSeries()

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

  const { colors } = useTheme()
  const color = activeProjectId
    ? projects[activeProjectId].hslaColor
    : colors.primary

  const getDataPointStartedAt = (index: number) =>
    match(timeGrouping, {
      day: () => firstTimeGroupStartedAt + convertDuration(index, "d", "ms"),
      week: () => firstTimeGroupStartedAt + convertDuration(index, "w", "ms"),
      month: () => addMonths(firstTimeGroupStartedAt, index).getTime(),
    })

  const selectedDataPointStartedAt = getDataPointStartedAt(selectedDataPoint)

  const [chartMinValue, chartMaxValue] = useMemo(() => {
    const minValue = Math.min(...totals)
    const maxValue = Math.max(...totals)

    return [
      Math.floor(convertDuration(minValue, "s", "h")),
      Math.ceil(convertDuration(maxValue, "s", "h")),
    ].map((value) => convertDuration(value, "h", "s"))
  }, [totals])

  return (
    <ElementSizeAware
      render={({ setElement, size }) => {
        const data = normalize([...totals, chartMinValue, chartMaxValue]).slice(
          0,
          -2
        )

        const yLabels = [chartMinValue, chartMaxValue]
        const yLabelsData = normalize([chartMinValue, chartMaxValue])

        return (
          <VStack fullWidth gap={20} ref={setElement}>
            {size && (
              <>
                <HStack>
                  <Spacer width={lineChartConfig.expectedYAxisLabelWidth} />
                  <LineChartItemInfo
                    itemIndex={selectedDataPoint}
                    isVisible={isSelectedDataPointVisible}
                    containerWidth={size.width}
                    data={data}
                  >
                    <VStack>
                      <Text color="contrast" weight="semibold">
                        <EmphasizeNumbers
                          value={formatDuration(
                            totals[selectedDataPoint],
                            "s",
                            {
                              maxUnit: "h",
                            }
                          )}
                        />
                      </Text>
                      <Text color="supporting" size={14} weight="semibold">
                        {match(timeGrouping, {
                          day: () =>
                            format(
                              selectedDataPointStartedAt,
                              "EEE d, MMM yyyy"
                            ),
                          week: () =>
                            `${format(
                              selectedDataPointStartedAt,
                              "d MMM"
                            )} - ${format(
                              selectedDataPointStartedAt +
                                convertDuration(1, "w", "ms"),
                              "d MMM"
                            )}`,
                          month: () =>
                            format(selectedDataPointStartedAt, "MMMM yyyy"),
                        })}
                      </Text>
                    </VStack>
                  </LineChartItemInfo>
                </HStack>
                <HStack>
                  <ChartYAxis
                    expectedLabelWidth={lineChartConfig.expectedYAxisLabelWidth}
                    renderLabel={(index) => (
                      <Text key={index} size={12} color="supporting">
                        {formatDuration(yLabels[index], "s", {
                          maxUnit: "h",
                          minUnit: "h",
                        })}
                      </Text>
                    )}
                    data={yLabelsData}
                  />
                  <VStack
                    style={{
                      position: "relative",
                      minHeight: lineChartConfig.chartHeight,
                    }}
                    fullWidth
                  >
                    <ChartHorizontalGridLines data={yLabelsData} />
                    <ProjectsLineCharts
                      chartMin={chartMinValue}
                      chartMax={chartMaxValue}
                      width={
                        size.width - lineChartConfig.expectedYAxisLabelWidth
                      }
                    />
                    <LineChartPositionTracker
                      data={data}
                      color={color}
                      onChange={(index) => {
                        if (index === null) {
                          setIsSelectedDataPointVisible(false)
                        } else {
                          setIsSelectedDataPointVisible(true)
                          setSelectedDataPoint(index)
                        }
                      }}
                    />
                  </VStack>
                </HStack>

                <HStack>
                  <Spacer width={lineChartConfig.expectedYAxisLabelWidth} />
                  <ChartXAxis
                    data={data}
                    expectedLabelWidth={lineChartConfig.expectedLabelWidth}
                    labelsMinDistance={lineChartConfig.labelsMinDistance}
                    containerWidth={
                      size.width - lineChartConfig.expectedYAxisLabelWidth
                    }
                    expectedLabelHeight={lineChartConfig.expectedLabelHeight}
                    renderLabel={(index) => {
                      const startedAt = getDataPointStartedAt(index)

                      return (
                        <Text size={12} color="supporting" nowrap>
                          {format(startedAt, "d MMM")}
                        </Text>
                      )
                    }}
                  />
                </HStack>
              </>
            )}
          </VStack>
        )
      }}
    />
  )
}
Enter fullscreen mode Exit fullscreen mode

We won't delve into detail on every component that makes this chart work, as I have an article that explains how to create a line chart from scratch without using any component libraries. The main enhancement to the existing LineChart implementation for this report is the ability to display a stacked area chart.

import { useMemo } from "react"
import { useTrackedTimeReport } from "../state/TrackedTimeReportContext"
import { sum } from "@lib/utils/array/sum"
import { order } from "@lib/utils/array/order"
import { HSLA } from "@lib/ui/colors/HSLA"
import { mergeSameSizeDataArrays } from "@lib/utils/math/mergeSameSizeDataArrays"
import styled from "styled-components"
import { takeWholeSpaceAbsolutely } from "@lib/ui/css/takeWholeSpaceAbsolutely"
import { lineChartConfig } from "./lineChartConfig"
import { normalize } from "@lib/utils/math/normalize"
import { LineChart } from "@lib/ui/charts/LineChart"
import { useTrackedTime } from "../state/TrackedTimeContext"

type ChartDesription = {
  data: number[]
  color: HSLA
}

const Container = styled.div`
  ${takeWholeSpaceAbsolutely};
`

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

const ChartWrapper = styled.div`
  ${takeWholeSpaceAbsolutely};
`

type ProjectsLineChartsProps = {
  width: number
  chartMin: number
  chartMax: number
}

export const ProjectsLineCharts = ({
  width,
  chartMin,
  chartMax,
}: ProjectsLineChartsProps) => {
  const { projects } = useTrackedTime()
  const { projectsTimeSeries, activeProjectId } = useTrackedTimeReport()

  const charts = useMemo(() => {
    if (activeProjectId) {
      const data = projectsTimeSeries[activeProjectId]
      return [
        {
          data: normalize([...data, chartMin, chartMax]).slice(0, -2),
          color: projects[activeProjectId].hslaColor,
        },
      ]
    }

    const entries = Object.entries(projectsTimeSeries).filter(
      ([, data]) => sum(data) > 0
    )

    const result: ChartDesription[] = []
    const ordered = order(entries, ([, data]) => sum(data), "desc")
    const totals = mergeSameSizeDataArrays(ordered.map(([, data]) => data))
    const normalizedTotals = normalize([...totals, chartMin, chartMax]).slice(
      0,
      -2
    )
    ordered.forEach(([projectId], index) => {
      const { hslaColor } = projects[projectId]

      const area = mergeSameSizeDataArrays(
        ordered.slice(index).map(([, data]) => data)
      )
      const chartData = normalizedTotals.map((dataPoint, index) => {
        return totals[index] > 0 ? (area[index] / totals[index]) * dataPoint : 0
      })

      result.push({
        data: chartData,
        color: hslaColor,
      })
    })

    return result
  }, [activeProjectId, chartMax, chartMin, projects, projectsTimeSeries])

  return (
    <Container>
      <Content>
        {charts.map((chart, index) => (
          <ChartWrapper key={index}>
            <LineChart
              dataPointsConnectionKind="sharp"
              fillKind={activeProjectId ? "gradient" : "solid"}
              data={chart.data}
              width={width}
              height={lineChartConfig.chartHeight}
              color={chart.color}
            />
          </ChartWrapper>
        ))}
      </Content>
    </Container>
  )
}
Enter fullscreen mode Exit fullscreen mode

The ProjectsLineCharts component will render a single chart when a specific project is active. Otherwise, it will render multiple charts, one on top of another, to achieve a stacked chart effect. To accomplish this, we need to render the same number of charts as there are projects. However, each subsequent chart should represent the total time tracked of all projects minus the time tracked by the projects represented in the charts rendered before it. To calculate this, we iterate over the normalized totals and multiply each data point by its share of the total time tracked during that period. While this may sound complex, a thorough reading of the previously mentioned article and a review of the code should provide a clear understanding of how it works.

import { useMemo } from "react"
import styled, { useTheme } from "styled-components"
import { transition } from "../../css/transition"
import { HSLA } from "../../colors/HSLA"
import { match } from "@lib/utils/match"
import { Match } from "../../base/Match"
import { calculateControlPoints } from "./utils/calculateControlPoints"
import { createSmoothPath } from "./utils/createSmoothPath"
import { createSmoothClosedPath } from "./utils/createSmoothClosedPath"
import { createSharpPath } from "./utils/createSharpPath"
import { createSharpClosedPath } from "./utils/createSharpClosedPath"

type LineChartFillKind = "gradient" | "solid"
type DataPointsConnectionKind = "sharp" | "smooth"

interface LineChartProps {
  data: number[]
  height: number
  width: number
  color: HSLA
  fillKind?: LineChartFillKind
  dataPointsConnectionKind?: DataPointsConnectionKind
}

const Path = styled.path`
  ${transition}
`

export const LineChart = ({
  data,
  width,
  height,
  color,
  fillKind = "gradient",
  dataPointsConnectionKind = "smooth",
}: LineChartProps) => {
  const [path, closedPath] = useMemo(() => {
    if (data.length === 0) return ["", ""]

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

    return match(dataPointsConnectionKind, {
      smooth: () => {
        const controlPoints = calculateControlPoints(points)
        return [
          createSmoothPath(points, controlPoints, width, height),
          createSmoothClosedPath(points, controlPoints, width, height),
        ]
      },
      sharp: () => {
        return [
          createSharpPath(points, width, height),
          createSharpClosedPath(points, width, height),
        ]
      },
    })
  }, [data, dataPointsConnectionKind, height, width])

  const theme = useTheme()

  return (
    <svg
      style={{ minWidth: width, overflow: "visible" }}
      width={width}
      height={height}
      viewBox={`0 0 ${width} ${height}`}
    >
      <Path d={path} fill="none" stroke={color.toCssValue()} strokeWidth="2" />
      <Match
        value={fillKind}
        gradient={() => (
          <>
            <defs>
              <linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
                <stop
                  offset="0%"
                  stopColor={color.getVariant({ a: () => 0.4 }).toCssValue()}
                />
                <stop
                  offset="100%"
                  stopColor={theme.colors.transparent.toCssValue()}
                />
              </linearGradient>
            </defs>
          </>
        )}
        solid={() => (
          <>
            <Path
              d={closedPath}
              fill={theme.colors.background.toCssValue()}
              strokeWidth="0"
            />
          </>
        )}
      />
      <Path
        d={closedPath}
        fill={match(fillKind, {
          gradient: () => "url(#gradient)",
          solid: () => color.getVariant({ a: () => 0.4 }).toCssValue(),
        })}
        strokeWidth="0"
      />
    </svg>
  )
}
Enter fullscreen mode Exit fullscreen mode

To make parts of the stacked area chart a bit transparent we first render the chart area with a solid app background color, and then make another pass with a semi-transparent fill. To also support a gradient effect, which looks nicer when a single project is selected, we have a fillKind prop that can be either gradient or solid. Our chart also can be displayed as a smooth line by setting the dataPointsConnectionKind prop to smooth, but for this report, we use a sharp connection between data points to make it more clear where the data points are.

Top comments (2)

Collapse
 
ricardogesteves profile image
Ricardo Esteves

Looks good!

Collapse
 
a727283 profile image
Awais

Nice post!