Speed up rendering of table by reducing temporal calls
This commit is contained in:
parent
085dc389ca
commit
f72204c796
13
frontend/hooks/usePalette.ts
Normal file
13
frontend/hooks/usePalette.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { createPalette } from 'hue-map'
|
||||||
|
|
||||||
|
import { useStore } from '/src/stores'
|
||||||
|
import useSettingsStore from '/src/stores/settingsStore'
|
||||||
|
|
||||||
|
export const usePalette = (min: number, max: number) => {
|
||||||
|
const colormap = useStore(useSettingsStore, state => state.colormap)
|
||||||
|
|
||||||
|
return createPalette({
|
||||||
|
map: (colormap === undefined || colormap === 'crabfit') ? [[0, [247, 158, 0, 0]], [1, [247, 158, 0, 255]]] : colormap,
|
||||||
|
steps: Math.max((max - min) + 1, 2),
|
||||||
|
}).format()
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
import { Fragment, useMemo, useRef, useState } from 'react'
|
||||||
import { Temporal } from '@js-temporal/polyfill'
|
import { Temporal } from '@js-temporal/polyfill'
|
||||||
import { createPalette } from 'hue-map'
|
|
||||||
|
|
||||||
import Content from '/src/components/Content/Content'
|
import Content from '/src/components/Content/Content'
|
||||||
import Legend from '/src/components/Legend/Legend'
|
import Legend from '/src/components/Legend/Legend'
|
||||||
|
|
@ -10,9 +9,10 @@ import { PersonResponse } from '/src/config/api'
|
||||||
import { useTranslation } from '/src/i18n/client'
|
import { useTranslation } from '/src/i18n/client'
|
||||||
import { useStore } from '/src/stores'
|
import { useStore } from '/src/stores'
|
||||||
import useSettingsStore from '/src/stores/settingsStore'
|
import useSettingsStore from '/src/stores/settingsStore'
|
||||||
import { calculateAvailability, calculateColumns, calculateRows, convertTimesToDates, makeClass, relativeTimeFormat } from '/src/utils'
|
import { calculateAvailability, calculateTable, makeClass, relativeTimeFormat } from '/src/utils'
|
||||||
|
|
||||||
import styles from './AvailabilityViewer.module.scss'
|
import styles from './AvailabilityViewer.module.scss'
|
||||||
|
import { usePalette } from '/hooks/usePalette'
|
||||||
|
|
||||||
interface AvailabilityViewerProps {
|
interface AvailabilityViewerProps {
|
||||||
times: string[]
|
times: string[]
|
||||||
|
|
@ -23,9 +23,8 @@ interface AvailabilityViewerProps {
|
||||||
const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps) => {
|
const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps) => {
|
||||||
const { t, i18n } = useTranslation('event')
|
const { t, i18n } = useTranslation('event')
|
||||||
|
|
||||||
const timeFormat = useStore(useSettingsStore, state => state.timeFormat)
|
const timeFormat = useStore(useSettingsStore, state => state.timeFormat) ?? '12h'
|
||||||
const highlight = useStore(useSettingsStore, state => state.highlight)
|
const highlight = useStore(useSettingsStore, state => state.highlight)
|
||||||
const colormap = useStore(useSettingsStore, state => state.colormap)
|
|
||||||
const [filteredPeople, setFilteredPeople] = useState(people.map(p => p.name))
|
const [filteredPeople, setFilteredPeople] = useState(people.map(p => p.name))
|
||||||
const [tempFocus, setTempFocus] = useState<string>()
|
const [tempFocus, setTempFocus] = useState<string>()
|
||||||
const [focusCount, setFocusCount] = useState<number>()
|
const [focusCount, setFocusCount] = useState<number>()
|
||||||
|
|
@ -39,120 +38,85 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps
|
||||||
people: string[]
|
people: string[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Calculate rows and columns
|
// Calculate table
|
||||||
const [dates, rows, columns] = useMemo(() => {
|
const { rows, columns } = useMemo(() =>
|
||||||
const dates = convertTimesToDates(times, timezone)
|
calculateTable(times, i18n.language, timeFormat, timezone),
|
||||||
return [dates, calculateRows(dates), calculateColumns(dates)]
|
[times, i18n.language, timeFormat, timezone])
|
||||||
}, [times, timezone])
|
|
||||||
|
|
||||||
// Calculate availabilities
|
// Calculate availabilities
|
||||||
const { availabilities, min, max } = useMemo(() => calculateAvailability(dates, people
|
const { availabilities, min, max } = useMemo(() =>
|
||||||
.filter(p => filteredPeople.includes(p.name))
|
calculateAvailability(times, people.filter(p => filteredPeople.includes(p.name))),
|
||||||
.map(p => ({
|
[times, filteredPeople, people])
|
||||||
...p,
|
|
||||||
availability: convertTimesToDates(p.availability, timezone),
|
// Create the colour palette
|
||||||
}))
|
const palette = usePalette(min, max)
|
||||||
), [dates, filteredPeople, people, timezone])
|
|
||||||
|
|
||||||
// Is specific dates or just days of the week
|
// Is specific dates or just days of the week
|
||||||
const isSpecificDates = useMemo(() => times[0].length === 13, [times])
|
const isSpecificDates = useMemo(() => times[0].length === 13, [times])
|
||||||
|
|
||||||
// Create the colour palette
|
const heatmap = useMemo(() => columns.map((column, x) => <Fragment key={x}>
|
||||||
const [palette, setPalette] = useState<string[]>([])
|
{column ? <div className={styles.dateColumn}>
|
||||||
useEffect(() => {
|
{isSpecificDates && <label className={styles.dateLabel}>{column.header.dateLabel}</label>}
|
||||||
setPalette(createPalette({
|
<label className={styles.dayLabel}>{column.header.weekdayLabel}</label>
|
||||||
map: colormap !== 'crabfit' ? colormap : [[0, [247, 158, 0, 0]], [1, [247, 158, 0, 255]]],
|
|
||||||
steps: Math.max((max - min) + 1, 2),
|
|
||||||
}).format())
|
|
||||||
}, [min, max, colormap])
|
|
||||||
|
|
||||||
const heatmap = useMemo(() => (
|
<div
|
||||||
<div className={styles.heatmap}>
|
className={styles.times}
|
||||||
<div className={styles.timeLabels}>
|
data-border-left={x === 0 || columns.at(x - 1) === null}
|
||||||
{rows.map((row, i) =>
|
data-border-right={x === columns.length - 1 || columns.at(x + 1) === null}
|
||||||
<div className={styles.timeSpace} key={i}>
|
>
|
||||||
{row && row.minute === 0 && <label className={styles.timeLabel}>
|
{column.cells.map((cell, y) => {
|
||||||
{row.toLocaleString(i18n.language, { hour: 'numeric', hour12: timeFormat === '12h' })}
|
if (y === column.cells.length - 1) return null
|
||||||
</label>}
|
|
||||||
</div>
|
if (!cell) return <div
|
||||||
)}
|
className={makeClass(styles.timeSpace, styles.grey)}
|
||||||
|
key={y}
|
||||||
|
title={t<string>('greyed_times')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
let peopleHere = availabilities.find(a => a.date === cell.serialized)?.people ?? []
|
||||||
|
if (tempFocus) {
|
||||||
|
peopleHere = peopleHere.filter(p => p === tempFocus)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div
|
||||||
|
key={y}
|
||||||
|
className={makeClass(
|
||||||
|
styles.time,
|
||||||
|
(focusCount === undefined || focusCount === peopleHere.length) && highlight && (peopleHere.length === max || tempFocus) && peopleHere.length > 0 && styles.highlight,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: (focusCount === undefined || focusCount === peopleHere.length) ? palette[tempFocus && peopleHere.length ? max : peopleHere.length] : 'transparent',
|
||||||
|
...cell.minute !== 0 && cell.minute !== 30 && { borderTopColor: 'transparent' },
|
||||||
|
...cell.minute === 30 && { borderTopStyle: 'dotted' },
|
||||||
|
}}
|
||||||
|
aria-label={peopleHere.join(', ')}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
const cellBox = e.currentTarget.getBoundingClientRect()
|
||||||
|
const wrapperBox = wrapperRef.current?.getBoundingClientRect() ?? { x: 0, y: 0 }
|
||||||
|
setTooltip({
|
||||||
|
x: Math.round(cellBox.x - wrapperBox.x + cellBox.width / 2),
|
||||||
|
y: Math.round(cellBox.y - wrapperBox.y + cellBox.height) + 6,
|
||||||
|
available: `${peopleHere.length} / ${filteredPeople.length} ${t('available')}`,
|
||||||
|
date: cell.label,
|
||||||
|
people: peopleHere,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setTooltip(undefined)}
|
||||||
|
/>
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div> : <div className={styles.columnSpacer} />}
|
||||||
{columns.map((column, i) => <Fragment key={i}>
|
</Fragment>), [
|
||||||
{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 })
|
|
||||||
let peopleHere = availabilities.find(a => a.date.equals(date))?.people ?? []
|
|
||||||
if (tempFocus) {
|
|
||||||
peopleHere = peopleHere.filter(p => p === tempFocus)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div
|
|
||||||
key={i}
|
|
||||||
className={makeClass(
|
|
||||||
styles.time,
|
|
||||||
(focusCount === undefined || focusCount === peopleHere.length) && highlight && (peopleHere.length === max || tempFocus) && peopleHere.length > 0 && styles.highlight,
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
backgroundColor: (focusCount === undefined || focusCount === peopleHere.length) ? palette[tempFocus && peopleHere.length ? max : peopleHere.length] : 'transparent',
|
|
||||||
...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 = wrapperRef.current?.getBoundingClientRect() ?? { x: 0, y: 0 }
|
|
||||||
setTooltip({
|
|
||||||
x: Math.round(cellBox.x - wrapperBox.x + cellBox.width / 2),
|
|
||||||
y: Math.round(cellBox.y - wrapperBox.y + cellBox.height) + 6,
|
|
||||||
available: `${peopleHere.length} / ${filteredPeople.length} ${t('available')}`,
|
|
||||||
date: isSpecificDates
|
|
||||||
? date.toLocaleString(i18n.language, { dateStyle: 'long', timeStyle: 'short', hour12: timeFormat === '12h' })
|
|
||||||
: `${date.toLocaleString(i18n.language, { timeStyle: 'short', hour12: timeFormat === '12h' })}, ${date.toLocaleString(i18n.language, { weekday: 'long' })}`,
|
|
||||||
people: peopleHere,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => setTooltip(undefined)}
|
|
||||||
/>
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div> : <div className={styles.columnSpacer} />}
|
|
||||||
</Fragment>)}
|
|
||||||
</div>
|
|
||||||
), [
|
|
||||||
availabilities,
|
availabilities,
|
||||||
dates,
|
|
||||||
isSpecificDates,
|
isSpecificDates,
|
||||||
rows,
|
|
||||||
columns,
|
columns,
|
||||||
highlight,
|
highlight,
|
||||||
max,
|
max,
|
||||||
t,
|
t,
|
||||||
timeFormat,
|
|
||||||
palette,
|
palette,
|
||||||
tempFocus,
|
tempFocus,
|
||||||
focusCount,
|
focusCount,
|
||||||
filteredPeople,
|
filteredPeople,
|
||||||
i18n.language,
|
|
||||||
timezone,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
|
|
@ -197,7 +161,19 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps
|
||||||
|
|
||||||
<div className={styles.wrapper} ref={wrapperRef}>
|
<div className={styles.wrapper} ref={wrapperRef}>
|
||||||
<div>
|
<div>
|
||||||
{heatmap}
|
<div className={styles.heatmap}>
|
||||||
|
{useMemo(() => <div className={styles.timeLabels}>
|
||||||
|
{rows.map((row, i) =>
|
||||||
|
<div className={styles.timeSpace} key={i}>
|
||||||
|
{row && <label className={styles.timeLabel}>
|
||||||
|
{row.label}
|
||||||
|
</label>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>, [rows])}
|
||||||
|
|
||||||
|
{heatmap}
|
||||||
|
</div>
|
||||||
|
|
||||||
{tooltip && <div
|
{tooltip && <div
|
||||||
className={styles.tooltip}
|
className={styles.tooltip}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,5 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { loadGapiInsideDOM } from 'gapi-script'
|
import { loadGapiInsideDOM } from 'gapi-script'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { Button, Center } from '/src/components'
|
|
||||||
import { Loader } from '../Loading/Loading.styles'
|
|
||||||
import {
|
|
||||||
CalendarList,
|
|
||||||
CheckboxInput,
|
|
||||||
CheckboxLabel,
|
|
||||||
CalendarLabel,
|
|
||||||
Info,
|
|
||||||
Options,
|
|
||||||
Title,
|
|
||||||
Icon,
|
|
||||||
LinkButton,
|
|
||||||
} from './GoogleCalendar.styles'
|
|
||||||
|
|
||||||
import googleLogo from '/src/res/google.svg'
|
import googleLogo from '/src/res/google.svg'
|
||||||
|
|
||||||
|
|
@ -52,9 +37,6 @@ const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
|
||||||
|
|
||||||
const importAvailability = () => {
|
const importAvailability = () => {
|
||||||
setFreeBusyLoading(true)
|
setFreeBusyLoading(true)
|
||||||
gtag('event', 'google_cal_sync', {
|
|
||||||
'event_category': 'event',
|
|
||||||
})
|
|
||||||
window.gapi.client.calendar.freebusy.query({
|
window.gapi.client.calendar.freebusy.query({
|
||||||
timeMin,
|
timeMin,
|
||||||
timeMax,
|
timeMax,
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { PublicClientApplication } from '@azure/msal-browser'
|
import { PublicClientApplication } from '@azure/msal-browser'
|
||||||
import { Client } from '@microsoft/microsoft-graph-client'
|
import { Client } from '@microsoft/microsoft-graph-client'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { Button, Center } from '/src/components'
|
import { Button, Center } from '/src/components'
|
||||||
import { Loader } from '../Loading/Loading.styles'
|
import outlookLogo from '/src/res/outlook.svg'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
CalendarLabel,
|
||||||
CalendarList,
|
CalendarList,
|
||||||
CheckboxInput,
|
CheckboxInput,
|
||||||
CheckboxLabel,
|
CheckboxLabel,
|
||||||
CalendarLabel,
|
Icon,
|
||||||
Info,
|
Info,
|
||||||
|
LinkButton,
|
||||||
Options,
|
Options,
|
||||||
Title,
|
Title,
|
||||||
Icon,
|
|
||||||
LinkButton,
|
|
||||||
} from '../GoogleCalendar/GoogleCalendar.styles'
|
} from '../GoogleCalendar/GoogleCalendar.styles'
|
||||||
|
import { Loader } from '../Loading/Loading.styles'
|
||||||
import outlookLogo from '/src/res/outlook.svg'
|
|
||||||
|
|
||||||
const scopes = ['Calendars.Read', 'Calendars.Read.Shared']
|
const scopes = ['Calendars.Read', 'Calendars.Read.Shared']
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import { Temporal } from '@js-temporal/polyfill'
|
|
||||||
|
|
||||||
interface Person {
|
interface Person {
|
||||||
name: string
|
name: string
|
||||||
availability: Temporal.ZonedDateTime[]
|
availability: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Availability {
|
interface Availability {
|
||||||
date: Temporal.ZonedDateTime
|
date: string
|
||||||
/** Names of everyone who is available at this date */
|
/** Names of everyone who is available at this date */
|
||||||
people: string[]
|
people: string[]
|
||||||
}
|
}
|
||||||
|
|
@ -24,12 +22,12 @@ interface AvailabilityInfo {
|
||||||
* where each person has a name and availability array, and returns the
|
* where each person has a name and availability array, and returns the
|
||||||
* group availability for each date passed in.
|
* group availability for each date passed in.
|
||||||
*/
|
*/
|
||||||
export const calculateAvailability = (dates: Temporal.ZonedDateTime[], people: Person[]): AvailabilityInfo => {
|
export const calculateAvailability = (dates: string[], people: Person[]): AvailabilityInfo => {
|
||||||
let min = Infinity
|
let min = Infinity
|
||||||
let max = -Infinity
|
let max = -Infinity
|
||||||
|
|
||||||
const availabilities: Availability[] = dates.map(date => {
|
const availabilities: Availability[] = dates.map(date => {
|
||||||
const names = people.flatMap(p => p.availability.some(d => d.equals(date)) ? [p.name] : [])
|
const names = people.flatMap(p => p.availability.some(d => d === date) ? [p.name] : [])
|
||||||
if (names.length < min) {
|
if (names.length < min) {
|
||||||
min = names.length
|
min = names.length
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
frontend/src/utils/calculateTable.ts
Normal file
53
frontend/src/utils/calculateTable.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { calculateColumns } from '/src/utils/calculateColumns'
|
||||||
|
import { calculateRows } from '/src/utils/calculateRows'
|
||||||
|
import { convertTimesToDates } from '/src/utils/convertTimesToDates'
|
||||||
|
import { serializeTime } from '/src/utils/serializeTime'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take rows and columns and turn them into a data structure representing an availability table
|
||||||
|
*/
|
||||||
|
export const calculateTable = (
|
||||||
|
/** As `HHmm-DDMMYYYY` or `HHmm-d` strings */
|
||||||
|
times: string[],
|
||||||
|
locale: string,
|
||||||
|
timeFormat: '12h' | '24h',
|
||||||
|
timezone: string,
|
||||||
|
) => {
|
||||||
|
const dates = convertTimesToDates(times, timezone)
|
||||||
|
const rows = calculateRows(dates)
|
||||||
|
const columns = calculateColumns(dates)
|
||||||
|
|
||||||
|
// Is specific dates or just days of the week
|
||||||
|
const isSpecificDates = times[0].length === 13
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows: rows.map(row => row && row.minute === 0 ? {
|
||||||
|
label: row.toLocaleString(locale, { hour: 'numeric', hour12: timeFormat === '12h' }),
|
||||||
|
string: row.toString(),
|
||||||
|
} : null),
|
||||||
|
|
||||||
|
columns: columns.map(column => column ? {
|
||||||
|
header: {
|
||||||
|
dateLabel: column.toLocaleString(locale, { month: 'short', day: 'numeric' }),
|
||||||
|
weekdayLabel: column.toLocaleString(locale, { weekday: 'short' }),
|
||||||
|
string: column.toString(),
|
||||||
|
},
|
||||||
|
cells: rows.map(row => {
|
||||||
|
if (!row) return null
|
||||||
|
const date = column.toZonedDateTime({ timeZone: timezone, plainTime: row })
|
||||||
|
const serialized = serializeTime(date, isSpecificDates)
|
||||||
|
|
||||||
|
// Cell not in dates
|
||||||
|
if (!times.includes(serialized)) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
serialized,
|
||||||
|
minute: date.minute,
|
||||||
|
label: isSpecificDates
|
||||||
|
? date.toLocaleString(locale, { dateStyle: 'long', timeStyle: 'short', hour12: timeFormat === '12h' })
|
||||||
|
: `${date.toLocaleString(locale, { timeStyle: 'short', hour12: timeFormat === '12h' })}, ${date.toLocaleString(locale, { weekday: 'long' })}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} : null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ export * from './convertTimesToDates'
|
||||||
export * from './calculateAvailability'
|
export * from './calculateAvailability'
|
||||||
export * from './calculateRows'
|
export * from './calculateRows'
|
||||||
export * from './calculateColumns'
|
export * from './calculateColumns'
|
||||||
|
export * from './calculateTable'
|
||||||
export * from './getWeekdayNames'
|
export * from './getWeekdayNames'
|
||||||
export * from './relativeTimeFormat'
|
export * from './relativeTimeFormat'
|
||||||
export * from './expandTimes'
|
export * from './expandTimes'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue