diff --git a/package-lock.json b/package-lock.json index 8a54b08c..c8ea6940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,7 @@ "@emotion/react": "^11.14.0", "@emotion/server": "^11.11.0", "@fontsource-variable/inter": "^5.3.0", - "@fullcalendar/core": "^6.1.21", - "@fullcalendar/interaction": "^6.1.21", - "@fullcalendar/react": "^6.1.21", - "@fullcalendar/timegrid": "^6.1.21", + "@fullcalendar/react": "^7.0.1", "@react-router/node": "^8.2.0", "@rjsf/chakra-ui": "^6.7.0", "@rjsf/core": "^6.7.0", @@ -33,8 +30,9 @@ "react-dom": "^19.2.8", "react-icons": "^5.7.0", "react-router": "^8.2.0", - "rrule": "^2.8.1", + "rrule-temporal": "^1.5.3", "smol-toml": "^1.7.0", + "temporal-polyfill": "^1.0.1", "timezones-ical-library": "^2.3.0" }, "devDependencies": { @@ -1674,54 +1672,38 @@ "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@fullcalendar/core": { - "version": "6.1.21", - "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz", - "integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==", - "license": "MIT", - "dependencies": { - "preact": "~10.12.1" - } - }, - "node_modules/@fullcalendar/daygrid": { - "version": "6.1.21", - "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.21.tgz", - "integrity": "sha512-QYb1y40RGYLlOxKpYWg8O+7njEnKnFG8Tt7qjnubJGR35s1phQg67E+81y2TyAbbm59p2JFOCXGDk9t6KDujIA==", + "node_modules/@full-ui/headless-calendar": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@full-ui/headless-calendar/-/headless-calendar-7.0.1.tgz", + "integrity": "sha512-ridxaPRKiSzfeXkk8kta1wC9asYqPWXf4q0KbUW5hRvWQ1rkxeUbhBarcMEOFC7FvJHczPSAm0JzSrXv/ykuKw==", "license": "MIT", "peerDependencies": { - "@fullcalendar/core": "~6.1.21" + "temporal-polyfill": "^1.0.1" } }, - "node_modules/@fullcalendar/interaction": { - "version": "6.1.21", - "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.21.tgz", - "integrity": "sha512-WPYpqtljDWmU0Xm2cOtFrLlocgxv7cgkOppj34Q6OUUat8a6Cnd6kYo2JR+irP223PE5lBYHFNp1qh7SIpJc0w==", + "node_modules/@fullcalendar/core": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-7.0.1.tgz", + "integrity": "sha512-SRJT4lSRZZGVCz6ss48pFCNiIS7iutpQVID73GFQufH94MTYs+akCW9aKCjMwsnEWad5UV7Mcm4xndFQ4dDi0g==", "license": "MIT", "peerDependencies": { - "@fullcalendar/core": "~6.1.21" + "@full-ui/headless-calendar": "7.0.1", + "temporal-polyfill": "^1.0.1" } }, "node_modules/@fullcalendar/react": { - "version": "6.1.21", - "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.21.tgz", - "integrity": "sha512-TLpmGUd5k/PMdCh8XbeFC9PW9wuGvMms1oCxWgXyjK3EFPXAAd0PLfcvwKdyxoAS5eK1E4RJFkjMHvsYHpimcg==", - "license": "MIT", - "peerDependencies": { - "@fullcalendar/core": "~6.1.21", - "react": "^16.7.0 || ^17 || ^18 || ^19", - "react-dom": "^16.7.0 || ^17 || ^18 || ^19" - } - }, - "node_modules/@fullcalendar/timegrid": { - "version": "6.1.21", - "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.21.tgz", - "integrity": "sha512-2DnShx/jallGmb8QCkr6pAOu/zuPhJrP7+uTrAtSnbqsX7GF3lTxqSeNGkTQwsgF5g/ia8udhQ+JNYaE+TN1cQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-7.0.1.tgz", + "integrity": "sha512-R+/YnZVr82eJy3qeA94Kj1xjNta3MU+xkHAKuqTVCriNxUusjaLIskh7bcRt7TJHsoT3OmdSCBgw5fjXjIC9vQ==", "license": "MIT", "dependencies": { - "@fullcalendar/daygrid": "~6.1.21" + "@full-ui/headless-calendar": "7.0.1", + "@fullcalendar/core": "7.0.1" }, "peerDependencies": { - "@fullcalendar/core": "~6.1.21" + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19", + "temporal-polyfill": "^1.0.1" } }, "node_modules/@humanfs/core": { @@ -1854,6 +1836,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-temporal/polyfill": { + "name": "temporal-polyfill", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-1.0.1.tgz", + "integrity": "sha512-N2SoI9olnW7BUsU8RosDphZQl9s+WJ8O7PoJMFCr/e5/1rFkVI4GNOWaSeySG+UoP04foPYsnLWbJmbXOiShZg==", + "license": "MIT", + "dependencies": { + "temporal-spec": "1.0.0", + "temporal-utils": "1.0.1" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", @@ -6574,16 +6567,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/preact": { - "version": "10.12.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", - "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6930,13 +6913,13 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, - "node_modules/rrule": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", - "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", - "license": "BSD-3-Clause", + "node_modules/rrule-temporal": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-1.5.3.tgz", + "integrity": "sha512-qALnXyu4MKNUeykkkO0r6Xxl5or3rM8Cf6ibKIe/29sgmq3tGm1oNq4G1Ddp8Ku3mnKmvC3+3yFAJ3OgOu6OJw==", + "license": "MIT", "dependencies": { - "tslib": "^2.4.0" + "@js-temporal/polyfill": "^0.5.1" } }, "node_modules/run-parallel": { @@ -7131,6 +7114,28 @@ "dev": true, "license": "MIT" }, + "node_modules/temporal-polyfill": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-1.0.1.tgz", + "integrity": "sha512-N2SoI9olnW7BUsU8RosDphZQl9s+WJ8O7PoJMFCr/e5/1rFkVI4GNOWaSeySG+UoP04foPYsnLWbJmbXOiShZg==", + "license": "MIT", + "dependencies": { + "temporal-spec": "1.0.0", + "temporal-utils": "1.0.1" + } + }, + "node_modules/temporal-spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-1.0.0.tgz", + "integrity": "sha512-00Ahj1e1ifaERTMOIIGpOCdOo9IEk2m6GGSMedsn9a2SIsGLdOTbmME1Htv6IM82b6VHrzSUTIVc7YHy6hdhFQ==", + "license": "Apache-2.0" + }, + "node_modules/temporal-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/temporal-utils/-/temporal-utils-1.0.1.tgz", + "integrity": "sha512-HAixuesxFQIUaQk3ptX2jhfO/FsOkgVkDDMawvp6n/fkB1q6BKfs3lURw9I+pK/2e2e/q/vrLrxmeqauBoyGMQ==", + "license": "MIT" + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/package.json b/package.json index b7f145a6..1cd74fbc 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,7 @@ "@emotion/react": "^11.14.0", "@emotion/server": "^11.11.0", "@fontsource-variable/inter": "^5.3.0", - "@fullcalendar/core": "^6.1.21", - "@fullcalendar/interaction": "^6.1.21", - "@fullcalendar/react": "^6.1.21", - "@fullcalendar/timegrid": "^6.1.21", + "@fullcalendar/react": "^7.0.1", "@react-router/node": "^8.2.0", "@rjsf/chakra-ui": "^6.7.0", "@rjsf/core": "^6.7.0", @@ -30,8 +27,9 @@ "react-dom": "^19.2.8", "react-icons": "^5.7.0", "react-router": "^8.2.0", - "rrule": "^2.8.1", + "rrule-temporal": "^1.5.3", "smol-toml": "^1.7.0", + "temporal-polyfill": "^1.0.1", "timezones-ical-library": "^2.3.0" }, "scripts": { @@ -69,5 +67,8 @@ "typescript-eslint": "^8.65.0", "vite": "^8.1.5", "vitest": "^4.1.10" + }, + "overrides": { + "@js-temporal/polyfill": "npm:temporal-polyfill@^1.0.1" } } diff --git a/src/components/Calendar.css b/src/components/Calendar.css deleted file mode 100644 index b69ae34c..00000000 --- a/src/components/Calendar.css +++ /dev/null @@ -1,67 +0,0 @@ -.fc .fc-scrollgrid, -.fc .fc-scrollgrid-section > td, -.fc .fc-timegrid-axis { - border-color: transparent; -} - -.fc .fc-timegrid-slot { - border-color: var(--chakra-colors-border-emphasized); - border-width: 1.5px; - border-left-color: transparent; -} - -.fc td, -.fc th { - border-color: var(--chakra-colors-border-emphasized); - border-width: 1.5px; -} - -.fc .fc-scrollgrid-section > th:nth-child(1), -.fc .fc-col-header-cell:last-child { - border-right-color: transparent; -} - -.fc .fc-scrollgrid-section-sticky > * { - background: transparent; -} - -.fc .fc-col-header-cell .fc-scrollgrid-sync-inner { - background: var(--chakra-colors-bg); -} - -.fc .fc-timegrid-event-harness-inset .fc-timegrid-event { - box-shadow: var(--chakra-colors-bg) 0px 0px 0px 1.5px; -} - -.fc .fc-scroller-harness { - overflow: visible; -} - -.fc .fc-timegrid-slot-label { - border: none; -} - -.fc .fc-timegrid-slot-label-frame { - position: relative; -} - -.fc .fc-timegrid-slot-label-cushion { - font-size: 0.75rem; - line-height: 1.3; - opacity: 0.7; - position: absolute; - top: -1.25rem; - right: 0.25rem; -} - -.fc .fc-timegrid-event { - border-radius: 0; - border-width: 0; - padding-left: 0.25rem; -} - -.fc .fc-col-header-cell-cushion { - font-size: 0.8rem; - letter-spacing: 0.05rem; - text-transform: uppercase; -} diff --git a/src/components/Calendar.module.css b/src/components/Calendar.module.css new file mode 100644 index 00000000..2d08bc3a --- /dev/null +++ b/src/components/Calendar.module.css @@ -0,0 +1,134 @@ +:global(:root) { + /* primary */ + --fc-monarch-primary: rgb(65 95 145); + --fc-monarch-primary-foreground: rgb(255 255 255); + --fc-monarch-primary-over: #526e9c; + --fc-monarch-primary-down: #647ea7; + + /* secondary */ + --fc-monarch-secondary: rgb(214 227 255); + --fc-monarch-secondary-foreground: rgb(40 71 119); + --fc-monarch-secondary-over: #c9d6f2; + --fc-monarch-secondary-down: #bdc9e5; + + /* tertiary */ + --fc-monarch-tertiary: rgb(133 93 140); + --fc-monarch-tertiary-foreground: rgb(255 255 255); + --fc-monarch-tertiary-over: #916c97; + --fc-monarch-tertiary-down: #9d7ca2; + + /* calendar content */ + --fc-monarch-event: var(--fc-monarch-primary); + --fc-monarch-event-contrast: var(--fc-monarch-primary-foreground); + --fc-monarch-highlight: #d6e3ff4d; + --fc-monarch-now: rgb(186 26 26); + + /* controls */ + --fc-monarch-selected: rgb(86 95 113); + --fc-monarch-selected-foreground: rgb(255 255 255); + --fc-monarch-selected-over: #656e7e; + --fc-monarch-selected-down: #757d8c; + --fc-monarch-outline: #6687ff; + + /* popover */ + --fc-monarch-popover: var(--fc-monarch-background); + + /* neutral backgrounds */ + --fc-monarch-background: var(--chakra-colors-bg); + --fc-monarch-faint: var(--chakra-colors-bg-subtle); + --fc-monarch-muted: var(--chakra-colors-bg-muted); + --fc-monarch-strong: var(--chakra-colors-bg-emphasized); + --fc-monarch-stronger: var(--chakra-colors-bg-solid); + --fc-monarch-strongest: var(--chakra-colors-bg-focus-ring); + + /* neutral foregrounds */ + --fc-monarch-foreground: var(--chakra-colors-fg); + --fc-monarch-faint-foreground: var(--chakra-colors-fg-subtle); + --fc-monarch-muted-foreground: var(--chakra-colors-fg-muted); + + /* neutral borders */ + --fc-monarch-border: var(--chakra-colors-border-emphasized); + --fc-monarch-strong-border: var(--chakra-colors-border-inverted); +} + +@media not print { + :global(.dark) { + /* primary */ + --fc-monarch-primary: rgb(170 199 255); + --fc-monarch-primary-foreground: rgb(10 48 95); + --fc-monarch-primary-over: #b2cdff; + --fc-monarch-primary-down: #bbd2ff; + + /* secondary */ + --fc-monarch-secondary: rgb(40 71 119); + --fc-monarch-secondary-foreground: rgb(214 227 255); + --fc-monarch-secondary-over: #32507e; + --fc-monarch-secondary-down: #3c5885; + + /* tertiary */ + --fc-monarch-tertiary: rgb(221 188 224); + --fc-monarch-tertiary-foreground: rgb(63 40 68); + --fc-monarch-tertiary-over: #e0c3e3; + --fc-monarch-tertiary-down: #e4c9e6; + + /* calendar content */ + --fc-monarch-event: var(--fc-monarch-primary); + --fc-monarch-event-contrast: var(--fc-monarch-primary-foreground); + --fc-monarch-highlight: #2847774d; + --fc-monarch-now: rgb(255 180 171); + + /* controls */ + --fc-monarch-selected: rgb(190 198 220); + --fc-monarch-selected-foreground: rgb(40 49 65); + --fc-monarch-selected-over: #c4cce0; + --fc-monarch-selected-down: #cbd1e3; + + /* popover */ + --fc-monarch-popover: rgb(17 19 24); + --fc-monarch-popover-foreground: rgb(226 226 233); + } +} + +/* THIS ONE */ +.fc-slot-header-inner { + line-height: 0.1rem; + padding-block: 0.2rem; + opacity: 0.7; + font-size: 0.75rem; + position: relative; + top: -0.75rem; +} + +.fc-day-header-inner { + font-size: 0.8rem; + letter-spacing: 0.05rem; +} + +.fc-event { + cursor: pointer; +} + +.fc-event-inner { + padding-top: 0.25rem; + padding-inline-start: 0.5rem; +} + +.fc-event-inner > * { + padding-block: 0rem; +} + +.fc-day-lane { + border-width: 1.5px; +} + +.fc-slot-lane { + border-width: 1.5px; +} + +.fc-slot-header { + border-width: 1.5px; +} + +.fc-slot-header-divider { + border-inline-end-width: 1.5px; +} diff --git a/src/components/Calendar.tsx b/src/components/Calendar.tsx index e19c9183..01a545c9 100644 --- a/src/components/Calendar.tsx +++ b/src/components/Calendar.tsx @@ -1,19 +1,24 @@ import { useContext, useMemo } from "react"; -import { Box, Circle, Float, Text } from "@chakra-ui/react"; +import { Circle, Float, Text } from "@chakra-ui/react"; import { Tooltip } from "./ui/tooltip"; -import FullCalendar from "@fullcalendar/react"; -import type { EventContentArg, EventApi } from "@fullcalendar/core"; -import timeGridPlugin from "@fullcalendar/timegrid"; -import interactionPlugin from "@fullcalendar/interaction"; +import FullCalendar, { + type EventDisplayInfo, + type EventApi, +} from "@fullcalendar/react"; +import themePlugin from "@fullcalendar/react/themes/monarch"; +import timeGridPlugin from "@fullcalendar/react/timegrid"; +import interactionPlugin from "@fullcalendar/react/interaction"; import type { Activity } from "../lib/activity"; import { CustomActivity, Timeslot } from "../lib/activity"; import { Slot } from "../lib/dates"; import { HydrantContext } from "../lib/hydrant"; -import "./Calendar.css"; +import "@fullcalendar/react/skeleton.css"; +import "@fullcalendar/react/themes/monarch/theme.css"; +import styles from "./Calendar.module.css"; // Threshold at which to display a distance warning, in feet (650 meters) const DISTANCE_WARNING_THRESHOLD = 2112; @@ -21,6 +26,9 @@ const DISTANCE_WARNING_THRESHOLD = 2112; // Walking speed, in ft/s (~3 mph) const WALKING_SPEED = 4.4; +// User's timezone (for converting between Date and Temporal.PlainDateTime) +const USER_TZ = Temporal.Now.timeZoneId(); + /** * Calendar showing all the activities, including the buttons on top that * change the schedule option selected. @@ -71,7 +79,14 @@ export function Calendar() { if (!beforeEvent.start || !beforeEvent.room) { continue; } - if (thisEvent.start.getTime() != beforeEvent.end.getTime()) { + if ( + Temporal.Instant.compare( + thisEvent.start.toTemporalInstant(), + Temporal.PlainDateTime.from(beforeEvent.end) + .toZonedDateTime(USER_TZ) + .toInstant(), + ) !== 0 + ) { continue; } @@ -100,59 +115,60 @@ export function Calendar() { return undefined; }; - const renderEvent = ({ event }: EventContentArg) => { + const renderEvent = ({ + event, + titleClass, + timeClass, + isNarrow, + isShort, + }: EventDisplayInfo) => { + const room = event.extendedProps.room as string | undefined; + const activity = event.extendedProps.activity as Activity; + const distanceWarning = getDistanceWarning(event); + const smallText = isNarrow || isShort; + const TitleText = () => ( {event.title} ); - const room = event.extendedProps.room as string | undefined; - const activity = event.extendedProps.activity as Activity; - const distanceWarning = getDistanceWarning(event); + const RoomText = () => ( + + {room} + + ); return ( <> - - {!(activity instanceof CustomActivity) ? ( - - {TitleText()} - - ) : ( - - )} - {event.extendedProps.roomClarification ? ( - - {room} - - ) : ( - {room} - )} - + {!(activity instanceof CustomActivity) ? ( + + {TitleText()} + + ) : ( + + )} + {event.extendedProps.roomClarification ? ( + + {RoomText()} + + ) : ( + + )} {distanceWarning ? ( - + ! @@ -175,12 +194,15 @@ export function Calendar() { return ( { // extendedProps: non-standard props of {@link Event.eventInputs} @@ -188,23 +210,36 @@ export function Calendar() { }} headerToolbar={false} height="auto" - // a date that is, conveniently enough, a monday - initialDate="2001-01-01" - slotDuration="00:30:00" - slotLabelFormat={({ date }) => { - const { hour } = date; + eventShortHeight={30} + initialDate={(() => { + const now = Temporal.Now.plainDateISO(); + return now.subtract({ days: now.dayOfWeek - 1 }).toString(); + })()} + slotDuration={Temporal.Duration.from({ minutes: 30 })} + slotHeaderContent={({ time }) => { + const milliseconds = time?.milliseconds ?? 0; + const hour = Temporal.Duration.from({ milliseconds }).total({ + unit: "hour", + }); return hour === 12 ? "noon" : hour < 12 ? `${hour.toString()} AM` : `${(hour - 12).toString()} PM`; }} + slotHeaderInnerClass={styles["fc-slot-header-inner"]} + slotHeaderDividerClass={styles["fc-slot-header-divider"]} + dayLaneClass={styles["fc-day-lane"]} + slotLaneClass={styles["fc-slot-lane"]} + slotHeaderClass={styles["fc-slot-header"]} + dayHeaderContent={({ text }) => text.toLocaleUpperCase()} + dayHeaderInnerClass={styles["fc-day-header-inner"]} slotMinTime={ - events.some((e) => (e.start as Date).getHours() < 8) - ? "06:00:00" - : "08:00:00" + events.some((e) => Temporal.PlainDateTime.from(e.start).hour < 8) + ? Temporal.Duration.from({ hours: 6 }) + : Temporal.Duration.from({ hours: 8 }) } - slotMaxTime="22:00:00" + slotMaxTime={Temporal.Duration.from({ hours: 22 })} weekends={false} selectable={viewedActivity instanceof CustomActivity} select={(e) => { @@ -212,8 +247,18 @@ export function Calendar() { state.addTimeslot( viewedActivity, Timeslot.fromStartEnd( - Slot.fromStartDate(e.start), - Slot.fromStartDate(e.end), + Slot.fromStartDate( + e.start + .toTemporalInstant() + .toZonedDateTimeISO(USER_TZ) + .toPlainDateTime(), + ), + Slot.fromStartDate( + e.end + .toTemporalInstant() + .toZonedDateTimeISO(USER_TZ) + .toPlainDateTime(), + ), ), ); } diff --git a/src/components/SelectedActivities.tsx b/src/components/SelectedActivities.tsx index f37cf528..a1b20893 100644 --- a/src/components/SelectedActivities.tsx +++ b/src/components/SelectedActivities.tsx @@ -12,12 +12,19 @@ export function ColorButton( props: ComponentPropsWithoutRef<"button"> & { color: string }, ) { const { children, color, style, ...otherProps } = props; + const contractColor = textColor(color); return (