diff --git a/package.json b/package.json
index b108b85a..9579e4e2 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,10 @@
"@base-ui/react": "^1.5.0",
"@fontsource-variable/raleway": "^5.2.8",
"@icons-pack/react-simple-icons": "^13.13.0",
+ "@schedule-x/calendar": "^4.6.0",
+ "@schedule-x/react": "^4.1.0",
+ "@schedule-x/scroll-controller": "^4.6.0",
+ "@schedule-x/theme-shadcn": "^4.6.0",
"@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.8",
"axios": "^1.16.1",
@@ -23,7 +27,8 @@
"react": "^19.2.6",
"react-dom": "^19.2.6",
"sonner": "^2.0.7",
- "tailwind-merge": "^3.6.0"
+ "tailwind-merge": "^3.6.0",
+ "temporal-polyfill": "^0.3.2"
},
"devDependencies": {
"@rolldown/plugin-babel": "^0.2.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ebd6295d..19cf7c88 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,18 @@ importers:
'@icons-pack/react-simple-icons':
specifier: ^13.13.0
version: 13.13.0(react@19.2.6)
+ '@schedule-x/calendar':
+ specifier: ^4.6.0
+ version: 4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2)
+ '@schedule-x/react':
+ specifier: ^4.1.0
+ version: 4.1.0(@schedule-x/calendar@4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+ '@schedule-x/scroll-controller':
+ specifier: ^4.6.0
+ version: 4.6.0(@preact/signals@2.9.1(preact@10.29.2))
+ '@schedule-x/theme-shadcn':
+ specifier: ^4.6.0
+ version: 4.6.0
'@tanstack/react-query':
specifier: ^5.100.14
version: 5.100.14(react@19.2.6)
@@ -53,6 +65,9 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
+ temporal-polyfill:
+ specifier: ^0.3.2
+ version: 0.3.2
devDependencies:
'@rolldown/plugin-babel':
specifier: ^0.2.3
@@ -1106,8 +1121,8 @@ packages:
peerDependencies:
preact: '>= 10.25.0 || >=11.0.0-0'
- '@react-grab/cli@0.1.42':
- resolution: {integrity: sha512-yfK2tmRxCbigM5NQ3IW/1abTOUxhAkmpLopWLtdRGPfUw5liVyKjy5yb7VJZa9nj/v9RDl3+VfqgRmFASYJCdg==}
+ '@react-grab/cli@0.1.43':
+ resolution: {integrity: sha512-S+UBB0Mwu1g0Cjhff5YzsA33YFYu91DmazPL9uVvF1wDjsO2Lu9hKN/OzBCHYjWlxdBT7TLy2A6pNTS3Sx8o9w==}
hasBin: true
'@rolldown/binding-android-arm64@1.0.2':
@@ -1234,6 +1249,28 @@ packages:
rollup:
optional: true
+ '@schedule-x/calendar@4.6.0':
+ resolution: {integrity: sha512-8Rf5bUf0DVphJB2BaudtG4IHkw21wgMJiiuk5o+7SIOq6YH4hkVJo+Kk12pWSfp2KTZ9mnnUuLV+szBVhiEPlg==}
+ peerDependencies:
+ '@preact/signals': ^2.0.2
+ preact: ^10.19.2
+ temporal-polyfill: 0.3.0
+
+ '@schedule-x/react@4.1.0':
+ resolution: {integrity: sha512-l2hn1kdTModk4Lbt+nEr/VAcuNMA+w+hAAtgz8VLOj5UeieM7PWoK+cDapzPHCjUKisCSPd8QZfAX966Ma82kQ==}
+ peerDependencies:
+ '@schedule-x/calendar': ^3.1.0 || ^4.0.0
+ react: ^16.7.0 || ^17 || ^18 || ^19
+ react-dom: ^16.7.0 || ^17 || ^18 || ^19
+
+ '@schedule-x/scroll-controller@4.6.0':
+ resolution: {integrity: sha512-bJsNrwdX3KD6/vsovZgCNeGBboa++RgXg6ZJTKEAFOcNb/SeMYf4EhX66+pEhCe0JPfvzjoWEBpSj0fSCcoUcg==}
+ peerDependencies:
+ '@preact/signals': ^2.0.2
+
+ '@schedule-x/theme-shadcn@4.6.0':
+ resolution: {integrity: sha512-MwEBne7fPeN88Xa1mXxPQFkVIqs7Xi0zWMIHyppCgrPncfhDt1Gid4I7GqjsB3SnOyHWLbb5sL5Ab2ZaQesgtQ==}
+
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -2921,8 +2958,8 @@ packages:
peerDependencies:
react: ^19.2.6
- react-grab@0.1.42:
- resolution: {integrity: sha512-U4a0UXnuB0oJm/1c2aSjFCuifVlkWB4lStrMEh1V4iWsvhZln69Sf4ISH5pJah3N27zjiCc+CGcla9wZp9Hc6A==}
+ react-grab@0.1.43:
+ resolution: {integrity: sha512-3wplt0CobZE5k1Vf3PBqBm3vlNQnVWEpSGBMHRnnFcj/khYYwGXSMSTedW1s8MI5FqkdhU/CFoJ7hQqGwzqFIA==}
hasBin: true
peerDependencies:
react: '>=17.0.0'
@@ -3164,6 +3201,12 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
+ temporal-polyfill@0.3.2:
+ resolution: {integrity: sha512-TzHthD/heRK947GNiSu3Y5gSPpeUDH34+LESnfsq8bqpFhsB79HFBX8+Z834IVX68P3EUyRPZK5bL/1fh437Eg==}
+
+ temporal-spec@0.3.1:
+ resolution: {integrity: sha512-B4TUhezh9knfSIMwt7RVggApDRJZo73uZdj8AacL2mZ8RP5KtLianh2MXxL06GN9ESYiIsiuoLQhgVfwe55Yhw==}
+
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -4249,7 +4292,7 @@ snapshots:
'@preact/signals-core': 1.14.2
preact: 10.29.2
- '@react-grab/cli@0.1.42':
+ '@react-grab/cli@0.1.43':
dependencies:
agent-install: 0.0.5
commander: 14.0.3
@@ -4328,6 +4371,24 @@ snapshots:
estree-walker: 2.0.2
picomatch: 4.0.4
+ '@schedule-x/calendar@4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2)':
+ dependencies:
+ '@preact/signals': 2.9.1(preact@10.29.2)
+ preact: 10.29.2
+ temporal-polyfill: 0.3.2
+
+ '@schedule-x/react@4.1.0(@schedule-x/calendar@4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+ dependencies:
+ '@schedule-x/calendar': 4.6.0(@preact/signals@2.9.1(preact@10.29.2))(preact@10.29.2)(temporal-polyfill@0.3.2)
+ react: 19.2.6
+ react-dom: 19.2.6(react@19.2.6)
+
+ '@schedule-x/scroll-controller@4.6.0(@preact/signals@2.9.1(preact@10.29.2))':
+ dependencies:
+ '@preact/signals': 2.9.1(preact@10.29.2)
+
+ '@schedule-x/theme-shadcn@4.6.0': {}
+
'@sec-ant/readable-stream@0.4.1': {}
'@sindresorhus/merge-streams@4.0.0': {}
@@ -6099,9 +6160,9 @@ snapshots:
react: 19.2.6
scheduler: 0.27.0
- react-grab@0.1.42(react@19.2.6):
+ react-grab@0.1.43(react@19.2.6):
dependencies:
- '@react-grab/cli': 0.1.42
+ '@react-grab/cli': 0.1.43
bippy: 0.5.41(react@19.2.6)
optionalDependencies:
react: 19.2.6
@@ -6120,7 +6181,7 @@ snapshots:
react: 19.2.6
react-doctor: 0.2.14(eslint@10.4.1(jiti@2.7.0))(oxlint-tsgolint@0.23.0)
react-dom: 19.2.6(react@19.2.6)
- react-grab: 0.1.42(react@19.2.6)
+ react-grab: 0.1.43(react@19.2.6)
optionalDependencies:
unplugin: 3.0.0
transitivePeerDependencies:
@@ -6396,6 +6457,12 @@ snapshots:
tapable@2.3.3: {}
+ temporal-polyfill@0.3.2:
+ dependencies:
+ temporal-spec: 0.3.1
+
+ temporal-spec@0.3.1: {}
+
tiny-invariant@1.3.3: {}
tinyexec@1.2.3: {}
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
new file mode 100644
index 00000000..62f0a568
--- /dev/null
+++ b/src/components/ui/popover.tsx
@@ -0,0 +1,88 @@
+import * as React from "react"
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
+
+import { cn } from "@/lib/utils"
+
+function Popover({ ...props }: PopoverPrimitive.Root.Props) {
+ return
+}
+
+function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ alignOffset = 0,
+ side = "bottom",
+ sideOffset = 4,
+ ...props
+}: PopoverPrimitive.Popup.Props &
+ Pick<
+ PopoverPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
+ return (
+
+
+
+
+
+ )
+}
+
+function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function PopoverDescription({
+ className,
+ ...props
+}: PopoverPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Popover,
+ PopoverContent,
+ PopoverDescription,
+ PopoverHeader,
+ PopoverTitle,
+ PopoverTrigger,
+}
diff --git a/src/index.css b/src/index.css
index 4d635c5c..816fd434 100644
--- a/src/index.css
+++ b/src/index.css
@@ -108,6 +108,8 @@
--brand-text-hero: #cde8f0;
--brand-navbar: #0d3b4a;
--brand-footer: rgba(8, 71, 80, 0.95);
+
+ scrollbar-color: var(--muted-foreground) transparent;
}
/*
---break---
@@ -185,6 +187,22 @@
}
}
+/* Weird CSS stuff/fixes we have to modify thanks to Schedule-X */
+.sx__time-grid-event-inner .sx__time-grid-event-time,
+.sx__time-grid-event-inner .sx__time-grid-event-location {
+ display: inline;
+}
+.sx__time-grid-event-inner .sx__time-grid-event-time .sx__event-icon,
+.sx__time-grid-event-inner .sx__time-grid-event-location .sx__event-icon {
+ display: none;
+}
+.sx__time-grid-event-inner .sx__time-grid-event-location::before {
+ content: ", ";
+}
+.sx__date-picker-popup.sx__date-picker-popup {
+ overflow: auto;
+}
+
/* Hero scroll cue: pulses opacity and offsets vertically (CSS animation, not motion-react,
to keep the work on the GPU/compositor instead of the JS main thread). */
@keyframes scroll-cue {
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 4ec2cea2..ba88adae 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -11,6 +11,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as SigsRouteImport } from './routes/sigs'
import { Route as ProjectsRouteImport } from './routes/projects'
+import { Route as EventsRouteImport } from './routes/events'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ProjectProjectIdRouteImport } from './routes/project/$projectId'
@@ -25,6 +26,11 @@ const ProjectsRoute = ProjectsRouteImport.update({
path: '/projects',
getParentRoute: () => rootRouteImport,
} as any)
+const EventsRoute = EventsRouteImport.update({
+ id: '/events',
+ path: '/events',
+ getParentRoute: () => rootRouteImport,
+} as any)
const AboutRoute = AboutRouteImport.update({
id: '/about',
path: '/about',
@@ -44,6 +50,7 @@ const ProjectProjectIdRoute = ProjectProjectIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
+ '/events': typeof EventsRoute
'/projects': typeof ProjectsRoute
'/sigs': typeof SigsRoute
'/project/$projectId': typeof ProjectProjectIdRoute
@@ -51,6 +58,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
+ '/events': typeof EventsRoute
'/projects': typeof ProjectsRoute
'/sigs': typeof SigsRoute
'/project/$projectId': typeof ProjectProjectIdRoute
@@ -59,19 +67,27 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
+ '/events': typeof EventsRoute
'/projects': typeof ProjectsRoute
'/sigs': typeof SigsRoute
'/project/$projectId': typeof ProjectProjectIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/about' | '/projects' | '/sigs' | '/project/$projectId'
+ fullPaths:
+ | '/'
+ | '/about'
+ | '/events'
+ | '/projects'
+ | '/sigs'
+ | '/project/$projectId'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/about' | '/projects' | '/sigs' | '/project/$projectId'
+ to: '/' | '/about' | '/events' | '/projects' | '/sigs' | '/project/$projectId'
id:
| '__root__'
| '/'
| '/about'
+ | '/events'
| '/projects'
| '/sigs'
| '/project/$projectId'
@@ -80,6 +96,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
+ EventsRoute: typeof EventsRoute
ProjectsRoute: typeof ProjectsRoute
SigsRoute: typeof SigsRoute
ProjectProjectIdRoute: typeof ProjectProjectIdRoute
@@ -101,6 +118,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProjectsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/events': {
+ id: '/events'
+ path: '/events'
+ fullPath: '/events'
+ preLoaderRoute: typeof EventsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/about': {
id: '/about'
path: '/about'
@@ -128,6 +152,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
+ EventsRoute: EventsRoute,
ProjectsRoute: ProjectsRoute,
SigsRoute: SigsRoute,
ProjectProjectIdRoute: ProjectProjectIdRoute,
diff --git a/src/routes/events.tsx b/src/routes/events.tsx
new file mode 100644
index 00000000..c69e8ade
--- /dev/null
+++ b/src/routes/events.tsx
@@ -0,0 +1,449 @@
+import "temporal-polyfill/global";
+import "@schedule-x/theme-shadcn/dist/index.css";
+
+import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
+import {
+ createViewDay,
+ createViewMonthAgenda,
+ createViewMonthGrid,
+ createViewWeek,
+ type CalendarEvent,
+ type CalendarType,
+} from "@schedule-x/calendar";
+import { ScheduleXCalendar, useCalendarApp } from "@schedule-x/react";
+import { createScrollControllerPlugin } from "@schedule-x/scroll-controller";
+import { queryOptions, useMutation, useQuery } from "@tanstack/react-query";
+import { createFileRoute } from "@tanstack/react-router";
+import axios from "axios";
+import { MapPin } from "lucide-react";
+import { useCallback, useMemo, useState } from "react";
+import { toast } from "sonner";
+
+import aboutUsThumb from "@/assets/images/about-us.png";
+import { Button } from "@/components/ui/button";
+import { Popover, PopoverTrigger } from "@/components/ui/popover";
+import { Skeleton } from "@/components/ui/skeleton";
+import { useTheme } from "@/components/ui/theme-provider";
+import { cn } from "@/lib/utils";
+
+export const Route = createFileRoute("/events")({
+ component: Events,
+ loader: ({ context: { queryClient } }) => queryClient.prefetchQuery(eventsQueryOptions),
+});
+
+/// Types and Interfaces
+
+type ResolvedTheme = "dark" | "light";
+
+type EventType =
+ | "general"
+ | "sig_ai"
+ | "sig_swe"
+ | "sig_cyber"
+ | "sig_data"
+ | "sig_arch"
+ | "sig_graph"
+ | "social"
+ | "misc";
+
+interface ApiEvent {
+ id: string;
+ name: string;
+ description: string;
+ start_at: string;
+ end_at: string;
+ location: string;
+ type: EventType;
+ timezone: string;
+ creator_id: string;
+}
+
+interface EventsPage {
+ data: ApiEvent[];
+ total: number;
+}
+
+interface SelectedEvent {
+ event: ApiEvent;
+ rect: DOMRect;
+}
+
+interface EventsCalendarProps {
+ theme: ResolvedTheme;
+ events: ApiEvent[];
+ onSelect: (selection: SelectedEvent) => void;
+}
+
+/// Module-scoped constants
+
+const EVENT_TYPES = [
+ { key: "general", label: "General", color: "#084778", darkColor: "#5b9bd5" },
+ { key: "sig_swe", label: "SWE", color: "#3da9fc", darkColor: "#6cbcff" },
+ { key: "sig_ai", label: "AI", color: "#00c9a7", darkColor: "#2fdcbb" },
+ { key: "sig_cyber", label: "Cyber", color: "#ff6b6b", darkColor: "#ff9a9a" },
+ { key: "sig_data", label: "Data", color: "#f7b731", darkColor: "#ffd56b" },
+ { key: "sig_graph", label: "Graphics", color: "#a55eea", darkColor: "#c79bf2" },
+ { key: "sig_arch", label: "Architecture", color: "#fc5c7d", darkColor: "#ff8fa6" },
+ { key: "social", label: "Social", color: "#00e1bf", darkColor: "#3df0d6" },
+ { key: "misc", label: "Misc", color: "#93a3b6", darkColor: "#b4c2d2" },
+] as const satisfies readonly {
+ readonly key: EventType;
+ readonly label: string;
+ readonly color: string;
+ readonly darkColor: string;
+}[];
+
+const EVENT_TYPE_BADGE_CLASSES: Record = {
+ general: "border-[#338acf]/30 bg-[#338acf]/15 text-[#338acf]",
+ sig_swe: "border-[#4cb0fc]/30 bg-[#4cb0fc]/15 text-[#4cb0fc]",
+ sig_ai: "border-[#00c9a7]/30 bg-[#00c9a7]/15 text-[#00c9a7]",
+ sig_cyber: "border-[#ff6b6b]/30 bg-[#ff6b6b]/15 text-[#ff6b6b]",
+ sig_data: "border-[#f7b731]/30 bg-[#f7b731]/15 text-[#f7b731]",
+ sig_graph: "border-[#a55eea]/30 bg-[#a55eea]/15 text-[#a55eea]",
+ sig_arch: "border-[#fc5c7d]/30 bg-[#fc5c7d]/15 text-[#fc5c7d]",
+ social: "border-[#00e1bf]/30 bg-[#00e1bf]/15 text-[#00e1bf]",
+ misc: "border-[#93a3b6]/30 bg-[#93a3b6]/15 text-[#93a3b6]",
+};
+
+const EVENT_CALENDARS: Record = Object.fromEntries(
+ EVENT_TYPES.map((meta) => [
+ meta.key,
+ {
+ colorName: meta.key,
+ lightColors: { main: meta.color, container: `${meta.color}40`, onContainer: meta.color },
+ darkColors: {
+ main: meta.darkColor,
+ container: `${meta.darkColor}40`,
+ onContainer: meta.darkColor,
+ },
+ },
+ ]),
+);
+
+const API_BASE_URL =
+ (import.meta.env.VITE_API_URL as string | undefined) || "http://localhost:8000";
+
+const EVENTS_PAGE_SIZE = 50;
+const PACIFIC_TZ = "America/Los_Angeles";
+
+const WEEKDAY_FMT = new Intl.DateTimeFormat("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ timeZone: PACIFIC_TZ,
+});
+const TIME_FMT = new Intl.DateTimeFormat("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ timeZone: PACIFIC_TZ,
+});
+
+const CALENDAR_SECTION_CLASSES = cn(
+ "mx-auto flex w-full max-w-[1700px] flex-col",
+ "h-[calc(100svh-4rem)] px-4 py-4 md:h-[calc(100svh-5.125rem)] md:px-8 md:py-6",
+);
+const CALENDAR_SHELL_CLASSES = cn(
+ "min-h-0 w-full flex-1 rounded-3xl border border-border bg-card",
+ "shadow-[0px_16px_40px_rgba(112,144,176,0.2)]",
+ "[&_.sx-react-calendar-wrapper]:size-full",
+);
+
+/// Tanstack Query keys
+
+const eventsKeys = {
+ all: ["events"] as const,
+ lists: () => [...eventsKeys.all, "list"] as const,
+ list: (params: Record) => [...eventsKeys.lists(), params] as const,
+};
+
+const eventsQueryOptions = queryOptions({
+ queryKey: eventsKeys.list({}),
+ queryFn: async () => {
+ // Until we have date-range paginations on Kanae, we will literally fetch everything
+ const first = await axios.get(`${API_BASE_URL}/events`, {
+ params: { page: 1, size: EVENTS_PAGE_SIZE },
+ });
+ const { total } = first.data;
+
+ const remaining = total - first.data.data.length;
+ const leftover = remaining % EVENTS_PAGE_SIZE;
+ const remainingPages = (remaining - leftover) / EVENTS_PAGE_SIZE + (leftover > 0 ? 1 : 0);
+
+ const rest = await Promise.all(
+ Array.from({ length: remainingPages }, (_, index) =>
+ axios.get(`${API_BASE_URL}/events`, {
+ params: { page: index + 2, size: EVENTS_PAGE_SIZE },
+ }),
+ ),
+ );
+
+ return {
+ data: [...first.data.data, ...rest.flatMap((response) => response.data.data)],
+ total,
+ };
+ },
+ staleTime: 60_000,
+
+ // Remove these when Kanae is live
+ retry: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+});
+
+/// Events Calendar component
+
+// This exists because `useCalendarApp` only builds the calendar once and doesn't re-render theme changes
+// See: https://github.com/schedule-x/react/blob/main/src/use-calendar-app.tsx
+function EventsCalendar({ theme, events, onSelect }: Readonly) {
+ const [initialScroll] = useState(() => `${String(new Date().getHours()).padStart(2, "0")}:00`);
+ const scrollController = useMemo(
+ () => createScrollControllerPlugin({ initialScroll }),
+ [initialScroll],
+ );
+
+ const sortedEvents = useMemo(() => new Map(events.map((event) => [event.id, event])), [events]);
+
+ const calendarEvents = useMemo(
+ () =>
+ events.map((event) => ({
+ id: event.id,
+ title: event.name,
+ description: event.description,
+ location: event.location,
+ calendarId: event.type,
+ start: Temporal.Instant.from(event.start_at).toZonedDateTimeISO(event.timezone),
+ end: Temporal.Instant.from(event.end_at).toZonedDateTimeISO(event.timezone),
+ })),
+ [events],
+ );
+
+ const calendar = useCalendarApp({
+ views: [createViewWeek(), createViewMonthGrid(), createViewDay(), createViewMonthAgenda()],
+ defaultView: "week",
+ weekOptions: { gridHeight: 1200 },
+ dayBoundaries: { start: "08:00", end: "24:00" },
+ events: calendarEvents,
+ calendars: EVENT_CALENDARS,
+ theme: "shadcn",
+ isDark: theme === "dark",
+ timezone: PACIFIC_TZ,
+ selectedDate: Temporal.Now.plainDateISO(PACIFIC_TZ),
+ plugins: [scrollController],
+ callbacks: {
+ onEventClick: (calendarEvent, clickEvent) => {
+ const found = sortedEvents.get(String(calendarEvent.id));
+ const target = clickEvent.currentTarget;
+ if (found && target instanceof Element) {
+ onSelect({ event: found, rect: target.getBoundingClientRect() });
+ }
+ },
+ },
+ });
+
+ calendar?.setTheme(theme);
+
+ return (
+
+
+
+ );
+}
+
+/// Route Component
+
+function Events() {
+ const { data: eventsPage, isError } = useQuery(eventsQueryOptions);
+ const events = useMemo(() => eventsPage?.data ?? [], [eventsPage]);
+
+ const { mutate: joinEvent, isPending: isJoining } = useMutation({
+ mutationFn: (eventId: string) => {
+ // Until Kanae is up, this goes nowhere for now
+ return Promise.resolve(eventId);
+ },
+ onSuccess: () => {
+ toast.success("You're on the list! We'll see you there.");
+ setSelected(undefined);
+ },
+ });
+
+ const [selected, setSelected] = useState();
+ const selectedEvent = selected?.event;
+
+ // Not my favorite way to do this,
+ // but a system theme could result in the calendar could result in it being set to light theme
+ const { theme } = useTheme();
+ const [prefersDark] = useState(
+ () => globalThis.matchMedia("(prefers-color-scheme: dark)").matches,
+ );
+ const systemTheme: ResolvedTheme = prefersDark ? "dark" : "light";
+ const resolvedTheme: ResolvedTheme = theme === "system" ? systemTheme : theme;
+
+ const handleJoinEvent = useCallback(() => {
+ if (selectedEvent) {
+ joinEvent(selectedEvent.id);
+ }
+ }, [selectedEvent, joinEvent]);
+
+ const handlePopoverOpenChange = useCallback((open: boolean) => {
+ if (!open) {
+ setSelected(undefined);
+ }
+ }, []);
+
+ const selectedEventType = useMemo(
+ () => EVENT_TYPES.find((meta) => meta.key === selectedEvent?.type) ?? EVENT_TYPES[0],
+ [selectedEvent],
+ );
+
+ const anchor = useMemo(
+ () => (selected ? { getBoundingClientRect: () => selected.rect } : undefined),
+ [selected],
+ );
+
+ return (
+
+
+
+
+
+
+ Events and Activities
+
+
+ Keep up to date with our events!
+
+
+
+
+
+ {eventsPage ? (
+
+ ) : undefined}
+
+ {!eventsPage && isError ? (
+
+ Failed to load events
+
+ ) : undefined}
+
+ {!eventsPage && !isError ? (
+
+ ) : undefined}
+
+
+
+
+
+
+
+ {selectedEvent ? (
+ <>
+
+
+
+
+ {selectedEventType.label}
+
+
+
+ {selectedEvent.name}
+
+
+
+
+ {WEEKDAY_FMT.format(new Date(selectedEvent.start_at))}
+
+
+ {TIME_FMT.format(new Date(selectedEvent.start_at))} -{" "}
+ {TIME_FMT.format(new Date(selectedEvent.end_at))}
+
+
+
+ {selectedEvent.location}
+
+
+
+
+ {selectedEvent.description}
+
+
+
+
+ >
+ ) : undefined}
+
+
+
+
+
+
+
+
+
+ Never miss an event
+
+
+ Subscribe to our newsletter for weekly updates
+
+
+
+
+
+
+ );
+}
diff --git a/vite.config.ts b/vite.config.ts
index 10821127..7e51f7db 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -29,13 +29,19 @@ export default defineConfig({
output: {
codeSplitting: {
groups: [
- { name: "vendor-react", test: /[\\/]react(-dom)?[\\/]/ },
- { name: "vendor-base-ui", test: /[\\/]@base-ui[\\/]/ },
+ // Schedule-X's renderer uses @preact/signals, so we send it into its own vendor chunk
+ { name: "vendor-preact", test: /[\\/](preact|@preact)[\\/]/ },
+ { name: "vendor-react", test: /[\\/]node_modules[\\/]react(-dom)?[\\/]/ },
+ { name: "vendor-base-ui", test: /[\\/](@base-ui|@floating-ui)[\\/]/ },
{ name: "vendor-tanstack", test: /[\\/]@tanstack[\\/]/ },
{
name: "vendor-icons",
test: /[\\/](@icons-pack[\\/]react-simple-icons|lucide-react)[\\/]/,
},
+ {
+ name: "vendor-schedule-x",
+ test: /[\\/](@schedule-x|temporal-polyfill)[\\/]/,
+ },
{ name: "vendor-misc", test: /node_modules[\\/]/ },
],
},