Creating an Interactive Time-Tracking Report with React and TypeScript
Tags: react, typescript, datavisualization, timetracking
Published: 2024-04-15 04:30:09

{% embed https://youtu.be/gMZPIyhdQLI %}

🐙 [GitHub](https://github.com/radzionc/radzionkit)  |  🎮 [Demo](https://increaser.org)

# 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](https://increaser.org/) codebase is private, you can find all the reusable components and utilities in the [RadzionKit](https://github.com/radzionc/radzionkit) repository.

![Increaser Time Tracking](https://cdn-images-1.medium.com/proxy/1*HFz4IqNczBuz0-PGVjan5w.jpeg)

## Time Tracking and Data Management in Increaser

At [Increaser](https://increaser.org/), 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.

```ts
export type Set = {
  start: number
  end: number
  projectId: string
}
```

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](https://increaser.org/) 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.

```ts
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
```

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

Currently, [Increaser](https://increaser.org/)'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](https://radzion.com/blog/hsla-color).
- `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.

```tsx
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"
)
```

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](https://radzion.com/blog/storage).

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

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

## Organizing Data with the TrackedTimeProvider

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

```tsx
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>
  )
}
```

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.

```tsx
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]
  }
}
```

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.

```tsx
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())
}
```

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](https://github.com/radzionc/radzionkit).

```tsx
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,
    }
  })
}
```

## 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.

```tsx
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"
)
```

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](active.png)

```tsx
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],
}
```

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`.

```tsx
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
}
```

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.

```tsx
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>
  )
}
```

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.

```tsx
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])
}
```

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](https://github.com/radzionc/radzionkit). However, for months, we'll utilize the `subMonths` function from `date-fns` due to the variable duration of months.

```tsx
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(),
  })
}
```

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](https://github.com/radzionc/radzionkit).

```tsx
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>
}
```

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](https://github.com/radzionc/radzionkit).

```tsx
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>
  )
}
```

To ensure the filters are comfortably displayed on various screen sizes, we utilize two components for responsive design from [RadzionKit](https://github.com/radzionc/radzionkit):

- `ElementSizeAware` to determine the width of the parent element.
- `BasedOnNumber` is a simple helper component that I prefer for better readability.

```tsx
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>
      )}
    />
  )
}
```

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.

```tsx
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>
  )
}
```

## 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.

```tsx
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>}
    />
  )
}
```

To make the `IncludeCurrentPeriodSelector` resemble the `ExpandableSelector`, we'll use the `SelectContainer` component from [RadzionKit](https://github.com/radzionc/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.

```tsx
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>
  )
}
```

Finally, we have the `ManageProjectsNamesVisibility` component. Here, we use a combination of the `IconButton` and `Tooltip` components from [RadzionKit](https://github.com/radzionc/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.

```tsx
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>
      )}
    />
  )
}
```

## 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.

```tsx
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>
)
```

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.

```tsx
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>
  )
}
```

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.

```tsx
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>
  )
}
```

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](https://github.com/radzionc/radzionkit).

```tsx
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])
}
```

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

```tsx
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>
  )
}
```

## 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.

```tsx
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>
  )
}
```

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.

```tsx
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;
    }
  }
`
```

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.

```tsx
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>
  )
}
```

## 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`.

```tsx
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]
  )
}
```

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.

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

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

  return sum(timeseries.flatMap(({ data }) => data))
}
```

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`.

```tsx
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")};
          }
        `};
`
```

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](https://github.com/radzionc/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.

```tsx
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>
        )
      })}
    </>
  )
}
```

## 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](https://radzion.com/blog/piechart).

```tsx
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>
  )
}
```

## 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.

```tsx
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>
        )
      }}
    />
  )
}
```

We won't delve into detail on every component that makes this chart work, as I have an [article](https://radzion.com/blog/linechart) 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.

```tsx
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>
  )
}
```

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](https://radzion.com/blog/linechart) and a review of the code should provide a clear understanding of how it works.

```tsx
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>
  )
}
```

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.