Speed up rendering of table by reducing temporal calls

This commit is contained in:
Benji Grant 2023-06-09 01:41:33 +10:00
parent 085dc389ca
commit f72204c796
7 changed files with 157 additions and 134 deletions

View 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()
}

View file

@ -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,82 +38,56 @@ 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[]>([])
useEffect(() => {
setPalette(createPalette({
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 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}> {column ? <div className={styles.dateColumn}>
{isSpecificDates && <label className={styles.dateLabel}>{column.toLocaleString(i18n.language, { month: 'short', day: 'numeric' })}</label>} {isSpecificDates && <label className={styles.dateLabel}>{column.header.dateLabel}</label>}
<label className={styles.dayLabel}>{column.toLocaleString(i18n.language, { weekday: 'short' })}</label> <label className={styles.dayLabel}>{column.header.weekdayLabel}</label>
<div <div
className={styles.times} className={styles.times}
data-border-left={i === 0 || columns.at(i - 1) === null} data-border-left={x === 0 || columns.at(x - 1) === null}
data-border-right={i === columns.length - 1 || columns.at(i + 1) === null} data-border-right={x === columns.length - 1 || columns.at(x + 1) === null}
> >
{rows.map((row, i) => { {column.cells.map((cell, y) => {
if (i === rows.length - 1) return null if (y === column.cells.length - 1) return null
if (!row || rows.at(i + 1) === null || dates.every(d => !d.equals(column.toZonedDateTime({ timeZone: timezone, plainTime: row })))) { if (!cell) return <div
return <div
className={makeClass(styles.timeSpace, styles.grey)} className={makeClass(styles.timeSpace, styles.grey)}
key={i} key={y}
title={t<string>('greyed_times')} title={t<string>('greyed_times')}
/> />
}
const date = column.toZonedDateTime({ timeZone: timezone, plainTime: row }) let peopleHere = availabilities.find(a => a.date === cell.serialized)?.people ?? []
let peopleHere = availabilities.find(a => a.date.equals(date))?.people ?? []
if (tempFocus) { if (tempFocus) {
peopleHere = peopleHere.filter(p => p === tempFocus) peopleHere = peopleHere.filter(p => p === tempFocus)
} }
return <div return <div
key={i} key={y}
className={makeClass( className={makeClass(
styles.time, styles.time,
(focusCount === undefined || focusCount === peopleHere.length) && highlight && (peopleHere.length === max || tempFocus) && peopleHere.length > 0 && styles.highlight, (focusCount === undefined || focusCount === peopleHere.length) && highlight && (peopleHere.length === max || tempFocus) && peopleHere.length > 0 && styles.highlight,
)} )}
style={{ style={{
backgroundColor: (focusCount === undefined || focusCount === peopleHere.length) ? palette[tempFocus && peopleHere.length ? max : peopleHere.length] : 'transparent', backgroundColor: (focusCount === undefined || focusCount === peopleHere.length) ? palette[tempFocus && peopleHere.length ? max : peopleHere.length] : 'transparent',
...date.minute !== 0 && date.minute !== 30 && { borderTopColor: 'transparent' }, ...cell.minute !== 0 && cell.minute !== 30 && { borderTopColor: 'transparent' },
...date.minute === 30 && { borderTopStyle: 'dotted' }, ...cell.minute === 30 && { borderTopStyle: 'dotted' },
}} }}
aria-label={peopleHere.join(', ')} aria-label={peopleHere.join(', ')}
onMouseEnter={e => { onMouseEnter={e => {
@ -124,9 +97,7 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps
x: Math.round(cellBox.x - wrapperBox.x + cellBox.width / 2), x: Math.round(cellBox.x - wrapperBox.x + cellBox.width / 2),
y: Math.round(cellBox.y - wrapperBox.y + cellBox.height) + 6, y: Math.round(cellBox.y - wrapperBox.y + cellBox.height) + 6,
available: `${peopleHere.length} / ${filteredPeople.length} ${t('available')}`, available: `${peopleHere.length} / ${filteredPeople.length} ${t('available')}`,
date: isSpecificDates date: cell.label,
? 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, people: peopleHere,
}) })
}} }}
@ -135,24 +106,17 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps
})} })}
</div> </div>
</div> : <div className={styles.columnSpacer} />} </div> : <div className={styles.columnSpacer} />}
</Fragment>)} </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>
<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} {heatmap}
</div>
{tooltip && <div {tooltip && <div
className={styles.tooltip} className={styles.tooltip}

View file

@ -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,

View file

@ -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']

View file

@ -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
} }

View 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)
}
}

View file

@ -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'