diff --git a/package-lock.json b/package-lock.json index be2a30ec..3aef1850 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.2.8", - "@fullcalendar/core": "^6.1.20", - "@fullcalendar/interaction": "^6.1.20", - "@fullcalendar/react": "^6.1.20", - "@fullcalendar/timegrid": "^6.1.20", + "@fullcalendar/react": "7.0.0-rc.1", "@react-router/node": "^7.14.1", "@rjsf/chakra-ui": "^6.4.2", "@rjsf/core": "^6.4.2", @@ -33,8 +30,9 @@ "react-dom": "^19.2.4", "react-icons": "^5.6.0", "react-router": "^7.14.1", - "rrule": "^2.8.1", + "rrule-temporal": "^1.5.2", "smol-toml": "^1.6.1", + "temporal-polyfill": "^0.3.2", "timezones-ical-library": "^2.1.3" }, "devDependencies": { @@ -1752,54 +1750,38 @@ "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@fullcalendar/core": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", - "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", - "license": "MIT", - "dependencies": { - "preact": "~10.12.1" - } - }, - "node_modules/@fullcalendar/daygrid": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", - "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==", + "node_modules/@full-ui/headless-calendar": { + "version": "7.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@full-ui/headless-calendar/-/headless-calendar-7.0.0-rc.1.tgz", + "integrity": "sha512-JoecyBKMyF0OhpfH2wHAE3eSwTbK183CS+RkaB0KLXA7nS2Tf+obBGVz+kWsil9Ov0x41F7d96S27TnzvaO/UQ==", "license": "MIT", "peerDependencies": { - "@fullcalendar/core": "~6.1.20" + "temporal-polyfill": "^0.3.2" } }, - "node_modules/@fullcalendar/interaction": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz", - "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==", + "node_modules/@fullcalendar/core": { + "version": "7.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-7.0.0-rc.1.tgz", + "integrity": "sha512-ke+z94EGR747DwXd6pgYxQhAQ/xjEEARjj6Nx4e3Y9s6L9PFNcdLNMxQr6QPRcgdQ2ipaWDREnPenH3tcak2qQ==", "license": "MIT", "peerDependencies": { - "@fullcalendar/core": "~6.1.20" + "@full-ui/headless-calendar": "7.0.0-rc.1", + "temporal-polyfill": "^0.3.2" } }, "node_modules/@fullcalendar/react": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.20.tgz", - "integrity": "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==", - "license": "MIT", - "peerDependencies": { - "@fullcalendar/core": "~6.1.20", - "react": "^16.7.0 || ^17 || ^18 || ^19", - "react-dom": "^16.7.0 || ^17 || ^18 || ^19" - } - }, - "node_modules/@fullcalendar/timegrid": { - "version": "6.1.20", - "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz", - "integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==", + "version": "7.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-7.0.0-rc.1.tgz", + "integrity": "sha512-/oMniktNyhuyfrVklN4QRKjvfMFR2kAqYeThFVjb8nfdF9EolN23ZkYnV2RU9n56KFg/zPOy2f0pdIDzn0kR3w==", "license": "MIT", "dependencies": { - "@fullcalendar/daygrid": "~6.1.20" + "@full-ui/headless-calendar": "7.0.0-rc.1", + "@fullcalendar/core": "7.0.0-rc.1" }, "peerDependencies": { - "@fullcalendar/core": "~6.1.20" + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19", + "temporal-polyfill": "^0.3.2" } }, "node_modules/@humanfs/core": { @@ -1918,6 +1900,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@mjackson/node-fetch-server": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz", @@ -6222,6 +6216,12 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, "node_modules/jsdom": { "version": "29.0.2", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", @@ -7221,16 +7221,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", @@ -7613,13 +7603,13 @@ "fsevents": "~2.3.2" } }, - "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.2", + "resolved": "https://registry.npmjs.org/rrule-temporal/-/rrule-temporal-1.5.2.tgz", + "integrity": "sha512-I5rAiZfRlMh0vuG23HrGBMLZOSiQO7H1Uq8l9qyfA6oTD5j+UMRwpRs4aVU4XdaFhgN1p3K+cHelG8KvLTTm+g==", + "license": "MIT", "dependencies": { - "tslib": "^2.4.0" + "@js-temporal/polyfill": "^0.5.1" } }, "node_modules/run-parallel": { @@ -7820,6 +7810,21 @@ "dev": true, "license": "MIT" }, + "node_modules/temporal-polyfill": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-0.3.2.tgz", + "integrity": "sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==", + "license": "MIT", + "dependencies": { + "temporal-spec": "0.3.1" + } + }, + "node_modules/temporal-spec": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/temporal-spec/-/temporal-spec-0.3.1.tgz", + "integrity": "sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==", + "license": "ISC" + }, "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 c9cd205e..96e62a7e 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.2.8", - "@fullcalendar/core": "^6.1.20", - "@fullcalendar/interaction": "^6.1.20", - "@fullcalendar/react": "^6.1.20", - "@fullcalendar/timegrid": "^6.1.20", + "@fullcalendar/react": "7.0.0-rc.1", "@react-router/node": "^7.14.1", "@rjsf/chakra-ui": "^6.4.2", "@rjsf/core": "^6.4.2", @@ -30,8 +27,9 @@ "react-dom": "^19.2.4", "react-icons": "^5.6.0", "react-router": "^7.14.1", - "rrule": "^2.8.1", + "rrule-temporal": "^1.5.2", "smol-toml": "^1.6.1", + "temporal-polyfill": "^0.3.2", "timezones-ical-library": "^2.1.3" }, "scripts": { 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..6643900a --- /dev/null +++ b/src/components/Calendar.module.css @@ -0,0 +1,116 @@ +: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; +} + +.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; +} diff --git a/src/components/Calendar.tsx b/src/components/Calendar.tsx index e19c9183..72ffa416 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,32 @@ 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"]} + 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 +243,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 (