There is a time-tracker app, Increaser, with an interface for adding work sessions the same way you would add an event in Google Calendar. Let me show you how I implemented it with React.
To make it comfortable for the user, we want to distinguish an editable session from the existing ones and make it easy to change session boundaries and position.
import {
MouseEventHandler,
ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import { useEvent } from "react-use"
import { Interval } from "shared/entities/Interval"
import { enforceRange } from "shared/utils/enforceRange"
import { formatDuration } from "shared/utils/formatDuration"
import styled, { css } from "styled-components"
import { HSLA } from "ui/colors/HSLA"
import { MoveIcon } from "ui/icons/MoveIcon"
import { Text } from "ui/Text"
import { HourSpace } from "ui/timeline/HourSpace"
import { centerContentCSS } from "ui/utils/centerContentCSS"
import { getVerticalMarginCSS } from "ui/utils/getVerticalMarginCSS"
import { MS_IN_HOUR, MS_IN_MIN } from "utils/time"
import { InteractiveBoundaryArea } from "./InteractiveBoundaryArea"
import { IntervalRect } from "./IntervalRect"
import { MaxIntervalEndBoundary } from "./MaxIntervalEndBoundary"
interface RenderContentParams {
pxInMs: number
}
export interface IntervalInputProps {
color: HSLA
value: Interval
onChange: (value: Interval) => void
startOfDay: number
startHour: number
endHour: number
maxIntervalEnd?: number
minDuration?: number
renderContent?: (params: RenderContentParams) => ReactNode
}
const pxInHour = 60
const defaultMinDurationInMin = 10
const Container = styled.div`
${getVerticalMarginCSS(8)}
`
type IntervalEditorControl = "start" | "end" | "position"
const MoveIconWr = styled.div`
font-size: 16px;
`
const CurrentIntervalRect = styled(IntervalRect)`
${centerContentCSS}
${({ $color }) => css`
background: ${$color.getVariant({ a: () => 0.12 }).toCssValue()};
border: 2px solid ${$color.toCssValue()};
color: ${$color.toCssValue()};
`}
`
const InteractiveDragArea = styled.div`
position: absolute;
width: 100%;
cursor: grab;
`
const DurationText = styled(Text)`
position: absolute;
width: 100%;
text-align: center;
transition: none;
`
export const IntervalInput = ({
color,
value,
onChange,
startOfDay,
endHour,
startHour,
renderContent,
minDuration = defaultMinDurationInMin * MS_IN_MIN,
maxIntervalEnd: optionalMaxIntervalEnd,
}: IntervalInputProps) => {
const hoursCount = endHour - startHour
const maxIntervalEnd =
optionalMaxIntervalEnd ?? startOfDay + MS_IN_HOUR * endHour
const minIntervalStart = startOfDay + MS_IN_HOUR * startHour
const height = hoursCount * pxInHour
const pxInMs = height / (hoursCount * MS_IN_HOUR)
const [activeControl, setActiveControl] =
useState<IntervalEditorControl | null>(null)
useEvent("mouseup", () => setActiveControl(null))
const containerElement = useRef<HTMLDivElement | null>(null)
const intervalElement = useRef<HTMLDivElement | null>(null)
useEffect(() => {
intervalElement.current?.scrollIntoView()
}, [intervalElement])
const valueDuration = value.end - value.start
const handleMouseMove: MouseEventHandler<HTMLDivElement> = ({ clientY }) => {
if (!activeControl) return
const containerRect = containerElement?.current?.getBoundingClientRect()
if (!containerRect) return
const y =
enforceRange(clientY, containerRect.top, containerRect.bottom) -
containerRect.top
const getNewInterval = () => {
if (activeControl === "position") {
const oldCenter =
(value.start + valueDuration / 2 - startOfDay) * pxInMs
const offset = y - oldCenter
const msOffset = enforceRange(
offset / pxInMs,
minIntervalStart - value.start,
maxIntervalEnd - value.end
)
return {
start: value.start + msOffset,
end: value.end + msOffset,
}
} else {
const timestamp = startOfDay + y / pxInMs
return {
start:
activeControl === "start"
? Math.max(
Math.min(timestamp, value.end - minDuration),
minIntervalStart
)
: value.start,
end:
activeControl === "end"
? Math.min(
Math.max(timestamp, value.start + minDuration),
maxIntervalEnd
)
: value.end,
}
}
}
onChange(getNewInterval())
}
const cursor = useMemo(() => {
if (!activeControl) return undefined
if (activeControl === "position") return "grabbing"
return "row-resize"
}, [activeControl])
const intervalStartInPx = pxInMs * (value.start - startOfDay)
const intervalEndInPx = pxInMs * (value.end - startOfDay)
const intervalDurationInPx = pxInMs * valueDuration
return (
<Container
ref={containerElement}
style={{ height: height, cursor }}
onMouseMove={handleMouseMove}
>
<HourSpace
formatHour={(hour) => {
const date = new Date(startOfDay + hour * MS_IN_HOUR)
return date.toLocaleString(undefined, { hour: "numeric" })
}}
start={0}
end={hoursCount}
hourLabelWidthInPx={20}
>
{renderContent && renderContent({ pxInMs })}
<CurrentIntervalRect
$color={color}
ref={intervalElement}
style={{
top: intervalStartInPx,
height: intervalDurationInPx,
}}
>
<MoveIconWr style={{ opacity: activeControl ? 0 : 1 }}>
<MoveIcon />
</MoveIconWr>
</CurrentIntervalRect>
<DurationText
style={{
top: intervalEndInPx + 2,
}}
weight="bold"
>
{formatDuration(valueDuration, "ms")}
</DurationText>
{optionalMaxIntervalEnd && (
<MaxIntervalEndBoundary
timestamp={optionalMaxIntervalEnd}
y={pxInMs * (optionalMaxIntervalEnd - startOfDay)}
isActive={!!activeControl}
/>
)}
{!activeControl && (
<>
<InteractiveDragArea
style={{
top: intervalStartInPx,
height: intervalDurationInPx,
}}
onMouseDown={() => setActiveControl("position")}
/>
<InteractiveBoundaryArea
y={intervalStartInPx}
onMouseDown={() => setActiveControl("start")}
/>
<InteractiveBoundaryArea
y={intervalEndInPx}
onMouseDown={() => setActiveControl("end")}
/>
</>
)}
</HourSpace>
</Container>
)
}
The component receives the session's color and interval, onChange
handler, the start of day timestamp, and start and end hours to show only part of the day. To disable making a session later than a given part of the day and disable sessions smaller than a given duration, we pass optional maxIntervalEnd
and minDuration
properties. We can show an existing session by passing a function to the renderContent
property.
First, we want to make a fixed height container using hours count multiplied by the pixelsInHour
constant. Then we use the HourSpace
component to spread lines with hours numbers along the container.
import styled from "styled-components"
import { Text } from "ui/Text"
import { getRange } from "utils/generic"
import { VStack } from "../Stack"
interface Props {
start: number
end: number
className?: string
hourLabelWidthInPx?: number
children?: React.ReactNode
underLinesContent?: React.ReactNode
formatHour?: (hour: number) => string | number
}
const Container = styled(VStack)`
position: relative;
`
const HourWr = styled.div`
position: relative;
display: flex;
align-items: center;
`
const HourContainer = styled.div`
position: absolute;
width: 100%;
`
const HourContent = styled.div<{ labelWidth: number }>`
width: 100%;
display: grid;
gap: 8px;
grid-template-columns: ${({ labelWidth }) => labelWidth}px 1fr;
align-items: center;
`
const HourLine = styled.div`
background: ${({ theme }) => theme.colors.backgroundGlass.toCssValue()};
height: 1px;
width: 100%;
`
const Content = styled.div<{ leftOffset: number }>`
position: absolute;
width: calc(100% - ${(p) => p.leftOffset}px);
height: 100%;
left: ${(p) => p.leftOffset}px;
display: flex;
`
export const HourSpace = ({
start,
end,
className,
hourLabelWidthInPx = 20,
children,
underLinesContent,
formatHour = (v) => v,
}: Props) => {
const hours = getRange(end + 1 - start).map((index) => start + index)
return (
<Container
className={className}
justifyContent="space-between"
fullHeight
fullWidth
>
{underLinesContent && (
<Content leftOffset={hourLabelWidthInPx}>{underLinesContent}</Content>
)}
{hours.map((hour) => {
return (
<HourWr key={hour}>
<HourContainer>
<HourContent labelWidth={hourLabelWidthInPx}>
<Text
style={{ textAlign: "start" }}
size={14}
color="supporting"
>
{formatHour(hour)}
</Text>
<HourLine />
</HourContent>
</HourContainer>
</HourWr>
)
})}
{children && (
<Content leftOffset={hourLabelWidthInPx}>{children}</Content>
)}
</Container>
)
}
Inside it, we first render the content from parents, if there are any, and proceed with the current editable interval. To distinguish it from the other session, we make the content almost transparent, add a border color, and place the move icon inside to indicate to the user that they can interact with the element. Then we render the session duration and max interval boundary.
To start the editing process user will interact with one of three elements: two boundaries for editing the start and end of the interval and an interactive area for dragging the session. Each has a custom cursor pointer, absolute position, and onMouseDown handler to set active control. Once we are in the editing mode, we hide them. Once we have active control, we should listen for mouse movement and update the interval. Once the user lifts the mouse, we set the activeControl
value to null
and reappear invisible interactive areas. To update interval boundaries, we use the clientY
variable from the mousemove event against the container's bounding box to calculate the new start and end for the session.