From fc1759fb3c8b5b012aa3398d407b345893cb4fc6 Mon Sep 17 00:00:00 2001 From: Nicholas Chiang Date: Sun, 31 Oct 2021 20:49:29 -0700 Subject: [PATCH 1/5] wip(calendar): create daily display --- components/calendar/components.tsx | 2 +- components/calendar/daily-display.tsx | 283 ++++++++++++++++++ ...isplay.module.scss => display.module.scss} | 0 components/calendar/index.tsx | 4 +- components/calendar/place-meetings.ts | 100 ++++--- components/calendar/weekly-display.tsx | 6 +- 6 files changed, 340 insertions(+), 55 deletions(-) create mode 100644 components/calendar/daily-display.tsx rename components/calendar/{weekly-display.module.scss => display.module.scss} (100%) diff --git a/components/calendar/components.tsx b/components/calendar/components.tsx index 90859fb3..f80edab5 100644 --- a/components/calendar/components.tsx +++ b/components/calendar/components.tsx @@ -5,7 +5,7 @@ import useTranslation from 'next-translate/useTranslation'; import { getDateWithDay, getDateWithTime } from 'lib/utils/time'; -import styles from './weekly-display.module.scss'; +import styles from './display.module.scss'; import { useCalendarState } from './state'; const COLS = Array(7).fill(null); diff --git a/components/calendar/daily-display.tsx b/components/calendar/daily-display.tsx new file mode 100644 index 00000000..2d83e8ad --- /dev/null +++ b/components/calendar/daily-display.tsx @@ -0,0 +1,283 @@ +import { + MouseEvent, + UIEvent, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { animated, useSpring } from 'react-spring'; +import cn from 'classnames'; +import mergeRefs from 'react-merge-refs'; +import { ResizeObserver as polyfill } from '@juggle/resize-observer'; +import useMeasure from 'react-use-measure'; +import useTranslation from 'next-translate/useTranslation'; + +import LoadingDots from 'components/loading-dots'; + +import { Callback } from 'lib/model/callback'; +import { Meeting } from 'lib/model/meeting'; +import { Position } from 'lib/model/position'; +import { useClickContext } from 'lib/hooks/click-outside'; +import { useOrg } from 'lib/context/org'; +import { useUser } from 'lib/context/user'; + +import { DialogPage, useCalendarState } from './state'; +import { Lines, Times } from './components'; +import { MouseEventHackData, MouseEventHackTarget } from './hack-types'; +import { config, width } from './spring-animation'; +import { expand, placeMeetingsInDay } from './place-meetings'; +import { getMeeting, getPosition } from './utils'; +import MeetingItem from './meetings/item'; +import MeetingRnd from './meetings/rnd'; +import styles from './display.module.scss'; + +export interface DailyDisplayProps { + searching: boolean; + meetings: Meeting[]; + filtersOpen: boolean; + width: number; + setWidth: Callback; + offset: Position; + setOffset: Callback; +} + +function DailyDisplay({ + searching, + meetings, + filtersOpen, + width: cellWidth, + setWidth: setCellWidth, + offset, + setOffset, +}: DailyDisplayProps): JSX.Element { + const [cellsMeasureIsCorrect, setCellsMeasureIsCorrect] = useState(false); + const [rowsMeasureRef, rowsMeasure] = useMeasure({ polyfill }); + const [cellsMeasureRef, cellsMeasure] = useMeasure({ + polyfill, + scroll: true, + }); + const [cellMeasureRef, cellMeasure] = useMeasure({ polyfill }); + + useEffect(() => { + setCellWidth(cellMeasure.width); + }, [setCellWidth, cellMeasure.width]); + + // See: https://github.com/pmndrs/react-use-measure/issues/37 + // Current workaround is to listen for scrolls on the parent div. Once + // the user scrolls, we know that the `rowsMeasure.x` is no longer correct + // but that the `cellsMeasure.x` is correct. + useEffect(() => { + setOffset({ + x: cellsMeasureIsCorrect ? cellsMeasure.x : rowsMeasure.x + 8, + y: cellsMeasure.y, + }); + }, [ + setOffset, + cellsMeasureIsCorrect, + cellsMeasure.x, + cellsMeasure.y, + rowsMeasure.x, + ]); + + useEffect(() => { + setCellsMeasureIsCorrect(false); + }, [filtersOpen]); + + // Scroll to 8:30am by default (assumes 48px per hour). + const rowsRef = useRef(null); + useEffect(() => { + if (rowsRef.current) rowsRef.current.scrollTop = 48 * 8 + 24; + }, []); + + const { + rnd, + setRnd, + setEditing, + dragging, + setDialog, + setDialogPage, + start, + } = useCalendarState(); + + const [eventTarget, setEventTarget] = useState(); + const [eventData, setEventData] = useState(); + + // Create a new `TimeslotRND` closest to the user's click position. Assumes + // each column is 82px wide and every hour is 48px tall (i.e. 12px = 15min). + const { user } = useUser(); + const { org } = useOrg(); + const onClick = useCallback( + (event: MouseEvent) => { + if (dragging) return; + const pos = { x: event.clientX - offset.x, y: event.clientY - offset.y }; + const orgId = org ? org.id : user.orgs[0] || 'default'; + const creating = new Meeting({ id: 0, creator: user, org: orgId }); + setEventTarget(undefined); + setEventData(undefined); + setEditing(getMeeting(48, pos, creating, cellWidth, start)); + setDialogPage(DialogPage.Create); + setDialog(true); + setRnd(true); + }, + [ + org, + user, + setEditing, + setDialog, + setDialogPage, + setRnd, + dragging, + start, + offset, + cellWidth, + ] + ); + + // Sync the scroll position of the main cell grid and the static headers. This + // was inspired by the way that Google Calendar's UI is currently setup. + // @see {@link https://mzl.la/35OIC9y} + const headerRef = useRef(null); + const timesRef = useRef(null); + const ticking = useRef(false); + const onScroll = useCallback((event: UIEvent) => { + setCellsMeasureIsCorrect(true); + const { scrollTop, scrollLeft } = event.currentTarget; + if (!ticking.current) { + requestAnimationFrame(() => { + if (timesRef.current) timesRef.current.scrollTop = scrollTop; + if (headerRef.current) headerRef.current.scrollLeft = scrollLeft; + ticking.current = false; + }); + ticking.current = true; + } + }, []); + + const eventGroups = useMemo(() => placeMeetingsInDay(meetings, start.getDay()), [meetings, start]); + const props = useSpring({ config, marginRight: filtersOpen ? width : 0 }); + + const [now, setNow] = useState(new Date()); + useEffect(() => { + const tick = () => setNow(new Date()); + const intervalId = window.setInterval(tick, 60000); + return () => window.clearInterval(intervalId); + }, []); + + const { updateEl, removeEl } = useClickContext(); + const cellsClickRef = useCallback( + (node: HTMLElement | null) => { + if (!node) return removeEl('calendar-cells'); + return updateEl('calendar-cells', node); + }, + [updateEl, removeEl] + ); + + const { lang: locale } = useTranslation(); + const today = + now.getFullYear() === start.getFullYear() && + now.getMonth() === start.getMonth() && + now.getDate() === start.getDate(); + + // Show current time indicator if today is current date. + const { y: top } = getPosition(now); + + return ( + +
+
+
+
+

+
+ {start.toLocaleString(locale, { weekday: 'short' })} +
+
{start.getDate()}
+

+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ {searching && ( +
+ +
+ )} +
+
+ +
+
+
+ {rnd && ( + + )} +
+ {today && ( +
+
+
+
+ )} + {eventGroups + .map((cols: Meeting[][]) => + cols.map((col: Meeting[], colIdx) => + col.map((e: Meeting) => ( + + )) + ) + ) + .flat(2)} + +
+
+
+
+
+
+ + ); +} + +export default memo(DailyDisplay); diff --git a/components/calendar/weekly-display.module.scss b/components/calendar/display.module.scss similarity index 100% rename from components/calendar/weekly-display.module.scss rename to components/calendar/display.module.scss diff --git a/components/calendar/index.tsx b/components/calendar/index.tsx index a2e1884a..dcb0cd8b 100644 --- a/components/calendar/index.tsx +++ b/components/calendar/index.tsx @@ -34,7 +34,7 @@ import FiltersSheet from './filters-sheet'; import Header from './header'; import RecurDialog from './recur-dialog'; import SearchBar from './search-bar'; -import WeeklyDisplay from './weekly-display'; +import DailyDisplay from './daily-display'; import styles from './calendar.module.scss'; const initialEditData = new Meeting(); @@ -370,7 +370,7 @@ export default function Calendar({ byOrg={byOrg} />
- m.time.from.getDay() === day) + .sort(({ time: e1 }, { time: e2 }) => { + if (e1.from < e2.from) return -1; + if (e1.from > e2.from) return 1; + if (e1.to < e2.to) return -1; + if (e1.to > e2.to) return 1; + return 0; + }) + .forEach((e) => { + // Check if a new event group needs to be started. + if (lastEventEnding && e.time.from >= lastEventEnding) { + // The event is later than any of the events in the + // current group. There is no overlap. Output the + // current event group and start a new one. + groups.push(columns); + columns = []; + lastEventEnding = undefined; + } + + // Try to place the event inside an existing column. + let placed = false; + columns.some((col) => { + if (!col[col.length - 1].time.overlaps(e.time, true)) { + col.push(e); + placed = true; + } + return placed; + }); + + // It was not possible to place the event (it overlaps + // with events in each existing column). Add a new column + // to the current event group with the event in it. + if (!placed) columns.push([e]); + + // Remember the last event end time of the current group. + if (!lastEventEnding || e.time.to > lastEventEnding) + lastEventEnding = e.time.to; + }); + return [...groups, columns]; +} + // Place concurrent meetings side-by-side (like GCal). // @see {@link https://share.clickup.com/t/h/hpxh7u/WQO1OW4DQN0SIZD} // @see {@link https://stackoverflow.com/a/11323909/10023158} // @see {@link https://jsbin.com/detefuveta/edit} -export function placeMeetings(meetings: Meeting[]): Meeting[][][][] { +export function placeMeetingsInWeek(meetings: Meeting[]): Meeting[][][][] { const COLS = Array(7).fill(null); // Each day contains the groups that are on that day. - return COLS.map((_, day) => { - // Each group contains columns of events that overlap. - const groups: Meeting[][][] = []; - // Each column contains events that do not overlap. - let columns: Meeting[][] = []; - let lastEventEnding: Date | undefined; - // Place each event into a column within an event group. - meetings - .filter((m) => m.time.from.getDay() === day) - .sort(({ time: e1 }, { time: e2 }) => { - if (e1.from < e2.from) return -1; - if (e1.from > e2.from) return 1; - if (e1.to < e2.to) return -1; - if (e1.to > e2.to) return 1; - return 0; - }) - .forEach((e) => { - // Check if a new event group needs to be started. - if (lastEventEnding && e.time.from >= lastEventEnding) { - // The event is later than any of the events in the - // current group. There is no overlap. Output the - // current event group and start a new one. - groups.push(columns); - columns = []; - lastEventEnding = undefined; - } - - // Try to place the event inside an existing column. - let placed = false; - columns.some((col) => { - if (!col[col.length - 1].time.overlaps(e.time, true)) { - col.push(e); - placed = true; - } - return placed; - }); - - // It was not possible to place the event (it overlaps - // with events in each existing column). Add a new column - // to the current event group with the event in it. - if (!placed) columns.push([e]); - - // Remember the last event end time of the current group. - if (!lastEventEnding || e.time.to > lastEventEnding) - lastEventEnding = e.time.to; - }); - return [...groups, columns]; - }); + return COLS.map((_, day) => placeMeetingsInDay(meetings, day)); } diff --git a/components/calendar/weekly-display.tsx b/components/calendar/weekly-display.tsx index 1419af04..7bf3701a 100644 --- a/components/calendar/weekly-display.tsx +++ b/components/calendar/weekly-display.tsx @@ -27,11 +27,11 @@ import { DialogPage, useCalendarState } from './state'; import { Headers, Lines, Times, Weekdays } from './components'; import { MouseEventHackData, MouseEventHackTarget } from './hack-types'; import { config, width } from './spring-animation'; -import { expand, placeMeetings } from './place-meetings'; +import { expand, placeMeetingsInWeek } from './place-meetings'; import { getMeeting, getPosition } from './utils'; import MeetingItem from './meetings/item'; import MeetingRnd from './meetings/rnd'; -import styles from './weekly-display.module.scss'; +import styles from './display.module.scss'; export interface WeeklyDisplayProps { searching: boolean; @@ -157,7 +157,7 @@ function WeeklyDisplay({ } }, []); - const eventGroups = useMemo(() => placeMeetings(meetings), [meetings]); + const eventGroups = useMemo(() => placeMeetingsInWeek(meetings), [meetings]); const props = useSpring({ config, marginRight: filtersOpen ? width : 0 }); const [now, setNow] = useState(new Date()); From 829527d2f24ebe90c5cafd81c8a1df1d48841bed Mon Sep 17 00:00:00 2001 From: Nicholas Chiang Date: Sun, 31 Oct 2021 21:00:17 -0700 Subject: [PATCH 2/5] wip(calendar): enable switching between day/week --- components/calendar/index.tsx | 39 ++++++++++++++++++++++-------- components/calendar/search-bar.tsx | 11 +++++++++ components/calendar/state.ts | 6 +++++ 3 files changed, 46 insertions(+), 10 deletions(-) diff --git a/components/calendar/index.tsx b/components/calendar/index.tsx index dcb0cd8b..d2c073f6 100644 --- a/components/calendar/index.tsx +++ b/components/calendar/index.tsx @@ -25,7 +25,7 @@ import useSingle from 'lib/hooks/single'; import useURLParamSync from 'lib/hooks/url-param-sync'; import { useUser } from 'lib/context/user'; -import { CalendarStateContext, DialogPage } from './state'; +import { CalendarDisplay, CalendarStateContext, DialogPage } from './state'; import CreatePage from './dialog/create-page'; import DialogSurface from './dialog/surface'; import DisplayPage from './dialog/display-page'; @@ -35,6 +35,7 @@ import Header from './header'; import RecurDialog from './recur-dialog'; import SearchBar from './search-bar'; import DailyDisplay from './daily-display'; +import WeeklyDisplay from './weekly-display'; import styles from './calendar.module.scss'; const initialEditData = new Meeting(); @@ -48,6 +49,7 @@ export default function Calendar({ org: byOrg, user: byUser, }: CalendarProps): JSX.Element { + const [display, setDisplay] = useState('Week'); const [filtersOpen, setFiltersOpen] = useState(false); const [mutatedIds, setMutatedIds] = useState>(new Set()); const [query, setQuery] = useState(new MeetingsQuery()); @@ -236,6 +238,8 @@ export default function Calendar({ dragging, setDragging, start: query.from, + display, + setDisplay, }), [ editing, @@ -252,6 +256,8 @@ export default function Calendar({ dragging, setDragging, query.from, + display, + setDisplay, ] ); @@ -370,15 +376,28 @@ export default function Calendar({ byOrg={byOrg} />
- + {display === 'Day' && ( + + )} + {display === 'Week' && ( + + )} { window.open(query.getURL('/api/meetings/csv')); }, [query]); + const { display, setDisplay } = useCalendarState(); return (
@@ -48,6 +51,14 @@ function SearchBar({ )}
+
+ setDisplay(evt.currentTarget.value as CalendarDisplay)} />
+
+
Date: Sun, 31 Oct 2021 21:17:50 -0700 Subject: [PATCH 5/5] fix(calendar/header): update query prev/next callbacks --- components/calendar/header.tsx | 47 +++++++++++++++++----------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/components/calendar/header.tsx b/components/calendar/header.tsx index 514669bc..20e5c5a9 100644 --- a/components/calendar/header.tsx +++ b/components/calendar/header.tsx @@ -8,6 +8,8 @@ import { Callback } from 'lib/model/callback'; import { MeetingsQuery } from 'lib/model/query/meetings'; import { useOrg } from 'lib/context/org'; +import { useCalendarState } from './state'; + export interface CalendarHeaderProps { query: MeetingsQuery; setQuery: Callback; @@ -15,9 +17,11 @@ export interface CalendarHeaderProps { function CalendarHeader({ query, setQuery }: CalendarHeaderProps): JSX.Element { const { org } = useOrg(); + const { display } = useCalendarState(); const { t, lang: locale } = useTranslation(); - const title = useMemo(() => { + const dayTitle = useMemo(() => query.from.toLocaleString(locale, { month: 'long', day: 'numeric', year: 'numeric' }), [query.from, locale]); + const weekTitle = useMemo(() => { const { from, to } = query; if (from.getMonth() !== to.getMonth()) return `${from.toLocaleString(locale, { @@ -30,26 +34,21 @@ function CalendarHeader({ query, setQuery }: CalendarHeaderProps): JSX.Element { return from.toLocaleString(locale, { month: 'long', year: 'numeric' }); }, [query, locale]); - const prevWeek = useCallback(() => { - setQuery((prev) => { - const to = new Date(prev.from); - const from = new Date(to.getFullYear(), to.getMonth(), to.getDate() - 7); - return new MeetingsQuery({ ...prev, from, to }); + const delta = useMemo(() => display === 'Day' ? 1 : 7, [display]); + const prev = useCallback(() => { + setQuery((p) => { + const from = new Date(p.from.getFullYear(), p.from.getMonth(), p.from.getDate() - delta); + const to = new Date(p.to.getFullYear(), p.to.getMonth(), p.to.getDate() - delta); + return new MeetingsQuery({ ...p, from, to }); }); - }, [setQuery]); - - const nextWeek = useCallback(() => { - setQuery((prev) => { - const from = new Date(prev.to); - const to = new Date( - from.getFullYear(), - from.getMonth(), - from.getDate() + 7 - ); - return new MeetingsQuery({ ...prev, from, to }); + }, [setQuery, delta]); + const next = useCallback(() => { + setQuery((p) => { + const from = new Date(p.from.getFullYear(), p.from.getMonth(), p.from.getDate() + delta); + const to = new Date(p.to.getFullYear(), p.to.getMonth(), p.to.getDate() + delta); + return new MeetingsQuery({ ...p, from, to }); }); - }, [setQuery]); - + }, [setQuery, delta]); const today = useCallback(() => { setQuery((prev) => { const { from, to } = new MeetingsQuery(); @@ -60,16 +59,16 @@ function CalendarHeader({ query, setQuery }: CalendarHeaderProps): JSX.Element { return (