Repository: icssc/AntAlmanac
Pull request #919 (2024-02-21 08:08:46)

Extending the Hover Feature for Finals
## Summary

Since we've been diversifying the information available about finals, I decided to generalize the functionality of the hover feature.
- Modified HoveredStore.ts to have separate data fields for calendarized finals, calendarized courses, and raw ScheduleCourse data
- Correspondingly updated the variable names in courseRenderPane and sectionTableBody to decentralize it from "courses" to events in general
- Cleaned up the logic in CalendarRoot for incorporating the hovered courses, and added finals to the logic
- Changed the date view to override any selected finals week to the week of the hovered final

I also made an extremely small one line refactor in TermData for consistency's sake since it is related to this PR and was discussed.

## Test Plan
- Attempt hover on courses as well as finals for an arbitrary course on an empty schedule
- Attempt hover on both after adding a course
- Attempt hover on both after adding a course from a different, non-default quarter
- Attempt hover on both after adding a course from a different but default quarter

## Future Followup
Is there a reason hover to preview is still an experimental toggle feature?



Patch:
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 0808a78c..a4c42415 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -7,16 +7,49 @@ const config = {
     root: true,
     parser: '@typescript-eslint/parser',
     parserOptions: {
-        project: 'tsconfig.json',
+        project: ['tsconfig.json'],
+        sourceType: 'module',
+    },
+    plugins: ['import', '@typescript-eslint/eslint-plugin'],
+    extends: [
+        'eslint:recommended',
+        'plugin:import/recommended',
+        'plugin:import/typescript',
+        'plugin:@typescript-eslint/recommended',
+        'prettier',
+    ],
+    rules: {
+        '@typescript-eslint/no-unused-vars': [
+            'error',
+            {
+                argsIgnorePattern: '^_',
+                varsIgnorePattern: '^_',
+                caughtErrorsIgnorePattern: '^_',
+            },
+        ],
+        'import/order': [
+            'error',
+            {
+                alphabetize: {
+                    order: 'asc',
+                },
+                'newlines-between': 'always',
+            },
+        ],
+    },
+    settings: {
+        'import/resolver': {
+            typescript: {
+                project: ['tsconfig.json', 'apps/*/tsconfig.json'],
+            },
+        },
     },
-    plugins: ['@typescript-eslint/eslint-plugin'],
-    extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
     env: {
         browser: true,
         es6: true,
         node: true,
     },
-    ignorePatterns: ['*.config.*', '*.cjs'],
+    ignorePatterns: ['*.config.*', '*rc.cjs'],
 };
 
 module.exports = config;
diff --git a/.nvmrc b/.nvmrc
new file mode 100644
index 00000000..9de22568
--- /dev/null
+++ b/.nvmrc
@@ -0,0 +1,1 @@
+lts/iron
diff --git a/apps/antalmanac/.eslintrc.cjs b/apps/antalmanac/.eslintrc.cjs
index 8de43d0e..e336187f 100644
--- a/apps/antalmanac/.eslintrc.cjs
+++ b/apps/antalmanac/.eslintrc.cjs
@@ -1,43 +1,27 @@
-/** @type {import('eslint').Linter.Config} */
+// @ts-check
+
+/**
+ * @type {import('eslint').Linter.Config}
+ */
 const config = {
-  root: true,
-  parser: '@typescript-eslint/parser',
-  parserOptions: {
-    project: 'tsconfig.json',
-  },
-  plugins: [
-    'react',
-    'react-hooks',
-    'import',
-    'jsx-a11y',
-    '@typescript-eslint/eslint-plugin',
-  ],
-  extends: [
-    'eslint:recommended',
-    'plugin:react/recommended',
-    'plugin:react-hooks/recommended',
-    'plugin:react/jsx-runtime',
-    'plugin:jsx-a11y/recommended',
-    'plugin:@typescript-eslint/eslint-recommended',
-    'plugin:@typescript-eslint/recommended',
-    'prettier',
-  ],
-  rules: {
-    'import/order': 'error',
-  },
-  env: {
-    browser: true,
-    es6: true,
-  },
-  globals: {
-    React: true,
-  },
-  settings: {
-    react: {
-      version: 'detect',
-    }
-  },
-  ignorePatterns: ['vite.config.ts'],
-}
+    parserOptions: {
+        project: ['tsconfig.json'],
+    },
+    plugins: ['react', 'react-hooks', 'jsx-a11y'],
+    extends: [
+        'plugin:react/recommended',
+        'plugin:react-hooks/recommended',
+        'plugin:react/jsx-runtime',
+        'plugin:jsx-a11y/recommended',
+    ],
+    globals: {
+        React: true,
+    },
+    settings: {
+        react: {
+            version: 'detect',
+        },
+    },
+};
 
-module.exports = config
+module.exports = config;
diff --git a/apps/antalmanac/index.html b/apps/antalmanac/index.html
index 716ad419..427f5979 100644
--- a/apps/antalmanac/index.html
+++ b/apps/antalmanac/index.html
@@ -20,7 +20,9 @@
             name="viewport"
             content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"
         />
-        <meta name="theme-color" content="#000000" />
+        <meta name="theme-color" content="#305db7" />
+        <meta name="apple-mobile-web-app-capable" content="yes" />
+        <meta name="apple-mobile-web-app-status-bar-style " content="black-translucent" />
         <meta
             name="keywords"
             content="UCI,Anteater,search,classes,Calendar,development,software,add courses,antalmanac,course,course planner"
diff --git a/apps/antalmanac/package.json b/apps/antalmanac/package.json
index 27753c18..ee1079e9 100644
--- a/apps/antalmanac/package.json
+++ b/apps/antalmanac/package.json
@@ -2,6 +2,7 @@
     "name": "antalmanac",
     "version": "1.0.0",
     "private": true,
+    "type": "module",
     "homepage": "https://antalmanac.com",
     "description": "A course exploration and scheduling tool for UCI Anteaters",
     "bugs": {
@@ -31,7 +32,7 @@
         "@mui/icons-material": "^5.11.0",
         "@mui/lab": "^5.0.0-alpha.118",
         "@mui/material": "^5.11.7",
-        "@packages/antalmanac-types": "*",
+        "@packages/antalmanac-types": "workspace:^",
         "@react-leaflet/core": "^2.1.0",
         "@reactour/tour": "^3.6.1",
         "@tanstack/react-query": "^4.24.4",
@@ -65,6 +66,7 @@
         "react-split": "^2.0.14",
         "recharts": "^2.4.2",
         "superjson": "^1.12.3",
+        "ua-parser-js": "^1.0.37",
         "websoc-fuzzy-search": "^1.0.1",
         "zustand": "^4.3.2"
     },
@@ -72,6 +74,7 @@
         "@babel/core": "^7.20.12",
         "@packages/antalmanac-types": "workspace:^",
         "@testing-library/react": "^14.0.0",
+        "@types/eslint": "^8.56.5",
         "@types/file-saver": "^2.0.5",
         "@types/leaflet": "^1.9.0",
         "@types/leaflet-routing-machine": "^3.2.4",
@@ -85,8 +88,6 @@
         "@types/react-lazyload": "^3.2.0",
         "@types/reactour": "^1.18.5",
         "@types/ua-parser-js": "^0.7.39",
-        "@typescript-eslint/eslint-plugin": "^5.54.1",
-        "@typescript-eslint/parser": "^5.54.1",
         "@vitejs/plugin-react": "^4.0.4",
         "aws-sdk": "^2.1550.0",
         "eslint": "^8.36.0",
@@ -95,25 +96,10 @@
         "eslint-plugin-jsx-a11y": "^6.7.1",
         "eslint-plugin-react": "^7.32.2",
         "eslint-plugin-react-hooks": "^4.6.0",
-        "gh-pages": "^5.0.0",
-        "husky": "^8.0.3",
-        "lint-staged": "^13.1.1",
         "peterportal-api-next-types": "1.0.0-rc.2.68.0",
         "prettier": "^2.8.4",
         "typescript": "^4.9.5",
         "vite": "^4.4.9",
         "vite-plugin-svgr": "^2.4.0"
-    },
-    "browserslist": {
-        "production": [
-            ">0.2%",
-            "not dead",
-            "not op_mini all"
-        ],
-        "development": [
-            "last 1 chrome version",
-            "last 1 firefox version",
-            "last 1 safari version"
-        ]
     }
 }
diff --git a/apps/antalmanac/public/manifest.json b/apps/antalmanac/public/manifest.json
index 9d029ca9..984de002 100644
--- a/apps/antalmanac/public/manifest.json
+++ b/apps/antalmanac/public/manifest.json
@@ -10,6 +10,6 @@
     ],
     "start_url": "./index.html",
     "display": "standalone",
-    "theme_color": "#000000",
-    "background_color": "#ffffff"
+    "theme_color": "#305db7",
+    "background_color": "#000000"
 }
diff --git a/apps/antalmanac/src/actions/ActionTypesStore.ts b/apps/antalmanac/src/actions/ActionTypesStore.ts
new file mode 100644
index 00000000..1515d30f
--- /dev/null
+++ b/apps/antalmanac/src/actions/ActionTypesStore.ts
@@ -0,0 +1,163 @@
+import { EventEmitter } from 'events';
+import { RepeatingCustomEvent, ScheduleCourse } from '@packages/antalmanac-types';
+import { autoSaveSchedule } from '$actions/AppStoreActions';
+import AppStore from '$stores/AppStore';
+
+const MAX_UNSAVED_ACTIONS = 1000;
+
+export interface UndoAction {
+    type: 'undoAction';
+}
+
+export interface AddCourseAction {
+    type: 'addCourse';
+    course: ScheduleCourse;
+    scheduleIndex: number;
+}
+
+export interface DeleteCourseAction {
+    type: 'deleteCourse';
+    sectionCode: string;
+    term: string;
+}
+
+export interface AddCustomEventAction {
+    type: 'addCustomEvent';
+    customEvent: RepeatingCustomEvent;
+    scheduleIndices: number[];
+}
+
+export interface DeleteCustomEventAction {
+    type: 'deleteCustomEvent';
+    customEventId: number;
+}
+
+export interface EditCustomEventAction {
+    type: 'editCustomEvent';
+    editedCustomEvent: RepeatingCustomEvent;
+    newScheduleIndices: number[];
+}
+
+export interface ChangeCustomEventColorAction {
+    type: 'changeCustomEventColor';
+    customEventId: number;
+    newColor: string;
+}
+
+export interface ClearScheduleAction {
+    type: 'clearSchedule';
+}
+
+export interface CopyScheduleAction {
+    type: 'copySchedule';
+    to: number;
+}
+
+export interface ChangeCourseColorAction {
+    type: 'changeCourseColor';
+    sectionCode: string;
+    term: string;
+    newColor: string;
+}
+
+export type ActionType =
+    | AddCourseAction
+    | DeleteCourseAction
+    | AddCustomEventAction
+    | DeleteCustomEventAction
+    | EditCustomEventAction
+    | ChangeCustomEventColorAction
+    | ClearScheduleAction
+    | CopyScheduleAction
+    | ChangeCourseColorAction
+    | UndoAction;
+
+function parseUnsavedActionsString(unsavedActionsString: string): ActionType[] {
+    try {
+        return JSON.parse(unsavedActionsString);
+    } catch {
+        return [];
+    }
+}
+
+class ActionTypesStore extends EventEmitter {
+    constructor() {
+        super();
+    }
+
+    async autoSaveSchedule(action: ActionType) {
+        const autoSave = typeof Storage !== 'undefined' && window.localStorage.getItem('autoSave') == 'true';
+        if (autoSave) {
+            const savedUserID = window.localStorage.getItem('userID');
+
+            if (savedUserID) {
+                this.emit('autoSaveStart');
+                await autoSaveSchedule(savedUserID);
+                AppStore.unsavedChanges = false;
+                this.emit('autoSaveEnd');
+            }
+        } else {
+            const unsavedActionsString = window.localStorage.getItem('unsavedActions');
+            if (unsavedActionsString == null) {
+                const unsavedActions = [action];
+                localStorage.setItem('unsavedActions', JSON.stringify(unsavedActions));
+            } else {
+                let unsavedActions = parseUnsavedActionsString(unsavedActionsString);
+                if (unsavedActions.length > MAX_UNSAVED_ACTIONS) {
+                    unsavedActions = unsavedActions.slice(100);
+                }
+                unsavedActions.push(action);
+                localStorage.setItem('unsavedActions', JSON.stringify(unsavedActions));
+            }
+        }
+    }
+
+    async loadScheduleFromLocalSave() {
+        const unsavedActionsString = window.localStorage.getItem('unsavedActions');
+
+        if (unsavedActionsString == null) return;
+        if (!confirm('You have unsaved changes. Would you like to load them?')) {
+            window.localStorage.removeItem('unsavedActions');
+            return;
+        }
+
+        const actions = parseUnsavedActionsString(unsavedActionsString);
+
+        actions.forEach((action) => {
+            switch (action.type) {
+                case 'addCourse':
+                    AppStore.schedule.addCourse(action.course, action.scheduleIndex);
+                    break;
+                case 'deleteCourse':
+                    AppStore.schedule.deleteCourse(action.sectionCode, action.term);
+                    break;
+                case 'addCustomEvent':
+                    AppStore.schedule.addCustomEvent(action.customEvent, action.scheduleIndices);
+                    break;
+                case 'deleteCustomEvent':
+                    AppStore.schedule.deleteCustomEvent(action.customEventId);
+                    break;
+                case 'editCustomEvent':
+                    AppStore.schedule.editCustomEvent(action.editedCustomEvent, action.newScheduleIndices);
+                    break;
+                case 'changeCustomEventColor':
+                    AppStore.schedule.changeCustomEventColor(action.customEventId, action.newColor);
+                    break;
+                case 'changeCourseColor':
+                    AppStore.schedule.changeCourseColor(action.sectionCode, action.term, action.newColor);
+                    break;
+                case 'clearSchedule':
+                    AppStore.schedule.clearCurrentSchedule();
+                    break;
+                case 'copySchedule':
+                    AppStore.schedule.copySchedule(action.to);
+                    break;
+                default:
+                    break;
+            }
+        });
+    }
+}
+
+const actionTypesStore = new ActionTypesStore();
+export default actionTypesStore;
diff --git a/apps/antalmanac/src/actions/AppStoreActions.ts b/apps/antalmanac/src/actions/AppStoreActions.ts
index 4bf8caea..24c3814b 100644
--- a/apps/antalmanac/src/actions/AppStoreActions.ts
+++ b/apps/antalmanac/src/actions/AppStoreActions.ts
@@ -102,6 +102,31 @@ export const saveSchedule = async (userID: string, rememberMe: boolean) => {
     }
 };
 
