āØ Watch on YouTube | š GitHub
In this post, we'll conduct a React masterclass, during which I'll share how I've created this attractive timeline, alternatively known as a calendar view, to display work sessions overview. While the code for Increaser sits in a private repository, you can locate all the reusable components, hooks, and utilities featured in this article at the ReactKit repository. I trust you'll find lots of useful content in this masterclass, particularly when it comes to absolute positioning and time management.
DayOverviewProvider
Our component is constructed from four principal sections:
- Navigation between days of the week
- Summary of work sessions with a projects breakdown
- A timeline featuring blocks of work, which also presents the current time, and shows the amount of a workday that remains
- Lastly, a flow to add a work session, but we won't be covering this feature in this video.
import styled from "styled-components"
import { Panel } from "@increaser/ui/ui/Panel/Panel"
import { AmountOverview } from "./AmountOverview"
import { DayTimeline } from "./DayTimeline"
import { AddSession } from "./AddSession"
import { horizontalPaddingInPx } from "./config"
import { WeekNavigation } from "./WeekNavigation"
import { DayOverviewProvider } from "./DayOverviewProvider"
const Container = styled(Panel)`
height: 100%;
`
export const DayOverview = () => {
return (
<DayOverviewProvider>
<Container padding={horizontalPaddingInPx} withSections kind="secondary">
<WeekNavigation />
<AmountOverview />
<DayTimeline />
<AddSession />
</Container>
</DayOverviewProvider>
)
}
We'll need to reuse some state in multiple components within DayOverview
, so let's create a provider using React Context.
DayOverviewProvider
will effectively deliver:
- Sets for the current day
- Current time
- Start and end times of the timeline
- Start of the current day
- End of the workday
- A function to change the current day
import { Set } from "@increaser/entities/User"
import { ComponentWithChildrenProps } from "@increaser/ui/props"
import { createContextHook } from "@increaser/ui/state/createContextHook"
import { useRhythmicRerender } from "@increaser/ui/hooks/useRhythmicRerender"
import { getLastItem } from "@increaser/utils/array/getLastItem"
import { MS_IN_MIN } from "@increaser/utils/time"
import { startOfHour, endOfHour, isToday } from "date-fns"
import { useFocus } from "focus/hooks/useFocus"
import { createContext, useEffect, useMemo, useState } from "react"
import { startOfDay } from "date-fns"
import { useAssertUserState } from "user/state/UserStateContext"
import { getDaySets } from "sets/helpers/getDaySets"
import { useStartOfDay } from "@increaser/ui/hooks/useStartOfDay"
interface DayOverviewContextState {
sets: Set[]
currentTime: number
timelineStartsAt: number
timelineEndsAt: number
dayStartedAt: number
workdayEndsAt: number
setCurrentDay: (timestamp: number) => void
}
const DayOverviewContext = createContext<DayOverviewContextState | undefined>(
undefined
)
export const useDayOverview = createContextHook(
DayOverviewContext,
"DayOverview"
)
export const DayOverviewProvider = ({
children,
}: ComponentWithChildrenProps) => {
const currentTime = useRhythmicRerender()
const todayStartedAt = useStartOfDay()
const [currentDay, setCurrentDay] = useState(todayStartedAt)
const dayStartedAt = startOfDay(currentDay).getTime()
const { currentSet } = useFocus()
useEffect(() => {
if (currentSet && dayStartedAt !== todayStartedAt) {
setCurrentDay(todayStartedAt)
}
}, [currentSet, dayStartedAt, todayStartedAt])
const { sets: allSets } = useAssertUserState()
const sets = useMemo(() => {
const result = getDaySets(allSets, dayStartedAt)
if (currentSet && isToday(dayStartedAt)) {
result.push({
start: currentSet.startedAt,
end: currentTime,
projectId: currentSet.projectId,
})
}
return result
}, [allSets, currentSet, currentTime, dayStartedAt])
const { goalToStartWorkAt, goalToFinishWorkBy } = useAssertUserState()
const workdayEndsAt = dayStartedAt + goalToFinishWorkBy * MS_IN_MIN
const timelineStartsAt = useMemo(() => {
if (sets.length) {
return startOfHour(sets[0].start).getTime()
}
const workdayStartsAt = dayStartedAt + goalToStartWorkAt * MS_IN_MIN
if (currentTime < workdayStartsAt) {
return startOfHour(currentTime).getTime()
}
return workdayStartsAt
}, [currentTime, dayStartedAt, goalToStartWorkAt, sets])
const timelineEndsAt = useMemo(() => {
if (!sets.length) {
return workdayEndsAt
}
const lastSetEnd = getLastItem(sets).end
if (workdayEndsAt > lastSetEnd) {
return workdayEndsAt
}
return endOfHour(lastSetEnd).getTime()
}, [sets, workdayEndsAt])
return (
<DayOverviewContext.Provider
value={{
sets,
currentTime,
timelineStartsAt,
timelineEndsAt,
workdayEndsAt,
dayStartedAt,
setCurrentDay,
}}
>
{children}
</DayOverviewContext.Provider>
)
}
We will interact with the context using the useDayOverview
hook, which will generate an error if we attempt to utilize it outside of the provider.
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
}
}
To obtain the current time and rerender the component, we will employ the useRhythmicRerender
hook.
import { useEffect, useState } from "react"
export const useRhythmicRerender = (durationInMs = 1000) => {
const [time, setTime] = useState<number>(Date.now())
useEffect(() => {
const interval = setInterval(() => setTime(Date.now()), durationInMs)
return () => clearInterval(interval)
}, [setTime, durationInMs])
return time
}
We will record the current day as a timestamp in the currentDay
state and distribute the setCurrentDay
function to provider consumer to modify it.
We will filter the sets to include only those within the current day, and if the user is in focus mode, we will add a current set to the list.
Based on the current time, sets, and the user's preferred start and end of the workday, we will compute the start and end of the timeline.
Panel Container
We encapsulate our component in a Panel
container, which spaces out the content inside and outlines the component with a border.
import styled, { css } from "styled-components"
import { defaultBorderRadiusCSS } from "../borderRadius"
import { getCSSUnit } from "../utils/getCSSUnit"
import { getColor } from "../theme/getters"
import { match } from "@increaser/utils/match"
type PanelKind = "regular" | "secondary"
export interface PanelProps {
width?: React.CSSProperties["width"]
padding?: React.CSSProperties["padding"]
direction?: React.CSSProperties["flexDirection"]
kind?: PanelKind
withSections?: boolean
}
const panelPaddingCSS = css<{ padding?: React.CSSProperties["padding"] }>`
padding: ${({ padding }) => getCSSUnit(padding || 20)};
`
export const Panel = styled.div<PanelProps>`
${defaultBorderRadiusCSS};
width: ${({ width }) => (width ? getCSSUnit(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};
gap: 1px;
> * {
${contentCSS}
}
`
: contentCSS
}}
${({ kind }) =>
kind === "secondary" &&
css`
border: 2px solid ${getColor("mist")};
`}
`
We will continue to maintain all the hardcoded sizes and distances in the config.ts
file.
export const horizontalPaddingInPx = 20
export const timeLabelWidthInPx = 40
export const timeLabelGapInPx = 8
export const botomPlaceholderHeightInPx = 28
export const topPlaceholderHeightInPx = horizontalPaddingInPx
export const minimumHourHeightInPx = 40
WeekNavigation
The WeekNavigation
component presents the specific day of the week and enables the user to navigate between days.
import { WEEKDAYS } from "@increaser/utils/time"
import styled from "styled-components"
import { useDayOverview } from "../DayOverviewProvider"
import { useStartOfDay } from "@increaser/ui/hooks/useStartOfDay"
import { useStartOfWeek } from "@increaser/ui/hooks/useStartOfWeek"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { useFocus } from "focus/hooks/useFocus"
import { verticalPadding } from "@increaser/ui/css/verticalPadding"
import { horizontalPadding } from "@increaser/ui/css/horizontalPadding"
import { SameWidthChildrenRow } from "@increaser/ui/ui/Layout/SameWidthChildrenRow"
import { horizontalPaddingInPx } from "../config"
import { WeekdayOption } from "./WeekdayOption"
import { InvisibleHTMLRadio } from "@increaser/ui/ui/inputs/InvisibleHTMLRadio"
const Container = styled(SameWidthChildrenRow)`
${verticalPadding(2)}
${horizontalPadding(horizontalPaddingInPx * 0.6)}
`
export const WeekNavigation = () => {
const todayStartedAt = useStartOfDay()
const weekStartedAt = useStartOfWeek()
const { setCurrentDay, dayStartedAt } = useDayOverview()
const { currentSet } = useFocus()
if (currentSet) {
return null
}
return (
<Container rowHeight={horizontalPaddingInPx * 1.6} gap={1} fullWidth>
{WEEKDAYS.map((weekday, index) => {
const weekdayStartsAt =
weekStartedAt + convertDuration(index, "d", "ms")
const isActive = dayStartedAt === weekdayStartsAt
const isEnabled = weekdayStartsAt <= todayStartedAt
return (
<WeekdayOption key={index} isActive={isActive} isEnabled={isEnabled}>
{isEnabled && (
<InvisibleHTMLRadio
isSelected={isActive}
groupName="week-navigation"
value={weekdayStartsAt}
onSelect={() => setCurrentDay(weekdayStartsAt)}
/>
)}
{weekday.slice(0, 3)}
</WeekdayOption>
)
})}
</Container>
)
}
We will position the weekdays inside a CSS Grid container abstracted behind the SameWidthChildrenRow
component.
import styled, { css } from "styled-components"
import { getCSSUnit } from "../utils/getCSSUnit"
interface Props {
gap: number
minChildrenWidth?: number
childrenWidth?: number
rowHeight?: number
fullWidth?: boolean
maxColumns?: number
}
const getColumnMax = (maxColumns: number | undefined, gap: number) => {
if (!maxColumns) return `0px`
const gapCount = maxColumns - 1
const totalGapWidth = `calc(${gapCount} * ${getCSSUnit(gap)})`
return `calc((100% - ${totalGapWidth}) / ${maxColumns})`
}
const getColumnWidth = ({
minChildrenWidth,
maxColumns,
gap,
childrenWidth,
}: Props) => {
if (childrenWidth !== undefined) {
return getCSSUnit(childrenWidth)
}
return `
minmax(
max(
${getCSSUnit(minChildrenWidth || 0)},
${getColumnMax(maxColumns, gap)}
),
1fr
)`
}
export const SameWidthChildrenRow = styled.div<Props>`
display: grid;
grid-template-columns: repeat(auto-fit, ${getColumnWidth});
gap: ${({ gap }) => getCSSUnit(gap)};
${({ rowHeight }) =>
rowHeight &&
css`
grid-auto-rows: ${getCSSUnit(rowHeight)};
`}
${({ fullWidth }) =>
fullWidth &&
css`
width: 100%;
`}
`
To cycle over weekdays, we will utilize the WEEKDAYS
array that contains the names of the days of the week. To procure the commencement of a weekday, we do simple math by adding days converted to milliseconds to the start of the week timestamp.
The active day will be the one that corresponds with the dayStartedAt
timestamp, and we will disable all the days that fall in the future.
For a better accessibility experience and keyword support, we will render an invisible radio input inside every option.
import { centerContent } from "@increaser/ui/css/centerContent"
import { interactive } from "@increaser/ui/css/interactive"
import { transition } from "@increaser/ui/css/transition"
import { getColor } from "@increaser/ui/ui/theme/getters"
import styled, { css } from "styled-components"
interface WeekdayOptionProps {
isActive: boolean
isEnabled: boolean
}
export const WeekdayOption = styled.label<WeekdayOptionProps>`
${centerContent}
${transition}
font-size: 12px;
font-weight: 500;
border-radius: 4px;
border: 2px solid transparent;
${({ isEnabled }) =>
isEnabled
? css`
${interactive}
color: ${getColor("textSupporting")};
`
: css`
pointer-events: none;
color: ${getColor("textShy")};
`}
${({ isActive }) =>
isActive
? css`
color: ${getColor("contrast")};
background: ${getColor("mist")};
`
: css`
:hover {
color: ${getColor("text")};
border-color: ${getColor("mist")};
}
`}
`
The WeekdayOption
component will style the label component, changing the text border and background color based on the isActive
and isEnabled
props.
AmountOverview
The AmountOverview
component provides an overview of the current day's work sessions and projects breakdown.
import { HStack, VStack } from "@increaser/ui/ui/Stack"
import {
HStackSeparatedBy,
slashSeparator,
} from "@increaser/ui/ui/StackSeparatedBy"
import { WEEKDAYS } from "@increaser/utils/time"
import { ProjectTotal } from "projects/components/ProjectTotal"
import { ProjectsAllocationLine } from "projects/components/ProjectsAllocationLine"
import { getProjectColor } from "projects/utils/getProjectColor"
import { getProjectName } from "projects/utils/getProjectName"
import { useDayOverview } from "./DayOverviewProvider"
import { Text } from "@increaser/ui/ui/Text"
import { formatDuration } from "@increaser/utils/time/formatDuration"
import { getSetsSum } from "sets/helpers/getSetsSum"
import { useWeekTimeAllocation } from "weekTimeAllocation/hooks/useWeekTimeAllocation"
import { useProjects } from "projects/hooks/useProjects"
import { getProjectsTotalRecord } from "projects/helpers/getProjectsTotalRecord"
import { useTheme } from "styled-components"
import { getWeekday } from "@increaser/utils/time/getWeekday"
export const AmountOverview = () => {
const theme = useTheme()
const { sets, dayStartedAt } = useDayOverview()
const setsTotal = getSetsSum(sets)
const { allocation } = useWeekTimeAllocation()
const weekday = getWeekday(new Date(dayStartedAt))
const { projectsRecord } = useProjects()
const allocatedMinutes = allocation ? allocation[weekday] : 0
const projectsTotal = getProjectsTotalRecord(sets)
return (
<VStack fullWidth gap={8}>
<VStack gap={4}>
<HStack alignItems="center" justifyContent="space-between">
<Text weight="semibold" color="supporting" size={14}>
{WEEKDAYS[weekday]}
</Text>
<HStackSeparatedBy
separator={<Text color="shy">{slashSeparator}</Text>}
>
<Text size={14} weight="semibold">
{formatDuration(setsTotal, "ms")}
</Text>
<Text size={14} weight="semibold" color="shy">
{formatDuration(allocatedMinutes, "min")}
</Text>
</HStackSeparatedBy>
</HStack>
<ProjectsAllocationLine
projectsRecord={projectsRecord}
sets={sets}
allocatedMinutes={allocatedMinutes}
/>
</VStack>
<VStack gap={4} fullWidth>
{Object.entries(projectsTotal)
.sort((a, b) => b[1] - a[1])
.map(([projectId]) => (
<ProjectTotal
key={projectId}
name={getProjectName(projectsRecord, projectId)}
color={getProjectColor(projectsRecord, theme, projectId)}
value={projectsTotal[projectId]}
/>
))}
</VStack>
</VStack>
)
}
First, we display the name of the current day along with the total time spent on work sessions compared to the designated time for the day. Normally, you would opt to work less over the weekends, so the allocated time will be different for Saturday and Sunday.
To visualize the breakdown of projects, we will employ the ProjectsAllocationLine
component.
To estimate the total time spent on each project, we will exploit the getProjectsTotalRecord
helper.
import { getSetDuration } from "sets/helpers/getSetDuration"
import { Set } from "sets/Set"
export const getProjectsTotalRecord = (sets: Set[]) =>
sets.reduce(
(acc, set) => ({
...acc,
[set.projectId]: (acc[set.projectId] || 0) + getSetDuration(set),
}),
{} as Record<string, number>
)
DayTimeline
The DayTimeline
component forms the most complex part of our display, heavily stocking up on the absolutely positioned elements. The major segments of the component are:
- A block highlighting the time left before the end of the workday
- A timeline marker showing the hours of the day
- A current time indicator
- A workday end status showing how much time left before the end of the workday
- Work blocks displaying the work sessions
- A floating button permitting edits to the last work session
import styled from "styled-components"
import { TimelineMarks } from "./TimelineMarks"
import { WorkdayEndStatus } from "./WorkdayEndStatus"
import { CurrentTime } from "./CurrentTime"
import { WorkdayLeftBlock } from "./WorkdayLeftBlock"
import { WorkBlocks } from "./WorkBlocks"
import { ManageLastSession } from "./ManageLastSession"
import {
botomPlaceholderHeightInPx,
minimumHourHeightInPx,
topPlaceholderHeightInPx,
} from "./config"
import { useDayOverview } from "./DayOverviewProvider"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { takeWholeSpaceAbsolutely } from "@increaser/ui/css/takeWholeSpaceAbsolutely"
import { takeWholeSpace } from "@increaser/ui/css/takeWholeSpace"
const Wrapper = styled.div`
flex: 1;
padding: 0;
position: relative;
min-height: 320px;
`
const Container = styled.div`
${takeWholeSpaceAbsolutely}
overflow: auto;
padding-bottom: ${botomPlaceholderHeightInPx}px;
padding-top: ${topPlaceholderHeightInPx}px;
`
const Content = styled.div`
${takeWholeSpace};
position: relative;
`
export const DayTimeline = () => {
const { timelineStartsAt, timelineEndsAt } = useDayOverview()
const timespan = timelineEndsAt - timelineStartsAt
const minHeight = convertDuration(timespan, "ms", "h") * minimumHourHeightInPx
return (
<Wrapper>
<Container>
<Content style={{ minHeight }}>
<WorkdayLeftBlock />
<TimelineMarks />
<CurrentTime />
<WorkdayEndStatus />
<WorkBlocks />
<ManageLastSession />
</Content>
</Container>
</Wrapper>
)
}
The Wrapper
uses flex: 1
to accommodate all the available space, and we use min-height
to guarantee the component is at least 320px tall.
The Container
will be an absolutely positioned element with overflow: auto
to permit scrolling when space is inadequate.
The Content
will be a relatively positioned element occupying all available space within the Container
.
Since a lengthy timeline may not fit into the screen, we set a min-height
for the Content
element based on the timeline duration so that one hour is at least 40px
tall.
WorkdayLeftBlock
The WorkdayLeftBlock
component will visualize the remaining time until the end of the workday. It only makes sense for today, so it will hide if the user is looking at a past weekday.
import styled from "styled-components"
import { useDayOverview } from "./DayOverviewProvider"
import { getColor } from "@increaser/ui/ui/theme/getters"
import { toPercents } from "@increaser/utils/toPercents"
const Container = styled.div`
width: 100%;
background: ${getColor("foreground")};
position: absolute;
left: 0;
`
export const WorkdayLeftBlock = () => {
const { workdayEndsAt, timelineEndsAt, timelineStartsAt, currentTime } =
useDayOverview()
const workEndsIn = workdayEndsAt - currentTime
const timespan = timelineEndsAt - timelineStartsAt
if (currentTime > workdayEndsAt) {
return null
}
return (
<Container
style={{
top: toPercents((currentTime - timelineStartsAt) / timespan),
height: toPercents(workEndsIn / timespan),
}}
/>
)
}
To convert ratios for the top
and height
attributes to percentages, we employ the toPercents
helper.
type Format = "round"
export const toPercents = (value: number, format?: Format) => {
const number = value * 100
return `${format === "round" ? Math.round(number) : number}%`
}
TimelineMarks
The TimelineMarks
component will portray the hours of the day.
import { useMemo } from "react"
import { useDayOverview } from "./DayOverviewProvider"
import { PositionAbsolutelyCenterHorizontally } from "@increaser/ui/ui/PositionAbsolutelyCenterHorizontally"
import { toPercents } from "@increaser/utils/toPercents"
import styled from "styled-components"
import {
horizontalPaddingInPx,
timeLabelGapInPx,
timeLabelWidthInPx,
} from "./config"
import { Text } from "@increaser/ui/ui/Text"
import { formatTime } from "@increaser/utils/time/formatTime"
import { getColor } from "@increaser/ui/ui/theme/getters"
import { getHoursInRange } from "@increaser/utils/time/getHoursInRange"
import { takeWholeSpace } from "@increaser/ui/css/takeWholeSpace"
import { horizontalPadding } from "@increaser/ui/css/horizontalPadding"
import { centerContent } from "@increaser/ui/css/centerContent"
import { transition } from "@increaser/ui/css/transition"
const Container = styled.div`
display: grid;
grid-template-columns: ${timeLabelWidthInPx}px 1fr;
align-items: center;
${horizontalPadding(horizontalPaddingInPx)};
gap: ${timeLabelGapInPx}px;
`
const Time = styled.div`
${centerContent};
${takeWholeSpace};
`
const Line = styled.div`
width: 100%;
height: 1px;
background: ${getColor("mist")};
${transition};
`
export const TimelineMarks = () => {
const { timelineStartsAt, timelineEndsAt } = useDayOverview()
const marks = useMemo(() => {
return getHoursInRange(timelineStartsAt, timelineEndsAt)
}, [timelineEndsAt, timelineStartsAt])
const timespan = timelineEndsAt - timelineStartsAt
return (
<>
{marks.map((mark) => {
return (
<PositionAbsolutelyCenterHorizontally
fullWidth
top={toPercents((mark - timelineStartsAt) / timespan)}
key={mark}
>
<Container>
<Time>
<Text color="shy" size={14}>
{formatTime(mark)}
</Text>
</Time>
<Line />
</Container>
</PositionAbsolutelyCenterHorizontally>
)
})}
</>
)
}
We rely on a recursive function getHoursInRange
to acquire the hours of the day between two timestamps.
import { startOfHour } from "date-fns"
import { MS_IN_HOUR } from "."
export const getHoursInRange = (start: number, end: number) => {
const recursive = (time: number): number[] => {
if (time > end) {
return []
}
const nextHour = time + MS_IN_HOUR
if (time < start) {
return recursive(nextHour)
}
return [time, ...recursive(nextHour)]
}
return recursive(startOfHour(start).getTime())
}
To center the text with the time and line, we use the PositionAbsolutelyCenterHorizontally
abstract component.
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>
)
}
It combines a Wrapper
with 0 height, Container
with horizontally aligned content, and Content
with absolute positioning. Briefly, it involves a lot of wrapping, but as a user of the component, you access a pleasing API for absolute positioning.
CurrentTime
The CurrentTime
component strongly resembles one of the markers in the TimelineMarks
component.
import { PositionAbsolutelyCenterHorizontally } from "@increaser/ui/ui/PositionAbsolutelyCenterHorizontally"
import { toPercents } from "@increaser/utils/toPercents"
import { useDayOverview } from "./DayOverviewProvider"
import styled from "styled-components"
import { getColor } from "@increaser/ui/ui/theme/getters"
import { horizontalPaddingInPx, timeLabelWidthInPx } from "./config"
import { formatTime } from "@increaser/utils/time/formatTime"
import { Text } from "@increaser/ui/ui/Text"
import { useStartOfDay } from "@increaser/ui/hooks/useStartOfDay"
import { centerContent } from "@increaser/ui/css/centerContent"
import { absoluteOutline } from "@increaser/ui/css/absoluteOutline"
const Line = styled.div`
width: 100%;
height: 2px;
background: ${getColor("primary")};
`
const Wrapper = styled.div`
width: ${timeLabelWidthInPx}px;
margin-left: ${horizontalPaddingInPx}px;
position: relative;
${centerContent}
height: 20px;
`
const Time = styled(Text)`
position: absolute;
`
const Outline = styled.div`
${absoluteOutline(6, 6)};
background: ${getColor("background")};
border-radius: 8px;
border: 2px solid ${getColor("primary")};
`
export const CurrentTime = () => {
const {
currentTime,
timelineStartsAt,
timelineEndsAt,
workdayEndsAt,
dayStartedAt,
} = useDayOverview()
const todayStartedAt = useStartOfDay()
if (dayStartedAt !== todayStartedAt) {
return null
}
if (currentTime > workdayEndsAt && timelineEndsAt === workdayEndsAt) {
return null
}
const timespan = timelineEndsAt - timelineStartsAt
const top = toPercents((currentTime - timelineStartsAt) / timespan)
return (
<>
<PositionAbsolutelyCenterHorizontally fullWidth top={top}>
<Line />
</PositionAbsolutelyCenterHorizontally>
<PositionAbsolutelyCenterHorizontally fullWidth top={top}>
<Wrapper>
<Outline />
<Time size={14} weight="bold">
{formatTime(currentTime)}
</Time>
</Wrapper>
</PositionAbsolutelyCenterHorizontally>
</>
)
}
An interesting CSS trick here involves the absoluteOutline
helper to create a border around the current time.
import { css } from "styled-components"
import { toSizeUnit } from "./toSizeUnit"
export const absoluteOutline = (
horizontalOffset: number | string,
verticalOffset: number | string
) => {
return css`
pointer-events: none;
position: absolute;
left: -${toSizeUnit(horizontalOffset)};
top: -${toSizeUnit(verticalOffset)};
width: calc(100% + ${toSizeUnit(horizontalOffset)} * 2);
height: calc(100% + ${toSizeUnit(verticalOffset)} * 2);
`
}
As the Outline
is an absolutely positioned element, we have to turn the Time
component into an absolute as well.
WorkdayEndStatus
When the user observes the current day and it's not yet the end of the workday, we present the WorkdayEndStatus
component to display how much time remains before work concludes.
import { Text } from "@increaser/ui/ui/Text"
import { formatDuration } from "@increaser/utils/time/formatDuration"
import styled from "styled-components"
import { useDayOverview } from "./DayOverviewProvider"
import { useStartOfDay } from "@increaser/ui/hooks/useStartOfDay"
const Container = styled.div`
position: absolute;
bottom: -20px;
width: 100%;
display: flex;
justify-content: center;
font-size: 14px;
line-height: 1;
`
export const WorkdayEndStatus = () => {
const { workdayEndsAt, timelineEndsAt, currentTime, dayStartedAt } =
useDayOverview()
const workEndsIn = workdayEndsAt - currentTime
const todayStartedAt = useStartOfDay()
if (dayStartedAt !== todayStartedAt) {
return null
}
if (timelineEndsAt > workdayEndsAt) {
return null
}
return (
<Container>
{currentTime < workdayEndsAt && (
<Text color="contrast" weight="semibold" size={14}>
<Text as="span" color="supporting">
workday ends in
</Text>{" "}
{formatDuration(workEndsIn, "ms")}
</Text>
)}
</Container>
)
}
To format, we use the proficient formatDuration
helper.
import { padWithZero } from "../padWithZero"
import { H_IN_DAY, MIN_IN_HOUR, S_IN_HOUR, S_IN_MIN } from "."
import { DurationUnit, convertDuration } from "./convertDuration"
export const formatDuration = (duration: number, unit: DurationUnit) => {
const minutes = Math.round(convertDuration(duration, unit, "min"))
if (minutes < MIN_IN_HOUR) return `${minutes}m`
const hours = Math.floor(minutes / S_IN_MIN)
if (hours < H_IN_DAY) {
const minutesPart = Math.round(minutes % S_IN_MIN)
if (!minutesPart) {
return `${hours}h`
}
return `${hours}h ${minutesPart}m`
}
const days = Math.floor(hours / H_IN_DAY)
const hoursPart = Math.round(hours % H_IN_DAY)
if (!hoursPart) {
return `${days}d`
}
return `${days}d ${hoursPart}h`
}
WorkBlocks
A work block is an assembly of sets that are close together. As per Andrew Huberman, one of the productivity secrets involves organizing work into 90-minute blocks. That's why we desire to categorize sessions into blocks and highlight their duration to the user.
import { useDayOverview } from "./DayOverviewProvider"
import { getBlocks } from "@increaser/entities-utils/block"
import { WorkBlock } from "./WorkBlock"
export const WorkBlocks = () => {
const { sets } = useDayOverview()
const blocks = getBlocks(sets)
return (
<>
{blocks.map((block, index) => (
<WorkBlock key={index} block={block} />
))}
</>
)
}
Since I also incorporate blocks on the server-side to create a scoreboard of the most productive users, the getBlocks
function is contained in the entities-utils
package.
export const getBlocks = (sets: Set[]): Block[] => {
const blocks: Block[] = []
sets.forEach((set, index) => {
const prevSet = sets[index - 1]
if (!prevSet) {
blocks.push({ sets: [set] })
return
}
const distance = getDistanceBetweenSets(prevSet, set)
if (distance > blockDistanceInMinutes * MS_IN_MIN) {
blocks.push({ sets: [set] })
return
}
getLastItem(blocks).sets.push(set)
})
return blocks
}
WorkBlock
To show a dashed line around the block, we'll once again use the absoluteOutline
helper.
import { Block } from "@increaser/entities/Block"
import styled from "styled-components"
import { useDayOverview } from "../DayOverviewProvider"
import {
getBlockBoundaries,
getBlockWorkDuration,
} from "@increaser/entities-utils/block"
import { toPercents } from "@increaser/utils/toPercents"
import { takeWholeSpace } from "@increaser/ui/css/takeWholeSpace"
import { getColor } from "@increaser/ui/ui/theme/getters"
import {
horizontalPaddingInPx,
timeLabelWidthInPx,
timeLabelGapInPx,
} from "../config"
import { convertDuration } from "@increaser/utils/time/convertDuration"
import { Text } from "@increaser/ui/ui/Text"
import { formatDuration } from "@increaser/utils/time/formatDuration"
import { WorkSession } from "./WorkSession"
import { getSetDuration } from "@increaser/entities-utils/set/getSetDuration"
import { transition } from "@increaser/ui/css/transition"
import { absoluteOutline } from "@increaser/ui/css/absoluteOutline"
interface WorkBlockProps {
block: Block
}
const leftOffset =
horizontalPaddingInPx + timeLabelWidthInPx + timeLabelGapInPx * 2
const Container = styled.div`
width: calc(100% - ${leftOffset}px - ${horizontalPaddingInPx}px);
left: ${leftOffset}px;
position: absolute;
${transition};
`
const Content = styled.div`
position: relative;
${takeWholeSpace}
`
const Outline = styled.div`
${absoluteOutline(2, 2)};
border-radius: 4px;
border: 1px dashed ${getColor("textSupporting")};
`
const Duration = styled(Text)`
position: absolute;
top: 1px;
right: 4px;
`
export const WorkBlock = ({ block }: WorkBlockProps) => {
const { timelineStartsAt, timelineEndsAt } = useDayOverview()
const timespan = timelineEndsAt - timelineStartsAt
const { start, end } = getBlockBoundaries(block)
const blockDuration = end - start
const showDuration = blockDuration > convertDuration(25, "min", "ms")
return (
<Container
style={{
top: toPercents((start - timelineStartsAt) / timespan),
height: toPercents(blockDuration / timespan),
}}
>
<Content>
<Outline />
{block.sets.map((set) => (
<WorkSession
set={set}
style={{
top: toPercents((set.start - start) / blockDuration),
height: toPercents(getSetDuration(set) / blockDuration),
}}
/>
))}
{showDuration && (
<Duration color="supporting" size={14} weight="semibold">
{formatDuration(getBlockWorkDuration(block), "ms")}
</Duration>
)}
</Content>
</Container>
)
}
If the session is too short, we won't display the duration. To alternate between different time units, we use the convertDuration
helper.
import { MS_IN_DAY, MS_IN_HOUR, MS_IN_MIN, MS_IN_SEC, MS_IN_WEEK } from "."
export type DurationUnit = "ms" | "s" | "min" | "h" | "d" | "w"
const msInUnit: Record<DurationUnit, number> = {
ms: 1,
s: MS_IN_SEC,
min: MS_IN_MIN,
h: MS_IN_HOUR,
d: MS_IN_DAY,
w: MS_IN_WEEK,
}
export const convertDuration = (
value: number,
from: DurationUnit,
to: DurationUnit
) => {
const result = value * (msInUnit[from] / msInUnit[to])
return result
}
To illustrate a work session, we have a designated component.
import { Set } from "@increaser/entities/User"
import { transition } from "@increaser/ui/css/transition"
import { UIComponentProps } from "@increaser/ui/props"
import { getColor } from "@increaser/ui/ui/theme/getters"
import { useFocus } from "focus/hooks/useFocus"
import { useProjects } from "projects/hooks/useProjects"
import { getProjectColor } from "projects/utils/getProjectColor"
import styled, { useTheme } from "styled-components"
interface WorkSessionProps extends UIComponentProps {
set: Set
}
const Container = styled.div`
border-radius: 2px;
background: ${getColor("mist")};
overflow: hidden;
position: absolute;
width: 100%;
${transition};
`
const Identifier = styled.div`
width: 4px;
height: 100%;
${transition};
`
export const WorkSession = ({ set, ...rest }: WorkSessionProps) => {
const { projectsRecord } = useProjects()
const { currentSet } = useFocus()
const theme = useTheme()
const color = getProjectColor(projectsRecord, theme, set.projectId)
return (
<Container {...rest}>
{!currentSet && <Identifier style={{ background: color.toCssValue() }} />}
</Container>
)
}
We assign a session a mist
background using the getColor
helper, which simplifies interaction with styled components themes.
import { DefaultTheme } from "styled-components"
import { ThemeColors } from "./ThemeColors"
interface ThemeGetterParams {
theme: DefaultTheme
}
type ColorName = keyof Omit<ThemeColors, "getLabelColor">
export const getColor =
(color: ColorName) =>
({ theme }: ThemeGetterParams) => {
return theme.colors[color].toCssValue()
}
To link a session with a project, we'll display a small identifier in the form of a vertical line. Here we utilize the getProjectColor
helper to retrieve the project's color.
import { EnhancedProject } from "projects/Project"
import { DefaultTheme } from "styled-components"
export const getProjectColor = (
projectsRecord: Record<string, EnhancedProject>,
theme: DefaultTheme,
projectId = ""
) => {
const project = projectsRecord[projectId]
if (!project) return theme.colors.mist
return theme.colors.getLabelColor(project.color)
}
ManageLastSession
Lastly, we'll introduce a floating button to modify the last work session. The ManageLastSession
component will generate a menu component with options to either edit or remove the last session.
import { useStartOfDay } from "@increaser/ui/hooks/useStartOfDay"
import { useFocus } from "focus/hooks/useFocus"
import { useDayOverview } from "../DayOverviewProvider"
import { getLastItem } from "@increaser/utils/array/getLastItem"
import { Menu } from "@increaser/ui/ui/Menu"
import { MenuOptionProps, MenuOption } from "@increaser/ui/ui/Menu/MenuOption"
import { EditIcon } from "@increaser/ui/ui/icons/EditIcon"
import { MoveIcon } from "@increaser/ui/ui/icons/MoveIcon"
import { TrashBinIcon } from "@increaser/ui/ui/icons/TrashBinIcon"
import { useState } from "react"
import { Match } from "@increaser/ui/ui/Match"
import { ChangeLastSetIntervalOverlay } from "focus/components/ChangeLastSetInvervalOverlay"
import { ChangeLastSetProjectOverlay } from "focus/components/ChangeLastSetProjectOverlay"
import { useDeleteLastSetMutation } from "sets/hooks/useDeleteLastSetMutation"
import { ManageSetOpener } from "./ManageSetOpener"
type MenuOptionType = "editInterval" | "changeProject"
export const ManageLastSession = () => {
const [selectedOption, setSelectedOption] = useState<MenuOptionType | null>(
null
)
const { currentSet } = useFocus()
const todayStartedAt = useStartOfDay()
const { dayStartedAt, sets } = useDayOverview()
const { mutate: deleteLastSet } = useDeleteLastSetMutation()
if (currentSet || dayStartedAt !== todayStartedAt || !sets.length) {
return null
}
return (
<>
<Menu
title="Manage last session"
renderContent={({ view, onClose }) => {
const options: MenuOptionProps[] = [
{
icon: <EditIcon />,
text: "Change project",
onSelect: () => {
setSelectedOption("changeProject")
},
},
{
icon: <MoveIcon />,
text: "Edit interval",
onSelect: () => {
setSelectedOption("editInterval")
},
},
{
icon: <TrashBinIcon />,
text: "Delete session",
kind: "alert",
onSelect: () => {
deleteLastSet()
},
},
]
return options.map(({ text, icon, onSelect, kind }) => (
<MenuOption
text={text}
key={text}
icon={icon}
view={view}
kind={kind}
onSelect={() => {
onClose()
onSelect()
}}
/>
))
}}
renderOpener={(openerProps) => (
<ManageSetOpener set={getLastItem(sets)} openerProps={openerProps} />
)}
/>
{selectedOption && (
<Match
value={selectedOption}
editInterval={() => (
<ChangeLastSetIntervalOverlay
onClose={() => setSelectedOption(null)}
/>
)}
changeProject={() => (
<ChangeLastSetProjectOverlay
onClose={() => setSelectedOption(null)}
/>
)}
/>
)}
</>
)
}
To display the opener, we use the ManageSetOpener
component which retrieves a set and essential props for the Menu component, to activate and position the menu. Here we position the opener absolutely with a 4px
offset from the end of the session.
import { interactive } from "@increaser/ui/css/interactive"
import { HStack } from "@increaser/ui/ui/Stack"
import { defaultTransitionCSS } from "@increaser/ui/ui/animations/transitions"
import { MoreHorizontalIcon } from "@increaser/ui/ui/icons/MoreHorizontalIcon"
import { getColor } from "@increaser/ui/ui/theme/getters"
import { centerContentCSS } from "@increaser/ui/ui/utils/centerContentCSS"
import { toPercents } from "@increaser/utils/toPercents"
import { getProjectEmoji } from "projects/utils/getProjectEmoji"
import styled, { useTheme } from "styled-components"
import { horizontalPaddingInPx } from "../config"
import { Text } from "@increaser/ui/ui/Text"
import { useProjects } from "projects/hooks/useProjects"
import { RenderOpenerProps } from "@increaser/ui/ui/Menu/PopoverMenu"
import { Set } from "@increaser/entities/User"
import { useDayOverview } from "../DayOverviewProvider"
import { formatDuration } from "@increaser/utils/time/formatDuration"
import { getProjectColor } from "projects/utils/getProjectColor"
const offsetInPx = 4
const Container = styled.div`
${interactive}
position: absolute;
right: ${horizontalPaddingInPx}px;
border-radius: 8px;
padding: 4px 8px;
${centerContentCSS};
font-size: 14px;
border: 2px solid;
background: ${getColor("background")};
color: ${getColor("text")};
${defaultTransitionCSS};
:hover {
color: ${getColor("contrast")};
}
`
interface ManageSetOpener {
set: Set
openerProps: RenderOpenerProps
}
export const ManageSetOpener = ({ set, openerProps }: ManageSetOpener) => {
const { projectsRecord } = useProjects()
const { start, end, projectId } = set
const { timelineEndsAt, timelineStartsAt } = useDayOverview()
const timespan = timelineEndsAt - timelineStartsAt
const theme = useTheme()
const color = getProjectColor(projectsRecord, theme, projectId)
return (
<Container
{...openerProps}
style={{
top: `calc(${toPercents(
(end - timelineStartsAt) / timespan
)} + ${offsetInPx}px)`,
borderColor: color.toCssValue(),
}}
>
<HStack alignItems="center" gap={8}>
<Text>{getProjectEmoji(projectsRecord, projectId)}</Text>
<Text weight="semibold">{formatDuration(end - start, "ms")}</Text>
<MoreHorizontalIcon />
</HStack>
</Container>
)
}
Top comments (2)
Thank you
Nice work