Use Temporal polyfill to implement availability viewer structure
This commit is contained in:
parent
877c4b3479
commit
756b71433c
|
|
@ -11,7 +11,8 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^2.37.0",
|
"@azure/msal-browser": "^2.37.0",
|
||||||
"@giraugh/tools": "^1.5.0",
|
"@giraugh/tools": "^1.6.0",
|
||||||
|
"@js-temporal/polyfill": "^0.4.4",
|
||||||
"@microsoft/microsoft-graph-client": "^3.0.5",
|
"@microsoft/microsoft-graph-client": "^3.0.5",
|
||||||
"accept-language": "^3.0.18",
|
"accept-language": "^3.0.18",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
|
|
|
||||||
50
frontend/src/app/how-to/page.module.scss
Normal file
50
frontend/src/app/how-to/page.module.scss
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
.step {
|
||||||
|
text-decoration-color: var(--primary);
|
||||||
|
text-decoration-style: solid;
|
||||||
|
text-decoration-line: underline;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fakeCalendar {
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
& div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
grid-gap: 2px;
|
||||||
|
}
|
||||||
|
& div:first-of-type span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3px 0;
|
||||||
|
font-weight: bold;
|
||||||
|
user-select: none;
|
||||||
|
opacity: .7;
|
||||||
|
@media (max-width: 350px) {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& div:last-of-type span {
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 0;
|
||||||
|
color: #FFF;
|
||||||
|
background-color: var(--primary);
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
border-start-start-radius: 3px;
|
||||||
|
border-end-start-radius: 3px;
|
||||||
|
color: inherit;
|
||||||
|
background-color: var(--surface);
|
||||||
|
}
|
||||||
|
&:last-of-type {
|
||||||
|
border-end-end-radius: 3px;
|
||||||
|
border-start-end-radius: 3px;
|
||||||
|
color: inherit;
|
||||||
|
background-color: var(--surface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
frontend/src/app/how-to/page.tsx
Normal file
74
frontend/src/app/how-to/page.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { Trans } from 'react-i18next/TransWithoutContext'
|
||||||
|
import { Metadata } from 'next'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { range } from '@giraugh/tools'
|
||||||
|
|
||||||
|
import AvailabilityViewer from '/src/components/AvailabilityViewer/AvailabilityViewer'
|
||||||
|
import Button from '/src/components/Button/Button'
|
||||||
|
import Content from '/src/components/Content/Content'
|
||||||
|
import Header from '/src/components/Header/Header'
|
||||||
|
import { P } from '/src/components/Paragraph/Text'
|
||||||
|
import Section from '/src/components/Section/Section'
|
||||||
|
import TimeRangeField from '/src/components/TimeRangeField/TimeRangeField'
|
||||||
|
import Video from '/src/components/Video/Video'
|
||||||
|
import { useTranslation } from '/src/i18n/server'
|
||||||
|
|
||||||
|
import styles from './page.module.scss'
|
||||||
|
|
||||||
|
export const generateMetadata = async (): Promise<Metadata> => {
|
||||||
|
const { t } = await useTranslation('help')
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('name'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Page = async () => {
|
||||||
|
const { t, i18n } = await useTranslation(['common', 'help'])
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<Content>
|
||||||
|
{/* @ts-expect-error Async Server Component */}
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<h1>{t('help:name')}</h1>
|
||||||
|
|
||||||
|
<Video />
|
||||||
|
|
||||||
|
<P>{t('help:p1')}</P>
|
||||||
|
<P>{t('help:p2')}</P>
|
||||||
|
|
||||||
|
<h2 className={styles.step}>{t('help:s1')}</h2>
|
||||||
|
<P><Trans i18nKey="help:p3" t={t} i18n={i18n}>_<Link href="/">_</Link>_</Trans></P>
|
||||||
|
<P>{t('help:p4')}</P>
|
||||||
|
<div className={styles.fakeCalendar}>
|
||||||
|
<div>{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(d => <span key={d}>{d}</span>)}</div>
|
||||||
|
<div>{range(11, 17).map(d => <span key={d}>{d}</span>)}</div>
|
||||||
|
</div>
|
||||||
|
<P>{t('help:p5')}</P>
|
||||||
|
<TimeRangeField name="time" staticValue={{ start: 11, end: 17 }} />
|
||||||
|
|
||||||
|
<h2 className={styles.step}>{t('help:s2')}</h2>
|
||||||
|
<P>{t('help:p6')}</P>
|
||||||
|
<P>{t('help:p7')}</P>
|
||||||
|
<AvailabilityViewer
|
||||||
|
times={['1100-12042021', '1115-12042021', '1130-12042021', '1145-12042021', '1200-12042021', '1215-12042021', '1230-12042021', '1245-12042021', '1300-12042021', '1315-12042021', '1330-12042021', '1345-12042021', '1400-12042021', '1415-12042021', '1430-12042021', '1445-12042021', '1500-12042021', '1515-12042021', '1530-12042021', '1545-12042021', '1600-12042021', '1615-12042021', '1630-12042021', '1645-12042021', '1100-13042021', '1115-13042021', '1130-13042021', '1145-13042021', '1200-13042021', '1215-13042021', '1230-13042021', '1245-13042021', '1300-13042021', '1315-13042021', '1330-13042021', '1345-13042021', '1400-13042021', '1415-13042021', '1430-13042021', '1445-13042021', '1500-13042021', '1515-13042021', '1530-13042021', '1545-13042021', '1600-13042021', '1615-13042021', '1630-13042021', '1645-13042021', '1100-14042021', '1115-14042021', '1130-14042021', '1145-14042021', '1200-14042021', '1215-14042021', '1230-14042021', '1245-14042021', '1300-14042021', '1315-14042021', '1330-14042021', '1345-14042021', '1400-14042021', '1415-14042021', '1430-14042021', '1445-14042021', '1500-14042021', '1515-14042021', '1530-14042021', '1545-14042021', '1600-14042021', '1615-14042021', '1630-14042021', '1645-14042021', '1100-15042021', '1115-15042021', '1130-15042021', '1145-15042021', '1200-15042021', '1215-15042021', '1230-15042021', '1245-15042021', '1300-15042021', '1315-15042021', '1330-15042021', '1345-15042021', '1400-15042021', '1415-15042021', '1430-15042021', '1445-15042021', '1500-15042021', '1515-15042021', '1530-15042021', '1545-15042021', '1600-15042021', '1615-15042021', '1630-15042021', '1645-15042021', '1100-16042021', '1115-16042021', '1130-16042021', '1145-16042021', '1200-16042021', '1215-16042021', '1230-16042021', '1245-16042021', '1300-16042021', '1315-16042021', '1330-16042021', '1345-16042021', '1400-16042021', '1415-16042021', '1430-16042021', '1445-16042021', '1500-16042021', '1515-16042021', '1530-16042021', '1545-16042021', '1600-16042021', '1615-16042021', '1630-16042021', '1645-16042021']}
|
||||||
|
people={[{ name: 'Jenny', created_at: 0, availability: ['1100-12042021', '1100-13042021', '1100-14042021', '1100-15042021', '1115-12042021', '1115-13042021', '1115-14042021', '1115-15042021', '1130-12042021', '1130-13042021', '1130-14042021', '1130-15042021', '1145-12042021', '1145-13042021', '1145-14042021', '1145-15042021', '1200-12042021', '1200-13042021', '1200-14042021', '1200-15042021', '1215-12042021', '1215-13042021', '1215-14042021', '1215-15042021', '1230-12042021', '1230-13042021', '1230-14042021', '1230-15042021', '1245-12042021', '1245-13042021', '1245-14042021', '1245-15042021', '1300-12042021', '1300-13042021', '1300-14042021', '1300-15042021', '1300-16042021', '1315-12042021', '1315-13042021', '1315-14042021', '1315-15042021', '1315-16042021', '1330-12042021', '1330-13042021', '1330-14042021', '1330-15042021', '1330-16042021', '1345-12042021', '1345-13042021', '1345-14042021', '1345-15042021', '1345-16042021', '1400-12042021', '1400-13042021', '1400-14042021', '1400-15042021', '1400-16042021', '1415-12042021', '1415-13042021', '1415-14042021', '1415-15042021', '1415-16042021', '1430-12042021', '1430-13042021', '1430-14042021', '1430-15042021', '1430-16042021', '1445-12042021', '1445-13042021', '1445-14042021', '1445-15042021', '1445-16042021', '1500-12042021', '1500-15042021', '1500-16042021', '1515-12042021', '1515-15042021', '1515-16042021', '1530-12042021', '1530-15042021', '1530-16042021', '1545-12042021', '1545-15042021', '1545-16042021', '1600-12042021', '1600-15042021', '1600-16042021', '1615-12042021', '1615-15042021', '1615-16042021', '1630-12042021', '1630-15042021', '1630-16042021', '1645-12042021', '1645-15042021', '1645-16042021'] }]}
|
||||||
|
timezone="UTC"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h2 className={styles.step}>{t('help:s3')}</h2>
|
||||||
|
<P>{t('help:p8')}</P>
|
||||||
|
<P>{t('help:p9')}</P>
|
||||||
|
<P>{t('help:p10')}</P>
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<Content isCentered>
|
||||||
|
<Button href="/">{t('common:cta')}</Button>
|
||||||
|
</Content>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page
|
||||||
|
|
@ -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 { 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')`
|
export const Tooltip = styled('div')`
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
@ -153,80 +45,3 @@ export const TooltipPerson = styled('span')`
|
||||||
border-color: var(--text);
|
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'
|
.wrapper {
|
||||||
|
|
||||||
export const Wrapper = styled('div')`
|
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -13,15 +11,15 @@ export const Wrapper = styled('div')`
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 400px) {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
`
|
}
|
||||||
|
|
||||||
export const Label = styled('label')`
|
.label {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
`
|
}
|
||||||
|
|
||||||
export const Bar = styled('div')`
|
.bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 40%;
|
width: 40%;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|
@ -34,19 +32,14 @@ export const Bar = styled('div')`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
`
|
}
|
||||||
|
|
||||||
export const Grade = styled('div')`
|
.highlight {
|
||||||
flex: 1;
|
background-image: repeating-linear-gradient(
|
||||||
background-color: ${props => props.$color};
|
45deg,
|
||||||
|
transparent,
|
||||||
${props => props.$highlight && `
|
transparent 4.5px,
|
||||||
background-image: repeating-linear-gradient(
|
rgba(0,0,0,.5) 4.5px,
|
||||||
45deg,
|
rgba(0,0,0,.5) 9px
|
||||||
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 stats = await getStats()
|
||||||
const { t } = await useTranslation('home')
|
const { t } = await useTranslation('home')
|
||||||
|
|
||||||
return <div className={styles.wrapper}>
|
return stats ? <div className={styles.wrapper}>
|
||||||
<div>
|
<div>
|
||||||
<span className={styles.number}>
|
<span className={styles.number}>
|
||||||
{new Intl.NumberFormat().format(stats?.event_count || 17000)}{!stats?.event_count && '+'}
|
{new Intl.NumberFormat().format(stats.event_count)}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.label}>{t('about.events')}</span>
|
<span className={styles.label}>{t('about.events')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className={styles.number}>
|
<span className={styles.number}>
|
||||||
{new Intl.NumberFormat().format(stats?.person_count || 65000)}{!stats?.person_count && '+'}
|
{new Intl.NumberFormat().format(stats.person_count)}
|
||||||
</span>
|
</span>
|
||||||
<span className={styles.label}>{t('about.availabilities')}</span>
|
<span className={styles.label}>{t('about.availabilities')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Stats
|
export default Stats
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { FieldValues, useController, UseControllerProps } from 'react-hook-form'
|
import { FieldValues, useController, UseControllerProps } from 'react-hook-form'
|
||||||
import dayjs from 'dayjs'
|
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> {
|
interface TimeRangeFieldProps<TValues extends FieldValues> extends UseControllerProps<TValues> {
|
||||||
label?: React.ReactNode
|
label?: React.ReactNode
|
||||||
description?: React.ReactNode
|
description?: React.ReactNode
|
||||||
|
staticValue?: {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimeRangeField = <TValues extends FieldValues>({
|
const TimeRangeField = <TValues extends FieldValues>({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
|
staticValue,
|
||||||
...props
|
...props
|
||||||
}: TimeRangeFieldProps<TValues>) => {
|
}: 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>
|
return <Wrapper>
|
||||||
{label && <Label>{label}</Label>}
|
{label && <Label>{label}</Label>}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ interface LanguageDetails {
|
||||||
/** 0: Sunday, 1: Monday */
|
/** 0: Sunday, 1: Monday */
|
||||||
weekStart: 0 | 1
|
weekStart: 0 | 1
|
||||||
timeFormat: '12h' | '24h'
|
timeFormat: '12h' | '24h'
|
||||||
/** TODO: document */
|
/** The separator to show between hours and minutes (default `:`) */
|
||||||
separator?: string
|
separator?: string
|
||||||
/** Day.js locale import */
|
/** Day.js locale import */
|
||||||
import: () => Promise<unknown>
|
import: () => Promise<unknown>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ColorMap } from 'hue-map/dist/maps'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
|
@ -9,13 +10,13 @@ interface SettingsStore {
|
||||||
timeFormat: TimeFormat
|
timeFormat: TimeFormat
|
||||||
theme: Theme
|
theme: Theme
|
||||||
highlight: boolean
|
highlight: boolean
|
||||||
colormap: string
|
colormap: 'crabfit' | ColorMap
|
||||||
|
|
||||||
setWeekStart: (weekStart: 0 | 1) => void
|
setWeekStart: (weekStart: 0 | 1) => void
|
||||||
setTimeFormat: (timeFormat: TimeFormat) => void
|
setTimeFormat: (timeFormat: TimeFormat) => void
|
||||||
setTheme: (theme: Theme) => void
|
setTheme: (theme: Theme) => void
|
||||||
setHighlight: (highlight: boolean) => void
|
setHighlight: (highlight: boolean) => void
|
||||||
setColormap: (colormap: string) => void
|
setColormap: (colormap: 'crabfit' | ColorMap) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useSettingsStore = create<SettingsStore>()(persist(
|
const useSettingsStore = create<SettingsStore>()(persist(
|
||||||
|
|
|
||||||
43
frontend/src/utils/calculateAvailability.ts
Normal file
43
frontend/src/utils/calculateAvailability.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
interface Person {
|
||||||
|
name: string
|
||||||
|
availability: Temporal.ZonedDateTime[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Availability {
|
||||||
|
date: Temporal.ZonedDateTime
|
||||||
|
/** Names of everyone who is available at this date */
|
||||||
|
people: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvailabilityInfo {
|
||||||
|
availabilities: Availability[]
|
||||||
|
/** The amount of people available in the date with lowest availability */
|
||||||
|
min: number
|
||||||
|
/** The amount of people available in the date with highest availability */
|
||||||
|
max: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes an array of dates and an array of people,
|
||||||
|
* where each person has a name and availability array, and returns the
|
||||||
|
* group availability for each date passed in.
|
||||||
|
*/
|
||||||
|
export const calculateAvailability = (dates: Temporal.ZonedDateTime[], people: Person[]): AvailabilityInfo => {
|
||||||
|
let min = Infinity
|
||||||
|
let max = -Infinity
|
||||||
|
|
||||||
|
const availabilities: Availability[] = dates.map(date => {
|
||||||
|
const names = people.flatMap(p => p.availability.some(d => d.equals(date)) ? [p.name] : [])
|
||||||
|
if (names.length < min) {
|
||||||
|
min = names.length
|
||||||
|
}
|
||||||
|
if (names.length > max) {
|
||||||
|
max = names.length
|
||||||
|
}
|
||||||
|
return { date, people: names }
|
||||||
|
})
|
||||||
|
|
||||||
|
return { availabilities, min, max }
|
||||||
|
}
|
||||||
25
frontend/src/utils/calculateColumns.ts
Normal file
25
frontend/src/utils/calculateColumns.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { splitArrayBy } from '@giraugh/tools'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the columns required for an availability grid
|
||||||
|
* @returns An array of PlainDate or null, where null indicates a spacer column between dates
|
||||||
|
*/
|
||||||
|
export const calculateColumns = (dates: Temporal.ZonedDateTime[]): (Temporal.PlainDate | null)[] => {
|
||||||
|
// Dedupe dates by date and sort
|
||||||
|
const sortedDates = [...new Map(dates.map(d => {
|
||||||
|
const plain = d.toPlainDate()
|
||||||
|
return [plain.toString(), plain]
|
||||||
|
})).values()]
|
||||||
|
.sort(Temporal.PlainDate.compare)
|
||||||
|
|
||||||
|
// Partition by distance
|
||||||
|
const partitionedDates = splitArrayBy(sortedDates, (a, b) => !a.add({ days: 1 }).equals(b))
|
||||||
|
|
||||||
|
// Join
|
||||||
|
return partitionedDates.reduce((columns, partition, i) => [
|
||||||
|
...columns,
|
||||||
|
...partition,
|
||||||
|
...partitionedDates.length - 1 < i ? [null] : [], // Add spacer in between partitions
|
||||||
|
], [] as (Temporal.PlainDate | null)[])
|
||||||
|
}
|
||||||
26
frontend/src/utils/calculateRows.ts
Normal file
26
frontend/src/utils/calculateRows.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { splitArrayBy } from '@giraugh/tools'
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the rows required for an availability grid
|
||||||
|
* @returns An array of PlainTime or null, where null indicates a spacer row in gaps
|
||||||
|
*/
|
||||||
|
export const calculateRows = (dates: Temporal.ZonedDateTime[]): (Temporal.PlainTime | null)[] => {
|
||||||
|
// Dedupe dates by time and sort
|
||||||
|
const sortedDates = [...new Map(dates.map(d => {
|
||||||
|
const plain = d.toPlainTime()
|
||||||
|
return [plain.toString({ smallestUnit: 'minute' }), plain]
|
||||||
|
})).values()]
|
||||||
|
.sort(Temporal.PlainTime.compare)
|
||||||
|
|
||||||
|
// Partition by distance
|
||||||
|
const partitionedDates = splitArrayBy(sortedDates, (a, b) => !a.add({ minutes: 15 }).equals(b))
|
||||||
|
|
||||||
|
// Add end cap time and join
|
||||||
|
return partitionedDates.reduce((rows, partition, i) => [
|
||||||
|
...rows,
|
||||||
|
...partition,
|
||||||
|
partition[partition.length - 1].add({ minutes: 15 }),
|
||||||
|
...partitionedDates.length - 1 < i ? [null, null] : [], // Add spacer in between partitions
|
||||||
|
], [] as (Temporal.PlainTime | null)[])
|
||||||
|
}
|
||||||
54
frontend/src/utils/convertTimesToDates.ts
Normal file
54
frontend/src/utils/convertTimesToDates.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take times as strings and convert to Dayjs objects
|
||||||
|
* @param times An array of strings in `HHmm-d` or `HHmm-DDMMYYYY` format
|
||||||
|
* @param timezone The target timezone
|
||||||
|
*/
|
||||||
|
export const convertTimesToDates = (times: string[], timezone: string): Temporal.ZonedDateTime[] => {
|
||||||
|
const isSpecificDates = times[0].length === 13
|
||||||
|
|
||||||
|
return times.map(time => isSpecificDates ?
|
||||||
|
parseSpecificDate(time).withTimeZone(timezone)
|
||||||
|
: parseWeekdayDate(time).withTimeZone(timezone)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse from UTC `HHmm-DDMMYYYY` format into a ZonedDateTime in UTC
|
||||||
|
const parseSpecificDate = (str: string): Temporal.ZonedDateTime => {
|
||||||
|
if (str.length !== 13) {
|
||||||
|
throw new Error('String must be in HHmm-DDMMYYYY format')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract values
|
||||||
|
const [hour, minute] = [Number(str.substring(0, 2)), Number(str.substring(2, 4))]
|
||||||
|
const [day, month, year] = [Number(str.substring(5, 7)), Number(str.substring(7, 9)), Number(str.substring(9))]
|
||||||
|
|
||||||
|
// Construct PlainDateTime
|
||||||
|
return Temporal.ZonedDateTime.from({
|
||||||
|
hour, minute, day, month, year,
|
||||||
|
timeZone: 'UTC',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse from UTC `HHmm-d` format into a ZonedDateTime in UTC based on the current date
|
||||||
|
const parseWeekdayDate = (str: string): Temporal.ZonedDateTime => {
|
||||||
|
if (str.length !== 6) {
|
||||||
|
throw new Error('String must be in HHmm-d format')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract values
|
||||||
|
const [hour, minute] = [Number(str.substring(0, 2)), Number(str.substring(2, 4))]
|
||||||
|
let dayOfWeek = Number(str.substring(5))
|
||||||
|
if (dayOfWeek === 0) {
|
||||||
|
dayOfWeek = 7 // Sunday is 7 in ISO8601
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct PlainDateTime from today
|
||||||
|
const today = Temporal.Now.zonedDateTimeISO('UTC').round('day')
|
||||||
|
const currentDayOfWeek = today.dayOfWeek
|
||||||
|
return today.with({
|
||||||
|
hour, minute,
|
||||||
|
day: today.day + (dayOfWeek - currentDayOfWeek), // Set day of week
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
export const detectBrowser = () => {
|
export const detectBrowser = () => {
|
||||||
// Opera 8.0+
|
// Opera 8.0+
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
export * from './detectBrowser'
|
export * from './detectBrowser'
|
||||||
export * from './makeClass'
|
export * from './makeClass'
|
||||||
export * from './unhyphenate'
|
export * from './unhyphenate'
|
||||||
|
export * from './convertTimesToDates'
|
||||||
|
export * from './calculateAvailability'
|
||||||
|
export * from './calculateRows'
|
||||||
|
export * from './calculateColumns'
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
|
"target": "ES2022",
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
|
|
|
||||||
|
|
@ -65,10 +65,10 @@
|
||||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.40.0.tgz#3ba73359e11f5a7bd3e407f70b3528abfae69cec"
|
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.40.0.tgz#3ba73359e11f5a7bd3e407f70b3528abfae69cec"
|
||||||
integrity sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==
|
integrity sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==
|
||||||
|
|
||||||
"@giraugh/tools@^1.5.0":
|
"@giraugh/tools@^1.6.0":
|
||||||
version "1.5.0"
|
version "1.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/@giraugh/tools/-/tools-1.5.0.tgz#5c5cc8b248d1e04aebc46dcbeb7620c99f47d2ec"
|
resolved "https://registry.yarnpkg.com/@giraugh/tools/-/tools-1.6.0.tgz#6bf501161a3a848c00d3f6f960d9cfe386860941"
|
||||||
integrity sha512-DZTrxKU5Ul5+UnDUNja0Cp1HcMLPGF2fh7j4ICEzaNwvRMwcHRH241L/pfneuibZ22w1Ka4U7LVzOWL+SjLIjw==
|
integrity sha512-KaC5YrA2UIA6Ol4fQtGknQX+gkQOsRhEhK4h+2GrfOj4+jfO1lz8zf8wHrSk4sorOv+a47sFANJGnicSVXq7SQ==
|
||||||
|
|
||||||
"@humanwhocodes/config-array@^0.11.8":
|
"@humanwhocodes/config-array@^0.11.8":
|
||||||
version "0.11.8"
|
version "0.11.8"
|
||||||
|
|
@ -89,6 +89,14 @@
|
||||||
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
|
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
|
||||||
integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
|
integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
|
||||||
|
|
||||||
|
"@js-temporal/polyfill@^0.4.4":
|
||||||
|
version "0.4.4"
|
||||||
|
resolved "https://registry.yarnpkg.com/@js-temporal/polyfill/-/polyfill-0.4.4.tgz#4c26b4a1a68c19155808363f520204712cfc2558"
|
||||||
|
integrity sha512-2X6bvghJ/JAoZO52lbgyAPFj8uCflhTo2g7nkFzEQdXd/D8rEeD4HtmTEpmtGCva260fcd66YNXBOYdnmHqSOg==
|
||||||
|
dependencies:
|
||||||
|
jsbi "^4.3.0"
|
||||||
|
tslib "^2.4.1"
|
||||||
|
|
||||||
"@microsoft/microsoft-graph-client@^3.0.5":
|
"@microsoft/microsoft-graph-client@^3.0.5":
|
||||||
version "3.0.5"
|
version "3.0.5"
|
||||||
resolved "https://registry.yarnpkg.com/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.5.tgz#c661b5bb271fd59d34391b0657b8a78c02af9ab8"
|
resolved "https://registry.yarnpkg.com/@microsoft/microsoft-graph-client/-/microsoft-graph-client-3.0.5.tgz#c661b5bb271fd59d34391b0657b8a78c02af9ab8"
|
||||||
|
|
@ -1717,6 +1725,11 @@ js-yaml@^4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse "^2.0.1"
|
argparse "^2.0.1"
|
||||||
|
|
||||||
|
jsbi@^4.3.0:
|
||||||
|
version "4.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jsbi/-/jsbi-4.3.0.tgz#b54ee074fb6fcbc00619559305c8f7e912b04741"
|
||||||
|
integrity sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==
|
||||||
|
|
||||||
json-schema-traverse@^0.4.1:
|
json-schema-traverse@^0.4.1:
|
||||||
version "0.4.1"
|
version "0.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
|
||||||
|
|
@ -2637,6 +2650,11 @@ tslib@^2.3.0, tslib@^2.4.0, tslib@^2.5.0:
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
|
||||||
integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==
|
integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==
|
||||||
|
|
||||||
|
tslib@^2.4.1:
|
||||||
|
version "2.5.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338"
|
||||||
|
integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA==
|
||||||
|
|
||||||
tsutils@^3.21.0:
|
tsutils@^3.21.0:
|
||||||
version "3.21.0"
|
version "3.21.0"
|
||||||
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
|
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue