In today's article, we're going to build an exciting new feature for the productivity app, Increaser. We're introducing a "Work Budget" feature that allows you to set weekly work targets, review previous weeks' work hours, and track average work durations for weekdays and weekends. This tool provides real-time updates on your current week's progress, making it an invaluable resource for monitoring your work habits and discovering strategies to boost your productivity by working smarter, not harder.
The front end of this feature is developed using React, while the back end is powered by NodeJS and DynamoDB. Although the Increaser source code is private, all reusable components and utilities are available through the RadzionKit repository.
Our feature is thoughtfully divided into two main parts. On the left side, you can adjust your work budget using sliders, which provide an immediate preview on a bar chart to visualize what your week might look like. On the right side, a detailed report is segmented into three sections. The first section compares your total hours worked this week to your preset budget. The second section displays the average workday and weekend hours over the last 30 days, using colored days for visual representation. The final section shows a bar chart of your work hours from the past four weeks.
import { FixedWidthContent } from "@increaser/app/components/reusable/fixed-width-content"
import { PageTitle } from "@increaser/app/ui/PageTitle"
import { Page } from "@lib/next-ui/Page"
import { UserStateOnly } from "../user/state/UserStateOnly"
import { ManageWorkBudget } from "./ManageWorkBudget"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { WorkBudgetReport } from "./WorkBudgetReport"
const title = "Work Budget"
export const WorkBudgetPage: Page = () => {
return (
<FixedWidthContent>
<PageTitle documentTitle={`👍 ${title}`} title={title} />
<UserStateOnly>
<UniformColumnGrid
style={{ alignItems: "start" }}
fullWidth
minChildrenWidth={320}
gap={40}
>
<ManageWorkBudget />
<WorkBudgetReport />
</UniformColumnGrid>
</UserStateOnly>
</FixedWidthContent>
)
}
To ensure these components are evenly distributed across the interface, we are utilizing the UniformColumnGrid component from RadzionKit. By setting the minWidth
attribute, we ensure that the layout remains aesthetically pleasing and functional on mobile screens, adapting to a single column layout as necessary.
import { VStack } from "@lib/ui/layout/Stack"
import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import { useTheme } from "styled-components"
import { WorkBudgetInput } from "@increaser/ui/workBudget/WorkBudgetInput"
import { getWorkdayColor } from "@increaser/ui/workBudget/getWorkdayColor"
import { getWeekendColor } from "@increaser/ui/workBudget/getWeekendColor"
import { useUpdateUserMutation } from "../user/mutations/useUpdateUserMutation"
import { BarChart } from "@lib/ui/charts/BarChart"
import { Text } from "@lib/ui/text"
import { getShortWeekday } from "@lib/utils/time"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { SectionTitle } from "@lib/ui/text/SectionTitle"
import { Panel } from "@lib/ui/panel/Panel"
import { InputDebounce } from "@lib/ui/inputs/InputDebounce"
import { getWorkBudgetTotal } from "@increaser/entities-utils/workBudget/getWorkBudgetTotal"
import { workdaysNumber } from "@lib/utils/time/workweek"
import { useDaysBudget } from "@increaser/ui/workBudget/hooks/useDaysBudget"
export const ManageWorkBudget = () => {
const { workdayHours, weekendHours } = useAssertUserState()
const { mutate: updateUser } = useUpdateUserMutation()
const theme = useTheme()
const workBudgetTotal = getWorkBudgetTotal({
workdayHours,
weekendHours,
})
const formattedWorkdBudgetTotal = formatDuration(workBudgetTotal, "h", {
maxUnit: "h",
kind: "long",
})
const daysBudget = useDaysBudget()
return (
<Panel>
<VStack gap={20}>
<SectionTitle>
My preference ~ {formattedWorkdBudgetTotal} / week
</SectionTitle>
<VStack gap={40}>
<VStack gap={28}>
<InputDebounce
value={workdayHours}
onChange={(workdayHours) => updateUser({ workdayHours })}
render={({ value, onChange }) => (
<WorkBudgetInput
value={value}
onChange={onChange}
color={getWorkdayColor(theme)}
name="Workday"
/>
)}
/>
<InputDebounce
value={weekendHours}
onChange={(weekendHours) => updateUser({ weekendHours })}
render={({ value, onChange }) => (
<WorkBudgetInput
value={value}
onChange={onChange}
color={getWeekendColor(theme)}
name="Weekend"
/>
)}
/>
</VStack>
<BarChart
height={160}
items={daysBudget.map((value, index) => {
const color =
index < workdaysNumber
? getWorkdayColor(theme)
: getWeekendColor(theme)
return {
value,
label: <Text>{getShortWeekday(index)}</Text>,
color,
renderValue:
value > 0
? () => (
<Text>
{formatDuration(value, "min", { maxUnit: "h" })}
</Text>
)
: undefined,
}
})}
/>
</VStack>
</VStack>
</Panel>
)
}
We encapsulate both sections in a Panel
component from RadzionKit for structured layout management. To visually separate them, we assign different 'kind' properties to each. The report section is set with a transparent background, effectively creating a subtle contrast that enhances the overall clarity of the interface.
import styled, { css } from "styled-components"
import { toSizeUnit } from "../css/toSizeUnit"
import { getColor } from "../theme/getters"
import { match } from "@lib/utils/match"
import { borderRadius } from "../css/borderRadius"
type PanelKind = "regular" | "secondary"
export interface PanelProps {
width?: React.CSSProperties["width"]
padding?: React.CSSProperties["padding"]
direction?: React.CSSProperties["flexDirection"]
kind?: PanelKind
withSections?: boolean
}
export const panelDefaultPadding = 20
const panelPaddingCSS = css<{ padding?: React.CSSProperties["padding"] }>`
padding: ${({ padding }) => toSizeUnit(padding || panelDefaultPadding)};
`
export const Panel = styled.div<PanelProps>`
${borderRadius.m};
width: ${({ width }) => (width ? toSizeUnit(width) : undefined)};
overflow: hidden;
${({ withSections, direction = "column", kind = "regular", theme }) => {
const contentBackground = match(kind, {
secondary: () => theme.colors.background.toCssValue(),
regular: () => theme.colors.mist.toCssValue(),
})
const contentCSS = css`
${panelPaddingCSS}
background: ${contentBackground};
`
return withSections
? css`
display: flex;
flex-direction: ${direction};
${kind === "secondary"
? css`
background: ${getColor("mist")};
gap: 2px;
`
: css`
gap: 1px;
`}
> * {
${contentCSS}
}
`
: contentCSS
}}
${({ kind }) =>
kind === "secondary" &&
css`
border: 2px solid ${getColor("mist")};
`}
`
At the top of the ManageWorkBudget
component, we display a title that includes the work budget selected by the user. To convert minutes into a readable time format, we utilize the formatDuration
utility from RadzionKit.
import { convertDuration } from "./convertDuration"
import { pluralize } from "../pluralize"
import { durationUnitName, DurationUnit, durationUnits } from "./DurationUnit"
import { match } from "../match"
import { padWithZero } from "../padWithZero"
import { isEmpty } from "../array/isEmpty"
type FormatDurationKind = "short" | "long" | "digitalClock"
interface FormatDurationOptions {
maxUnit?: DurationUnit
minUnit?: DurationUnit
kind?: FormatDurationKind
}
export const formatDuration = (
duration: number,
durationUnit: DurationUnit,
options: FormatDurationOptions = {}
) => {
if (duration < 0) {
formatDuration(Math.abs(duration), durationUnit, options)
}
const kind = options.kind ?? "short"
const maxUnit = options.maxUnit || "d"
const minUnit = options.minUnit || "min"
const maxUnitIndex = durationUnits.indexOf(maxUnit)
const minUnitIndex = durationUnits.indexOf(minUnit)
if (maxUnitIndex < minUnitIndex) {
throw new Error("maxUnit must be greater than minUnit")
}
const units = durationUnits.slice(minUnitIndex, maxUnitIndex + 1).reverse()
const result: string[] = []
units.forEach((unit, index) => {
const convertedValue = convertDuration(duration, durationUnit, unit)
const isLastUnit = index === units.length - 1
const wholeValue = isLastUnit
? Math.round(convertedValue)
: Math.floor(convertedValue)
duration -= convertDuration(wholeValue, unit, durationUnit)
if (wholeValue === 0) {
if (kind === "digitalClock") {
if (index < units.length - 2 && isEmpty(result)) {
return
}
} else if (!isLastUnit || !isEmpty(result)) {
return
}
}
const value = match(kind, {
short: () => `${wholeValue}${unit.slice(0, 1)}`,
long: () => pluralize(wholeValue, durationUnitName[unit]),
digitalClock: () => padWithZero(wholeValue),
})
result.push(value)
})
return result.join(kind === "digitalClock" ? ":" : " ")
}
Most users are unlikely to track more than 10 hours per day, aligning with Increaser's philosophy of not encouraging excessive work hours. To accommodate this, we use a slider component named WorkBudgetInput
. This component accepts a value in hours, an onChange
callback for real-time updates, a name
attribute for labeling, and a color
in HSLA format. For more details on HSLA color formatting, you can refer to this article.
import { HSLA } from "@lib/ui/colors/HSLA"
import { InputContainer } from "@lib/ui/inputs/InputContainer"
import { LabelText } from "@lib/ui/inputs/LabelText"
import { InputProps } from "@lib/ui/props"
import { SegmentedSlider } from "@lib/ui/inputs/Slider/SegmentedSlider"
type WorkBudgetInputProps = InputProps<number> & {
color: HSLA
name: string
}
export const WorkBudgetInput = ({
value,
onChange,
color,
name,
}: WorkBudgetInputProps) => {
return (
<InputContainer as="div">
<LabelText>{name}</LabelText>
<SegmentedSlider
max={10}
value={value}
onChange={onChange}
color={color}
/>
</InputContainer>
)
}
The WorkBudgetInput
component acts primarily as a wrapper around the SegmentedSlider
from RadzionKit. This variant of a slider is designed with clear segments, making it particularly suitable for scenarios with a relatively small range of values. Users can easily count the segments to gauge the value quickly. The WorkBudgetInput
enhances this setup by adding a label to the slider, making it more user-friendly and informative.
import styled, { useTheme } from "styled-components"
import { PressTracker } from "../../base/PressTracker"
import { InputProps } from "../../props"
import { interactive } from "../../css/interactive"
import { centerContent } from "../../css/centerContent"
import { toSizeUnit } from "../../css/toSizeUnit"
import { defaultTransition } from "../../css/transition"
import { getColor } from "../../theme/getters"
import { InvisibleHTMLSlider } from "./InvisibleHtmlSlider"
import { PositionAbsolutelyCenterVertically } from "../../layout/PositionAbsolutelyCenterVertically"
import { toPercents } from "@lib/utils/toPercents"
import { Center } from "../../layout/Center"
import { range } from "@lib/utils/array/range"
import { HSLA } from "../../colors/HSLA"
import { UniformColumnGrid } from "../../layout/UniformColumnGrid"
type SegmentedSliderProps = InputProps<number> & {
max: number
color: HSLA
}
const sliderConfig = {
railHeight: 20,
controlSize: 24,
}
const Control = styled.div`
transition: outline ${defaultTransition};
outline: 4px solid transparent;
width: 8px;
height: ${toSizeUnit(sliderConfig.controlSize)};
background: ${getColor("contrast")};
border-radius: 2px;
`
const Container = styled.label`
width: 100%;
height: ${toSizeUnit(sliderConfig.controlSize + 4)};
${interactive};
${centerContent};
position: relative;
&:focus-within ${Control} {
outline: 8px solid ${getColor("mistExtra")};
}
&:hover ${Control} {
outline-color: ${getColor("mist")};
}
`
const Line = styled(UniformColumnGrid)`
width: 100%;
height: ${toSizeUnit(sliderConfig.railHeight)};
border-radius: 4px;
position: relative;
overflow: hidden;
`
const Section = styled.div``
export const SegmentedSlider = ({
value,
onChange,
max,
color,
}: SegmentedSliderProps) => {
const { colors } = useTheme()
const xPosition = toPercents(value / max)
return (
<PressTracker
onChange={({ position }) => {
if (position) {
const newValue = Math.round(position.x * max)
onChange(newValue)
}
}}
render={({ props }) => (
<Container {...props}>
<InvisibleHTMLSlider
step={1}
value={value}
onChange={onChange}
min={0}
max={max}
/>
<Line gap={1}>
{range(max).map((index) => (
<Section
style={{
background: (index < value
? color
: colors.mist
).toCssValue(),
}}
key={index}
/>
))}
</Line>
<PositionAbsolutelyCenterVertically left={xPosition} fullHeight>
<Center>
<Control />
</Center>
</PositionAbsolutelyCenterVertically>
</Container>
)}
/>
)
}
To construct this custom segmented slider, we utilize several components from RadzionKit. At the core is the PressTracker
component, which accurately tracks the user's press position on the slider. For more in-depth information on PressTracker
, you can refer to this article. Additionally, the InvisibleHTMLSlider
component is employed to enable native keyboard interactions while remaining visually concealed.
The visual segmentation of the slider is achieved using the UniformColumnGrid
component. This component forms a CSS grid with a 1px gap between each section. Sections are dynamically colored based on the current value of the slider, creating a clear and intuitive visual representation of selected values.
To efficiently manage slider interactions without overloading the server, we use the InputDebounce
component from RadzionKit. This component delays the onChange
callback until the user stops interacting with the slider for a specified interval, typically 300 milliseconds. This approach ensures server updates are only made after the user has finished adjusting, reducing unnecessary network traffic and enhancing responsiveness.
import { ReactNode, useEffect, useState } from "react"
import { InputProps } from "../props"
type InputDebounceProps<T> = InputProps<T> & {
render: (props: InputProps<T>) => ReactNode
interval?: number
}
export function InputDebounce<T>({
value,
onChange,
interval = 300,
render,
}: InputDebounceProps<T>) {
const [currentValue, setCurrentValue] = useState<T>(value)
useEffect(() => {
if (currentValue === value) return
const timeout = setTimeout(() => {
onChange(currentValue)
}, interval)
return () => clearTimeout(timeout)
}, [currentValue, interval, onChange, value])
return (
<>
{render({
value: currentValue,
onChange: setCurrentValue,
})}
</>
)
}
Below the sliders we want to display a bar chart with seven days of the week starting from Monday, we fill workdays bars with the same color as the workday slider and weekend bars with the same color as the weekend slider. So it becomes clear to the user how changes in the sliders affect the overall work budget. To learn more about BarChart
implementation you can refer to this article.
import { ReactNode } from "react"
import styled from "styled-components"
import { Spacer } from "../../layout/Spacer"
import { HStack, VStack } from "../../layout/Stack"
import { HSLA } from "../../colors/HSLA"
import { toSizeUnit } from "../../css/toSizeUnit"
import { Text } from "../../text"
import { getColor } from "../../theme/getters"
import { toPercents } from "@lib/utils/toPercents"
import { centerContent } from "../../css/centerContent"
import { transition } from "../../css/transition"
export interface BarChartItem {
label?: ReactNode
value: number
color: HSLA
renderValue?: (value: number) => ReactNode
}
interface BarChartProps {
items: BarChartItem[]
height: React.CSSProperties["height"]
expectedValueHeight?: React.CSSProperties["height"]
expectedLabelHeight?: React.CSSProperties["height"]
minBarWidth?: number
}
const barValueGap = "4px"
const barLabelGap = "4px"
const defaultLabelSize = 12
const Bar = styled.div`
border-radius: 4px;
width: 100%;
${transition};
`
const RelativeWrapper = styled.div`
position: relative;
${centerContent};
`
export const BarPlaceholder = styled(Bar)`
height: 2px;
background: ${getColor("mist")};
`
const Value = styled(Text)`
position: absolute;
white-space: nowrap;
line-height: 1;
bottom: ${barValueGap};
color: ${getColor("textSupporting")};
`
const Label = styled(Value)`
top: ${barLabelGap};
`
const Content = styled(HStack)`
flex: 1;
`
const Column = styled(VStack)`
height: 100%;
justify-content: end;
flex: 1;
`
export const BarChart = ({
items,
height,
expectedValueHeight = defaultLabelSize,
expectedLabelHeight = defaultLabelSize,
minBarWidth,
}: BarChartProps) => {
const maxValue = Math.max(...items.map((item) => item.value))
const hasLabels = items.some((item) => item.label)
return (
<VStack style={{ height }}>
<Spacer
height={`calc(${toSizeUnit(expectedValueHeight)} + ${barValueGap})`}
/>
<Content gap={4}>
{items.map(({ value, color, renderValue, label }, index) => {
return (
<Column
style={minBarWidth ? { minWidth: minBarWidth } : undefined}
key={index}
>
{renderValue && (
<RelativeWrapper>
<Value style={{ fontSize: defaultLabelSize }} as="div">
{renderValue(value)}
</Value>
</RelativeWrapper>
)}
<Bar
style={{
background: color.toCssValue(),
height: value ? toPercents(value / maxValue) : "2px",
}}
/>
{label && (
<RelativeWrapper>
<Label style={{ fontSize: defaultLabelSize }} as="div">
{label}
</Label>
</RelativeWrapper>
)}
</Column>
)
})}
</Content>
{hasLabels && (
<Spacer
height={`calc(${toSizeUnit(expectedLabelHeight)} + ${barLabelGap})`}
/>
)}
</VStack>
)
}
The work budget in our system comprises two fields: workdayHours
and weekendHours
. These are stored within the User entity in DynamoDB in a flat structure. This design choice simplifies the process of updating individual fields, allowing for more efficient and straightforward database operations.
export type WorkBudget = {
workdayHours: number
weekendHours: number
}
export type User = DayMoments &
WorkBudget & {
id: string
email: string
country?: CountryCode
name?: string
sets: Set[]
registrationDate: number
projects: Project[]
habits: Record<string, Habit>
tasks: Record<string, Task>
freeTrialEnd: number
isAnonymous: boolean
appSumo?: AppSumo
ignoreEmails?: boolean
timeZone: number
lastSyncedMonthEndedAt?: number
lastSyncedWeekEndedAt?: number
focusSounds: FocusSound[]
updatedAt: number
sumbittedHabitsAt?: number
finishedOnboardingAt?: number
subscription?: Subscription
lifeTimeDeal?: LifeTimeDeal
}
The front-end updates the workdayHours
and weekendHours
fields, along with other User fields, using the useUpdateUserMutation
hook. This hook performs an optimistic update to the React state before sending the request to the server through the updateUser
operation on the API. This method ensures a smooth and responsive user experience by reflecting changes immediately in the UI. For a deeper understanding of how to efficiently build backends within a monorepo, you can refer to this article.
import { User } from "@increaser/entities/User"
import { useApi } from "@increaser/api-ui/hooks/useApi"
import { useMutation } from "@tanstack/react-query"
import { useUserState } from "@increaser/ui/user/UserStateContext"
export const useUpdateUserMutation = () => {
const api = useApi()
const { updateState } = useUserState()
return useMutation({
mutationFn: async (input: Partial<User>) => {
updateState(input)
return api.call("updateUser", input)
},
})
}
With the work budget management configured, we can now turn our attention to the detailed three-section report, visually delineated using the SeparatedByLine
component from RadzionKit for clear separation. The first section, encapsulated within the CurrentWeekVsBudget
component, displays two cumulative lines on a chart: a half-transparent line represents the expected work hours based on the budget from Monday to Sunday, and a solid line shows the actual work hours, corresponding to the current day of the week. Users can hover over the chart to view detailed stats for a specific day, with default stats presented for the current day, ensuring a cohesive and intuitive user experience.
import { Panel } from "@lib/ui/panel/Panel"
import { WorkBudgetDaysReport } from "./WorkBudgetDaysReport"
import { WorkBudgetWeeksReport } from "./WorkBudgetWeeksReport"
import { CurrentWeekVsBudget } from "./CurrentWeekVsBudget"
import { SeparatedByLine } from "@lib/ui/layout/SeparatedByLine"
export const WorkBudgetReport = () => {
return (
<Panel kind="secondary">
<SeparatedByLine gap={40}>
<CurrentWeekVsBudget />
<WorkBudgetDaysReport />
<WorkBudgetWeeksReport />
</SeparatedByLine>
</Panel>
)
}
To maintain consistency in titles across the page, we use the SectionTitle
component in the CurrentWeekVsBudget
component. The chart requires a fixed width, so we measure the width of the parent element using the ElementSizeAware
component, which ensures the chart fits perfectly within its allocated space. You can learn more about how this component works in this article. To improve the alignment further, we add a small spacer to the right of the chart, providing a balanced visual layout.
import { HStack, VStack } from "@lib/ui/layout/Stack"
import { SectionTitle } from "@lib/ui/text/SectionTitle"
import { ElementSizeAware } from "@lib/ui/base/ElementSizeAware"
import { Spacer } from "@lib/ui/layout/Spacer"
import { chartConfig } from "./config"
import { ComparisonChart } from "./ComparisonChart"
export const CurrentWeekVsBudget = () => {
return (
<VStack gap={20}>
<SectionTitle>Current week vs budget</SectionTitle>
<HStack>
<ElementSizeAware
render={({ setElement, size }) => (
<VStack fullWidth gap={8} ref={setElement}>
{size && <ComparisonChart width={size.width} />}
</VStack>
)}
/>
<Spacer width={chartConfig.expectedXLabelWidth / 2} />
</HStack>
</VStack>
)
}
Our ComparisonChart
component leverages a reusable component designed for creating line charts. While we won’t delve into each component's specifics here, you can find a comprehensive guide on how to construct line charts without relying on external charting libraries in this article.
import { HStack, VStack } from "@lib/ui/layout/Stack"
import { useState } from "react"
import { useWeekday } from "@lib/ui/hooks/useWeekday"
import { getLastItem } from "@lib/utils/array/getLastItem"
import { ChartYAxis } from "@lib/ui/charts/ChartYAxis"
import { Text } from "@lib/ui/text"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { ChartHorizontalGridLines } from "@lib/ui/charts/ChartHorizontalGridLines"
import { D_IN_WEEK } from "@lib/utils/time"
import { Spacer } from "@lib/ui/layout/Spacer"
import { HoverTracker } from "@lib/ui/base/HoverTracker"
import { getClosestItemIndex } from "@lib/utils/math/getClosestItemIndex"
import { useCurrentWeekVsBudgetColors } from "./useCurrentWeekVsBudgetColors"
import { chartConfig } from "./config"
import { useWorkBudgetData } from "./useWorkBudgetData"
import { useWorkDoneData } from "./useWorkDoneData"
import { normalizeDataArrays } from "@lib/utils/math/normalizeDataArrays"
import { SelectedDayInfo } from "./SelectedDayInfo"
import { WeekChartXAxis } from "./WeekChartXAxis"
import { TakeWholeSpaceAbsolutely } from "@lib/ui/css/takeWholeSpaceAbsolutely"
import { CurrentDayLine } from "./CurrentDayLine"
import { ComparisonChartLines } from "./ComparisonChartLines"
import { ComponentWithWidthProps } from "@lib/ui/props"
export const ComparisonChart = ({ width }: ComponentWithWidthProps) => {
const weekday = useWeekday()
const colors = useCurrentWeekVsBudgetColors()
const workBudgetData = useWorkBudgetData()
const workDoneData = useWorkDoneData()
const [selectedDataPoint, setSelectedDataPoint] = useState<number>(weekday)
const yData = [workBudgetData[0], getLastItem(workBudgetData)]
const normalized = normalizeDataArrays({
y: yData,
workBudget: workBudgetData,
workDone: workDoneData,
})
const contentWidth = width - chartConfig.expectedYAxisLabelWidth
return (
<>
<HStack>
<Spacer width={chartConfig.expectedYAxisLabelWidth} />
<SelectedDayInfo
expectedValue={workBudgetData[selectedDataPoint]}
doneValue={workDoneData[selectedDataPoint]}
width={contentWidth}
index={selectedDataPoint}
/>
</HStack>
<HStack>
<ChartYAxis
expectedLabelWidth={chartConfig.expectedYAxisLabelWidth}
renderLabel={(index) => (
<Text key={index} size={12} color="supporting">
{formatDuration(yData[index], "min", {
maxUnit: "h",
minUnit: "h",
})}
</Text>
)}
data={normalized.y}
/>
<VStack
style={{
position: "relative",
minHeight: chartConfig.chartHeight,
}}
fullWidth
>
<ChartHorizontalGridLines data={yData} />
<ComparisonChartLines
value={[
{ data: normalized.workBudget, color: colors.budget },
{ data: normalized.workDone, color: colors.done },
]}
width={contentWidth}
/>
<HoverTracker
onChange={({ position }) => {
setSelectedDataPoint(
position ? getClosestItemIndex(D_IN_WEEK, position.x) : weekday
)
}}
render={({ props }) => <TakeWholeSpaceAbsolutely {...props} />}
/>
<CurrentDayLine value={selectedDataPoint} />
</VStack>
</HStack>
<HStack>
<Spacer width={chartConfig.expectedYAxisLabelWidth} />
<WeekChartXAxis value={selectedDataPoint} />
</HStack>
</>
)
}
Before displaying the report, we need to fetch data for both lines. The useWorkBudgetData
hook retrieves a cumulative array of expected or budgeted work hours for each day of the week. To ensure consistency in time format across both lines, we convert the data to minutes using the convertDuration
utility from RadzionKit.
import { useDaysBudget } from "@increaser/ui/workBudget/hooks/useDaysBudget"
import { cumulativeSum } from "@lib/utils/math/cumulativeSum"
import { convertDuration } from "@lib/utils/time/convertDuration"
export const useWorkBudgetData = () => {
const daysBudget = useDaysBudget()
return cumulativeSum(daysBudget).map((value) =>
convertDuration(value, "h", "min")
)
}
User's tracked data is structured as an array of sets, each containing a project ID and start and end timestamps. To calculate the total work hours for each day, we employ the useCurrentWeekMinutesWorkedByDay
hook, which iterates over these sets and tallies the total work hours for each day of the week. For those interested in a deeper dive into the time-tracking implementation at Increaser, you can explore this article.
import { useCurrentWeekMinutesWorkedByDay } from "@increaser/ui/sets/hooks/useCurrentWeekMinutesWorkedByDay"
import { cumulativeSum } from "@lib/utils/math/cumulativeSum"
export const useWorkDoneData = () => {
const days = useCurrentWeekMinutesWorkedByDay()
return cumulativeSum(days)
}
The selectedDataPoint
represents the currently highlighted weekday, which defaults to the current weekday. We use the HoverTracker
component to monitor the user's mouse position and update the selectedDataPoint
state accordingly. To clearly indicate which day is selected, a vertical line is displayed on the chart using the CurrentDayLine
component, and the corresponding weekday label on the X-axis is highlighted.
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { PositionAbsolutelyCenterVertically } from "@lib/ui/layout/PositionAbsolutelyCenterVertically"
import { ComponentWithValueProps } from "@lib/ui/props"
import { getColor } from "@lib/ui/theme/getters"
import { D_IN_WEEK } from "@lib/utils/time"
import { toPercents } from "@lib/utils/toPercents"
import styled from "styled-components"
const Line = styled.div`
height: 100%;
border-left: ${toSizeUnit(2)} dashed;
color: ${getColor("mistExtra")};
`
export const CurrentDayLine = ({ value }: ComponentWithValueProps<number>) => (
<PositionAbsolutelyCenterVertically
fullHeight
style={{
pointerEvents: "none",
}}
left={toPercents(value / (D_IN_WEEK - 1))}
>
<Line />
</PositionAbsolutelyCenterVertically>
)
To accurately position Y-axis labels and align two line charts, we must normalize the data using the normalizeDataArrays
utility from RadzionKit. This utility takes an object containing arrays of numbers and outputs the same object with normalized arrays. The normalization process entails finding the maximum and minimum values across the arrays, calculating the range, and then scaling each value to fit within a normalized range between 0 and 1. This ensures that all elements are properly aligned and displayed correctly on the chart.
export const normalizeDataArrays = <T extends Record<string, number[]>>(
input: T
): T => {
const values = Object.values(input).flat()
const max = Math.max(...values)
const min = Math.min(...values)
const range = max - min
return Object.fromEntries(
Object.entries(input).map(([key, value]) => [
key,
value.map((v) => (v - min) / range),
])
) as T
}
To assist users in setting a realistic work budget, the second section of the report displays the average work hours for each day of the week over the last 30 days. We differentiate workdays and weekends with distinct colors to simplify identification for the user. For visualizing this data, we employ the BarChart
component once again, this time omitting the labels to maintain a clean and focused presentation.
import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import { useStartOfDay } from "@lib/ui/hooks/useStartOfDay"
import { ShyInfoBlock } from "@lib/ui/info/ShyInfoBlock"
import { VStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import { range } from "@lib/utils/array/range"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { startOfDay } from "date-fns"
import { useMemo } from "react"
import { splitBy } from "@lib/utils/array/splitBy"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { AvgDay } from "./AvgDay"
import { BarChart } from "@lib/ui/charts/BarChart"
import { getWorkdayColor } from "@increaser/ui/workBudget/getWorkdayColor"
import { getWeekendColor } from "@increaser/ui/workBudget/getWeekendColor"
import { useTheme } from "styled-components"
import { isWorkday } from "@lib/utils/time/workweek"
import { getSetDuration } from "@increaser/entities-utils/set/getSetDuration"
const maxDays = 30
const minDays = 7
export const WorkBudgetDaysReport = () => {
const todayStartedAt = useStartOfDay()
const { sets } = useAssertUserState()
const lastDayStartedAt = todayStartedAt - convertDuration(1, "d", "ms")
const firstDayStartedAt = useMemo(() => {
if (!sets.length) return todayStartedAt
const firstSetDayStartedAt = startOfDay(sets[0].start).getTime()
return Math.max(
lastDayStartedAt - maxDays * convertDuration(1, "d", "ms"),
firstSetDayStartedAt
)
}, [lastDayStartedAt, sets, todayStartedAt])
const days =
Math.round(lastDayStartedAt - firstDayStartedAt) /
convertDuration(1, "d", "ms")
const totals = useMemo(() => {
const result = range(days).map(() => 0)
sets.forEach((set) => {
const setDayStartedAt = startOfDay(set.start).getTime()
const dayIndex = Math.round(
(setDayStartedAt - firstDayStartedAt) / convertDuration(1, "d", "ms")
)
if (dayIndex < 0 || dayIndex >= days) return
result[dayIndex] += getSetDuration(set)
})
return result
}, [days, firstDayStartedAt, sets])
const [workdays, weekends] = useMemo(() => {
return splitBy(totals, (total, index) => {
const timestamp =
firstDayStartedAt + index * convertDuration(1, "d", "ms")
return isWorkday(timestamp) ? 0 : 1
})
}, [firstDayStartedAt, totals])
const theme = useTheme()
if (days < minDays) {
return (
<ShyInfoBlock>
After {minDays} days of using the app, you'll access a report that shows
your average work hours on weekdays and weekends.
</ShyInfoBlock>
)
}
return (
<VStack gap={20}>
<Text color="contrast" weight="semibold">
Last {days} days report
</Text>
<UniformColumnGrid gap={20}>
<AvgDay value={workdays} name="workday" />
<AvgDay value={weekends} name="weekend" />
</UniformColumnGrid>
<BarChart
expectedLabelHeight={0}
expectedValueHeight={0}
height={60}
items={totals.map((value, index) => {
const dayStartedAt =
firstDayStartedAt + index * convertDuration(1, "d", "ms")
return {
value,
color: isWorkday(dayStartedAt)
? getWorkdayColor(theme)
: getWeekendColor(theme),
}
})}
/>
</VStack>
)
}
While the second section of the report highlights the average work hours for workdays and weekends, the third section presents an average of the entire week along with a bar chart depicting the total hours for the last four weeks. In both sections, if there is insufficient data to display a comprehensive report, a ShyInfoBlock
will appear, subtly informing the user of the lack of data.
import { ShyInfoBlock } from "@lib/ui/info/ShyInfoBlock"
import { VStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import { range } from "@lib/utils/array/range"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { useMemo } from "react"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { useTheme } from "styled-components"
import { useStartOfWeek } from "@lib/ui/hooks/useStartOfWeek"
import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { fromWeek, toWeek } from "@lib/utils/time/Week"
import { order } from "@lib/utils/array/order"
import { LabeledValue } from "@lib/ui/text/LabeledValue"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { sum } from "@lib/utils/array/sum"
import { BarChart } from "@lib/ui/charts/BarChart"
const maxWeeks = 4
const minWeeks = 2
export const WorkBudgetWeeksReport = () => {
const weekStartedAt = useStartOfWeek()
const { projects } = useProjects()
const lastWeekStartedAt = weekStartedAt - convertDuration(1, "w", "ms")
const firstWeekStartedAt = useMemo(() => {
const allWeeks = projects.flatMap((project) => project.weeks).map(fromWeek)
if (!allWeeks.length) return lastWeekStartedAt
return Math.max(
lastWeekStartedAt - convertDuration(maxWeeks, "w", "ms"),
order(allWeeks, (v) => v, "asc")[0]
)
}, [lastWeekStartedAt, projects])
const weeks =
Math.round(lastWeekStartedAt - firstWeekStartedAt) /
convertDuration(1, "w", "ms")
const totals = useMemo(() => {
const result = range(weeks).map(() => 0)
projects
.flatMap((project) => project.weeks)
.forEach(({ week, year, seconds }) => {
const weekStartedAt = fromWeek({ week, year })
const weekIndex = Math.round(
(weekStartedAt - firstWeekStartedAt) / convertDuration(1, "w", "ms")
)
if (weekIndex < 0 || weekIndex >= weeks) return
result[weekIndex] += seconds
})
return result
}, [firstWeekStartedAt, projects, weeks])
const theme = useTheme()
if (weeks < minWeeks) {
return (
<ShyInfoBlock>
After {minWeeks} weeks of using the app, you'll access a report that
shows your average work week.
</ShyInfoBlock>
)
}
return (
<VStack gap={20}>
<Text color="contrast" weight="semibold">
Last {weeks} weeks report
</Text>
<UniformColumnGrid gap={20}>
<LabeledValue labelColor="supporting" name={`Avg. week`}>
<Text as="span" color="contrast">
{formatDuration(sum(totals) / weeks, "s", { maxUnit: "h" })}
</Text>
</LabeledValue>
</UniformColumnGrid>
<BarChart
height={120}
items={totals.map((value, index) => {
const weekStartedAt =
firstWeekStartedAt + index * convertDuration(1, "w", "ms")
return {
value,
label: <Text>week #{toWeek(weekStartedAt).week + 1}</Text>,
color: theme.colors.mist,
renderValue:
value > 0
? () => (
<Text>{formatDuration(value, "s", { maxUnit: "h" })}</Text>
)
: undefined,
}
})}
/>
</VStack>
)
}