diff --git a/src/views/components/injected/CoursePopup/CoursePopup.tsx b/src/views/components/injected/CoursePopup/CoursePopup.tsx index 1f41f55e..9ce6796a 100644 --- a/src/views/components/injected/CoursePopup/CoursePopup.tsx +++ b/src/views/components/injected/CoursePopup/CoursePopup.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { Course } from 'src/shared/types/Course'; import { UserSchedule } from 'src/shared/types/UserSchedule'; import CoursePopupDescriptions from './CoursePopupDescriptions'; +import CoursePopupGradeDistribution from './CoursePopupGradeDistribution'; import CoursePopupHeadingAndActions from './CoursePopupHeadingAndActions'; interface CoursePopup2Props { @@ -16,6 +17,7 @@ const CoursePopup = ({ course, activeSchedule, onClose }: CoursePopup2Props) =>
+
); diff --git a/src/views/components/injected/CoursePopup/CoursePopupGradeDistribution.tsx b/src/views/components/injected/CoursePopup/CoursePopupGradeDistribution.tsx index f16656cc..80638ac6 100644 --- a/src/views/components/injected/CoursePopup/CoursePopupGradeDistribution.tsx +++ b/src/views/components/injected/CoursePopup/CoursePopupGradeDistribution.tsx @@ -1,7 +1,203 @@ -interface CoursePopupGradeDistributionProps {} +import Spinner from '@views/components/common/Spinner/Spinner'; +import Text from '@views/components/common/Text/Text'; +import Highcharts from 'highcharts'; +import HighchartsReact from 'highcharts-react-official'; +import React from 'react'; +import { Course } from 'src/shared/types/Course'; +import { Distribution, LetterGrade } from 'src/shared/types/Distribution'; +import { + NoDataError, + queryAggregateDistribution, + querySemesterDistribution, +} from 'src/views/lib/database/queryDistribution'; +import colors from 'src/views/styles/colors.module.scss'; -const CoursePopupGradeDistribution: React.FC< - CoursePopupGradeDistributionProps -> = ({}: CoursePopupGradeDistributionProps) => {}; +interface CoursePopupGradeDistributionProps { + course: Course; +} + +enum DataStatus { + LOADING = 'LOADING', + FOUND = 'FOUND', + NOT_FOUND = 'NOT_FOUND', + ERROR = 'ERROR', +} + +const GRADE_COLORS: Record = { + A: colors.turtle_pond, + 'A-': colors.turtle_pond, + 'B+': colors.cactus, + B: colors.cactus, + 'B-': colors.cactus, + 'C+': colors.sunshine, + C: colors.sunshine, + 'C-': colors.sunshine, + 'D+': colors.tangerine, + D: colors.tangerine, + 'D-': colors.tangerine, + F: colors.speedway_brick, +}; + +interface State { + semester: string; + distributions: Record; + status: DataStatus; + chartData: { y: number; color: string | null }[]; +} + +type Action = + | { type: 'SET_SEMESTER'; semester: string } + | { type: 'SET_DISTRIBUTIONS'; distributions: Record } + | { type: 'SET_STATUS'; status: DataStatus } + | { type: 'SET_CHART_DATA'; chartData: { y: number; color: string | null }[] }; + +const initialState: State = { + semester: 'Aggregate', + distributions: {}, + status: DataStatus.LOADING, + chartData: [], +}; + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'SET_SEMESTER': + return { ...state, semester: action.semester }; + case 'SET_DISTRIBUTIONS': + return { ...state, distributions: action.distributions }; + case 'SET_STATUS': + return { ...state, status: action.status }; + case 'SET_CHART_DATA': + return { ...state, chartData: action.chartData }; + default: + return state; + } +} + +const CoursePopupGradeDistribution: React.FC = ({ course }) => { + const [state, dispatch] = React.useReducer(reducer, initialState); + const ref = React.useRef(null); + + React.useEffect(() => { + const fetchInitialData = async () => { + try { + const [aggregateDist, semesters] = await queryAggregateDistribution(course); + const initialDistributions: Record = { Aggregate: aggregateDist }; + const semesterPromises = semesters.map(semester => querySemesterDistribution(course, semester)); + const semesterDistributions = await Promise.all(semesterPromises); + semesters.forEach((semester, i) => { + initialDistributions[`${semester.season} ${semester.year}`] = semesterDistributions[i]; + }); + dispatch({ type: 'SET_DISTRIBUTIONS', distributions: initialDistributions }); + dispatch({ type: 'SET_STATUS', status: DataStatus.FOUND }); + } catch (e) { + console.error(e); + if (e instanceof NoDataError) { + dispatch({ type: 'SET_STATUS', status: DataStatus.NOT_FOUND }); + } else { + dispatch({ type: 'SET_STATUS', status: DataStatus.ERROR }); + } + } + }; + + fetchInitialData(); + }, [course]); + + React.useEffect(() => { + if (state.status === DataStatus.FOUND && state.distributions[state.semester]) { + const chartData = Object.entries(state.distributions[state.semester]).map(([grade, count]) => ({ + y: count, + color: GRADE_COLORS[grade as LetterGrade], + })); + dispatch({ type: 'SET_CHART_DATA', chartData }); + } + }, [state.distributions, state.semester, state.status]); + + const handleSelectSemester = (event: React.ChangeEvent) => { + dispatch({ type: 'SET_SEMESTER', semester: event.target.value }); + }; + + const chartOptions: Highcharts.Options = { + title: { text: undefined }, + subtitle: { text: undefined }, + legend: { enabled: false }, + xAxis: { + title: { text: 'Grade' }, + categories: ['A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D+', 'D', 'D-', 'F'], + crosshair: true, + }, + yAxis: { + min: 0, + title: { text: 'Number of Students' }, + }, + chart: { + style: { fontFamily: 'Roboto Flex', fontWeight: '600' }, + spacingBottom: 25, + spacingTop: 25, + height: 250, + }, + credits: { enabled: false }, + accessibility: { enabled: true }, + tooltip: { + headerFormat: '{point.key}', + pointFormat: + '', + footerFormat: '
{point.y:.0f} Students
', + shared: true, + useHTML: true, + }, + plotOptions: { + bar: { pointPadding: 0.2, borderWidth: 0 }, + series: { animation: { duration: 700 } }, + }, + series: [ + { + type: 'column', + name: 'Grades', + data: state.chartData, + }, + ], + }; + + return ( +
+ {state.status === DataStatus.LOADING && } + {state.status === DataStatus.NOT_FOUND && No grade distribution data found} + {state.status === DataStatus.ERROR && Error fetching grade distribution data} + {state.status === DataStatus.FOUND && ( + <> +
+ Grade distribution for {`${course.department} ${course.number}`} + +
+ + + )} +
+ ); +}; export default CoursePopupGradeDistribution;