diff --git a/client/src/app/_components/_base/Dropdown.tsx b/client/src/app/_components/_base/Dropdown.tsx new file mode 100644 index 0000000..3df527e --- /dev/null +++ b/client/src/app/_components/_base/Dropdown.tsx @@ -0,0 +1,65 @@ +import { forwardRef } from 'react'; +import { Menu, MenuItem, MenuItems } from '@headlessui/react'; + +import { clmx } from '@/util/classConcat'; +import { IconType } from '@/util/iconType'; + +export const Dropdown = forwardRef< + HTMLDivElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +Dropdown.displayName = 'Dropdown'; + +export const DropdownItems = forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +DropdownItems.displayName = 'DropdownItems'; + +export function DropdownOption({ + icon: Icon, + className, + children, + ...props +}: { icon: IconType } & JSX.IntrinsicElements['button']) { + return ( + + + + ); +} diff --git a/client/src/app/calendar/_components/Agenda.tsx b/client/src/app/calendar/_components/Agenda.tsx index a144c02..8009577 100644 --- a/client/src/app/calendar/_components/Agenda.tsx +++ b/client/src/app/calendar/_components/Agenda.tsx @@ -5,16 +5,10 @@ import { Fragment, forwardRef, useCallback, - useMemo, useState, } from 'react'; -import { - Popover, - PopoverButton, - Transition, - TransitionChild, -} from '@headlessui/react'; +import { Popover, PopoverButton, Transition } from '@headlessui/react'; import { IconChevronDown, IconMinus, @@ -22,16 +16,12 @@ import { IconPoint, } from '@tabler/icons-react'; -import { - UseDatesArrayProps, - dateFormat, - useDatesArray, -} from '../_util/dateUtils'; +import { UseDatesArrayProps, dateFormat } from '../_util/dateUtils'; import { CalendarProps, EventType } from './Calendar'; import { clmx, clx } from '@/util/classConcat'; import { IconTypeProps } from '@/util/iconType'; -import { alphabetical } from '@/util/sort'; import { useEventColorId } from '../_util/cabinColors'; +import { useEventsByDay } from '../_util/eventsByDay'; import EventPopup from './EventPopup'; import RoomSwatch from './RoomSwatch'; @@ -40,35 +30,10 @@ import RoomSwatch from './RoomSwatch'; type AgendaProps = Pick & UseDatesArrayProps; export default function Agenda({ ...props }: AgendaProps) { - const { events: events_in, updatePeriod } = props; + const { updatePeriod } = props; // get days per event - const dates = useDatesArray(props); - const eventsByDay = useMemo(() => { - if (!events_in) return null; - const events = events_in.sort(alphabetical((s) => s.title)); - return dates.map((d) => { - const ad = { - date: d, - count: events.filter( - (event) => event.dateStart <= d && event.dateEnd >= d, - ).length, - arrivals: events.filter((event) => event.dateStart === d), - departures: events.filter((event) => event.dateEnd === d), - }; - return { - ...ad, - unchanged: events.filter( - (event) => - event.dateStart <= d && - event.dateEnd > d && - !ad.arrivals.find((it) => it.id === event.id) && - !ad.departures.find((it) => it.id === event.id), - ), - noChanges: !ad.arrivals.length && !ad.departures.length, - }; - }); - }, [dates, events_in]); + const eventsByDay = useEventsByDay(props); return ( <> @@ -140,15 +105,6 @@ export default function Agenda({ ...props }: AgendaProps) { ); } -type EventsByDay = { - date: number; - count: number; - noChanges: boolean; - arrivals: EventType[]; - departures: EventType[]; - unchanged: EventType[]; -}; - const subtleBtnStyles = clx( '-mx-2 -my-0.5 rounded-md px-2 py-0.5 text-left focus:bg-slate-200 focus:outline-none hover:enabled:bg-slate-200/50', ); diff --git a/client/src/app/calendar/_components/Calendar.tsx b/client/src/app/calendar/_components/Calendar.tsx index 6ab7ed3..341389e 100644 --- a/client/src/app/calendar/_components/Calendar.tsx +++ b/client/src/app/calendar/_components/Calendar.tsx @@ -10,6 +10,7 @@ import { dateStartOfWeek, dateTS, dateTSLocal, + dateTSObject, dayjs, } from '../_util/dateUtils'; import { Inside } from '@/util/inferTypes'; @@ -22,6 +23,7 @@ import { SetState } from '@/util/stateType'; import Timeline from './Timeline'; import Controls from './Controls'; import Agenda from './Agenda'; +import Overview, { OVERVIEW_NUM_WEEKS } from './Overview'; export const EVENTS_QUERY = graphql(` query Stays($start: Int!, $end: Int!) { @@ -68,13 +70,17 @@ export default function Calendar() { const sq = useSearchParams(); const router = useRouter(); + // calendar view + const [view] = useCalendarView(); + // days const defaultDays = useDefaultDays(); const days = useMemo(() => { + if (view === 'OVERVIEW') return 31; const num = parseInt(sq.get('days' as QP) ?? ''); if (!Number.isFinite(num)) return undefined; return num; - }, [sq]); + }, [sq, view]); const daysWithDefault = days ?? defaultDays; function setDays(d: typeof days) { updateQuery('days', d ?? ''); @@ -88,8 +94,10 @@ export default function Calendar() { if (daysWithDefault !== 7) return new Date(); startDateNum = dateStartOfWeek(dateTS(new Date())); } + if (view === 'OVERVIEW') + startDateNum = dateTSObject(startDateNum).startOf('month').unix(); return new Date(dateTSLocal(startDateNum) * 1000); - }, [daysWithDefault, sq]); + }, [daysWithDefault, sq, view]); function setStartDate(d: typeof startDate) { updateQuery('date', dateTS(d)); } @@ -99,7 +107,7 @@ export default function Calendar() { function updateQuery(key: QP, val: string | number) { const query = new URLSearchParams(sq); - if (days) query.set('days' as QP, '' + days); + if (days && view !== 'OVERVIEW') query.set('days' as QP, '' + days); query.set('date' as QP, '' + dateTS(startDate)); query.set(key, '' + val); router.push('?' + query.toString(), { scroll: false }); @@ -112,13 +120,22 @@ export default function Calendar() { .toDate(), [daysWithDefault, startDate], ); - const parsedDates = useMemo( - () => ({ - start: dateTS(startDate), - end: dateTS(endDate), - }), - [endDate, startDate], - ); + const parsedDates = useMemo(() => { + let start = dateTS(startDate); + let end = dateTS(endDate); + + if (view === 'OVERVIEW') { + const s = dateTSObject(start).startOf('month').startOf('week'); + start = s.unix(); + end = s.add(7 * OVERVIEW_NUM_WEEKS, 'days').unix(); + } + + return { start, end }; + }, [endDate, startDate, view]); + + const selectedDate = useMemo(() => { + return dateTS(startDate); + }, [startDate]); // get calendar events const query = useGraphQuery(EVENTS_QUERY, parsedDates); @@ -136,9 +153,6 @@ export default function Calendar() { const _dbr = useDisplayByRooms(); const toggleDisplayByRoom = useCallback(() => _dbr[1](!_dbr[0]), [_dbr]); - // calendar view - const [view] = useCalendarView(); - // CALENDAR PROPS const props: CalendarProps = { events, @@ -152,6 +166,7 @@ export default function Calendar() { setStartDate, setDays, }, + selectedDate, roomCollapse: { state: roomCollapse, set: setRoomCollapse, @@ -216,6 +231,9 @@ export default function Calendar() { {/* agenda view */} {view === 'AGENDA' && } + + {/* overview view */} + {view === 'OVERVIEW' && } @@ -231,6 +249,7 @@ export type CalendarProps = { start: number; end: number; }; + selectedDate: number; periodState: { days: number | undefined; setDays: (d: number | undefined) => void; diff --git a/client/src/app/calendar/_components/Controls.tsx b/client/src/app/calendar/_components/Controls.tsx index 81a1c62..9f62d69 100644 --- a/client/src/app/calendar/_components/Controls.tsx +++ b/client/src/app/calendar/_components/Controls.tsx @@ -42,6 +42,7 @@ export default function Controls(props: CalendarProps) { const { isLoading, dates, + selectedDate, periodState: { days, setDays, startDate, setStartDate }, roomCollapse: { full: [fullCollapse, setFullCollapse], @@ -86,7 +87,7 @@ export default function Controls(props: CalendarProps) { onClick={() => setDatePickerOpen(true)} >

- {dateFormat(dates.start, `MMM 'YY`)} + {dateFormat(selectedDate, `MMM 'YY`)}

@@ -176,23 +177,27 @@ export default function Controls(props: CalendarProps) { {/* number of days to show */} -
- { - if (!v.length) setDays(undefined); - const d = parseInt((v.match(/[\d\.]/g) || ['']).join('')); - if (Number.isFinite(d)) - setDays(clamp(d, CALENDAR_DAYS_MIN, CALENDAR_DAYS_MAX)); - }} - /> - days -
+ {view !== 'OVERVIEW' && ( + <> +
+ { + if (!v.length) setDays(undefined); + const d = parseInt((v.match(/[\d\.]/g) || ['']).join('')); + if (Number.isFinite(d)) + setDays(clamp(d, CALENDAR_DAYS_MIN, CALENDAR_DAYS_MAX)); + }} + /> + days +
-
+
+ + )} {/* rooms controls */}
; + +export const OVERVIEW_NUM_WEEKS = 6; + +// COMPONENT +export default function Overview({ ...props }: CalendarProps) { + const { + events, + isLoading, + selectedDate: firstOfMonth, + dates: queryDates, + } = props; + + const [selectedDate, setSelectedDate] = useState(null); + useEffect(() => { + if ( + selectedDate && + (selectedDate < queryDates.start || selectedDate >= queryDates.end) + ) + setSelectedDate(null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryDates]); + + const colorIds = useEventColorIds(events ?? []); + + // calculate dates + const eventsByDay = useEventsByDay( + { + events, + dates: queryDates, + days: 7 * OVERVIEW_NUM_WEEKS, + }, + (d) => { + return { + ...d, + isSelected: d.date === selectedDate, + isToday: d.date === dateTS(new Date()), + inMonth: + dateTSObject(d.date).month() === dateTSObject(firstOfMonth).month(), + events: [...d.unchanged, ...d.arrivals].sort( + alphabetical((d) => colorIds?.[d.id] ?? 'zzz'), + ), + }; + }, + true, + ); + + const selectedEvents = useMemo(() => { + if (!selectedDate) return null; + return events?.filter( + (event) => + event.dateStart <= selectedDate && event.dateEnd >= selectedDate, + ); + }, [events, selectedDate]); + + return ( + <> +
+
+ {/* CALENDAR */} +
+
+ + {/* header */} +
+ +
+ + {/* calendar days */} +
+
+ {eventsByDay?.map((d) => ( + // single day + + ))} +
+
+
+ + {/* SELECTED DAY PANEL */} +
+
+ {/* selected day */} + {selectedDate && ( +
+ {/* selected date */} +

+ {dateFormat(selectedDate, 'MMM D, YYYY')} +

+
+ + {/* events */} + +
+ {selectedEvents?.map((event) => ( + // regular events + + ))} +
+ + {/* loader */} + {isLoading && !selectedEvents && ( +
+ +
+ )} + + {/* no events message */} + {selectedEvents && !selectedEvents.length && ( +
+ no events +
+ )} +
+
+ )} + + {/* no day selected */} +
+ select a day +
+
+
+
+ + ); +} + +function OverviewSwatch({ event }: { event: EventType }) { + const colorId = useEventColorId(event); + + return ( + <> + + + {/* plus sign if it can ever be easily implemented... */} + {/*
+ + +
*/} + + ); +} diff --git a/client/src/app/calendar/_components/RoomSwatch.tsx b/client/src/app/calendar/_components/RoomSwatch.tsx index ed199f2..24710f1 100644 --- a/client/src/app/calendar/_components/RoomSwatch.tsx +++ b/client/src/app/calendar/_components/RoomSwatch.tsx @@ -1,8 +1,4 @@ -import { - CABIN_COLORS, - getCabinColor, - getCabinColorObject, -} from '../_util/cabinColors'; +import { getCabinColor, getCabinColorObject } from '../_util/cabinColors'; import { clmx } from '@/util/classConcat'; export default function RoomSwatch({ diff --git a/client/src/app/calendar/_components/SampleCal.tsx b/client/src/app/calendar/_components/SampleCal.tsx index f087266..0816011 100644 --- a/client/src/app/calendar/_components/SampleCal.tsx +++ b/client/src/app/calendar/_components/SampleCal.tsx @@ -116,6 +116,13 @@ const days: { datetime: '2022-01-22T19:00', href: '#', }, + { + id: 6, + name: 'Hockey game', + time: '7PM', + datetime: '2022-01-22T19:00', + href: '#', + }, ], }, { date: '2022-01-23', isCurrentMonth: true, events: [] }, @@ -161,6 +168,7 @@ export function SampleMonth() { return (
+ {/* controls */}

@@ -318,7 +326,10 @@ export function SampleMonth() {

+ + {/* calendar */}
+ {/* days header */}
Mon @@ -343,6 +354,7 @@ export function SampleMonth() {
+ {/* full size calendar days */}
{days.map((day) => (
))}
+ + {/* mobile calendar days */}
{days.map((day, dayIndex) => (
+ + {/* selected day's events */} {selectedDay?.events.length ? (
    diff --git a/client/src/app/calendar/_components/TimelineEvent.tsx b/client/src/app/calendar/_components/TimelineEvent.tsx index 5dbe277..6559fbf 100644 --- a/client/src/app/calendar/_components/TimelineEvent.tsx +++ b/client/src/app/calendar/_components/TimelineEvent.tsx @@ -31,7 +31,7 @@ export default function TimelineEvent({ highlightRoom?: string; placeholder?: EventPlaceholder; onOpen?: () => void; -} & CalendarProps) { +} & Pick) { const { dates: dateLimits, days } = props; // get correct color scheme diff --git a/client/src/app/calendar/_components/TimelineHeader.tsx b/client/src/app/calendar/_components/TimelineHeader.tsx index 81a618c..a6164d9 100644 --- a/client/src/app/calendar/_components/TimelineHeader.tsx +++ b/client/src/app/calendar/_components/TimelineHeader.tsx @@ -3,7 +3,14 @@ import { dateFormat, dateTS, useDatesArray } from '../_util/dateUtils'; import { gridCols } from '../_util/grid'; import { Children } from '@/util/propTypes'; -export default function TimelineHeader({ ...props }: CalendarProps) { +type TimelineHeaderProps = Pick & { + updatePeriod?: CalendarProps['updatePeriod']; + noDate?: boolean; +}; +export default function TimelineHeader({ + noDate, + ...props +}: TimelineHeaderProps) { const { days, updatePeriod } = props; const gridTemplateColumns = gridCols(days); @@ -15,16 +22,22 @@ export default function TimelineHeader({ ...props }: CalendarProps) { {dates.map((date) => ( ))} diff --git a/client/src/app/calendar/_util/cabinColors.ts b/client/src/app/calendar/_util/cabinColors.ts index 16125cd..0ea768b 100644 --- a/client/src/app/calendar/_util/cabinColors.ts +++ b/client/src/app/calendar/_util/cabinColors.ts @@ -96,17 +96,36 @@ export function getCabinColorObject( return CABIN_COLORS[key]; } -export function useEventColorId(event: EventType) { - const id = useMemo(() => { - if (event.reservations.length) { - const r = event.reservations[0]; - if (r.room && 'id' in r.room) { - let c = getCabinColor(r.room.id); - if (c) return r.room.id; - c = getCabinColor(r.room.cabin?.id); - if (c) return r.room.cabin?.id; +export function useEventColorIds(events: EventType[]) { + const ids = useMemo(() => { + const out: Record = {}; + + for (const event of events) { + if (event.reservations.length) { + const r = event.reservations[0]; + if (r.room && 'id' in r.room) { + let c = getCabinColor(r.room.id); + if (c) { + out[event.id] = r.room.id; + continue; + } + c = getCabinColor(r.room.cabin?.id); + if (c) { + out[event.id] = r.room.cabin?.id; + continue; + } + } } + + out[event.id] = undefined; } - }, [event.reservations]); - return id; + + return out; + }, [events]); + return ids; +} + +export function useEventColorId(event: EventType) { + const ids = useEventColorIds([event]); + return ids[event.id]; } diff --git a/client/src/app/calendar/_util/controls.ts b/client/src/app/calendar/_util/controls.ts index d669a1e..79548d7 100644 --- a/client/src/app/calendar/_util/controls.ts +++ b/client/src/app/calendar/_util/controls.ts @@ -5,18 +5,18 @@ import { useDefaultDays } from './defaultDays'; import { D1, dateStartOfWeek, dateTS } from './dateUtils'; export function useCalendarControls(props: CalendarProps) { - const { updatePeriod, days, dates } = props; + const { updatePeriod, days, selectedDate } = props; const defaultDays = useDefaultDays(); const daysWithDefault = days ?? defaultDays; const last = useCallback( - () => updatePeriod(dates.start - D1 * daysWithDefault), - [dates.start, daysWithDefault, updatePeriod], + () => updatePeriod(selectedDate - D1 * daysWithDefault), + [selectedDate, daysWithDefault, updatePeriod], ); const next = useCallback( - () => updatePeriod(dates.start + D1 * daysWithDefault), - [dates.start, daysWithDefault, updatePeriod], + () => updatePeriod(selectedDate + D1 * daysWithDefault), + [selectedDate, daysWithDefault, updatePeriod], ); const today = useCallback(() => { let today = dateTS(new Date()); diff --git a/client/src/app/calendar/_util/dateUtils.ts b/client/src/app/calendar/_util/dateUtils.ts index 20ef3a8..6e518bc 100644 --- a/client/src/app/calendar/_util/dateUtils.ts +++ b/client/src/app/calendar/_util/dateUtils.ts @@ -19,6 +19,11 @@ export function dateTS(d: Date | number, isInputNotUTC: boolean = true) { return day.utc(isInputNotUTC).startOf('date').unix(); } +/** get dayjs object for a TS datestamp. */ +export function dateTSObject(ts: number) { + return dayjs.unix(ts).utc(); +} + export function showDate(d: Date | number) { const date = d instanceof Date ? dayjs(d) : dayjs.unix(d); return date.utc().format('YYYY-MM-DD'); diff --git a/client/src/app/calendar/_util/displayByRooms.ts b/client/src/app/calendar/_util/displayByRooms.ts index 347db5e..903b51e 100644 --- a/client/src/app/calendar/_util/displayByRooms.ts +++ b/client/src/app/calendar/_util/displayByRooms.ts @@ -27,6 +27,7 @@ export function useDisplayByRooms() { const VIEWS = { TIMELINE: true, AGENDA: true, + OVERVIEW: true, } as const; export type ViewType = keyof typeof VIEWS; const allViews = Object.keys(VIEWS) as ViewType[]; diff --git a/client/src/app/calendar/_util/eventsByDay.ts b/client/src/app/calendar/_util/eventsByDay.ts new file mode 100644 index 0000000..695af50 --- /dev/null +++ b/client/src/app/calendar/_util/eventsByDay.ts @@ -0,0 +1,84 @@ +import { useMemo } from 'react'; +import { CalendarProps, EventType } from '../_components/Calendar'; +import { alphabetical } from '@/util/sort'; +import { UseDatesArrayProps, useDatesArray } from './dateUtils'; + +export type EventsByDay = { + date: number; + count: number; + noChanges: boolean; + arrivals: EventType[]; + departures: EventType[]; + unchanged: EventType[]; +}; + +export function useEventsByDay any>( + props: Pick & UseDatesArrayProps, + processor: CB, + withDefault?: boolean, +): ReturnType[] | null; +export function useEventsByDay( + props: Pick & UseDatesArrayProps, + processor?: CB, + withDefault?: boolean, +): EventsByDay[] | null; +export function useEventsByDay( + props: Pick & UseDatesArrayProps, + processor?: CB, + withDefault?: boolean, +) { + type T = CB extends (e: EventsByDay) => any ? ReturnType : never; + + const { events: events_in } = props; + + const dates = useDatesArray(props); + + const eventsByDay = useMemo< + (T extends never ? EventsByDay : T)[] | null + >(() => { + if (!events_in) + return withDefault + ? dates.map((d) => { + const ad = EBD_DEFAULT(d); + if (typeof processor === 'function') return processor(ad); + return ad; + }) + : null; + const events = events_in.sort(alphabetical((s) => s.title)); + return dates.map((d) => { + let ad = { + date: d, + count: events.filter( + (event) => event.dateStart <= d && event.dateEnd >= d, + ).length, + arrivals: events.filter((event) => event.dateStart === d), + departures: events.filter((event) => event.dateEnd === d), + } as EventsByDay; + ad = { + ...ad, + unchanged: events.filter( + (event) => + event.dateStart <= d && + event.dateEnd > d && + !ad.arrivals.find((it) => it.id === event.id) && + !ad.departures.find((it) => it.id === event.id), + ), + noChanges: !ad.arrivals.length && !ad.departures.length, + }; + + if (typeof processor === 'function') return processor(ad); + return ad; + }); + }, [dates, events_in, processor, withDefault]); + + return eventsByDay; +} + +const EBD_DEFAULT: (d: number) => EventsByDay = (date) => ({ + date, + count: 0, + noChanges: false, + arrivals: [], + departures: [], + unchanged: [], +}); diff --git a/client/src/app/calendar/new/_components/FormHeader.tsx b/client/src/app/calendar/new/_components/FormHeader.tsx index 1146a3c..d3f8f6b 100644 --- a/client/src/app/calendar/new/_components/FormHeader.tsx +++ b/client/src/app/calendar/new/_components/FormHeader.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; +import { MenuButton } from '@headlessui/react'; import { ActionIcon } from '@mantine/core'; import { IconDotsVertical, @@ -9,10 +9,13 @@ import { IconTrash, } from '@tabler/icons-react'; -import { IconType } from '@/util/iconType'; -import { clmx, clx } from '@/util/classConcat'; import { useFormCtx } from '../state/formCtx'; import { useFormActions } from './FormActions'; +import { + Dropdown, + DropdownItems, + DropdownOption, +} from '@/app/_components/_base/Dropdown'; export default function FormHeader() { const { eventText, updateId } = useFormCtx(); @@ -28,7 +31,7 @@ export default function FormHeader() { {eventText.title}
- + {/* dropdown button */}
@@ -37,34 +40,34 @@ export default function FormHeader() {
{/* dropdown menu */} - +
- - +
- +
-
-
+ +
)} @@ -73,31 +76,6 @@ export default function FormHeader() { // ---------------------------------------- -function Option({ - icon: Icon, - className, - children, - ...props -}: { icon: IconType } & JSX.IntrinsicElements['button']) { - return ( - - - - ); -} - function RestoreScroll() { useEffect(() => { const cb = () =>