Repository: icssc/AntAlmanac
Pull request #742 (2023-10-18 06:21:16)

Refactor functions used for ExportCalendar
## Summary
This change does refactoring to have ExportCalendar use `getEventsInCalendar` instead of `getCurrentCourses`

## Test Plan
Tests passed. I also added some courses and custom events to test manully. Works fine.

## Issues

Closes https://github.com/icssc/AntAlmanac/issues/147


## Future Followup

1. Add `location` to customEvent when https://github.com/icssc/AntAlmanac/issues/249 is done.
2. Find a way to get the term for the corresponding custom event. Now we are using the default term.


github-actions[bot]: Deployed staging instance to https://staging-742.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
github-actions[bot]: Deployed staging instance to https://staging-742.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
github-actions[bot]: Deployed staging instance to https://staging-742.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
github-actions[bot]: Deployed staging instance to https://staging-742.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
github-actions[bot]: Deployed staging instance to https://staging-742.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
github-actions[bot]: Deployed staging instance to https://staging-742.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
github-actions[bot]: Deployed staging instance to https://staging-742.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->

Patch:
diff --git a/.github/reviewer-lottery.yml b/.github/reviewer-lottery.yml
index 87f8309f..36be8a87 100644
--- a/.github/reviewer-lottery.yml
+++ b/.github/reviewer-lottery.yml
@@ -2,12 +2,11 @@ groups:
   - name: devs
     reviewers: 1
     usernames:
-      - Voark
+      - kevinwu098
       - EricPedley
       - bevm0
       - MinhxNguyen7
       - stevenguyukai
       - Douglas-Hong
-      - teresa-liang
       - JacE070
       
diff --git a/apps/antalmanac/src/actions/AppStoreActions.ts b/apps/antalmanac/src/actions/AppStoreActions.ts
index 11c43f0b..8caab3f4 100644
--- a/apps/antalmanac/src/actions/AppStoreActions.ts
+++ b/apps/antalmanac/src/actions/AppStoreActions.ts
@@ -3,12 +3,14 @@ import { VariantType } from 'notistack';
 import { TRPCError } from '@trpc/server';
 import { WebsocSection } from 'peterportal-api-next-types';
 import { ScheduleCourse } from '@packages/antalmanac-types';
-import { SnackbarPosition } from '$components/AppBar/NotificationSnackbar';
+import { SnackbarPosition } from '$components/NotificationSnackbar';
 import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog';
 import analyticsEnum, { logAnalytics } from '$lib/analytics';
-import { CourseDetails, courseNumAsDecimal, termsInSchedule, warnMultipleTerms } from '$lib/helpers';
+import { warnMultipleTerms } from '$lib/helpers';
+import { CourseDetails } from '$lib/course_data.types';
 import AppStore from '$stores/AppStore';
 import trpc from '$lib/api/trpc';
+import { courseNumAsDecimal } from '$lib/analytics';
 
 export const addCourse = (
     section: WebsocSection,
@@ -23,7 +25,7 @@ export const addCourse = (
         label: courseDetails.deptCode,
         value: courseNumAsDecimal(courseDetails.courseNumber),
     });
-    const terms = termsInSchedule(term);
+    const terms = AppStore.termsInSchedule(term);
 
     if (terms.size > 1 && !quiet) warnMultipleTerms(terms);
 
