In this article, we will construct a time-planner using TypeScript, with React handling the frontend and NodeJS on the backend. This tool enables you to allocate time among your projects, set goals to either increase or decrease the time spent on specific projects, and track your progress in real-time throughout the week. Additionally, you can review the actual time spent on each project compared to the planned time over the last eight weeks. Although the source code of the Increaser app, which includes the time-planner feature, is within a private repository, you can access all the reusable components and utilities that we will utilize today in the RadzionKit repository.
Our page is divided into two main sections: the time-planner and the report, which compares the budgeted time with the actual time spent on each project. To arrange these sections into two equal columns that adapt responsively on smaller screens, we utilize the UniformColumnGrid
component from RadzionKit. This component simplifies the implementation of one of the most common CSS Grid layouts—a row with equal-width columns.
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 { ManageProjectsBudget } from "./ManageProjectsBudget"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { ProjectsBudgetReport } from "./ProjectsBudgetReport"
const title = "Projects budget"
export const ProjectsBudgetPage: Page = () => {
return (
<FixedWidthContent>
<PageTitle documentTitle={`🎯 ${title}`} title={title} />
<UserStateOnly>
<UniformColumnGrid fullWidth minChildrenWidth={320} gap={40}>
<ManageProjectsBudget />
<ProjectsBudgetReport />
</UniformColumnGrid>
</UserStateOnly>
</FixedWidthContent>
)
}
The ManageProjectsBudget
interface is structured into three sections: the overview, a conditional prompt for time management, and a list of projects along with their respective budgets. We employ the VStack
component from RadzionKit to vertically stack these sections, ensuring a consistent gap of 24 pixels between each.
import { Page } from "@lib/next-ui/Page"
import { ProjectsBudgetOverview } from "./ProjectsBudgetOverview"
import { VStack } from "@lib/ui/layout/Stack"
import { ManageTimePrompt } from "./ManageTimePrompt"
import { ProjectsBudgetList } from "./ProjectsBudgetList"
export const ManageProjectsBudget: Page = () => {
return (
<VStack gap={24} style={{ maxWidth: 480 }}>
<ProjectsBudgetOverview />
<ManageTimePrompt />
<ProjectsBudgetList />
</VStack>
)
}
The ProjectBudgetOverview
component displays the total allocated time, the remaining free time, and the total work budget. If you're curious about the source of the "work budget" value, be sure to check out a dedicated article that covers this feature in detail. To present these figures, we utilize the LabeledValue
component from RadzionKit, which pairs a label with its corresponding value and inserts a colon between them.
import { HStack, VStack } from "@lib/ui/layout/Stack"
import styled from "styled-components"
import { ProjectsBudgetVisualization } from "./ProjectsBudgetVisualization"
import { LabeledValue } from "@lib/ui/text/LabeledValue"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { Text } from "@lib/ui/text"
import { useWorkBudgetTotal } from "@increaser/ui/workBudget/hooks/useWorkBudgetTotal"
import { useFreeHours } from "./hooks/useFreeHours"
import { useProjectsBudgetedHours } from "./hooks/useProjectsBudgetedHours"
const Container = styled(VStack)`
gap: 8px;
font-size: 14px;
`
export const ProjectsBudgetOverview = () => {
const workBudetTotal = useWorkBudgetTotal()
const budgetedHours = useProjectsBudgetedHours()
const freeHours = useFreeHours()
return (
<Container>
<HStack
wrap="wrap"
alignItems="center"
fullWidth
justifyContent="space-between"
>
<HStack gap={20}>
<LabeledValue labelColor="supporting" name="Allocated">
<Text color="contrast">
{budgetedHours
? formatDuration(budgetedHours, "h", {
maxUnit: "h",
})
: "-"}
</Text>
</LabeledValue>
{freeHours > 0 && (
<LabeledValue labelColor="supporting" name="Free">
<Text color="contrast">
{formatDuration(freeHours, "h", {
maxUnit: "h",
})}
</Text>
</LabeledValue>
)}
</HStack>
<LabeledValue labelColor="supporting" name="Work budget">
<Text color="contrast">
{formatDuration(workBudetTotal, "h", {
maxUnit: "h",
})}
</Text>
</LabeledValue>
</HStack>
<ProjectsBudgetVisualization />
</Container>
)
}
The budgetedHours
and freeHours
values are derived from the user state, which is maintained in React Context. At Increaser, we load all essential user data at the application's launch. Although this is a blocking operation, it does not impact the user experience, as we cache the data in local storage using the react-query persistor.
The ProjectsBudgetVisualization
component acts as a wrapper around the CountableItemsVisualization
from RadzionKit. It takes budgeted projects and their allocated times and transforms them into a list of HSLA colors, with each color representing an hour of work. For more insights on how to effectively use the HSLA color format in React, check out this article.
import { range } from "@lib/utils/array/range"
import { useTheme } from "styled-components"
import { CountableItemsVisualization } from "@lib/ui/visual/CountableItemsVisualization"
import { MIN_IN_HOUR } from "@lib/utils/time"
import { useMemo } from "react"
import { useBudgetedProjects } from "./hooks/useBudgetedProjects"
import { useWorkBudgetTotal } from "@increaser/ui/workBudget/hooks/useWorkBudgetTotal"
export const ProjectsBudgetVisualization = () => {
const workBudgetTotal = useWorkBudgetTotal()
const { colors } = useTheme()
const projects = useBudgetedProjects()
const items = useMemo(() => {
const result = projects
.map(({ hslaColor, allocatedMinutesPerWeek }) => {
const hours = allocatedMinutesPerWeek / MIN_IN_HOUR
return range(hours).map(() => hslaColor)
})
.flat()
if (result.length < workBudgetTotal) {
result.push(
...range(workBudgetTotal - result.length).map(() => colors.mist)
)
}
return result
}, [colors.mist, projects, workBudgetTotal])
return <CountableItemsVisualization value={items} />
}
The ManageTimePrompt
component checks the status of free hours, and if available, prompts the user to allocate time to a project or adjust the budget for the projects listed below. If the user has exceeded the work budget, the component displays a warning message. Upon clicking on the prompt, the AddBudgetForm
component opens, allowing the user to allocate time to a project that currently has no budget. For this conditional rendering, we utilize the Opener
component from RadzionKit, which I personally prefer over using the useState
hook in similar scenarios.
import { Opener } from "@lib/ui/base/Opener"
import { AddBudgetForm } from "./AddBudgetForm"
import { useFreeHours } from "./hooks/useFreeHours"
import { pluralize } from "@lib/utils/pluralize"
import { PanelPrompt } from "@lib/ui/panel/PanelPrompt"
import { ShyWarningBlock } from "@lib/ui/status/ShyWarningBlock"
import Link from "next/link"
import { AppPath } from "@increaser/ui/navigation/AppPath"
import { getColor } from "@lib/ui/theme/getters"
import styled from "styled-components"
import { transition } from "@lib/ui/css/transition"
const BudgetLink = styled(Link)`
color: ${getColor("text")};
font-weight: 500;
border-bottom: 1px dashed ${getColor("text")};
${transition};
&:hover {
color: ${getColor("contrast")};
}
`
export const ManageTimePrompt = () => {
const freeHours = useFreeHours()
if (freeHours === 0) {
return null
}
if (freeHours < 0) {
return (
<ShyWarningBlock title="Exceeding Work Budget">
You have allocated {pluralize(-freeHours, "hour")} more than your
current work budget allows. Please adjust the budget for the projects
listed below or{" "}
<BudgetLink href={AppPath.WorkBudget}>
expand your overall work budget
</BudgetLink>{" "}
to accommodate your plans.
</ShyWarningBlock>
)
}
return (
<Opener
renderOpener={({ onOpen, isOpen }) =>
!isOpen && (
<PanelPrompt
title={`You have ${pluralize(freeHours, "free hour")}!`}
onClick={onOpen}
>
Tap here to allocate time for a project with no budget or adjust the
budget for the projects listed below.
</PanelPrompt>
)
}
renderContent={({ onClose }) => <AddBudgetForm onFinish={onClose} />}
/>
)
}
The state of the AddBudgetForm
includes four fields:
projectId
— the ID of the project to which the user wants to allocate time.hours
— the number of hours the user wishes to allocate.goal
— the user's goal for the project, which is optional and can be either doMore
or doLess
.workingDays
— the days of the week on which the user plans to work on the project, selectable as either workdays
or everyday
.Upon submitting the form, we call the mutate
function from the useUpdateProjectMutation
hook. This function performs an optimistic update of the project and triggers a corresponding API endpoint to update the project in the database. For a deeper understanding of how to efficiently build backends within a monorepo, refer to this article.
import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { Panel } from "@lib/ui/panel/Panel"
import { preventDefault } from "@lib/ui/utils/preventDefault"
import { Button } from "@lib/ui/buttons/Button"
import { useCallback, useMemo, useState } from "react"
import { Fields } from "@lib/ui/inputs/Fields"
import { ProjectInput } from "@increaser/ui/projects/ProjectInput"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { Field } from "@lib/ui/inputs/Field"
import { useUpdateProjectMutation } from "../api/useUpdateProjectMutation"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { ProjectGoal, ProjectWorkingDays } from "@increaser/entities/Project"
import { InputContainer } from "@lib/ui/inputs/InputContainer"
import { LabelText } from "@lib/ui/inputs/LabelText"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { FinishableComponentProps } from "@lib/ui/props"
import { useFreeHours } from "./hooks/useFreeHours"
import { BudgetHoursInput } from "./BudgetHoursInput"
import { WorkdingDaysInput } from "./WorkingDaysInput"
import { ProjectGoalInput } from "./ProjectGoalInput"
type WeeklyGoalShape = {
projectId: string | null
hours: number | null
goal: ProjectGoal | null
workingDays: ProjectWorkingDays
}
export const AddBudgetForm = ({ onFinish }: FinishableComponentProps) => {
const { activeProjects, projectsRecord } = useProjects()
const options = useMemo(
() => activeProjects.filter((p) => !p.allocatedMinutesPerWeek),
[activeProjects]
)
const getInitialValue = useCallback(
(): WeeklyGoalShape => ({
projectId: options[0]?.id ?? null,
hours: null,
goal: null,
workingDays: "everyday",
}),
[options]
)
const [value, setValue] = useState<WeeklyGoalShape>(getInitialValue)
const errorMessage = useMemo(() => {
if (!value.projectId) return "Please select a project"
if (!value.hours) return "Please enter a budget"
}, [value.projectId, value.hours])
const freeHours = useFreeHours()
const { mutate: updateProject } = useUpdateProjectMutation()
return (
<InputContainer style={{ gap: 8 }} as="div">
<LabelText>Add project budget</LabelText>
<Panel
as="form"
onSubmit={preventDefault(() => {
if (errorMessage) return
updateProject({
id: shouldBePresent(value.projectId),
fields: {
allocatedMinutesPerWeek: convertDuration(
value.hours ?? 0,
"h",
"min"
),
goal: value.goal,
workingDays: value.workingDays,
},
})
onFinish()
})}
withSections
style={value.hours ? undefined : { background: "transparent" }}
kind="secondary"
>
<Fields>
<Field>
<ProjectInput
label="Project"
options={options}
value={value.projectId ? projectsRecord[value.projectId] : null}
onChange={(project) =>
setValue((prev) => ({
...prev,
projectId: project?.id ?? null,
}))
}
/>
</Field>
<Field>
<BudgetHoursInput
max={freeHours}
value={value.hours}
onChange={(hours) => setValue((prev) => ({ ...prev, hours }))}
/>
</Field>
<Field>
<WorkdingDaysInput
value={value.workingDays}
onChange={(workingDays) =>
setValue((prev) => ({ ...prev, workingDays }))
}
/>
</Field>
</Fields>
{value.projectId && value.hours && (
<ProjectGoalInput
value={value.goal}
onChange={(goal) => setValue((prev) => ({ ...prev, goal }))}
project={projectsRecord[value.projectId]}
hours={value.hours}
/>
)}
<UniformColumnGrid gap={20}>
<Button type="button" onClick={onFinish} kind="secondary" size="l">
Cancel
</Button>
<Button isDisabled={errorMessage} size="l">
Submit
</Button>
</UniformColumnGrid>
</Panel>
</InputContainer>
)
}
To implement the ProjectInput
, we leverage the FixedOptionsInput
, which functions as a combobox with a fixed set of options. While it is a relatively complex component, those interested in understanding its mechanics can check out this article.
import {
FixedOptionsInput,
FixedOptionsInputWrapperProps,
} from "@lib/ui/inputs/dropdown/FixedOptionsInput"
import { EnhancedProject } from "../EnhancedProject"
import { IdentifierPlaceholder } from "@lib/ui/inputs/dropdown/FixedOptionsInput/IdentifierPlaceholder"
import { ProjectOption } from "./ProjectOption"
import { ProjectIdentifier } from "./ProjectIdentifier"
type ProjectInputProps = FixedOptionsInputWrapperProps<EnhancedProject>
export function ProjectInput({
value,
onChange,
label,
options,
...rest
}: ProjectInputProps) {
return (
<FixedOptionsInput
value={value}
label={label}
onChange={onChange}
placeholder="Search for a project"
options={options}
getOptionSearchStrings={(option) => [option.name]}
getOptionName={(option) => option.name}
getOptionKey={(option) => option.id}
renderOptionIdentifier={(project) => (
<ProjectIdentifier>{project.emoji}</ProjectIdentifier>
)}
optionIdentifierPlaceholder={<IdentifierPlaceholder />}
renderOption={(project) => <ProjectOption value={project} />}
{...rest}
/>
)
}
The BudgetHoursInput
component is a straightforward input field designed to restrict users from entering a value greater than the number of free hours available. It wraps around the HoursInput
component, an integer-only input adorned with a clock icon at the front. To clarify the purpose of the input field as being for budgeting, we add an info icon. When hovered over, this icon displays a tooltip with an explanation, utilizing the WithHint
component from RadzionKit.
import { WithHint } from "@lib/ui/tooltips/WithHint"
import { HoursInput } from "@increaser/ui/weeklyGoals/HoursInput"
import { InputProps } from "@lib/ui/props"
type BudgetHoursInputProps = InputProps<number | null> & {
max: number
}
export const BudgetHoursInput = ({
value,
onChange,
max,
}: BudgetHoursInputProps) => (
<HoursInput
autoFocus
label={
<WithHint hint="Select the number of hours you aim to spend on this project each week.">
Budget
</WithHint>
}
placeholder="Enter hours"
max={max}
value={value}
onChange={onChange}
/>
)
The WorkingDaysInput
is a wrapper around the RadioInput
component from RadzionKit. It receives an array of options, a function to render an option, the selected value, and an onChange
callback. The RadioInput
displays a horizontal list of options, where the selected option is marked by a highlighted circle on the left side.
import {
ProjectWorkingDays,
projectWorkingDays,
workingDayOptionName,
} from "@increaser/entities/Project"
import { RadioInput } from "@lib/ui/inputs/RadioInput"
import { InputContainer } from "@lib/ui/inputs/InputContainer"
import { LabelText } from "@lib/ui/inputs/LabelText"
import { InputProps } from "@lib/ui/props"
export const WorkdingDaysInput = ({
value,
onChange,
}: InputProps<ProjectWorkingDays>) => {
return (
<InputContainer>
<LabelText>Working days</LabelText>
<RadioInput
options={projectWorkingDays}
renderOption={(option) => workingDayOptionName[option]}
value={value}
onChange={onChange}
/>
</InputContainer>
)
}
To avoid overloading the user, we display the interface for setting a goal only after the user has selected a project and entered the budget. The ProjectGoalInput
features a MinimalisticSwitch
from RadzionKit, which toggles between setting a goal and not setting one. If a goal is set, the component then displays a RadioInput
with available options, alongside text that formulates the goal in a human-readable format.
import { VStack } from "@lib/ui/layout/Stack"
import {
ProjectGoal,
goalOptionName,
projectGoals,
} from "@increaser/entities/Project"
import { RadioInput } from "@lib/ui/inputs/RadioInput"
import { pluralize } from "@lib/utils/pluralize"
import { MinimalisticSwitch } from "@lib/ui/inputs/Switch/MinimalisticSwitch"
import { Text } from "@lib/ui/text"
import { capitalizeFirstLetter } from "@lib/utils/capitalizeFirstLetter"
import { InputProps } from "@lib/ui/props"
import { EnhancedProject } from "@increaser/ui/projects/EnhancedProject"
type ProjectGoalInputProps = InputProps<ProjectGoal | null> & {
project: EnhancedProject
hours: number
}
export const ProjectGoalInput = ({
value,
onChange,
project,
hours,
}: ProjectGoalInputProps) => {
return (
<VStack gap={20}>
<MinimalisticSwitch
onChange={() => onChange(value ? null : "doMore")}
value={value !== null}
label={`Set a goal ${value ? "to work" : "..."}`}
/>
{value !== null && (
<>
<RadioInput
options={projectGoals}
renderOption={(goal) => capitalizeFirstLetter(goalOptionName[goal])}
value={value}
onChange={(goal) => onChange(goal)}
/>
<Text color="regular" size={14}>
<Text as="span" weight="bold" color="contrast">
{capitalizeFirstLetter(goalOptionName[value])}
</Text>{" "}
{pluralize(hours, "hour")} of {project.name} per week
</Text>
</>
)}
</VStack>
)
}
Once the user submits the form, it appears in the ProjectsBudgetList
component, which iterates over each project sorted by the time allocated. Each project is rendered using the ProjectBudgetItem
component, which is divided into two parts. The left part describes the project budget, while the right part includes two buttons for editing and deleting the project budget.
import { EnhancedProject } from "@increaser/ui/projects/EnhancedProject"
import { HStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import styled from "styled-components"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { Panel } from "@lib/ui/panel/Panel"
import { ProjectGoalShyIndicator } from "./ProjectGoalShyIndicator"
import { TrashBinIcon } from "@lib/ui/icons/TrashBinIcon"
import { UnstyledButton } from "@lib/ui/buttons/UnstyledButton"
import { centerContent } from "@lib/ui/css/centerContent"
import { transition } from "@lib/ui/css/transition"
import { getHoverVariant } from "@lib/ui/theme/getHoverVariant"
import { getColor } from "@lib/ui/theme/getters"
import { EditIcon } from "@lib/ui/icons/EditIcon"
import { useUpdateProjectMutation } from "../api/useUpdateProjectMutation"
import { Opener } from "@lib/ui/base/Opener"
import { ManageProjectBudget } from "./ManageProjectBudget"
type WeeklyGoalItemProps = {
value: EnhancedProject
}
const Container = styled(Panel)`
font-size: 14px;
`
const PanelButton = styled(UnstyledButton)`
height: 100%;
font-size: 20px;
${centerContent};
${transition};
&:hover {
background: ${getHoverVariant("foreground")};
color: ${getColor("contrast")};
}
`
export const ProjectBudgetItem = ({ value }: WeeklyGoalItemProps) => {
const { mutate } = useUpdateProjectMutation()
return (
<Opener
renderOpener={({ onOpen, isOpen }) =>
isOpen ? null : (
<Container withSections direction="row">
<HStack
fullWidth
alignItems="center"
justifyContent="space-between"
>
<HStack alignItems="center" gap={8}>
{<ProjectGoalShyIndicator value={value.goal ?? null} />}
<Text color="contrast" cropped weight="semibold">
{value.name}
</Text>
</HStack>
<HStack alignItems="center" gap={4}>
<Text weight="bold" color="contrast">
{formatDuration(value.allocatedMinutesPerWeek, "min", {
kind: "long",
minUnit: "h",
maxUnit: "h",
})}{" "}
</Text>
</HStack>
</HStack>
<PanelButton onClick={onOpen}>
<EditIcon />
</PanelButton>
<PanelButton
onClick={() => {
mutate({
id: value.id,
fields: {
allocatedMinutesPerWeek: 0,
goal: null,
},
})
}}
>
<TrashBinIcon />
</PanelButton>
</Container>
)
}
renderContent={({ onClose }) => (
<ManageProjectBudget onFinish={onClose} project={value} />
)}
/>
)
}
To visually indicate whether a project has a goal, we use the ProjectGoalShyIndicator
. This component displays a gray circle if the project has no goal, a green plus icon if the goal is to "do more," and an orange minus icon if the goal is to "do less." For this type of conditional rendering, we utilize the match
utility function and the Match
component from RadzionKit, which effectively serve as a switch-case replacement.
import { ComponentWithValueProps } from "@lib/ui/props"
import { ProjectGoal } from "@increaser/entities/Project"
import { IconWrapper } from "@lib/ui/icons/IconWrapper"
import { PlusIcon } from "@lib/ui/icons/PlusIcon"
import { Match } from "@lib/ui/base/Match"
import styled, { useTheme } from "styled-components"
import { getColor } from "@lib/ui/theme/getters"
import { round } from "@lib/ui/css/round"
import { MinusIcon } from "@lib/ui/icons/MinusIcon"
import { sameDimensions } from "@lib/ui/css/sameDimensions"
import { match } from "@lib/utils/match"
const Container = styled(IconWrapper)`
background: ${getColor("mist")};
${round};
${sameDimensions("1.4em")};
`
export const ProjectGoalShyIndicator = ({
value,
}: ComponentWithValueProps<ProjectGoal | null>) => {
const { colors } = useTheme()
return (
<Container
style={
value
? {
background: match(value, {
doMore: () => colors.success,
doLess: () => colors.idle,
})
.getVariant({ a: () => 0.08 })
.toCssValue(),
color: match(value, {
doMore: () => colors.success,
doLess: () => colors.idle,
}).toCssValue(),
}
: undefined
}
>
{value ? (
<Match
value={value}
doMore={() => <PlusIcon />}
doLess={() => <MinusIcon />}
/>
) : undefined}
</Container>
)
}
We utilize the Panel
component from RadzionKit as a container. By setting the withSections
prop to true
, we implement a design pattern where background and padding are applied to the children, thereby creating transparent borders between them. As the Panel
is a flexbox element, we can also pass direction="row"
to align the children horizontally.
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")};
`}
`
Since the Panel
component applies padding to its children, all that is required for the PanelButton
component is setting the height to 100%
, centering the content, specifying the size for the icons inside, and adding hover effects. To trigger the opening of the ManageProjectBudget
component, we utilize the Opener
component once again.
import { VStack } from "@lib/ui/layout/Stack"
import { Button } from "@lib/ui/buttons/Button"
import { useCallback, useMemo, useState } from "react"
import { Fields } from "@lib/ui/inputs/Fields"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { Field } from "@lib/ui/inputs/Field"
import { useUpdateProjectMutation } from "../api/useUpdateProjectMutation"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { ProjectGoal, ProjectWorkingDays } from "@increaser/entities/Project"
import { Text } from "@lib/ui/text"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { FinishableComponentProps } from "@lib/ui/props"
import { EnhancedProject } from "@increaser/ui/projects/EnhancedProject"
import { useFreeHours } from "./hooks/useFreeHours"
import { Panel } from "@lib/ui/panel/Panel"
import { preventDefault } from "@lib/ui/utils/preventDefault"
import { BudgetHoursInput } from "./BudgetHoursInput"
import { WorkdingDaysInput } from "./WorkingDaysInput"
import { ProjectGoalInput } from "./ProjectGoalInput"
type WeeklyGoalShape = {
hours: number | null
goal: ProjectGoal | null
workingDays: ProjectWorkingDays
}
type ManageProjectBudgetOverlayProps = FinishableComponentProps & {
project: EnhancedProject
}
export const ManageProjectBudget = ({
onFinish,
project,
}: ManageProjectBudgetOverlayProps) => {
const getInitialValue = useCallback(
(): WeeklyGoalShape => ({
hours: convertDuration(project.allocatedMinutesPerWeek, "min", "h"),
goal: project.goal ?? null,
workingDays: project.workingDays,
}),
[project.allocatedMinutesPerWeek, project.goal, project.workingDays]
)
const [value, setValue] = useState<WeeklyGoalShape>(getInitialValue)
const freeHours =
useFreeHours() +
convertDuration(project.allocatedMinutesPerWeek, "min", "h")
const { mutate: updateProject } = useUpdateProjectMutation()
const errorMessage = useMemo(() => {
if (!value.hours) return "Please enter a budget"
}, [value.hours])
return (
<Panel
as="form"
onSubmit={preventDefault(() => {
updateProject({
id: project.id,
fields: {
allocatedMinutesPerWeek: convertDuration(
shouldBePresent(value.hours),
"h",
"min"
),
goal: value.goal,
workingDays: value.workingDays,
},
})
onFinish()
})}
withSections
style={value.hours ? undefined : { background: "transparent" }}
kind="secondary"
>
<VStack gap={20}>
<Text weight="semibold" color="contrast">
{project.emoji} {project.name}
</Text>
<Fields>
<Field>
<BudgetHoursInput
max={freeHours}
value={value.hours}
onChange={(hours) => setValue((prev) => ({ ...prev, hours }))}
/>
</Field>
<Field>
<WorkdingDaysInput
value={value.workingDays}
onChange={(workingDays) =>
setValue((prev) => ({ ...prev, workingDays }))
}
/>
</Field>
</Fields>
</VStack>
{value.hours && (
<ProjectGoalInput
value={value.goal}
onChange={(goal) => setValue((prev) => ({ ...prev, goal }))}
project={project}
hours={value.hours}
/>
)}
<UniformColumnGrid gap={20}>
<Button
onClick={() => {
onFinish()
}}
type="button"
kind="secondary"
size="l"
>
Cancel
</Button>
<Button isDisabled={errorMessage} size="l">
Submit
</Button>
</UniformColumnGrid>
</Panel>
)
}
The ManageProjectBudget
component functions similarly to the AddBudgetForm
, but with a key difference: it receives the project
prop. As a result, there is no need to display the project selection field. Instead, we display the project name and emoji at the top of the form, providing a clear and immediate reference to the project being managed.
import { VStack } from "@lib/ui/layout/Stack"
import { Button } from "@lib/ui/buttons/Button"
import { useCallback, useMemo, useState } from "react"
import { Fields } from "@lib/ui/inputs/Fields"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { Field } from "@lib/ui/inputs/Field"
import { useUpdateProjectMutation } from "../api/useUpdateProjectMutation"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { ProjectGoal, ProjectWorkingDays } from "@increaser/entities/Project"
import { Text } from "@lib/ui/text"
import { UniformColumnGrid } from "@lib/ui/layout/UniformColumnGrid"
import { FinishableComponentProps } from "@lib/ui/props"
import { EnhancedProject } from "@increaser/ui/projects/EnhancedProject"
import { useFreeHours } from "./hooks/useFreeHours"
import { Panel } from "@lib/ui/panel/Panel"
import { preventDefault } from "@lib/ui/utils/preventDefault"
import { BudgetHoursInput } from "./BudgetHoursInput"
import { WorkdingDaysInput } from "./WorkingDaysInput"
import { ProjectGoalInput } from "./ProjectGoalInput"
type WeeklyGoalShape = {
hours: number | null
goal: ProjectGoal | null
workingDays: ProjectWorkingDays
}
type ManageProjectBudgetOverlayProps = FinishableComponentProps & {
project: EnhancedProject
}
export const ManageProjectBudget = ({
onFinish,
project,
}: ManageProjectBudgetOverlayProps) => {
const getInitialValue = useCallback(
(): WeeklyGoalShape => ({
hours: convertDuration(project.allocatedMinutesPerWeek, "min", "h"),
goal: project.goal ?? null,
workingDays: project.workingDays,
}),
[project.allocatedMinutesPerWeek, project.goal, project.workingDays]
)
const [value, setValue] = useState<WeeklyGoalShape>(getInitialValue)
const freeHours =
useFreeHours() +
convertDuration(project.allocatedMinutesPerWeek, "min", "h")
const { mutate: updateProject } = useUpdateProjectMutation()
const errorMessage = useMemo(() => {
if (!value.hours) return "Please enter a budget"
}, [value.hours])
return (
<Panel
as="form"
onSubmit={preventDefault(() => {
updateProject({
id: project.id,
fields: {
allocatedMinutesPerWeek: convertDuration(
shouldBePresent(value.hours),
"h",
"min"
),
goal: value.goal,
workingDays: value.workingDays,
},
})
onFinish()
})}
withSections
style={value.hours ? undefined : { background: "transparent" }}
kind="secondary"
>
<VStack gap={20}>
<Text weight="semibold" color="contrast">
{project.emoji} {project.name}
</Text>
<Fields>
<Field>
<BudgetHoursInput
max={freeHours}
value={value.hours}
onChange={(hours) => setValue((prev) => ({ ...prev, hours }))}
/>
</Field>
<Field>
<WorkdingDaysInput
value={value.workingDays}
onChange={(workingDays) =>
setValue((prev) => ({ ...prev, workingDays }))
}
/>
</Field>
</Fields>
</VStack>
{value.hours && (
<ProjectGoalInput
value={value.goal}
onChange={(goal) => setValue((prev) => ({ ...prev, goal }))}
project={project}
hours={value.hours}
/>
)}
<UniformColumnGrid gap={20}>
<Button
onClick={() => {
onFinish()
}}
type="button"
kind="secondary"
size="l"
>
Cancel
</Button>
<Button isDisabled={errorMessage} size="l">
Submit
</Button>
</UniformColumnGrid>
</Panel>
)
}
With budget management addressed, we can transition to the report section. If you're curious about how time tracking is implemented at Increaser, you can explore this article. We display the report within the Panel
component. By assigning the kind="secondary"
prop, we make the background transparent, adding more contrast to the content. To delineate the various segments of the report, we use the SeparatedByLine
component from RadzionKit.
import { SeparatedByLine } from "@lib/ui/layout/SeparatedByLine"
import { Panel } from "@lib/ui/panel/Panel"
import { CurrentWeekProgress } from "./CurrentWeekProgress"
import { PreviousWeeksProgress } from "./PreviousWeeks/PreviousWeeksProgress"
export const ProjectsBudgetReport = () => (
<Panel kind="secondary">
<SeparatedByLine gap={40}>
<CurrentWeekProgress />
<PreviousWeeksProgress />
</SeparatedByLine>
</Panel>
)
The CurrentWeekProgress
component displays a breakdown of the progress for each budgeted project in the current week. We wrap it with the GoalsRequired
component, which displays an info message if no projects are budgeted. By wrapping the ProjectBudgetWidget
component with the CurrentProjectProvider
we eliminate the need for prop drilling, making all the child components aware of the current project.
import { ProjectBudgetWidget } from "./ProjectBudgetWidget"
import { CurrentProjectProvider } from "../components/ProjectView/CurrentProjectProvider"
import { VStack } from "@lib/ui/layout/Stack"
import { GoalsRequired } from "./GoalsRequired"
import { useBudgetedProjects } from "./hooks/useBudgetedProjects"
import { SectionTitle } from "@lib/ui/text/SectionTitle"
export const CurrentWeekProgress = () => {
const projects = useBudgetedProjects()
return (
<VStack gap={20}>
<SectionTitle>This week</SectionTitle>
<GoalsRequired>
<>
{projects.map((project) => (
<CurrentProjectProvider value={project} key={project.id}>
<ProjectBudgetWidget />
</CurrentProjectProvider>
))}
</>
</GoalsRequired>
</VStack>
)
}
Given the common pattern of passing a single value down the component tree, I've introduced a small helper, getValueProviderSetup
, to RadzionKit. This utility receives the name of the entity and returns a hook for accessing the value, along with a Provider component for setting the value.
import { EnhancedProject } from "@increaser/ui/projects/EnhancedProject"
import { getValueProviderSetup } from "@lib/ui/state/getValueProviderSetup"
export const { useValue: useCurrentProject, provider: CurrentProjectProvider } =
getValueProviderSetup<EnhancedProject>("Project")
The ProjectBudgetWidget
renders a visualization that helps you access if you are on track with your goal compred to the current day of the week. It's not only used in this report, we also use it in the UI for starting focused session to motivate users to work on their project to reach the goal or in the case doLess
goal to caution them about overworking.
import { useCurrentProject } from "@increaser/app/projects/components/ProjectView/CurrentProjectProvider"
import { VStack } from "@lib/ui/layout/Stack"
import { ProjectBudgetWidgetHeader } from "./ProjectBudgetWidgetHeader"
import { ProjectBudgetOverview } from "./ProjectBudgetOverview"
export const ProjectBudgetWidget = () => {
const { allocatedMinutesPerWeek } = useCurrentProject()
return (
<VStack gap={4}>
<ProjectBudgetWidgetHeader />
<VStack style={{ height: 28 }}>
{allocatedMinutesPerWeek > 0 && <ProjectBudgetOverview />}
</VStack>
</VStack>
)
}
In the widget header, we display the goal indicator and project name on the left side, and the worked time alongside the allocated time on the right side. To insert a slash between the two values, we utilize the HStackSeparatedBy
component from RadzionKit. Thanks to the setup of the project provider, we can easily retrieve the current project and display its data using the useCurrentProject
hook.
import { HStack } from "@lib/ui/layout/Stack"
import { Text } from "@lib/ui/text"
import styled from "styled-components"
import { useCurrentProject } from "../../components/ProjectView/CurrentProjectProvider"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { AppPath } from "@increaser/ui/navigation/AppPath"
import Link from "next/link"
import { ProjectGoalShyIndicator } from "../ProjectGoalShyIndicator"
import { HStackSeparatedBy } from "@lib/ui/layout/StackSeparatedBy"
const Container = styled(HStack)`
width: 100%;
align-items: center;
justify-content: space-between;
gap: 20px;
font-size: 14px;
`
export const ProjectBudgetWidgetHeader = () => {
const { allocatedMinutesPerWeek, doneMinutesThisWeek, goal, name } =
useCurrentProject()
return (
<Container>
<HStack alignItems="center" gap={4}>
<ProjectGoalShyIndicator value={goal ?? null} />
<Text weight="semibold" color="contrast">
{name}
</Text>
</HStack>
<Link href={AppPath.ProjectsBudget}>
<HStackSeparatedBy separator="/">
<Text weight="semibold" color="contrast">
{doneMinutesThisWeek > 0
? formatDuration(doneMinutesThisWeek, "min", {
maxUnit: "h",
})
: "-"}
</Text>
{allocatedMinutesPerWeek > 0 && (
<Text color="supporting" weight="semibold">
{formatDuration(allocatedMinutesPerWeek, "min", {
maxUnit: "h",
})}
</Text>
)}
</HStackSeparatedBy>
</Link>
</Container>
)
}
In the ProjectBudgetOverview
component, it's essential to accurately visualize whether the user has met or failed to meet their goal. Additionally, if the final day of the week has not yet arrived, we display an offset. This offset appears in red if the user is behind the target and in green if they are ahead. To achieve this, we employ the Offset
styled component, which is absolutely positioned and has its width and left value set based on the user's progress. The Fill
component is utilized to represent the proportion of the user's worked time relative to the allocated time.
import styled, { useTheme } from "styled-components"
import { transition } from "@lib/ui/css/transition"
import { borderRadius } from "@lib/ui/css/borderRadius"
import { getColor } from "@lib/ui/theme/getters"
import { useCurrentProject } from "../../components/ProjectView/CurrentProjectProvider"
import { toPercents } from "@lib/utils/toPercents"
import { useMemo } from "react"
import { useCurrentDayTarget } from "../hooks/useCurrentDayTarget"
import { ProjectBudgetWidgetDays } from "./ProjectBudgetWidgetDays"
import { useHasReachedFinalWorkday } from "../hooks/useHasReachedFinalWorkday"
import { match } from "@lib/utils/match"
const Container = styled.div`
position: relative;
display: flex;
align-items: center;
${borderRadius.m};
${transition};
height: 100%;
background: ${getColor("foreground")};
border: 1px solid ${getColor("mist")};
overflow: hidden;
`
const Fill = styled.div`
height: 100%;
${transition};
background: ${getColor("background")};
`
const Offset = styled.div`
position: absolute;
top: 0;
height: 100%;
${transition};
`
export const ProjectBudgetOverview = () => {
const { goal, allocatedMinutesPerWeek, doneMinutesThisWeek } =
useCurrentProject()
const { colors } = useTheme()
const hasReachedFinalDay = useHasReachedFinalWorkday()
const hasReachedGoal = useMemo(() => {
if (!goal) return false
if (goal === "doMore") {
return doneMinutesThisWeek >= allocatedMinutesPerWeek
}
return hasReachedFinalDay && doneMinutesThisWeek <= allocatedMinutesPerWeek
}, [allocatedMinutesPerWeek, doneMinutesThisWeek, goal, hasReachedFinalDay])
const target = useCurrentDayTarget()
const isUnderTarget = doneMinutesThisWeek < target
return (
<Container
style={
goal && (hasReachedFinalDay || hasReachedGoal)
? {
borderColor: (hasReachedGoal
? colors.success
: colors.alert
).toCssValue(),
}
: {}
}
>
<Fill
style={{
width: toPercents(
Math.min(doneMinutesThisWeek / allocatedMinutesPerWeek, 1)
),
}}
/>
{goal && !(hasReachedFinalDay || hasReachedGoal) && (
<>
<Offset
style={{
left: toPercents(
isUnderTarget
? doneMinutesThisWeek / allocatedMinutesPerWeek
: target / allocatedMinutesPerWeek
),
width: toPercents(
isUnderTarget
? (target - doneMinutesThisWeek) / allocatedMinutesPerWeek
: (doneMinutesThisWeek - target) / allocatedMinutesPerWeek
),
background: (isUnderTarget
? match(goal, {
doMore: () => colors.alert,
doLess: () => colors.success,
})
: match(goal, {
doMore: () => colors.success,
doLess: () => colors.alert,
})
).toCssValue(),
}}
/>
</>
)}
<ProjectBudgetWidgetDays />
</Container>
)
}
The useHasReachedFinalWorkday
hook determines if the current day aligns with the end of the work period. It returns true if today is Friday, Saturday, or Sunday for projects with "workdays" set as their working days, or if today is Sunday for projects designated to "everyday" working days.
import { useWeekday } from "@lib/ui/hooks/useWeekday"
import { useProjectDaysAllocation } from "./useProjectDaysAllocation"
export const useHasReachedFinalWorkday = () => {
const weekday = useWeekday()
const allocation = useProjectDaysAllocation()
return weekday + 1 >= allocation.length
}
The ProjectBudgetWidgetDays
component displays the days of the week when the user hovers over the progress bar. The useProjectDaysAllocation
hook returns the proportion of time allocated to each day, ensuring that the total for all days equals one. The Container
is an absolutely positioned flexbox element set to a row direction. To populate this container, we iterate over each day, rendering a segment with the weekday name centered and a border on the right side.
import { takeWholeSpaceAbsolutely } from "@lib/ui/css/takeWholeSpaceAbsolutely"
import { HStack, VStack } from "@lib/ui/layout/Stack"
import styled, { useTheme } from "styled-components"
import { Text } from "@lib/ui/text"
import { useWeekday } from "@lib/ui/hooks/useWeekday"
import { toPercents } from "@lib/utils/toPercents"
import { getShortWeekday } from "@lib/utils/time"
import { useProjectDaysAllocation } from "../hooks/useProjectDaysAllocation"
import { transition } from "@lib/ui/css/transition"
const DayName = styled(Text)`
font-size: 12px;
font-weight: 500;
`
const Day = styled(VStack)`
height: 100%;
border-right: 1px dashed;
align-items: center;
justify-content: center;
padding-right: 4px;
opacity: 0;
${transition};
`
const Container = styled(HStack)`
${takeWholeSpaceAbsolutely};
&:hover ${Day} {
opacity: 1;
}
`
export const ProjectBudgetWidgetDays = () => {
const weekday = useWeekday()
const { colors } = useTheme()
const segments = useProjectDaysAllocation()
return (
<Container>
{segments.map((value, index) => {
if (value === 0) return null
return (
<Day
key={index}
style={{
width: toPercents(value / 1),
color: (index === weekday
? colors.contrast
: colors.textSupporting
).toCssValue(),
borderWidth: index === segments.length - 1 ? 0 : 1,
}}
>
<DayName>{getShortWeekday(index)}</DayName>
</Day>
)
})}
</Container>
)
}
In the PreviousWeeksProgress
component, we iterate over each budgeted project and display the ProjectPreviousWeeks
component, which presents a chart to help visualize how the previous weeks' performance compares to the allocated time. To streamline data management and avoid prop drilling, we utilize the CurrentProjectProvider
. Additionally, if no projects are budgeted, the GoalsRequired
component displays an informational message to alert the user.
import { CurrentProjectProvider } from "../../components/ProjectView/CurrentProjectProvider"
import { VStack } from "@lib/ui/layout/Stack"
import { GoalsRequired } from "../GoalsRequired"
import { ProjectPreviousWeeks } from "../ProjectPreviousWeeks"
import { useBudgetedProjects } from "../hooks/useBudgetedProjects"
import { pluralize } from "@lib/utils/pluralize"
import { previousWeeksConfig } from "./previousWeeksConfig"
import { SectionTitle } from "@lib/ui/text/SectionTitle"
export const PreviousWeeksProgress = () => {
const projectsWithGoals = useBudgetedProjects()
return (
<VStack gap={20}>
<SectionTitle>
Previous {pluralize(previousWeeksConfig.weeks, "week")}
</SectionTitle>
<GoalsRequired>
<>
{projectsWithGoals.map((project) => (
<CurrentProjectProvider value={project} key={project.id}>
<ProjectPreviousWeeks />
</CurrentProjectProvider>
))}
</>
</GoalsRequired>
</VStack>
)
}
In the ProjectPreviousWeeks
component, we first extract the tracked hours from the previous eight weeks and verify if there's at least one week with tracked hours before proceeding to draw the line chart. To link the project with its respective chart, we display the project name alongside a colored circle, enhancing visual association and clarity.
import { HStack, VStack } from "@lib/ui/layout/Stack"
import { useCurrentProject } from "../components/ProjectView/CurrentProjectProvider"
import { Text } from "@lib/ui/text"
import { ProjectGoalChart } from "./PreviousWeeks/ProjectGoalChart"
import { useCurrentProjectPrevWeeks } from "../hooks/useCurrentProjectPrevWeeks"
import { ShyInfoBlock } from "@lib/ui/info/ShyInfoBlock"
import { previousWeeksConfig } from "./PreviousWeeks/previousWeeksConfig"
import { Circle } from "@lib/ui/layout/Circle"
import styled from "styled-components"
import { round } from "@lib/ui/css/round"
import { getColor } from "@lib/ui/theme/getters"
const Pill = styled.div`
${round};
padding: 4px 8px;
background: ${getColor("mist")};
`
export const ProjectPreviousWeeks = () => {
const { name, hslaColor } = useCurrentProject()
const weeks = useCurrentProjectPrevWeeks(previousWeeksConfig.weeks)
return (
<VStack gap={12}>
{weeks.some((week) => week.seconds > 0) ? (
<ProjectGoalChart value={weeks} />
) : (
<ShyInfoBlock>No time tracked in the previous weeks</ShyInfoBlock>
)}
<HStack alignItems="center" justifyContent="center" fullWidth gap={6}>
<Pill>
<HStack alignItems="center" fullWidth gap={8}>
<Circle size={8} background={hslaColor} />
<Text size={14} weight="semibold">
{name}
</Text>
</HStack>
</Pill>
</HStack>
</VStack>
)
}
Our ProjectGoalChart
component utilizes reusable components specifically designed for creating line charts. While we won't explore the specifics of each component in this article, you can find a comprehensive guide on how to construct line charts without relying on external charting libraries in this article.
import { ProjectWeek } from "@increaser/entities/timeTracking"
import { ComponentWithValueProps } from "@lib/ui/props"
import { useState } from "react"
import { useCurrentProject } from "../../components/ProjectView/CurrentProjectProvider"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { ElementSizeAware } from "@lib/ui/base/ElementSizeAware"
import { HStack, VStack } from "@lib/ui/layout/Stack"
import { Spacer } from "@lib/ui/layout/Spacer"
import { LineChartItemInfo } from "@lib/ui/charts/LineChart/LineChartItemInfo"
import { Text } from "@lib/ui/text"
import { EmphasizeNumbers } from "@lib/ui/text/EmphasizeNumbers"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { format } from "date-fns"
import { fromWeek } from "@lib/utils/time/Week"
import { ChartYAxis } from "@lib/ui/charts/ChartYAxis"
import { LineChart } from "@lib/ui/charts/LineChart"
import { LineChartPositionTracker } from "@lib/ui/charts/LineChart/LineChartPositionTracker"
import { PositionAbsolutelyCenterHorizontally } from "@lib/ui/layout/PositionAbsolutelyCenterHorizontally"
import { toPercents } from "@lib/utils/toPercents"
import styled from "styled-components"
import { getColor } from "@lib/ui/theme/getters"
import { normalizeDataArrays } from "@lib/utils/math/normalizeDataArrays"
export const lineChartConfig = {
chartHeight: 80,
expectedYAxisLabelWidth: 32,
expectedLabelWidth: 58,
expectedLabelHeight: 18,
labelsMinDistance: 20,
}
const Line = styled.div`
border-bottom: 1px dashed ${getColor("textShy")};
width: 100%;
pointer-events: none;
`
export const ProjectGoalChart = ({
value,
}: ComponentWithValueProps<ProjectWeek[]>) => {
const { allocatedMinutesPerWeek, hslaColor } = useCurrentProject()
const targets = [convertDuration(allocatedMinutesPerWeek, "min", "s")]
const normalized = normalizeDataArrays({
done: value.map((week) => week.seconds),
targets,
})
const [selectedDataPoint, setSelectedDataPoint] = useState<number>(
value.length - 1
)
const [isSelectedDataPointVisible, setIsSelectedDataPointVisible] =
useState<boolean>(false)
const selectedDataPointStartedAt = fromWeek(value[selectedDataPoint])
return (
<ElementSizeAware
render={({ setElement, size }) => {
return (
<VStack fullWidth gap={20} ref={setElement}>
{size && (
<>
<HStack>
<Spacer width={lineChartConfig.expectedYAxisLabelWidth} />
<LineChartItemInfo
itemIndex={selectedDataPoint}
isVisible={isSelectedDataPointVisible}
containerWidth={
size.width - lineChartConfig.expectedYAxisLabelWidth
}
dataPointsNumber={value.length}
>
<VStack>
<Text color="contrast" weight="semibold">
<EmphasizeNumbers
value={formatDuration(
value[selectedDataPoint].seconds,
"s",
{
maxUnit: "h",
}
)}
/>
</Text>
<Text color="supporting" size={14} weight="semibold">
{`${format(
selectedDataPointStartedAt,
"d MMM"
)} - ${format(
selectedDataPointStartedAt +
convertDuration(1, "w", "ms"),
"d MMM"
)}`}
</Text>
</VStack>
</LineChartItemInfo>
</HStack>
<HStack>
<ChartYAxis
expectedLabelWidth={lineChartConfig.expectedYAxisLabelWidth}
renderLabel={(index) => (
<Text key={index} size={12} color="supporting">
{formatDuration(targets[index], "s", {
maxUnit: "h",
minUnit: "h",
})}
</Text>
)}
data={normalized.targets}
/>
<VStack
style={{
position: "relative",
minHeight: lineChartConfig.chartHeight,
}}
fullWidth
>
<PositionAbsolutelyCenterHorizontally
top={toPercents(1 - normalized.targets[0])}
fullWidth
>
<Line />
</PositionAbsolutelyCenterHorizontally>
<LineChart
dataPointsConnectionKind="sharp"
fillKind={"gradient"}
data={normalized.done}
width={
size.width - lineChartConfig.expectedYAxisLabelWidth
}
height={lineChartConfig.chartHeight}
color={hslaColor}
/>
<LineChartPositionTracker
data={normalized.done}
color={hslaColor}
onChange={(index) => {
if (index === null) {
setIsSelectedDataPointVisible(false)
} else {
setIsSelectedDataPointVisible(true)
setSelectedDataPoint(index)
}
}}
/>
</VStack>
</HStack>
</>
)}
</VStack>
)
}}
/>
)
}
Before rendering the chart and the line, it's crucial to normalize the data to ensure all data points fit within the range from 0 to 1, as required by our charting components. To accomplish this, we utilize the normalizeDataArrays
utility from RadzionKit. We input the tracked time across previous weeks and an array of the budgeted time into this utility. To display the budget line accurately, we simply take the first element from the normalized target array and subtract it from 1 to determine the correct top attribute.
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
}
If a user wishes to determine precisely how much they worked on any specific week, they simply need to hover over the chart. The LineChartPositionTracker
will activate isSelectedDataPointVisible
, and the LineChartItemInfo
will take the data point by index. It then converts it to a readable format using the formatDuration
utility from RadzionKit, appending the week's start and end dates beneath the tracked time value.