diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/EnrollmentHistoryPopup.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/EnrollmentHistoryPopup.tsx new file mode 100644 index 000000000..4a475aac7 --- /dev/null +++ b/apps/antalmanac/src/components/RightPane/SectionTable/EnrollmentHistoryPopup.tsx @@ -0,0 +1,102 @@ +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 { MOBILE_BREAKPOINT } from '../../../globals'; +import { DepartmentEnrollmentHistory, EnrollmentHistory } from '$lib/enrollmentHistory'; +import { isDarkMode } from '$lib/helpers'; + +export interface EnrollmentHistoryPopupProps { + department: string; + courseNumber: string; +} + +export function EnrollmentHistoryPopup({ department, courseNumber }: EnrollmentHistoryPopupProps) { + const [loading, setLoading] = useState(true); + const [enrollmentHistory, setEnrollmentHistory] = useState(); + const isMobileScreen = useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT})`); + + const deptEnrollmentHistory = useMemo(() => new DepartmentEnrollmentHistory(department), [department]); + + const graphWidth = useMemo(() => (isMobileScreen ? 250 : 450), [isMobileScreen]); + const graphHeight = useMemo(() => (isMobileScreen ? 175 : 250), [isMobileScreen]); + const popupTitle = useMemo(() => { + if (enrollmentHistory == null) { + return 'No past enrollment data found for this course'; + } + + return `${department} ${courseNumber} | ${enrollmentHistory.year} ${ + enrollmentHistory.quarter + } | ${enrollmentHistory.instructors.join(', ')}`; + }, [courseNumber, department, enrollmentHistory]); + + const encodedDept = useMemo(() => encodeURIComponent(department), [department]); + const axisColor = isDarkMode() ? '#fff' : '#111'; + const tooltipDateColor = '#111'; + + useEffect(() => { + if (!loading) { + return; + } + + deptEnrollmentHistory.find(courseNumber).then((data) => { + if (data) { + setEnrollmentHistory(data); + } + setLoading(false); + }); + }, [loading, deptEnrollmentHistory, courseNumber]); + + if (loading) { + return ( + + + + ); + } + + if (enrollmentHistory == null) { + return ( + + + {popupTitle} + + + ); + } + + return ( + + + {popupTitle} + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx index 9edb16f82..6119c66e7 100644 --- a/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx +++ b/apps/antalmanac/src/components/RightPane/SectionTable/SectionTable.tsx @@ -17,6 +17,7 @@ import { GlobalStyles } from '@mui/material'; import { MOBILE_BREAKPOINT } from '../../../globals'; import CourseInfoBar from './CourseInfoBar'; import CourseInfoButton from './CourseInfoButton'; +import { EnrollmentHistoryPopup } from './EnrollmentHistoryPopup'; import GradesPopup from './GradesPopup'; import { SectionTableProps } from './SectionTable.types'; import SectionTableBody from './SectionTableBody'; @@ -168,7 +169,12 @@ function SectionTable(props: SectionTableProps) { analyticsAction={analyticsEnum.classSearch.actions.CLICK_PAST_ENROLLMENT} text="Past Enrollment" icon={} - redirectLink={`https://zot-tracker.herokuapp.com/?dept=${encodedDept}&number=${courseDetails.courseNumber}&courseType=all`} + popupContent={ + + } /> diff --git a/apps/antalmanac/src/lib/enrollmentHistory.ts b/apps/antalmanac/src/lib/enrollmentHistory.ts new file mode 100644 index 000000000..bea9c8477 --- /dev/null +++ b/apps/antalmanac/src/lib/enrollmentHistory.ts @@ -0,0 +1,160 @@ +import { queryGraphQL } from './helpers'; +import { termData } from './termData'; + +// This represents the enrollment history of a course section during one quarter +export interface EnrollmentHistoryGraphQL { + year: string; + quarter: string; + department: string; + courseNumber: string; + dates: string[]; + totalEnrolledHistory: string[]; + maxCapacityHistory: string[]; + waitlistHistory: string[]; + instructors: string[]; +} + +export interface EnrollmentHistoryGraphQLResponse { + data: { + enrollmentHistory: EnrollmentHistoryGraphQL[]; + }; +} + +/** + * To organize the data and make it easier to graph the enrollment + * data, we can merge the dates, totalEnrolledHistory, maxCapacityHistory, + * and waitlistHistory arrays into one array that contains the enrollment data + * for each day + */ +export interface EnrollmentHistory { + year: string; + quarter: string; + department: string; + courseNumber: string; + days: EnrollmentHistoryDay[]; + instructors: string[]; +} + +export interface EnrollmentHistoryDay { + date: string; + totalEnrolled: number; + maxCapacity: number; + waitlist: number | null; +} + +export class DepartmentEnrollmentHistory { + // Each key in the cache will be the department and courseNumber concatenated + static enrollmentHistoryCache: Record = {}; + static termShortNames: string[] = termData.map((term) => term.shortName); + static QUERY_TEMPLATE = `{ + enrollmentHistory(department: "$$DEPARTMENT$$", courseNumber: "$$COURSE_NUMBER$$", sectionType: Lec) { + year + quarter + department + courseNumber + dates + totalEnrolledHistory + maxCapacityHistory + waitlistHistory + instructors + } + }`; + + department: string; + partialQueryString: string; + + constructor(department: string) { + this.department = department; + this.partialQueryString = DepartmentEnrollmentHistory.QUERY_TEMPLATE.replace('$$DEPARTMENT$$', department); + } + + async find(courseNumber: string): Promise { + const cacheKey = this.department + courseNumber; + return (DepartmentEnrollmentHistory.enrollmentHistoryCache[cacheKey] ??= await this.queryEnrollmentHistory( + courseNumber + )); + } + + async queryEnrollmentHistory(courseNumber: string): Promise { + // Query for the enrollment history of all lecture sections that were offered + const queryString = this.partialQueryString.replace('$$COURSE_NUMBER$$', courseNumber); + + const res = (await queryGraphQL(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; + } + + return null; + } + + /** + * Parses enrollment history data from PeterPortal so that + * we can pass the data into a recharts graph. For each element in the given + * array, merge the dates, totalEnrolledHistory, maxCapacityHistory, + * and waitlistHistory arrays into one array that contains the enrollment data + * for each day. + * + * @param res Array of enrollment histories from PeterPortal + * @returns Array of enrollment histories that we can use for the graph + */ + static parseEnrollmentHistoryResponse(res: EnrollmentHistoryGraphQL[]): EnrollmentHistory[] { + const parsedEnrollmentHistory: EnrollmentHistory[] = []; + + for (const enrollmentHistory of res) { + const enrollmentDays: EnrollmentHistoryDay[] = []; + + for (const [i, date] of enrollmentHistory.dates.entries()) { + const d = new Date(date); + const formattedDate = `${d.getMonth() + 1}/${d.getDate() + 1}/${d.getFullYear()}`; + + enrollmentDays.push({ + date: formattedDate, + totalEnrolled: Number(enrollmentHistory.totalEnrolledHistory[i]), + maxCapacity: Number(enrollmentHistory.maxCapacityHistory[i]), + waitlist: + enrollmentHistory.waitlistHistory[i] === 'n/a' + ? null + : Number(enrollmentHistory.waitlistHistory[i]), + }); + } + + parsedEnrollmentHistory.push({ + year: enrollmentHistory.year, + quarter: enrollmentHistory.quarter, + department: enrollmentHistory.department, + courseNumber: enrollmentHistory.courseNumber, + days: enrollmentDays, + instructors: enrollmentHistory.instructors, + }); + } + + return parsedEnrollmentHistory; + } + + /** + * Sorts the given array of enrollment histories so that + * the most recent 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 + */ + static sortEnrollmentHistory(enrollmentHistory: EnrollmentHistory[]) { + enrollmentHistory.sort((a, b) => { + 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 + return ( + DepartmentEnrollmentHistory.termShortNames.indexOf(aTerm) - + DepartmentEnrollmentHistory.termShortNames.indexOf(bTerm) + ); + }); + } +}