198 lines
6.8 KiB
TypeScript
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
|