Let's check a calendar view made with React at increaser.org so that you can find interesting bits for your project.
Here we have an interface showing work sessions for the last seven days, the start and end of work for a selected day, and a green area highlighting time for rest.
I called the component GroupedByDaySessions
. At the top level, it has two sections - navigation between days and the calendar view itself. The container with lines and hours is a separate component that is also used to display work sessions of the current day. We pass the start and end hours to the component, width of hour labels, and content to show under the lines. In our case, this content is the green area at the bottom.
import { TimePerformance } from "focus/components/TodayTimeline/TimePerformance"
import { getProjectColor } from "projects/utils/getProjectColor"
import { Fragment, useMemo } from "react"
import { getSetDuration } from "sets/helpers/getSetDuration"
import { toPercents } from "shared/utils/toPercents"
import styled from "styled-components"
import { defaultTransitionCSS } from "ui/animations/transitions"
import { centerContentCSS } from "ui/helpers/centerContentCSS"
import { HourSpace } from "ui/HourSpace"
import { RadioGroup } from "ui/Input/RadioGroup"
import { VStack } from "ui/Stack"
import { getLast } from "utils/generic"
import { getCSSUnit } from "utils/getCSSUnit"
import { MIN_IN_HOUR, MS_IN_HOUR, MS_IN_MIN, offsetedUtils } from "utils/time"
import { useDailyReport } from "./DailyReportContext"
import { RestArea } from "./RestArea"
const hourLabelWidthInPx = 20
const NavigationWr = styled.div`
padding-left: ${hourLabelWidthInPx}px;
`
const toOrderedHours = (timestamps: number[]) =>
timestamps.map((t) => offsetedUtils.toTime(t).hour).sort((a, b) => a - b)
const SessionsContainer = styled.div`
position: relative;
height: 100%;
flex: 1;
cursor: pointer;
${centerContentCSS}
`
const Session = styled.div`
position: absolute;
left: 4px;
width: calc(100% - 8px);
border-radius: 2px;
${defaultTransitionCSS}
`
const Hours = styled(HourSpace)`
flex: 1;
`
const Container = styled(VStack)`
margin-left: ${getCSSUnit(-hourLabelWidthInPx)};
flex: 1;
width: calc(100% + ${getCSSUnit(hourLabelWidthInPx)});
`
const TimePerformanceWr = styled(VStack)`
position: absolute;
pointer-events: none;
width: 100px;
align-items: center;
justify-content: center;
`
const StartLine = styled.div`
position: absolute;
width: 100%;
border-bottom: 2px dashed ${({ theme }) => theme.colors.text.toCssValue()};
`
export const GroupedByDaySessions = () => {
const {
days,
selectedDayIndex,
selectDay,
projectsRecord,
goalToStartWorkAt,
goalToGoToBedAt,
} = useDailyReport()
const [startHour, endHour] = useMemo(() => {
const groupedSets = days
.map(({ sets }) => sets)
.filter((sets) => sets.length > 0)
const starts = groupedSets.map((sets) => sets[0].start)
if (starts.length < 1) return [6, 18]
const ends = groupedSets.map((sets) => getLast(sets).end)
const startHour = Math.max(
Math.min(
toOrderedHours(starts)[0],
Math.floor(goalToStartWorkAt / MIN_IN_HOUR)
) - 1,
0
)
const endHour = Math.min(
24,
Math.max(
getLast(toOrderedHours(ends)),
Math.ceil(goalToGoToBedAt / MIN_IN_HOUR)
)
)
return [startHour, endHour]
}, [days, goalToGoToBedAt, goalToStartWorkAt])
const hoursNumber = endHour - startHour
const timelineInMs = hoursNumber * MS_IN_HOUR
return (
<Container fullHeight fullWidth gap={24}>
<NavigationWr>
<RadioGroup
groupName="weekdays"
options={days.map((day, index) => index)}
onChange={selectDay}
optionToString={(i) => i.toString()}
renderItem={(i) => {
const { startsAt } = days[i]
return new Date(startsAt).toLocaleDateString(undefined, {
weekday: "short",
})
}}
isFullWidth
value={selectedDayIndex}
/>
</NavigationWr>
<Hours
start={startHour}
end={endHour}
hourLabelWidthInPx={hourLabelWidthInPx}
underLinesContent={
<RestArea hoursNumber={hoursNumber} startHour={startHour} />
}
>
{days.map(({ sets, startsAt }, dayIndex) => {
const timelineStart = startsAt + startHour * MS_IN_HOUR
const isSelectedDay = selectedDayIndex === dayIndex
return (
<SessionsContainer
onClick={() => selectDay(dayIndex)}
key={dayIndex}
>
{sets.map((set, index) => {
const isFirst = index === 0
const isLast = index === sets.length - 1
const height = toPercents(getSetDuration(set) / timelineInMs)
const top = toPercents(
(set.start - timelineStart) / timelineInMs
)
const project = projectsRecord[set.projectId]
const background = getProjectColor(projectsRecord, project.id)
.getVariant({ a: () => (isSelectedDay ? 1 : 0.64) })
.toCssValue()
return (
<Fragment key={index}>
<Session style={{ height, top, background }}>
{isSelectedDay && (
<VStack
style={{ position: "relative" }}
fullWidth
alignItems="center"
>
{isFirst && (
<TimePerformanceWr
style={{
bottom: 4,
}}
>
<TimePerformance timestamp={set.start} />
</TimePerformanceWr>
)}
</VStack>
)}
</Session>
{selectedDayIndex === dayIndex && isLast && (
<TimePerformanceWr
style={{
top: `calc(${top} + ${height} + 4px)`,
}}
>
<TimePerformance timestamp={set.end} />
</TimePerformanceWr>
)}
</Fragment>
)
})}
</SessionsContainer>
)
})}
<StartLine
style={{
top: toPercents(
((goalToStartWorkAt - startHour * MIN_IN_HOUR) * MS_IN_MIN) /
timelineInMs
),
}}
/>
</Hours>
</Container>
)
}
The container of the HourSpace
component is a relatively positioned flexbox component. We render under-lines content and children in absolutely positioned wrappers with a left offset preventing content from overlaying hours labels.
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
}
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: 4px;
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,
}: 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 size={14} color="supporting">
{hour}
</Text>
<HourLine />
</HourContent>
</HourContainer>
</HourWr>
)
})}
{children && (
<Content leftOffset={hourLabelWidthInPx}>{children}</Content>
)}
</Container>
)
}
Once we passed all the props to the HourSpace
component, we can iterate over every day and display work sessions. The SessionsContainer component takes full height and has flex set to one so that every other one will have the same width.
Every session is an absolutely positioned element with a color of a corresponding project. To calculate the top and height attributes, we don't need to know the parent's height in px. We know the number of hours we display, and our sessions have start and end denominated in timestamps. Therefore we can convert everything to milliseconds and divide it by the timelineInMs variable.