Creating an Interactive Time-Tracking Report with React and TypeScript

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.

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
}

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

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
)

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

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

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

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

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

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
)

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

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
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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.

Leave a Reply

Your email address will not be published. Required fields are marked *