From b2bd9125e74d427216efcf08197e3edf796a02db Mon Sep 17 00:00:00 2001 From: Benji Grant Date: Tue, 13 Jun 2023 12:19:06 +1000 Subject: [PATCH 1/2] Use a web worker to calculate the table --- frontend/src/app/[id]/EventAvailabilities.tsx | 31 ++++++++++++++-- frontend/src/app/how-to/page.tsx | 14 +++++--- .../AvailabilityEditor/AvailabilityEditor.tsx | 36 +++++++------------ .../AvailabilityViewer/AvailabilityViewer.tsx | 27 ++++++-------- .../components/Skeleton/Skeleton.module.scss | 34 ++++++++++++++++++ .../components/Skeleton/Skeleton.tsx | 15 ++++++++ .../TimeRangeField/TimeRangeField.tsx | 2 +- frontend/src/utils/calculateTable.ts | 26 +++++++++----- frontend/src/workers/calculateTable.ts | 5 +++ 9 files changed, 133 insertions(+), 57 deletions(-) create mode 100644 frontend/src/components/AvailabilityViewer/components/Skeleton/Skeleton.module.scss create mode 100644 frontend/src/components/AvailabilityViewer/components/Skeleton/Skeleton.tsx create mode 100644 frontend/src/workers/calculateTable.ts diff --git a/frontend/src/app/[id]/EventAvailabilities.tsx b/frontend/src/app/[id]/EventAvailabilities.tsx index 8450029..992f48e 100644 --- a/frontend/src/app/[id]/EventAvailabilities.tsx +++ b/frontend/src/app/[id]/EventAvailabilities.tsx @@ -12,8 +12,10 @@ import SelectField from '/src/components/SelectField/SelectField' import { EventResponse, getPeople, PersonResponse, updatePerson } from '/src/config/api' import { useTranslation } from '/src/i18n/client' import timezones from '/src/res/timezones.json' +import { useStore } from '/src/stores' import useRecentsStore from '/src/stores/recentsStore' -import { expandTimes, makeClass } from '/src/utils' +import useSettingsStore from '/src/stores/settingsStore' +import { calculateTable, expandTimes, makeClass } from '/src/utils' import styles from './page.module.scss' @@ -25,6 +27,8 @@ interface EventAvailabilitiesProps { const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => { const { t, i18n } = useTranslation('event') + const timeFormat = useStore(useSettingsStore, state => state.timeFormat) ?? '12h' + const [people, setPeople] = useState(data.people) const expandedTimes = useMemo(() => expandTimes(event.times), [event.times]) @@ -34,6 +38,28 @@ const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => { const [tab, setTab] = useState<'group' | 'you'>('group') const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone) + // Web worker for calculating the heatmap table + const tableWorker = useMemo(() => (typeof window !== undefined && window.Worker) ? new Worker(new URL('/src/workers/calculateTable', import.meta.url)) : undefined, []) + + // Calculate table (using a web worker if available) + const [table, setTable] = useState>() + + useEffect(() => { + const args = { times: expandedTimes, locale: i18n.language, timeFormat, timezone } + if (tableWorker) { + tableWorker.postMessage(args) + setTable(undefined) + } else { + setTable(calculateTable(args)) + } + }, [tableWorker, expandedTimes, i18n.language, timeFormat, timezone]) + + useEffect(() => { + if (tableWorker) { + tableWorker.onmessage = (e: MessageEvent>) => setTable(e.data) + } + }, [tableWorker]) + // Add this event to recents const addRecent = useRecentsStore(state => state.addRecent) useEffect(() => { @@ -138,7 +164,7 @@ const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => { {tab === 'group' ? : user && { setUser({ ...user, availability: oldAvailability }) }) }} + table={table} />} } diff --git a/frontend/src/app/how-to/page.tsx b/frontend/src/app/how-to/page.tsx index 0140b25..92e4cb8 100644 --- a/frontend/src/app/how-to/page.tsx +++ b/frontend/src/app/how-to/page.tsx @@ -13,7 +13,7 @@ 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 { getWeekdayNames } from '/src/utils' +import { calculateTable, getWeekdayNames } from '/src/utils' import styles from './page.module.scss' @@ -25,9 +25,13 @@ export const generateMetadata = async (): Promise => { } } +const 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'] + const Page = async () => { const { t, i18n } = await useTranslation(['common', 'help']) + const table = calculateTable({ times, locale: i18n.language, timezone: 'UTC', timeFormat: '12h' }) + return <>
@@ -53,9 +57,9 @@ const Page = async () => {

{t('help:p6')}

{t('help:p7')}

{t('help:s3')}

@@ -63,7 +67,7 @@ const Page = async () => {

{t('help:p9')}

{t('help:p10')}

{ { name: 'Mark', created_at: 1618232400, availability: ['1200-12042021', '1200-13042021', '1200-14042021', '1200-16042021', '1215-12042021', '1215-13042021', '1215-14042021', '1215-16042021', '1230-12042021', '1230-13042021', '1230-14042021', '1230-16042021', '1245-12042021', '1245-13042021', '1245-14042021', '1245-16042021', '1300-12042021', '1300-13042021', '1300-14042021', '1300-16042021', '1315-12042021', '1315-13042021', '1315-14042021', '1315-16042021', '1330-12042021', '1330-13042021', '1330-14042021', '1330-16042021', '1345-12042021', '1345-13042021', '1345-14042021', '1345-16042021', '1400-12042021', '1400-13042021', '1400-14042021', '1400-16042021', '1415-12042021', '1415-13042021', '1415-14042021', '1415-16042021', '1430-12042021', '1430-13042021', '1430-14042021', '1430-16042021', '1445-12042021', '1445-13042021', '1445-14042021', '1445-16042021', '1500-12042021', '1500-13042021', '1500-14042021', '1500-16042021', '1515-12042021', '1515-13042021', '1515-14042021', '1515-16042021', '1530-12042021', '1530-13042021', '1530-14042021', '1530-16042021', '1545-12042021', '1545-13042021', '1545-14042021', '1545-16042021'] }, { name: 'Alex', created_at: 1618232400, availability: ['1200-13042021', '1200-14042021', '1215-13042021', '1215-14042021', '1230-13042021', '1230-14042021', '1245-13042021', '1245-14042021', '1300-13042021', '1300-14042021', '1315-13042021', '1315-14042021', '1330-13042021', '1330-14042021', '1345-13042021', '1345-14042021', '1400-13042021', '1400-14042021', '1415-13042021', '1415-14042021', '1430-13042021', '1430-14042021', '1445-13042021', '1445-14042021', '1500-13042021', '1500-14042021', '1515-13042021', '1515-14042021', '1530-13042021', '1530-14042021', '1545-13042021', '1545-14042021', '1200-12042021', '1215-12042021', '1545-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', '1100-15042021', '1100-16042021', '1115-15042021', '1115-16042021', '1130-15042021', '1130-16042021', '1145-15042021', '1145-16042021', '1200-15042021', '1200-16042021', '1215-15042021', '1215-16042021', '1230-15042021', '1230-16042021', '1245-15042021', '1245-16042021', '1300-15042021', '1300-16042021', '1315-15042021', '1315-16042021', '1330-15042021', '1330-16042021', '1345-15042021', '1345-16042021', '1400-15042021', '1400-16042021', '1415-15042021', '1415-16042021', '1430-15042021', '1430-16042021', '1445-15042021', '1445-16042021', '1500-15042021', '1500-16042021', '1515-15042021', '1515-16042021', '1530-15042021', '1530-16042021', '1545-15042021', '1545-16042021', '1600-15042021', '1600-16042021', '1615-15042021', '1615-16042021', '1630-15042021', '1630-16042021', '1645-15042021', '1645-16042021'] }, ]} - timezone="UTC" + table={table} /> diff --git a/frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx b/frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx index e559761..d46cb6e 100644 --- a/frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx +++ b/frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx @@ -1,36 +1,24 @@ -import { Fragment, useCallback, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useRef, useState } from 'react' import Content from '/src/components/Content/Content' import GoogleCalendar from '/src/components/GoogleCalendar/GoogleCalendar' import { usePalette } from '/src/hooks/usePalette' import { useTranslation } from '/src/i18n/client' -import { useStore } from '/src/stores' -import useSettingsStore from '/src/stores/settingsStore' import { calculateTable, makeClass, parseSpecificDate } from '/src/utils' import styles from '../AvailabilityViewer/AvailabilityViewer.module.scss' +import Skeleton from '../AvailabilityViewer/components/Skeleton/Skeleton' interface AvailabilityEditorProps { times: string[] timezone: string value: string[] onChange: (value: string[]) => void + table?: ReturnType } -const AvailabilityEditor = ({ - times, - timezone, - value = [], - onChange, -}: AvailabilityEditorProps) => { - const { t, i18n } = useTranslation('event') - - const timeFormat = useStore(useSettingsStore, state => state.timeFormat) ?? '12h' - - // Calculate table - const { rows, columns } = useMemo(() => - calculateTable(times, i18n.language, timeFormat, timezone), - [times, i18n.language, timeFormat, timezone]) +const AvailabilityEditor = ({ times, timezone, value = [], onChange, table }: AvailabilityEditorProps) => { + const { t } = useTranslation('event') // Ref and state required to rerender but also access static version in callbacks const selectingRef = useRef([]) @@ -64,24 +52,24 @@ const AvailabilityEditor = ({
- {rows.map((row, i) => + {table?.rows.map((row, i) =>
{row && }
- )} + ) ?? null}
- {columns.map((column, x) => + {table?.columns.map((column, x) => {column ?
{column.header.dateLabel && }
{column.cells.map((cell, y) => { if (y === column.cells.length - 1) return null @@ -132,7 +120,7 @@ const AvailabilityEditor = ({ } } setSelecting(found.flatMap(d => { - const serialized = columns[d.x]?.cells[d.y]?.serialized + const serialized = table.columns[d.x]?.cells[d.y]?.serialized if (serialized && times.includes(serialized)) { return [serialized] } @@ -144,7 +132,7 @@ const AvailabilityEditor = ({ })}
:
} - )} + ) ?? }
diff --git a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx index 7c06cec..bba52c9 100644 --- a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx +++ b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.tsx @@ -13,17 +13,17 @@ import useSettingsStore from '/src/stores/settingsStore' import { calculateAvailability, calculateTable, makeClass, relativeTimeFormat } from '/src/utils' import styles from './AvailabilityViewer.module.scss' +import Skeleton from './components/Skeleton/Skeleton' interface AvailabilityViewerProps { times: string[] - timezone: string people: PersonResponse[] + table?: ReturnType } -const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps) => { +const AvailabilityViewer = ({ times, people, table }: AvailabilityViewerProps) => { const { t, i18n } = useTranslation('event') - const timeFormat = useStore(useSettingsStore, state => state.timeFormat) ?? '12h' const highlight = useStore(useSettingsStore, state => state.highlight) const [filteredPeople, setFilteredPeople] = useState(people.map(p => p.name)) const [tempFocus, setTempFocus] = useState() @@ -38,11 +38,6 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps people: string[] }>() - // 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(times, people.filter(p => filteredPeople.includes(p.name))), @@ -56,15 +51,15 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps setFilteredPeople(people.map(p => p.name)) }, [people.length]) - const heatmap = useMemo(() => columns.map((column, x) => + const heatmap = useMemo(() => table?.columns.map((column, x) => {column ?
{column.header.dateLabel && }
{column.cells.map((cell, y) => { if (y === column.cells.length - 1) return null @@ -110,9 +105,9 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps })}
:
} - ), [ + ) ?? , [ availabilities, - columns, + table?.columns, highlight, max, min, @@ -167,14 +162,14 @@ const AvailabilityViewer = ({ times, timezone, people }: AvailabilityViewerProps
{useMemo(() =>
- {rows.map((row, i) => + {table?.rows.map((row, i) =>
{row && }
- )} -
, [rows])} + ) ?? null} +
, [table?.rows])} {heatmap}
diff --git a/frontend/src/components/AvailabilityViewer/components/Skeleton/Skeleton.module.scss b/frontend/src/components/AvailabilityViewer/components/Skeleton/Skeleton.module.scss new file mode 100644 index 0000000..f3863e5 --- /dev/null +++ b/frontend/src/components/AvailabilityViewer/components/Skeleton/Skeleton.module.scss @@ -0,0 +1,34 @@ +.skeleton { + opacity: .5; + + & > div:last-of-type { + height: 382px; + width: 300px; + border: 2px solid currentColor; + border-radius: 3px; + margin-block: 2px 10px; + position: relative; + } +} + +.dayLabels { + display: flex; + justify-content: space-around; + + span { + height: .9em; + display: block; + width: 3ch; + background: currentColor; + border-radius: .2em; + } +} + +.dateLabels { + font-size: 12px; + margin-block-end: 3px; + + span { + width: 5ch; + } +} diff --git a/frontend/src/components/AvailabilityViewer/components/Skeleton/Skeleton.tsx b/frontend/src/components/AvailabilityViewer/components/Skeleton/Skeleton.tsx new file mode 100644 index 0000000..e18c5e5 --- /dev/null +++ b/frontend/src/components/AvailabilityViewer/components/Skeleton/Skeleton.tsx @@ -0,0 +1,15 @@ +import { makeClass } from '/src/utils' + +import styles from './Skeleton.module.scss' + +interface SkeletonProps { + isSpecificDates?: boolean +} + +const Skeleton = ({ isSpecificDates }: SkeletonProps) =>
+ {isSpecificDates ?
{Array.from({ length: 5 }).map((_, i) => )}
: null} +
{Array.from({ length: 5 }).map((_, i) => )}
+
+
+ +export default Skeleton diff --git a/frontend/src/components/TimeRangeField/TimeRangeField.tsx b/frontend/src/components/TimeRangeField/TimeRangeField.tsx index e82b22e..faac415 100644 --- a/frontend/src/components/TimeRangeField/TimeRangeField.tsx +++ b/frontend/src/components/TimeRangeField/TimeRangeField.tsx @@ -108,7 +108,7 @@ const Handle = ({ value, onChange, labelPadding }: HandleProps) => { left: `calc(${value * 4.166}% - 11px)`, '--extra-padding': labelPadding, } as React.CSSProperties} - data-label={Temporal.PlainTime.from({ hour: Number(times[value] === '24' ? '00' : times[value]) }).toLocaleString(i18n.language, { hour: 'numeric', hour12: timeFormat === '12h' })} + data-label={Temporal.PlainTime.from({ hour: Number(times[value] === '24' ? '00' : times[value]) }).toLocaleString(i18n.language, { hour: 'numeric', hourCycle: timeFormat === '12h' ? 'h12' : 'h24' })} onMouseDown={() => { document.addEventListener('mousemove', handleMouseMove) isMoving.current = true diff --git a/frontend/src/utils/calculateTable.ts b/frontend/src/utils/calculateTable.ts index e710330..b2358c4 100644 --- a/frontend/src/utils/calculateTable.ts +++ b/frontend/src/utils/calculateTable.ts @@ -3,16 +3,24 @@ import { calculateRows } from '/src/utils/calculateRows' import { convertTimesToDates } from '/src/utils/convertTimesToDates' import { serializeTime } from '/src/utils/serializeTime' +export interface CalculateTableArgs { + /** As `HHmm-DDMMYYYY` or `HHmm-d` strings */ + times: string[] + locale: string + timeFormat: '12h' | '24h' + timezone: string +} + /** * Take rows and columns and turn them into a data structure representing an availability table */ -export const calculateTable = ( +export const calculateTable = ({ /** As `HHmm-DDMMYYYY` or `HHmm-d` strings */ - times: string[], - locale: string, - timeFormat: '12h' | '24h', - timezone: string, -) => { + times, + locale, + timeFormat, + timezone, +}: CalculateTableArgs) => { const dates = convertTimesToDates(times, timezone) const rows = calculateRows(dates) const columns = calculateColumns(dates) @@ -22,7 +30,7 @@ export const calculateTable = ( return { rows: rows.map(row => row && row.minute === 0 ? { - label: row.toLocaleString(locale, { hour: 'numeric', hour12: timeFormat === '12h' }), + label: row.toLocaleString(locale, { hour: 'numeric', hourCycle: timeFormat === '12h' ? 'h12' : 'h24' }), string: row.toString(), } : null), @@ -44,8 +52,8 @@ export const calculateTable = ( 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' })}`, + ? date.toLocaleString(locale, { dateStyle: 'long', timeStyle: 'short', hourCycle: timeFormat === '12h' ? 'h12' : 'h24' }) + : `${date.toLocaleString(locale, { timeStyle: 'short', hourCycle: timeFormat === '12h' ? 'h12' : 'h24' })}, ${date.toLocaleString(locale, { weekday: 'long' })}`, } }) } : null) diff --git a/frontend/src/workers/calculateTable.ts b/frontend/src/workers/calculateTable.ts new file mode 100644 index 0000000..8dc3239 --- /dev/null +++ b/frontend/src/workers/calculateTable.ts @@ -0,0 +1,5 @@ +import { calculateTable, CalculateTableArgs } from '/src/utils' + +self.onmessage = (e: MessageEvent) => { + self.postMessage(calculateTable(e.data)) +} From 58e1ec47e4b0b72417b7615185e3868fe2587aa7 Mon Sep 17 00:00:00 2001 From: Benji Grant Date: Tue, 13 Jun 2023 12:25:01 +1000 Subject: [PATCH 2/2] Fix weekday date parsing --- frontend/src/utils/convertTimesToDates.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/utils/convertTimesToDates.ts b/frontend/src/utils/convertTimesToDates.ts index 2065ef6..a0064ff 100644 --- a/frontend/src/utils/convertTimesToDates.ts +++ b/frontend/src/utils/convertTimesToDates.ts @@ -39,10 +39,7 @@ const parseWeekdayDate = (str: string): Temporal.ZonedDateTime => { // 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 - } + const dayOfWeek = Number(str.substring(5)) // Construct PlainDateTime from today const today = Temporal.Now.zonedDateTimeISO('UTC').round('day')