Skip to content

Commit

Permalink
Add Past Enrollment Popup (#820)
Browse files Browse the repository at this point in the history
Co-authored-by: Minh Nguyen <[email protected]>
  • Loading branch information
Douglas-Hong and MinhxNguyen7 committed Jan 26, 2024
1 parent 7608839 commit 38945af
Show file tree
Hide file tree
Showing 3 changed files with 269 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -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<EnrollmentHistory>();
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 (
<Box padding={1}>
<Skeleton variant="text" animation="wave" height={graphHeight} width={graphWidth} />
</Box>
);
}

if (enrollmentHistory == null) {
return (
<Box padding={1}>
<Typography variant="body1" align="center">
{popupTitle}
</Typography>
</Box>
);
}

return (
<Box sx={{ padding: 0.5 }}>
<Typography
sx={{
marginTop: '.5rem',
textAlign: 'center',
fontWeight: 500,
marginRight: '2rem',
marginLeft: '2rem',
marginBottom: '.5rem',
}}
>
{popupTitle}
</Typography>
<Link
href={`https://zot-tracker.herokuapp.com/?dept=${encodedDept}&number=${courseNumber}&courseType=all`}
target="_blank"
rel="noopener noreferrer"
sx={{ display: 'flex', height: graphHeight, width: graphWidth }}
>
<ResponsiveContainer width="95%" height="95%">
<LineChart data={enrollmentHistory.days} 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 }} />
<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 }} />
<Line type="monotone" dataKey="waitlist" stroke="#ffc658" name="Waitlist" dot={{ r: 2 }} />
</LineChart>
</ResponsiveContainer>
</Link>
</Box>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -168,7 +169,12 @@ function SectionTable(props: SectionTableProps) {
analyticsAction={analyticsEnum.classSearch.actions.CLICK_PAST_ENROLLMENT}
text="Past Enrollment"
icon={<ShowChartIcon />}
redirectLink={`https://zot-tracker.herokuapp.com/?dept=${encodedDept}&number=${courseDetails.courseNumber}&courseType=all`}
popupContent={
<EnrollmentHistoryPopup
department={courseDetails.deptCode}
courseNumber={courseDetails.courseNumber}
/>
}
/>
</Box>

Expand Down
160 changes: 160 additions & 0 deletions apps/antalmanac/src/lib/enrollmentHistory.ts
Original file line number Diff line number Diff line change
@@ -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<string, EnrollmentHistory | null> = {};
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<EnrollmentHistory | null> {
const cacheKey = this.department + courseNumber;
return (DepartmentEnrollmentHistory.enrollmentHistoryCache[cacheKey] ??= await this.queryEnrollmentHistory(
courseNumber
));
}

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;
}

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)
);
});
}
}

0 comments on commit 38945af

Please sign in to comment.