crabfit/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx
2023-06-09 02:06:41 +10:00

198 lines
6.8 KiB
TypeScript

'use client'
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
import { Temporal } from '@js-temporal/polyfill'
import Content from '/src/components/Content/Content'
import Legend from '/src/components/Legend/Legend'
import { PersonResponse } from '/src/config/api'
import { useTranslation } from '/src/i18n/client'
import { useStore } from '/src/stores'
import useSettingsStore from '/src/stores/settingsStore'
import { calculateAvailability, calculateTable, makeClass, relativeTimeFormat } from '/src/utils'
import styles from './AvailabilityViewer.module.scss'
import { usePalette } from '/hooks/usePalette'
interface AvailabilityViewerProps {
times: string[]
timezone: string
people: PersonResponse[]
}
const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps) => {
const { t, i18n } = useTranslation('event')
const timeFormat = useStore(useSettingsStore, state => state.timeFormat) ?? '12h'
const highlight = useStore(useSettingsStore, state => state.highlight)
const [filteredPeople, setFilteredPeople] = useState(people.map(p => p.name))
const [tempFocus, setTempFocus] = useState<string>()
const [focusCount, setFocusCount] = useState<number>()
const wrapperRef = useRef<HTMLDivElement>(null)
const [tooltip, setTooltip] = useState<{
x: number
y: number
available: string
date: string
people: string[]
}>()
// Calculate table
const { rows, columns } = useMemo(() =>
calculateTable(times, i18n.language, timeFormat, timezone),
[times, i18n.language, timeFormat, timezone])
// Calculate availabilities
const { availabilities, min, max } = useMemo(() =>
calculateAvailability(times, people.filter(p => filteredPeople.includes(p.name))),
[times, filteredPeople, people])
// Create the colour palette
const palette = usePalette(Math.max((max - min) + 1, 2))
// Reselect everyone if the amount of people changes
useEffect(() => {
setFilteredPeople(people.map(p => p.name))
}, [people.length])
const heatmap = useMemo(() => columns.map((column, x) => <Fragment key={x}>
{column ? <div className={styles.dateColumn}>
{column.header.dateLabel && <label className={styles.dateLabel}>{column.header.dateLabel}</label>}
<label className={styles.dayLabel}>{column.header.weekdayLabel}</label>
<div
className={styles.times}
data-border-left={x === 0 || columns.at(x - 1) === null}
data-border-right={x === columns.length - 1 || columns.at(x + 1) === null}
>
{column.cells.map((cell, y) => {
if (y === column.cells.length - 1) return null
if (!cell) return <div
className={makeClass(styles.timeSpace, styles.grey)}
key={y}
title={t<string>('greyed_times')}
/>
let peopleHere = availabilities.find(a => a.date === cell.serialized)?.people ?? []
if (tempFocus) {
peopleHere = peopleHere.filter(p => p === tempFocus)
}
return <div
key={y}
className={makeClass(
styles.time,
(focusCount === undefined || focusCount === peopleHere.length) && highlight && (peopleHere.length === max || tempFocus) && peopleHere.length > 0 && styles.highlight,
)}
style={{
backgroundColor: (focusCount === undefined || focusCount === peopleHere.length) ? palette[tempFocus && peopleHere.length ? max : peopleHere.length] : 'transparent',
...cell.minute !== 0 && cell.minute !== 30 && { borderTopColor: 'transparent' },
...cell.minute === 30 && { borderTopStyle: 'dotted' },
}}
aria-label={peopleHere.join(', ')}
onMouseEnter={e => {
const cellBox = e.currentTarget.getBoundingClientRect()
const wrapperBox = wrapperRef.current?.getBoundingClientRect() ?? { x: 0, y: 0 }
setTooltip({
x: Math.round(cellBox.x - wrapperBox.x + cellBox.width / 2),
y: Math.round(cellBox.y - wrapperBox.y + cellBox.height) + 6,
available: `${peopleHere.length} / ${filteredPeople.length} ${t('available')}`,
date: cell.label,
people: peopleHere,
})
}}
onMouseLeave={() => setTooltip(undefined)}
/>
})}
</div>
</div> : <div className={styles.columnSpacer} />}
</Fragment>), [
availabilities,
columns,
highlight,
max,
t,
palette,
tempFocus,
focusCount,
filteredPeople,
])
return <>
<Content>
<Legend
min={min}
max={max}
total={filteredPeople.length}
palette={palette}
onSegmentFocus={setFocusCount}
/>
<span className={styles.info}>{t('group.info1')}</span>
{people.length > 1 && <>
<span className={styles.info}>{t('group.info2')}</span>
<div className={styles.people}>
{people.map(person =>
<button
type="button"
className={makeClass(
styles.person,
filteredPeople.includes(person.name) && styles.personSelected,
)}
key={person.name}
onClick={() => {
setTempFocus(undefined)
if (filteredPeople.includes(person.name)) {
setFilteredPeople(filteredPeople.filter(n => n !== person.name))
} else {
setFilteredPeople([...filteredPeople, person.name])
}
}}
onMouseOver={() => setTempFocus(person.name)}
onMouseOut={() => setTempFocus(undefined)}
title={relativeTimeFormat(Temporal.Instant.fromEpochSeconds(person.created_at), i18n.language)}
>{person.name}</button>
)}
</div>
</>}
</Content>
<div className={styles.wrapper} ref={wrapperRef}>
<div>
<div className={styles.heatmap}>
{useMemo(() => <div className={styles.timeLabels}>
{rows.map((row, i) =>
<div className={styles.timeSpace} key={i}>
{row && <label className={styles.timeLabel}>
{row.label}
</label>}
</div>
)}
</div>, [rows])}
{heatmap}
</div>
{tooltip && <div
className={styles.tooltip}
style={{ top: tooltip.y, left: tooltip.x }}
>
<h3>{tooltip.available}</h3>
<span>{tooltip.date}</span>
{!!filteredPeople.length && <div>
{tooltip.people.map(person => <span key={person}>{person}</span>)}
{filteredPeople.filter(p => !tooltip.people.includes(p)).map(person =>
<span key={person} data-disabled>{person}</span>
)}
</div>}
</div>}
</div>
</div>
</>
}
export default AvailabilityViewer