-
Notifications
You must be signed in to change notification settings - Fork 75
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Co-authored-by: Minh Nguyen <[email protected]>
- Loading branch information
1 parent
7608839
commit 38945af
Showing
3 changed files
with
269 additions
and
1 deletion.
There are no files selected for viewing
102 changes: 102 additions & 0 deletions
102
apps/antalmanac/src/components/RightPane/SectionTable/EnrollmentHistoryPopup.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
); | ||
}); | ||
} | ||
} |