From f72204c796d01d92f3727267397036d6adb3308d Mon Sep 17 00:00:00 2001 From: Benji Grant Date: Fri, 9 Jun 2023 01:41:33 +1000 Subject: [PATCH] Speed up rendering of table by reducing temporal calls --- frontend/hooks/usePalette.ts | 13 ++ .../AvailabilityViewer/AvailabilityViewer.tsx | 180 ++++++++---------- .../GoogleCalendar/GoogleCalendar.jsx | 20 +- .../OutlookCalendar/OutlookCalendar.jsx | 14 +- frontend/src/utils/calculateAvailability.ts | 10 +- frontend/src/utils/calculateTable.ts | 53 ++++++ frontend/src/utils/index.ts | 1 + 7 files changed, 157 insertions(+), 134 deletions(-) create mode 100644 frontend/hooks/usePalette.ts create mode 100644 frontend/src/utils/calculateTable.ts diff --git a/frontend/hooks/usePalette.ts b/frontend/hooks/usePalette.ts new file mode 100644 index 0000000..93c7621 --- /dev/null +++ b/frontend/hooks/usePalette.ts @@ -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() +} diff --git a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx index ae31ff3..7a3698e 100644 --- a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx +++ b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx @@ -1,8 +1,7 @@ 'use client' -import { Fragment, useEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useMemo, useRef, 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' @@ -10,9 +9,10 @@ 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, relativeTimeFormat } from '/src/utils' +import { calculateAvailability, calculateTable, makeClass, relativeTimeFormat } from '/src/utils' import styles from './AvailabilityViewer.module.scss' +import { usePalette } from '/hooks/usePalette' interface AvailabilityViewerProps { times: string[] @@ -23,9 +23,8 @@ interface AvailabilityViewerProps { const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps) => { 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 colormap = useStore(useSettingsStore, state => state.colormap) const [filteredPeople, setFilteredPeople] = useState(people.map(p => p.name)) const [tempFocus, setTempFocus] = useState() const [focusCount, setFocusCount] = useState() @@ -39,120 +38,85 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps people: string[] }>() - // Calculate rows and columns - const [dates, rows, columns] = useMemo(() => { - const dates = convertTimesToDates(times, timezone) - return [dates, calculateRows(dates), calculateColumns(dates)] - }, [times, timezone]) + // Calculate table + const { rows, columns } = useMemo(() => + calculateTable(times, i18n.language, timeFormat, timezone), + [times, i18n.language, timeFormat, timezone]) // Calculate availabilities - const { availabilities, min, max } = useMemo(() => calculateAvailability(dates, people - .filter(p => filteredPeople.includes(p.name)) - .map(p => ({ - ...p, - availability: convertTimesToDates(p.availability, timezone), - })) - ), [dates, filteredPeople, people, timezone]) + const { availabilities, min, max } = useMemo(() => + calculateAvailability(times, people.filter(p => filteredPeople.includes(p.name))), + [times, filteredPeople, people]) + + // Create the colour palette + const palette = usePalette(min, max) // 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([]) - 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(() => columns.map((column, x) => + {column ?
+ {isSpecificDates && } + - const heatmap = useMemo(() => ( -
-
- {rows.map((row, i) => -
- {row && row.minute === 0 && } -
- )} +
+ {column.cells.map((cell, y) => { + if (y === column.cells.length - 1) return null + + if (!cell) return
('greyed_times')} + /> + + let peopleHere = availabilities.find(a => a.date === cell.serialized)?.people ?? [] + if (tempFocus) { + peopleHere = peopleHere.filter(p => p === tempFocus) + } + + return
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)} + /> + })}
- - {columns.map((column, i) => - {column ?
- {isSpecificDates && } - - -
- {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
('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
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)} - /> - })} -
-
:
} - )} -
- ), [ +
:
} + ), [ availabilities, - dates, isSpecificDates, - rows, columns, highlight, max, t, - timeFormat, palette, tempFocus, focusCount, filteredPeople, - i18n.language, - timezone, ]) return <> @@ -197,7 +161,19 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps
- {heatmap} +
+ {useMemo(() =>
+ {rows.map((row, i) => +
+ {row && } +
+ )} +
, [rows])} + + {heatmap} +
{tooltip &&
{ const importAvailability = () => { setFreeBusyLoading(true) - gtag('event', 'google_cal_sync', { - 'event_category': 'event', - }) window.gapi.client.calendar.freebusy.query({ timeMin, timeMax, diff --git a/frontend/src/components/OutlookCalendar/OutlookCalendar.jsx b/frontend/src/components/OutlookCalendar/OutlookCalendar.jsx index dbe62f2..7eb26da 100644 --- a/frontend/src/components/OutlookCalendar/OutlookCalendar.jsx +++ b/frontend/src/components/OutlookCalendar/OutlookCalendar.jsx @@ -1,23 +1,23 @@ -import { useState, useEffect } from 'react' import { PublicClientApplication } from '@azure/msal-browser' import { Client } from '@microsoft/microsoft-graph-client' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { Button, Center } from '/src/components' -import { Loader } from '../Loading/Loading.styles' +import outlookLogo from '/src/res/outlook.svg' + import { + CalendarLabel, CalendarList, CheckboxInput, CheckboxLabel, - CalendarLabel, + Icon, Info, + LinkButton, Options, Title, - Icon, - LinkButton, } from '../GoogleCalendar/GoogleCalendar.styles' - -import outlookLogo from '/src/res/outlook.svg' +import { Loader } from '../Loading/Loading.styles' const scopes = ['Calendars.Read', 'Calendars.Read.Shared'] diff --git a/frontend/src/utils/calculateAvailability.ts b/frontend/src/utils/calculateAvailability.ts index e2daef5..93b4b01 100644 --- a/frontend/src/utils/calculateAvailability.ts +++ b/frontend/src/utils/calculateAvailability.ts @@ -1,12 +1,10 @@ -import { Temporal } from '@js-temporal/polyfill' - interface Person { name: string - availability: Temporal.ZonedDateTime[] + availability: string[] } interface Availability { - date: Temporal.ZonedDateTime + date: string /** Names of everyone who is available at this date */ people: string[] } @@ -24,12 +22,12 @@ interface AvailabilityInfo { * 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 => { +export const calculateAvailability = (dates: string[], 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] : []) + const names = people.flatMap(p => p.availability.some(d => d === date) ? [p.name] : []) if (names.length < min) { min = names.length } diff --git a/frontend/src/utils/calculateTable.ts b/frontend/src/utils/calculateTable.ts new file mode 100644 index 0000000..30ed47d --- /dev/null +++ b/frontend/src/utils/calculateTable.ts @@ -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) + } +} diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 3b8b2ef..a1dc11f 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -5,6 +5,7 @@ export * from './convertTimesToDates' export * from './calculateAvailability' export * from './calculateRows' export * from './calculateColumns' +export * from './calculateTable' export * from './getWeekdayNames' export * from './relativeTimeFormat' export * from './expandTimes'