diff --git a/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx b/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx
index bd3b20ae..23c1180e 100644
--- a/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx
+++ b/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx
@@ -254,7 +254,6 @@ class ScheduleCalendar extends PureComponent<ScheduleCalendarProps, ScheduleCale
         return (
             <div className={classes.container} style={isMobile ? { height: 'calc(100% - 50px)' } : undefined}>
                 <CalendarToolbar
-                    onTakeScreenshot={this.handleTakeScreenshot}
                     currentScheduleIndex={this.state.currentScheduleIndex}
                     toggleDisplayFinalsSchedule={this.toggleDisplayFinalsSchedule}
                     showFinalsSchedule={this.state.showFinalsSchedule}
diff --git a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
index 105e434f..64842a66 100644
--- a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
+++ b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
@@ -102,27 +102,29 @@ export interface Location {
     /**
      * If the location only applies on specific days, this is non-null.
      */
-    days?: string[];
+    days?: string;
+}
+
+export interface FinalExam {
+    examStatus: 'NO_FINAL' | 'TBA_FINAL' | 'SCHEDULED_FINAL';
+    dayOfWeek: 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | null;
+    month: number | null;
+    day: number | null;
+    startTime: {
+        hour: number;
+        minute: number;
+    } | null;
+    endTime: {
+        hour: number;
+        minute: number;
+    } | null;
+    locations: Location[] | null;
 }
 
 export interface CourseEvent extends CommonCalendarEvent {
     locations: Location[];
     showLocationInfo: boolean;
-    finalExam: {
-        examStatus: 'NO_FINAL' | 'TBA_FINAL' | 'SCHEDULED_FINAL';
-        dayOfWeek: 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | null;
-        month: number | null;
-        day: number | null;
-        startTime: {
-            hour: number;
-            minute: number;
-        } | null;
-        endTime: {
-            hour: number;
-            minute: number;
-        } | null;
-        locations: Location[] | null;
-    };
+    finalExam: FinalExam;
     courseTitle: string;
     instructors: string[];
     isCustomEvent: false;
@@ -138,6 +140,7 @@ export interface CourseEvent extends CommonCalendarEvent {
 export interface CustomEvent extends CommonCalendarEvent {
     customEventID: number;
     isCustomEvent: true;
+    days: string[];
 }
 
 export type CalendarEvent = CourseEvent | CustomEvent;
diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx b/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx
index fbe42927..40a16d30 100644
--- a/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx
+++ b/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx
@@ -30,6 +30,7 @@ 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;
diff --git a/apps/antalmanac/src/components/AppBar/AboutPage.tsx b/apps/antalmanac/src/components/Header/AboutPage.tsx
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/AboutPage.tsx
rename to apps/antalmanac/src/components/Header/AboutPage.tsx
diff --git a/apps/antalmanac/src/components/AppBar/Exports/Export.tsx b/apps/antalmanac/src/components/Header/Export.tsx
similarity index 91%
rename from apps/antalmanac/src/components/AppBar/Exports/Export.tsx
rename to apps/antalmanac/src/components/Header/Export.tsx
index d72f327e..6d36ad61 100644
--- a/apps/antalmanac/src/components/AppBar/Exports/Export.tsx
+++ b/apps/antalmanac/src/components/Header/Export.tsx
@@ -2,8 +2,8 @@ import { Button, Paper, Popover, Tooltip } from '@mui/material';
 import { IosShare } from '@mui/icons-material';
 import { useCallback, useMemo, useState } from 'react';
 
-import ExportCalendar from '$components/AppBar/Exports/ExportCalendar';
-import ScreenshotButton from '$components/AppBar/Exports/ScreenshotButton';
+import ExportCalendar from '$components/buttons/ExportCalendar';
+import ScreenshotButton from '$components/buttons/Screenshot';
 
 function Export() {
     const [anchorEl, setAnchorEl] = useState<HTMLElement>();
diff --git a/apps/antalmanac/src/components/AppBar/Feedback.tsx b/apps/antalmanac/src/components/Header/Feedback.tsx
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/Feedback.tsx
rename to apps/antalmanac/src/components/Header/Feedback.tsx
diff --git a/apps/antalmanac/src/components/AppBar/CustomAppBar.tsx b/apps/antalmanac/src/components/Header/Header.tsx
similarity index 95%
rename from apps/antalmanac/src/components/AppBar/CustomAppBar.tsx
rename to apps/antalmanac/src/components/Header/Header.tsx
index 6f8c9321..61838c2b 100644
--- a/apps/antalmanac/src/components/AppBar/CustomAppBar.tsx
+++ b/apps/antalmanac/src/components/Header/Header.tsx
@@ -11,7 +11,7 @@ import ImportStudyList from './ImportStudyList';
 import LoadSaveScheduleFunctionality from './LoadSaveFunctionality';
 import News from './News';
 import SettingsMenu from './SettingsMenu';
-import Export from './Exports/Export';
+import Export from './Export';
 import Logo from './halloween-logo.png';
 import MobileLogo from './halloween-mobile-logo.png';
 
@@ -51,7 +51,7 @@ const components = [
     <AboutPage key="about" />,
 ];
 
-const CustomAppBar = ({ classes }: CustomAppBarProps) => {
+const Header = ({ classes }: CustomAppBarProps) => {
     const isMobileScreen = useMediaQuery('(max-width:750px)');
 
     const [anchorEl, setAnchorEl] = useState<Element | null>(null);
@@ -95,4 +95,4 @@ const CustomAppBar = ({ classes }: CustomAppBarProps) => {
     );
 };
 
-export default withStyles(styles)(CustomAppBar);
+export default withStyles(styles)(Header);
diff --git a/apps/antalmanac/src/components/AppBar/ImportStudyList.tsx b/apps/antalmanac/src/components/Header/ImportStudyList.tsx
similarity index 91%
rename from apps/antalmanac/src/components/AppBar/ImportStudyList.tsx
rename to apps/antalmanac/src/components/Header/ImportStudyList.tsx
index c84b5338..665f5fee 100644
--- a/apps/antalmanac/src/components/AppBar/ImportStudyList.tsx
+++ b/apps/antalmanac/src/components/Header/ImportStudyList.tsx
@@ -22,9 +22,12 @@ import TermSelector from '../RightPane/CoursePane/SearchForm/TermSelector';
 import RightPaneStore from '../RightPane/RightPaneStore';
 import { addCustomEvent, openSnackbar } from '$actions/AppStoreActions';
 import analyticsEnum, { logAnalytics } from '$lib/analytics';
-import { ZotCourseResponse, addCoursesMultiple, queryZotCourse } from '$lib/helpers';
+import { warnMultipleTerms } from '$lib/helpers';
 import AppStore from '$stores/AppStore';
-import { getCourseInfo, queryWebsoc } from '$lib/course-helpers';
+import WebSOC from '$lib/websoc';
+import { CourseInfo } from '$lib/course_data.types';
+import { addCourse } from '$actions/AppStoreActions';
+import { ZotCourseResponse, queryZotCourse } from '$lib/zotcourse';
 
 const styles = {
     inputLabel: {
@@ -66,6 +69,19 @@ class ImportStudyList extends PureComponent<ImportStudyListProps, ImportStudyLis
         this.setState({ isOpen: true });
     };
 
+    addCoursesMultiple = (
+        courseInfo: { [sectionCode: string]: CourseInfo },
+        term: string,
+        scheduleIndex: number
+    ) => {
+        for (const section of Object.values(courseInfo)) {
+            addCourse(section.section, section.courseDetails, term, scheduleIndex, true);
+        }
+        const terms = AppStore.termsInSchedule(term);
+        if (terms.size > 1) warnMultipleTerms(terms);
+        return Object.values(courseInfo).length;
+    };
+
     handleClose = (doImport: boolean) => {
         this.setState({ isOpen: false }, async () => {
             document.removeEventListener('keydown', this.enterEvent, false);
@@ -93,13 +109,11 @@ class ImportStudyList extends PureComponent<ImportStudyListProps, ImportStudyLis
                 }
 
                 try {
-                    const sectionsAdded = addCoursesMultiple(
-                        getCourseInfo(
-                            await queryWebsoc({
-                                term: this.state.selectedTerm,
-                                sectionCodes: sectionCodes.join(','),
-                            })
-                        ),
+                    const sectionsAdded = this.addCoursesMultiple(
+                        await WebSOC.getCourseInfo({
+                            term: this.state.selectedTerm,
+                            sectionCodes: sectionCodes.join(','),
+                        }),
                         this.state.selectedTerm,
                         currSchedule
                     );
diff --git a/apps/antalmanac/src/components/AppBar/LoadSaveFunctionality.tsx b/apps/antalmanac/src/components/Header/LoadSaveFunctionality.tsx
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/LoadSaveFunctionality.tsx
rename to apps/antalmanac/src/components/Header/LoadSaveFunctionality.tsx
diff --git a/apps/antalmanac/src/components/AppBar/News.tsx b/apps/antalmanac/src/components/Header/News.tsx
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/News.tsx
rename to apps/antalmanac/src/components/Header/News.tsx
diff --git a/apps/antalmanac/src/components/AppBar/NotificationHub.tsx b/apps/antalmanac/src/components/Header/NotificationHub.tsx
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/NotificationHub.tsx
rename to apps/antalmanac/src/components/Header/NotificationHub.tsx
diff --git a/apps/antalmanac/src/components/AppBar/SettingsMenu.tsx b/apps/antalmanac/src/components/Header/SettingsMenu.tsx
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/SettingsMenu.tsx
rename to apps/antalmanac/src/components/Header/SettingsMenu.tsx
diff --git a/apps/antalmanac/src/components/AppBar/halloween-logo.png b/apps/antalmanac/src/components/Header/halloween-logo.png
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/halloween-logo.png
rename to apps/antalmanac/src/components/Header/halloween-logo.png
diff --git a/apps/antalmanac/src/components/AppBar/halloween-mobile-logo.png b/apps/antalmanac/src/components/Header/halloween-mobile-logo.png
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/halloween-mobile-logo.png
rename to apps/antalmanac/src/components/Header/halloween-mobile-logo.png
diff --git a/apps/antalmanac/src/components/Header/index.tsx b/apps/antalmanac/src/components/Header/index.tsx
new file mode 100644
index 00000000..579f1ac2
--- /dev/null
+++ b/apps/antalmanac/src/components/Header/index.tsx
@@ -0,0 +1,1 @@
+export { default } from './Header';
diff --git a/apps/antalmanac/src/components/AppBar/logo.svg b/apps/antalmanac/src/components/Header/logo.svg
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/logo.svg
rename to apps/antalmanac/src/components/Header/logo.svg
diff --git a/apps/antalmanac/src/components/AppBar/mobile-logo.svg b/apps/antalmanac/src/components/Header/mobile-logo.svg
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/mobile-logo.svg
rename to apps/antalmanac/src/components/Header/mobile-logo.svg
diff --git a/apps/antalmanac/src/components/Map/Map.css b/apps/antalmanac/src/components/Map/Map.css
index fed1eb0c..ad46bf70 100644
--- a/apps/antalmanac/src/components/Map/Map.css
+++ b/apps/antalmanac/src/components/Map/Map.css
@@ -19,7 +19,9 @@
 }
 
 /**
+ * Handle styling for the leaflet popup
  */
 .leaflet-popup-content-wrapper {
-    border-radius: unset;
+    padding: 0;
+    width: 250px;
 }
diff --git a/apps/antalmanac/src/components/Map/Map.tsx b/apps/antalmanac/src/components/Map/Map.tsx
index c59c9172..b329a510 100644
--- a/apps/antalmanac/src/components/Map/Map.tsx
+++ b/apps/antalmanac/src/components/Map/Map.tsx
@@ -74,14 +74,7 @@ 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
-);
+const buildings = Object.entries(buildingCatalogue);
 
 /**
  * Map of all course locations on UCI campus.
@@ -102,10 +95,12 @@ export default function CourseMap() {
 
         AppStore.on('addedCoursesChange', updateMarkers);
         AppStore.on('currentScheduleIndexChange', updateMarkers);
+        AppStore.on('colorChange', updateMarkers);
 
         return () => {
             AppStore.removeListener('addedCoursesChange', updateMarkers);
             AppStore.removeListener('currentScheduleIndexChange', updateMarkers);
+            AppStore.removeListener('colorChange', updateMarkers);
         };
     }, []);
 
@@ -272,12 +267,15 @@ export default function CourseMap() {
                                 stackIndex={coursesSameBuildingPrior.length}
                             >
                                 <Box>
-                                    <Typography variant="body2">
-                                        Class: {marker.title} {marker.sectionType}
+                                    <Typography variant="body1">
+                                        <span style={{ fontWeight: 'bold' }}>Class:</span> {marker.title}{' '}
+                                        {marker.sectionType}
                                     </Typography>
-                                    <Typography variant="body2">
-                                        Room{allRoomsInBuilding.length > 1 && 's'}: {marker.locations[0].building}{' '}
-                                        {allRoomsInBuilding.join('/')}
+                                    <Typography variant="body1">
+                                        <span style={{ fontWeight: 'bold' }}>
+                                            Room{allRoomsInBuilding.length > 1 && 's'}:
+                                        </span>{' '}
+                                        {marker.locations[0].building} {allRoomsInBuilding.join('/')}
                                     </Typography>
                                 </Box>
                             </LocationMarker>
diff --git a/apps/antalmanac/src/components/Map/Marker.tsx b/apps/antalmanac/src/components/Map/Marker.tsx
index edd8fa8f..34a0110d 100644
--- a/apps/antalmanac/src/components/Map/Marker.tsx
+++ b/apps/antalmanac/src/components/Map/Marker.tsx
@@ -1,8 +1,8 @@
 import { forwardRef, type Ref } from 'react';
 import Leaflet from 'leaflet';
 import { Marker, Popup } from 'react-leaflet';
-import { Box, Button, Link, Typography } from '@mui/material';
-import { DirectionsWalk as DirectionsWalkIcon } from '@mui/icons-material';
+import { Box, Button, IconButton, Typography } from '@mui/material';
+import { DirectionsWalk as DirectionsWalkIcon, Info } from '@mui/icons-material';
 
 const GOOGLE_MAPS_URL = 'https://www.google.com/maps/dir/?api=1&travelmode=walking&destination=';
 const IMAGE_CMS_URL = 'https://cms.concept3d.com/map/lib/image-cache/i.php?mapId=463&image=';
@@ -82,53 +82,64 @@ const LocationMarker = forwardRef(
                 <Popup>
                     <Box
                         sx={{
-                            width: 200,
                             display: 'flex',
                             flexDirection: 'column',
-                            gap: 0.5,
                             justifyContent: 'center',
-                            m: 2,
+                            width: 250,
                         }}
                     >
-                        <Box>
-                            {location ? (
-                                <Link
-                                    href={`http://www.classrooms.uci.edu/classrooms/${acronym}`}
-                                    target="_blank"
-                                    rel="noopener noreferrer"
-                                    textAlign="center"
-                                    variant="h6"
-                                >
-                                    {location}
-                                </Link>
-                            ) : (
-                                <Typography textAlign="center">{location}</Typography>
-                            )}
-                        </Box>
-
                         {image && (
-                            <Box sx={{ mx: 'auto', my: 1, width: 150, height: 150 }}>
-                                <Box
-                                    component="img"
-                                    src={`${IMAGE_CMS_URL}${image}`}
-                                    alt="Building Snapshot"
-                                    sx={{ width: 1, height: 1, objectFit: 'cover' }}
-                                />
-                            </Box>
+                            <Box
+                                height={150}
+                                borderRadius={'0.75rem 0.75rem 0 0'}
+                                component="img"
+                                src={`${IMAGE_CMS_URL}${image}`}
+                                alt="Building Snapshot"
+                                sx={{
+                                    objectFit: 'cover',
+                                }}
+                            />
                         )}
 
-                        {children}
+                        <Box display="flex" flexDirection="column" mx={2} my={1.25} gap={1}>
+                            <Box display="flex" flexDirection="column" gap={0.5}>
+                                <Box display="flex" justifyContent="space-between" alignItems="flex-start">
+                                    <Typography fontSize={'1.25rem'} lineHeight={1.25} fontWeight={600}>
+                                        {location}
+                                    </Typography>
+                                    {location && (
+                                        <IconButton
+                                            href={`http://www.classrooms.uci.edu/classrooms/${acronym}`}
+                                            target="_blank"
+                                            size="medium"
+                                            sx={{ padding: 0 }}
+                                        >
+                                            <Info fontSize="large" color="primary" />
+                                        </IconButton>
+                                    )}
+                                </Box>
 
-                        <Button
-                            variant="contained"
-                            color="inherit"
-                            startIcon={<DirectionsWalkIcon />}
-                            href={`${GOOGLE_MAPS_URL}${lat},${lng}`}
-                            target="_blank"
-                            sx={{ alignSelf: 'center' }}
-                        >
-                            Directions
-                        </Button>
+                                {children}
+                            </Box>
+
+                            <Button
+                                variant="contained"
+                                color="primary"
+                                startIcon={<DirectionsWalkIcon color="secondary" />}
+                                href={`${GOOGLE_MAPS_URL}${lat},${lng}`}
+                                target="_blank"
+                                sx={{ alignSelf: 'center', width: '100%', borderRadius: '0.75rem' }}
+                            >
+                                <Typography
+                                    color="secondary"
+                                    fontSize={'1.25rem'}
+                                    letterSpacing={1.25}
+                                    fontWeight={500}
+                                >
+                                    Directions
+                                </Typography>
+                            </Button>
+                        </Box>
                     </Box>
                 </Popup>
             </Marker>
diff --git a/apps/antalmanac/src/components/AppBar/NotificationSnackbar.tsx b/apps/antalmanac/src/components/NotificationSnackbar.tsx
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/NotificationSnackbar.tsx
rename to apps/antalmanac/src/components/NotificationSnackbar.tsx
diff --git a/apps/antalmanac/src/components/PatchNotes.tsx b/apps/antalmanac/src/components/PatchNotes.tsx
index eab0a78b..601003c8 100644
--- a/apps/antalmanac/src/components/PatchNotes.tsx
+++ b/apps/antalmanac/src/components/PatchNotes.tsx
@@ -51,15 +51,18 @@ function PatchNotes() {
             data-testid={dialogTestId}
             slots={{ backdrop: PatchNotesBackdrop }}
         >
-            <DialogTitle>{"What's New - August 2023"}</DialogTitle>
+            <DialogTitle>{"What's New - October 2023"}</DialogTitle>
 
             <DialogContent>
                 <Typography>Features</Typography>
                 <ul>
-                    <li>Courses will now be greyed out if they conflict with your current schedule</li>
+                    <li>
+                        You can now hover over the Zotistics button to see the Zotistics graph! On mobile, you can still
+                        click the Zotistics button to toggle the graph.
+                    </li>
                 </ul>
                 <img
-                    src="https://user-images.githubusercontent.com/100006999/255796434-10555ecb-5632-4ff3-8be3-c04267722011.gif"
+                    src="https://user-images.githubusercontent.com/78244965/277567417-f9816b9d-ddda-4c0f-80f4-eeac92428612.gif"
                     alt="(gif of the new feature)"
                     style={{
                         maxWidth: '100%',
diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx
index 19e81601..625e17d2 100644
--- a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx
+++ b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneButtonRow.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import {
     Box,
     Checkbox,
@@ -10,6 +10,7 @@ import {
     Tooltip,
     type SelectChangeEvent,
     type SxProps,
+    Popover,
 } from '@mui/material';
 import { ArrowBack, Visibility, Refresh } from '@mui/icons-material';
 import { useColumnStore, SECTION_TABLE_COLUMNS, type SectionTableColumn } from '$stores/ColumnStore';
@@ -65,20 +66,24 @@ export function ColumnToggleButton() {
         store.selectedColumns,
         store.setSelectedColumns,
     ]);
-    const [open, setOpen] = useState(false);
+    const [anchorEl, setAnchorEl] = useState<HTMLElement>();
+    const open = Boolean(anchorEl);
 
-    const handleChange = useCallback((e: SelectChangeEvent<SectionTableColumn[]>) => {
-        if (typeof e.target.value !== 'string') {
-            setSelectedColumns(e.target.value);
-        }
-    }, []);
+    const handleChange = useCallback(
+        (e: SelectChangeEvent<SectionTableColumn[]>) => {
+            if (typeof e.target.value !== 'string') {
+                setSelectedColumns(e.target.value);
+            }
+        },
+        [setSelectedColumns]
+    );
 
-    const handleOpen = useCallback(() => {
-        setOpen(true);
-    }, [setOpen]);
+    const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
+        setAnchorEl(event.currentTarget);
+    }, []);
 
     const handleClose = useCallback(() => {
-        setOpen(false);
+        setAnchorEl(undefined);
     }, []);
 
     const selectedColumnNames = useMemo(
@@ -89,28 +94,31 @@ export function ColumnToggleButton() {
     return (
         <>
             <Tooltip title="Show/Hide Columns">
-                <IconButton onClick={handleOpen} sx={buttonSx}>
+                <IconButton onClick={handleClick} sx={buttonSx}>
                     <Visibility />
                 </IconButton>
             </Tooltip>
-            <FormControl>
-                <Select
-                    multiple
-                    value={selectedColumnNames}
-                    open={open}
-                    onChange={handleChange}
-                    onClose={handleClose}
-                    renderValue={renderEmptySelectValue}
-                    sx={{ visibility: 'hidden', position: 'absolute' }}
-                >
-                    {COLUMN_LABEL_ENTRIES.map(([column, label], index) => (
-                        <MenuItem key={column} value={column}>
-                            <Checkbox checked={selectedColumns[index]} color="default" />
-                            <ListItemText primary={label} />
-                        </MenuItem>
-                    ))}
-                </Select>
-            </FormControl>
+
+            <Popover open={open} anchorEl={anchorEl} onClose={handleClose} sx={{ visibility: 'hidden' }}>
+                <FormControl>
+                    <Select
+                        multiple
+                        value={selectedColumnNames}
+                        open={open}
+                        onChange={handleChange}
+                        onClose={handleClose}
+                        renderValue={renderEmptySelectValue}
+                        MenuProps={{ anchorEl }}
+                    >
+                        {COLUMN_LABEL_ENTRIES.map(([column, label], index) => (
+                            <MenuItem key={column} value={column}>
+                                <Checkbox checked={selectedColumns[index]} color="default" />
+                                <ListItemText primary={label} />
+                            </MenuItem>
+                        ))}
+                    </Select>
+                </FormControl>
+            </Popover>
         </>
     );
 }
diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneRoot.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneRoot.tsx
index ddd4adc6..759c3f89 100644
--- a/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneRoot.tsx
+++ b/apps/antalmanac/src/components/RightPane/CoursePane/CoursePaneRoot.tsx
@@ -6,7 +6,8 @@ import CourseRenderPane from './CourseRenderPane';
 import SearchForm from './SearchForm/SearchForm';
 import analyticsEnum, { logAnalytics } from '$lib/analytics';
 import { openSnackbar } from '$actions/AppStoreActions';
-import { clearCache } from '$lib/course-helpers';
+import WebSOC from '$lib/websoc';
+import Grades from '$lib/grades';
 
 function RightPane() {
     const [key, forceUpdate] = useReducer((currentCount) => currentCount + 1, 0);
@@ -33,7 +34,8 @@ function RightPane() {
             category: analyticsEnum.classSearch.title,
             action: analyticsEnum.classSearch.actions.REFRESH,
         });
-        clearCache();
+        WebSOC.clearCache();
+        Grades.clearCache();
         forceUpdate();
     }, []);
 
@@ -66,7 +68,7 @@ function RightPane() {
             {RightPaneStore.getDoDisplaySearch() ? (
                 <SearchForm toggleSearch={toggleSearch} />
             ) : (
-                <CourseRenderPane key={key} />
+                <CourseRenderPane key={key} id={key} />
             )}
         </div>
     );
diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx
index 5ac2e23f..2375e8b8 100644
--- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx
+++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx
@@ -1,9 +1,8 @@
-import { IconButton } from '@material-ui/core';
-import CloseIcon from '@material-ui/icons/Close';
-import React, { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
 import LazyLoad from 'react-lazyload';
 
-import { Alert } from '@mui/material';
+import { Alert, Box, IconButton } from '@mui/material';
+import { Close } from '@mui/icons-material';
 import { AACourse, AASection } from '@packages/antalmanac-types';
 import { WebsocDepartment, WebsocSchool, WebsocAPIResponse, GE } from 'peterportal-api-next-types';
 import RightPaneStore from '../RightPaneStore';
@@ -15,16 +14,24 @@ import loadingGif from './SearchForm/Gifs/loading.gif';
 import darkNoNothing from './static/dark-no_results.png';
 import noNothing from './static/no_results.png';
 import AppStore from '$stores/AppStore';
-import { isDarkMode, queryWebsoc, queryWebsocMultiple } from '$lib/helpers';
+import { isDarkMode } from '$lib/helpers';
 import Grades from '$lib/grades';
 import analyticsEnum from '$lib/analytics';
 import { openSnackbar } from '$actions/AppStoreActions';
+import WebSOC from '$lib/websoc';
 
-function flattenSOCObject(SOCObject: WebsocAPIResponse): (WebsocSchool | WebsocDepartment | AACourse)[] {
-    const courseColors = AppStore.getAddedCourses().reduce((accumulator, { section }) => {
+function getColors() {
+    const courseColors = AppStore.schedule.getCurrentCourses().reduce((accumulator, { section }) => {
         accumulator[section.sectionCode] = section.color;
         return accumulator;
     }, {} as { [key: string]: string });
+
+    return courseColors;
+}
+
+const flattenSOCObject = (SOCObject: WebsocAPIResponse): (WebsocSchool | WebsocDepartment | AACourse)[] => {
+    const courseColors = getColors();
+
     return SOCObject.schools.reduce((accumulator: (WebsocSchool | WebsocDepartment | AACourse)[], school) => {
         accumulator.push(school);
 
@@ -41,10 +48,9 @@ function flattenSOCObject(SOCObject: WebsocAPIResponse): (WebsocSchool | WebsocD
 
         return accumulator;
     }, []);
-}
-
+};
 const RecruitmentBanner = () => {
-    const [bannerVisibility, setBannerVisibility] = React.useState<boolean>(true);
+    const [bannerVisibility, setBannerVisibility] = useState(true);
 
     // Display recruitment banner if more than 11 weeks (in ms) has passed since last dismissal
     const recruitmentDismissalTime = window.localStorage.getItem('recruitmentDismissalTime');
@@ -55,7 +61,7 @@ const RecruitmentBanner = () => {
     const displayRecruitmentBanner = bannerVisibility && !dismissedRecently && isSearchCS;
 
     return (
-        <div style={{ position: 'fixed', bottom: 5, right: 5, zIndex: 999 }}>
+        <Box sx={{ position: 'fixed', bottom: 5, right: 5, zIndex: 999 }}>
             {displayRecruitmentBanner ? (
                 <Alert
                     icon={false}
@@ -74,7 +80,7 @@ const RecruitmentBanner = () => {
                                 setBannerVisibility(false);
                             }}
                         >
-                            <CloseIcon fontSize="inherit" />
+                            <Close fontSize="inherit" />
                         </IconButton>
                     }
                 >
@@ -86,8 +92,8 @@ const RecruitmentBanner = () => {
                     <br />
                     We have opportunities for experienced devs and those with zero experience!
                 </Alert>
-            ) : null}{' '}
-        </div>
+            ) : null}
+        </Box>
     );
 };
 
@@ -137,33 +143,38 @@ const SectionTableWrapped = (
     return <div>{component}</div>;
 };
 
-export function CourseRenderPane() {
+const LoadingMessage = () => {
+    return (
+        <Box sx={{ height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
+            <img src={isDarkMode() ? darkModeLoadingGif : loadingGif} alt="Loading courses" />
+        </Box>
+    );
+};
+
+const ErrorMessage = () => {
+    return (
+        <Box sx={{ height: '100%', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
+            <img
+                src={isDarkMode() ? darkNoNothing : noNothing}
+                alt="No Results Found"
+                style={{ objectFit: 'contain', width: '80%', height: '80%' }}
+            />
+        </Box>
+    );
+};
+
+export default function CourseRenderPane(props: { id?: number }) {
+    const [websocResp, setWebsocResp] = useState<WebsocAPIResponse>();
+    const [courseData, setCourseData] = useState<(WebsocSchool | WebsocDepartment | AACourse)[]>([]);
     const [loading, setLoading] = useState(true);
     const [error, setError] = useState(false);
     const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames());
-    const [courseData, setCourseData] = useState<(WebsocSchool | WebsocDepartment | AACourse)[]>([]);
 
     const loadCourses = useCallback(async () => {
         setLoading(true);
 
         const formData = RightPaneStore.getFormData();
 
-        const params = {
-            department: formData.deptValue,
-            term: formData.term,
-            ge: formData.ge,
-            courseNumber: formData.courseNumber,
-            sectionCodes: formData.sectionCode,
-            instructorName: formData.instructor,
-            units: formData.units,
-            endTime: formData.endTime,
-            startTime: formData.startTime,
-            fullCourses: formData.coursesFull,
-            building: formData.building,
-            room: formData.room,
-            division: formData.division,
-        };
-
         const websocQueryParams = {
             department: formData.deptValue,
             term: formData.term,
@@ -189,12 +200,18 @@ export function CourseRenderPane() {
             // Query websoc for course information and populate gradescache
             const [websocJsonResp, _] = await Promise.all([
                 websocQueryParams.units.includes(',')
-                    ? queryWebsocMultiple(websocQueryParams, 'units')
-                    : queryWebsoc(websocQueryParams),
-                Grades.populateGradesCache(gradesQueryParams),
+                    ? WebSOC.queryMultiple(websocQueryParams, 'units')
+                    : WebSOC.query(websocQueryParams),
+                // Catch the error here so that the course pane still loads even if the grades cache fails to populate
+                Grades.populateGradesCache(gradesQueryParams)
+                    .catch((error) => {
+                        console.error(error);
+                        openSnackbar('error', 'Error loading grades information');
+                    }),
             ]);
 
             setError(false);
+            setWebsocResp(websocJsonResp);
             setCourseData(flattenSOCObject(websocJsonResp));
         } catch (error) {
             console.error(error);
@@ -205,95 +222,61 @@ export function CourseRenderPane() {
         }
     }, []);
 
-    useEffect(() => {
-        loadCourses();
-    }, []);
+    const updateScheduleNames = () => {
+        setScheduleNames(AppStore.getScheduleNames());
+    };
 
     useEffect(() => {
-        const updateScheduleNames = () => {
-            setScheduleNames(AppStore.getScheduleNames());
+        const changeColors = () => {
+            if (websocResp == null) {
+                return;
+            }
+            setCourseData(flattenSOCObject(websocResp));
         };
 
-        AppStore.on('scheduleNamesChange', updateScheduleNames);
+        AppStore.on('currentScheduleIndexChange', changeColors);
 
         return () => {
-            AppStore.off('scheduleNamesChange', updateScheduleNames);
+            AppStore.off('currentScheduleIndexChange', changeColors);
         };
-    }, []);
+    }, [websocResp]);
 
-    if (loading) {
-        return (
-            <div
-                style={{
-                    height: '100%',
-                    width: '100%',
-                    display: 'flex',
-                    justifyContent: 'center',
-                    alignItems: 'center',
-                }}
-            >
-                <img src={isDarkMode() ? darkModeLoadingGif : loadingGif} alt="Loading courses" />
-            </div>
-        );
-    }
+    useEffect(() => {
+        loadCourses();
+        AppStore.on('scheduleNamesChange', updateScheduleNames);
 
-    if (error) {
-        return (
-            <div
-                style={{
-                    height: '100%',
-                    overflowY: 'scroll',
-                    position: 'relative',
-                }}
-            >
-                <div
-                    style={{
-                        height: '100%',
-                        width: '100%',
-                        display: 'flex',
-                        justifyContent: 'center',
-                        alignItems: 'center',
-                    }}
-                >
-                    <img src={isDarkMode() ? darkNoNothing : noNothing} alt="No Results Found" />
-                </div>
-            </div>
-        );
-    }
+        return () => {
+            AppStore.off('scheduleNamesChange', updateScheduleNames);
+        };
+    }, [loadCourses, props.id]);
 
     return (
         <>
-            <RecruitmentBanner />
-            <div style={{ height: '100%', overflowY: 'scroll', position: 'relative' }}>
-                <div style={{ height: '50px', marginBottom: '5px' }} />
-                {courseData.length === 0 ? (
-                    <div
-                        style={{
-                            height: '100%',
-                            width: '100%',
-                            display: 'flex',
-                            justifyContent: 'center',
-                            alignItems: 'center',
-                        }}
-                    >
-                        <img src={isDarkMode() ? darkNoNothing : noNothing} alt="No Results Found" />
-                    </div>
-                ) : (
-                    courseData.map((_, index: number) => {
-                        let heightEstimate = 200;
-                        if ((courseData[index] as AACourse).sections !== undefined)
-                            heightEstimate = (courseData[index] as AACourse).sections.length * 60 + 20 + 40;
-
-                        return (
-                            <LazyLoad once key={index} overflow height={heightEstimate} offset={500}>
-                                {SectionTableWrapped(index, { courseData, scheduleNames })}
-                            </LazyLoad>
-                        );
-                    })
-                )}
-            </div>
+            {loading ? (
+                <LoadingMessage />
+            ) : error || courseData.length === 0 ? (
+                <ErrorMessage />
+            ) : (
+                <>
+                    <RecruitmentBanner />
+                    <Box>
+                        <Box sx={{ height: '50px', marginBottom: '5px' }} />
+                        {courseData.map((_: WebsocSchool | WebsocDepartment | AACourse, index: number) => {
+                            let heightEstimate = 200;
+                            if ((courseData[index] as AACourse).sections !== undefined)
+                                heightEstimate = (courseData[index] as AACourse).sections.length * 60 + 20 + 40;
+                            return (
+                                <LazyLoad once key={index} overflow height={heightEstimate} offset={500}>
+                                    {SectionTableWrapped(index, {
+                                        courseData: courseData,
+                                        scheduleNames: scheduleNames,
+                                    })}
+                                </LazyLoad>
+                            );
+                        })}
+                    </Box>
+                </>
+            )}
         </>
     );
 }
-
-export default CourseRenderPane;
diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/FuzzySearch.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/FuzzySearch.tsx
index 9383bf78..76354b74 100644
--- a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/FuzzySearch.tsx
+++ b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/FuzzySearch.tsx
@@ -1,7 +1,9 @@
 import TextField from '@material-ui/core/TextField';
 import Autocomplete, { AutocompleteInputChangeReason } from '@material-ui/lab/Autocomplete';
-import { ChangeEvent, PureComponent } from 'react';
-import search, { SearchResult } from 'websoc-fuzzy-search';
+import { PureComponent } from 'react';
+import search from 'websoc-fuzzy-search';
+
+type SearchResult = ReturnType<typeof search>;
 
 import RightPaneStore from '../../RightPaneStore';
 import analyticsEnum, { logAnalytics } from '$lib/analytics';
@@ -21,9 +23,9 @@ interface FuzzySearchProps {
 }
 
 interface FuzzySearchState {
-    cache: Record<string, Record<string, SearchResult>>;
+    cache: Record<string, SearchResult>;
     open: boolean;
-    results: Record<string, SearchResult>;
+    results: SearchResult;
     value: string;
 }
 
@@ -57,14 +59,14 @@ class FuzzySearch extends PureComponent<FuzzySearchProps, FuzzySearchState> {
                 const deptValue = ident[0].split(' ').slice(0, -1).join(' ');
                 let deptLabel;
                 for (const [key, value] of Object.entries(this.state.cache)) {
-                    if (Object.keys(value).includes(deptValue)) {
-                        deptLabel = this.state.cache[key][deptValue].name;
+                    if (Object.keys(value ?? {}).includes(deptValue)) {
+                        deptLabel = this.state.cache[key]?.[deptValue].name;
                         break;
                     }
                 }
                 if (!deptLabel) {
                     const deptSearch = search({ query: deptValue.toLowerCase(), numResults: 1 });
-                    if (deptSearch[deptValue]) {
+                    if (deptSearch?.[deptValue]) {
                         deptLabel = deptSearch[deptValue].name;
                         this.setState({
                             cache: {
@@ -82,7 +84,7 @@ class FuzzySearch extends PureComponent<FuzzySearchProps, FuzzySearchState> {
             case emojiMap.INSTRUCTOR:
                 RightPaneStore.updateFormValue(
                     'instructor',
-                    Object.keys(this.state.results).filter((x) => this.state.results[x].name === ident[0])[0]
+                    Object.keys(this.state.results ?? {}).filter((x) => this.state.results?.[x].name === ident[0])[0]
                 );
                 break;
             default:
@@ -98,7 +100,7 @@ class FuzzySearch extends PureComponent<FuzzySearchProps, FuzzySearchState> {
     filterOptions = (options: string[]) => options;
 
     getOptionLabel = (option: string) => {
-        const object = this.state.results[option];
+        const object = this.state.results?.[option];
         if (!object) return option;
         switch (object.type) {
             case 'GE_CATEGORY': {
@@ -111,9 +113,9 @@ class FuzzySearch extends PureComponent<FuzzySearchProps, FuzzySearchState> {
             case 'DEPARTMENT':
                 return `${emojiMap.DEPARTMENT} ${option}: ${object.name}`;
             case 'COURSE':
-                // TODO: fix our `websoc-fuzzy-search` package to strongly type `object.metadata` correctly
-                // eslint-disable-next-line @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unsafe-member-access
-                return `${emojiMap.COURSE} ${object.metadata.department} ${object.metadata.number}: ${object.name}`;
+                return `${emojiMap.COURSE} ${object.metadata.department} ${(object.metadata as any).number}: ${
+                    object.name
+                }`;
             case 'INSTRUCTOR':
                 return `${emojiMap.INSTRUCTOR} ${object.name}`;
             default:
@@ -124,7 +126,7 @@ class FuzzySearch extends PureComponent<FuzzySearchProps, FuzzySearchState> {
 
     getOptionSelected = () => true;
 
-    onInputChange = (event: ChangeEvent<unknown>, value: string, reason: AutocompleteInputChangeReason) => {
+    onInputChange = (_event: unknown, value: string, reason: AutocompleteInputChangeReason) => {
         const lowerCaseValue = value.toLowerCase();
         if (reason === 'input') {
             this.setState(
@@ -165,10 +167,14 @@ class FuzzySearch extends PureComponent<FuzzySearchProps, FuzzySearchState> {
         return (
             <Autocomplete
                 style={{ width: '100%' }}
-                options={Object.keys(this.state.results)}
+                options={Object.keys(this.state.results ?? {})}
                 renderInput={(params) => (
-                    // eslint-disable-next-line @typescript-eslint/no-unsafe-return
-                    <TextField {...params} inputRef={(input) => input} fullWidth label={'Search'} />
+                    <TextField
+                        {...params}
+                        inputRef={(input: HTMLInputElement | null) => input && input.focus()}
+                        fullWidth
+                        label={'Search'}
+                    />
                 )}
                 autoHighlight={true}
                 filterOptions={this.filterOptions}
diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/CourseInfoButton.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/CourseInfoButton.tsx
index 0f54c60f..4899f127 100644
--- a/apps/antalmanac/src/components/RightPane/SectionTable/CourseInfoButton.tsx
+++ b/apps/antalmanac/src/components/RightPane/SectionTable/CourseInfoButton.tsx
@@ -1,7 +1,7 @@
-import { Button, Popover, useMediaQuery } from '@material-ui/core';
+import { Button, Paper, Popper, useMediaQuery } from '@material-ui/core';
 import { withStyles } from '@material-ui/core/styles';
 import { ClassNameMap } from '@material-ui/core/styles/withStyles';
-import React, { useState } from 'react';
+import React, { useEffect, useState } from 'react';
 
 import { MOBILE_BREAKPOINT } from '../../../globals';
 import { logAnalytics } from '$lib/analytics';
@@ -34,25 +34,55 @@ function CourseInfoButton({
 }: CourseInfoButtonProps) {
     const [popupAnchor, setPopupAnchor] = useState<HTMLElement | null>(null);
     const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT})`);
+    const [isClicked, setIsClicked] = useState(false);
+
+    useEffect(() => {
+        // When the user clicks on the button, it triggers both onMouseEnter
+        // and onClick. In order to log the analytics only once, we should
+        // have this hook when the popupAnchor changes
+        if (popupAnchor) {
+            logAnalytics({
+                category: analyticsCategory,
+                action: analyticsAction,
+            });
+        }
+    }, [popupAnchor, analyticsCategory, analyticsAction]);
+
+    const handleMouseEnter = (event: React.MouseEvent<HTMLElement>) => {
+        // If there is popup content, allow the content to be shown when the button is hovered
+        // Note that on mobile devices, hovering is not possible, so the popup still needs to be able
+        // to appear when the button is clicked
+        if (popupContent) {
+            setPopupAnchor(event.currentTarget);
+        }
+    };
+
+    const handleMouseLeave = () => {
+        if (popupContent) {
+            setIsClicked(false);
+            setPopupAnchor(null);
+        }
+    };
+
     return (
-        <>
+        <div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} style={{ display: 'flex' }}>
             <Button
                 className={classes.button}
                 startIcon={!isMobileScreen && icon}
                 variant="contained"
                 size="small"
                 onClick={(event: React.MouseEvent<HTMLElement>) => {
-                    logAnalytics({
-                        category: analyticsCategory,
-                        action: analyticsAction,
-                    });
-
                     if (redirectLink) {
                         window.open(redirectLink);
                     }
 
                     if (popupContent) {
-                        setPopupAnchor(event.currentTarget);
+                        // This is mostly used for devices that don't support hovering
+                        // and thus only support clicking to open/close the popup
+                        // If isClicked is true, then the popup is currently visible; otherwise, if
+                        // isClicked is false, then the popup is currently hidden
+                        setPopupAnchor(isClicked ? null : event.currentTarget);
+                        setIsClicked((prev) => !prev);
                     }
                 }}
             >
@@ -60,23 +90,11 @@ function CourseInfoButton({
             </Button>
 
             {popupContent && (
-                <Popover
-                    anchorEl={popupAnchor}
-                    open={Boolean(popupAnchor)}
-                    onClose={() => setPopupAnchor(null)}
-                    anchorOrigin={{
-                        vertical: 'bottom',
-                        horizontal: 'center',
-                    }}
-                    transformOrigin={{
-                        vertical: 'top',
-                        horizontal: 'center',
-                    }}
-                >
-                    {popupContent}
-                </Popover>
+                <Popper anchorEl={popupAnchor} open={Boolean(popupAnchor)} placement="bottom">
+                    <Paper>{popupContent}</Paper>
+                </Popper>
             )}
-        </>
+        </div>
     );
 }
 
diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GEDataFetchProvider.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GEDataFetchProvider.tsx
index ed94a0f8..c324e1cf 100644
--- a/apps/antalmanac/src/components/RightPane/SectionTable/GEDataFetchProvider.tsx
+++ b/apps/antalmanac/src/components/RightPane/SectionTable/GEDataFetchProvider.tsx
@@ -3,7 +3,7 @@ import { PureComponent } from 'react';
 import RightPaneStore from '../RightPaneStore';
 import { SectionTableProps } from './SectionTable.types';
 import SectionTableLazyWrapper from './SectionTableLazyWrapper';
-import { queryWebsoc } from '$lib/course-helpers';
+import WebSOC from '$lib/websoc';
 
 /**
  * If we remove this class, when you search a department+GE combo, only the lectures show up, not the discussions.
@@ -26,7 +26,7 @@ class GeDataFetchProvider extends PureComponent<SectionTableProps> {
             courseTitle: this.props.courseDetails.courseTitle,
         };
 
-        const jsonResp = await queryWebsoc(params);
+        const jsonResp = await WebSOC.query(params);
 
         this.setState({
             courseDetails: jsonResp.schools[0].departments[0].courses[0],
diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx
index 16616d16..01045f8d 100644
--- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx
+++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx
@@ -57,20 +57,22 @@ function GradesPopup(props: GradesPopupProps) {
 
     const [gradeData, setGradeData] = useState<GradeData>();
 
-    const width = useMemo(() => (isMobileScreen ? 300 : 500), [isMobileScreen]);
+    const width = useMemo(() => (isMobileScreen ? 250 : 400), [isMobileScreen]);
 
-    const height = useMemo(() => (isMobileScreen ? 200 : 300), [isMobileScreen]);
+    const height = useMemo(() => (isMobileScreen ? 150 : 200), [isMobileScreen]);
 
     const graphTitle = useMemo(() => {
         return gradeData
-            ? `${deptCode} ${courseNumber}${instructor ? ` — ${instructor}` : "" } | Average GPA: ${gradeData.courseGrades.averageGPA.toFixed(2)}`
+            ? `${deptCode} ${courseNumber}${
+                  instructor ? ` — ${instructor}` : ''
+              } | Average GPA: ${gradeData.courseGrades.averageGPA.toFixed(2)}`
             : 'Grades are not available for this class.';
     }, [deptCode, instructor, gradeData]);
 
     const gpaString = useMemo(
-        () => (gradeData ? `Average GPA: ${gradeData.courseGrades.averageGPA.toFixed(2)}` : ""), 
+        () => (gradeData ? `Average GPA: ${gradeData.courseGrades.averageGPA.toFixed(2)}` : ''),
         [gradeData]
-    )
+    );
 
     useEffect(() => {
         if (loading === false) {
@@ -107,15 +109,15 @@ function GradesPopup(props: GradesPopupProps) {
     const axisColor = isDarkMode() ? '#fff' : '#111';
 
     return (
-        <Box>
+        <Box sx={{ padding: '4px' }}>
             <Typography
                 sx={{
                     marginTop: '.5rem',
                     textAlign: 'center',
                     fontWeight: 500,
-                    fontSize: '1.2rem',
-                    marginRight: '4rem',
-                    marginLeft: '4rem',
+                    marginRight: '2rem',
+                    marginLeft: '2rem',
+                    marginBottom: '.5rem',
                 }}
             >
                 {graphTitle}
diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/OpenSpotAlertPopover.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/OpenSpotAlertPopover.tsx
index 93e9e1b7..3e6f003d 100644
--- a/apps/antalmanac/src/components/RightPane/SectionTable/OpenSpotAlertPopover.tsx
+++ b/apps/antalmanac/src/components/RightPane/SectionTable/OpenSpotAlertPopover.tsx
@@ -44,7 +44,7 @@ class OpenSpotAlertPopover extends PureComponent<OpenSpotAlertPopoverProps, Open
         invalidInputMessage: '',
     };
 
-    componentDidUpdate(prevProps: OpenSpotAlertPopoverProps, prevState: OpenSpotAlertPopoverState) {
+    componentDidUpdate(_prevProps: OpenSpotAlertPopoverProps, prevState: OpenSpotAlertPopoverState) {
         if (
             (!prevState.anchorElement && this.state.anchorElement) ||
             (prevState.anchorElement && !this.state.anchorElement)
@@ -124,14 +124,12 @@ class OpenSpotAlertPopover extends PureComponent<OpenSpotAlertPopoverProps, Open
                         {this.state.invalidInput ? <Typography>{this.state.invalidInputMessage}</Typography> : null}
                         <div>
                             <InputMask
-                                // eslint-disable-next-line @typescript-eslint/ban-ts-comment
-                                /* @ts-ignore The type interface for this library uses "maskPlaceholder" while the library itself uses "maskChar"*/
                                 maskChar={null}
                                 mask="999 999 9999"
                                 value={this.state.phoneNumber}
                                 onChange={this.handlePhoneNumberChange}
                             >
-                                {() => <TextField label="Phone number" margin="dense" type="text" />}
+                                <TextField label="Phone number" margin="dense" type="text" />
                             </InputMask>
                         </div>
                         <div className={classes.buttonContainer}>
diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
index b6dda0a2..eb1ded12 100644
--- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
+++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
@@ -26,7 +26,8 @@ import { ColorAndDelete, ScheduleAddCell } from './SectionTableButtons';
 import restrictionsMapping from './static/restrictionsMapping.json';
 import GradesPopup from './GradesPopup';
 import analyticsEnum, { logAnalytics } from '$lib/analytics';
-import { clickToCopy, CourseDetails, isDarkMode } from '$lib/helpers';
+import { clickToCopy, isDarkMode } from '$lib/helpers';
+import { CourseDetails } from '$lib/course_data.types';
 import Grades from '$lib/grades';
 import AppStore from '$stores/AppStore';
 import { useTabStore } from '$stores/TabStore';
diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx
index 951ae796..25d681af 100644
--- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx
+++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableButtons.tsx
@@ -9,7 +9,7 @@ import { MOBILE_BREAKPOINT } from '../../../globals';
 import { addCourse, deleteCourse, openSnackbar } from '$actions/AppStoreActions';
 import ColorPicker from '$components/ColorPicker';
 import analyticsEnum, { logAnalytics } from '$lib/analytics';
-import { CourseDetails } from '$lib/helpers';
+import { CourseDetails } from '$lib/course_data.types';
 import AppStore from '$stores/AppStore';
 
 // Reset these params in url becasue when copy a specific class's link, it only copy its course code
@@ -52,6 +52,7 @@ export const ColorAndDelete = withStyles(styles)((props: ColorAndDeleteProps) =>
                     <Delete fontSize="small" />
                 </IconButton>
                 <ColorPicker
+                    key={AppStore.getCurrentScheduleIndex()}
                     color={color}
                     isCustomEvent={false}
                     sectionCode={sectionCode}
diff --git a/apps/antalmanac/src/components/AppBar/Exports/ExportCalendar.tsx b/apps/antalmanac/src/components/buttons/ExportCalendar.tsx
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/Exports/ExportCalendar.tsx
rename to apps/antalmanac/src/components/buttons/ExportCalendar.tsx
diff --git a/apps/antalmanac/src/components/AppBar/Exports/ScreenshotButton.tsx b/apps/antalmanac/src/components/buttons/Screenshot.tsx
similarity index 100%
rename from apps/antalmanac/src/components/AppBar/Exports/ScreenshotButton.tsx
rename to apps/antalmanac/src/components/buttons/Screenshot.tsx
diff --git a/apps/antalmanac/src/lib/analytics.ts b/apps/antalmanac/src/lib/analytics.ts
index 3be89160..8d0e558e 100644
--- a/apps/antalmanac/src/lib/analytics.ts
+++ b/apps/antalmanac/src/lib/analytics.ts
@@ -85,3 +85,53 @@ interface AnalyticsProps {
 export function logAnalytics({ category, action, label, value }: AnalyticsProps) {
     ReactGA4.event({ category, action, label, value });
 }
+
+/**
+ * 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
+ */
+export function courseNumAsDecimal(courseNumber: string): number {
+    // I wanted to split the course detail number into letters and digits
+    const courseNumArr = courseNumber.split(/(\d+)/);
+    // Gets rid of empty strings in courseNumArr
+    const filtered = courseNumArr.filter((value) => value !== '');
+
+    // Return 0 if array is empty
+    if (filtered.length === 0) {
+        console.error(`No characters were found, returning 0, Input: ${courseNumber}`);
+        return 0;
+    }
+
+    const lastElement = filtered[filtered.length - 1].toUpperCase(); // .toUpperCase() won't affect numeric characters
+    const lastElementCharCode = lastElement.charCodeAt(0); // Just checks the first character of the last element in the array
+    // Return the last element of the filtered array as an integer if it represents an integer
+    if ('0'.charCodeAt(0) <= lastElementCharCode && lastElementCharCode <= '9'.charCodeAt(0)) {
+        return parseInt(lastElement);
+    }
+
+    // If the string does not have any numeric characters
+    if (filtered.length === 1) {
+        console.error(`The string did not have numbers, returning 0, Input: ${courseNumber}`);
+        return 0;
+    }
+
+    // This element is the second to last element of the array, supposedly a string of numeric characters
+    const secondToLastElement = filtered[filtered.length - 2];
+    // The characters within [A-I] or [a-i] will be converted to 1-9, respectively
+    const letterAsNumber = lastElement.toUpperCase().charCodeAt(0) - 'A'.charCodeAt(0) + 1;
+    if (1 <= letterAsNumber && letterAsNumber <= 9) {
+        return parseFloat(`${secondToLastElement}.${letterAsNumber}`);
+    } else {
+        console.error(
+            `The first character type at the end of the string was not within [A-I] or [a-i], returning last numbers found in string, Violating Character: ${
+                filtered[filtered.length - 1][0]
+            }, Input: ${courseNumber}`
+        );
+        // This will represent an integer at this point because the split in the beginning split the array into strings of digits and strings of other characters
+        // If the last element in the array does not represent an integer, then the second to last element must represent an integer
+        return parseInt(secondToLastElement);
+    }
+}
diff --git a/apps/antalmanac/src/lib/buildingCatalogue.ts b/apps/antalmanac/src/lib/buildingCatalogue.ts
index fc8a8411..3f450524 100644
--- a/apps/antalmanac/src/lib/buildingCatalogue.ts
+++ b/apps/antalmanac/src/lib/buildingCatalogue.ts
@@ -888,12 +888,6 @@ export default {
         lng: -117.838409,
         imageURLs: ['463/university-tower-2.jpg'],
     },
-    99610: {
-        name: 'Interim Classroom Facility (ICF)',
-        lat: 33.644463,
-        lng: -117.840012,
-        imageURLs: ['463/interim-classroom-facility.jpg'],
-    },
     101047: {
         name: 'University Hills Community Center',
         lat: 33.636307,
@@ -924,12 +918,6 @@ export default {
         lng: -117.838112,
         imageURLs: ['463/business-ii.jpg'],
     },
-    114585: {
-        name: '100 Theory',
-        lat: 33.644245,
-        lng: -117.855675,
-        imageURLs: [],
-    },
     114586: {
         name: 'Anteater Instruction & Research Building (AIRB)',
         lat: 33.643009,
@@ -954,30 +942,6 @@ export default {
         lng: -117.828911,
         imageURLs: ['463/farm-school-red-barn.jpg'],
     },
-    114594: {
-        name: 'Bonney Research Laboratory (BRL)',
-        lat: 33.646461,
-        lng: -117.846169,
-        imageURLs: ['463/bonney-research-laboratory.jpg'],
-    },
-    114595: {
-        name: 'Qureshey Research Laboratory (QRL)',
-        lat: 33.646648,
-        lng: -117.845764,
-        imageURLs: ['463/qureshey-research-laboratory.jpg'],
-    },
-    114596: {
-        name: 'Center for the Neurobiology of Learning & Memory Annex (CNLM)',
-        lat: 33.646629,
-        lng: -117.846428,
-        imageURLs: ['463/center-for-the-neurobiology-of-learning-and-memory-annex.jpg'],
-    },
-    114597: {
-        name: 'Greenhouse',
-        lat: 33.64732,
-        lng: -117.845367,
-        imageURLs: ['463/greenhouse.jpg'],
-    },
     114598: {
         name: 'Merage School of Business (SB1)',
         lat: 33.647072,
@@ -990,60 +954,18 @@ export default {
         lng: -117.835861,
         imageURLs: ['463/education.jpg'],
     },
-    114600: {
-        name: 'Engineering Gateway (EG)',
-        lat: 33.643215,
-        lng: -117.840157,
-        imageURLs: ['463/engineering-gateway.jpg'],
-    },
-    114601: {
-        name: 'Engineering Laboratory Facility (ELF)',
-        lat: 33.643822,
-        lng: -117.839622,
-        imageURLs: ['463/engineering-laboratory-facility.jpg'],
-    },
     114602: {
         name: 'Little Theatre',
         lat: 33.647213,
         lng: -117.844063,
         imageURLs: ['463/little-theatre.jpg'],
     },
-    114603: {
-        name: 'Humanities Interim Classroom Facility (HICF) | Studio Art Trailer',
-        lat: 33.646919,
-        lng: -117.84687,
-        imageURLs: ['463/humanities-interim-classroom-facility.jpg'],
-    },
-    114604: {
-        name: 'Multipurpose Academic & Administrative Building (MPAA)',
-        lat: 33.647156,
-        lng: -117.837013,
-        imageURLs: ['463/multipurpose-academic-and-admin-building.jpg'],
-    },
-    114605: {
-        name: 'Beckman Laser Institute (BLI)',
-        lat: 33.644192,
-        lng: -117.849915,
-        imageURLs: ['463/beckman-laser-institute.jpg'],
-    },
-    114606: {
-        name: 'Gillespie Neuroscience Research Facility (GNRF)',
-        lat: 33.644009,
-        lng: -117.851265,
-        imageURLs: ['463/gillespie-neuroscience-research-facility.jpg'],
-    },
     114607: {
         name: 'Sue & Bill Gross Hall: A CIRM Institute',
         lat: 33.643555,
         lng: -117.851501,
         imageURLs: ['463/gross-hall.jpg'],
     },
-    114608: {
-        name: 'Hewitt Research Hall (HRH)',
-        lat: 33.643505,
-        lng: -117.851921,
-        imageURLs: ['463/hewitt-hall.jpg'],
-    },
     114609: {
         name: 'Plumwood House (PH)',
         lat: 33.644718,
@@ -1056,72 +978,12 @@ export default {
         lng: -117.852249,
         imageURLs: ['463/gavin-herbert-eye-institute.jpg'],
     },
-    114611: {
-        name: 'Steinhaus Hall (SH)',
-        lat: 33.646347,
-        lng: -117.844887,
-        imageURLs: ['463/steinhaus-hall.jpg'],
-    },
-    114612: {
-        name: 'Bison Modular (BIMO)',
-        lat: 33.643814,
-        lng: -117.84639,
-        imageURLs: ['463/bison-modular.jpg'],
-    },
-    114613: {
-        name: 'Multipurpose Science & Technology Building (MSTB)',
-        lat: 33.64209,
-        lng: -117.844467,
-        imageURLs: ['463/multipurpose-science-and-technology.jpg'],
-    },
-    114614: {
-        name: 'Physical Sciences High Bay',
-        lat: 33.64362,
-        lng: -117.845291,
-        imageURLs: ['463/physical-sciences-high-bay.jpg'],
-    },
-    114615: {
-        name: 'Rowland Hall (RH)',
-        lat: 33.644531,
-        lng: -117.844162,
-        imageURLs: ['463/rowland-hall.jpg'],
-    },
-    114616: {
-        name: 'Anteater Instruction & Research Building (AIRB)',
-        lat: 33.643009,
-        lng: -117.838013,
-        imageURLs: ['463/anteater-instruction-and-research-building.jpg'],
-    },
-    114618: {
-        name: 'Sprague Hall (SPH)',
-        lat: 33.644062,
-        lng: -117.852066,
-        imageURLs: ['463/sprague-hall.jpg'],
-    },
     114621: {
         name: 'Facilities Management Building',
         lat: 33.664715,
         lng: -117.854805,
         imageURLs: [],
     },
-    114622: {
-        name: 'Humanities Gateway (HG)',
-        lat: 33.648266,
-        lng: -117.844406,
-        imageURLs: ['463/humanities-gateway.jpg'],
-    },
-    114623: {
-        name: 'Social Ecology I (SE)',
-        lat: 33.646194,
-        lng: -117.838844,
-        imageURLs: ['463/social-ecology-i.jpg'],
-    },
-    114624: {
-        name: 'Student Center (SC)',
-        lat: 33.649323,
-        lng: -117.842255,
-        imageURLs: ['463/student-center_1.jpg'],
-    },
     114626: {
         name: 'Student Health Center II',
         lat: 33.645878,
@@ -1152,30 +1014,6 @@ export default {
         lng: -117.84079,
         imageURLs: ['463/irvine-barclay-theatre.jpg'],
     },
-    114858: {
-        name: 'Social & Behavioral Sciences Gateway (SBSG)',
-        lat: 33.647488,
-        lng: -117.839165,
-        imageURLs: ['463/social-and-behavioral-sciences-gateway.jpg'],
-    },
-    114877: {
-        name: 'Biological Sciences III (BS3)',
-        lat: 33.645363,
-        lng: -117.846222,
-        imageURLs: ['463/biological-sciences-iii.jpg'],
-    },
-    114878: {
-        name: 'Social Science Tower (SST)',
-        lat: 33.646591,
-        lng: -117.840294,
-        imageURLs: ['463/social-science-tower.jpg'],
-    },
-    114879: {
-        name: 'Gavin Herbert Eye Institute',
-        lat: 33.641743,
-        lng: -117.852249,
-        imageURLs: ['463/gavin-herbert-eye-institute.jpg'],
-    },
     114880: {
         name: 'Child Development School (CD)',
         lat: 33.662647,
@@ -1692,12 +1530,6 @@ export default {
         lng: -117.844681,
         imageURLs: [],
     },
-    364042: {
-        name: 'Student Services West',
-        lat: 33.646072,
-        lng: -117.849152,
-        imageURLs: [],
-    },
     364043: {
         name: 'Student Services West',
         lat: 33.646072,
@@ -1710,12 +1542,6 @@ export default {
         lng: -117.837051,
         imageURLs: ['463/banning-house.jpg'],
     },
-    407362: {
-        name: 'Gateway Study Center (GC)',
-        lat: 33.647388,
-        lng: -117.841873,
-        imageURLs: ['463/gateway-study-center.jpg'],
-    },
     444368: {
         name: 'Plaza Verde',
         lat: 33.648224,
@@ -1752,12 +1578,6 @@ export default {
         lng: -117.848709,
         imageURLs: ['463/grounds-maintenance-facility.jpg'],
     },
-    444506: {
-        name: 'Facilities Management Building',
-        lat: 33.66478,
-        lng: -117.854935,
-        imageURLs: [],
-    },
     444507: {
         name: 'Passport Services ',
         lat: 33.664795,
@@ -1812,36 +1632,6 @@ export default {
         lng: -117.844414,
         imageURLs: [],
     },
-    454486: {
-        name: 'Medical Surge I (MS1)',
-        lat: 33.646484,
-        lng: -117.850174,
-        imageURLs: ['463/medical-surge-i.jpg'],
-    },
-    454487: {
-        name: 'Donald Bren Hall (DBH)',
-        lat: 33.643288,
-        lng: -117.841965,
-        imageURLs: ['463/donald-bren-hall.jpg'],
-    },
-    454488: {
-        name: 'Engineering Hall (EH)',
-        lat: 33.643799,
-        lng: -117.841202,
-        imageURLs: ['463/engineering-hall.jpg'],
-    },
-    454489: {
-        name: 'Air Pollution Health Effects Laboratory (APHEL)',
-        lat: 33.664837,
-        lng: -117.853844,
-        imageURLs: [],
-    },
-    454490: {
-        name: 'Student Center South',
-        lat: 33.648724,
-        lng: -117.842705,
-        imageURLs: [],
-    },
     454491: {
         name: 'Student Center South',
         lat: 33.648724,
diff --git a/apps/antalmanac/src/lib/course-helpers.ts b/apps/antalmanac/src/lib/course-helpers.ts
deleted file mode 100644
index c66c38d1..00000000
--- a/apps/antalmanac/src/lib/course-helpers.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import type { WebsocAPIResponse, WebsocSectionMeeting } from 'peterportal-api-next-types';
-import { PETERPORTAL_WEBSOC_ENDPOINT } from './api/endpoints';
-import type { CourseInfo } from './helpers';
-
-interface CacheEntry extends WebsocAPIResponse {
-    timestamp: number;
-}
-
-const websocCache: { [key: string]: CacheEntry } = {};
-
-export function clearCache() {
-    Object.keys(websocCache).forEach((key) => delete websocCache[key]); //https://stackoverflow.com/a/19316873/14587004
-}
-
-export function getCourseInfo(SOCObject: WebsocAPIResponse) {
-    const courseInfo: { [sectionCode: string]: CourseInfo } = {};
-    for (const school of SOCObject.schools) {
-        for (const department of school.departments) {
-            for (const course of department.courses) {
-                for (const section of course.sections) {
-                    courseInfo[section.sectionCode] = {
-                        courseDetails: {
-                            deptCode: department.deptCode,
-                            courseNumber: course.courseNumber,
-                            courseTitle: course.courseTitle,
-                            courseComment: course.courseComment,
-                            prerequisiteLink: course.prerequisiteLink,
-                        },
-                        section: section,
-                    };
-                }
-            }
-        }
-    }
-    return courseInfo;
-}
-
-// Construct a request to PeterPortal with the params as a query string
-export async function queryWebsoc(params: Record<string, string>) {
-    // Construct a request to PeterPortal with the params as a query string
-    const url = new URL(PETERPORTAL_WEBSOC_ENDPOINT);
-    const searchString = new URLSearchParams(cleanParams(params)).toString();
-    if (websocCache[searchString]?.timestamp > Date.now() - 30 * 60 * 1000) {
-        //NOTE: Check out how caching works
-        //if cache hit and less than 30 minutes old
-        return websocCache[searchString];
-    }
-    url.search = searchString;
-
-    //The data from the API will duplicate a section if it has multiple locations.
-    //I.e., if there's a Tuesday section in two different (probably adjoined) rooms,
-    //courses[i].sections[j].meetings will have two entries, despite it being the same section.
-    //For now, I'm correcting it with removeDuplicateMeetings, but the API should handle this
-
-    const response: WebsocAPIResponse = await fetch(url, {
-        headers: {
-            Referer: 'https://antalmanac.com/',
-        },
-    })
-        .then((r) => r.json())
-        .then((r) => r.payload);
-    websocCache[searchString] = { ...response, timestamp: Date.now() };
-    return removeDuplicateMeetings(response);
-}
-
-// Removes duplicate meetings as a result of multiple locations from WebsocAPIResponse.
-// See queryWebsoc for more info
-// NOTE: The separator is currently an ampersand. Maybe it should be refactored to be an array
-// TODO: Remove if and when API is fixed
-// Maybe put this into CourseRenderPane.tsx -> flattenSOCObject()
-function removeDuplicateMeetings(websocResp: WebsocAPIResponse): WebsocAPIResponse {
-    websocResp.schools.forEach((school, schoolIndex) => {
-        school.departments.forEach((department, departmentIndex) => {
-            department.courses.forEach((course, courseIndex) => {
-                course.sections.forEach((section, sectionIndex) => {
-                    // Merge meetings that have the same meeting day and time
-
-                    const existingMeetings: WebsocSectionMeeting[] = [];
-
-                    // I know that this is n^2, but a section can't have *that* many locations
-                    for (const meeting of section.meetings) {
-                        let isNewMeeting = true;
-
-                        for (let i = 0; i < existingMeetings.length; i++) {
-                            const sameDayAndTime =
-                                meeting.days === existingMeetings[i].days &&
-                                meeting.startTime === existingMeetings[i].startTime &&
-                                meeting.endTime === existingMeetings[i].endTime;
-                            const sameBuilding = meeting.bldg === existingMeetings[i].bldg;
-
-                            //This shouldn't be possible because there shouldn't be duplicate locations in a section
-                            if (sameDayAndTime && sameBuilding) {
-                                console.warn('Found two meetings with same days, time, and bldg', websocResp);
-                                break;
-                            }
-
-                            // Add the building to existing meeting instead of creating a new one
-                            if (sameDayAndTime && !sameBuilding) {
-                                existingMeetings[i] = {
-                                    timeIsTBA: existingMeetings[i].timeIsTBA,
-                                    days: existingMeetings[i].days,
-                                    startTime: existingMeetings[i].startTime,
-                                    endTime: existingMeetings[i].endTime,
-                                    bldg: [existingMeetings[i].bldg + ' & ' + meeting.bldg],
-                                };
-                                isNewMeeting = false;
-                            }
-                        }
-
-                        if (isNewMeeting) existingMeetings.push(meeting);
-                    }
-
-                    // Update websocResp with correct meetings
-                    websocResp.schools[schoolIndex].departments[departmentIndex].courses[courseIndex].sections[
-                        sectionIndex
-                    ].meetings = existingMeetings;
-                });
-            });
-        });
-    });
-    return websocResp;
-}
-
-function cleanParams(record: Record<string, string>) {
-    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/course_data.types.ts b/apps/antalmanac/src/lib/course_data.types.ts
new file mode 100644
index 00000000..1b4876c1
--- /dev/null
+++ b/apps/antalmanac/src/lib/course_data.types.ts
@@ -0,0 +1,15 @@
+import { WebsocSection } from "peterportal-api-next-types";
+
+
+export interface CourseDetails {
+    deptCode: string;
+    courseNumber: string;
+    courseTitle: string;
+    courseComment: string;
+    prerequisiteLink: string;
+}
+
+export interface CourseInfo {
+    courseDetails: CourseDetails;
+    section: WebsocSection;
+}
\ No newline at end of file
diff --git a/apps/antalmanac/src/lib/download.ts b/apps/antalmanac/src/lib/download.ts
index 8d0fe89b..abcbade2 100644
--- a/apps/antalmanac/src/lib/download.ts
+++ b/apps/antalmanac/src/lib/download.ts
@@ -1,10 +1,11 @@
-import type { HourMinute, WebsocSectionFinalExam } from 'peterportal-api-next-types';
+import type { Course, HourMinute, WebsocSectionFinalExam } from 'peterportal-api-next-types';
 import { saveAs } from 'file-saver';
 import { createEvents, type EventAttributes } from 'ics';
 import { notNull } from './utils';
 import { openSnackbar } from '$actions/AppStoreActions';
 import analyticsEnum, { logAnalytics } from '$lib/analytics';
-import { termData } from '$lib/termData';
+import { getDefaultTerm, termData } from '$lib/termData';
+import { CourseEvent, CustomEvent, FinalExam } from '$components/Calendar/CourseCalendarEvent';
 import AppStore from '$stores/AppStore';
 
 export const quarterStartDates = Object.fromEntries(
@@ -153,7 +154,7 @@ export function getFirstClass(
  * // examEnd = [2019, 6, 7, 12, 30]
  * ```
  */
-export function getExamTime(exam: WebsocSectionFinalExam, year: number): [DateTimeArray, DateTimeArray] | [] {
+export function getExamTime(exam: FinalExam, year: number): [DateTimeArray, DateTimeArray] | [] {
     if (exam.month && exam.day && exam.startTime && exam.endTime) {
         const month = exam.month;
         const day = exam.day;
@@ -246,74 +247,83 @@ export function getRRule(bydays: string[], quarter: string) {
     return `FREQ=WEEKLY;BYDAY=${bydays.toString()};INTERVAL=1;COUNT=${count}`;
 }
 
-export function getEventsFromCourses(courses = AppStore.schedule.getCurrentCourses()): EventAttributes[] {
-    const events = courses.flatMap((course) => {
-        const {
-            term,
-            deptCode,
-            courseNumber,
-            courseTitle,
-            section: { sectionType, instructors, meetings, finalExam },
-        } = course;
-
-        const courseEvents: EventAttributes[] = meetings
-            .map((meeting) => {
-                if (meeting.timeIsTBA) {
-                    return;
+export function getEventsFromCourses(events = AppStore.getEventsInCalendar()): EventAttributes[] {
+    const calendarEvents = events.flatMap((event) => {
+        if (event.isCustomEvent) {
+            // FIXME: We don't have a way to get the term for custom events,
+            // so we just use the default term.
+            const { title, start, end } = event as CustomEvent;
+            const days = getByDays(event.days.join(''));
+            const term = getDefaultTerm().shortName;
+            const rrule = getRRule(days, getQuarter(term));
+            const eventStartDate = getClassStartDate(term, days);
+            const [firstClassStart, firstClassEnd] = getFirstClass(
+                eventStartDate,
+                { hour: start.getHours(), minute: start.getMinutes() },
+                { hour: end.getHours(), minute: end.getMinutes() }
+            );
+            const customEvent: EventAttributes = {
+                productId: 'antalmanac/ics',
+                startOutputType: 'local' as const,
+                endOutputType: 'local' as const,
+                title: title,
+                // TODO: Add location to custom events, waiting for https://github.com/icssc/AntAlmanac/issues/249
+                // location: `${location.building} ${location.room}`,
+                start: firstClassStart,
+                end: firstClassEnd,
+                recurrenceRule: rrule,
+            };
+            return customEvent;
+        } else {
+            const { term, title, courseTitle, instructors, sectionType, start, end, finalExam } = event;
+            const courseEvents: EventAttributes[] = event.locations
+                .map((location) => {
+                    if (location.days === undefined) return null;
+                    const days = getByDays(location.days);
+
+                    const classStartDate = getClassStartDate(term, days);
+
+                    const [firstClassStart, firstClassEnd] = getFirstClass(
+                        classStartDate,
+                        { hour: start.getHours(), minute: start.getMinutes() },
+                        { hour: end.getHours(), minute: end.getMinutes() }
+                    );
+
+                    const rrule = getRRule(days, getQuarter(term));
+
+                    // Add VEvent to events array.
+                    return {
+                        productId: 'antalmanac/ics',
+                        startOutputType: 'local' as const,
+                        endOutputType: 'local' as const,
+                        title: `${title} ${sectionType}`,
+                        description: `${courseTitle}\nTaught by ${instructors.join('/')}`,
+                        location: `${location.building} ${location.room}`,
+                        start: firstClassStart,
+                        end: firstClassEnd,
+                        recurrenceRule: rrule,
+                    };
+                })
+                .filter(notNull);
+
+            // Add final to events.
+            if (finalExam.examStatus === 'SCHEDULED_FINAL') {
+                if (finalExam.startTime && finalExam.endTime) {
+                    courseEvents.push({
+                        productId: 'antalmanac/ics',
+                        startOutputType: 'local' as const,
+                        endOutputType: 'local' as const,
+                        title: `${title} Final Exam`,
+                        description: `Final Exam for ${sectionType} ${courseTitle}`,
+                        start: getExamTime(finalExam, getYear(term))[0]!,
+                        end: getExamTime(finalExam, getYear(term))[1]!,
+                    });
                 }
-
-                if (!(meeting.days && meeting.startTime && meeting.endTime)) {
-                    return;
-                }
-
-                const bydays = getByDays(meeting.days);
-
-                const classStartDate = getClassStartDate(term, bydays);
-
-                const [firstClassStart, firstClassEnd] = getFirstClass(
-                    classStartDate,
-                    meeting.startTime,
-                    meeting.endTime
-                );
-
-                const rrule = getRRule(bydays, getQuarter(term));
-
-                // Add VEvent to events array.
-                return {
-                    productId: 'antalmanac/ics',
-                    startOutputType: 'local' as const,
-                    endOutputType: 'local' as const,
-                    title: `${deptCode} ${courseNumber} ${sectionType}`,
-                    description: `${courseTitle}\nTaught by ${instructors.join('/')}`,
-                    location: meeting.bldg.join(', '),
-                    start: firstClassStart,
-                    end: firstClassEnd,
-                    recurrenceRule: rrule,
-                };
-            })
-            .filter(notNull);
-
-        // Add final to events.
-        if (finalExam.examStatus === 'SCHEDULED_FINAL') {
-            const [examStart, examEnd] = getExamTime(finalExam, getYear(term));
-
-            if (examStart && examEnd) {
-                courseEvents.push({
-                    productId: 'antalmanac/ics',
-                    startOutputType: 'local' as const,
-                    endOutputType: 'local' as const,
-                    title: `${deptCode} ${courseNumber} Final Exam`,
-                    description: `Final Exam for ${courseTitle}`,
-                    start: examStart,
-                    end: examEnd,
-                });
             }
+            return courseEvents;
         }
-
-        return courseEvents;
     });
-
-    return events;
+    return calendarEvents;
 }
 
 export function exportCalendar() {
diff --git a/apps/antalmanac/src/lib/helpers.ts b/apps/antalmanac/src/lib/helpers.ts
index 279027c0..5c4e96fb 100644
--- a/apps/antalmanac/src/lib/helpers.ts
+++ b/apps/antalmanac/src/lib/helpers.ts
@@ -1,12 +1,8 @@
 import React from 'react';
 
-import { WebsocSectionMeeting, WebsocSection, WebsocAPIResponse } from 'peterportal-api-next-types';
-import { PETERPORTAL_GRAPHQL_ENDPOINT, PETERPORTAL_WEBSOC_ENDPOINT } from './api/endpoints';
-import Grades from './grades';
-import { addCourse, openSnackbar } from '$actions/AppStoreActions';
+import { PETERPORTAL_GRAPHQL_ENDPOINT } from './api/endpoints';
+import { openSnackbar } from '$actions/AppStoreActions';
 import AppStore from '$stores/AppStore';
-import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog';
-import trpc from '$lib/api/trpc';
 
 export async function queryGraphQL<PromiseReturnType>(queryString: string): Promise<PromiseReturnType | null> {
     const query = JSON.stringify({
@@ -28,242 +24,6 @@ export async function queryGraphQL<PromiseReturnType>(queryString: string): Prom
 
     return json as Promise<PromiseReturnType>;
 }
-export interface CourseDetails {
-    deptCode: string;
-    courseNumber: string;
-    courseTitle: string;
-    courseComment: string;
-    prerequisiteLink: string;
-}
-
-export interface CourseInfo {
-    courseDetails: CourseDetails;
-    section: WebsocSection;
-}
-
-export interface ZotCourseResponse {
-    codes: string[];
-    customEvents: RepeatingCustomEvent[];
-}
-export async function queryZotCourse(schedule_name: string) {
-    const response = await trpc.zotcourse.getUserData.mutate({ scheduleName: schedule_name });
-    // For custom event, there is no course attribute in each.
-    const codes = response.data
-        .filter((section: { eventType: number }) => section.eventType === 3)
-        .map((section: { course: { code: string } }) => section.course.code) as string[];
-    const days = [false, false, false, false, false, false, false];
-    const customEvents: RepeatingCustomEvent[] = response.data
-        .filter((section: { eventType: number }) => section.eventType === 1)
-        .map((event: { title: string; start: string; end: string; dow: number[] }) => {
-            return {
-                title: event.title,
-                start: event.start,
-                end: event.end,
-                days: days.map((_, index) => event.dow.includes(index)),
-                scheduleIndices: [AppStore.getCurrentScheduleIndex()],
-                customEventID: Date.now(),
-                color: '#551a8b',
-            };
-        }) as RepeatingCustomEvent[];
-    return {
-        codes: codes,
-        customEvents: customEvents,
-    };
-}
-
-interface CacheEntry extends WebsocAPIResponse {
-    timestamp: number;
-}
-
-const websocCache: { [key: string]: CacheEntry } = {};
-
-export function clearCache() {
-    Object.keys(websocCache).forEach((key) => delete websocCache[key]); //https://stackoverflow.com/a/19316873/14587004
-    Grades.clearCache();
-}
-
-function cleanParams(record: Record<string, string>) {
-    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;
-}
-
-// Construct a request to PeterPortal with the params as a query string
-export async function queryWebsoc(params: Record<string, string>) {
-    // Construct a request to PeterPortal with the params as a query string
-    const url = new URL(PETERPORTAL_WEBSOC_ENDPOINT);
-    const searchString = new URLSearchParams(cleanParams(params)).toString();
-    if (websocCache[searchString]?.timestamp > Date.now() - 30 * 60 * 1000) {
-        //NOTE: Check out how caching works
-        //if cache hit and less than 30 minutes old
-        return websocCache[searchString];
-    }
-    url.search = searchString;
-
-    //The data from the API will duplicate a section if it has multiple locations.
-    //I.e., if there's a Tuesday section in two different (probably adjoined) rooms,
-    //courses[i].sections[j].meetings will have two entries, despite it being the same section.
-    //For now, I'm correcting it with removeDuplicateMeetings, but the API should handle this
-
-    const response: WebsocAPIResponse = await fetch(url, {
-        headers: {
-            Referer: 'https://antalmanac.com/',
-        },
-    })
-        .then((r) => r.json())
-        .then((r) => r.payload);
-    websocCache[searchString] = { ...response, timestamp: Date.now() };
-    return removeDuplicateMeetings(response);
-}
-
-// Removes duplicate meetings as a result of multiple locations from WebsocAPIResponse.
-// See queryWebsoc for more info
-// NOTE: The separator is currently an ampersand. Maybe it should be refactored to be an array
-// TODO: Remove if and when API is fixed
-// Maybe put this into CourseRenderPane.tsx -> flattenSOCObject()
-function removeDuplicateMeetings(websocResp: WebsocAPIResponse): WebsocAPIResponse {
-    websocResp.schools.forEach((school, schoolIndex) => {
-        school.departments.forEach((department, departmentIndex) => {
-            department.courses.forEach((course, courseIndex) => {
-                course.sections.forEach((section, sectionIndex) => {
-                    // Merge meetings that have the same meeting day and time
-
-                    const existingMeetings: WebsocSectionMeeting[] = [];
-
-                    // I know that this is n^2, but a section can't have *that* many locations
-                    for (const meeting of section.meetings) {
-                        let isNewMeeting = true;
-
-                        for (let i = 0; i < existingMeetings.length; i++) {
-                            const sameDayAndTime =
-                                meeting.days === existingMeetings[i].days &&
-                                meeting.startTime === existingMeetings[i].startTime &&
-                                meeting.endTime === existingMeetings[i].endTime;
-                            const sameBuilding = meeting.bldg === existingMeetings[i].bldg;
-
-                            //This shouldn't be possible because there shouldn't be duplicate locations in a section
-                            if (sameDayAndTime && sameBuilding) {
-                                console.warn('Found two meetings with same days, time, and bldg', websocResp);
-                                break;
-                            }
-
-                            // Add the building to existing meeting instead of creating a new one
-                            if (sameDayAndTime && !sameBuilding) {
-                                existingMeetings[i] = {
-                                    timeIsTBA: existingMeetings[i].timeIsTBA,
-                                    days: existingMeetings[i].days,
-                                    startTime: existingMeetings[i].startTime,
-                                    endTime: existingMeetings[i].endTime,
-                                    bldg: [existingMeetings[i].bldg + ' & ' + meeting.bldg],
-                                };
-                                isNewMeeting = false;
-                            }
-                        }
-
-                        if (isNewMeeting) existingMeetings.push(meeting);
-                    }
-
-                    // Update websocResp with correct meetings
-                    websocResp.schools[schoolIndex].departments[departmentIndex].courses[courseIndex].sections[
-                        sectionIndex
-                    ].meetings = existingMeetings;
-                });
-            });
-        });
-    });
-    return websocResp;
-}
-
-export function combineSOCObjects(SOCObjects: WebsocAPIResponse[]) {
-    const combined = SOCObjects.shift() as WebsocAPIResponse;
-    for (const res of SOCObjects) {
-        for (const school of res.schools) {
-            const schoolIndex = combined.schools.findIndex((s) => s.schoolName === school.schoolName);
-            if (schoolIndex !== -1) {
-                for (const dept of school.departments) {
-                    const deptIndex = combined.schools[schoolIndex].departments.findIndex(
-                        (d) => d.deptCode === dept.deptCode
-                    );
-                    if (deptIndex !== -1) {
-                        const courses = new Set(combined.schools[schoolIndex].departments[deptIndex].courses);
-                        for (const course of dept.courses) {
-                            courses.add(course);
-                        }
-                        const coursesArray = Array.from(courses);
-                        coursesArray.sort(
-                            (left, right) =>
-                                parseInt(left.courseNumber.replace(/\D/g, '')) -
-                                parseInt(right.courseNumber.replace(/\D/g, ''))
-                        );
-                        combined.schools[schoolIndex].departments[deptIndex].courses = coursesArray;
-                    } else {
-                        combined.schools[schoolIndex].departments.push(dept);
-                    }
-                }
-            } else {
-                combined.schools.push(school);
-            }
-        }
-    }
-    return combined;
-}
-
-export async function queryWebsocMultiple(params: { [key: string]: string }, fieldName: string) {
-    const responses: WebsocAPIResponse[] = [];
-    for (const field of params[fieldName].trim().replace(' ', '').split(',')) {
-        const req = JSON.parse(JSON.stringify(params)) as Record<string, string>;
-        req[fieldName] = field;
-        responses.push(await queryWebsoc(req));
-    }
-
-    return combineSOCObjects(responses);
-}
-
-export const addCoursesMultiple = (
-    courseInfo: { [sectionCode: string]: CourseInfo },
-    term: string,
-    scheduleIndex: number
-) => {
-    for (const section of Object.values(courseInfo)) {
-        addCourse(section.section, section.courseDetails, term, scheduleIndex, true);
-    }
-    const terms = termsInSchedule(term);
-    if (terms.size > 1) warnMultipleTerms(terms);
-    return Object.values(courseInfo).length;
-};
-
-export const termsInSchedule = (term: string) =>
-    new Set([term, ...AppStore.schedule.getCurrentCourses().map((course) => course.term)]);
 
 export const warnMultipleTerms = (terms: Set<string>) => {
     openSnackbar(
@@ -292,51 +52,4 @@ export function isDarkMode() {
     }
 }
 
-/**
- * @param courseNumber A string that represents the course number of a course (eg. '122A', '121')
- * @returns This function returns an int or number with a decimal representation of the passed in string (eg. courseNumAsDecimal('122A') returns 122.1, courseNumAsDecimal('121') returns 121)
- */
-export function courseNumAsDecimal(courseNumber: string): number {
-    // I wanted to split the course detail number into letters and digits
-    const courseNumArr = courseNumber.split(/(\d+)/);
-    // Gets rid of empty strings in courseNumArr
-    const filtered = courseNumArr.filter((value) => value !== '');
-
-    // Return 0 if array is empty
-    if (filtered.length === 0) {
-        console.error(`No characters were found, returning 0, Input: ${courseNumber}`);
-        return 0;
-    }
-
-    const lastElement = filtered[filtered.length - 1].toUpperCase(); // .toUpperCase() won't affect numeric characters
-    const lastElementCharCode = lastElement.charCodeAt(0); // Just checks the first character of the last element in the array
-    // Return the last element of the filtered array as an integer if it represents an integer
-    if ('0'.charCodeAt(0) <= lastElementCharCode && lastElementCharCode <= '9'.charCodeAt(0)) {
-        return parseInt(lastElement);
-    }
-
-    // If the string does not have any numeric characters
-    if (filtered.length === 1) {
-        console.error(`The string did not have numbers, returning 0, Input: ${courseNumber}`);
-        return 0;
-    }
-
-    // This element is the second to last element of the array, supposedly a string of numeric characters
-    const secondToLastElement = filtered[filtered.length - 2];
-    // The characters within [A-I] or [a-i] will be converted to 1-9, respectively
-    const letterAsNumber = lastElement.toUpperCase().charCodeAt(0) - 'A'.charCodeAt(0) + 1;
-    if (1 <= letterAsNumber && letterAsNumber <= 9) {
-        return parseFloat(`${secondToLastElement}.${letterAsNumber}`);
-    } else {
-        console.error(
-            `The first character type at the end of the string was not within [A-I] or [a-i], returning last numbers found in string, Violating Character: ${
-                filtered[filtered.length - 1][0]
-            }, Input: ${courseNumber}`
-        );
-        // This will represent an integer at this point because the split in the beginning split the array into strings of digits and strings of other characters
-        // If the last element in the array does not represent an integer, then the second to last element must represent an integer
-        return parseInt(secondToLastElement);
-    }
-}
-
 export const FAKE_LOCATIONS = ['VRTL REMOTE', 'ON LINE', 'TBA'];
diff --git a/apps/antalmanac/src/lib/websoc.ts b/apps/antalmanac/src/lib/websoc.ts
new file mode 100644
index 00000000..fa2a6a11
--- /dev/null
+++ b/apps/antalmanac/src/lib/websoc.ts
@@ -0,0 +1,214 @@
+import type { WebsocAPIResponse, WebsocSectionMeeting } from 'peterportal-api-next-types';
+import { PETERPORTAL_WEBSOC_ENDPOINT } from './api/endpoints';
+import type { CourseInfo } from './course_data.types';
+
+interface CacheEntry extends WebsocAPIResponse {
+    timestamp: number;
+}
+
+class _WebSOC {
+    private cache: { [key: string]: CacheEntry };
+
+    constructor() {
+        this.cache = {};
+    }
+    
+    clearCache() {
+        Object.keys(this.cache).forEach((key) => delete this.cache[key]); //https://stackoverflow.com/a/19316873/14587004
+    }
+
+    // Construct a request to PeterPortal with the params as a query string
+    async query(params: Record<string, string>) {
+        // Construct a request to PeterPortal with the params as a query string
+        const url = new URL(PETERPORTAL_WEBSOC_ENDPOINT);
+        const searchString = new URLSearchParams(this.cleanSearchParams(params)).toString();
+        if (this.cache[searchString]?.timestamp > Date.now() - 30 * 60 * 1000) {
+            //NOTE: Check out how caching works
+            //if cache hit and less than 30 minutes old
+            return this.cache[searchString];
+        }
+        url.search = searchString;
+
+        //The data from the API will duplicate a section if it has multiple locations.
+        //I.e., if there's a Tuesday section in two different (probably adjoined) rooms,
+        //courses[i].sections[j].meetings will have two entries, despite it being the same section.
+        //For now, I'm correcting it with removeDuplicateMeetings, but the API should handle this
+
+        const response: WebsocAPIResponse = await fetch(url, {
+            headers: {
+                Referer: 'https://antalmanac.com/',
+            },
+        })
+            .then((r) => r.json())
+            .then((r) => r.payload);
+        this.cache[searchString] = { ...response, timestamp: Date.now() };
+        return this.removeDuplicateMeetings(response);
+    }
+
+    async queryMultiple(params: { [key: string]: string }, fieldName: string) {
+        const responses: WebsocAPIResponse[] = [];
+        for (const field of params[fieldName].trim().replace(' ', '').split(',')) {
+            const req = JSON.parse(JSON.stringify(params)) as Record<string, string>;
+            req[fieldName] = field;
+            responses.push(await this.query(req));
+        }
+    
+        return this.combineSOCObjects(responses);
+    }
+
+    async getCourseInfo(websoc_params: Record<string, string>) {
+        const SOCObject = await this.query(websoc_params);
+        const courseInfo: { [sectionCode: string]: CourseInfo } = {};
+        for (const school of SOCObject.schools) {
+            for (const department of school.departments) {
+                for (const course of department.courses) {
+                    for (const section of course.sections) {
+                        courseInfo[section.sectionCode] = {
+                            courseDetails: {
+                                deptCode: department.deptCode,
+                                courseNumber: course.courseNumber,
+                                courseTitle: course.courseTitle,
+                                courseComment: course.courseComment,
+                                prerequisiteLink: course.prerequisiteLink,
+                            },
+                            section: section,
+                        };
+                    }
+                }
+            }
+        }
+        return courseInfo;
+    }
+
+    private combineSOCObjects(SOCObjects: WebsocAPIResponse[]) {
+        const combined = SOCObjects.shift() as WebsocAPIResponse;
+        for (const res of SOCObjects) {
+            for (const school of res.schools) {
+                const schoolIndex = combined.schools.findIndex((s) => s.schoolName === school.schoolName);
+                if (schoolIndex !== -1) {
+                    for (const dept of school.departments) {
+                        const deptIndex = combined.schools[schoolIndex].departments.findIndex(
+                            (d) => d.deptCode === dept.deptCode
+                        );
+                        if (deptIndex !== -1) {
+                            const courses = new Set(combined.schools[schoolIndex].departments[deptIndex].courses);
+                            for (const course of dept.courses) {
+                                courses.add(course);
+                            }
+                            const coursesArray = Array.from(courses);
+                            coursesArray.sort(
+                                (left, right) =>
+                                    parseInt(left.courseNumber.replace(/\D/g, '')) -
+                                    parseInt(right.courseNumber.replace(/\D/g, ''))
+                            );
+                            combined.schools[schoolIndex].departments[deptIndex].courses = coursesArray;
+                        } else {
+                            combined.schools[schoolIndex].departments.push(dept);
+                        }
+                    }
+                } else {
+                    combined.schools.push(school);
+                }
+            }
+        }
+        return combined;
+    }
+
+    // Removes duplicate meetings as a result of multiple locations from WebsocAPIResponse.
+    // See queryWebsoc for more info
+    // NOTE: The separator is currently an ampersand. Maybe it should be refactored to be an array
+    // TODO: Remove if and when API is fixed
+    // Maybe put this into CourseRenderPane.tsx -> flattenSOCObject()
+    private removeDuplicateMeetings(websocResp: WebsocAPIResponse): WebsocAPIResponse {
+        websocResp.schools.forEach((school, schoolIndex) => {
+            school.departments.forEach((department, departmentIndex) => {
+                department.courses.forEach((course, courseIndex) => {
+                    course.sections.forEach((section, sectionIndex) => {
+                        // Merge meetings that have the same meeting day and time
+
+                        const existingMeetings: WebsocSectionMeeting[] = [];
+
+                        // I know that this is n^2, but a section can't have *that* many locations
+                        for (const meeting of section.meetings) {
+                            let isNewMeeting = true;
+
+                            for (let i = 0; i < existingMeetings.length; i++) {
+                                const sameDayAndTime =
+                                    meeting.days === existingMeetings[i].days &&
+                                    meeting.startTime === existingMeetings[i].startTime &&
+                                    meeting.endTime === existingMeetings[i].endTime;
+                                const sameBuilding = meeting.bldg === existingMeetings[i].bldg;
+
+                                //This shouldn't be possible because there shouldn't be duplicate locations in a section
+                                if (sameDayAndTime && sameBuilding) {
+                                    console.warn('Found two meetings with same days, time, and bldg', websocResp);
+                                    break;
+                                }
+
+                                // Add the building to existing meeting instead of creating a new one
+                                if (sameDayAndTime && !sameBuilding) {
+                                    existingMeetings[i] = {
+                                        timeIsTBA: existingMeetings[i].timeIsTBA,
+                                        days: existingMeetings[i].days,
+                                        startTime: existingMeetings[i].startTime,
+                                        endTime: existingMeetings[i].endTime,
+                                        bldg: [existingMeetings[i].bldg + ' & ' + meeting.bldg],
+                                    };
+                                    isNewMeeting = false;
+                                }
+                            }
+
+                            if (isNewMeeting) existingMeetings.push(meeting);
+                        }
+
+                        // Update websocResp with correct meetings
+                        websocResp.schools[schoolIndex].departments[departmentIndex].courses[courseIndex].sections[
+                            sectionIndex
+                        ].meetings = existingMeetings;
+                    });
+                });
+            });
+        });
+        return websocResp;
+    }
+
+    private cleanSearchParams(record: Record<string, string>) {
+        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;
+    }
+}
+
+export const WebSOC = new _WebSOC();
+
+export default WebSOC;
diff --git a/apps/antalmanac/src/lib/zotcourse.ts b/apps/antalmanac/src/lib/zotcourse.ts
new file mode 100644
index 00000000..78537ee1
--- /dev/null
+++ b/apps/antalmanac/src/lib/zotcourse.ts
@@ -0,0 +1,33 @@
+import { RepeatingCustomEvent } from "$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog";
+import AppStore from "$stores/AppStore";
+import trpc from "./api/trpc";
+
+export interface ZotCourseResponse {
+    codes: string[];
+    customEvents: RepeatingCustomEvent[];
+}
+export async function queryZotCourse(schedule_name: string) {
+    const response = await trpc.zotcourse.getUserData.mutate({ scheduleName: schedule_name });
+    // For custom event, there is no course attribute in each.
+    const codes = response.data
+        .filter((section: { eventType: number }) => section.eventType === 3)
+        .map((section: { course: { code: string } }) => section.course.code) as string[];
+    const days = [false, false, false, false, false, false, false];
+    const customEvents: RepeatingCustomEvent[] = response.data
+        .filter((section: { eventType: number }) => section.eventType === 1)
+        .map((event: { title: string; start: string; end: string; dow: number[] }) => {
+            return {
+                title: event.title,
+                start: event.start,
+                end: event.end,
+                days: days.map((_, index) => event.dow.includes(index)),
+                scheduleIndices: [AppStore.getCurrentScheduleIndex()],
+                customEventID: Date.now(),
+                color: '#551a8b',
+            };
+        }) as RepeatingCustomEvent[];
+    return {
+        codes: codes,
+        customEvents: customEvents,
+    };
+}
\ No newline at end of file
diff --git a/apps/antalmanac/src/routes/Home.tsx b/apps/antalmanac/src/routes/Home.tsx
index bcee4fed..ccf64d5b 100644
--- a/apps/antalmanac/src/routes/Home.tsx
+++ b/apps/antalmanac/src/routes/Home.tsx
@@ -3,12 +3,12 @@ import DateFnsUtils from '@date-io/date-fns';
 import { MuiPickersUtilsProvider } from '@material-ui/pickers';
 import { Box, CssBaseline, useMediaQuery, useTheme } from '@material-ui/core';
 
-import Bar from '$components/AppBar/CustomAppBar';
+import Header from '$components/Header';
 import MobileHome from '$components/MobileHome';
 import PatchNotes from '$components/PatchNotes';
 import Calendar from '$components/Calendar/CalendarRoot';
 import DesktopTabs from '$components/RightPane/RightPaneRoot';
-import NotificationSnackbar from '$components/AppBar/NotificationSnackbar';
+import NotificationSnackbar from '$components/NotificationSnackbar';
 
 export default function Home() {
     const isMobileScreen = useMediaQuery('(max-width: 750px)');
@@ -18,7 +18,7 @@ export default function Home() {
         <MuiPickersUtilsProvider utils={DateFnsUtils}>
             <CssBaseline />
             <PatchNotes />
-            <Bar />
+            <Header />
             {isMobileScreen ? (
                 <MobileHome />
             ) : (
diff --git a/apps/antalmanac/src/stores/AppStore.ts b/apps/antalmanac/src/stores/AppStore.ts
index ae68613b..8be07c60 100644
--- a/apps/antalmanac/src/stores/AppStore.ts
+++ b/apps/antalmanac/src/stores/AppStore.ts
@@ -3,7 +3,7 @@ import { VariantType } from 'notistack';
 
 import { ScheduleCourse, ScheduleSaveState } from '@packages/antalmanac-types';
 import { Schedules } from './Schedules';
-import { SnackbarPosition } from '$components/AppBar/NotificationSnackbar';
+import { SnackbarPosition } from '$components/NotificationSnackbar';
 import { CalendarEvent, CourseEvent } from '$components/Calendar/CourseCalendarEvent';
 import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog';
 import { useTabStore } from '$stores/TabStore';
@@ -310,6 +310,9 @@ class AppStore extends EventEmitter {
         this.schedule.updateScheduleNote(newScheduleNote, scheduleIndex);
         this.emit('scheduleNotesChange');
     }
+
+    termsInSchedule = (term: string) =>
+        new Set([term, ...this.schedule.getCurrentCourses().map((course) => course.term)]);
 }
 
 const store = new AppStore();
diff --git a/apps/antalmanac/src/stores/ColumnStore.ts b/apps/antalmanac/src/stores/ColumnStore.ts
index 936fe843..850a2514 100644
--- a/apps/antalmanac/src/stores/ColumnStore.ts
+++ b/apps/antalmanac/src/stores/ColumnStore.ts
@@ -1,6 +1,6 @@
 import { create } from 'zustand';
-import analyticsEnum, { logAnalytics } from '$lib/analytics';
 import useTabStore from './TabStore';
+import analyticsEnum, { logAnalytics } from '$lib/analytics';
 
 /**
  * Search results are displayed in a tabular format.
@@ -72,7 +72,7 @@ export const useColumnStore = create<ColumnStore>((set, get) => {
             set(() => {
                 const selectedColumns = SECTION_TABLE_COLUMNS.map((column) => columns.includes(column));
                 const activeColumns = SECTION_TABLE_COLUMNS.filter(
-                    (_, index) => get().enabledColumns[index] && get().selectedColumns[index]
+                    (_, index) => get().enabledColumns[index] && selectedColumns[index]
                 );
                 return { selectedColumns: selectedColumns, activeColumns: activeColumns };
             });
diff --git a/apps/antalmanac/src/stores/Schedules.ts b/apps/antalmanac/src/stores/Schedules.ts
index 6443b9b0..0ecabbdf 100644
--- a/apps/antalmanac/src/stores/Schedules.ts
+++ b/apps/antalmanac/src/stores/Schedules.ts
@@ -7,9 +7,9 @@ import {
 } from '@packages/antalmanac-types';
 import { calendarizeCourseEvents, calendarizeCustomEvents, calendarizeFinals } from './calendarizeHelpers';
 import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog';
-import type { CourseInfo } from '$lib/helpers';
+import type { CourseInfo } from '$lib/course_data.types';
 import { getColorForNewSection } from '$stores/scheduleHelpers';
-import { getCourseInfo, queryWebsoc } from '$lib/course-helpers';
+import WebSOC from '$lib/websoc';
 
 export class Schedules {
     private schedules: Schedule[];
@@ -148,7 +148,7 @@ export class Schedules {
     }
 
     /**
-     * @return Reference of the course that matches the params
+     * @return A course that matches the params across all schedules
      */
     getExistingCourse(sectionCode: string, term: string) {
         for (const course of this.getAllCourses()) {
@@ -159,6 +159,18 @@ export class Schedules {
         return undefined;
     }
 
+    /**
+     * @return A course that matches the params in the current schedule
+     */
+    getExistingCourseInSchedule(sectionCode: string, term: string) {
+        for (const course of this.getCurrentCourses()) {
+            if (course.section.sectionCode === sectionCode && term === course.term) {
+                return course;
+            }
+        }
+        return undefined;
+    }
+
     /**
      * Adds a course to a given schedule index
      * Sets color to an unused color in set, also will not add class if already exists
@@ -172,7 +184,7 @@ export class Schedules {
             this.addUndoState();
         }
 
-        const existingSection = this.getExistingCourse(newCourse.section.sectionCode, newCourse.term);
+        const existingSection = this.getExistingCourseInSchedule(newCourse.section.sectionCode, newCourse.term);
 
         const existsInSchedule = this.doesCourseExistInSchedule(
             newCourse.section.sectionCode,
@@ -222,7 +234,8 @@ export class Schedules {
      */
     changeCourseColor(sectionCode: string, term: string, newColor: string) {
         this.addUndoState();
-        const course = this.getExistingCourse(sectionCode, term);
+
+        const course = this.getExistingCourseInSchedule(sectionCode, term);
         if (course) {
             course.section.color = newColor;
         }
@@ -464,7 +477,7 @@ export class Schedules {
 
             const websocRequests = Object.entries(courseDict).map(async ([term, courseSet]) => {
                 const sectionCodes = Array.from(courseSet).join(',');
-                const courseInfo = getCourseInfo(await queryWebsoc({ term, sectionCodes }));
+                const courseInfo = await WebSOC.getCourseInfo({ term, sectionCodes });
                 courseInfoDict.set(term, courseInfo);
             });
 
diff --git a/apps/antalmanac/src/stores/calendarizeHelpers.ts b/apps/antalmanac/src/stores/calendarizeHelpers.ts
index 1541cea7..bcc917fb 100644
--- a/apps/antalmanac/src/stores/calendarizeHelpers.ts
+++ b/apps/antalmanac/src/stores/calendarizeHelpers.ts
@@ -48,7 +48,9 @@ export function calendarizeCourseEvents(currentCourses: ScheduleCourse[] = []):
                         term: course.term,
                         title: `${course.deptCode} ${course.courseNumber}`,
                         courseTitle: course.courseTitle,
-                        locations: meeting.bldg.map(getLocation),
+                        locations: meeting.bldg.map(getLocation).map((location: Location) => {
+                            return { ...location, days: meeting.days === null ? undefined : meeting.days };
+                        }),
                         showLocationInfo: false,
                         instructors: course.section.instructors,
                         sectionCode: course.section.sectionCode,
@@ -121,7 +123,12 @@ export function calendarizeFinals(currentCourses: ScheduleCourse[] = []): Course
 export function calendarizeCustomEvents(currentCustomEvents: RepeatingCustomEvent[] = []): CustomEvent[] {
     return currentCustomEvents.flatMap((customEvent) => {
         const dayIndiciesOcurring = customEvent.days.map((day, index) => (day ? index : undefined)).filter(notNull);
-
+        /**
+         * Only include the day strings that the custom event occurs.
+         *
+         * @example [1, 3, 5] -> ['M', 'W', 'F']
+         */
+        const days = dayIndiciesOcurring.map((dayIndex) => COURSE_WEEK_DAYS[dayIndex]);
         return dayIndiciesOcurring.map((dayIndex) => {
             const startHour = parseInt(customEvent.start.slice(0, 2), 10);
             const startMin = parseInt(customEvent.start.slice(3, 5), 10);
@@ -135,6 +142,7 @@ export function calendarizeCustomEvents(currentCustomEvents: RepeatingCustomEven
                 isCustomEvent: true,
                 end: new Date(2018, 0, dayIndex, endHour, endMin),
                 title: customEvent.title,
+                days,
             };
         });
     });
diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts
index d87c2749..bb1e2a35 100644
--- a/apps/backend/src/index.ts
+++ b/apps/backend/src/index.ts
@@ -25,7 +25,7 @@ export async function start(corsEnabled = false) {
     app.use('/mapbox/directions/*', async (req, res) => {
         const searchParams = new URLSearchParams(req.query as any);
         searchParams.set('access_token', env.MAPBOX_ACCESS_TOKEN);
-        const url = `${MAPBOX_API_URL}/directions/v5/${req.params[0]}?${searchParams.toString()}`;
+        const url = `${MAPBOX_API_URL}/directions/v5/${(req.params as any)[0]}?${searchParams.toString()}`;
         const result = await fetch(url).then((res) => res.text());
         res.send(result);
     });
@@ -33,7 +33,7 @@ export async function start(corsEnabled = false) {
     app.use('/mapbox/tiles/*', async (req, res) => {
         const searchParams = new URLSearchParams(req.query as any);
         searchParams.set('access_token', env.MAPBOX_ACCESS_TOKEN);
-        const url = `${MAPBOX_API_URL}/styles/v1/mapbox/streets-v11/tiles/${req.params[0]}?${searchParams.toString()}`;
+        const url = `${MAPBOX_API_URL}/styles/v1/mapbox/streets-v11/tiles/${(req.params as any)[0]}?${searchParams.toString()}`;
         const buffer = await fetch(url).then((res) => res.arrayBuffer());
         res.type('image/png')
         res.send(Buffer.from(buffer))
diff --git a/apps/backend/src/routers/news.ts b/apps/backend/src/routers/news.ts
index 8cc56e49..dfc33842 100644
--- a/apps/backend/src/routers/news.ts
+++ b/apps/backend/src/routers/news.ts
@@ -1,5 +1,5 @@
 import { router, procedure } from '../trpc';
-import NewsModel, { News } from '$models/News';
+import NewsModel, { News } from '../models/News';
 
 const newsRouter = router({
     /**
diff --git a/apps/backend/src/routers/users.ts b/apps/backend/src/routers/users.ts
index 661164a5..67fcf8e2 100644
--- a/apps/backend/src/routers/users.ts
+++ b/apps/backend/src/routers/users.ts
@@ -8,10 +8,10 @@ import {
     ShortCourseSchema,
 } from '@packages/antalmanac-types';
 import { router, procedure } from '../trpc';
-import { getById, insertById } from '$db/ddb';
-import LegacyUserModel from '$models/User';
+import { getById, insertById } from '../db/ddb';
+import LegacyUserModel from '../models/User';
 
-import connectToMongoDB from '$db/mongodb';
+import connectToMongoDB from '../db/mongodb';
 
 export function convertLegacySchedule(legacyUserData: LegacyUserData) {
     const scheduleSaveState: ScheduleSaveState = { schedules: [], scheduleIndex: 0 };


Test patch:
diff --git a/apps/antalmanac/tests/__snapshots__/download-ics.test.ts.snap b/apps/antalmanac/tests/__snapshots__/download-ics.test.ts.snap
new file mode 100644
index 00000000..f38c5064
--- /dev/null
+++ b/apps/antalmanac/tests/__snapshots__/download-ics.test.ts.snap
@@ -0,0 +1,72 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`download-ics > converts schedule courses to events for the ics library 1`] = `
+[
+  {
+    "description": "placeholderCourseTitle
+Taught by placeholderInstructor1/placeholderInstructor2",
+    "end": [
+      2023,
+      9,
+      29,
+      3,
+      4,
+    ],
+    "endOutputType": "local",
+    "location": "placeholderLocation placeholderRoom",
+    "productId": "antalmanac/ics",
+    "recurrenceRule": "FREQ=WEEKLY;BYDAY=FR,MO,WE;INTERVAL=1;COUNT=31",
+    "start": [
+      2023,
+      9,
+      29,
+      1,
+      2,
+    ],
+    "startOutputType": "local",
+    "title": "placeholderDeptCode placeholderCourseNumber placeholderSectionType",
+  },
+  {
+    "description": "Final Exam for placeholderSectionType placeholderCourseTitle",
+    "end": [
+      2023,
+      3,
+      3,
+      3,
+      4,
+    ],
+    "endOutputType": "local",
+    "productId": "antalmanac/ics",
+    "start": [
+      2023,
+      3,
+      3,
+      1,
+      2,
+    ],
+    "startOutputType": "local",
+    "title": "placeholderDeptCode placeholderCourseNumber Final Exam",
+  },
+  {
+    "end": [
+      2023,
+      9,
+      29,
+      3,
+      4,
+    ],
+    "endOutputType": "local",
+    "productId": "antalmanac/ics",
+    "recurrenceRule": "FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=1;COUNT=31",
+    "start": [
+      2023,
+      9,
+      29,
+      1,
+      2,
+    ],
+    "startOutputType": "local",
+    "title": "placeholderCustomEventTitle",
+  },
+]
+`;
diff --git a/apps/antalmanac/tests/calendarize-helpers.test.ts b/apps/antalmanac/tests/calendarize-helpers.test.ts
index 7c3bb0c2..6f0358ae 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',
+            days: ['Su', 'Tu', 'Th', 'Sa'],
         },
         {
             isCustomEvent: true,
@@ -216,6 +217,7 @@ describe('calendarize-helpers', () => {
             start: new Date(2018, 0, 2, 1, 2),
             end: new Date(2018, 0, 2, 3, 4),
             title: 'title',
+            days: ['Su', 'Tu', 'Th', 'Sa'],
         },
         {
             isCustomEvent: true,
@@ -224,6 +226,7 @@ describe('calendarize-helpers', () => {
             start: new Date(2018, 0, 4, 1, 2),
             end: new Date(2018, 0, 4, 3, 4),
             title: 'title',
+            days: ['Su', 'Tu', 'Th', 'Sa'],
         },
         {
             isCustomEvent: true,
@@ -232,6 +235,7 @@ describe('calendarize-helpers', () => {
             start: new Date(2018, 0, 6, 1, 2),
             end: new Date(2018, 0, 6, 3, 4),
             title: 'title',
+            days: ['Su', 'Tu', 'Th', 'Sa'],
         },
     ];
 
diff --git a/apps/antalmanac/tests/download-ics.test.ts b/apps/antalmanac/tests/download-ics.test.ts
index f4d62aa4..f07b3628 100644
--- a/apps/antalmanac/tests/download-ics.test.ts
+++ b/apps/antalmanac/tests/download-ics.test.ts
@@ -2,96 +2,56 @@ import { EventAttributes } from 'ics';
 import type { Schedule } from '@packages/antalmanac-types';
 import { describe, test, expect } from 'vitest';
 import { getEventsFromCourses } from '$lib/download';
+import { CalendarEvent } from '$components/Calendar/CourseCalendarEvent';
 
 describe('download-ics', () => {
     test('converts schedule courses to events for the ics library', () => {
-        const courses: Schedule['courses'] = [
+        const courses: CalendarEvent[] = [
+            // CourseEvent
             {
-                courseComment: 'placeholderCourseComment',
-                courseNumber: 'placeholderCourseNumber',
-                courseTitle: 'placeholderCourseTitle',
-                deptCode: 'placeholderDeptCode',
-                prerequisiteLink: 'placeholderPrerequisiteLink',
-                section: {
-                    color: 'placeholderColor',
-                    sectionCode: 'placeholderSectionCode',
-                    sectionType: 'placeholderSectionType',
-                    sectionNum: 'placeholderSectionNum',
-                    units: 'placeholderUnits',
-                    instructors: ['placeholderInstructor1', 'placeholderInstructor2'],
-                    meetings: [
-                        {
-                            timeIsTBA: false,
-                            bldg: ['placeholderLocation'],
-                            days: 'MWF',
-                            startTime: {
-                                hour: 1,
-                                minute: 2,
-                            },
-                            endTime: {
-                                hour: 3,
-                                minute: 4,
-                            },
-                        },
-                    ],
-                    finalExam: {
-                        examStatus: 'SCHEDULED_FINAL',
-                        dayOfWeek: 'Sun',
-                        month: 2,
-                        day: 3,
-                        startTime: {
-                            hour: 1,
-                            minute: 2,
-                        },
-                        endTime: {
-                            hour: 3,
-                            minute: 4,
-                        },
-                        bldg: [],
+                color: 'placeholderColor',
+                start: new Date(2023, 9, 29, 1, 2),
+                end: new Date(2023, 9, 29, 3, 4),
+                title: 'placeholderDeptCode placeholderCourseNumber',
+                locations: [{ building: 'placeholderLocation', room: 'placeholderRoom', days: 'MWF' }],
+                showLocationInfo: true,
+                finalExam: {
+                    examStatus: 'SCHEDULED_FINAL',
+                    dayOfWeek: 'Mon',
+                    month: 2,
+                    day: 3,
+                    startTime: {
+                        hour: 1,
+                        minute: 2,
                     },
-                    maxCapacity: 'placeholderMaxCapacity',
-                    numCurrentlyEnrolled: {
-                        totalEnrolled: 'placeholderTotalEnrolled',
-                        sectionEnrolled: 'placeholderSectionEnrolled',
+                    endTime: {
+                        hour: 3,
+                        minute: 4,
                     },
-                    numOnWaitlist: 'placeholderNumOnWaitlist',
-                    numWaitlistCap: 'placeholderNumWaitlistCap',
-                    numRequested: 'placeholderNumRequested',
-                    numNewOnlyReserved: 'placeholderNumNewOnlyReserved',
-                    restrictions: 'placeholderRestrictions',
-                    status: 'OPEN',
-                    sectionComment: 'placeholderSectionComment',
+                    locations: [{ building: 'placeholderFinalLocation', room: 'placeholderFinalRoom' }],
                 },
+                courseTitle: 'placeholderCourseTitle',
+                instructors: ['placeholderInstructor1', 'placeholderInstructor2'],
+                isCustomEvent: false,
+                sectionCode: 'placeholderSectionCode',
+                sectionType: 'placeholderSectionType',
                 term: '2023 Fall', // Cannot be a random placeholder; it has to be in `quarterStartDates` otherwise it'll be undefined
             },
-        ];
-
-        const expectedResult: EventAttributes[] = [
-            {
-                productId: 'antalmanac/ics',
-                startOutputType: 'local',
-                endOutputType: 'local',
-                title: 'placeholderDeptCode placeholderCourseNumber placeholderSectionType',
-                description: 'placeholderCourseTitle\nTaught by placeholderInstructor1/placeholderInstructor2',
-                location: 'placeholderLocation',
-                start: [2023, 9, 29, 1, 2],
-                end: [2023, 9, 29, 3, 4],
-                recurrenceRule: 'FREQ=WEEKLY;BYDAY=FR,MO,WE;INTERVAL=1;COUNT=31',
-            },
+            // CustomEvent
             {
-                productId: 'antalmanac/ics',
-                startOutputType: 'local',
-                endOutputType: 'local',
-                title: 'placeholderDeptCode placeholderCourseNumber Final Exam',
-                description: 'Final Exam for placeholderCourseTitle',
-                start: [2023, 3, 3, 1, 2],
-                end: [2023, 3, 3, 3, 4],
+                color: 'placeholderColor',
+                start: new Date(2023, 9, 29, 1, 2),
+                end: new Date(2023, 9, 29, 3, 4),
+                title: 'placeholderCustomEventTitle',
+                customEventID: 123,
+                isCustomEvent: true,
+                days: ['M', 'W', 'F'],
             },
         ];
 
         const result = getEventsFromCourses(courses);
 
-        expect(result).toEqual(expectedResult);
+        expect(result).toMatchSnapshot();
     });
 
     test('ics file has the correct contents', () => {