Repository: icssc/AntAlmanac
Pull request #726 (2023-10-10 23:21:35)

feat: locations for custom events
# Summary
A continuation of #586 . I'll be collaborating with @stevenguyukai to prepare all the features.

## Features
- Custom events can optionally be associated with a building on campus, this is captured via the building's ID in the custom event
- The building selector has been extracted to a separate component
- The custom event create/edit dialog allows users to select a building for the custom event

## TODO
- Figure out how to get it into the map routing (if this is desired)


github-actions[bot]: Deployed staging instance to https://staging-726.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
stevenguyukai: # More TODO
### 1. Custom Calendar event
<img width="292" alt="1" src="https://github.com/icssc/AntAlmanac/assets/47913844/1a8abbb9-44b1-4b61-8d46-f4831eb24c01">

* Showing Location info on the pop ups
* And the `Location` on the pop up is clickable, so when click the building, it will open the map mark for that event


### 2. Rearrange Custom Event Dialog
<img width="266" alt="2" src="https://github.com/icssc/AntAlmanac/assets/47913844/a1e58ccf-463d-4ce3-9d67-6209be3d78ba">

* Put the building searching box before the schedule selector. 
* Because for most the student, they need no change of the schedules. So let it just be a default. So put it to the bottom


### 3. ADDED CLASSES Location Info
<img width="646" alt="3" src="https://github.com/icssc/AntAlmanac/assets/47913844/9fe7d631-2b09-410e-b7f8-a9203e4e5d0c">
 
* Adding location information to the custom event under `ADDED CLASSES`
github-actions[bot]: Deployed staging instance to https://staging-726.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
github-actions[bot]: Deployed staging instance to https://staging-726.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
EricPedley: Can you convert this to a draft until it's ready for review? Just makes our PR list easier to track.
github-actions[bot]: Deployed staging instance to https://staging-726.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
MinhxNguyen7: Is this PR done? There seems to be another TODO.

I strongly think that it would make sense for it to work with routing. 
stevenguyukai: > Is this PR done? There seems to be another TODO.
> 
> I strongly think that it would make sense for it to work with routing.

I finished the intended features and is waiting for review and merge
KevinWu098: > > Is this PR done? There seems to be another TODO.
> > I strongly think that it would make sense for it to work with routing.
> 
> I finished the intended features and is waiting for review and merge

<img width="1728" alt="Screenshot 2023-11-13 at 10 57 31 PM" src="https://github.com/icssc/AntAlmanac/assets/100006999/41de615b-54db-4cb4-b0f2-f12af9650ff9">

Unless I'm mistaken, the latest staging instance doesn't have the map routing? Clicking a location also leads to https://staging-726.antalmanac.com/map?location=0
stevenguyukai: Maybe because of the Conlifcts? Yea the instance is differ from my local
github-actions[bot]: Deployed staging instance to https://staging-726.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
stevenguyukai: @ap0nia Would you happen to know why the instance differs from my local deployment? And also why the tests failed. (For the newest instance I merged `main`, but it still act differently)
github-actions[bot]: Deployed staging instance to https://staging-726.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
github-actions[bot]: Deployed staging instance to https://staging-726.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
github-actions[bot]: Deployed staging instance to https://staging-726.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
github-actions[bot]: Deployed staging instance to https://staging-726.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->

Patch:
diff --git a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
index a36aa478..c1c16b34 100644
--- a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
+++ b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
@@ -16,6 +16,7 @@ import locationIds from '$lib/location_ids';
 import { useTabStore } from '$stores/TabStore';
 import { formatTimes } from '$stores/calendarizeHelpers';
 import { useTimeFormatStore } from '$stores/TimeStore';
