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
, anddays
: 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.
Top comments (2)
Looks good!
Nice post!