Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Past Enrollment Popup #820

Merged
merged 14 commits into from
Jan 25, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useState, useEffect, useMemo } from 'react';
import { LineChart, Line, CartesianGrid, ResponsiveContainer, XAxis, YAxis, Tooltip, Legend } from 'recharts';
import { Box, Link, Typography, Skeleton } from '@mui/material';
import EnrollmentHistoryHelper, { EnrollmentHistory } from '$lib/enrollmentHistory';
import { isDarkMode } from '$lib/helpers';

export interface EnrollmentHistoryPopupProps {
department: string;
courseNumber: string;
isMobileScreen: boolean;
}

const EnrollmentHistoryPopup = (props: EnrollmentHistoryPopupProps) => {
const { department, courseNumber, isMobileScreen } = props;
const [loading, setLoading] = useState(true);
const [enrollmentHistory, setEnrollmentHistory] = useState<EnrollmentHistory>();

const graphWidth = useMemo(() => (isMobileScreen ? 250 : 450), [isMobileScreen]);
const graphHeight = useMemo(() => (isMobileScreen ? 175 : 250), [isMobileScreen]);
const popupTitle = useMemo(() => {
return enrollmentHistory
? `${department} ${courseNumber} | ${enrollmentHistory.year} ${
enrollmentHistory.quarter
} | ${enrollmentHistory.instructors.join(', ')}`
: 'No past enrollment data found for this course';
}, [courseNumber, department, enrollmentHistory]);

const encodedDept = useMemo(() => encodeURIComponent(department), [department]);
const axisColor = isDarkMode() ? '#fff' : '#111';
const tooltipDateColor = '#111';
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved

useEffect(() => {
if (!loading) {
return;
}

EnrollmentHistoryHelper.queryEnrollmentHistory(department, courseNumber).then((enrollmentHistory) => {
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved
if (enrollmentHistory) {
setEnrollmentHistory(enrollmentHistory);
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved
}
setLoading(false);
});
}, [loading, department, courseNumber]);

if (loading) {
return (
<Box padding={1}>
<Skeleton variant="text" animation="wave" height={graphHeight} width={graphWidth} />
</Box>
);
}

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

return (
<Box sx={{ padding: '4px' }}>
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved
<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>
);
};

export default EnrollmentHistoryPopup;
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,13 @@ 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}
isMobileScreen={isMobileScreen}
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved
/>
}
/>
</Box>

Expand Down
142 changes: 142 additions & 0 deletions apps/antalmanac/src/lib/enrollmentHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
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
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved
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;
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved
}

class _EnrollmentHistory {
// Each key in the cache will be the department and courseNumber concatenated
enrollmentHistoryCache: Record<string, EnrollmentHistory>;
termShortNames: string[];

constructor() {
this.enrollmentHistoryCache = {};
this.termShortNames = termData.map((term) => term.shortName);
}

queryEnrollmentHistory = async (department: string, courseNumber: string): Promise<EnrollmentHistory | null> => {
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved
const cacheKey = department + courseNumber;
if (cacheKey in this.enrollmentHistoryCache) {
return this.enrollmentHistoryCache[cacheKey];
}
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved

// Query for the enrollment history of all lecture sections that were offered
const queryString = `{
enrollmentHistory(department: "${department}", courseNumber: "${courseNumber}", sectionType: Lec) {
year
quarter
department
courseNumber
dates
totalEnrolledHistory
maxCapacityHistory
waitlistHistory
instructors
}
}`;
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved

const res =
(await queryGraphQL<EnrollmentHistoryGraphQLResponse>(queryString))?.data?.enrollmentHistory ?? null;
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved

if (res && res.length > 0) {
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved
// Before caching and returning the response, we need to do
// some parsing so that we can pass the data into the graph
const parsedEnrollmentHistory = this.parseEnrollmentHistoryResponse(res);

// Sort the enrollment history so that the most recent quarters are
// in the beginning of the array
this.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];
this.enrollmentHistoryCache[cacheKey] = latestEnrollmentHistory;
return latestEnrollmentHistory;
}

return null;
};

parseEnrollmentHistoryResponse = (res: EnrollmentHistoryGraphQL[]): EnrollmentHistory[] => {
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved
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;
};

sortEnrollmentHistory = (enrollmentHistory: EnrollmentHistory[]) => {
Douglas-Hong marked this conversation as resolved.
Show resolved Hide resolved
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 this.termShortNames.indexOf(aTerm) - this.termShortNames.indexOf(bTerm);
});
};
}

const enrollmentHistory = new _EnrollmentHistory();
export default enrollmentHistory;
Loading