From 5abba62c6634babc7a787200115b8d968b349fbb Mon Sep 17 00:00:00 2001 From: Ben Grant Date: Mon, 29 May 2023 01:06:57 +1000 Subject: [PATCH] Migrate AvailabilityEditor --- frontend/src/app/[id]/EventAvailabilities.tsx | 49 ++++- frontend/src/app/[id]/not-found.tsx | 18 +- frontend/src/app/[id]/page.tsx | 17 +- .../AvailabilityEditor/AvailabilityEditor.jsx | 186 ------------------ .../AvailabilityEditor.styles.js | 24 --- .../AvailabilityEditor/AvailabilityEditor.tsx | 181 +++++++++++++++++ .../AvailabilityViewer.module.scss | 1 + .../components/Content/Content.module.scss | 1 + frontend/src/components/Copyable/Copyable.tsx | 2 +- frontend/src/utils/index.ts | 1 + frontend/src/utils/serializeTime.ts | 16 ++ 11 files changed, 277 insertions(+), 219 deletions(-) delete mode 100644 frontend/src/components/AvailabilityEditor/AvailabilityEditor.jsx delete mode 100644 frontend/src/components/AvailabilityEditor/AvailabilityEditor.styles.js create mode 100644 frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx create mode 100644 frontend/src/utils/serializeTime.ts diff --git a/frontend/src/app/[id]/EventAvailabilities.tsx b/frontend/src/app/[id]/EventAvailabilities.tsx index 56f1021..8450029 100644 --- a/frontend/src/app/[id]/EventAvailabilities.tsx +++ b/frontend/src/app/[id]/EventAvailabilities.tsx @@ -1,23 +1,31 @@ 'use client' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Trans } from 'react-i18next/TransWithoutContext' +import AvailabilityEditor from '/src/components/AvailabilityEditor/AvailabilityEditor' import AvailabilityViewer from '/src/components/AvailabilityViewer/AvailabilityViewer' import Content from '/src/components/Content/Content' import Login from '/src/components/Login/Login' import Section from '/src/components/Section/Section' import SelectField from '/src/components/SelectField/SelectField' -import { EventResponse, PersonResponse } from '/src/config/api' +import { EventResponse, getPeople, PersonResponse, updatePerson } from '/src/config/api' import { useTranslation } from '/src/i18n/client' import timezones from '/src/res/timezones.json' +import useRecentsStore from '/src/stores/recentsStore' import { expandTimes, makeClass } from '/src/utils' import styles from './page.module.scss' -const EventAvailabilities = ({ event, people }: { event: EventResponse, people: PersonResponse[] }) => { +interface EventAvailabilitiesProps { + event: EventResponse + people: PersonResponse[] +} + +const EventAvailabilities = ({ event, ...data }: EventAvailabilitiesProps) => { const { t, i18n } = useTranslation('event') + const [people, setPeople] = useState(data.people) const expandedTimes = useMemo(() => expandTimes(event.times), [event.times]) const [user, setUser] = useState() @@ -26,12 +34,32 @@ const EventAvailabilities = ({ event, people }: { event: EventResponse, people: const [tab, setTab] = useState<'group' | 'you'>('group') const [timezone, setTimezone] = useState(Intl.DateTimeFormat().resolvedOptions().timeZone) + // Add this event to recents + const addRecent = useRecentsStore(state => state.addRecent) + useEffect(() => { + addRecent({ + id: event.id, + name: event.name, + created_at: event.created_at, + }) + }, [addRecent]) + + // Refetch availabilities + useEffect(() => { + if (tab === 'group') { + getPeople(event.id) + .then(setPeople) + .catch(console.warn) + } + }, [tab]) + return <>
{ setUser(u) setPassword(p) + setTab(u ? 'you' : 'group') }} /> - {tab === 'group' && : user && { + const oldAvailability = [...user.availability] + setUser({ ...user, availability }) + updatePerson(event.id, user.name, { availability }, password) + .catch(e => { + console.warn(e) + setUser({ ...user, availability: oldAvailability }) + }) + }} />} } diff --git a/frontend/src/app/[id]/not-found.tsx b/frontend/src/app/[id]/not-found.tsx index d118932..4a49235 100644 --- a/frontend/src/app/[id]/not-found.tsx +++ b/frontend/src/app/[id]/not-found.tsx @@ -1,10 +1,22 @@ +'use client' + +import { useEffect } from 'react' + import Content from '/src/components/Content/Content' -import { useTranslation } from '/src/i18n/server' +import { useTranslation } from '/src/i18n/client' +import useRecentsStore from '/src/stores/recentsStore' import styles from './page.module.scss' -const NotFound = async () => { - const { t } = await useTranslation('event') +const NotFound = () => { + const { t } = useTranslation('event') + + // Remove this event from recents if it was in there + const removeRecent = useRecentsStore(state => state.removeRecent) + useEffect(() => { + // Note: Next.js doesn't expose path params to the 404 page + removeRecent(window.location.pathname.replace('/', '')) + }, [removeRecent]) return
diff --git a/frontend/src/app/[id]/page.tsx b/frontend/src/app/[id]/page.tsx index 046700c..6e40b37 100644 --- a/frontend/src/app/[id]/page.tsx +++ b/frontend/src/app/[id]/page.tsx @@ -1,4 +1,5 @@ import { Trans } from 'react-i18next/TransWithoutContext' +import { Metadata } from 'next' import { notFound } from 'next/navigation' import { Temporal } from '@js-temporal/polyfill' @@ -11,7 +12,21 @@ import { makeClass, relativeTimeFormat } from '/src/utils' import EventAvailabilities from './EventAvailabilities' import styles from './page.module.scss' -const Page = async ({ params }: { params: { id: string } }) => { +interface PageProps { + params: { id: string } +} + +export const generateMetadata = async ({ params }: PageProps): Promise => { + const event = await getEvent(params.id).catch(() => undefined) + const { t } = await useTranslation('event') + + // TODO: More metadata + return { + title: event?.name ?? t('error.title'), + } +} + +const Page = async ({ params }: PageProps) => { const event = await getEvent(params.id).catch(() => undefined) const people = await getPeople(params.id).catch(() => undefined) if (!event || !people) notFound() diff --git a/frontend/src/components/AvailabilityEditor/AvailabilityEditor.jsx b/frontend/src/components/AvailabilityEditor/AvailabilityEditor.jsx deleted file mode 100644 index 694428b..0000000 --- a/frontend/src/components/AvailabilityEditor/AvailabilityEditor.jsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useState, useRef, Fragment, Suspense, lazy } from 'react' -import { useTranslation } from 'react-i18next' -import dayjs from 'dayjs' -import localeData from 'dayjs/plugin/localeData' -import customParseFormat from 'dayjs/plugin/customParseFormat' -import isBetween from 'dayjs/plugin/isBetween' -import dayjs_timezone from 'dayjs/plugin/timezone' -import utc from 'dayjs/plugin/utc' - -import { useLocaleUpdateStore } from '/src/stores' - -import { - Wrapper, - ScrollWrapper, - Container, - Date, - Times, - DateLabel, - DayLabel, - Spacer, - TimeLabels, - TimeLabel, - TimeSpace, - StyledMain, -} from '/src/components/AvailabilityViewer/AvailabilityViewer.styles' -import { Time } from './AvailabilityEditor.styles' - -import { _GoogleCalendar, _OutlookCalendar, Center } from '/src/components' -import { Loader } from '../Loading/Loading.styles' - -const GoogleCalendar = lazy(() => _GoogleCalendar()) -const OutlookCalendar = lazy(() => _OutlookCalendar()) - -dayjs.extend(localeData) -dayjs.extend(customParseFormat) -dayjs.extend(isBetween) -dayjs.extend(utc) -dayjs.extend(dayjs_timezone) - -const AvailabilityEditor = ({ - times, - timeLabels, - dates, - timezone, - isSpecificDates, - value = [], - onChange, -}) => { - const { t } = useTranslation('event') - const locale = useLocaleUpdateStore(state => state.locale) - - const [selectingTimes, _setSelectingTimes] = useState([]) - const staticSelectingTimes = useRef([]) - const setSelectingTimes = newTimes => { - staticSelectingTimes.current = newTimes - _setSelectingTimes(newTimes) - } - - const startPos = useRef({}) - const staticMode = useRef(null) - const [mode, _setMode] = useState(staticMode.current) - const setMode = newMode => { - staticMode.current = newMode - _setMode(newMode) - } - - return ( - <> - -
{t('event:you.info')}
-
- {isSpecificDates && ( - -
- }> - onChange( - times.filter(time => !busyArray.some(busy => - dayjs(time, 'HHmm-DDMMYYYY').isBetween(busy.start, busy.end, null, '[)') - )) - )} - /> - onChange( - times.filter(time => !busyArray.some(busy => - dayjs(time, 'HHmm-DDMMYYYY').isBetween(dayjs.tz(busy.start.dateTime, busy.start.timeZone), dayjs.tz(busy.end.dateTime, busy.end.timeZone), null, '[)') - )) - )} - /> - -
-
- )} - - - - - - {!!timeLabels.length && timeLabels.map((label, i) => - - {label.label?.length !== '' && {label.label}} - - )} - - {dates.map((date, x) => { - const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date) - const last = dates.length === x+1 || (isSpecificDates ? dayjs(dates[x+1], 'DDMMYYYY') : dayjs().day(dates[x+1])).diff(parsedDate, 'day') > 1 - return ( - - - {isSpecificDates && {parsedDate.format('MMM D')}} - {parsedDate.format('ddd')} - - 1} - > - {timeLabels.map((timeLabel, y) => { - if (!timeLabel.time) return null - if (!times.includes(`${timeLabel.time}-${date}`)) { - return ( - - ) - } - const time = `${timeLabel.time}-${date}` - - return ( - - - {last && dates.length !== x+1 && ( - - )} - - ) - })} - - - - - ) -} - -export default AvailabilityEditor diff --git a/frontend/src/components/AvailabilityEditor/AvailabilityEditor.styles.js b/frontend/src/components/AvailabilityEditor/AvailabilityEditor.styles.js deleted file mode 100644 index c72955a..0000000 --- a/frontend/src/components/AvailabilityEditor/AvailabilityEditor.styles.js +++ /dev/null @@ -1,24 +0,0 @@ -import { styled } from 'goober' - -export const Time = styled('div')` - height: 10px; - touch-action: none; - 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); - `} - - ${props => (props.$selected || (props.$mode === 'add' && props.$selecting)) && ` - background-color: var(--primary); - `}; - ${props => props.$mode === 'remove' && props.$selecting && ` - background-color: var(--background); - `}; -` diff --git a/frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx b/frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx new file mode 100644 index 0000000..5276a41 --- /dev/null +++ b/frontend/src/components/AvailabilityEditor/AvailabilityEditor.tsx @@ -0,0 +1,181 @@ +import { Fragment, useCallback, useMemo, useRef, useState } from 'react' +import { createPalette } from 'hue-map' + +import Content from '/src/components/Content/Content' +import { useTranslation } from '/src/i18n/client' +import { useStore } from '/src/stores' +import useSettingsStore from '/src/stores/settingsStore' +import { calculateColumns, calculateRows, convertTimesToDates, makeClass, serializeTime } from '/src/utils' + +import styles from '../AvailabilityViewer/AvailabilityViewer.module.scss' + +interface AvailabilityEditorProps { + times: string[] + timezone: string + value: string[] + onChange: (value: string[]) => void +} + +const AvailabilityEditor = ({ + times, + timezone, + value = [], + onChange, +}: AvailabilityEditorProps) => { + const { t, i18n } = useTranslation('event') + + const timeFormat = useStore(useSettingsStore, state => state.timeFormat) + const colormap = useStore(useSettingsStore, state => state.colormap) + + // Calculate rows and columns + const [dates, rows, columns] = useMemo(() => { + const dates = convertTimesToDates(times, timezone) + return [dates, calculateRows(dates), calculateColumns(dates)] + }, [times, timezone]) + + // Ref and state required to rerender but also access static version in callbacks + const selectingRef = useRef([]) + const [selecting, _setSelecting] = useState([]) + const setSelecting = useCallback((v: string[]) => { + selectingRef.current = v + _setSelecting(v) + }, []) + + const startPos = useRef({ x: 0, y: 0 }) + const mode = useRef<'add' | 'remove'>() + + // Is specific dates or just days of the week + const isSpecificDates = useMemo(() => times[0].length === 13, [times]) + + const palette = useMemo(() => createPalette({ + map: colormap !== 'crabfit' ? colormap : [[0, [247, 158, 0, 0]], [1, [247, 158, 0, 255]]], + steps: 2, + }).format(), [colormap]) + + return <> + {t('you.info')} + {/* {isSpecificDates && ( + +
+ }> + onChange( + times.filter(time => !busyArray.some(busy => + dayjs(time, 'HHmm-DDMMYYYY').isBetween(busy.start, busy.end, null, '[)') + )) + )} + /> + onChange( + times.filter(time => !busyArray.some(busy => + dayjs(time, 'HHmm-DDMMYYYY').isBetween(dayjs.tz(busy.start.dateTime, busy.start.timeZone), dayjs.tz(busy.end.dateTime, busy.end.timeZone), null, '[)') + )) + )} + /> + +
+
+ )} */} + +
+
+
+
+ {rows.map((row, i) => +
+ {row && row.minute === 0 && } +
+ )} +
+ + {columns.map((column, x) => + {column ?
+ {isSpecificDates && } + + +
+ {rows.map((row, y) => { + if (y === rows.length - 1) return null + + if (!row || rows.at(y + 1) === null || dates.every(d => !d.equals(column.toZonedDateTime({ timeZone: timezone, plainTime: row })))) { + return
('greyed_times')} + /> + } + + const date = column.toZonedDateTime({ timeZone: timezone, plainTime: row }) + + return
{ + e.preventDefault() + startPos.current = { x, y } + mode.current = value.includes(serializeTime(date, isSpecificDates)) ? 'remove' : 'add' + setSelecting([serializeTime(date, isSpecificDates)]) + e.currentTarget.releasePointerCapture(e.pointerId) + + document.addEventListener('pointerup', () => { + if (mode.current === 'add') { + onChange([...value, ...selectingRef.current]) + } else if (mode.current === 'remove') { + onChange(value.filter(t => !selectingRef.current.includes(t))) + } + mode.current = undefined + }, { once: true }) + }} + onPointerEnter={() => { + if (mode.current) { + const found = [] + for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y) + 1; cy++) { + for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x) + 1; cx++) { + found.push({ y: cy, x: cx }) + } + } + setSelecting(found.flatMap(d => { + const [time, date] = [rows[d.y], columns[d.x]] + if (time !== null && date !== null) { + const str = serializeTime(date.toZonedDateTime({ timeZone: timezone, plainTime: time }), isSpecificDates) + if (times.includes(str)) { + return [str] + } + return [] + } + return [] + })) + } + }} + /> + })} +
+
:
} + )} +
+
+
+ +} + +export default AvailabilityEditor diff --git a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.module.scss b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.module.scss index be75eec..664af46 100644 --- a/frontend/src/components/AvailabilityViewer/AvailabilityViewer.module.scss +++ b/frontend/src/components/AvailabilityViewer/AvailabilityViewer.module.scss @@ -97,6 +97,7 @@ height: 10px; background-origin: border-box; transition: background-color .1s; + touch-action: none; border-top-width: 2px; border-top-style: solid; diff --git a/frontend/src/components/Content/Content.module.scss b/frontend/src/components/Content/Content.module.scss index 674bcdc..387f1d4 100644 --- a/frontend/src/components/Content/Content.module.scss +++ b/frontend/src/components/Content/Content.module.scss @@ -8,6 +8,7 @@ display: flex; align-items: center; justify-content: center; + text-align: center; } .slim { diff --git a/frontend/src/components/Copyable/Copyable.tsx b/frontend/src/components/Copyable/Copyable.tsx index e7f90e1..d3bce21 100644 --- a/frontend/src/components/Copyable/Copyable.tsx +++ b/frontend/src/components/Copyable/Copyable.tsx @@ -24,7 +24,7 @@ const Copyable = ({ children, className, ...props }: CopyableProps) => { }) .catch(e => console.error('Failed to copy', e)) } - title={navigator.clipboard ? t('nav.title') : undefined} + title={'clipboard' in navigator ? t('nav.title') : undefined} className={makeClass(className, 'clipboard' in navigator && styles.copyable)} {...props} >{copied ?? children}

diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 266a34c..3b8b2ef 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -8,3 +8,4 @@ export * from './calculateColumns' export * from './getWeekdayNames' export * from './relativeTimeFormat' export * from './expandTimes' +export * from './serializeTime' diff --git a/frontend/src/utils/serializeTime.ts b/frontend/src/utils/serializeTime.ts new file mode 100644 index 0000000..094ef4d --- /dev/null +++ b/frontend/src/utils/serializeTime.ts @@ -0,0 +1,16 @@ +import { Temporal } from '@js-temporal/polyfill' + +/** + * Takes a ZonedDateTime in any timezone, and serializes it in UTC + * @param isSpecificDates Whether to format at `HHmm-DDMMYYYY` or `HHmm-d` + * @returns Time serialized to UTC + */ +export const serializeTime = (time: Temporal.ZonedDateTime, isSpecificDates: boolean) => { + const t = time.withTimeZone('UTC') + const [hour, minute, day, month] = [t.hour, t.minute, t.day, t.month].map(x => x.toString().padStart(2, '0')) + const [year, dayOfWeek] = [t.year.toString().padStart(4, '0'), (t.dayOfWeek === 7 ? 0 : t.dayOfWeek).toString()] + + return isSpecificDates + ? `${hour}${minute}-${day}${month}${year}` + : `${hour}${minute}-${dayOfWeek}` +}