From caff9cb5a9a7cb870e789f017111f8beec28f1a0 Mon Sep 17 00:00:00 2001 From: Kevin Matthews <49137025+kr-matthews@users.noreply.github.com> Date: Wed, 7 Feb 2024 02:53:11 -0800 Subject: [PATCH] Schedule calendar view (#452) * Add fullcalendar dependencies * Add initial (flawed, incomplete) calendar view * Set calendar start/end times * Prevent height change on room de-select * Fix start date change bug * lint --- Frontend/package.json | 3 + Frontend/src/lib/activities.ts | 56 ++++++++++++ Frontend/src/lib/dates.ts | 32 +++++++ Frontend/src/pages/schedule/CalendarView.jsx | 94 +++++++++++++++++++- Frontend/src/pages/schedule/Schedule.jsx | 5 +- Frontend/src/pages/schedule/TableView.jsx | 6 +- 6 files changed, 187 insertions(+), 9 deletions(-) diff --git a/Frontend/package.json b/Frontend/package.json index 21977d0b..b9fd99b2 100644 --- a/Frontend/package.json +++ b/Frontend/package.json @@ -23,6 +23,9 @@ }, "dependencies": { "@dinero.js/currencies": "2.0.0-alpha.14", + "@fullcalendar/luxon3": "^6.1.10", + "@fullcalendar/react": "^6.1.10", + "@fullcalendar/timegrid": "^6.1.10", "@stripe/react-stripe-js": "2.4.0", "@stripe/stripe-js": "2.4.0", "@tanstack/react-query": "4.33.0", diff --git a/Frontend/src/lib/activities.ts b/Frontend/src/lib/activities.ts index a592bceb..5e693a99 100644 --- a/Frontend/src/lib/activities.ts +++ b/Frontend/src/lib/activities.ts @@ -1,4 +1,11 @@ import { Activity } from '@wca/helpers' +import { DateTime } from 'luxon' +import { + addEndBufferWithinDay, + doesRangeCrossMidnight, + roundBackToHour, + todayWithTime, +} from './dates' export const earliestWithLongestTieBreaker = (a: Activity, b: Activity) => { if (a.startTime < b.startTime) { @@ -47,3 +54,52 @@ export const getActivityEvent = (activity: Activity) => { export const getActivityRoundId = (activity: Activity) => { return activity.activityCode.split('-').slice(0, 2).join('-') } + +export const earliestTimeOfDayWithBuffer = ( + activities: Activity[], + timeZone: string +) => { + if (activities.length === 0) return undefined + + const doesAnyCrossMidnight = activities.some(({ startTime, endTime }) => + doesRangeCrossMidnight(startTime, endTime, timeZone) + ) + if (doesAnyCrossMidnight) { + return '00:00:00' + } + + const startTimes = activities.map(({ startTime }) => + todayWithTime(startTime, timeZone) + ) + return roundBackToHour(DateTime.min(...startTimes)).toISOTime({ + suppressMilliseconds: true, + includeOffset: false, + }) +} + +export const latestTimeOfDayWithBuffer = ( + activities: Activity[], + timeZone: string +) => { + if (activities.length === 0) return undefined + + const doesAnyCrossMidnight = activities.some(({ startTime, endTime }) => + doesRangeCrossMidnight(startTime, endTime, timeZone) + ) + if (doesAnyCrossMidnight) { + return '24:00:00' + } + + const endTimes = activities.map(({ endTime }) => + todayWithTime(endTime, timeZone) + ) + const result = addEndBufferWithinDay(DateTime.max(...endTimes)).toISOTime({ + suppressMilliseconds: true, + includeOffset: false, + }) + + if (result === '00:00:00') { + return '24:00:00' + } + return result +} diff --git a/Frontend/src/lib/dates.ts b/Frontend/src/lib/dates.ts index c357e86a..cf1140df 100644 --- a/Frontend/src/lib/dates.ts +++ b/Frontend/src/lib/dates.ts @@ -61,3 +61,35 @@ export const getLongDate = (date: string, timeZone: string) => { timeZone, }) } + +export const doesRangeCrossMidnight = ( + start: string, + end: string, + timeZone: string +) => { + const luxonStart = DateTime.fromISO(start).setZone(timeZone) + const luxonEnd = DateTime.fromISO(end).setZone(timeZone) + return luxonStart.day !== luxonEnd.day +} + +export const todayWithTime = (date: string, timeZone: string) => { + const luxonDate = DateTime.fromISO(date).setZone(timeZone) + return DateTime.utc().set({ + hour: luxonDate.hour, + minute: luxonDate.minute, + second: luxonDate.second, + millisecond: luxonDate.millisecond, + }) +} + +export const roundBackToHour = (date: DateTime) => { + return date.set({ minute: 0, second: 0, millisecond: 0 }) +} + +export const addEndBufferWithinDay = (date: DateTime) => { + const buffered = date.plus({ minutes: 10 }) + if (buffered.day !== date.day) { + return date + } + return buffered +} diff --git a/Frontend/src/pages/schedule/CalendarView.jsx b/Frontend/src/pages/schedule/CalendarView.jsx index b031da4b..23b59669 100644 --- a/Frontend/src/pages/schedule/CalendarView.jsx +++ b/Frontend/src/pages/schedule/CalendarView.jsx @@ -1,8 +1,94 @@ +import luxonPlugin from '@fullcalendar/luxon3' +import FullCalendar from '@fullcalendar/react' +import timeGridPlugin from '@fullcalendar/timegrid' import React from 'react' +import { + earliestTimeOfDayWithBuffer, + getActivityEvent, + latestTimeOfDayWithBuffer, +} from '../../lib/activities' +import { getTextColor } from '../../lib/colors' -// TODO +// based on monolith code: https://github.com/thewca/worldcubeassociation.org/blob/0882a86cf5d83c3a0dbc667a59be05ce8845c3e4/WcaOnRails/app/webpacker/components/EditSchedule/EditActivities/index.js -// { dates, timeZone, venuesShown, events } -export default function CalendarView() { - return
Calendar view does not exist yet.
+// TODO: make column date consistent with table view dates +// TODO: add tooltip or popup on events for more details +// TODO: set calendar's locale +// TODO: table has 24h, calendar has 12h - make consistent (fixed by setting locale?) +// TODO: indicate that event split across days are such? +// TODO: add add-to-calendar functionality? + +export default function CalendarView({ + dates, + timeZone, + activeVenues, + activeRooms, + activeEvents, +}) { + const activeEventIds = activeEvents.map(({ id }) => id) + const fcActivities = activeRooms.flatMap((room) => + room.activities + .filter((activity) => + ['other', ...activeEventIds].includes(getActivityEvent(activity)) + ) + .map((activity) => ({ + title: activity.name, + start: activity.startTime, + end: activity.endTime, + backgroundColor: room.color, + textColor: getTextColor(room.color), + })) + ) + + // independent of which activities are visible, + // to prevent calendar height jumping around + const activeVenuesActivities = activeVenues.flatMap((venue) => + venue.rooms.flatMap((room) => room.activities) + ) + const calendarStart = + earliestTimeOfDayWithBuffer(activeVenuesActivities, timeZone) ?? '00:00:00' + const calendarEnd = + latestTimeOfDayWithBuffer(activeVenuesActivities, timeZone) ?? '00:00:00' + + const onEventClick = () => { + /* TODO */ + } + + return ( + <> +