Repository: icssc/AntAlmanac
Pull request #696 (2023-09-12 03:39:26)

feat: multiple locations on courses
## Summary
A continuation of #634 


github-actions[bot]: Deployed staging instance to https://staging-696.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
EricPedley: Also, seems like this just does everything #634 does. Are #634 and #592 closable after this?
ap0nia: I was gonna let him push some commits to address the comments and stuff so he could get some participation credit. I just opened this PR because the previous one was getting pretty stale and seemed kinda difficult to get up to speed. I still don't know how to rebase lol, so you can take care of that if you want.
github-actions[bot]: Deployed staging instance to https://staging-696.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
alanchangxyz: a PR on a PR, since i cannot commit directly to this icssc/ branch - #697
github-actions[bot]: Deployed staging instance to https://staging-696.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->
EricPedley: For testing purposes, using ENGRMAE 295 Fall 2023 (section code 19177) as it's the first course I found that has two locations. Leaving this comment here so nobody needs to dig up an example on their own.
github-actions[bot]: Deployed staging instance to https://staging-696.antalmanac.com
<!-- Sticky Pull Request Commentstaging url -->

Patch:
diff --git a/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx b/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx
index 08597486..7d763dab 100644
--- a/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx
+++ b/apps/antalmanac/src/components/Calendar/CalendarRoot.tsx
@@ -69,26 +69,28 @@ const AntAlmanacEvent =
     ({ classes }: { classes: ClassNameMap }) =>
     // eslint-disable-next-line react/display-name
     ({ event }: { event: CalendarEvent }) => {
-        if (!event.isCustomEvent)
-            return (
-                <div>
-                    <div className={classes.firstLineContainer}>
-                        <div> {event.title}</div>
-                        <div className={classes.sectionType}> {event.sectionType}</div>
-                    </div>
-                    <div className={classes.secondLineContainer}>
-                        <div>{event.bldg}</div>
-                        <div>{event.sectionCode}</div>
-                    </div>
+        return event.isCustomEvent ? (
+            <div className={classes.customEventContainer}>
+                <div className={classes.customEventTitle}>{event.title}</div>
+            </div>
+        ) : (
+            <div>
+                <div className={classes.firstLineContainer}>
+                    <div> {event.title}</div>
+                    <div className={classes.sectionType}> {event.sectionType}</div>
                 </div>
-            );
-        else {
-            return (
-                <div className={classes.customEventContainer}>
-                    <div className={classes.customEventTitle}>{event.title}</div>
+                <div className={classes.secondLineContainer}>
+                    <div>
+                        {event.showLocationInfo
+                            ? event.locations.map((location) => `${location.building} ${location.room}`).join(', ')
+                            : event.locations.length > 1
+                            ? `${event.locations.length} Locations`
+                            : `${event.locations[0].building} ${event.locations[0].room}`}
+                    </div>
+                    <div>{event.sectionCode}</div>
                 </div>
-            );
-        }
+            </div>
+        );
     };
 interface ScheduleCalendarProps {
     classes: ClassNameMap;
diff --git a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
index b0adad7f..105e434f 100644
--- a/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
+++ b/apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
@@ -88,8 +88,26 @@ interface CommonCalendarEvent extends Event {
     title: string;
 }
 
+export interface Location {
+    /**
+     * @example 'ICS'
+     */
+    building: string;
+
+    /**
+     * @example '174'
+     */
+    room: string;
+
+    /**
+     * If the location only applies on specific days, this is non-null.
+     */
+    days?: string[];
+}
+
 export interface CourseEvent extends CommonCalendarEvent {
-    bldg: string; // E.g., ICS 174, which is actually building + room
+    locations: Location[];
+    showLocationInfo: boolean;
     finalExam: {
         examStatus: 'NO_FINAL' | 'TBA_FINAL' | 'SCHEDULED_FINAL';
         dayOfWeek: 'Sun' | 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | null;
@@ -103,7 +121,7 @@ export interface CourseEvent extends CommonCalendarEvent {
             hour: number;
             minute: number;
         } | null;
-        bldg: string[] | null;
+        locations: Location[] | null;
     };
     courseTitle: string;
     instructors: string[];
@@ -158,22 +176,22 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => {
     }, [setActiveTab]);
 
     const { classes, courseInMoreInfo } = props;
-    if (!courseInMoreInfo.isCustomEvent) {
-        const { term, instructors, sectionCode, title, finalExam, bldg, sectionType } = courseInMoreInfo;
-
-        const [buildingName = ''] = bldg.split(' ');
 
-        const buildingId = locationIds[buildingName] ?? 69420;
+    if (!courseInMoreInfo.isCustomEvent) {
+        const { term, instructors, sectionCode, title, finalExam, locations, sectionType } = courseInMoreInfo;
 
         let finalExamString = '';
+
         if (finalExam.examStatus == 'NO_FINAL') {
             finalExamString = 'No Final';
         } else if (finalExam.examStatus == 'TBA_FINAL') {
             finalExamString = 'Final TBA';
         } else {
-            if (finalExam.startTime && finalExam.endTime && finalExam.month && finalExam.bldg) {
+            if (finalExam.startTime && finalExam.endTime && finalExam.month && finalExam.locations) {
                 const timeString = translate24To12HourTime(finalExam.startTime, finalExam.endTime);
-                const locationString = `at ${finalExam.bldg.join(', ')}`;
+                const locationString = `at ${finalExam.locations
+                    .map((location) => `${location.building} ${location.room}`)
+                    .join(', ')}`;
                 const finalExamMonth = MONTHS[finalExam.month];
 
                 finalExamString = `${finalExam.dayOfWeek} ${finalExamMonth} ${finalExam.day} ${timeString} ${locationString}`;
@@ -229,15 +247,19 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => {
                             <td className={`${classes.multiline} ${classes.rightCells}`}>{instructors.join('\n')}</td>
                         </tr>
                         <tr>
-                            <td className={classes.alignToTop}>Location</td>
+                            <td className={classes.alignToTop}>Location{locations.length > 1 && 's'}</td>
                             <td className={`${classes.multiline} ${classes.rightCells}`}>
-                                <Link
-                                    className={classes.clickableLocation}
-                                    to={`/map?location=${buildingId}`}
-                                    onClick={focusMap}
-                                >
-                                    {bldg}
-                                </Link>
+                                {locations.map((location) => (
+                                    <div key={`${sectionCode} @ ${location.building} ${location.room}`}>
+                                        <Link
+                                            className={classes.clickableLocation}
+                                            to={`/map?location=${locationIds[location.building] ?? 0}`}
+                                            onClick={focusMap}
+                                        >
+                                            {location.building} {location.room}
+                                        </Link>
+                                    </div>
+                                ))}
                             </td>
                         </tr>
                         <tr>
diff --git a/apps/antalmanac/src/components/Map/Map.tsx b/apps/antalmanac/src/components/Map/Map.tsx
index d19a0042..242f478b 100644
--- a/apps/antalmanac/src/components/Map/Map.tsx
+++ b/apps/antalmanac/src/components/Map/Map.tsx
@@ -41,7 +41,7 @@ interface MarkerContent {
 export function getCoursesPerBuilding() {
     const courseEvents = AppStore.getCourseEventsInCalendar();
 
-    const allBuildingCodes = courseEvents.map((event) => event.bldg.split(' ').slice(0, -1).join(' '));
+    const allBuildingCodes = courseEvents.flatMap((event) => event.locations.map((location) => location.building));
 
     const uniqueBuildingCodes = new Set(allBuildingCodes);
 
@@ -53,10 +53,10 @@ export function getCoursesPerBuilding() {
 
     validBuildingCodes.forEach((buildingCode) => {
         coursesPerBuilding[buildingCode] = courseEvents
-            .filter((event) => event.bldg.split(' ').slice(0, -1).join(' ') === buildingCode)
+            .filter((event) => event.locations.map((location) => location.building).includes(buildingCode))
             .map((event) => {
                 const locationData = buildingCatalogue[locationIds[buildingCode]];
-                const key = `${event.title} ${event.sectionType} @ ${event.bldg}`;
+                const key = `${event.title} ${event.sectionType} @ ${event.locations[0]}`;
                 const acronym = locationData.name.substring(
                     locationData.name.indexOf('(') + 1,
                     locationData.name.indexOf(')')
@@ -73,6 +73,7 @@ export function getCoursesPerBuilding() {
                 return markerData;
             });
     });
+
     return coursesPerBuilding;
 }
 
@@ -97,15 +98,11 @@ export default function CourseMap() {
     const [markers, setMarkers] = useState(getCoursesPerBuilding());
     const [calendarEvents, setCalendarEvents] = useState(AppStore.getCourseEventsInCalendar());
 
-    const updateMarkers = useCallback(() => {
-        setMarkers(getCoursesPerBuilding());
-    }, [setMarkers, getCoursesPerBuilding]);
-
-    const updateCalendarEvents = useCallback(() => {
-        setCalendarEvents(AppStore.getCourseEventsInCalendar());
-    }, [setCalendarEvents]);
-
     useEffect(() => {
+        const updateMarkers = () => {
+            setMarkers(getCoursesPerBuilding());
+        };
+
         AppStore.on('addedCoursesChange', updateMarkers);
         AppStore.on('currentScheduleIndexChange', updateMarkers);
 
@@ -113,9 +110,13 @@ export default function CourseMap() {
             AppStore.removeListener('addedCoursesChange', updateMarkers);
             AppStore.removeListener('currentScheduleIndexChange', updateMarkers);
         };
-    }, [updateMarkers]);
+    }, []);
 
     useEffect(() => {
+        const updateCalendarEvents = () => {
+            setCalendarEvents(AppStore.getCourseEventsInCalendar());
+        };
+
         AppStore.on('addedCoursesChange', updateCalendarEvents);
         AppStore.on('currentScheduleIndexChange', updateCalendarEvents);
 
@@ -123,7 +124,7 @@ export default function CourseMap() {
             AppStore.removeListener('addedCoursesChange', updateCalendarEvents);
             AppStore.removeListener('currentScheduleIndexChange', updateCalendarEvents);
         };
-    }, [updateCalendarEvents]);
+    }, []);
 
     useEffect(() => {
         const locationID = Number(searchParams.get('location') ?? 0);
@@ -215,7 +216,7 @@ export default function CourseMap() {
     return (
         <Box sx={{ width: '100%', display: 'flex', flexDirection: 'column', flexGrow: 1, height: '100%' }}>
             <MapContainer ref={map} center={[33.6459, -117.842717]} zoom={16} style={{ height: '100%' }}>
-                {/** Menu floats above the map. */}
+                {/* Menu floats above the map. */}
                 <Paper sx={{ zIndex: 400, position: 'relative', my: 2, mx: 6.942, marginX: '15%', marginY: 8 }}>
                     <Tabs value={selectedDayIndex} onChange={handleChange} variant="fullWidth" sx={{ minHeight: 0 }}>
                         {days.map((day) => (
@@ -249,9 +250,16 @@ export default function CourseMap() {
                 {/* Draw a marker for each class that occurs today. */}
                 {markersToDisplay.map((marker, index) => {
                     // Find all courses that occur in the same building prior to this one to stack them properly.
+                    // TODO Handle multiple buildings between class comparisons on markers.
                     const coursesSameBuildingPrior = markersToDisplay
                         .slice(0, index)
-                        .filter((m) => m.bldg.split(' ')[0] === marker.bldg.split(' ')[0]);
+                        .filter((m) =>
+                            m.locations.map((location) => location.building).includes(marker.locations[0].building)
+                        );
+
+                    const allRoomsInBuilding = marker.locations
+                        .filter((location) => location.building == marker.locations[0].building)
+                        .reduce((roomList, location) => [...roomList, location.room], [] as string[]);
 
                     return (
                         <Fragment key={Object.values(marker).join('')}>
@@ -264,7 +272,10 @@ export default function CourseMap() {
                                     <Typography variant="body2">
                                         Class: {marker.title} {marker.sectionType}
                                     </Typography>
-                                    <Typography variant="body2">Room: {marker.bldg.split(' ').slice(-1)}</Typography>
+                                    <Typography variant="body2">
+                                        Room{allRoomsInBuilding.length > 1 && 's'}: {marker.locations[0].building}{' '}
+                                        {allRoomsInBuilding.join('/')}
+                                    </Typography>
                                 </Box>
                             </LocationMarker>
                         </Fragment>
diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
index bd1f7c17..c356fe97 100644
--- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
+++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTableBody.tsx
@@ -201,19 +201,23 @@ const LocationsCell = withStyles(styles)((props: LocationsCellProps) => {
     return (
         <NoPaddingTableCell className={classes.cell}>
             {meetings.map((meeting) => {
-                const [buildingName = ''] = meeting.bldg[0].split(' ');
-                const buildingId = locationIds[buildingName];
                 return meeting.bldg[0] !== 'TBA' ? (
-                    <Fragment key={meeting.timeIsTBA + meeting.bldg[0]}>
-                        <Link
-                            className={classes.clickableLocation}
-                            to={`/map?location=${buildingId}`}
-                            onClick={focusMap}
-                        >
-                            {meeting.bldg}
-                        </Link>
-                        <br />
-                    </Fragment>
+                    meeting.bldg.map((bldg) => {
+                        const [buildingName = ''] = bldg.split(' ');
+                        const buildingId = locationIds[buildingName];
+                        return (
+                            <Fragment key={meeting.timeIsTBA + bldg}>
+                                <Link
+                                    className={classes.clickableLocation}
+                                    to={`/map?location=${buildingId}`}
+                                    onClick={focusMap}
+                                >
+                                    {bldg}
+                                </Link>
+                                <br />
+                            </Fragment>
+                        );
+                    })
                 ) : (
                     <Box>{meeting.bldg}</Box>
                 );
diff --git a/apps/antalmanac/src/lib/download.ts b/apps/antalmanac/src/lib/download.ts
index e1447895..8d0fe89b 100644
--- a/apps/antalmanac/src/lib/download.ts
+++ b/apps/antalmanac/src/lib/download.ts
@@ -285,7 +285,7 @@ export function getEventsFromCourses(courses = AppStore.schedule.getCurrentCours
                     endOutputType: 'local' as const,
                     title: `${deptCode} ${courseNumber} ${sectionType}`,
                     description: `${courseTitle}\nTaught by ${instructors.join('/')}`,
-                    location: `${meeting.bldg}`,
+                    location: meeting.bldg.join(', '),
                     start: firstClassStart,
                     end: firstClassEnd,
                     recurrenceRule: rrule,
diff --git a/apps/antalmanac/src/stores/calendarizeHelpers.ts b/apps/antalmanac/src/stores/calendarizeHelpers.ts
index 0451af34..1541cea7 100644
--- a/apps/antalmanac/src/stores/calendarizeHelpers.ts
+++ b/apps/antalmanac/src/stores/calendarizeHelpers.ts
@@ -1,6 +1,6 @@
 import { ScheduleCourse } from '@packages/antalmanac-types';
 import { HourMinute } from 'peterportal-api-next-types';
-import { CourseEvent, CustomEvent } from '$components/Calendar/CourseCalendarEvent';
+import { CourseEvent, CustomEvent, Location } from '$components/Calendar/CourseCalendarEvent';
 import { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog';
 import { notNull, getReferencesOccurring } from '$lib/utils';
 
@@ -8,6 +8,11 @@ const COURSE_WEEK_DAYS = ['Su', 'M', 'Tu', 'W', 'Th', 'F', 'Sa'];
 
 const FINALS_WEEK_DAYS = ['Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri'];
 
+export function getLocation(location: string): Location {
+    const [building = '', room = ''] = location.split(' ');
+    return { building, room };
+}
+
 export function calendarizeCourseEvents(currentCourses: ScheduleCourse[] = []): CourseEvent[] {
     return currentCourses.flatMap((course) => {
         return course.section.meetings
@@ -34,19 +39,26 @@ export function calendarizeCourseEvents(currentCourses: ScheduleCourse[] = []):
                     .map((day, index) => (day ? index : undefined))
                     .filter(notNull);
 
+                // Intermediate formatting to subtract `bldg` attribute in favor of `locations`
+                const { bldg: _, ...finalExam } = course.section.finalExam;
+
                 return dayIndicesOccurring.map((dayIndex) => {
                     return {
                         color: course.section.color,
                         term: course.term,
                         title: `${course.deptCode} ${course.courseNumber}`,
                         courseTitle: course.courseTitle,
-                        bldg: meeting.bldg[0],
+                        locations: meeting.bldg.map(getLocation),
+                        showLocationInfo: false,
                         instructors: course.section.instructors,
                         sectionCode: course.section.sectionCode,
                         sectionType: course.section.sectionType,
                         start: new Date(2018, 0, dayIndex, startHour, startMin),
                         end: new Date(2018, 0, dayIndex, endHour, endMin),
-                        finalExam: course.section.finalExam,
+                        finalExam: {
+                            ...finalExam,
+                            locations: course.section.finalExam.bldg?.map(getLocation) ?? [],
+                        },
                         isCustomEvent: false,
                     };
                 });
@@ -64,7 +76,8 @@ export function calendarizeFinals(currentCourses: ScheduleCourse[] = []): Course
                 course.section.finalExam.dayOfWeek
         )
         .flatMap((course) => {
-            const finalExam = course.section.finalExam;
+            const { bldg, ...finalExam } = course.section.finalExam;
+
             const startHour = finalExam.startTime?.hour;
             const startMin = finalExam.startTime?.minute;
             const endHour = finalExam.endTime?.hour;
@@ -84,22 +97,24 @@ export function calendarizeFinals(currentCourses: ScheduleCourse[] = []): Course
              */
             const dayIndicesOcurring = weekdaysOccurring.map((day, index) => (day ? index : undefined)).filter(notNull);
 
-            return dayIndicesOcurring.map((dayIndex) => {
-                return {
-                    color: course.section.color,
-                    term: course.term,
-                    title: `${course.deptCode} ${course.courseNumber}`,
-                    courseTitle: course.courseTitle,
-                    bldg: course.section.meetings[0].bldg[0],
-                    instructors: course.section.instructors,
-                    sectionCode: course.section.sectionCode,
-                    sectionType: 'Fin',
-                    start: new Date(2018, 0, dayIndex - 1, startHour, startMin),
-                    end: new Date(2018, 0, dayIndex - 1, endHour, endMin),
-                    finalExam: course.section.finalExam,
-                    isCustomEvent: false,
-                };
-            });
+            return dayIndicesOcurring.map((dayIndex) => ({
+                color: course.section.color,
+                term: course.term,
+                title: `${course.deptCode} ${course.courseNumber}`,
+                courseTitle: course.courseTitle,
+                locations: bldg ? bldg.map(getLocation) : course.section.meetings[0].bldg.map(getLocation),
+                showLocationInfo: true,
+                instructors: course.section.instructors,
+                sectionCode: course.section.sectionCode,
+                sectionType: 'Fin',
+                start: new Date(2018, 0, dayIndex - 1, startHour, startMin),
+                end: new Date(2018, 0, dayIndex - 1, endHour, endMin),
+                finalExam: {
+                    ...finalExam,
+                    locations: bldg?.map(getLocation) ?? [],
+                },
+                isCustomEvent: false,
+            }));
         });
 }
 


Test patch:
diff --git a/apps/antalmanac/tests/calendarize-helpers.test.ts b/apps/antalmanac/tests/calendarize-helpers.test.ts
index 5b175f9b..7c3bb0c2 100644
--- a/apps/antalmanac/tests/calendarize-helpers.test.ts
+++ b/apps/antalmanac/tests/calendarize-helpers.test.ts
@@ -2,6 +2,7 @@ import { describe, test, expect } from 'vitest';
 import type { Schedule } from '@packages/antalmanac-types';
 import type { RepeatingCustomEvent } from '$components/Calendar/Toolbar/CustomEventDialog/CustomEventDialog';
 import { calendarizeCourseEvents, calendarizeCustomEvents, calendarizeFinals } from '$stores/calendarizeHelpers';
+import type { CourseEvent, CustomEvent } from '$components/Calendar/CourseCalendarEvent';
 
 describe('calendarize-helpers', () => {
     const courses: Schedule['courses'] = [
@@ -66,9 +67,9 @@ describe('calendarize-helpers', () => {
     ];
 
     // 3 of the same event
-    const calendarizedCourses = [
+    const calendarizedCourses: CourseEvent[] = [
         {
-            bldg: undefined,
+            locations: [],
             color: 'placeholderColor',
             term: 'placeholderTerm',
             title: 'placeholderDeptCode placeholderCourseNumber',
@@ -91,12 +92,13 @@ describe('calendarize-helpers', () => {
                     hour: 3,
                     minute: 4,
                 },
-                bldg: [],
+                locations: [],
             },
+            showLocationInfo: false,
             isCustomEvent: false,
         },
         {
-            bldg: undefined,
+            locations: [],
             color: 'placeholderColor',
             term: 'placeholderTerm',
             title: 'placeholderDeptCode placeholderCourseNumber',
@@ -119,12 +121,13 @@ describe('calendarize-helpers', () => {
                     hour: 3,
                     minute: 4,
                 },
-                bldg: [],
+                locations: [],
             },
+            showLocationInfo: false,
             isCustomEvent: false,
         },
         {
-            bldg: undefined,
+            locations: [],
             color: 'placeholderColor',
             term: 'placeholderTerm',
             title: 'placeholderDeptCode placeholderCourseNumber',
@@ -147,15 +150,16 @@ describe('calendarize-helpers', () => {
                     hour: 3,
                     minute: 4,
                 },
-                bldg: [],
+                locations: [],
             },
+            showLocationInfo: false,
             isCustomEvent: false,
         },
     ];
 
-    const calendarizedCourseFinals = [
+    const calendarizedCourseFinals: CourseEvent[] = [
         {
-            bldg: undefined,
+            locations: [],
             color: 'placeholderColor',
             term: 'placeholderTerm',
             title: 'placeholderDeptCode placeholderCourseNumber',
@@ -178,8 +182,9 @@ describe('calendarize-helpers', () => {
                     hour: 3,
                     minute: 4,
                 },
-                bldg: [],
+                locations: [],
             },
+            showLocationInfo: true,
             isCustomEvent: false,
         },
     ];
@@ -195,7 +200,7 @@ describe('calendarize-helpers', () => {
         },
     ];
 
-    const calendarizedCustomEvents = [
+    const calendarizedCustomEvents: CustomEvent[] = [
         {
             isCustomEvent: true,
             customEventID: 0,