✨ Watch on YouTube | 🐙 GitHub | 🎮 Demo
Building a Time-Tracking Feature with React and TypeScript
In this article, we’ll build an exciting feature for a real productivity app using React and TypeScript. We’ll create an interactive timeline that allows users to track their time seamlessly by adding, editing, and deleting sessions without traditional input fields. Instead, the interface will resemble a calendar app. Although the Increaser source code is in a private repository, you can still explore all the reusable components and utilities in the RadzionKit repository.
Setting Up the Initial React State and Context for Session Management
To start, let’s set up the essential React state for this feature. Our mutable state will consist of two fields:
weekday
: An index representing the selected day of the week where the user wants to add, edit, or delete a session.currentSet
: This field will be updated when a user clicks on an existing session or starts creating a new one.
We’ll extend the Set
type with an optional index
field to identify which session is being edited. This will help us distinguish between editing an existing session and creating a new one.
import { Set } from "@increaser/entities/User"
import { createContextHook } from "@lib/ui/state/createContextHook"
import { Dispatch, SetStateAction, createContext } from "react"
import { Interval } from "@lib/utils/interval/Interval"
type TrackTimeSet = Set & {
index?: number
}
export type TrackTimeMutableState = {
weekday: number
currentSet: TrackTimeSet | null
}
type TrackTimeState = TrackTimeMutableState & {
setState: Dispatch<SetStateAction<TrackTimeMutableState>>
sets: Set[]
dayInterval: Interval
}
export const TrackTimeContext = createContext<TrackTimeState | undefined>(
undefined
)
export const useTrackTime = createContextHook(TrackTimeContext, "TrackTime")
In Increaser, we represent a work session as an interval where start and end times are stored as timestamps and projectId
is a reference to the project the session belongs to.
export type Interval = {
start: number
end: number
}
export type Set = Interval & {
projectId: string
}
Our context state will include:
- A mutable state to keep track of changes.
- A
setState
function to modify the mutable state. - An array of sets representing sessions for the selected day.
- An interval reflecting the timeframe of the selected day. For past days, the interval will span from the start to the end of the day. For the current day, the interval will range from the start of the day up to the current time.
To create a hook for accessing the context state, we use a small helper function called createContextHook
. This function checks if the context is available and throws an error if it's not provided.
import { Context as ReactContext, useContext } from "react"
export function createContextHook<T>(
Context: ReactContext<T | undefined>,
contextName: string
) {
return () => {
const context = useContext(Context)
if (!context) {
throw new Error(`${contextName} is not provided`)
}
return context
}
}
The TrackTimeContext
provider will manage the mutable state, initializing weekday
to the current day of the week and setting currentSet
to null
initially. To supply dayInterval
and sets
to the context, we manipulate the existing state provided by various hooks. This setup ensures that the relevant data is easily accessible and consistently updated.
import { useCurrentWeekSets } from "@increaser/ui/sets/hooks/useCurrentWeekSets"
import { useWeekday } from "@lib/ui/hooks/useWeekday"
import { ComponentWithChildrenProps } from "@lib/ui/props"
import { useMemo, useState } from "react"
import { getDaySets } from "../../sets/helpers/getDaySets"
import { useStartOfWeek } from "@lib/ui/hooks/useStartOfWeek"
import { convertDuration } from "@lib/utils/time/convertDuration"
import {
TrackTimeContext,
TrackTimeMutableState,
} from "./state/TrackTimeContext"
export const TrackTimeProvider = ({ children }: ComponentWithChildrenProps) => {
const currentWeekday = useWeekday()
const [state, setState] = useState<TrackTimeMutableState>({
weekday: currentWeekday,
currentSet: null,
})
const { weekday } = state
const currentWeekSets = useCurrentWeekSets()
const weekStartedAt = useStartOfWeek()
const dayInterval = useMemo(() => {
const start = weekStartedAt + convertDuration(weekday, "d", "ms")
const end =
weekday === currentWeekday
? Date.now()
: start + convertDuration(1, "d", "ms")
return { start, end }
}, [currentWeekday, weekStartedAt, weekday])
const sets = useMemo(() => {
return getDaySets(currentWeekSets, dayInterval.start)
}, [currentWeekSets, dayInterval.start])
return (
<TrackTimeContext.Provider
value={{ ...state, dayInterval, sets, setState }}
>
{children}
</TrackTimeContext.Provider>
)
}
Structuring the Time-Tracking Feature: Header, Content, and Footer
Our time-tracking feature comprises three key parts: a header, a content area where users interact with sessions, and a footer with action buttons. By applying flex: 1
to all containers, the content area occupies the entire available vertical space. The Panel
component from RadzionKit helps organize the content and footer, separating them with a line for a clear visual distinction.
import { Panel } from "@lib/ui/panel/Panel"
import { VStack } from "@lib/ui/layout/Stack"
import styled from "styled-components"
import { TrackTimeFooter } from "./TrackTimeFooter"
import { TrackTimeHeader } from "./TrackTimeHeader"
import { TrackTimeContent } from "./TrackTimeContent"
const Container = styled(VStack)`
max-width: 440px;
gap: 16px;
flex: 1;
`
export const TrackTime = () => (
<Container>
<TrackTimeHeader />
<Panel style={{ flex: 1 }} kind="secondary" withSections>
<TrackTimeContent />
<TrackTimeFooter />
</Panel>
</Container>
)
Our header is a flexbox row container with the title aligned to the left and two selectors positioned on the right. The ProjectSelector
is displayed only when the user is either creating or editing a session.
import { useTrackTime } from "./state/TrackTimeContext"
import { WeekdaySelector } from "./WeekdaySelector"
import { HStack } from "@lib/ui/layout/Stack"
import { ProjectSelector } from "./ProjectSelector"
import { TrackTimeTitle } from "./TrackTimeTitle"
export const TrackTimeHeader = () => {
const { currentSet } = useTrackTime()
return (
<HStack
fullWidth
alignItems="center"
justifyContent="space-between"
gap={20}
wrap="wrap"
>
<TrackTimeTitle />
<HStack alignItems="center" gap={8}>
{currentSet && <ProjectSelector />}
<WeekdaySelector />
</HStack>
</HStack>
)
}
The TrackTimeTitle
component displays a title based on the current state. If no session is selected, the title will read "Manage sessions." If a session has an index, indicating that an existing session is being edited, the title will be "Edit session." Otherwise, the title will be "Add session."
import { useTrackTime } from "./state/TrackTimeContext"
import { SectionTitle } from "@lib/ui/text/SectionTitle"
import { useMemo } from "react"
export const TrackTimeTitle = () => {
const { currentSet } = useTrackTime()
const title = useMemo(() => {
if (!currentSet) {
return "Manage sessions"
}
if (currentSet.index !== undefined) {
return "Edit session"
}
return "Add session"
}, [currentSet])
return <SectionTitle>{title}</SectionTitle>
}
Both the ProjectSelector
and WeekdaySelector
components are dropdowns built on the ExpandableSelector
component from RadzionKit. Although currentSet
may sometimes be empty, we know that when the ProjectSelector
is displayed, currentSet
is populated. Thus, we use the shouldBePresent
utility to confirm that the value is not null.
import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { ExpandableSelector } from "@lib/ui/select/ExpandableSelector"
import { Text } from "@lib/ui/text"
import { useTrackTime } from "./state/TrackTimeContext"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
export const ProjectSelector = () => {
const { currentSet, setState } = useTrackTime()
const { projectId } = shouldBePresent(currentSet)
const { activeProjects, projectsRecord } = useProjects()
return (
<ExpandableSelector
style={{ width: 142 }}
value={projectId}
onChange={(projectId) =>
setState((state) => ({
...state,
currentSet: {
...shouldBePresent(state.currentSet),
projectId,
},
}))
}
options={activeProjects.map((project) => project.id)}
getOptionKey={(option) => option}
renderOption={(option) => (
<>
<Text color="contrast">{projectsRecord[option].emoji}</Text>
<Text>{option ? projectsRecord[option].name : "All projects"}</Text>
</>
)}
/>
)
}
At the end of each week and month Increaser sets the week and month totals for each projects and they can’t be changed. Therefore in the WeekdaySelector
component we only give user the option to select days that belong to the current week and month. We also disable the selector when the user edits or creates a session.
import { useAssertUserState } from "@increaser/ui/user/UserStateContext"
import { useStartOfWeek } from "@lib/ui/hooks/useStartOfWeek"
import { useWeekday } from "@lib/ui/hooks/useWeekday"
import { ExpandableSelector } from "@lib/ui/select/ExpandableSelector"
import { range } from "@lib/utils/array/range"
import { WEEKDAYS } from "@lib/utils/time"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { useMemo } from "react"
import { useTrackTime } from "./state/TrackTimeContext"
export const WeekdaySelector = () => {
const { weekday, setState, currentSet } = useTrackTime()
const { lastSyncedMonthEndedAt, lastSyncedWeekEndedAt } = useAssertUserState()
const currentWeekday = useWeekday()
const weekStartedAt = useStartOfWeek()
const options = useMemo(() => {
const weekdays = range(currentWeekday + 1)
const minStartedAt = Math.max(
lastSyncedMonthEndedAt ?? 0,
lastSyncedWeekEndedAt ?? 0
)
return weekdays.filter((weekday) => {
const dayStartedAt = weekStartedAt + convertDuration(weekday, "d", "ms")
return dayStartedAt >= minStartedAt
})
}, [
currentWeekday,
lastSyncedMonthEndedAt,
lastSyncedWeekEndedAt,
weekStartedAt,
])
return (
<ExpandableSelector
style={{ width: 142 }}
isDisabled={currentSet !== null}
value={weekday}
onChange={(weekday) => setState((state) => ({ ...state, weekday }))}
options={options.toReversed()}
getOptionKey={(option) => option.toString()}
renderOption={(option) => {
if (option === currentWeekday) {
return "Today"
}
if (option === currentWeekday - 1) {
return "Yesterday"
}
return WEEKDAYS[option]
}}
/>
)
}
In the TrackTimeFooter
component, we include the DeleteSetAction
component when the user is editing an existing session. If the user is creating or editing a session, we show "Submit" and "Cancel" buttons. Otherwise, the AddSetPrompt
component is displayed.
import { Button } from "@lib/ui/buttons/Button"
import { HStack } from "@lib/ui/layout/Stack"
import { useTrackTime } from "./state/TrackTimeContext"
import { DeleteSetAction } from "./DeleteSetAction"
import { AddSetPrompt } from "./AddSetPrompt"
import { SubmitSetAction } from "./SubmitSetAction"
export const TrackTimeFooter = () => {
const { setState, currentSet } = useTrackTime()
return (
<HStack gap={12} wrap="wrap" fullWidth justifyContent="space-between">
{currentSet?.index === undefined ? <div /> : <DeleteSetAction />}
{currentSet ? (
<HStack gap={12}>
<Button
onClick={() =>
setState((state) => ({
...state,
currentSet: null,
}))
}
kind="secondary"
>
Cancel
</Button>
<SubmitSetAction />
</HStack>
) : (
<AddSetPrompt />
)}
</HStack>
)
}
When the user clicks the “Delete” button, we invoke the useDeleteSetMutation
hook to remove the session and set currentSet
to null
, thereby concluding the editing process.
import { Button } from "@lib/ui/buttons/Button"
import { analytics } from "../../analytics"
import { useTrackTime } from "./state/TrackTimeContext"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { useDeleteSetMutation } from "../../sets/hooks/useDeleteSetMutation"
export const DeleteSetAction = () => {
const { sets, setState, currentSet } = useTrackTime()
const { mutate: deleteSet } = useDeleteSetMutation()
return (
<Button
onClick={() => {
deleteSet(sets[shouldBePresent(currentSet?.index)])
analytics.trackEvent("Delete session")
setState((state) => ({
...state,
currentSet: null,
}))
}}
kind="alert"
>
Delete
</Button>
)
}
Deleting Sessions with useDeleteSetMutation
The useDeleteSetMutation
hook optimistically updates the client state and then calls the API to delete the session, using the interval as the input.
import { useMutation } from "@tanstack/react-query"
import { useApi } from "@increaser/api-ui/hooks/useApi"
import {
useAssertUserState,
useUserState,
} from "@increaser/ui/user/UserStateContext"
import { deleteSet } from "@increaser/entities-utils/set/deleteSet"
import { Interval } from "@lib/utils/interval/Interval"
export const useDeleteSetMutation = () => {
const api = useApi()
const { updateState } = useUserState()
const { sets } = useAssertUserState()
return useMutation({
mutationFn: (value: Interval) => {
updateState({ sets: deleteSet({ sets, value }) })
return api.call("deleteSet", value)
},
})
}
On the server side, we’ll use the same deleteSet
function from the @increaser/entities-utils
package to generate a new array of sets that excludes the specified session. For a deeper dive into building backends in a TypeScript monorepo, check out this article.
import { getUser, updateUser } from "@increaser/db/user"
import { assertUserId } from "../../auth/assertUserId"
import { ApiResolver } from "../../resolvers/ApiResolver"
import { deleteSet as remove } from "@increaser/entities-utils/set/deleteSet"
export const deleteSet: ApiResolver<"deleteSet"> = async ({
input,
context,
}) => {
const userId = assertUserId(context)
const { sets } = await getUser(userId, ["sets"])
await updateUser(userId, {
sets: remove({ sets, value: input }),
})
}
Since no two sessions can share identical start and end timestamps, we identify sessions by their intervals. To compare intervals, we’ll use the areEqualIntervals
function, which wraps around the haveEqualFields
utility from RadzionKit. This utility checks whether two objects share the same values for specified fields.
import { Set } from "@increaser/entities/User"
import { Interval } from "@lib/utils/interval/Interval"
import { areEqualIntervals } from "@lib/utils/interval/areEqualIntervals"
type DeleteSetInput = {
sets: Set[]
value: Interval
}
export const deleteSet = ({ sets, value }: DeleteSetInput) =>
sets.filter((set) => !areEqualIntervals(set, value))
Adding and Updating Sessions with SubmitSetAction
The SubmitSetAction
component follows a single validation rule: a session cannot overlap with another session. Depending on whether the index
field is present, we call either the addSet
or updateSet
mutation. The respective hooks follow the same pattern as the deleteSet
mutation we covered earlier.
import { Button } from "@lib/ui/buttons/Button"
import { useMemo } from "react"
import { useAddSetMutation } from "../../sets/hooks/useAddSetMutation"
import { analytics } from "../../analytics"
import { areIntersecting } from "@lib/utils/interval/areIntersecting"
import { useTrackTime } from "./state/TrackTimeContext"
import { useUpdateSetMutation } from "../../sets/hooks/useUpdateSetMutation"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
export const SubmitSetAction = () => {
const { sets, setState, currentSet: potentialCurrentSet } = useTrackTime()
const currentSet = shouldBePresent(potentialCurrentSet)
const isDisabled = useMemo(() => {
const hasIntersection = sets.some((set, index) =>
currentSet.index === index ? false : areIntersecting(set, currentSet)
)
if (hasIntersection) {
return "This session intersects with another session"
}
return false
}, [currentSet, sets])
const { mutate: addSet } = useAddSetMutation()
const { mutate: updateSet } = useUpdateSetMutation()
const onSubmit = () => {
if (currentSet.index === undefined) {
addSet(currentSet)
analytics.trackEvent("Add session")
} else {
updateSet({
old: sets[currentSet.index],
new: currentSet,
})
analytics.trackEvent("Update session")
}
setState((state) => ({
...state,
currentSet: null,
}))
}
return (
<Button onClick={onSubmit} isDisabled={isDisabled}>
Submit
</Button>
)
}
When the user clicks the “Add Session” button, we update the currentSet
field to a new object. The start time is set to the end of the dayInterval
minus the default duration, and the end time is set to the end of the dayInterval
. The projectId
is initialized with the ID of the first active project. To convert minutes into milliseconds, we use the convertDuration
utility from RadzionKit.
import { Button } from "@lib/ui/buttons/Button"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { defaultIntervalDuration } from "./config"
import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { useTrackTime } from "./state/TrackTimeContext"
export const AddSetPrompt = () => {
const { activeProjects } = useProjects()
const { setState, dayInterval } = useTrackTime()
return (
<Button
onClick={() =>
setState((state) => ({
...state,
currentSet: {
start:
dayInterval.end -
convertDuration(defaultIntervalDuration, "min", "ms"),
end: dayInterval.end,
projectId: activeProjects[0].id,
},
}))
}
>
Add Session
</Button>
)
}
Building the Timeline with TimeSpace
The core of our time-tracking feature is the timeline itself. The layout uses a relative wrapper that occupies all available space with flex: 1
, and an absolutely positioned container with overflow-y: auto
. This arrangement ensures that the feature fills the available space, while the content area remains scrollable.
import { panelDefaultPadding } from "@lib/ui/panel/Panel"
import styled from "styled-components"
import { TakeWholeSpaceAbsolutely } from "@lib/ui/css/takeWholeSpaceAbsolutely"
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { useTrackTime } from "./state/TrackTimeContext"
import { TimeSpace } from "@lib/ui/timeline/TimeSpace"
import { msToPx } from "./config"
import { Sessions } from "./Sessions"
import { ScrollIntoViewOnFirstAppearance } from "@lib/ui/base/ScrollIntoViewOnFirstAppearance"
import { SetEditor } from "./SetEditor"
const Wrapper = styled.div`
flex: 1;
position: relative;
`
const Container = styled(TakeWholeSpaceAbsolutely)`
overflow-y: auto;
padding: ${toSizeUnit(panelDefaultPadding)};
`
const DefaultScrollPosition = styled.div`
position: absolute;
left: 0;
bottom: 0;
`
export const TrackTimeContent = () => {
const { currentSet, dayInterval } = useTrackTime()
return (
<Wrapper>
<Container>
<TimeSpace
msToPx={msToPx}
startsAt={dayInterval.start}
endsAt={dayInterval.end}
>
<Sessions />
{currentSet && <SetEditor />}
<ScrollIntoViewOnFirstAppearance<HTMLDivElement>
render={(props) => <DefaultScrollPosition {...props} />}
/>
</TimeSpace>
</Container>
</Wrapper>
)
}
The TimeSpace
component draws lines with hourly time markers within the range specified by the startsAt
and endsAt
timestamps. Using the msToPx
function, we determine how many pixels should represent one millisecond, ensuring accurate scaling.
import { MS_IN_HOUR } from "@lib/utils/time"
export const pxInHour = 100
export const pxInMs = pxInHour / MS_IN_HOUR
export const msToPx = (ms: number) => ms * pxInMs
export const pxToMs = (px: number) => px / pxInMs
export const defaultIntervalDuration = 30
The TimeSpace
component has a fixed height, which is calculated by passing the difference between the endsAt
and startsAt
timestamps through the msToPx
function. The getHoursInRange
utility generates an array of hourly timestamps within the specified range. We use the PositionAbsolutelyCenterHorizontally
component from RadzionKit to vertically center the time labels and lines.
import styled from "styled-components"
import { getColor } from "../theme/getters"
import { formatTime } from "@lib/utils/time/formatTime"
import { getHoursInRange } from "@lib/utils/time/getHoursInRange"
import { Fragment } from "react"
import { PositionAbsolutelyCenterHorizontally } from "../layout/PositionAbsolutelyCenterHorizontally"
import { HStack, VStack } from "../layout/Stack"
import { ComponentWithChildrenProps } from "../props"
import { Text } from "../text"
import { toSizeUnit } from "../css/toSizeUnit"
import { verticalPadding } from "../css/verticalPadding"
interface TimeSpaceProps extends ComponentWithChildrenProps {
startsAt: number
endsAt: number
msToPx: (ms: number) => number
}
const labelSize = 12
const Label = styled(Text)`
font-size: ${toSizeUnit(labelSize)};
line-height: 1;
color: ${getColor("textSupporting")};
`
const Wrapper = styled.div`
${verticalPadding(labelSize / 2)};
user-select: none;
`
const Container = styled.div`
position: relative;
`
const Transparent = styled.div`
opacity: 0;
`
const Line = styled.div`
background: ${getColor("mistExtra")};
height: 1px;
width: 100%;
`
export const TimeSpace = ({
startsAt,
endsAt,
msToPx,
children,
}: TimeSpaceProps) => {
const height = msToPx(endsAt - startsAt)
const marks = getHoursInRange(startsAt, endsAt)
return (
<Wrapper>
<Container style={{ height }}>
<HStack fullHeight gap={8}>
<VStack style={{ position: "relative" }} fullHeight>
{marks.map((mark, index) => {
const top = msToPx(mark - startsAt)
const label = <Label>{formatTime(mark)}</Label>
return (
<Fragment key={index}>
<Transparent>{label}</Transparent>
<PositionAbsolutelyCenterHorizontally top={top}>
{label}
</PositionAbsolutelyCenterHorizontally>
</Fragment>
)
})}
</VStack>
<VStack fullWidth fullHeight style={{ position: "relative" }}>
{marks.map((mark, index) => {
const top = msToPx(mark - startsAt)
return (
<PositionAbsolutelyCenterHorizontally
key={index}
fullWidth
top={top}
>
<Line />
</PositionAbsolutelyCenterHorizontally>
)
})}
{children}
</VStack>
</HStack>
</Container>
</Wrapper>
)
}
To initially scroll the content area to the bottom of the timeline (representing the current time when the current day is selected), we use the ScrollIntoViewOnFirstAppearance
component from RadzionKit. This component ensures the provided element scrolls into view upon its initial appearance.
import React, { useRef, useEffect } from "react"
type ScrollIntoViewOnFirstAppearanceProps<T extends HTMLElement> = {
render: (props: { ref: React.RefObject<T> }) => React.ReactNode
}
export const ScrollIntoViewOnFirstAppearance = <T extends HTMLElement>({
render,
}: ScrollIntoViewOnFirstAppearanceProps<T>) => {
const element = useRef<T>(null)
const hasScrolled = useRef(false)
useEffect(() => {
if (element.current && !hasScrolled.current) {
element.current.scrollIntoView({ behavior: "smooth", block: "start" })
hasScrolled.current = true
}
}, [])
return <>{render({ ref: element })}</>
}
Managing Sessions in the Sessions Component
In the Sessions
component, we iterate through each set for the current day, rendering a Session
component for each. We position each session using top
and height
attributes, which are calculated from the session's start and end timestamps in conjunction with the msToPx
function.
import { useTrackTime } from "./state/TrackTimeContext"
import { msToPx } from "./config"
import { Session } from "./Session"
import { getSetHash } from "../../sets/helpers/getSetHash"
export const Sessions = () => {
const { dayInterval, sets } = useTrackTime()
return (
<>
{sets.map((value, index) => (
<Session
key={getSetHash(value)}
value={value}
index={index}
style={{
top: msToPx(value.start - dayInterval.start),
height: msToPx(value.end - value.start),
}}
/>
))}
</>
)
}
To prevent overlapping with the session currently being edited and rendered by the SetEditor
component, we check if the current session is being edited and avoid rendering it within the Session
component. Sessions are only clickable when no session is being edited. When the user clicks on a session, the currentSet
field is updated with that session and its index.
import { transition } from "@lib/ui/css/transition"
import { ComponentWithValueProps, UIComponentProps } from "@lib/ui/props"
import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { getProjectColor } from "@increaser/ui/projects/utils/getProjectColor"
import styled, { css, useTheme } from "styled-components"
import { Set } from "@increaser/entities/User"
import { HSLA } from "@lib/ui/colors/HSLA"
import { useTrackTime } from "./state/TrackTimeContext"
import { borderRadius } from "@lib/ui/css/borderRadius"
import { LinesFiller } from "@lib/ui/visual/LinesFiller"
const Container = styled.div<{ isInteractive: boolean; $color: HSLA }>`
position: absolute;
overflow: hidden;
width: 100%;
${borderRadius.xs};
${transition};
color: ${({ $color }) => $color.getVariant({ a: () => 0.4 }).toCssValue()};
background: ${({ $color }) =>
$color.getVariant({ a: () => 0.1 }).toCssValue()};
border: 2px solid ${({ $color }) =>
$color.getVariant({ a: () => 0.6 }).toCssValue()};
${({ isInteractive, $color }) =>
isInteractive &&
css`
cursor: pointer;
&:hover {
border-color: ${$color.toCssValue()};
color: ${$color.toCssValue()};
}
`}
`
type SessionProps = ComponentWithValueProps<Set> &
UIComponentProps & {
index: number
}
export const Session = ({ value, index, ...rest }: SessionProps) => {
const { setState, currentSet } = useTrackTime()
const { projectsRecord } = useProjects()
const theme = useTheme()
const color = getProjectColor(projectsRecord, theme, value.projectId)
if (currentSet?.index === index) {
return null
}
return (
<Container
onClick={
!currentSet
? () => {
setState((state) => ({
...state,
currentSet: {
...value,
index,
},
}))
}
: undefined
}
isInteractive={!currentSet}
$color={color}
{...rest}
>
<LinesFiller density={0.28} rotation={45 * (index % 2 === 0 ? 1 : -1)} />
</Container>
)
}
We determine the session’s color using the getProjectColor
utility, which returns a project's color in HSLA format. If you're curious about managing colors with HSLA in a React app, check out this article. By using the getVariant
method on an HSLA instance, we can conveniently adjust the color's alpha channel.
import { TakeWholeSpaceAbsolutely } from "@lib/ui/css/takeWholeSpaceAbsolutely"
import { HStack } from "@lib/ui/layout/Stack"
import { range } from "@lib/utils/array/range"
import styled from "styled-components"
import { ElementSizeAware } from "../base/ElementSizeAware"
import { toSizeUnit } from "../css/toSizeUnit"
import { ElementSize } from "../hooks/useElementSize"
import { calculateRightAngleTriangleSide } from "@lib/utils/math/calculateRightAngleTriangleSide"
import { calculateHypotenuse } from "@lib/utils/math/calculateHypotenuse"
import { degreesToRadians } from "@lib/utils/degreesToRadians"
const Wrapper = styled(TakeWholeSpaceAbsolutely)`
overflow: hidden;
`
const Container = styled(HStack)`
height: 100%;
justify-content: space-between;
align-items: center;
`
type LinesFillerProps = {
rotation?: number
density?: number
lineWidth?: number
}
export const LinesFiller = ({
rotation = 45,
lineWidth = 2,
density = 0.32,
}: LinesFillerProps) => {
return (
<ElementSizeAware
render={({ setElement, size }) => {
const fill = ({ width, height }: ElementSize) => {
const offset = calculateRightAngleTriangleSide({
givenSideLength: height,
angleInRadians: degreesToRadians(Math.abs(rotation)),
knownSide: "adjacent",
})
const totalWidth = width + offset
const count = Math.round((totalWidth / lineWidth) * density)
const lineSize = calculateHypotenuse(offset, height)
return (
<Container
style={{
width: totalWidth,
marginLeft: -(offset / 2),
}}
>
{range(count).map((index) => (
<div
style={{
height: lineSize,
transform: `rotate(${rotation}deg)`,
borderLeft: `${toSizeUnit(lineWidth)} solid`,
}}
key={index}
/>
))}
</Container>
)
}
return <Wrapper ref={setElement}>{size && fill(size)}</Wrapper>
}}
/>
)
}
For a better visual appeal, we fill the session with diagonal lines using the LinesFiller
component from RadzionKit. To create the pattern, we use a component that occupies the entire space of its parent and measures its size. We then apply trigonometric calculations to determine the appropriate length of the lines and an offset for the container. This container is wider than the parent, ensuring it covers the whole area with diagonal lines. The lines are rotated using the transform
property and positioned within a flexbox row container with evenly spaced lines. To allow the component's color to be customized, we use the border
property so that it inherits the border color from the parent's color property, providing full flexibility in visual styling.
import { TakeWholeSpace } from "@lib/ui/css/takeWholeSpace"
import { IntervalEditorControl } from "@lib/ui/timeline/IntervalEditorControl"
import { useEffect, useMemo, useRef, useState } from "react"
import { useEvent } from "react-use"
import { useTrackTime } from "./state/TrackTimeContext"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { getIntervalDuration } from "@lib/utils/interval/getIntervalDuration"
import { msToPx, pxToMs } from "./config"
import { enforceRange } from "@lib/utils/enforceRange"
import { MoveIcon } from "@lib/ui/icons/MoveIcon"
import { PositionAbsolutelyCenterHorizontally } from "@lib/ui/layout/PositionAbsolutelyCenterHorizontally"
import { useProjects } from "@increaser/ui/projects/ProjectsProvider"
import { InteractiveBoundaryArea } from "@lib/ui/timeline/InteractiveBoundaryArea"
import { FloatingIntervalDuration } from "@lib/ui/timeline/FloatingIntervalDuration"
import { InteractiveDragArea } from "@lib/ui/timeline/InteractiveDragArea"
import { CurrentIntervalRect } from "@lib/ui/timeline/CurrentIntervalRect"
import { IconWrapper } from "@lib/ui/icons/IconWrapper"
export const SetEditor = () => {
const { projectsRecord } = useProjects()
const { currentSet, dayInterval, setState } = useTrackTime()
const value = shouldBePresent(currentSet)
const [activeControl, setActiveControl] =
useState<IntervalEditorControl | null>(null)
useEvent("pointerup", () => setActiveControl(null))
useEvent("pointercancel", () => setActiveControl(null))
const containerElement = useRef<HTMLDivElement | null>(null)
const intervalElement = useRef<HTMLDivElement | null>(null)
useEffect(() => {
intervalElement.current?.scrollIntoView({
block: "nearest",
inline: "start",
})
}, [value])
useEvent("pointermove", ({ clientY }) => {
if (!activeControl) return
const containerRect = containerElement?.current?.getBoundingClientRect()
if (!containerRect) return
const timestamp = dayInterval.start + pxToMs(clientY - containerRect.top)
const getNewInterval = () => {
if (activeControl === "position") {
const halfDuration = valueDuration / 2
const oldCenter = value.start + halfDuration
const newCenter = enforceRange(
timestamp,
dayInterval.start + halfDuration,
dayInterval.end - halfDuration
)
const offset = newCenter - oldCenter
return {
start: value.start + offset,
end: value.end + offset,
}
} else {
return {
start:
activeControl === "start"
? enforceRange(timestamp, dayInterval.start, value.end)
: value.start,
end:
activeControl === "end"
? enforceRange(timestamp, value.start, dayInterval.end)
: value.end,
}
}
}
const interval = getNewInterval()
setState((state) => ({
...state,
currentSet: {
...shouldBePresent(state.currentSet),
...interval,
},
}))
})
const cursor = useMemo(() => {
if (!activeControl) return undefined
if (activeControl === "position") return "grabbing"
return "row-resize"
}, [activeControl])
const valueDuration = getIntervalDuration(value)
const intervalStartInPx = msToPx(value.start - dayInterval.start)
const intervalEndInPx = msToPx(value.end - dayInterval.start)
const intervalDurationInPx = msToPx(valueDuration)
return (
<TakeWholeSpace style={{ cursor }} ref={containerElement}>
<CurrentIntervalRect
$color={projectsRecord[value.projectId].hslaColor}
ref={intervalElement}
style={{
top: intervalStartInPx,
height: intervalDurationInPx,
}}
>
<IconWrapper style={{ opacity: activeControl ? 0 : 1 }}>
<MoveIcon />
</IconWrapper>
</CurrentIntervalRect>
<FloatingIntervalDuration
style={{
top: intervalEndInPx + 2,
}}
value={value}
/>
{!activeControl && (
<>
<InteractiveDragArea
style={{
top: intervalStartInPx,
height: intervalDurationInPx,
}}
onPointerDown={() => setActiveControl("position")}
/>
<PositionAbsolutelyCenterHorizontally
fullWidth
top={intervalStartInPx}
>
<InteractiveBoundaryArea
onPointerDown={() => setActiveControl("start")}
/>
</PositionAbsolutelyCenterHorizontally>
<PositionAbsolutelyCenterHorizontally fullWidth top={intervalEndInPx}>
<InteractiveBoundaryArea
onPointerDown={() => setActiveControl("end")}
/>
</PositionAbsolutelyCenterHorizontally>
</>
)}
</TakeWholeSpace>
)
}
Editing Sessions with the SetEditor Component
To facilitate session editing, we have the SetEditor
component. When the user interacts with a session, the activeControl
state is set to one of three values: position
, start
, or end
. For each of these states, we render interactive areas that primarily serve to change the cursor appearance to reflect their purpose. These interactive areas capture the pointerdown
event and adjust the activeControl
state appropriately. They don't have any specific visual design beyond signaling the active control to the user through the cursor change.
import styled from "styled-components"
import { ComponentWithChildrenProps } from "../props"
interface PositionAbsolutelyCenterHorizontallyProps
extends ComponentWithChildrenProps {
top: React.CSSProperties["top"]
fullWidth?: boolean
}
const Wrapper = styled.div`
position: absolute;
left: 0;
`
const Container = styled.div`
position: relative;
display: flex;
align-items: center;
`
const Content = styled.div`
position: absolute;
left: 0;
`
export const PositionAbsolutelyCenterHorizontally = ({
top,
children,
fullWidth,
}: PositionAbsolutelyCenterHorizontallyProps) => {
const width = fullWidth ? "100%" : undefined
return (
<Wrapper style={{ top, width }}>
<Container style={{ width }}>
<Content style={{ width }}>{children}</Content>
</Container>
</Wrapper>
)
}
The PositionAbsolutelyCenterHorizontally
component simplifies the placement of an absolutely positioned element by its horizontal center. It wraps the content in nested styled components to ensure accurate alignment: a Wrapper
that is absolutely positioned and extends to the left edge as a base positioning layer, a Container
that aligns its content with a flex layout for vertical centering, and an absolute positioning Content
div that centers the content horizontally. By specifying the top
property to align it vertically and optionally setting fullWidth
to stretch it across the available width, this component handles horizontal centering efficiently, providing a clear and reusable way to position elements absolutely.
To accurately calculate the new position and size of the session, we maintain a reference to the container element. This allows us to extract its bounding box and compute coordinates relative to it. We also keep a reference to the edited session, ensuring it remains visible by listening for changes and calling the scrollIntoView
method. This approach keeps the edited session centered in the viewport.
export const enforceRange = (value: number, min: number, max: number) =>
Math.max(min, Math.min(max, value))
On the pointerup
and pointercancel
events, we reset the activeControl
state to null
, signifying that the user is no longer interacting with the session. During the pointermove
event, we verify if the user is actively engaged with the session. If so, we calculate the new timestamp based on the pointer's position, adjusting the session's start and end timestamps accordingly. The enforceRange
utility ensures the session interval remains within the day's bounds.
import styled from "styled-components"
import { VStack } from "../layout/Stack"
import { getColor } from "../theme/getters"
import { ComponentWithValueProps, UIComponentProps } from "../props"
import { HStackSeparatedBy, dotSeparator } from "../layout/StackSeparatedBy"
import { Text } from "../text"
import { formatTime } from "@lib/utils/time/formatTime"
import { Interval } from "@lib/utils/interval/Interval"
import { formatDuration } from "@lib/utils/time/formatDuration"
import { getIntervalDuration } from "@lib/utils/interval/getIntervalDuration"
export type FloatingIntervalDurationProps = UIComponentProps &
ComponentWithValueProps<Interval>
const Container = styled(VStack)`
position: absolute;
width: 100%;
align-items: center;
font-size: 14px;
font-weight: 500;
color: ${getColor("contrast")};
`
export const FloatingIntervalDuration = ({
value,
...rest
}: FloatingIntervalDurationProps) => (
<Container as="div" {...rest}>
<HStackSeparatedBy separator={dotSeparator}>
<Text>
{formatTime(value.start)} - {formatTime(value.end)}
</Text>
<Text>
{formatDuration(getIntervalDuration(value), "ms", { kind: "long" })}
</Text>
</HStackSeparatedBy>
</Container>
)
To assist the user in setting a precise interval, we display the FloatingIntervalDuration
component beneath the session. This component shows the session's formatted start and end times, along with its duration, all separated by a dot using the HStackSeparatedBy
component from RadzionKit.