Use Temporal polyfill to implement availability viewer structure
This commit is contained in:
parent
877c4b3479
commit
756b71433c
24 changed files with 768 additions and 551 deletions
|
|
@ -1,235 +0,0 @@
|
|||
import { useState, useEffect, useRef, useMemo, Fragment } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import dayjs from 'dayjs'
|
||||
import localeData from 'dayjs/plugin/localeData'
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { createPalette } from 'hue-map'
|
||||
|
||||
import { useSettingsStore, useLocaleUpdateStore } from '/src/stores'
|
||||
|
||||
import { Legend } from '/src/components'
|
||||
import {
|
||||
Wrapper,
|
||||
ScrollWrapper,
|
||||
Container,
|
||||
Date,
|
||||
Times,
|
||||
DateLabel,
|
||||
DayLabel,
|
||||
Time,
|
||||
Spacer,
|
||||
Tooltip,
|
||||
TooltipTitle,
|
||||
TooltipDate,
|
||||
TooltipContent,
|
||||
TooltipPerson,
|
||||
TimeLabels,
|
||||
TimeLabel,
|
||||
TimeSpace,
|
||||
People,
|
||||
Person,
|
||||
StyledMain,
|
||||
Info,
|
||||
} from './AvailabilityViewer.styles'
|
||||
|
||||
import locales from '/src/i18n/locales'
|
||||
|
||||
dayjs.extend(localeData)
|
||||
dayjs.extend(customParseFormat)
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const AvailabilityViewer = ({
|
||||
times,
|
||||
timeLabels,
|
||||
dates,
|
||||
isSpecificDates,
|
||||
people = [],
|
||||
min = 0,
|
||||
max = 0,
|
||||
}) => {
|
||||
const [tooltip, setTooltip] = useState(null)
|
||||
const timeFormat = useSettingsStore(state => state.timeFormat)
|
||||
const highlight = useSettingsStore(state => state.highlight)
|
||||
const colormap = useSettingsStore(state => state.colormap)
|
||||
const [filteredPeople, setFilteredPeople] = useState([])
|
||||
const [touched, setTouched] = useState(false)
|
||||
const [tempFocus, setTempFocus] = useState(null)
|
||||
const [focusCount, setFocusCount] = useState(null)
|
||||
|
||||
const { t } = useTranslation('event')
|
||||
const locale = useLocaleUpdateStore(state => state.locale)
|
||||
|
||||
const wrapper = useRef()
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredPeople(people.map(p => p.name))
|
||||
setTouched(people.length <= 1)
|
||||
}, [people])
|
||||
|
||||
const [palette, setPalette] = useState([])
|
||||
|
||||
useEffect(() => setPalette(createPalette({
|
||||
map: colormap === 'crabfit' ? [[0, [247,158,0,0]], [1, [247,158,0,255]]] : colormap,
|
||||
steps: tempFocus !== null ? 2 : Math.min(max, filteredPeople.length)+1,
|
||||
}).format()), [tempFocus, filteredPeople, max, colormap])
|
||||
|
||||
const heatmap = useMemo(() => (
|
||||
<Container>
|
||||
<TimeLabels>
|
||||
{!!timeLabels.length && timeLabels.map((label, i) =>
|
||||
<TimeSpace key={i}>
|
||||
{label.label?.length !== '' && <TimeLabel>{label.label}</TimeLabel>}
|
||||
</TimeSpace>
|
||||
)}
|
||||
</TimeLabels>
|
||||
{dates.map((date, i) => {
|
||||
const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date)
|
||||
const last = dates.length === i+1 || (isSpecificDates ? dayjs(dates[i+1], 'DDMMYYYY') : dayjs().day(dates[i+1])).diff(parsedDate, 'day') > 1
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
<Date>
|
||||
{isSpecificDates && <DateLabel locale={locale}>{parsedDate.format('MMM D')}</DateLabel>}
|
||||
<DayLabel>{parsedDate.format('ddd')}</DayLabel>
|
||||
|
||||
<Times
|
||||
$borderRight={last}
|
||||
$borderLeft={i === 0 || (parsedDate).diff(isSpecificDates ? dayjs(dates[i-1], 'DDMMYYYY') : dayjs().day(dates[i-1]), 'day') > 1}
|
||||
>
|
||||
{timeLabels.map((timeLabel, i) => {
|
||||
if (!timeLabel.time) return null
|
||||
if (!times.includes(`${timeLabel.time}-${date}`)) {
|
||||
return (
|
||||
<TimeSpace className="timespace" key={i} title={t('event:greyed_times')} />
|
||||
)
|
||||
}
|
||||
const time = `${timeLabel.time}-${date}`
|
||||
const peopleHere = tempFocus !== null
|
||||
? people.filter(person => person.availability.includes(time) && tempFocus === person.name).map(person => person.name)
|
||||
: people.filter(person => person.availability.includes(time) && filteredPeople.includes(person.name)).map(person => person.name)
|
||||
|
||||
return (
|
||||
<Time
|
||||
key={i}
|
||||
$time={time}
|
||||
className="time"
|
||||
$peopleCount={focusCount !== null && focusCount !== peopleHere.length ? null : peopleHere.length}
|
||||
$palette={palette}
|
||||
aria-label={peopleHere.join(', ')}
|
||||
$maxPeople={tempFocus !== null ? 1 : Math.min(max, filteredPeople.length)}
|
||||
$minPeople={tempFocus !== null ? 0 : Math.min(min, filteredPeople.length)}
|
||||
$highlight={highlight}
|
||||
onMouseEnter={e => {
|
||||
const cellBox = e.currentTarget.getBoundingClientRect()
|
||||
const wrapperBox = wrapper?.current?.getBoundingClientRect() ?? { x: 0, y: 0 }
|
||||
const timeText = timeFormat === '12h' ? `h${locales[locale]?.separator ?? ':'}mma` : `HH${locales[locale]?.separator ?? ':'}mm`
|
||||
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('event:available')}`,
|
||||
date: parsedDate.hour(time.slice(0, 2)).minute(time.slice(2, 4)).format(isSpecificDates ? `${timeText} ddd, D MMM YYYY` : `${timeText} ddd`),
|
||||
people: peopleHere,
|
||||
})
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setTooltip(null)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Times>
|
||||
</Date>
|
||||
{last && dates.length !== i+1 && <Spacer />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</Container>
|
||||
), [
|
||||
people,
|
||||
filteredPeople,
|
||||
tempFocus,
|
||||
focusCount,
|
||||
highlight,
|
||||
locale,
|
||||
dates,
|
||||
isSpecificDates,
|
||||
max,
|
||||
min,
|
||||
t,
|
||||
timeFormat,
|
||||
timeLabels,
|
||||
times,
|
||||
palette,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledMain>
|
||||
<Legend
|
||||
min={Math.min(min, filteredPeople.length)}
|
||||
max={Math.min(max, filteredPeople.length)}
|
||||
total={filteredPeople.length}
|
||||
onSegmentFocus={count => setFocusCount(count)}
|
||||
/>
|
||||
<Info>{t('event:group.info1')}</Info>
|
||||
{people.length > 1 && (
|
||||
<>
|
||||
<Info>{t('event:group.info2')}</Info>
|
||||
<People>
|
||||
{people.map((person, i) =>
|
||||
<Person
|
||||
key={i}
|
||||
$filtered={filteredPeople.includes(person.name)}
|
||||
onClick={() => {
|
||||
setTempFocus(null)
|
||||
if (filteredPeople.includes(person.name)) {
|
||||
if (!touched) {
|
||||
setTouched(true)
|
||||
setFilteredPeople([person.name])
|
||||
} else {
|
||||
setFilteredPeople(filteredPeople.filter(n => n !== person.name))
|
||||
}
|
||||
} else {
|
||||
setFilteredPeople([...filteredPeople, person.name])
|
||||
}
|
||||
}}
|
||||
onMouseOver={() => setTempFocus(person.name)}
|
||||
onMouseOut={() => setTempFocus(null)}
|
||||
title={person.created && dayjs.unix(person.created).fromNow()}
|
||||
>{person.name}</Person>
|
||||
)}
|
||||
</People>
|
||||
</>
|
||||
)}
|
||||
</StyledMain>
|
||||
|
||||
<Wrapper ref={wrapper}>
|
||||
<ScrollWrapper>
|
||||
{heatmap}
|
||||
|
||||
{tooltip && (
|
||||
<Tooltip
|
||||
$x={tooltip.x}
|
||||
$y={tooltip.y}
|
||||
>
|
||||
<TooltipTitle>{tooltip.available}</TooltipTitle>
|
||||
<TooltipDate>{tooltip.date}</TooltipDate>
|
||||
{!!filteredPeople.length && (
|
||||
<TooltipContent>
|
||||
{tooltip.people.map(person =>
|
||||
<TooltipPerson key={person}>{person}</TooltipPerson>
|
||||
)}
|
||||
{filteredPeople.filter(p => !tooltip.people.includes(p)).map(person =>
|
||||
<TooltipPerson key={person} disabled>{person}</TooltipPerson>
|
||||
)}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
</ScrollWrapper>
|
||||
</Wrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AvailabilityViewer
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
.heatmap {
|
||||
display: inline-flex;
|
||||
box-sizing: border-box;
|
||||
min-width: 100%;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 0 calc(calc(100% - 600px) / 2);
|
||||
|
||||
@media (max-width: 660px) {
|
||||
padding: 0 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.timeLabels {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 40px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
|
||||
.timeSpace {
|
||||
height: 10px;
|
||||
position: relative;
|
||||
border-top: 2px solid transparent;
|
||||
|
||||
&.grey {
|
||||
background-origin: border-box;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 4.3px,
|
||||
var(--loading) 4.3px,
|
||||
var(--loading) 8.6px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.timeLabel {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -.7em;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dateColumn {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.dateLabel {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dayLabel {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.times {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
border-bottom: 2px solid var(--text);
|
||||
border-left: 1px solid var(--text);
|
||||
border-right: 1px solid var(--text);
|
||||
|
||||
&[data-border-left=true] {
|
||||
border-left: 2px solid var(--text);
|
||||
border-top-left-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
&[data-border-right=true] {
|
||||
border-right: 2px solid var(--text);
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
}
|
||||
|
||||
& .time + .timespace, & .timespace:first-of-type {
|
||||
border-top: 2px solid var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
height: 10px;
|
||||
background-origin: border-box;
|
||||
transition: background-color .1s;
|
||||
|
||||
border-top-width: 2px;
|
||||
border-top-style: solid;
|
||||
border-top-color: var(--text);
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.highlight {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 4.3px,
|
||||
rgba(0,0,0,.5) 4.3px,
|
||||
rgba(0,0,0,.5) 8.6px
|
||||
);
|
||||
}
|
||||
|
||||
.info {
|
||||
display: block;
|
||||
text-align: center;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.people {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
margin: 14px auto;
|
||||
}
|
||||
|
||||
.person {
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--text);
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.personSelected {
|
||||
background: var(--primary);
|
||||
color: #FFFFFF;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
overflow-y: visible;
|
||||
margin: 20px 0;
|
||||
position: relative;
|
||||
|
||||
& > div {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.columnSpacer {
|
||||
width: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
|
@ -1,112 +1,4 @@
|
|||
import { styled } from 'goober'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
export const Wrapper = styled('div', forwardRef)`
|
||||
overflow-y: visible;
|
||||
margin: 20px 0;
|
||||
position: relative;
|
||||
`
|
||||
|
||||
export const ScrollWrapper = styled('div')`
|
||||
overflow-x: auto;
|
||||
`
|
||||
|
||||
export const Container = styled('div')`
|
||||
display: inline-flex;
|
||||
box-sizing: border-box;
|
||||
min-width: 100%;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: 0 calc(calc(100% - 600px) / 2);
|
||||
|
||||
@media (max-width: 660px) {
|
||||
padding: 0 30px;
|
||||
}
|
||||
`
|
||||
|
||||
export const Date = styled('div')`
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
margin-bottom: 10px;
|
||||
`
|
||||
|
||||
export const Times = styled('div')`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
border-bottom: 2px solid var(--text);
|
||||
border-left: 1px solid var(--text);
|
||||
border-right: 1px solid var(--text);
|
||||
|
||||
${props => props.$borderLeft && `
|
||||
border-left: 2px solid var(--text);
|
||||
border-top-left-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
`}
|
||||
${props => props.$borderRight && `
|
||||
border-right: 2px solid var(--text);
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
`}
|
||||
|
||||
& .time + .timespace, & .timespace:first-of-type {
|
||||
border-top: 2px solid var(--text);
|
||||
}
|
||||
`
|
||||
|
||||
export const DateLabel = styled('label')`
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
`
|
||||
|
||||
export const DayLabel = styled('label')`
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
`
|
||||
|
||||
export const Time = styled('div')`
|
||||
height: 10px;
|
||||
background-origin: border-box;
|
||||
transition: background-color .1s;
|
||||
|
||||
${props => props.$time.slice(2, 4) === '00' && `
|
||||
border-top: 2px solid var(--text);
|
||||
`}
|
||||
${props => props.$time.slice(2, 4) !== '00' && `
|
||||
border-top: 2px solid transparent;
|
||||
`}
|
||||
${props => props.$time.slice(2, 4) === '30' && `
|
||||
border-top: 2px dotted var(--text);
|
||||
`}
|
||||
|
||||
background-color: ${props => props.$palette[props.$peopleCount] ?? 'transparent'};
|
||||
|
||||
${props => props.$highlight && props.$peopleCount === props.$maxPeople && props.$peopleCount > 0 && `
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 4.3px,
|
||||
rgba(0,0,0,.5) 4.3px,
|
||||
rgba(0,0,0,.5) 8.6px
|
||||
);
|
||||
`}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
transition: none;
|
||||
}
|
||||
`
|
||||
|
||||
export const Spacer = styled('div')`
|
||||
width: 12px;
|
||||
flex-shrink: 0;
|
||||
`
|
||||
|
||||
export const Tooltip = styled('div')`
|
||||
position: absolute;
|
||||
|
|
@ -153,80 +45,3 @@ export const TooltipPerson = styled('span')`
|
|||
border-color: var(--text);
|
||||
`}
|
||||
`
|
||||
|
||||
export const TimeLabels = styled('div')`
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 40px;
|
||||
padding-right: 6px;
|
||||
`
|
||||
|
||||
export const TimeSpace = styled('div')`
|
||||
height: 10px;
|
||||
position: relative;
|
||||
border-top: 2px solid transparent;
|
||||
|
||||
&.timespace {
|
||||
background-origin: border-box;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 4.3px,
|
||||
var(--loading) 4.3px,
|
||||
var(--loading) 8.6px
|
||||
);
|
||||
}
|
||||
`
|
||||
|
||||
export const TimeLabel = styled('label')`
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: -.7em;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
export const StyledMain = styled('div')`
|
||||
width: 600px;
|
||||
margin: 20px auto;
|
||||
max-width: calc(100% - 60px);
|
||||
`
|
||||
|
||||
export const People = styled('div')`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
justify-content: center;
|
||||
margin: 14px auto;
|
||||
`
|
||||
|
||||
export const Person = styled('button')`
|
||||
font: inherit;
|
||||
font-size: 15px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--text);
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 2px 8px;
|
||||
user-select: none;
|
||||
|
||||
${props => props.$filtered && `
|
||||
background: var(--primary);
|
||||
color: #FFFFFF;
|
||||
border-color: var(--primary);
|
||||
`}
|
||||
`
|
||||
|
||||
export const Info = styled('span')`
|
||||
display: block;
|
||||
text-align: center;
|
||||
|
||||
@media print {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,218 @@
|
|||
'use client'
|
||||
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { Temporal } from '@js-temporal/polyfill'
|
||||
import { createPalette } from 'hue-map'
|
||||
|
||||
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, calculateColumns, calculateRows, convertTimesToDates, makeClass } from '/src/utils'
|
||||
|
||||
import styles from './AvailabilityViewer.module.scss'
|
||||
|
||||
interface AvailabilityViewerProps {
|
||||
times: string[]
|
||||
timezone: string
|
||||
people: PersonResponse[]
|
||||
}
|
||||
|
||||
const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps) => {
|
||||
const { t, i18n } = useTranslation('event')
|
||||
|
||||
// const [tooltip, setTooltip] = useState(null)
|
||||
const timeFormat = useStore(useSettingsStore, state => state.timeFormat)
|
||||
const highlight = useStore(useSettingsStore, state => state.highlight)
|
||||
const colormap = useStore(useSettingsStore, state => state.colormap)
|
||||
const [filteredPeople, setFilteredPeople] = useState(people.map(p => p.name))
|
||||
// const [tempFocus, setTempFocus] = useState(null)
|
||||
// const [focusCount, setFocusCount] = useState(null)
|
||||
|
||||
// const wrapper = useRef()
|
||||
|
||||
// Calculate rows and columns
|
||||
const [dates, rows, columns] = useMemo(() => {
|
||||
const dates = convertTimesToDates(times, timezone)
|
||||
return [dates, calculateRows(dates), calculateColumns(dates)]
|
||||
}, [times, timezone])
|
||||
|
||||
// Calculate availabilities
|
||||
const { availabilities, min, max } = useMemo(() => calculateAvailability(dates, people
|
||||
.filter(p => filteredPeople.includes(p.name))
|
||||
.map(p => ({
|
||||
...p,
|
||||
availability: convertTimesToDates(p.availability, timezone),
|
||||
}))
|
||||
), [dates, filteredPeople, people, timezone])
|
||||
|
||||
// Is specific dates or just days of the week
|
||||
const isSpecificDates = useMemo(() => times[0].length === 13, [times])
|
||||
|
||||
// Create the colour palette
|
||||
const [palette, setPalette] = useState<string[]>([])
|
||||
useEffect(() => {
|
||||
setPalette(createPalette({
|
||||
map: colormap !== 'crabfit' ? colormap : [[0, [247, 158, 0, 0]], [1, [247, 158, 0, 255]]],
|
||||
steps: (max - min) + 1,
|
||||
}).format())
|
||||
}, [min, max, colormap])
|
||||
|
||||
const heatmap = useMemo(() => (
|
||||
<div className={styles.heatmap}>
|
||||
<div className={styles.timeLabels}>
|
||||
{rows.map((row, i) =>
|
||||
<div className={styles.timeSpace} key={i}>
|
||||
{row && row.minute === 0 && <label className={styles.timeLabel}>
|
||||
{row.toLocaleString(i18n.language, { hour: 'numeric', hour12: timeFormat === '12h' })}
|
||||
</label>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{columns.map((column, i) => <Fragment key={i}>
|
||||
{column ? <div className={styles.dateColumn}>
|
||||
{isSpecificDates && <label className={styles.dateLabel}>{column.toLocaleString(i18n.language, { month: 'short', day: 'numeric' })}</label>}
|
||||
<label className={styles.dayLabel}>{column.toLocaleString(i18n.language, { weekday: 'short' })}</label>
|
||||
|
||||
<div
|
||||
className={styles.times}
|
||||
data-border-left={i === 0 || columns.at(i - 1) === null}
|
||||
data-border-right={i === columns.length - 1 || columns.at(i + 1) === null}
|
||||
>
|
||||
{rows.map((row, i) => {
|
||||
if (i === rows.length - 1) return null
|
||||
|
||||
if (!row || rows.at(i + 1) === null || dates.every(d => !d.equals(column.toZonedDateTime({ timeZone: timezone, plainTime: row })))) {
|
||||
return <div
|
||||
className={makeClass(styles.timeSpace, styles.grey)}
|
||||
key={i}
|
||||
title={t<string>('greyed_times')}
|
||||
/>
|
||||
}
|
||||
|
||||
const date = column.toZonedDateTime({ timeZone: timezone, plainTime: row })
|
||||
const peopleHere = availabilities.find(a => a.date.equals(date))?.people ?? []
|
||||
|
||||
return <div
|
||||
key={i}
|
||||
className={makeClass(
|
||||
styles.time,
|
||||
highlight && peopleHere.length === max && peopleHere.length > 0 && styles.highlight,
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: palette[peopleHere.length],
|
||||
...date.minute !== 0 && date.minute !== 30 && { borderTopColor: 'transparent' },
|
||||
...date.minute === 30 && { borderTopStyle: 'dotted' },
|
||||
}}
|
||||
aria-label={peopleHere.join(', ')}
|
||||
// onMouseEnter={e => {
|
||||
// const cellBox = e.currentTarget.getBoundingClientRect()
|
||||
// const wrapperBox = wrapper?.current?.getBoundingClientRect() ?? { x: 0, y: 0 }
|
||||
// const timeText = timeFormat === '12h' ? `h${locales[locale]?.separator ?? ':'}mma` : `HH${locales[locale]?.separator ?? ':'}mm`
|
||||
// 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('event:available')}`,
|
||||
// date: parsedDate.hour(time.slice(0, 2)).minute(time.slice(2, 4)).format(isSpecificDates ? `${timeText} ddd, D MMM YYYY` : `${timeText} ddd`),
|
||||
// people: peopleHere,
|
||||
// })
|
||||
// }}
|
||||
// onMouseLeave={() => {
|
||||
// setTooltip(null)
|
||||
// }}
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
</div> : <div className={styles.columnSpacer} />}
|
||||
</Fragment>)}
|
||||
</div>
|
||||
), [
|
||||
availabilities,
|
||||
dates,
|
||||
isSpecificDates,
|
||||
rows,
|
||||
columns,
|
||||
highlight,
|
||||
max,
|
||||
t,
|
||||
timeFormat,
|
||||
palette,
|
||||
])
|
||||
|
||||
return <>
|
||||
<Content>
|
||||
<Legend
|
||||
min={min}
|
||||
max={max}
|
||||
total={filteredPeople.length}
|
||||
palette={palette}
|
||||
onSegmentFocus={console.log}
|
||||
/>
|
||||
|
||||
<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(null)
|
||||
// if (filteredPeople.includes(person.name)) {
|
||||
// if (!touched) {
|
||||
// setTouched(true)
|
||||
// setFilteredPeople([person.name])
|
||||
// } else {
|
||||
// setFilteredPeople(filteredPeople.filter(n => n !== person.name))
|
||||
// }
|
||||
// } else {
|
||||
// setFilteredPeople([...filteredPeople, person.name])
|
||||
// }
|
||||
// }}
|
||||
// onMouseOver={() => setTempFocus(person.name)}
|
||||
// onMouseOut={() => setTempFocus(null)}
|
||||
title={Temporal.Instant.fromEpochSeconds(person.created_at).until(Temporal.Now.instant()).toLocaleString()}
|
||||
>{person.name}</button>
|
||||
)}
|
||||
</div>
|
||||
</>}
|
||||
</Content>
|
||||
|
||||
<div className={styles.wrapper}>
|
||||
<div>
|
||||
{heatmap}
|
||||
|
||||
{/* {tooltip && (
|
||||
<Tooltip
|
||||
$x={tooltip.x}
|
||||
$y={tooltip.y}
|
||||
>
|
||||
<TooltipTitle>{tooltip.available}</TooltipTitle>
|
||||
<TooltipDate>{tooltip.date}</TooltipDate>
|
||||
{!!filteredPeople.length && (
|
||||
<TooltipContent>
|
||||
{tooltip.people.map(person =>
|
||||
<TooltipPerson key={person}>{person}</TooltipPerson>
|
||||
)}
|
||||
{filteredPeople.filter(p => !tooltip.people.includes(p)).map(person =>
|
||||
<TooltipPerson key={person} disabled>{person}</TooltipPerson>
|
||||
)}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
export default AvailabilityViewer
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createPalette } from 'hue-map'
|
||||
|
||||
import { useSettingsStore } from '/src/stores'
|
||||
|
||||
import {
|
||||
Wrapper,
|
||||
Label,
|
||||
Bar,
|
||||
Grade,
|
||||
} from './Legend.styles'
|
||||
|
||||
const Legend = ({
|
||||
min,
|
||||
max,
|
||||
total,
|
||||
onSegmentFocus,
|
||||
}) => {
|
||||
const { t } = useTranslation('event')
|
||||
const highlight = useSettingsStore(state => state.highlight)
|
||||
const colormap = useSettingsStore(state => state.colormap)
|
||||
const setHighlight = useSettingsStore(state => state.setHighlight)
|
||||
|
||||
const [palette, setPalette] = useState([])
|
||||
|
||||
useEffect(() => setPalette(createPalette({
|
||||
map: colormap === 'crabfit' ? [[0, [247,158,0,0]], [1, [247,158,0,255]]] : colormap,
|
||||
steps: max+1-min,
|
||||
}).format()), [min, max, colormap])
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Label>{min}/{total} {t('event:available')}</Label>
|
||||
|
||||
<Bar
|
||||
onMouseOut={() => onSegmentFocus(null)}
|
||||
onClick={() => setHighlight(!highlight)}
|
||||
title={t('event:group.legend_tooltip')}
|
||||
>
|
||||
{[...Array(max+1-min).keys()].map(i => i+min).map(i =>
|
||||
<Grade
|
||||
key={i}
|
||||
$color={palette[i]}
|
||||
$highlight={highlight && i === max && max > 0}
|
||||
onMouseOver={() => onSegmentFocus(i)}
|
||||
/>
|
||||
)}
|
||||
</Bar>
|
||||
|
||||
<Label>{max}/{total} {t('event:available')}</Label>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default Legend
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { styled } from 'goober'
|
||||
|
||||
export const Wrapper = styled('div')`
|
||||
.wrapper {
|
||||
margin: 10px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -13,15 +11,15 @@ export const Wrapper = styled('div')`
|
|||
@media (max-width: 400px) {
|
||||
display: block;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const Label = styled('label')`
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
`
|
||||
}
|
||||
|
||||
export const Bar = styled('div')`
|
||||
.bar {
|
||||
display: flex;
|
||||
width: 40%;
|
||||
height: 20px;
|
||||
|
|
@ -34,19 +32,14 @@ export const Bar = styled('div')`
|
|||
width: 100%;
|
||||
margin: 8px 0;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const Grade = styled('div')`
|
||||
flex: 1;
|
||||
background-color: ${props => props.$color};
|
||||
|
||||
${props => props.$highlight && `
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 4.5px,
|
||||
rgba(0,0,0,.5) 4.5px,
|
||||
rgba(0,0,0,.5) 9px
|
||||
);
|
||||
`}
|
||||
`
|
||||
.highlight {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 4.5px,
|
||||
rgba(0,0,0,.5) 4.5px,
|
||||
rgba(0,0,0,.5) 9px
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/Legend/Legend.tsx
Normal file
43
frontend/src/components/Legend/Legend.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { useTranslation } from '/src/i18n/client'
|
||||
import { useStore } from '/src/stores'
|
||||
import useSettingsStore from '/src/stores/settingsStore'
|
||||
|
||||
import styles from './Legend.module.scss'
|
||||
|
||||
interface LegendProps {
|
||||
min: number
|
||||
max: number
|
||||
total: number
|
||||
palette: string[]
|
||||
onSegmentFocus: (segment: number | undefined) => void
|
||||
}
|
||||
|
||||
const Legend = ({ min, max, total, palette, onSegmentFocus }: LegendProps) => {
|
||||
const { t } = useTranslation('event')
|
||||
const highlight = useStore(useSettingsStore, state => state.highlight)
|
||||
const setHighlight = useStore(useSettingsStore, state => state.setHighlight)
|
||||
|
||||
return <div className={styles.wrapper}>
|
||||
<label className={styles.label}>{min}/{total} {t('available')}</label>
|
||||
|
||||
<div
|
||||
className={styles.bar}
|
||||
onMouseOut={() => onSegmentFocus(undefined)}
|
||||
onClick={() => setHighlight?.(!highlight)}
|
||||
title={t<string>('group.legend_tooltip')}
|
||||
>
|
||||
{[...Array(max + 1 - min).keys()].map(i => i + min).map(i =>
|
||||
<div
|
||||
key={i}
|
||||
style={{ flex: 1, backgroundColor: palette[i] }}
|
||||
className={highlight && i === max && max > 0 ? styles.highlight : undefined}
|
||||
onMouseOver={() => onSegmentFocus(i)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className={styles.label}>{max}/{total} {t('available')}</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Legend
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { Wrapper, Loader } from './Loading.styles'
|
||||
|
||||
const Loading = () => <Wrapper><Loader /></Wrapper>
|
||||
|
||||
export default Loading
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { styled } from 'goober'
|
||||
|
||||
export const Wrapper = styled('main')`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
`
|
||||
|
||||
export const Loader = styled('div')`
|
||||
@keyframes load {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
border: 3px solid var(--primary);
|
||||
border-left-color: transparent;
|
||||
border-radius: 100px;
|
||||
animation: load .5s linear infinite;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
border: 0;
|
||||
|
||||
&::before {
|
||||
content: 'loading...';
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
@ -14,20 +14,20 @@ const Stats = async () => {
|
|||
const stats = await getStats()
|
||||
const { t } = await useTranslation('home')
|
||||
|
||||
return <div className={styles.wrapper}>
|
||||
return stats ? <div className={styles.wrapper}>
|
||||
<div>
|
||||
<span className={styles.number}>
|
||||
{new Intl.NumberFormat().format(stats?.event_count || 17000)}{!stats?.event_count && '+'}
|
||||
{new Intl.NumberFormat().format(stats.event_count)}
|
||||
</span>
|
||||
<span className={styles.label}>{t('about.events')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.number}>
|
||||
{new Intl.NumberFormat().format(stats?.person_count || 65000)}{!stats?.person_count && '+'}
|
||||
{new Intl.NumberFormat().format(stats.person_count)}
|
||||
</span>
|
||||
<span className={styles.label}>{t('about.availabilities')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div> : null
|
||||
}
|
||||
|
||||
export default Stats
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { FieldValues, useController, UseControllerProps } from 'react-hook-form'
|
||||
import dayjs from 'dayjs'
|
||||
|
|
@ -13,14 +15,24 @@ const times = ['00', '01', '02', '03', '04', '05', '06', '07', '08', '09', '10',
|
|||
interface TimeRangeFieldProps<TValues extends FieldValues> extends UseControllerProps<TValues> {
|
||||
label?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
staticValue?: {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
}
|
||||
|
||||
const TimeRangeField = <TValues extends FieldValues>({
|
||||
label,
|
||||
description,
|
||||
staticValue,
|
||||
...props
|
||||
}: TimeRangeFieldProps<TValues>) => {
|
||||
const { field: { value, onChange } } = useController(props)
|
||||
const { field: { value, onChange } } = !staticValue
|
||||
? useController(props)
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
: { field: { value: staticValue, onChange: () => {} } }
|
||||
|
||||
if (!('start' in value) || !('end' in value)) return null
|
||||
|
||||
return <Wrapper>
|
||||
{label && <Label>{label}</Label>}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue