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 98% rename from components/calendar/weekly-display.module.scss rename to components/calendar/display.module.scss index 88290a53..b7621f49 100644 --- a/components/calendar/weekly-display.module.scss +++ b/components/calendar/display.module.scss @@ -15,7 +15,6 @@ } .wrapper { - border-right: 1px solid var(--accents-2); border-left: 1px solid var(--accents-2); padding-left: 8px; overflow: hidden; 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 (
('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' && ( + + )} 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/search-bar.module.scss b/components/calendar/search-bar.module.scss index f4999e19..91b2294a 100644 --- a/components/calendar/search-bar.module.scss +++ b/components/calendar/search-bar.module.scss @@ -22,6 +22,26 @@ .filterChips { flex-wrap: nowrap; } + + .select { + :global(.mdc-select) { + background-color: transparent; + border-radius: 0; + + :global(.mdc-select__anchor) { + border-radius: 0; + width: 90px; + + :global(.mdc-line-ripple::before) { + border: none; + } + } + } + + :global(.mdc-select:not(.mdc-select--disabled) .mdc-select__anchor) { + background-color: transparent; + } + } } .right { diff --git a/components/calendar/search-bar.tsx b/components/calendar/search-bar.tsx index 7f395937..5de7344b 100644 --- a/components/calendar/search-bar.tsx +++ b/components/calendar/search-bar.tsx @@ -1,5 +1,6 @@ import { memo, useCallback } from 'react'; import { IconButton } from '@rmwc/icon-button'; +import { Select } from '@rmwc/select'; import { TextField } from '@rmwc/textfield'; import { dequal } from 'dequal/lite'; @@ -9,6 +10,7 @@ import FilterListIcon from 'components/icons/filter-list'; import { Callback } from 'lib/model/callback'; import { MeetingsQuery } from 'lib/model/query/meetings'; +import { CalendarDisplay, useCalendarState } from './state'; import styles from './search-bar.module.scss'; export interface SearchBarProps { @@ -27,6 +29,7 @@ function SearchBar({ const downloadResults = useCallback(() => { window.open(query.getURL('/api/meetings/csv')); }, [query]); + const { display, setDisplay } = useCalendarState(); return (
@@ -46,6 +49,14 @@ function SearchBar({ icon={} /> )} +
+