+import buildingCatalogue from '$lib/buildingCatalogue';
 
 const styles: Styles<Theme, object> = {
     courseContainer: {
@@ -141,6 +142,7 @@ export interface CourseEvent extends CommonCalendarEvent {
 export interface CustomEvent extends CommonCalendarEvent {
     customEventID: number;
     isCustomEvent: true;
+    building: string;
     days: string[];
 }
 
@@ -288,10 +290,20 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => {
             </Paper>
         );
     } else {
-        const { title, customEventID } = courseInMoreInfo;
+        const { title, customEventID, building } = courseInMoreInfo;
         return (
             <Paper className={classes.customEventContainer} ref={paperRef}>
                 <div className={classes.title}>{title}</div>
+                <div className={classes.table}>
+                    Location: &nbsp;&nbsp;
+                    <Link
+                        className={classes.clickableLocation}
+                        to={`/map?location=${building ?? 0}`}
+                        onClick={focusMap}
+                    >
+                        {building ? buildingCatalogue[+building].name : ''}
+                    </Link>
+                </div>
                 <div className={classes.buttonBar}>
                     <div className={`${classes.colorPicker}`}>
                         <ColorPicker
diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx b/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx
index 40a16d30..69764d15 100644
--- a/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx
+++ b/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx
@@ -1,3 +1,4 @@
+import React, { PureComponent } from 'react';
 import {
     Button,
     Dialog,
@@ -12,7 +13,7 @@ import {
 } from '@material-ui/core';
 import { withStyles } from '@material-ui/core/styles';
 import { Add, Edit } from '@material-ui/icons';
-import React, { PureComponent } from 'react';
+import type { RepeatingCustomEvent } from '@packages/antalmanac-types';
 
 import DaySelector from './DaySelector';
 import ScheduleSelector from './ScheduleSelector';
@@ -20,6 +21,9 @@ import { addCustomEvent, editCustomEvent } from '$actions/AppStoreActions';
 import analyticsEnum, { logAnalytics } from '$lib/analytics';
 import { isDarkMode } from '$lib/helpers';
 import AppStore from '$stores/AppStore';
+import { BuildingSelect, ExtendedBuilding } from '$components/inputs/building-select';
+
+export { RepeatingCustomEvent };
 
 const styles = {
     textField: {
@@ -27,20 +31,6 @@ const styles = {
     },
 };
 
-/**
- * There is another CustomEvent interface in CourseCalendarEvent and they are slightly different. This one encapsulates the occurences of an event on multiple days, like Monday Tuesday Wednesday all in the same object as specified by the days array. The other one, `CustomEventDialog`'s CustomEvent, represents only one day, like the event on Monday, and needs to be duplicated to be repeated across multiple days.
- * https://github.com/icssc/AntAlmanac/wiki/The-Great-AntAlmanac-TypeScript-Rewritening%E2%84%A2#duplicate-interface-names-%EF%B8%8F
- * TODO: This needs to be moved to course_data.types.ts. It's stupid that components need to import from here instead of $lib
- */
-export interface RepeatingCustomEvent {
-    title: string;
-    start: string;
-    end: string;
-    days: boolean[];
-    customEventID: number;
-    color?: string;
-}
-
 interface CustomEventDialogProps {
     customEvent?: RepeatingCustomEvent;
     onDialogClose?: () => void;
@@ -58,6 +48,7 @@ const defaultCustomEvent: RepeatingCustomEvent = {
     title: '',
     days: [false, false, false, false, false, false, false],
     customEventID: 0,
+    building: undefined,
 };
 
 class CustomEventDialog extends PureComponent<CustomEventDialogProps, CustomEventDialogState> {
@@ -108,16 +99,21 @@ class CustomEventDialog extends PureComponent<CustomEventDialogProps, CustomEven
         this.setState({ days: days });
     };
 
+    handleBuildingChange = (building?: ExtendedBuilding | null) => {
+        this.setState({ building: building?.id });
+    };
+
     handleAddToCalendar = () => {
         if (!this.state.days.some((day) => day) || this.state.scheduleIndices.length === 0) return;
 
-        const newCustomEvent = {
+        const newCustomEvent: RepeatingCustomEvent = {
             color: this.props.customEvent ? this.props.customEvent.color : '#551a8b',
             title: this.state.title,
             days: this.state.days,
             start: this.state.start,
             end: this.state.end,
             customEventID: this.props.customEvent ? this.props.customEvent.customEventID : Date.now(),
+            building: this.state.building,
         };
 
         if (this.props.customEvent) editCustomEvent(newCustomEvent, this.state.scheduleIndices);
@@ -197,6 +193,7 @@ class CustomEventDialog extends PureComponent<CustomEventDialogProps, CustomEven
                             />
                         </form>
                         <DaySelector onSelectDay={this.handleDayChange} days={this.props.customEvent?.days} />
+                        <BuildingSelect value={this.state.building} onChange={this.handleBuildingChange} />
                         <ScheduleSelector
                             scheduleIndices={this.state.scheduleIndices}
                             onSelectScheduleIndices={this.handleSelectScheduleIndices}
diff --git a/apps/antalmanac/src/components/Map/Map.tsx b/apps/antalmanac/src/components/Map/Map.tsx
index 590a5e6f..1e5c0db0 100644
--- a/apps/antalmanac/src/components/Map/Map.tsx
+++ b/apps/antalmanac/src/components/Map/Map.tsx
@@ -5,15 +5,16 @@ import { useSearchParams, useNavigate } from 'react-router-dom';
 import L, { type Map, type LatLngTuple } from 'leaflet';
 import { MapContainer, TileLayer } from 'react-leaflet';
 import 'leaflet-routing-machine';
-import { Autocomplete, Box, Paper, Tab, Tabs, TextField, Typography } from '@mui/material';
+import { Box, Paper, Tab, Tabs, Typography } from '@mui/material';
 import ClassRoutes from './Routes';
 import LocationMarker from './Marker';
 import UserLocator from './UserLocator';
 import AppStore from '$stores/AppStore';
 import locationIds from '$lib/location_ids';
-import buildingCatalogue from '$lib/buildingCatalogue';
-import type { Building } from '$lib/buildingCatalogue';
+import buildingCatalogue, { Building } from '$lib/buildingCatalogue';
 import type { CourseEvent } from '$components/Calendar/CourseCalendarEvent';
+import { BuildingSelect, ExtendedBuilding } from '$components/inputs/building-select';
+import { notNull } from '$lib/utils';
 import { TILES_URL } from '$lib/api/endpoints';
 
 const ATTRIBUTION_MARKUP =
@@ -38,7 +39,9 @@ interface MarkerContent {
 export function getCoursesPerBuilding() {
     const courseEvents = AppStore.getCourseEventsInCalendar();
 
-    const allBuildingCodes = courseEvents.flatMap((event) => event.locations.map((location) => location.building));
+    const courseBuildings = courseEvents.flatMap((event) => event.locations.map((location) => location.building));
+
+    const allBuildingCodes = [...courseBuildings];
 
     const uniqueBuildingCodes = new Set(allBuildingCodes);
 
@@ -74,14 +77,66 @@ export function getCoursesPerBuilding() {
     return coursesPerBuilding;
 }
 
-/**
- * Get unique building names for the MUI Autocomplete.
- * A building with a duplicate name will have a higher index then a `findIndex` for another building with the same name.
- */
-const buildings = Object.entries(buildingCatalogue).filter(
-    ([_, building], index, array) =>
-        array.findIndex(([_, otherBuilding]) => otherBuilding.name === building.name) === index
-);
+export function getCustomEventPerBuilding() {
+    const customEvents = AppStore.getCustomEvents();
+
+    const customEventBuildings = customEvents.map((e) => e.building).filter(notNull);
+
+    // convert all digit to name in customEventBuilding  for example: 83096  ->  ICS
+    for (let i = 0; i < customEventBuildings.length; i++) {
+        customEventBuildings[i] =
+            Object.keys(locationIds).find((key) => locationIds[key] === parseInt(customEventBuildings[i])) || '';
+    }
+
+    const allBuildingCodes = [...customEventBuildings];
+
+    const uniqueBuildingCodes = new Set(allBuildingCodes);
+
+    const validBuildingCodes = [...uniqueBuildingCodes].filter(
+        (buildingCode) => buildingCatalogue[locationIds[buildingCode]] != null
+    );
+
+    interface localCustomEventType {
+        title: string;
+        start: string;
+        end: string;
+        days: boolean[];
+        customEventID: number;
+        color?: string | undefined;
+        building?: string | undefined;
+    }
+
+    const customEventPerBuilding: Record<string, (localCustomEventType & Building & MarkerContent)[]> = {};
+    for (let i = 0; i < validBuildingCodes.length; i++) {
+        customEventPerBuilding[validBuildingCodes[i]] = customEvents
+            .filter((event) => {
+                return (
+                    Object.keys(locationIds).find(
+                        (key) => locationIds[key] === parseInt(event.building ? event.building : '')
+                    ) == validBuildingCodes[i]
+                );
+            })
+            .map((event) => {
+                const locationData = buildingCatalogue[locationIds[validBuildingCodes[i]]];
+                const key = `${event.title} @ ${event.building}`;
+                const acronym = locationData.name.substring(
+                    locationData.name.indexOf('(') + 1,
+                    locationData.name.indexOf(')')
+                );
+                const markerCustomEventData = {
+                    key,
+                    image: locationData.imageURLs[0],
+                    acronym,
+                    markerColor: event.color ? event.color : '',
+                    location: locationData.name,
+                    ...locationData,
+                    ...event,
+                };
+                return markerCustomEventData;
+            });
+    }
+    return customEventPerBuilding;
+}
 
 /**
  * Map of all course locations on UCI campus.
@@ -93,6 +148,7 @@ export default function CourseMap() {
     const [searchParams] = useSearchParams();
     const [selectedDayIndex, setSelectedDay] = useState(0);
     const [markers, setMarkers] = useState(getCoursesPerBuilding());
+    const [customEventMarkers] = useState(getCustomEventPerBuilding());
     const [calendarEvents, setCalendarEvents] = useState(AppStore.getCourseEventsInCalendar());
 
     useEffect(() => {
@@ -146,9 +202,9 @@ export default function CourseMap() {
         [setSelectedDay]
     );
 
-    const handleSearch = useCallback(
-        (_event: React.SyntheticEvent, value: [string, Building] | null) => {
-            navigate(`/map?location=${value?.[0]}`);
+    const onBuildingChange = useCallback(
+        (building?: ExtendedBuilding | null) => {
+            navigate(`/map?location=${building?.id}`);
         },
         [navigate]
     );
@@ -193,12 +249,27 @@ export default function CourseMap() {
 
         const markersToday =
             today === 'All' ? markerValues : markerValues.filter((course) => course.start.toString().includes(today));
-
         return markersToday
             .sort((a, b) => a.start.getTime() - b.start.getTime())
             .filter((marker, i, arr) => arr.findIndex((other) => other.sectionCode === marker.sectionCode) === i);
     }, [markers, today]);
 
+    const customEventMarkersToDisplay = useMemo(() => {
+        const markerValues = Object.keys(customEventMarkers).flatMap((markerKey) => customEventMarkers[markerKey]);
+
+        const markersToday =
+            today === 'All'
+                ? markerValues
+                : markerValues.filter((event) => {
+                      return event.days.some((day, index) => day && WORK_WEEK[index] === today);
+                  });
+        return markersToday.sort((a, b) => {
+            const startDateA = new Date(`1970-01-01T${a.start}`);
+            const startDateB = new Date(`1970-01-01T${b.start}`);
+            return startDateA.getTime() - startDateB.getTime();
+        });
+    }, [customEventMarkers, today]);
+
     /**
      * Every two markers grouped as [start, destination] tuples for the routes.
      */
@@ -222,12 +293,7 @@ export default function CourseMap() {
                             <Tab key={day} label={day} sx={{ padding: 1, minHeight: 'auto', minWidth: '10%' }} />
                         ))}
                     </Tabs>
-                    <Autocomplete
-                        options={buildings}
-                        getOptionLabel={(option) => option[1].name ?? ''}
-                        onChange={handleSearch}
-                        renderInput={(params) => <TextField {...params} label="Search for a place" variant="filled" />}
-                    />
+                    <BuildingSelect onChange={onBuildingChange} />
                 </Paper>
 
                 <TileLayer
@@ -291,6 +357,25 @@ export default function CourseMap() {
                     );
                 })}
 
+                {/* Draw a marker for each custom Event that occurs today. */}
+                {customEventMarkersToDisplay.map((customEventMarkers, index) => {
+                    const customEventSameBuildingPrior = customEventMarkersToDisplay.slice(0, index);
+
+                    return (
+                        <Fragment key={Object.values(customEventMarkers).join('')}>
+                            <LocationMarker
+                                {...customEventMarkers}
+                                label={'E'}
+                                stackIndex={customEventSameBuildingPrior.length}
+                            >
+                                <Box>
+                                    <Typography variant="body2">Event: {customEventMarkers.title}</Typography>
+                                </Box>
+                            </LocationMarker>
+                        </Fragment>
+                    );
+                })}
+
                 {/* Render an additional marker if the user searched up a location. */}
                 {/* A unique key based on the building is used to make sure the previous marker un-renders. */}
                 {focusedLocation && (
diff --git a/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx b/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx
index 99e7547d..e7fdc3d3 100644
--- a/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx
+++ b/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx
@@ -3,16 +3,25 @@ import { withStyles } from '@material-ui/core/styles';
 import { ClassNameMap } from '@material-ui/core/styles/withStyles';
 import { Delete } from '@material-ui/icons';
 import moment from 'moment';
+import { Link } from 'react-router-dom';
+import { useCallback } from 'react';
 
 import CustomEventDialog, { RepeatingCustomEvent } from '../../Calendar/Toolbar/CustomEventDialog/CustomEventDialog';
 import ColorPicker from '../../ColorPicker';
 import { deleteCustomEvent } from '$actions/AppStoreActions';
 import analyticsEnum from '$lib/analytics';
+import buildingCatalogue from '$lib/buildingCatalogue';
+import { useTabStore } from '$stores/TabStore';
 
 const styles = {
     root: {
         padding: '4px 4px 0px 8px',
     },
+    customEventLocation: {
+        margin: '0.75rem',
+        color: '#bbbbbb',
+        fontSize: '1rem',
+    },
     colorPicker: {
         cursor: 'pointer',
         '& > div': {
@@ -51,6 +60,12 @@ const CustomEventDetailView = (props: CustomEventDetailViewProps) => {
         return `${startTime.format('h:mm A')} — ${endTime.format('h:mm A')} • ${daysString}`;
     };
 
+    const { setActiveTab } = useTabStore();
+
+    const focusMap = useCallback(() => {
+        setActiveTab(2);
+    }, [setActiveTab]);
+
     return (
         <Card>
             <CardHeader
@@ -59,6 +74,15 @@ const CustomEventDetailView = (props: CustomEventDetailViewProps) => {
                 title={customEvent.title}
                 subheader={readableDateAndTimeFormat(customEvent.start, customEvent.end, customEvent.days)}
             />
+            <div className={classes.customEventLocation}>
+                <Link
+                    className={classes.clickableLocation}
+                    to={`/map?location=${customEvent.building ?? 0}`}
+                    onClick={focusMap}
+                >
+                    {customEvent.building ? buildingCatalogue[+customEvent.building].name : ''}
+                </Link>
+            </div>
             <CardActions disableSpacing={true}>
                 <div className={classes.colorPicker}>
                     <ColorPicker
@@ -68,6 +92,7 @@ const CustomEventDetailView = (props: CustomEventDetailViewProps) => {
                         analyticsCategory={analyticsEnum.addedClasses.title}
                     />
                 </div>
+                <CustomEventDialog customEvent={customEvent} scheduleNames={props.scheduleNames} />
                 <IconButton
                     onClick={() => {
                         deleteCustomEvent(customEvent.customEventID);
@@ -75,7 +100,6 @@ const CustomEventDetailView = (props: CustomEventDetailViewProps) => {
                 >
                     <Delete fontSize="small" />
                 </IconButton>
-                <CustomEventDialog customEvent={customEvent} scheduleNames={props.scheduleNames} />
             </CardActions>
         </Card>
     );
diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
index 5870504d..e7cc8a0a 100644
--- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
+++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
@@ -14,7 +14,6 @@ import { Link } from 'react-router-dom';
 import { withStyles } from '@material-ui/core/styles';
 import { ClassNameMap, Styles } from '@material-ui/core/styles/withStyles';
 import classNames from 'classnames';
-import { bindHover, bindPopover, usePopupState } from 'material-ui-popup-state/hooks';
 import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
 
 import { AASection } from '@packages/antalmanac-types';
@@ -374,7 +373,6 @@ interface RestrictionsCellProps {
 
 const RestrictionsCell = withStyles(styles)((props: RestrictionsCellProps) => {
     const { classes, restrictions } = props;
-    const popupState = usePopupState({ popupId: 'RestrictionsCellPopup', variant: 'popover' });
 
     const parseRestrictions = (restrictionCode: string) => {
         return restrictionCode.split(' ').map((code, index) => {
@@ -393,25 +391,17 @@ const RestrictionsCell = withStyles(styles)((props: RestrictionsCellProps) => {
     return (
         <NoPaddingTableCell className={classes.cell}>
             <Box>
-                <Typography {...bindHover(popupState)}>
-                    <a
-                        href="https://www.reg.uci.edu/enrollment/restrict_codes.html"
-                        target="_blank"
-                        rel="noopener noreferrer"
-                    >
-                        {restrictions}
-                    </a>
-                </Typography>
-                <Popover
-                    {...bindPopover(popupState)}
-                    className={classes.popover}
-                    classes={{ paper: classes.paper }}
-                    anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
-                    transformOrigin={{ vertical: 'top', horizontal: 'left' }}
-                    disableRestoreFocus
-                >
-                    <Typography>{parseRestrictions(restrictions)}</Typography>
-                </Popover>
+                <Tooltip title={<Typography>{parseRestrictions(restrictions)}</Typography>}>
+                    <Typography>
+                        <a
+                            href="https://www.reg.uci.edu/enrollment/restrict_codes.html"
+                            target="_blank"
+                            rel="noopener noreferrer"
+                        >
+                            {restrictions}
+                        </a>
+                    </Typography>
+                </Tooltip>
             </Box>
         </NoPaddingTableCell>
     );
diff --git a/apps/antalmanac/src/components/inputs/building-select.tsx b/apps/antalmanac/src/components/inputs/building-select.tsx
new file mode 100644
index 00000000..477fbc81
--- /dev/null
+++ b/apps/antalmanac/src/components/inputs/building-select.tsx
@@ -0,0 +1,53 @@
+import { useCallback, useMemo } from 'react';
+import { Autocomplete, TextField } from '@mui/material';
+import buildingCatalogue, { Building } from '$lib/buildingCatalogue';
+
+export interface ExtendedBuilding extends Building {
+    id: string;
+}
+
+/**
+ * Get unique building names for the MUI Autocomplete.
+ * A building with a duplicate name will have a higher index then a `findIndex` for another building with the same name.
+ */
+const buildings: ExtendedBuilding[] = Object.entries(buildingCatalogue)
+    .filter(
+        ([_, building], index, array) =>
+            array.findIndex(([_, otherBuilding]) => otherBuilding.name === building.name) === index
+    )
+    .map(([id, building]) => ({ id, ...building }));
+
+export type BuildingSelectProps = {
+    value?: string;
+    onChange?: (building?: ExtendedBuilding | null) => unknown;
+};
+
+export function BuildingSelect(props: BuildingSelectProps) {
+    const handleChange = useCallback(async (_event: React.SyntheticEvent, value: ExtendedBuilding | null) => {
+        await props.onChange?.(value);
+    }, []);
+
+    const value = useMemo(() => {
+        if (props.value == null) {
+            return;
+        }
+
+        const building = buildingCatalogue[Number(props.value)];
+
+        return {
+            id: props.value,
+            ...building,
+        };
+    }, [props.value]);
+
+    return (
+        <Autocomplete
+            options={buildings}
+            value={value}
+            isOptionEqualToValue={(option, value) => option.id === value?.id}
+            getOptionLabel={(option) => option.name ?? ''}
+            onChange={handleChange}
+            renderInput={(params) => <TextField {...params} label="Search for a place" variant="filled" />}
+        />
+    );
+}
diff --git a/apps/antalmanac/src/lib/analytics.ts b/apps/antalmanac/src/lib/analytics.ts
index 8d0e558e..f611ef62 100644
--- a/apps/antalmanac/src/lib/analytics.ts
+++ b/apps/antalmanac/src/lib/analytics.ts
@@ -89,7 +89,7 @@ export function logAnalytics({ category, action, label, value }: AnalyticsProps)
 /**
  * Converts course number to a decimal representation.
  * E.g., '122A' -> 122.1, '121' -> 121
- * 
+ *
  * @param courseNumber A string that represents the course number of a course (eg. '122A', '121')
  * @returns Decimal representation of courseNumber
  */
diff --git a/apps/antalmanac/src/lib/course_data.types.ts b/apps/antalmanac/src/lib/course_data.types.ts
index 1b4876c1..619d745d 100644
--- a/apps/antalmanac/src/lib/course_data.types.ts
+++ b/apps/antalmanac/src/lib/course_data.types.ts
@@ -1,5 +1,4 @@
-import { WebsocSection } from "peterportal-api-next-types";
-
+import { WebsocSection } from 'peterportal-api-next-types';
 
 export interface CourseDetails {
     deptCode: string;
@@ -12,4 +11,4 @@ export interface CourseDetails {
 export interface CourseInfo {
     courseDetails: CourseDetails;
     section: WebsocSection;
-}
\ No newline at end of file
+}
diff --git a/apps/antalmanac/src/lib/termData.ts b/apps/antalmanac/src/lib/termData.ts
index a0519f7c..dfd65778 100644
--- a/apps/antalmanac/src/lib/termData.ts
+++ b/apps/antalmanac/src/lib/termData.ts
@@ -18,7 +18,7 @@ class Term {
  * Months are 0-indexed
  */
 const termData = [
-    new Term('2024 Winter', '2024 Winter Quarter', [2023, 0, 8]),
+    new Term('2024 Winter', '2024 Winter Quarter', [2024, 0, 8]),
     new Term('2023 Fall', '2023 Fall Quarter', [2023, 8, 28]),
     new Term('2023 Summer2', '2023 Summer Session 2', [2023, 7, 7]),
     new Term('2023 Summer10wk', '2023 10-wk Summer', [2023, 5, 26]),
diff --git a/apps/antalmanac/src/lib/websoc.ts b/apps/antalmanac/src/lib/websoc.ts
index fa2a6a11..5bed3e79 100644
--- a/apps/antalmanac/src/lib/websoc.ts
+++ b/apps/antalmanac/src/lib/websoc.ts
@@ -12,7 +12,7 @@ class _WebSOC {
     constructor() {
         this.cache = {};
     }
-    
+
     clearCache() {
         Object.keys(this.cache).forEach((key) => delete this.cache[key]); //https://stackoverflow.com/a/19316873/14587004
     }
@@ -52,7 +52,7 @@ class _WebSOC {
             req[fieldName] = field;
             responses.push(await this.query(req));
         }
-    
+
         return this.combineSOCObjects(responses);
     }
 
@@ -176,35 +176,35 @@ class _WebSOC {
         if ('term' in record) {
             const termValue = record['term'];
             const termParts = termValue.split(' ');
-    
+
             if (termParts.length === 2) {
                 const [year, quarter] = termParts;
-    
+
                 delete record['term'];
-    
+
                 record['quarter'] = quarter;
                 record['year'] = year;
             }
         }
-    
+
         if ('startTime' in record) {
             if (record['startTime'] === '') {
                 delete record['startTime'];
             }
         }
-    
+
         if ('endTime' in record) {
             if (record['endTime'] === '') {
                 delete record['endTime'];
             }
         }
-    
+
         if ('division' in record) {
             if (record['division'] === '') {
                 delete record['division'];
             }
         }
-    
+
         return record;
     }
 }
diff --git a/apps/antalmanac/src/lib/zotcourse.ts b/apps/antalmanac/src/lib/zotcourse.ts
index 78537ee1..9d89c591 100644
--- a/apps/antalmanac/src/lib/zotcourse.ts
+++ b/apps/antalmanac/src/lib/zotcourse.ts
@@ -1,6 +1,6 @@
-import { RepeatingCustomEvent } from "$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog";
-import AppStore from "$stores/AppStore";
-import trpc from "./api/trpc";
+import trpc from './api/trpc';
+import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog';
+import AppStore from '$stores/AppStore';
 
 export interface ZotCourseResponse {
     codes: string[];
@@ -30,4 +30,4 @@ export async function queryZotCourse(schedule_name: string) {
         codes: codes,
         customEvents: customEvents,
     };
-}
\ No newline at end of file
+}
diff --git a/apps/antalmanac/src/stores/calendarizeHelpers.ts b/apps/antalmanac/src/stores/calendarizeHelpers.ts
index 6833baad..8d36dcbe 100644
--- a/apps/antalmanac/src/stores/calendarizeHelpers.ts
+++ b/apps/antalmanac/src/stores/calendarizeHelpers.ts
@@ -4,9 +4,9 @@ import { CourseEvent, CustomEvent, Location } from '$components/Calendar/CourseC
 import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog';
 import { notNull, getReferencesOccurring } from '$lib/utils';
 
-const COURSE_WEEK_DAYS = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'];
+export const COURSE_WEEK_DAYS = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'];
 
-const FINALS_WEEK_DAYS = ['Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
+export const FINALS_WEEK_DAYS = ['Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
 
 export function getLocation(location: string): Location {
     const [building = '', room = ''] = location.split(' ');
@@ -142,6 +142,7 @@ export function calendarizeCustomEvents(currentCustomEvents: RepeatingCustomEven
                 isCustomEvent: true,
                 end: new Date(2018, 0, dayIndex, endHour, endMin),
                 title: customEvent.title,
+                building: customEvent.building ?? '',
                 days,
             };
         });
diff --git a/packages/types/src/customevent.ts b/packages/types/src/customevent.ts
index 45f8268c..0acd3b47 100644
--- a/packages/types/src/customevent.ts
+++ b/packages/types/src/customevent.ts
@@ -7,4 +7,7 @@ export const RepeatingCustomEventSchema = type({
     days: 'boolean[]',
     customEventID: 'number | parsedNumber',
     'color?': 'string',
+    'building?': 'string',
 });
+
+export type RepeatingCustomEvent = typeof RepeatingCustomEventSchema.infer;


Test patch:
diff --git a/apps/antalmanac/tests/__snapshots__/download-ics.test.ts.snap b/apps/antalmanac/tests/__snapshots__/download-ics.test.ts.snap
index 859b4541..32bab2a4 100644
--- a/apps/antalmanac/tests/__snapshots__/download-ics.test.ts.snap
+++ b/apps/antalmanac/tests/__snapshots__/download-ics.test.ts.snap
@@ -49,7 +49,7 @@ Taught by placeholderInstructor1/placeholderInstructor2",
   },
   {
     "end": [
-      2023,
+      2024,
       1,
       8,
       3,
@@ -59,7 +59,7 @@ Taught by placeholderInstructor1/placeholderInstructor2",
     "productId": "antalmanac/ics",
     "recurrenceRule": "FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=1;COUNT=30",
     "start": [
-      2023,
+      2024,
       1,
       8,
       1,
diff --git a/apps/antalmanac/tests/calendarize-helpers.test.ts b/apps/antalmanac/tests/calendarize-helpers.test.ts
index 6f0358ae..eb928d6b 100644
--- a/apps/antalmanac/tests/calendarize-helpers.test.ts
+++ b/apps/antalmanac/tests/calendarize-helpers.test.ts
@@ -208,6 +208,7 @@ describe('calendarize-helpers', () => {
             start: new Date(2018, 0, 0, 1, 2),
             end: new Date(2018, 0, 0, 3, 4),
             title: 'title',
+            building: '',
             days: ['Su', 'Tu', 'Th', 'Sa'],
         },
         {
@@ -217,6 +218,7 @@ describe('calendarize-helpers', () => {
             start: new Date(2018, 0, 2, 1, 2),
             end: new Date(2018, 0, 2, 3, 4),
             title: 'title',
+            building: '',
             days: ['Su', 'Tu', 'Th', 'Sa'],
         },
         {
@@ -226,6 +228,7 @@ describe('calendarize-helpers', () => {
             start: new Date(2018, 0, 4, 1, 2),
             end: new Date(2018, 0, 4, 3, 4),
             title: 'title',
+            building: '',
             days: ['Su', 'Tu', 'Th', 'Sa'],
         },
         {
@@ -235,6 +238,7 @@ describe('calendarize-helpers', () => {
             start: new Date(2018, 0, 6, 1, 2),
             end: new Date(2018, 0, 6, 3, 4),
             title: 'title',
+            building: '',
             days: ['Su', 'Tu', 'Th', 'Sa'],
         },
     ];