+export async function autoSaveSchedule(userID: string) {
+    logAnalytics({
+        category: analyticsEnum.nav.title,
+        action: analyticsEnum.nav.actions.SAVE_SCHEDULE,
+        label: userID,
+    });
+    if (userID == null) return;
+    userID = userID.replace(/\s+/g, '');
+
+    if (userID.length < 0) return;
+    const scheduleSaveState = AppStore.schedule.getScheduleAsSaveState();
+
+    try {
+        await trpc.users.saveUserData.mutate({ id: userID, userData: scheduleSaveState });
+
+        AppStore.saveSchedule();
+    } catch (e) {
+        if (e instanceof TRPCError) {
+            openSnackbar('error', `Schedule could not be auto-saved under username "${userID}`);
+        } else {
+            openSnackbar('error', 'Network error or server is down.');
+        }
+    }
+}
+
 export const loadSchedule = async (userId: string, rememberMe: boolean) => {
     logAnalytics({
         category: analyticsEnum.nav.title,
diff --git a/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx b/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx
index b1dccb66..e7379d42 100644
--- a/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx
+++ b/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx
@@ -80,14 +80,18 @@ export default function ScheduleCalendar(props: ScheduleCalendarProps) {
     const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames());
 
     const { isMilitaryTime } = useTimeFormatStore();
-    const { hoveredCourseEvents } = useHoveredStore();
+    const [hoveredCalendarizedCourses, hoveredCalendarizedFinal] = useHoveredStore((store) => [
+        store.hoveredCalendarizedCourses,
+        store.hoveredCalendarizedFinal,
+    ]);
 
     const getEventsForCalendar = (): CalendarEvent[] => {
-        return showFinalsSchedule
-            ? finalsEventsInCalendar
-            : hoveredCourseEvents
-              ? [...eventsInCalendar, ...hoveredCourseEvents]
-              : eventsInCalendar;
+        if (showFinalsSchedule)
+            return hoveredCalendarizedFinal
+                ? [...finalsEventsInCalendar, hoveredCalendarizedFinal]
+                : finalsEventsInCalendar;
+        else
+            return hoveredCalendarizedCourses ? [...eventsInCalendar, ...hoveredCalendarizedCourses] : eventsInCalendar;
     };
 
     const handleClosePopover = () => {
@@ -165,8 +169,11 @@ export default function ScheduleCalendar(props: ScheduleCalendarProps) {
 
     const onlyCourseEvents = eventsInCalendar.filter((e) => !e.isCustomEvent) as CourseEvent[];
 
-    const finalsDate =
-        onlyCourseEvents.length > 0 ? getFinalsStartDateForTerm(onlyCourseEvents[0].term) : getDefaultFinalsStartDate();
+    const finalsDate = hoveredCalendarizedFinal
+        ? getFinalsStartDateForTerm(hoveredCalendarizedFinal.term)
+        : onlyCourseEvents.length > 0
+          ? getFinalsStartDateForTerm(onlyCourseEvents[0].term)
+          : getDefaultFinalsStartDate();
 
     const finalsDateFormat = finalsDate ? 'ddd MM/DD' : 'ddd';
     const date = showFinalsSchedule && finalsDate ? finalsDate : new Date(2018, 0, 1);
diff --git a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
index 91780d68..15ea5eca 100644
--- a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
+++ b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
@@ -304,7 +304,7 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => {
                             to={`/map?location=${building ?? 0}`}
                             onClick={focusMap}
                         >
-                            {buildingCatalogue[+building].name}
+                            {buildingCatalogue[+building]?.name ?? ''}
                         </Link>
                     </div>
                 )}
diff --git a/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx b/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx
index 4fd5978d..95efe376 100644
--- a/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx
+++ b/apps/antalmanac/src/components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog.tsx
@@ -226,8 +226,8 @@ function CustomEventDialogs(props: CustomEventDialogProps) {
                         {disabled
                             ? 'Schedule and day must be checked'
                             : props.customEvent
-                            ? 'Save Changes'
-                            : 'Add Event'}
+                              ? 'Save Changes'
+                              : 'Add Event'}
                     </Button>
                 </DialogActions>
             </Dialog>
diff --git a/apps/antalmanac/src/components/Header/LoadSaveFunctionality.tsx b/apps/antalmanac/src/components/Header/LoadSaveFunctionality.tsx
index 95970876..db56c151 100644
--- a/apps/antalmanac/src/components/Header/LoadSaveFunctionality.tsx
+++ b/apps/antalmanac/src/components/Header/LoadSaveFunctionality.tsx
@@ -6,9 +6,10 @@ import {
     DialogContentText,
     DialogTitle,
     TextField,
+    CircularProgress,
+    Checkbox,
+    FormControlLabel,
 } from '@material-ui/core';
-import Checkbox from '@material-ui/core/Checkbox';
-import FormControlLabel from '@material-ui/core/FormControlLabel';
 import { CloudDownload, Save } from '@material-ui/icons';
 import { ChangeEvent, PureComponent, useEffect, useState } from 'react';
 
@@ -16,6 +17,7 @@ import { LoadingButton } from '@mui/lab';
 import { loadSchedule, saveSchedule } from '$actions/AppStoreActions';
 import { useThemeStore } from '$stores/SettingsStore';
 import AppStore from '$stores/AppStore';
+import actionTypesStore from '$actions/ActionTypesStore';
 
 interface LoadSaveButtonBaseProps {
     action: typeof saveSchedule;
@@ -32,6 +34,21 @@ interface LoadSaveButtonBaseState {
     rememberMe: boolean;
 }
 
+interface SaveLoadIconProps {
+    loading: boolean;
+    actionName: 'Save' | 'Load';
+}
+
+function SaveLoadIcon(props: SaveLoadIconProps) {
+    return props.loading ? (
+        <CircularProgress size={20} color="inherit" />
+    ) : props.actionName === 'Save' ? (
+        <Save />
+    ) : (
+        <CloudDownload />
+    );
+}
+
 class LoadSaveButtonBase extends PureComponent<LoadSaveButtonBaseProps, LoadSaveButtonBaseState> {
     state: LoadSaveButtonBaseState = {
         isOpen: false,
@@ -92,9 +109,9 @@ class LoadSaveButtonBase extends PureComponent<LoadSaveButtonBaseProps, LoadSave
                     id={this.props.id}
                     onClick={this.handleOpen}
                     color="inherit"
-                    startIcon={this.props.actionName === 'Save' ? <Save /> : <CloudDownload />}
+                    startIcon={<SaveLoadIcon loading={this.props.loading} actionName={this.props.actionName} />}
                     disabled={this.props.disabled}
-                    loading={this.props.loading}
+                    loading={false}
                 >
                     {this.props.actionName}
                 </LoadingButton>
@@ -147,6 +164,7 @@ const LoadSaveScheduleFunctionality = () => {
     const isDark = useThemeStore((store) => store.isDark);
 
     const [loading, setLoading] = useState(false);
+    const [saving, setSaving] = useState(false);
     const [skeletonMode, setSkeletonMode] = useState(AppStore.getSkeletonMode());
 
     const loadScheduleAndSetLoading = async (userID: string, rememberMe: boolean) => {
@@ -155,6 +173,12 @@ const LoadSaveScheduleFunctionality = () => {
         setLoading(false);
     };
 
+    const saveScheduleAndSetLoading = async (userID: string, rememberMe: boolean) => {
+        setSaving(true);
+        await saveSchedule(userID, rememberMe);
+        setSaving(false);
+    };
+
     useEffect(() => {
         const handleSkeletonModeChange = () => {
             setSkeletonMode(AppStore.getSkeletonMode());
@@ -178,14 +202,27 @@ const LoadSaveScheduleFunctionality = () => {
         }
     }, []);
 
+    useEffect(() => {
+        const handleAutoSaveStart = () => setSaving(true);
+        const handleAutoSaveEnd = () => setSaving(false);
+
+        actionTypesStore.on('autoSaveStart', handleAutoSaveStart);
+        actionTypesStore.on('autoSaveEnd', handleAutoSaveEnd);
+
+        return () => {
+            actionTypesStore.off('autoSaveStart', handleAutoSaveStart);
+            actionTypesStore.off('autoSaveEnd', handleAutoSaveEnd);
+        };
+    }, []);
+
     return (
         <div id="load-save-container">
             <LoadSaveButtonBase
                 id="save-button"
                 actionName={'Save'}
-                action={saveSchedule}
+                action={saveScheduleAndSetLoading}
                 disabled={loading}
-                loading={false}
+                loading={saving}
                 colorType={isDark ? 'secondary' : 'primary'}
             />
             <LoadSaveButtonBase
diff --git a/apps/antalmanac/src/components/Header/SettingsMenu.tsx b/apps/antalmanac/src/components/Header/SettingsMenu.tsx
index 7b4f20bf..5b45197a 100644
--- a/apps/antalmanac/src/components/Header/SettingsMenu.tsx
+++ b/apps/antalmanac/src/components/Header/SettingsMenu.tsx
@@ -4,8 +4,11 @@ import { Divider, Stack, Tooltip } from '@mui/material';
 import { CSSProperties } from '@material-ui/core/styles/withStyles';
 import { Close, DarkMode, Help, LightMode, Settings, SettingsBrightness } from '@mui/icons-material';
 
-import { usePreviewStore, useThemeStore, useTimeFormatStore } from '$stores/SettingsStore';
+import { usePreviewStore, useThemeStore, useTimeFormatStore, useAutoSaveStore } from '$stores/SettingsStore';
 import useCoursePaneStore from '$stores/CoursePaneStore';
+import appStore from '$stores/AppStore';
+import actionTypesStore from '$actions/ActionTypesStore';
+import { autoSaveSchedule } from '$actions/AppStoreActions';
 
 const lightSelectedStyle: CSSProperties = {
     backgroundColor: '#F0F7FF',
@@ -141,15 +144,30 @@ function TimeMenu() {
 
 function ExperimentalMenu() {
     const [previewMode, setPreviewMode] = usePreviewStore((store) => [store.previewMode, store.setPreviewMode]);
+    const [autoSave, setAutoSave] = useAutoSaveStore((store) => [store.autoSave, store.setAutoSave]);
 
     const handlePreviewChange = (event: React.ChangeEvent<HTMLInputElement>) => {
         setPreviewMode(event.target.checked);
     };
 
+    const handleAutoSaveChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
+        setAutoSave(event.target.checked);
+
+        if (!event.target.checked) return;
+
+        const savedUserID = window.localStorage.getItem('userID');
+
+        if (!savedUserID) return;
+        actionTypesStore.emit('autoSaveStart');
+        await autoSaveSchedule(savedUserID);
+        appStore.unsavedChanges = false;
+        actionTypesStore.emit('autoSaveEnd');
+    };
+
     return (
-        <Stack sx={{ padding: '1rem 1rem 0 1rem', width: '100%', display: 'flex' }} alignItems="middle">
-            <Box display="flex" justifyContent="space-between" width={1}>
-                <Box display="flex" alignItems="center" style={{ gap: 4 }}>
+        <Stack sx={{ padding: '1rem 1rem 0 1rem', width: '100%', display: 'flex', alignItems: 'middle' }}>
+            <Box style={{ display: 'flex', justifyContent: 'space-between', width: '1' }}>
+                <Box style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
                     <Typography variant="h6" style={{ display: 'flex', alignItems: 'center', alignContent: 'center' }}>
                         Hover to Preview
                     </Typography>
@@ -157,7 +175,19 @@ function ExperimentalMenu() {
                         <Help />
                     </Tooltip>
                 </Box>
-                <Switch color="primary" value={previewMode} checked={previewMode} onChange={handlePreviewChange} />
+                <Switch color={'primary'} value={previewMode} checked={previewMode} onChange={handlePreviewChange} />
+            </Box>
+
+            <Box style={{ display: 'flex', justifyContent: 'space-between', width: '1' }}>
+                <Box style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
+                    <Typography variant="h6" style={{ display: 'flex', alignItems: 'center', alignContent: 'center' }}>
+                        Auto Save
+                    </Typography>
+                    <Tooltip title={<Typography>Auto Save your schedule!</Typography>}>
+                        <Help />
+                    </Tooltip>
+                </Box>
+                <Switch color={'primary'} value={autoSave} checked={autoSave} onChange={handleAutoSaveChange} />
             </Box>
         </Stack>
     );
diff --git a/apps/antalmanac/src/components/Map/Map.tsx b/apps/antalmanac/src/components/Map/Map.tsx
index 58c93c8a..d222015c 100644
--- a/apps/antalmanac/src/components/Map/Map.tsx
+++ b/apps/antalmanac/src/components/Map/Map.tsx
@@ -78,7 +78,7 @@ export function getCoursesPerBuilding() {
 }
 
 export function getCustomEventPerBuilding() {
-    const customEvents = AppStore.getCustomEvents();
+    const customEvents = AppStore.getCustomEventsInCalendar();
 
     const customEventBuildings = customEvents.map((e) => e.building).filter(notNull);
 
@@ -98,9 +98,9 @@ export function getCustomEventPerBuilding() {
 
     interface localCustomEventType {
         title: string;
-        start: string;
-        end: string;
-        days: boolean[];
+        start: Date;
+        end: Date;
+        days: string[];
         customEventID: number;
         color?: string | undefined;
         building?: string | undefined;
@@ -251,20 +251,24 @@ export default function CourseMap() {
 
         const markersToday =
             today === 'All' ? markerValues : markerValues.filter((course) => course.start.toString().includes(today));
+
         return markersToday
             .sort((a, b) => a.start.getTime() - b.start.getTime())
             .filter((marker, i, arr) => arr.findIndex((other) => other.sectionCode === marker.sectionCode) === i);
     }, [markers, today]);
 
     const customEventMarkersToDisplay = useMemo(() => {
-        const markerValues = Object.keys(customEventMarkers).flatMap((markerKey) => customEventMarkers[markerKey]);
+        const markerValues = Object.keys(customEventMarkers)
+            .flatMap((markerKey) => customEventMarkers[markerKey])
+            .filter((marker, i, arr) => arr.findIndex((other) => other.key === marker.key) === i);
 
         const markersToday =
             today === 'All'
                 ? markerValues
                 : markerValues.filter((event) => {
-                      return event.days.some((day, index) => day && WORK_WEEK[index] === today);
+                      return event.days.some((day) => day && today.includes(day));
                   });
+
         return markersToday.sort((a, b) => {
             const startDateA = new Date(`1970-01-01T${a.start}`);
             const startDateB = new Date(`1970-01-01T${b.start}`);
@@ -277,13 +281,16 @@ export default function CourseMap() {
      */
     const startDestPairs = useMemo(() => {
         const allEvents = [...markersToDisplay, ...customEventMarkersToDisplay];
-        return allEvents.reduce((acc, cur, index) => {
-            acc.push([cur]);
-            if (index > 0) {
-                acc[index - 1].push(cur);
-            }
-            return acc;
-        }, [] as (typeof allEvents)[]);
+        return allEvents.reduce(
+            (acc, cur, index) => {
+                acc.push([cur]);
+                if (index > 0) {
+                    acc[index - 1].push(cur);
+                }
+                return acc;
+            },
+            [] as (typeof allEvents)[]
+        );
     }, [markersToDisplay, customEventMarkersToDisplay]);
 
     return (
diff --git a/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx b/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx
index e56f8f65..83f8e777 100644
--- a/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx
+++ b/apps/antalmanac/src/components/RightPane/AddedCourses/AddedCoursePane.tsx
@@ -303,11 +303,14 @@ function SkeletonSchedule() {
     }, []);
 
     const sectionsByTerm: [string, string[]][] = useMemo(() => {
-        const result = skeletonSchedule.courses.reduce((accumulated, course) => {
-            accumulated[course.term] ??= [];
-            accumulated[course.term].push(course.sectionCode);
-            return accumulated;
-        }, {} as Record<string, string[]>);
+        const result = skeletonSchedule.courses.reduce(
+            (accumulated, course) => {
+                accumulated[course.term] ??= [];
+                accumulated[course.term].push(course.sectionCode);
+                return accumulated;
+            },
+            {} as Record<string, string[]>
+        );
 
         return Object.entries(result);
     }, [skeletonSchedule.courses]);
@@ -403,6 +406,13 @@ function AddedSectionsGrid() {
         return scheduleNames[scheduleIndex];
     }, [scheduleNames, scheduleIndex]);
 
+    // "No Courses Added Yet!" notification
+    const NoCoursesBox = (
+        <Box style={{ paddingTop: '12px', paddingBottom: '12px' }}>
+            <Typography align="left">No Courses Added Yet!</Typography>
+        </Box>
+    );
+
     return (
         <Box display="flex" flexDirection="column" gap={1}>
             <Box display="flex" width={1} position="absolute" zIndex="2">
@@ -412,6 +422,7 @@ function AddedSectionsGrid() {
             </Box>
             <Box style={{ marginTop: 50 }}>
                 <Typography variant="h6">{`${scheduleName} (${scheduleUnits} Units)`}</Typography>
+                {courses.length < 1 ? NoCoursesBox : null}
                 <Box display="flex" flexDirection="column" gap={1}>
                     {courses.map((course) => {
                         return (
diff --git a/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx b/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx
index 9f7876ac..3c08df2c 100644
--- a/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx
+++ b/apps/antalmanac/src/components/RightPane/AddedCourses/CustomEventDetailView.tsx
@@ -74,7 +74,7 @@ const CustomEventDetailView = (props: CustomEventDetailViewProps) => {
             />
             <Box sx={{ margin: '0.75rem', color: '#bbbbbb', fontSize: '1rem' }}>
                 <Link to={`/map?location=${customEvent.building ?? 0}`} onClick={focusMap}>
-                    {customEvent.building ? buildingCatalogue[+customEvent.building].name : ''}
+                    {(customEvent.building && buildingCatalogue[+customEvent.building]?.name) || ''}
                 </Link>
             </Box>
 
diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx
index 33b49df8..bd99c5fb 100644
--- a/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx
+++ b/apps/antalmanac/src/components/RightPane/CoursePane/CourseRenderPane.tsx
@@ -1,7 +1,7 @@
 import { useCallback, useEffect, useState } from 'react';
 import LazyLoad from 'react-lazyload';
 
-import { Alert, Box, GlobalStyles, IconButton } from '@mui/material';
+import { Alert, Box, GlobalStyles, IconButton, useMediaQuery } 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';
@@ -27,7 +27,7 @@ function getColors() {
             accumulator[section.sectionCode] = section.color;
             return accumulator;
         },
-        {} as { [key: string]: string }
+        {} as Record<string, string>
     );
 
     return courseColors;
@@ -63,11 +63,15 @@ const RecruitmentBanner = () => {
     const dismissedRecently =
         recruitmentDismissalTime !== null &&
         Date.now() - parseInt(recruitmentDismissalTime) < 11 * 7 * 24 * 3600 * 1000;
-    const isSearchCS = ['COMPSCI', 'IN4MATX', 'I&C SCI', 'STATS'].includes(RightPaneStore.getFormData().deptValue);
+    const isSearchCS = ['COMPSCI', 'IN4MATX', 'I&C SCI', 'STATS'].includes(
+        RightPaneStore.getFormData().deptValue.toUpperCase()
+    );
     const displayRecruitmentBanner = bannerVisibility && !dismissedRecently && isSearchCS;
 
+    const isMobileScreen = useMediaQuery('(max-width: 750px)');
+
     return (
-        <Box sx={{ position: 'fixed', bottom: 5, right: 5, zIndex: 999 }}>
+        <Box sx={{ position: 'fixed', bottom: 5, right: isMobileScreen ? 5 : 75, zIndex: 999 }}>
             {displayRecruitmentBanner ? (
                 <Alert
                     icon={false}
@@ -178,7 +182,7 @@ export default function CourseRenderPane(props: { id?: number }) {
     const [error, setError] = useState(false);
     const [scheduleNames, setScheduleNames] = useState(AppStore.getScheduleNames());
 
-    const setHoveredCourseEvents = useHoveredStore((store) => store.setHoveredCourseEvents);
+    const setHoveredEvents = useHoveredStore((store) => store.setHoveredEvents);
 
     const loadCourses = useCallback(async () => {
         setLoading(true);
@@ -266,9 +270,9 @@ export default function CourseRenderPane(props: { id?: number }) {
      */
     useEffect(() => {
         return () => {
-            setHoveredCourseEvents(undefined);
+            setHoveredEvents(undefined);
         };
-    }, [setHoveredCourseEvents]);
+    }, []);
 
     return (
         <>
diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/HelpBox.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/HelpBox.tsx
index 1fab92e8..e291c1dc 100644
--- a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/HelpBox.tsx
+++ b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/HelpBox.tsx
@@ -1,4 +1,16 @@
-import { Paper, ImageList, ImageListItem, Typography, Link, List, ListItemText, ListItem } from '@mui/material';
+import { Close } from '@mui/icons-material';
+import {
+    Paper,
+    ImageList,
+    ImageListItem,
+    Typography,
+    Link,
+    List,
+    ListItemText,
+    ListItem,
+    Box,
+    IconButton,
+} from '@mui/material';
 
 const images = [
     {
@@ -15,12 +27,21 @@ const images = [
     },
 ];
 
-function HelpBox() {
+interface HelpBoxProps {
+    onDismiss: () => void;
+}
+
+function HelpBox({ onDismiss }: HelpBoxProps) {
     return (
         <Paper variant="outlined" sx={{ padding: 2, marginBottom: '10px', marginRight: '5px' }}>
-            <Typography variant="h5" fontWeight="bold">
-                Need help planning your schedule?
-            </Typography>
+            <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
+                <Typography variant="h5" fontWeight="bold">
+                    Need help planning your schedule?
+                </Typography>
+                <IconButton aria-label="close" size="large" color="inherit" onClick={onDismiss}>
+                    <Close fontSize="inherit" />
+                </IconButton>
+            </Box>
 
             <List component="ol" sx={{ listStyle: 'decimal', pl: 2, pb: 0 }}>
                 <ListItem sx={{ display: 'list-item', p: 0 }}>
diff --git a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/SearchForm.tsx b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/SearchForm.tsx
index a3433549..2bdc6f56 100644
--- a/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/SearchForm.tsx
+++ b/apps/antalmanac/src/components/RightPane/CoursePane/SearchForm/SearchForm.tsx
@@ -50,6 +50,7 @@ const styles: Styles<Theme, object> = {
 const SearchForm = (props: { classes: ClassNameMap; toggleSearch: () => void }) => {
     const { classes, toggleSearch } = props;
     const { manualSearchEnabled, toggleManualSearch } = useCoursePaneStore();
+    const [helpBoxVisibility, setHelpBoxVisibility] = useState(true);
 
     const onFormSubmit = (event: FormEvent) => {
         event.preventDefault();
@@ -57,7 +58,21 @@ const SearchForm = (props: { classes: ClassNameMap; toggleSearch: () => void })
     };
 
     const currentMonthIndex = new Date().getMonth(); // 0=Jan
-    const activeMonthIndices = [false, false, false, false, false, false, false, false, true, true, false, false];
+    // Active months: February/March for Spring planning, May/June for Fall planning, July/August for Summer planning,
+    // and November/December for Winter planning
+    const activeMonthIndices = [false, true, true, false, true, true, true, true, false, false, true, true];
+
+    // Display the help box only if more than 30 days has passed since the last dismissal and
+    // the current month is an active month
+    const helpBoxDismissalTime = window.localStorage.getItem('helpBoxDismissalTime');
+    const dismissedRecently =
+        helpBoxDismissalTime !== null && Date.now() - parseInt(helpBoxDismissalTime) < 30 * 24 * 3600 * 1000;
+    const displayHelpBox = helpBoxVisibility && !dismissedRecently && activeMonthIndices[currentMonthIndex];
+
+    const onHelpBoxDismiss = () => {
+        window.localStorage.setItem('helpBoxDismissalTime', Date.now().toString());
+        setHelpBoxVisibility(false);
+    };
 
     return (
         <div className={classes.rightPane}>
@@ -95,7 +110,7 @@ const SearchForm = (props: { classes: ClassNameMap; toggleSearch: () => void })
                 </div>
             </form>
 
-            {activeMonthIndices[currentMonthIndex] && <HelpBox />}
+            {displayHelpBox && <HelpBox onDismiss={onHelpBoxDismiss} />}
             <PrivacyPolicyBanner />
         </div>
     );
diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/EnrollmentHistoryPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/EnrollmentHistoryPopup.tsx
index deab64e6..f55b75cc 100644
--- a/apps/antalmanac/src/components/RightPane/SectionTable/EnrollmentHistoryPopup.tsx
+++ b/apps/antalmanac/src/components/RightPane/SectionTable/EnrollmentHistoryPopup.tsx
@@ -1,18 +1,82 @@
-import { useState, useEffect, useMemo } from 'react';
-import { LineChart, Line, CartesianGrid, ResponsiveContainer, XAxis, YAxis, Tooltip, Legend } from 'recharts';
-import { Box, Link, Typography, Skeleton, useMediaQuery } from '@mui/material';
+import { useState, useEffect, useMemo, useCallback } from 'react';
+import {
+    LineChart,
+    Line,
+    CartesianGrid,
+    ResponsiveContainer,
+    XAxis,
+    YAxis,
+    Tooltip as RechartsTooltip,
+    Legend,
+} from 'recharts';
+import { ArrowBack, ArrowForward } from '@mui/icons-material';
+import { Box, IconButton, Link, Typography, Skeleton, Tooltip, useMediaQuery } from '@mui/material';
 import { MOBILE_BREAKPOINT } from '../../../globals';
 import { DepartmentEnrollmentHistory, EnrollmentHistory } from '$lib/enrollmentHistory';
 import { useThemeStore } from '$stores/SettingsStore';
 
-export interface EnrollmentHistoryPopupProps {
+type PopupHeaderCallback = () => void;
+
+interface PopupHeaderProps {
+    graphWidth: number;
+    graphIndex: number;
+    handleForward: PopupHeaderCallback;
+    handleBack: PopupHeaderCallback;
+    popupTitle: string;
+    enrollmentHistory: EnrollmentHistory[];
+}
+
+function PopupHeader({
+    graphWidth,
+    graphIndex,
+    handleForward,
+    handleBack,
+    popupTitle,
+    enrollmentHistory,
+}: PopupHeaderProps) {
+    const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT})`);
+
+    return (
+        <Box
+            sx={{
+                display: 'flex',
+                justifyContent: 'space-between',
+                alignItems: 'center',
+                width: graphWidth,
+            }}
+        >
+            <Tooltip title="Older Graph">
+                {/* In order for a tooltip to work properly with disabled buttons, we need to wrap the button in a span */}
+                <span>
+                    <IconButton onClick={handleBack} disabled={graphIndex === 0}>
+                        <ArrowBack />
+                    </IconButton>
+                </span>
+            </Tooltip>
+            <Typography sx={{ fontWeight: 500, fontSize: isMobileScreen ? '0.8rem' : '1rem', textAlign: 'center' }}>
+                {popupTitle}
+            </Typography>
+            <Tooltip title="Newer Graph">
+                <span>
+                    <IconButton onClick={handleForward} disabled={graphIndex === enrollmentHistory.length - 1}>
+                        <ArrowForward />
+                    </IconButton>
+                </span>
+            </Tooltip>
+        </Box>
+    );
+}
+
+interface EnrollmentHistoryPopupProps {
     department: string;
     courseNumber: string;
 }
 
 export function EnrollmentHistoryPopup({ department, courseNumber }: EnrollmentHistoryPopupProps) {
     const [loading, setLoading] = useState(true);
-    const [enrollmentHistory, setEnrollmentHistory] = useState<EnrollmentHistory>();
+    const [enrollmentHistory, setEnrollmentHistory] = useState<EnrollmentHistory[]>();
+    const [graphIndex, setGraphIndex] = useState(0);
+
     const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT})`);
 
     const deptEnrollmentHistory = useMemo(() => new DepartmentEnrollmentHistory(department), [department]);
@@ -24,15 +88,24 @@ export function EnrollmentHistoryPopup({ department, courseNumber }: EnrollmentH
             return 'No past enrollment data found for this course';
         }
 
-        return `${department} ${courseNumber} | ${enrollmentHistory.year} ${
-            enrollmentHistory.quarter
-        } | ${enrollmentHistory.instructors.join(', ')}`;
-    }, [courseNumber, department, enrollmentHistory]);
+        const currEnrollmentHistory = enrollmentHistory[graphIndex];
+        return `${department} ${courseNumber} | ${currEnrollmentHistory.year} ${
+            currEnrollmentHistory.quarter
+        } | ${currEnrollmentHistory.instructors.join(', ')}`;
+    }, [courseNumber, department, enrollmentHistory, graphIndex]);
     const isDark = useThemeStore((state) => state.isDark);
     const encodedDept = useMemo(() => encodeURIComponent(department), [department]);
     const axisColor = isDark ? '#fff' : '#111';
     const tooltipDateColor = '#111';
 
+    const handleBack = useCallback(() => {
+        setGraphIndex((prev) => prev - 1);
+    }, []);
+
+    const handleForward = useCallback(() => {
+        setGraphIndex((prev) => prev + 1);
+    }, []);
+
     useEffect(() => {
         if (!loading) {
             return;
@@ -41,6 +114,9 @@ export function EnrollmentHistoryPopup({ department, courseNumber }: EnrollmentH
         deptEnrollmentHistory.find(courseNumber).then((data) => {
             if (data) {
                 setEnrollmentHistory(data);
+                // The graph index is the last past enrollment graph since we want to show
+                // the most recent quarter's graph
+                setGraphIndex(data.length - 1);
             }
             setLoading(false);
         });
@@ -64,20 +140,18 @@ export function EnrollmentHistoryPopup({ department, courseNumber }: EnrollmentH
         );
     }
 
+    const lineChartData = enrollmentHistory[graphIndex].days;
+
     return (
         <Box sx={{ padding: 0.5 }}>
-            <Typography
-                sx={{
-                    marginTop: '.5rem',
-                    textAlign: 'center',
-                    fontWeight: 500,
-                    marginRight: '2rem',
-                    marginLeft: '2rem',
-                    marginBottom: '.5rem',
-                }}
-            >
-                {popupTitle}
-            </Typography>
+            <PopupHeader
+                graphWidth={graphWidth}
+                graphIndex={graphIndex}
+                handleForward={handleForward}
+                handleBack={handleBack}
+                popupTitle={popupTitle}
+                enrollmentHistory={enrollmentHistory}
+            />
             <Link
                 href={`https://zot-tracker.herokuapp.com/?dept=${encodedDept}&number=${courseNumber}&courseType=all`}
                 target="_blank"
@@ -85,11 +159,11 @@ export function EnrollmentHistoryPopup({ department, courseNumber }: EnrollmentH
                 sx={{ display: 'flex', height: graphHeight, width: graphWidth }}
             >
                 <ResponsiveContainer width="95%" height="95%">
-                    <LineChart data={enrollmentHistory.days} style={{ cursor: 'pointer' }}>
+                    <LineChart data={lineChartData} style={{ cursor: 'pointer' }}>
                         <CartesianGrid strokeDasharray="3 3" />
                         <XAxis dataKey="date" tick={{ fontSize: 12, fill: axisColor }} />
                         <YAxis tick={{ fontSize: 12, fill: axisColor }} width={40} />
-                        <Tooltip labelStyle={{ color: tooltipDateColor }} />
+                        <RechartsTooltip labelStyle={{ color: tooltipDateColor }} />
                         <Legend />
                         <Line type="monotone" dataKey="totalEnrolled" stroke="#8884d8" name="Enrolled" dot={{ r: 2 }} />
                         <Line type="monotone" dataKey="maxCapacity" stroke="#82ca9d" name="Max" dot={{ r: 2 }} />
diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx
index 70515eee..223feac4 100644
--- a/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx
+++ b/apps/antalmanac/src/components/RightPane/SectionTable/GradesPopup.tsx
@@ -1,9 +1,8 @@
 import { useState, useEffect, useMemo } from 'react';
-import { Bar, BarChart, CartesianGrid, ResponsiveContainer, XAxis, YAxis } from 'recharts';
+import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
 import { Box, Link, Typography, Skeleton } from '@mui/material';
 import { useThemeStore } from '$stores/SettingsStore';
 import GradesHelper, { type Grades } from '$lib/grades';
-
 export interface GradeData {
     grades: {
         name: string;
@@ -26,6 +25,11 @@ async function getGradeData(
         return undefined;
     }
 
+    const totalGrades = Object.values(Object.entries(courseGrades).filter(([key]) => key !== 'averageGPA')).reduce(
+        (acc, [_, value]) => acc + value,
+        0
+    );
+
     /**
      * Format data for displaying in chart.
      *
@@ -36,7 +40,7 @@ async function getGradeData(
         .map(([key, value]) => {
             return {
                 name: key.replace('grade', '').replace('Count', ''),
-                all: value,
+                all: Number(((value / totalGrades) * 100).toFixed(2)),
             };
         });
 
@@ -134,7 +138,18 @@ function GradesPopup(props: GradesPopupProps) {
                     <BarChart data={gradeData.grades} style={{ cursor: 'pointer' }}>
                         <CartesianGrid strokeDasharray="3 3" />
                         <XAxis dataKey="name" tick={{ fontSize: 12, fill: axisColor }} />
-                        <YAxis tick={{ fontSize: 12, fill: axisColor }} width={40} />
+                        <YAxis tick={{ fontSize: 12, fill: axisColor }} width={40} unit="%" />
+                        <Tooltip
+                            content={({ active, payload, label }) => (
+                                <GradeTooltip
+                                    active={active ?? false}
+                                    payload={(payload as Array<Payload>) ?? null}
+                                    label={label ?? ''}
+                                />
+                            )}
+                            position={{ y: 100 }}
+                            offset={-5}
+                        />
                         <Bar dataKey="all" fill="#5182ed" />
                     </BarChart>
                 </ResponsiveContainer>
@@ -143,4 +158,40 @@ function GradesPopup(props: GradesPopupProps) {
     );
 }
 
+const GradeTooltip = (props: GradeTooltipProps) => {
+    const { active, payload, label } = props;
+    if (active && payload && payload.length) {
+        return (
+            <>
+                <Box
+                    sx={{
+                        backgroundColor: '#5182ed',
+                        padding: '5px',
+                        border: '1px solid #000',
+                        borderRadius: '5px',
+                        boxShadow: '0 0 5px 0 rgba(0, 0, 0, 0.5)',
+                    }}
+                >
+                    <Typography variant="body1" align="center" sx={{ color: '#fff', fontWeight: 500 }}>
+                        {`${label}: ${payload[0].value}%`}
+                    </Typography>
+                </Box>
+            </>
+        );
+    }
+
+    return null;
+};
+
+export interface GradeTooltipProps {
+    active: boolean;
+    payload: Array<Payload> | null;
+    label: string;
+}
+
+export interface Payload {
+    name: string;
+    value: number;
+}
+
 export default GradesPopup;
diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
index e9db3996..50f27d23 100644
--- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
+++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
@@ -531,20 +531,19 @@ const SectionTableBody = withStyles(styles)((props: SectionTableBodyProps) => {
         setCalendarEvents(AppStore.getCourseEventsInCalendar());
     }, [setCalendarEvents]);
 
-    const [hoveredCourseEvents, setHoveredCourseEvents] = useHoveredStore((store) => [
-        store.hoveredCourseEvents,
-        store.setHoveredCourseEvents,
-    ]);
+    const [hoveredEvents, setHoveredEvents] = useHoveredStore((store) => [store.hoveredEvents, store.setHoveredEvents]);
+
+    const alreadyHovered = useMemo(() => {
+        return hoveredEvents?.some((scheduleCourse) => scheduleCourse.section.sectionCode == section.sectionCode);
+    }, [hoveredEvents, section.sectionCode]);
 
     const handleHover = useCallback(() => {
-        const alreadyHovered =
-            hoveredCourseEvents &&
-            hoveredCourseEvents.some((courseEvent) => courseEvent.sectionCode == section.sectionCode);
-
-        !previewMode || alreadyHovered || addedCourse
-            ? setHoveredCourseEvents(undefined)
-            : setHoveredCourseEvents(section, courseDetails, term);
-    }, [addedCourse, courseDetails, hoveredCourseEvents, previewMode, section, setHoveredCourseEvents, term]);
+        if (!previewMode || alreadyHovered || addedCourse) {
+            setHoveredEvents(undefined);
+        } else {
+            setHoveredEvents(section, courseDetails, term);
+        }
+    }, [alreadyHovered, section, courseDetails, term]);
 
     // Attach event listeners to the store.
     useEffect(() => {
diff --git a/apps/antalmanac/src/lib/buildingCatalogue.ts b/apps/antalmanac/src/lib/buildingCatalogue.ts
index fc8a8411..e93fe32f 100644
--- a/apps/antalmanac/src/lib/buildingCatalogue.ts
+++ b/apps/antalmanac/src/lib/buildingCatalogue.ts
@@ -168,60 +168,24 @@ export default {
         lng: -117.849297,
         imageURLs: ['463/vista-field.jpg'],
     },
-    83057: {
-        name: 'Biological Sciences III (BS3)',
-        lat: 33.645363,
-        lng: -117.846222,
-        imageURLs: ['463/biological-sciences-iii.jpg'],
-    },
     83058: {
         name: 'Bison Modular (BIMO)',
         lat: 33.643814,
         lng: -117.84639,
         imageURLs: ['463/bison-modular.jpg'],
     },
-    83059: {
-        name: 'Bonney Research Laboratory (BRL)',
-        lat: 33.646461,
-        lng: -117.846169,
-        imageURLs: ['463/bonney-research-laboratory.jpg'],
-    },
-    83060: {
-        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'],
-    },
     83061: {
         name: 'Greenhouse',
         lat: 33.64732,
         lng: -117.845367,
         imageURLs: ['463/greenhouse.jpg'],
     },
-    83062: {
-        name: 'McGaugh Hall (MH)',
-        lat: 33.645271,
-        lng: -117.844887,
-        imageURLs: ['463/mcgaugh-hall.jpg'],
-    },
-    83063: {
-        name: 'Qureshey Research Laboratory (QRL)',
-        lat: 33.646648,
-        lng: -117.845764,
-        imageURLs: ['463/qureshey-research-laboratory.jpg'],
-    },
     83064: {
         name: 'Howard Schneiderman Lecture Hall (HSLH)',
         lat: 33.645618,
         lng: -117.844666,
         imageURLs: ['463/howard-schneiderman-lecture-hall.jpg'],
     },
-    83065: {
-        name: 'Steinhaus Hall (SH)',
-        lat: 33.646347,
-        lng: -117.844887,
-        imageURLs: ['463/steinhaus-hall.jpg'],
-    },
     83068: {
         name: 'Arroyo Vista',
         lat: 33.646317,
@@ -241,7 +205,7 @@ export default {
         imageURLs: [],
     },
     83072: {
-        name: 'Mesa Court ',
+        name: 'Mesa Court',
         lat: 33.651924,
         lng: -117.844528,
         imageURLs: [],
@@ -378,12 +342,6 @@ export default {
         lng: -117.840149,
         imageURLs: ['463/engineering-and-comuting-trailer.jpg'],
     },
-    83102: {
-        name: 'Engineering Gateway (EG)',
-        lat: 33.643215,
-        lng: -117.840157,
-        imageURLs: ['463/engineering-gateway.jpg'],
-    },
     83103: {
         name: 'Engineering Hall (EH)',
         lat: 33.643799,
@@ -618,12 +576,6 @@ export default {
         lng: -117.835938,
         imageURLs: ['463/law.jpg'],
     },
-    83167: {
-        name: 'Multipurpose Academic & Administrative Building (MPAA)',
-        lat: 33.647156,
-        lng: -117.837013,
-        imageURLs: ['463/multipurpose-academic-and-admin-building.jpg'],
-    },
     83168: {
         name: 'Science Library (SLIB)',
         lat: 33.645908,
@@ -648,12 +600,6 @@ export default {
         lng: -117.844681,
         imageURLs: ['463/croul-hall.jpg'],
     },
-    83372: {
-        name: 'Multipurpose Science & Technology Building (MSTB)',
-        lat: 33.64209,
-        lng: -117.844467,
-        imageURLs: ['463/multipurpose-science-and-technology.jpg'],
-    },
     83373: {
         name: 'Natural Sciences I (NS1)',
         lat: 33.644348,
@@ -726,12 +672,6 @@ export default {
         lng: -117.835938,
         imageURLs: ['463/continuing-education-6.jpg'],
     },
-    83544: {
-        name: 'Social Ecology I (SE)',
-        lat: 33.646194,
-        lng: -117.838844,
-        imageURLs: ['463/social-ecology-i.jpg'],
-    },
     83545: {
         name: 'Social Ecology II (SE2)',
         lat: 33.64666,
@@ -756,12 +696,6 @@ export default {
         lng: -117.847198,
         imageURLs: ['463/kuci-radio-station.jpg'],
     },
-    83559: {
-        name: 'Student Center (SC)',
-        lat: 33.649323,
-        lng: -117.842255,
-        imageURLs: ['463/student-center_1.jpg'],
-    },
     83560: {
         name: 'Student Health Center (SHC)',
         lat: 33.64555,
@@ -774,12 +708,6 @@ export default {
         lng: -117.842316,
         imageURLs: ['463/student-services-i.jpg'],
     },
-    83565: {
-        name: 'Social & Behavioral Sciences Gateway (SBSG)',
-        lat: 33.647488,
-        lng: -117.839165,
-        imageURLs: ['463/social-and-behavioral-sciences-gateway.jpg'],
-    },
     83566: {
         name: 'Social Science Hall (SSH)',
         lat: 33.646236,
@@ -810,12 +738,6 @@ export default {
         lng: -117.839096,
         imageURLs: ['463/social-science-plaza-b.jpg'],
     },
-    83571: {
-        name: 'Social Science Tower (SST)',
-        lat: 33.646591,
-        lng: -117.840294,
-        imageURLs: ['463/social-science-tower.jpg'],
-    },
     83572: {
         name: 'Social Science Trailer (SSTR)',
         lat: 33.647007,
@@ -888,12 +810,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,18 +840,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,
-        lng: -117.838013,
-        imageURLs: ['463/anteater-instruction-and-research-building.jpg'],
-    },
     114588: {
         name: 'Beckman Center of the National Academies of Sciences & Engineering',
         lat: 33.649414,
@@ -972,12 +876,6 @@ export default {
         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,
@@ -996,54 +894,24 @@ export default {
         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,
@@ -1062,54 +930,24 @@ export default {
         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,
@@ -1170,12 +1008,6 @@ export default {
         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,
@@ -1620,12 +1452,6 @@ export default {
         lng: -117.850998,
         imageURLs: ['463/gottschalk-pod.jpg'],
     },
-    255880: {
-        name: 'Student Center',
-        lat: 33.648888,
-        lng: -117.8424,
-        imageURLs: ['463/student-center_2.jpg'],
-    },
     255884: {
         name: 'Anteater Learning Pavilion (ALP)',
         lat: 33.647213,
@@ -1698,24 +1524,12 @@ export default {
         lng: -117.849152,
         imageURLs: [],
     },
-    364043: {
-        name: 'Student Services West',
-        lat: 33.646072,
-        lng: -117.849152,
-        imageURLs: [],
-    },
     407361: {
         name: 'ANTrepreneur Center',
         lat: 33.64621,
         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,14 +1566,8 @@ 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 ',
+        name: 'Passport Services',
         lat: 33.664795,
         lng: -117.854523,
         imageURLs: [],
@@ -1812,42 +1620,12 @@ 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,
-        lng: -117.842705,
-        imageURLs: [],
-    },
     454492: {
         name: 'Jao Family Sculpture Garden',
         lat: 33.647762,
@@ -1962,4 +1740,52 @@ export default {
         lng: -117.843788,
         imageURLs: ['463/1R7A7978.jpg'],
     },
+    363937: {
+        name: 'Mesa Parking Structure (MPS)',
+        lat: 33.6506,
+        lng: -117.84629,
+        imageURLs: [],
+    },
+    114576: {
+        name: 'Air Pollution Labs (APL)',
+        lat: 33.6648,
+        lng: -117.85382,
+        imageURLs: [],
+    },
+    363936: {
+        name: 'Student Center Parking Structure (SCPS)',
+        lat: 33.64971,
+        lng: -117.84189,
+        imageURLs: [],
+    },
+    363935: {
+        name: 'East Campus Parking Structure (ECPS)',
+        lat: 33.6451,
+        lng: -117.8282,
+        imageURLs: [],
+    },
+    363934: {
+        name: 'Social Science Parking Structure (SSPS)',
+        lat: 33.64768,
+        lng: -117.83801,
+        imageURLs: [],
+    },
+    363932: {
+        name: 'Anteater Parking Structure (APS)',
+        lat: 33.64314,
+        lng: -117.83783,
+        imageURLs: [],
+    },
+    367615: {
+        name: "Dean's Office",
+        lat: 33.64857,
+        lng: -117.84265,
+        imageURLs: [],
+    },
+    83062: {
+        name: 'McGaugh Hall (MH)',
+        lat: 33.64517,
+        lng: -117.84476,
+        imageURLs: [],
+    },
 } as Record<number, Building>;
diff --git a/apps/antalmanac/src/lib/enrollmentHistory.ts b/apps/antalmanac/src/lib/enrollmentHistory.ts
index bea9c847..cb2efb5e 100644
--- a/apps/antalmanac/src/lib/enrollmentHistory.ts
+++ b/apps/antalmanac/src/lib/enrollmentHistory.ts
@@ -44,7 +44,7 @@ export interface EnrollmentHistoryDay {
 
 export class DepartmentEnrollmentHistory {
     // Each key in the cache will be the department and courseNumber concatenated
-    static enrollmentHistoryCache: Record<string, EnrollmentHistory | null> = {};
+    static enrollmentHistoryCache: Record<string, EnrollmentHistory[] | null> = {};
     static termShortNames: string[] = termData.map((term) => term.shortName);
     static QUERY_TEMPLATE = `{
         enrollmentHistory(department: "$$DEPARTMENT$$", courseNumber: "$$COURSE_NUMBER$$", sectionType: Lec) {
@@ -68,30 +68,25 @@ export class DepartmentEnrollmentHistory {
         this.partialQueryString = DepartmentEnrollmentHistory.QUERY_TEMPLATE.replace('$$DEPARTMENT$$', department);
     }
 
-    async find(courseNumber: string): Promise<EnrollmentHistory | null> {
+    async find(courseNumber: string): Promise<EnrollmentHistory[] | null> {
         const cacheKey = this.department + courseNumber;
-        return (DepartmentEnrollmentHistory.enrollmentHistoryCache[cacheKey] ??= await this.queryEnrollmentHistory(
-            courseNumber
-        ));
+        return (DepartmentEnrollmentHistory.enrollmentHistoryCache[cacheKey] ??=
+            await this.queryEnrollmentHistory(courseNumber));
     }
 
-    async queryEnrollmentHistory(courseNumber: string): Promise<EnrollmentHistory | null> {
+    async queryEnrollmentHistory(courseNumber: string): Promise<EnrollmentHistory[] | null> {
         // Query for the enrollment history of all lecture sections that were offered
         const queryString = this.partialQueryString.replace('$$COURSE_NUMBER$$', courseNumber);
 
         const res = (await queryGraphQL<EnrollmentHistoryGraphQLResponse>(queryString))?.data?.enrollmentHistory;
 
-        if (res?.length) {
-            const parsedEnrollmentHistory = DepartmentEnrollmentHistory.parseEnrollmentHistoryResponse(res);
-            DepartmentEnrollmentHistory.sortEnrollmentHistory(parsedEnrollmentHistory);
-
-            // For now, just return the enrollment history of the most recent quarter
-            // instead of the entire array of enrollment histories
-            const latestEnrollmentHistory = parsedEnrollmentHistory[0];
-            return latestEnrollmentHistory;
+        if (!res?.length) {
+            return null;
         }
 
-        return null;
+        const parsedEnrollmentHistory = DepartmentEnrollmentHistory.parseEnrollmentHistoryResponse(res);
+        DepartmentEnrollmentHistory.sortEnrollmentHistory(parsedEnrollmentHistory);
+        return parsedEnrollmentHistory;
     }
 
     /**
@@ -140,7 +135,7 @@ export class DepartmentEnrollmentHistory {
 
     /**
      * Sorts the given array of enrollment histories so that
-     * the most recent quarters are in the beginning of the array.
+     * the oldest quarters are in the beginning of the array
      *
      * @param enrollmentHistory Array where each element represents the enrollment
      * history of a course section during one quarter
@@ -150,10 +145,10 @@ export class DepartmentEnrollmentHistory {
             const aTerm = `${a.year} ${a.quarter}`;
             const bTerm = `${b.year} ${b.quarter}`;
             // If the term for a appears earlier than the term for b in the list of
-            // term short names, then a must be the enrollment history for a later quarter
+            // term short names, then a must be the enrollment history for a more recent quarter
             return (
-                DepartmentEnrollmentHistory.termShortNames.indexOf(aTerm) -
-                DepartmentEnrollmentHistory.termShortNames.indexOf(bTerm)
+                DepartmentEnrollmentHistory.termShortNames.indexOf(bTerm) -
+                DepartmentEnrollmentHistory.termShortNames.indexOf(aTerm)
             );
         });
     }
diff --git a/apps/antalmanac/src/lib/location_ids.ts b/apps/antalmanac/src/lib/location_ids.ts
index 56e452a9..2fce39f6 100644
--- a/apps/antalmanac/src/lib/location_ids.ts
+++ b/apps/antalmanac/src/lib/location_ids.ts
@@ -10,7 +10,7 @@ const locationIds: Record<string, number> = {
     ICS: 83096,
     BRL: 114594,
     BLI: 83134,
-    'Mesa Court': 367611,
+    'Mesa Court': 83072,
     DBH: 83095,
     MS2: 83151,
     SC: 114624,
@@ -34,12 +34,12 @@ const locationIds: Record<string, number> = {
     MS1: 83150,
     LAW: 83166,
     ELF: 83104,
-    HT: 367490,
+    // HT: 367490,
     AIRB: 114616,
     CRCC: 83551,
     ET: 83106,
     EDUC: 114599,
-    CP: 114580,
+    CP: 444502,
     SB1: 114598,
     MAB: 83033,
     CRH: 83371,
@@ -50,12 +50,11 @@ const locationIds: Record<string, number> = {
     AITR: 83024,
     CDDC: 115051,
     MH: 83062,
-    'Student Center': 367612,
     PSLH: 83378,
     SCPS: 363936,
     ELH: 83105,
     SS2: 255997,
-    'Hitachi, PH': 114609,
+    PH: 114609,
     PSCB: 83376,
     HG: 83160,
     HSLH: 83064,
@@ -64,9 +63,9 @@ const locationIds: Record<string, number> = {
     CTT: 83026,
     CAC: 83027,
     ACT: 83022,
-    'Middle Earth': 367613,
+    'Middle Earth': 83073,
     SHC: 83560,
-    CNLMA: 114596,
+    CNLM: 114596,
     IH: 83140,
     FRF: 114575,
     Calit2: 114579,
@@ -90,7 +89,7 @@ const locationIds: Record<string, number> = {
     SE: 114623,
     'MS-C': 83146,
     SH: 114611,
-    'UCI Health': 367616,
+    //'UCI Health': 367616,
     SBSG: 114858,
     GNRF: 83136,
     SSPB: 83570,
@@ -103,7 +102,7 @@ const locationIds: Record<string, number> = {
     ICF: 83099,
     ALP: 255884,
     MSTB: 114613,
-    AH: 83619,
+    //AH: 83619,
     CH: 83050,
     ALH: 83618,
     SSH: 83566,
@@ -111,6 +110,195 @@ const locationIds: Record<string, number> = {
     'MS-E': 83149,
     APS: 363932,
     HH: 83161,
+    'Choral Studio': 83025,
+    'Production Studio': 83036,
+    DS: 83037,
+    'Anteater Ballpark': 83043,
+    'Anteater Stadium': 83045,
+    'Bren Events Center': 83046,
+    'Crawford Clubhouse': 83048,
+    'Crawford Field': 83049,
+    'Crawford Pool': 83051,
+    'Anteater Tennis Stadium': 83054,
+    'Track & Field House': 83055,
+    'Vista Field': 83056,
+    BIMO: 83058,
+    Greenhouse: 83061,
+    'Arroyo Vista': 83068,
+    'Camino del Sol': 83069,
+    'Campus Village': 83070,
+    'Palo Verde': 83074,
+    'Puerta del Sol': 83075,
+    'University Hills': 83076,
+    'Verano Place': 83077,
+    'Vista del Campo': 83078,
+    'Vista del Campo Norte': 83079,
+    'CAC G021 Arts Instructional Lab Classroom': 83081,
+    'OIT@CCC Drop-In Lab': 83083,
+    'Gateway Study Center Drop-In Lab': 83084,
+    'OIT@SST Drop-In Lab': 83086,
+    'OIT@Student Center Drop-In Lab': 83087,
+    'Humanities Studio Labs': 83090,
+    'Langson Library Labs': 83091,
+    'MSTB 210 Instructional Lab Classroom': 83092,
+    'NS1 2144 Instructional Lab Classroom': 83093,
+    'Science Library Labs': 83094,
+    "BC's Cavern": 83120,
+    'POD Social Sciences': 83122,
+    'Cafe Espresso': 83123,
+    'The Green Room': 83125,
+    'Phoenix Food Court': 83128,
+    'Starbucks @ Biological Sciences': 83130,
+    'Gottschalk Medical Plaza': 83137,
+    'Medical Education Building': 83141,
+    'Medical Sciences A Administration': 83143,
+    'Medical Sciences A Annex': 83144,
+    'Medical Sciences Classroom Facility': 83147,
+    'MS-D': 83148,
+    'MRI 3T Trailer': 83152,
+    'MRI 4T Trailer': 83153,
+    'Tamkin Student Lecture Building': 83156,
+    'Physical Sciences High Bay': 83377,
+    'Continuing Education 4': 83384,
+    'Continuing Education 3': 83385,
+    'Continuing Education 5': 83386,
+    'Continuing Education 6': 83387,
+    'Disability Services Center': 83552,
+    'KUCI Radio Station': 83558,
+    '100 Theory': 83609,
+    '111 Theory': 83611,
+    '5251 California': 83612,
+    '101 Academy': 83615,
+    'Administrative Modular': 83617,
+    'Faculty and Staff Housing Office': 83622,
+    'Newkirk Alumni Center': 83624,
+    'University Hills Community Center': 101047,
+    'Beckman Center of the National Academies of Sciences & Engineering': 114588,
+    'Farm School/Red Barn': 114590,
+    'Little Theatre': 114602,
+    'Gavin Herbert Eye Institute': 114610,
+    'Facilities Management Building': 114621,
+    'Student Health Center II': 114626,
+    'Court Building': 114628,
+    'Field Building': 114629,
+    'Irvine Barclay Theatre': 114659,
+    'Early Childhood Education Center': 114881,
+    "Children's Center": 114882,
+    'Extended Day Center': 114883,
+    'Infant Toddler Center I': 114884,
+    'Infant Toddler Center II': 114885,
+    'Verano Preschool': 114887,
+    'University Montessori School': 114888,
+    'American Heart Association': 114890,
+    'Yurt Dance Studio': 114891,
+    '20/20 Cafe': 114958,
+    'Au Bon Pain': 114959,
+    'Espress Yourself': 114960,
+    'Starbucks @ Paul Merage School of Business': 114961,
+    'Java City Kiosk': 114962,
+    'Med Ed Cafe': 114963,
+    'Neuropsychiatric Center': 115040,
+    'Douglas Hospital': 115041,
+    'University Hospital Tower': 115042,
+    'Manchester Pavilion': 115043,
+    'Faculty / Administration Offices': 115044,
+    'Pavilion 3': 115045,
+    'Chao Family Comprehensive Cancer Center': 115046,
+    'Imaging Services': 115047,
+    'Burn & Wound Therapy': 115048,
+    'Pavilion 4': 115049,
+    'Grunigen Medical Library': 115052,
+    'Laboratory Medicine Building': 115053,
+    'Parking & Security': 115054,
+    'Pavilion 1': 115055,
+    'Nikken Center': 115056,
+    'Pavilion 2': 115057,
+    Orthopaedics: 115058,
+    'Visitor Parking': 115059,
+    'Edward Shanbrom, MD Hall': 115060,
+    'Central Plant': 115061,
+    'Facilities Services': 115062,
+    'Administrative / Academic Offices': 115063,
+    'Home Dialysis': 115064,
+    'Courtyard Study Lounge Drop-In Lab': 123779,
+    'Watson Bridge': 126143,
+    'Aldrich Flag Poles': 126144,
+    'Ring Mall': 126145,
+    'Aldrich Park': 126146,
+    'Arts Bridge': 126147,
+    'Physical Sciences Plaza': 126148,
+    'Biological Sciences Plaza': 126149,
+    'Engineering Plaza': 126150,
+    'Social Sciences Plaza': 126151,
+    'Student Center Plaza': 126152,
+    'Humanities Plaza': 126153,
+    'Bostick Bridge': 126154,
+    'Athletics Bridge': 126155,
+    'Alumni Grove': 126156,
+    'Anteater Statue': 126157,
+    'Laurel L. Wilkening Rose Garden': 126158,
+    'Cesario Memorial': 126159,
+    'Bren Center Flag Poles': 126999,
+    'Claire Trevor Oscar/Emmy Display': 127000,
+    'Mesa Parking Bridge': 127001,
+    'Student Center Courtyard Stage': 127052,
+    'Palo Verde Bridge': 127246,
+    '120 Theory': 171183,
+    'Student Center Bridge': 186008,
+    'Continuing Education Bridge': 186011,
+    'Bridge Cafe': 186012,
+    'The Anteatery': 186013,
+    'Summer Session A': 197840,
+    'Summer Session B': 197841,
+    '131 Innovation': 255739,
+    '141 Innovation': 255740,
+    'Gottschalk P.O.D. Express': 255878,
+    'Anteater Community Resource Center': 255992,
+    'Engineering Computing Labs': 257153,
+    'Grunigen Medical Library Lab': 257154,
+    'MSTB 226 Instructional Lab Classroom': 257155,
+    'NS1 3112 Instructional Lab Classroom': 257156,
+    Taza: 264222,
+    'ALP 2800 Computer Focused Classroom': 363319,
+    'ALP 3600 Computer Focused Classroom': 363320,
+    'ALP 3610 Computer Focused Classroom': 363321,
+    'Student Services West': 364042,
+    'ANTrepreneur Center': 407361,
+    'Plaza Verde': 444368,
+    'Building Services Building': 444501,
+    'Electrical Substation': 444503,
+    'Facilities Management Shops': 444504,
+    'Grounds Maintenance Facility': 444505,
+    'Passport Services': 444507,
+    Receiving: 444508,
+    'Recycling Center': 444509,
+    'Thermal Energy Storage Tank': 444510,
+    APHEL: 445384,
+    Brandywine: 446803,
+    'Faculty Research Facility': 447392,
+    '5270 California': 451260,
+    UAG: 454485,
+    'Student Center South': 454490,
+    'Jao Family Sculpture Garden': 454492,
+    'Science Library Mesquite Garden': 454493,
+    'Anteater Plaza': 454494,
+    'Founders Court': 454495,
+    'Campus Village Community Center': 464544,
+    'Campus Village Housing Office': 464545,
+    'Mesa Court Community Center': 464546,
+    'Mesa Court Housing Office': 464547,
+    'Arroyo Vista Community Center & Housing Office': 464548,
+    'Middle Earth Housing Office': 464549,
+    'Palo Verde Housing Office': 464550,
+    'Palo Verde Community Center': 464551,
+    'Verano Place Housing Office': 464552,
+    'Infinity Fountain': 471175,
+    'Stacey Nicholas Tech Pavilion': 471176,
+    'Willed Body Program Dedication Monument': 471177,
+    'Schonfeld Fountain': 471178,
+    'Maya Lin Water Table': 471180,
+    'Gloria Gellman Sculpture': 471202,
+    'Sue & Bill Gross Hall: A CIRM Institute': 114607,
 };
 
 export default locationIds;
diff --git a/apps/antalmanac/src/lib/termData.ts b/apps/antalmanac/src/lib/termData.ts
index 8e9cd073..d63e062e 100644
--- a/apps/antalmanac/src/lib/termData.ts
+++ b/apps/antalmanac/src/lib/termData.ts
@@ -112,13 +112,13 @@ function getFinalsStartForTerm(term: string) {
  */
 function getDefaultFinalsStartDate() {
     // FIXME: Un-offset once Spring starts, or figure out a proper fix
-    const [year, month, day] = termData[defaultTerm + 1].finalsStartDate || [];
+    const [year, month, day] = getDefaultFinalsStart() ?? [];
     return year && month && day ? new Date(year, month, day + 1) : undefined;
 }
 
 function getFinalsStartDateForTerm(term: string) {
     const date = getFinalsStartForTerm(term);
-    const [year, month, day] = date || [];
+    const [year, month, day] = date ?? [];
     return year && month && day ? new Date(year, month, day + 1) : undefined;
 }
 
diff --git a/apps/antalmanac/src/stores/AppStore.ts b/apps/antalmanac/src/stores/AppStore.ts
index e6bae484..d1da68f8 100644
--- a/apps/antalmanac/src/stores/AppStore.ts
+++ b/apps/antalmanac/src/stores/AppStore.ts
@@ -6,6 +6,19 @@ import { Schedules } from './Schedules';
 import { SnackbarPosition } from '$components/NotificationSnackbar';
 import { CalendarEvent, CourseEvent } from '$components/Calendar/CourseCalendarEvent';
 import { useTabStore } from '$stores/TabStore';
+import actionTypesStore from '$actions/ActionTypesStore';
+import type {
+    AddCourseAction,
+    DeleteCourseAction,
+    AddCustomEventAction,
+    DeleteCustomEventAction,
+    EditCustomEventAction,
+    ChangeCustomEventColorAction,
+    ClearScheduleAction,
+    CopyScheduleAction,
+    ChangeCourseColorAction,
+    UndoAction,
+} from '$actions/ActionTypesStore';
 
 class AppStore extends EventEmitter {
     schedule: Schedules;
@@ -74,6 +87,12 @@ class AppStore extends EventEmitter {
             addedCourse = this.schedule.addCourse(newCourse, scheduleIndex);
         }
         this.unsavedChanges = true;
+        const action: AddCourseAction = {
+            type: 'addCourse',
+            course: newCourse,
+            scheduleIndex: scheduleIndex,
+        };
+        actionTypesStore.autoSaveSchedule(action);
         this.emit('addedCoursesChange');
         return addedCourse;
     }
@@ -90,6 +109,10 @@ class AppStore extends EventEmitter {
         return this.schedule.getCalendarizedCourseEvents();
     }
 
+    getCustomEventsInCalendar() {
+        return this.schedule.getCalendarizedCustomEvents();
+    }
+
     getFinalEventsInCalendar() {
         return this.schedule.getCalendarizedFinals();
     }
@@ -151,6 +174,12 @@ class AppStore extends EventEmitter {
     deleteCourse(sectionCode: string, term: string) {
         this.schedule.deleteCourse(sectionCode, term);
         this.unsavedChanges = true;
+        const action: DeleteCourseAction = {
+            type: 'deleteCourse',
+            sectionCode: sectionCode,
+            term: term,
+        };
+        actionTypesStore.autoSaveSchedule(action);
         this.emit('addedCoursesChange');
     }
 
@@ -163,6 +192,10 @@ class AppStore extends EventEmitter {
     undoAction() {
         this.schedule.revertState();
         this.unsavedChanges = true;
+        const action: UndoAction = {
+            type: 'undoAction',
+        };
+        actionTypesStore.autoSaveSchedule(action);
         this.emit('addedCoursesChange');
         this.emit('customEventsChange');
         this.emit('colorChange', false);
@@ -174,24 +207,47 @@ class AppStore extends EventEmitter {
     addCustomEvent(customEvent: RepeatingCustomEvent, scheduleIndices: number[]) {
         this.schedule.addCustomEvent(customEvent, scheduleIndices);
         this.unsavedChanges = true;
+        const action: AddCustomEventAction = {
+            type: 'addCustomEvent',
+            customEvent: customEvent,
+            scheduleIndices: scheduleIndices,
+        };
+        actionTypesStore.autoSaveSchedule(action);
         this.emit('customEventsChange');
     }
 
     editCustomEvent(editedCustomEvent: RepeatingCustomEvent, newScheduleIndices: number[]) {
         this.schedule.editCustomEvent(editedCustomEvent, newScheduleIndices);
         this.unsavedChanges = true;
+        const action: EditCustomEventAction = {
+            type: 'editCustomEvent',
+            editedCustomEvent: editedCustomEvent,
+            newScheduleIndices: newScheduleIndices,
+        };
+        actionTypesStore.autoSaveSchedule(action);
         this.emit('customEventsChange');
     }
 
     deleteCustomEvent(customEventId: number) {
         this.schedule.deleteCustomEvent(customEventId);
         this.unsavedChanges = true;
+        const action: DeleteCustomEventAction = {
+            type: 'deleteCustomEvent',
+            customEventId: customEventId,
+        };
+        actionTypesStore.autoSaveSchedule(action);
         this.emit('customEventsChange');
     }
 
     changeCustomEventColor(customEventId: number, newColor: string) {
         this.schedule.changeCustomEventColor(customEventId, newColor);
         this.unsavedChanges = true;
+        const action: ChangeCustomEventColorAction = {
+            type: 'changeCustomEventColor',
+            customEventId: customEventId,
+            newColor: newColor,
+        };
+        actionTypesStore.autoSaveSchedule(action);
         this.colorPickers[customEventId].emit('colorChange', newColor);
         this.emit('colorChange', false);
     }
@@ -213,11 +269,17 @@ class AppStore extends EventEmitter {
 
     saveSchedule() {
         this.unsavedChanges = false;
+        window.localStorage.removeItem('unsavedActions');
     }
 
     copySchedule(to: number) {
         this.schedule.copySchedule(to);
         this.unsavedChanges = true;
+        const action: CopyScheduleAction = {
+            type: 'copySchedule',
+            to: to,
+        };
+        actionTypesStore.autoSaveSchedule(action);
         this.emit('addedCoursesChange');
         this.emit('customEventsChange');
     }
@@ -229,6 +291,9 @@ class AppStore extends EventEmitter {
             return false;
         }
         this.unsavedChanges = false;
+
+        await actionTypesStore.loadScheduleFromLocalSave();
+
         this.emit('addedCoursesChange');
         this.emit('customEventsChange');
         this.emit('scheduleNamesChange');
@@ -263,6 +328,10 @@ class AppStore extends EventEmitter {
     clearSchedule() {
         this.schedule.clearCurrentSchedule();
         this.unsavedChanges = true;
+        const action: ClearScheduleAction = {
+            type: 'clearSchedule',
+        };
+        actionTypesStore.autoSaveSchedule(action);
         this.emit('addedCoursesChange');
         this.emit('customEventsChange');
     }
@@ -279,6 +348,13 @@ class AppStore extends EventEmitter {
     changeCourseColor(sectionCode: string, term: string, newColor: string) {
         this.schedule.changeCourseColor(sectionCode, term, newColor);
         this.unsavedChanges = true;
+        const action: ChangeCourseColorAction = {
+            type: 'changeCourseColor',
+            sectionCode: sectionCode,
+            term: term,
+            newColor: newColor,
+        };
+        actionTypesStore.autoSaveSchedule(action);
         this.colorPickers[sectionCode].emit('colorChange', newColor);
         this.emit('colorChange', false);
     }
diff --git a/apps/antalmanac/src/stores/HoveredStore.ts b/apps/antalmanac/src/stores/HoveredStore.ts
index 5245399e..5132a79a 100644
--- a/apps/antalmanac/src/stores/HoveredStore.ts
+++ b/apps/antalmanac/src/stores/HoveredStore.ts
@@ -1,32 +1,62 @@
 import { create } from 'zustand';
-import { AASection } from '@packages/antalmanac-types';
-import { calendarizeCourseEvents } from './calendarizeHelpers';
+import { AASection, ScheduleCourse } from '@packages/antalmanac-types';
+import { calendarizeCourseEvents, calendarizeFinals } from './calendarizeHelpers';
 import { CourseEvent } from '$components/Calendar/CourseCalendarEvent';
 import { CourseDetails } from '$lib/course_data.types';
 
+const HOVERED_SECTION_COLOR = '#80808080';
 export interface HoveredStore {
-    hoveredCourseEvents: CourseEvent[] | undefined;
-    setHoveredCourseEvents: (section?: AASection, courseDetails?: CourseDetails, term?: string) => void;
+    hoveredEvents: ScheduleCourse[] | undefined;
+    setHoveredEvents: (section?: AASection, courseDetails?: CourseDetails, term?: string) => void;
+    hoveredCalendarizedCourses: CourseEvent[] | undefined;
+    hoveredCalendarizedFinal: CourseEvent | undefined;
 }
 
+const DEFAULT_HOVERED_STORE = {
+    hoveredEvents: undefined,
+    hoveredCalendarizedCourses: undefined,
+    hoveredCalendarizedFinal: undefined,
+};
+
 export const useHoveredStore = create<HoveredStore>((set) => {
     return {
-        hoveredCourseEvents: undefined,
-        setHoveredCourseEvents: (section, courseDetails, term) => {
+        ...DEFAULT_HOVERED_STORE,
+        setHoveredEvents: (section, courseDetails, term) => {
+            if (section == null || courseDetails == null || term == null) {
+                set({ ...DEFAULT_HOVERED_STORE });
+                return;
+            }
             set({
-                hoveredCourseEvents:
-                    section && courseDetails && term
-                        ? calendarizeCourseEvents([
-                              {
-                                  ...courseDetails,
-                                  section: {
-                                      ...section,
-                                      color: '#80808080',
-                                  },
-                                  term,
-                              },
-                          ])
-                        : undefined,
+                hoveredEvents: [
+                    {
+                        ...courseDetails,
+                        section: {
+                            ...section,
+                            color: HOVERED_SECTION_COLOR,
+                        },
+                        term,
+                    },
+                ],
+                hoveredCalendarizedCourses: calendarizeCourseEvents([
+                    {
+                        ...courseDetails,
+                        section: {
+                            ...section,
+                            color: HOVERED_SECTION_COLOR,
+                        },
+                        term,
+                    },
+                ]),
+                hoveredCalendarizedFinal: calendarizeFinals([
+                    {
+                        ...courseDetails,
+                        section: {
+                            ...section,
+                            color: HOVERED_SECTION_COLOR,
+                        },
+                        term,
+                    },
+                ])[0],
             });
         },
     };
diff --git a/apps/antalmanac/src/stores/Schedules.ts b/apps/antalmanac/src/stores/Schedules.ts
index 1179bb16..93566ebc 100644
--- a/apps/antalmanac/src/stores/Schedules.ts
+++ b/apps/antalmanac/src/stores/Schedules.ts
@@ -402,6 +402,13 @@ export class Schedules {
         return calendarizeCourseEvents(this.getCurrentCourses());
     }
 
+    /**
+     * Convert just custom events into calendar compatible format.
+     */
+    getCalendarizedCustomEvents() {
+        return calendarizeCustomEvents(this.getCurrentCustomEvents());
+    }
+
     /**
      * Convert finals into calendar friendly format
      */
diff --git a/apps/antalmanac/src/stores/SettingsStore.ts b/apps/antalmanac/src/stores/SettingsStore.ts
index 3e691a18..464d5324 100644
--- a/apps/antalmanac/src/stores/SettingsStore.ts
+++ b/apps/antalmanac/src/stores/SettingsStore.ts
@@ -85,3 +85,22 @@ export const usePreviewStore = create<PreviewStore>((set) => {
         },
     };
 });
+
+export interface AutoSaveStore {
+    autoSave: boolean;
+    setAutoSave: (autoSave: boolean) => void;
+}
+
+export const useAutoSaveStore = create<AutoSaveStore>((set) => {
+    const autoSave = typeof Storage !== 'undefined' && window.localStorage.getItem('autoSave') == 'true';
+
+    return {
+        autoSave,
+        setAutoSave: (autoSave) => {
+            if (typeof Storage !== 'undefined') {
+                window.localStorage.setItem('autoSave', autoSave.toString());
+            }
+            set({ autoSave });
+        },
+    };
+});
diff --git a/apps/antalmanac/tsconfig.json b/apps/antalmanac/tsconfig.json
index 498a6c3a..f16751ea 100644
--- a/apps/antalmanac/tsconfig.json
+++ b/apps/antalmanac/tsconfig.json
@@ -16,6 +16,7 @@
         "jsx": "react-jsx",
         "baseUrl": ".",
         "paths": {
+            "$assets/*": ["src/assets/*"],
             "$actions/*": ["src/actions/*"],
             "$api/*": ["src/api/*"],
             "$components/*": ["src/components/*"],
diff --git a/apps/antalmanac/vite.config.ts b/apps/antalmanac/vite.config.ts
index 4ff61664..c6e03d07 100644
--- a/apps/antalmanac/vite.config.ts
+++ b/apps/antalmanac/vite.config.ts
@@ -7,6 +7,7 @@ export default defineConfig({
     plugins: [react(), svgr()],
     resolve: {
         alias: {
+            $assets: resolve(__dirname, './src/assets'),
             $actions: resolve(__dirname, './src/actions'),
             $api: resolve(__dirname, './src/api'),
             $components: resolve(__dirname, './src/components'),
@@ -14,7 +15,6 @@ export default defineConfig({
             $providers: resolve(__dirname, './src/providers'),
             $routes: resolve(__dirname, './src/routes'),
             $stores: resolve(__dirname, './src/stores'),
-            $assets: resolve(__dirname, './src/assets'),
         },
     },
     build: {
diff --git a/package.json b/package.json
index ef2df74d..ee84680d 100644
--- a/package.json
+++ b/package.json
@@ -1,34 +1,38 @@
 {
-  "name": "antalmanac",
-  "version": "1.0.0",
-  "private": true,
-  "scripts": {
-    "prepare": "husky install",
-    "start:aa": "turbo start --filter=antalmanac",
-    "start": "cross-env VITE_LOCAL_SERVER=true turbo start",
-    "build:aa": "turbo build --filter=antalmanac",
-    "build": "turbo build",
-    "deploy:aa": "turbo deploy --filter=antalmanac",
-    "deploy:cdk": "turbo deploy --filter=cdk",
-    "deploy": "turbo deploy",
-    "test": "vitest"
-  },
-  "devDependencies": {
-    "@types/node": "^20.11.6",
-    "cross-env": "^7.0.3",
-    "husky": "^8.0.3",
-    "jsdom": "^22.1.0",
-    "lint-staged": "^13.1.1",
-    "prettier": "^3.1.0",
-    "turbo": "latest",
-    "vitest": "^0.34.4"
-  },
-  "packageManager": "pnpm@8.6.0",
-  "engines": {
-    "pnpm": "^8.0.0",
-    "node": ">=16"
-  },
-  "dependencies": {
-    "ua-parser-js": "^1.0.37"
-  }
+    "name": "antalmanac",
+    "version": "1.0.0",
+    "private": true,
+    "type": "module",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/icssc/Antalmanac"
+    },
+    "scripts": {
+        "prepare": "husky install",
+        "start:aa": "turbo start --filter=antalmanac",
+        "start": "cross-env VITE_LOCAL_SERVER=true turbo start",
+        "build:aa": "turbo build --filter=antalmanac",
+        "build": "turbo build",
+        "deploy:aa": "turbo deploy --filter=antalmanac",
+        "deploy:cdk": "turbo deploy --filter=cdk",
+        "deploy": "turbo deploy",
+        "test": "vitest"
+    },
+    "devDependencies": {
+        "@types/eslint": "^8.56.5",
+        "@types/node": "^20.11.6",
+        "cross-env": "^7.0.3",
+        "eslint-import-resolver-typescript": "^3.6.1",
+        "husky": "^8.0.3",
+        "jsdom": "^22.1.0",
+        "lint-staged": "^13.1.1",
+        "prettier": "^3.1.0",
+        "turbo": "latest",
+        "vitest": "^0.34.4"
+    },
+    "packageManager": "pnpm@8.6.0",
+    "engines": {
+        "pnpm": "^8.0.0",
+        "node": ">=16"
+    }
 }
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index abad1214..fe1cbba6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,17 +7,19 @@ settings:
 importers:
 
   .:
-    dependencies:
-      ua-parser-js:
-        specifier: ^1.0.37
-        version: 1.0.37
     devDependencies:
+      '@types/eslint':
+        specifier: ^8.56.5
+        version: 8.56.5
       '@types/node':
         specifier: ^20.11.6
         version: 20.11.6
       cross-env:
         specifier: ^7.0.3
         version: 7.0.3
+      eslint-import-resolver-typescript:
+        specifier: ^3.6.1
+        version: 3.6.1(eslint-plugin-import@2.27.5)(eslint@8.38.0)
       husky:
         specifier: ^8.0.3
         version: 8.0.3
@@ -91,7 +93,7 @@ importers:
         specifier: ^5.11.7
         version: 5.11.10(@emotion/react@11.10.6)(@emotion/styled@11.10.6)(@types/react@18.0.28)(react-dom@18.2.0)(react@18.2.0)
       '@packages/antalmanac-types':
-        specifier: '*'
+        specifier: workspace:^
         version: link:../../packages/types
       '@react-leaflet/core':
         specifier: ^2.1.0
@@ -192,6 +194,9 @@ importers:
       superjson:
         specifier: ^1.12.3
         version: 1.12.3
+      ua-parser-js:
+        specifier: ^1.0.37
+        version: 1.0.37
       websoc-fuzzy-search:
         specifier: ^1.0.1
         version: 1.0.1
@@ -205,6 +210,9 @@ importers:
       '@testing-library/react':
         specifier: ^14.0.0
         version: 14.0.0(react-dom@18.2.0)(react@18.2.0)
+      '@types/eslint':
+        specifier: ^8.56.5
+        version: 8.56.5
       '@types/file-saver':
         specifier: ^2.0.5
         version: 2.0.5
@@ -244,12 +252,6 @@ importers:
       '@types/ua-parser-js':
         specifier: ^0.7.39
         version: 0.7.39
-      '@typescript-eslint/eslint-plugin':
-        specifier: ^5.54.1
-        version: 5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.37.0)(typescript@4.9.5)
-      '@typescript-eslint/parser':
-        specifier: ^5.54.1
-        version: 5.57.1(eslint@8.37.0)(typescript@4.9.5)
       '@vitejs/plugin-react':
         specifier: ^4.0.4
         version: 4.0.4(vite@4.4.9)
@@ -264,7 +266,7 @@ importers:
         version: 8.8.0(eslint@8.37.0)
       eslint-plugin-import:
         specifier: ^2.27.5
-        version: 2.27.5(@typescript-eslint/parser@5.57.1)(eslint@8.37.0)
+        version: 2.27.5(eslint-import-resolver-typescript@3.6.1)(eslint@8.37.0)
       eslint-plugin-jsx-a11y:
         specifier: ^6.7.1
         version: 6.7.1(eslint@8.37.0)
@@ -274,15 +276,6 @@ importers:
       eslint-plugin-react-hooks:
         specifier: ^4.6.0
         version: 4.6.0(eslint@8.37.0)
-      gh-pages:
-        specifier: ^5.0.0
-        version: 5.0.0
-      husky:
-        specifier: ^8.0.3
-        version: 8.0.3
-      lint-staged:
-        specifier: ^13.1.1
-        version: 13.1.2
       peterportal-api-next-types:
         specifier: 1.0.0-rc.2.68.0
         version: 1.0.0-rc.2.68.0
@@ -376,7 +369,7 @@ importers:
         version: 8.8.0(eslint@8.38.0)
       eslint-plugin-import:
         specifier: ^2.27.5
-        version: 2.27.5(@typescript-eslint/parser@5.57.1)(eslint@8.38.0)
+        version: 2.27.5(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0)
       husky:
         specifier: ^8.0.3
         version: 8.0.3
@@ -4155,6 +4148,13 @@ packages:
     resolution: {integrity: sha512-o9ILUTMSRiOC73o7h30mgLpuDwOJWqMHZfnEIwNQlfZsGY6Kw2gK5Gz6nXAjNt9zm3Qpr12pM4FDqssfdtBzGA==}
     dev: true
 
+  /@types/eslint@8.56.5:
+    resolution: {integrity: sha512-u5/YPJHo1tvkSF2CE0USEkxon82Z5DBy2xR+qfyYNszpX9qcs4sT6uq2kBbj4BXY1+DBGDPnrhMZV3pKWGNukw==}
+    dependencies:
+      '@types/estree': 1.0.0
+      '@types/json-schema': 7.0.11
+    dev: true
+
   /@types/estree@1.0.0:
     resolution: {integrity: sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==}
     dev: true
@@ -4368,34 +4368,6 @@ packages:
       '@types/webidl-conversions': 7.0.0
     dev: false
 
-  /@typescript-eslint/eslint-plugin@5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.37.0)(typescript@4.9.5):
-    resolution: {integrity: sha512-1MeobQkQ9tztuleT3v72XmY0XuKXVXusAhryoLuU5YZ+mXoYKZP9SQ7Flulh1NX4DTjpGTc2b/eMu4u7M7dhnQ==}
-    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
-    peerDependencies:
-      '@typescript-eslint/parser': ^5.0.0
-      eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
-      typescript: '*'
-    peerDependenciesMeta:
-      typescript:
-        optional: true
-    dependencies:
-      '@eslint-community/regexpp': 4.5.0
-      '@typescript-eslint/parser': 5.57.1(eslint@8.37.0)(typescript@4.9.5)
-      '@typescript-eslint/scope-manager': 5.57.1
-      '@typescript-eslint/type-utils': 5.57.1(eslint@8.37.0)(typescript@4.9.5)
-      '@typescript-eslint/utils': 5.57.1(eslint@8.37.0)(typescript@4.9.5)
-      debug: 4.3.4
-      eslint: 8.37.0
-      grapheme-splitter: 1.0.4
-      ignore: 5.2.4
-      natural-compare-lite: 1.4.0
-      semver: 7.3.8
-      tsutils: 3.21.0(typescript@4.9.5)
-      typescript: 4.9.5
-    transitivePeerDependencies:
-      - supports-color
-    dev: true
-
   /@typescript-eslint/eslint-plugin@5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.38.0)(typescript@4.9.5):
     resolution: {integrity: sha512-1MeobQkQ9tztuleT3v72XmY0XuKXVXusAhryoLuU5YZ+mXoYKZP9SQ7Flulh1NX4DTjpGTc2b/eMu4u7M7dhnQ==}
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -4424,26 +4396,6 @@ packages:
       - supports-color
     dev: true
 
-  /@typescript-eslint/parser@5.57.1(eslint@8.37.0)(typescript@4.9.5):
-    resolution: {integrity: sha512-hlA0BLeVSA/wBPKdPGxoVr9Pp6GutGoY380FEhbVi0Ph4WNe8kLvqIRx76RSQt1lynZKfrXKs0/XeEk4zZycuA==}
-    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
-    peerDependencies:
-      eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
-      typescript: '*'
-    peerDependenciesMeta:
-      typescript:
-        optional: true
-    dependencies:
-      '@typescript-eslint/scope-manager': 5.57.1
-      '@typescript-eslint/types': 5.57.1
-      '@typescript-eslint/typescript-estree': 5.57.1(typescript@4.9.5)
-      debug: 4.3.4
-      eslint: 8.37.0
-      typescript: 4.9.5
-    transitivePeerDependencies:
-      - supports-color
-    dev: true
-
   /@typescript-eslint/parser@5.57.1(eslint@8.38.0)(typescript@4.9.5):
     resolution: {integrity: sha512-hlA0BLeVSA/wBPKdPGxoVr9Pp6GutGoY380FEhbVi0Ph4WNe8kLvqIRx76RSQt1lynZKfrXKs0/XeEk4zZycuA==}
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -4472,26 +4424,6 @@ packages:
       '@typescript-eslint/visitor-keys': 5.57.1
     dev: true
 
-  /@typescript-eslint/type-utils@5.57.1(eslint@8.37.0)(typescript@4.9.5):
-    resolution: {integrity: sha512-/RIPQyx60Pt6ga86hKXesXkJ2WOS4UemFrmmq/7eOyiYjYv/MUSHPlkhU6k9T9W1ytnTJueqASW+wOmW4KrViw==}
-    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
-    peerDependencies:
-      eslint: '*'
-      typescript: '*'
-    peerDependenciesMeta:
-      typescript:
-        optional: true
-    dependencies:
-      '@typescript-eslint/typescript-estree': 5.57.1(typescript@4.9.5)
-      '@typescript-eslint/utils': 5.57.1(eslint@8.37.0)(typescript@4.9.5)
-      debug: 4.3.4
-      eslint: 8.37.0
-      tsutils: 3.21.0(typescript@4.9.5)
-      typescript: 4.9.5
-    transitivePeerDependencies:
-      - supports-color
-    dev: true
-
   /@typescript-eslint/type-utils@5.57.1(eslint@8.38.0)(typescript@4.9.5):
     resolution: {integrity: sha512-/RIPQyx60Pt6ga86hKXesXkJ2WOS4UemFrmmq/7eOyiYjYv/MUSHPlkhU6k9T9W1ytnTJueqASW+wOmW4KrViw==}
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -4538,26 +4470,6 @@ packages:
       - supports-color
     dev: true
 
-  /@typescript-eslint/utils@5.57.1(eslint@8.37.0)(typescript@4.9.5):
-    resolution: {integrity: sha512-kN6vzzf9NkEtawECqze6v99LtmDiUJCVpvieTFA1uL7/jDghiJGubGZ5csicYHU1Xoqb3oH/R5cN5df6W41Nfg==}
-    engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
-    peerDependencies:
-      eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
-    dependencies:
-      '@eslint-community/eslint-utils': 4.4.0(eslint@8.37.0)
-      '@types/json-schema': 7.0.11
-      '@types/semver': 7.3.13
-      '@typescript-eslint/scope-manager': 5.57.1
-      '@typescript-eslint/types': 5.57.1
-      '@typescript-eslint/typescript-estree': 5.57.1(typescript@4.9.5)
-      eslint: 8.37.0
-      eslint-scope: 5.1.1
-      semver: 7.3.8
-    transitivePeerDependencies:
-      - supports-color
-      - typescript
-    dev: true
-
   /@typescript-eslint/utils@5.57.1(eslint@8.38.0)(typescript@4.9.5):
     resolution: {integrity: sha512-kN6vzzf9NkEtawECqze6v99LtmDiUJCVpvieTFA1uL7/jDghiJGubGZ5csicYHU1Xoqb3oH/R5cN5df6W41Nfg==}
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -4795,23 +4707,11 @@ packages:
       is-string: 1.0.7
     dev: true
 
-  /array-union@1.0.2:
-    resolution: {integrity: sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==}
-    engines: {node: '>=0.10.0'}
-    dependencies:
-      array-uniq: 1.0.3
-    dev: true
-
   /array-union@2.1.0:
     resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
     engines: {node: '>=8'}
     dev: true
 
-  /array-uniq@1.0.3:
-    resolution: {integrity: sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==}
-    engines: {node: '>=0.10.0'}
-    dev: true
-
   /array.prototype.flat@1.3.1:
     resolution: {integrity: sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==}
     engines: {node: '>= 0.4'}
@@ -4855,10 +4755,6 @@ packages:
     engines: {node: '>=8'}
     dev: true
 
-  /async@3.2.4:
-    resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==}
-    dev: true
-
   /asynckit@0.4.0:
     resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
     dev: true
@@ -5225,10 +5121,6 @@ packages:
       delayed-stream: 1.0.0
     dev: true
 
-  /commander@2.20.3:
-    resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
-    dev: true
-
   /commander@3.0.2:
     resolution: {integrity: sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==}
     dev: false
@@ -5238,10 +5130,6 @@ packages:
     engines: {node: ^12.20.0 || >=14}
     dev: true
 
-  /commondir@1.0.1:
-    resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
-    dev: true
-
   /concat-map@0.0.1:
     resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
     dev: true
@@ -5644,10 +5532,6 @@ packages:
     resolution: {integrity: sha512-M8LVncPt1Xaw1XLxws6EoJCmY41RXLk87tq6PHvSHDyTYWla3CrEgGlbhC79e8LHyvQ2JTDXx//xzgSixNYcUQ==}
     dev: true
 
-  /email-addresses@5.0.0:
-    resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==}
-    dev: true
-
   /emoji-regex@8.0.0:
     resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
     dev: true
@@ -5661,6 +5545,14 @@ packages:
     engines: {node: '>= 0.8'}
     dev: false
 
+  /enhanced-resolve@5.15.1:
+    resolution: {integrity: sha512-3d3JRbwsCLJsYgvb6NuWEG44jjPSOMuS73L/6+7BZuoKm3W+qXnSoIYVHi8dG7Qcg4inAY4jbzkZ7MnskePeDg==}
+    engines: {node: '>=10.13.0'}
+    dependencies:
+      graceful-fs: 4.2.10
+      tapable: 2.2.1
+    dev: true
+
   /entities@4.4.0:
     resolution: {integrity: sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==}
     engines: {node: '>=0.12'}
@@ -5860,7 +5752,30 @@ packages:
       - supports-color
     dev: true
 
-  /eslint-module-utils@2.7.4(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-node@0.3.7)(eslint@8.37.0):
+  /eslint-import-resolver-typescript@3.6.1(eslint-plugin-import@2.27.5)(eslint@8.38.0):
+    resolution: {integrity: sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==}
+    engines: {node: ^14.18.0 || >=16.0.0}
+    peerDependencies:
+      eslint: '*'
+      eslint-plugin-import: '*'
+    dependencies:
+      debug: 4.3.4
+      enhanced-resolve: 5.15.1
+      eslint: 8.38.0
+      eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0)
+      eslint-plugin-import: 2.27.5(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0)
+      fast-glob: 3.3.2
+      get-tsconfig: 4.6.2
+      is-core-module: 2.11.0
+      is-glob: 4.0.3
+    transitivePeerDependencies:
+      - '@typescript-eslint/parser'
+      - eslint-import-resolver-node
+      - eslint-import-resolver-webpack
+      - supports-color
+    dev: true
+
+  /eslint-module-utils@2.7.4(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0):
     resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==}
     engines: {node: '>=4'}
     peerDependencies:
@@ -5881,15 +5796,16 @@ packages:
       eslint-import-resolver-webpack:
         optional: true
     dependencies:
-      '@typescript-eslint/parser': 5.57.1(eslint@8.37.0)(typescript@4.9.5)
+      '@typescript-eslint/parser': 5.57.1(eslint@8.38.0)(typescript@4.9.5)
       debug: 3.2.7(supports-color@5.5.0)
-      eslint: 8.37.0
+      eslint: 8.38.0
       eslint-import-resolver-node: 0.3.7
+      eslint-import-resolver-typescript: 3.6.1(eslint-plugin-import@2.27.5)(eslint@8.38.0)
     transitivePeerDependencies:
       - supports-color
     dev: true
 
-  /eslint-module-utils@2.7.4(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-node@0.3.7)(eslint@8.38.0):
+  /eslint-module-utils@2.7.4(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.1)(eslint@8.37.0):
     resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==}
     engines: {node: '>=4'}
     peerDependencies:
@@ -5909,16 +5825,49 @@ packages:
         optional: true
       eslint-import-resolver-webpack:
         optional: true
+    dependencies:
+      debug: 3.2.7(supports-color@5.5.0)
+      eslint: 8.37.0
+      eslint-import-resolver-node: 0.3.7
+      eslint-import-resolver-typescript: 3.6.1(eslint-plugin-import@2.27.5)(eslint@8.38.0)
+    transitivePeerDependencies:
+      - supports-color
+    dev: true
+
+  /eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0):
+    resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==}
+    engines: {node: '>=4'}
+    peerDependencies:
+      '@typescript-eslint/parser': '*'
+      eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
+    peerDependenciesMeta:
+      '@typescript-eslint/parser':
+        optional: true
     dependencies:
       '@typescript-eslint/parser': 5.57.1(eslint@8.38.0)(typescript@4.9.5)
+      array-includes: 3.1.6
+      array.prototype.flat: 1.3.1
+      array.prototype.flatmap: 1.3.1
       debug: 3.2.7(supports-color@5.5.0)
+      doctrine: 2.1.0
       eslint: 8.38.0
       eslint-import-resolver-node: 0.3.7
+      eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0)
+      has: 1.0.3
+      is-core-module: 2.11.0
+      is-glob: 4.0.3
+      minimatch: 3.1.2
+      object.values: 1.1.6
+      resolve: 1.22.1
+      semver: 6.3.0
+      tsconfig-paths: 3.14.1
     transitivePeerDependencies:
+      - eslint-import-resolver-typescript
+      - eslint-import-resolver-webpack
       - supports-color
     dev: true
 
-  /eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.57.1)(eslint@8.37.0):
+  /eslint-plugin-import@2.27.5(eslint-import-resolver-typescript@3.6.1)(eslint@8.37.0):
     resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==}
     engines: {node: '>=4'}
     peerDependencies:
@@ -5928,7 +5877,6 @@ packages:
       '@typescript-eslint/parser':
         optional: true
     dependencies:
-      '@typescript-eslint/parser': 5.57.1(eslint@8.37.0)(typescript@4.9.5)
       array-includes: 3.1.6
       array.prototype.flat: 1.3.1
       array.prototype.flatmap: 1.3.1
@@ -5936,7 +5884,7 @@ packages:
       doctrine: 2.1.0
       eslint: 8.37.0
       eslint-import-resolver-node: 0.3.7
-      eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-node@0.3.7)(eslint@8.37.0)
+      eslint-module-utils: 2.7.4(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.1)(eslint@8.37.0)
       has: 1.0.3
       is-core-module: 2.11.0
       is-glob: 4.0.3
@@ -5951,7 +5899,7 @@ packages:
       - supports-color
     dev: true
 
-  /eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.57.1)(eslint@8.38.0):
+  /eslint-plugin-import@2.27.5(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0):
     resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==}
     engines: {node: '>=4'}
     peerDependencies:
@@ -5961,7 +5909,6 @@ packages:
       '@typescript-eslint/parser':
         optional: true
     dependencies:
-      '@typescript-eslint/parser': 5.57.1(eslint@8.38.0)(typescript@4.9.5)
       array-includes: 3.1.6
       array.prototype.flat: 1.3.1
       array.prototype.flatmap: 1.3.1
@@ -5969,7 +5916,7 @@ packages:
       doctrine: 2.1.0
       eslint: 8.38.0
       eslint-import-resolver-node: 0.3.7
-      eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-node@0.3.7)(eslint@8.38.0)
+      eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0)
       has: 1.0.3
       is-core-module: 2.11.0
       is-glob: 4.0.3
@@ -6300,6 +6247,17 @@ packages:
       micromatch: 4.0.5
     dev: true
 
+  /fast-glob@3.3.2:
+    resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
+    engines: {node: '>=8.6.0'}
+    dependencies:
+      '@nodelib/fs.stat': 2.0.5
+      '@nodelib/fs.walk': 1.2.8
+      glob-parent: 5.1.2
+      merge2: 1.4.1
+      micromatch: 4.0.5
+    dev: true
+
   /fast-json-stable-stringify@2.1.0:
     resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
     dev: true
@@ -6354,20 +6312,6 @@ packages:
     resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
     dev: false
 
-  /filename-reserved-regex@2.0.0:
-    resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==}
-    engines: {node: '>=4'}
-    dev: true
-
-  /filenamify@4.3.0:
-    resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==}
-    engines: {node: '>=8'}
-    dependencies:
-      filename-reserved-regex: 2.0.0
-      strip-outer: 1.0.1
-      trim-repeated: 1.0.0
-    dev: true
-
   /fill-range@7.0.1:
     resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
     engines: {node: '>=8'}
@@ -6390,27 +6334,10 @@ packages:
       - supports-color
     dev: false
 
-  /find-cache-dir@3.3.2:
-    resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==}
-    engines: {node: '>=8'}
-    dependencies:
-      commondir: 1.0.1
-      make-dir: 3.1.0
-      pkg-dir: 4.2.0
-    dev: true
-
   /find-root@1.1.0:
     resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
     dev: false
 
-  /find-up@4.1.0:
-    resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
-    engines: {node: '>=8'}
-    dependencies:
-      locate-path: 5.0.0
-      path-exists: 4.0.0
-    dev: true
-
   /find-up@5.0.0:
     resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
     engines: {node: '>=10'}
@@ -6462,15 +6389,6 @@ packages:
     engines: {node: '>= 0.6'}
     dev: false
 
-  /fs-extra@8.1.0:
-    resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
-    engines: {node: '>=6 <7 || >=8'}
-    dependencies:
-      graceful-fs: 4.2.10
-      jsonfile: 4.0.0
-      universalify: 0.1.2
-    dev: true
-
   /fs.realpath@1.0.0:
     resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
     dev: true
@@ -6540,20 +6458,6 @@ packages:
       resolve-pkg-maps: 1.0.0
     dev: true
 
-  /gh-pages@5.0.0:
-    resolution: {integrity: sha512-Nqp1SjkPIB94Xw/3yYNTUL+G2dxlhjvv1zeN/4kMC1jfViTEqhtVz/Ba1zSXHuvXCN9ADNS1dN4r5/J/nZWEQQ==}
-    engines: {node: '>=10'}
-    hasBin: true
-    dependencies:
-      async: 3.2.4
-      commander: 2.20.3
-      email-addresses: 5.0.0
-      filenamify: 4.3.0
-      find-cache-dir: 3.3.2
-      fs-extra: 8.1.0
-      globby: 6.1.0
-    dev: true
-
   /glob-parent@5.1.2:
     resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
     engines: {node: '>= 6'}
@@ -6618,17 +6522,6 @@ packages:
       slash: 3.0.0
     dev: true
 
-  /globby@6.1.0:
-    resolution: {integrity: sha512-KVbFv2TQtbzCoxAnfD6JcHZTYCzyliEaaeM/gH8qQdkKr5s0OP9scEgvdcngyk7AVdY6YVW/TJHd+lQ/Df3Daw==}
-    engines: {node: '>=0.10.0'}
-    dependencies:
-      array-union: 1.0.2
-      glob: 7.2.3
-      object-assign: 4.1.1
-      pify: 2.3.0
-      pinkie-promise: 2.0.1
-    dev: true
-
   /gopd@1.0.1:
     resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
     dependencies:
@@ -7136,12 +7029,6 @@ packages:
     resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
     dev: true
 
-  /jsonfile@4.0.0:
-    resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
-    optionalDependencies:
-      graceful-fs: 4.2.10
-    dev: true
-
   /jss-plugin-camel-case@10.10.0:
     resolution: {integrity: sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==}
     dependencies:
@@ -7306,13 +7193,6 @@ packages:
     engines: {node: '>=14'}
     dev: true
 
-  /locate-path@5.0.0:
-    resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
-    engines: {node: '>=8'}
-    dependencies:
-      p-locate: 4.1.0
-    dev: true
-
   /locate-path@6.0.0:
     resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
     engines: {node: '>=10'}
@@ -7383,13 +7263,6 @@ packages:
       '@jridgewell/sourcemap-codec': 1.4.15
     dev: true
 
-  /make-dir@3.1.0:
-    resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
-    engines: {node: '>=8'}
-    dependencies:
-      semver: 6.3.0
-    dev: true
-
   /material-colors@1.2.6:
     resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
     dev: false
@@ -7830,13 +7703,6 @@ packages:
     resolution: {integrity: sha512-ge4ZTIetMQKAHKq2MwWf83ntzdJN20ndRKRaVNoZ3SkDkBNO99Qddz7r6+hrVx38I+ih6Rk5T1yslczAB6Q9Pg==}
     dev: false
 
-  /p-limit@2.3.0:
-    resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
-    engines: {node: '>=6'}
-    dependencies:
-      p-try: 2.2.0
-    dev: true
-
   /p-limit@3.1.0:
     resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
     engines: {node: '>=10'}
@@ -7851,13 +7717,6 @@ packages:
       yocto-queue: 1.0.0
     dev: true
 
-  /p-locate@4.1.0:
-    resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
-    engines: {node: '>=8'}
-    dependencies:
-      p-limit: 2.3.0
-    dev: true
-
   /p-locate@5.0.0:
     resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
     engines: {node: '>=10'}
@@ -7872,11 +7731,6 @@ packages:
       aggregate-error: 3.1.0
     dev: true
 
-  /p-try@2.2.0:
-    resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
-    engines: {node: '>=6'}
-    dev: true
-
   /pako@2.1.0:
     resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
     dev: false
@@ -7969,30 +7823,6 @@ packages:
     hasBin: true
     dev: true
 
-  /pify@2.3.0:
-    resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
-    engines: {node: '>=0.10.0'}
-    dev: true
-
-  /pinkie-promise@2.0.1:
-    resolution: {integrity: sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==}
-    engines: {node: '>=0.10.0'}
-    dependencies:
-      pinkie: 2.0.4
-    dev: true
-
-  /pinkie@2.0.4:
-    resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==}
-    engines: {node: '>=0.10.0'}
-    dev: true
-
-  /pkg-dir@4.2.0:
-    resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
-    engines: {node: '>=8'}
-    dependencies:
-      find-up: 4.1.0
-    dev: true
-
   /pkg-types@1.0.3:
     resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==}
     dependencies:
@@ -8849,13 +8679,6 @@ packages:
       acorn: 8.10.0
     dev: true
 
-  /strip-outer@1.0.1:
-    resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==}
-    engines: {node: '>=0.10.0'}
-    dependencies:
-      escape-string-regexp: 1.0.5
-    dev: true
-
   /strnum@1.0.5:
     resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
 
@@ -8902,6 +8725,11 @@ packages:
     resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
     dev: true
 
+  /tapable@2.2.1:
+    resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
+    engines: {node: '>=6'}
+    dev: true
+
   /text-segmentation@1.0.3:
     resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
     dependencies:
@@ -8994,13 +8822,6 @@ packages:
     hasBin: true
     dev: true
 
-  /trim-repeated@1.0.0:
-    resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==}
-    engines: {node: '>=0.10.0'}
-    dependencies:
-      escape-string-regexp: 1.0.5
-    dev: true
-
   /tsconfig-paths@3.14.1:
     resolution: {integrity: sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==}
     dependencies:
@@ -9203,11 +9024,6 @@ packages:
     resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==}
     dev: false
 
-  /universalify@0.1.2:
-    resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
-    engines: {node: '>= 4.0.0'}
-    dev: true
-
   /universalify@0.2.0:
     resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
     engines: {node: '>= 4.0.0'}


Test patch:
diff --git a/apps/antalmanac/tests/locations.test.ts b/apps/antalmanac/tests/locations.test.ts
new file mode 100644
index 00000000..7afb6885
--- /dev/null
+++ b/apps/antalmanac/tests/locations.test.ts
@@ -0,0 +1,45 @@
+import { describe, test, expect } from 'vitest';
+import locationIds from '$lib/location_ids';
+import buildingCatalogue from '$lib/buildingCatalogue';
+
+describe('locationIds', () => {
+    test('no duplicate names', () => {
+        const names = Object.keys(locationIds);
+        const uniqueNames = new Set(names);
+        expect(names.length).toBe(uniqueNames.size);
+    });
+});
+
+describe('buildingCatalogue', () => {
+    test('no duplicate names', () => {
+        const names = Object.values(buildingCatalogue).map((building) => building.name);
+        const uniqueNames = new Set(names);
+        expect(names.length).toBe(uniqueNames.size);
+    });
+});
+
+describe('locationIds and buildingCatalogue', () => {
+    test('all locationIds are in buildingCatalogue', () => {
+        const locationNames = Object.keys(locationIds);
+        const buildingNames = Object.values(buildingCatalogue).map((building) => {
+            const buildingName = building.name.includes('(')
+                ? building.name.substring(building.name.indexOf('(') + 1, building.name.indexOf(')'))
+                : building.name;
+            return buildingName;
+        });
+        for (const locationName of locationNames) {
+            expect(buildingNames).toContain(locationName);
+        }
+    });
+
+    test('all buildingCatalogue are in locationIds', () => {
+        const locationNames = Object.keys(locationIds);
+        const buildingNames = Object.values(buildingCatalogue).map((building) => building.name);
+        for (const buildingName of buildingNames) {
+            const name = buildingName.includes('(')
+                ? buildingName.substring(buildingName.indexOf('(') + 1, buildingName.indexOf(')'))
+                : buildingName;
+            expect(locationNames).toContain(name);
+        }
+    });
+});