import type { Course } from '@shared/types/Course'; import type { Distribution, LetterGrade } from '@shared/types/Distribution'; import { extendedColors } from '@shared/types/ThemeColors'; import Link from '@views/components/common/Link'; import Text from '@views/components/common/Text/Text'; import { NoDataError, queryAggregateDistribution, querySemesterDistribution, } from '@views/lib/database/queryDistribution'; import Highcharts from 'highcharts'; import HighchartsReact from 'highcharts-react-official'; import type { ChangeEvent } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import Skeleton from 'react-loading-skeleton'; const UT_GRADE_DISTRIBUTION_URL = 'https://reports.utexas.edu/spotlight-data/ut-course-grade-distributions'; interface GradeDistributionProps { course: Course; } const DataStatus = { LOADING: 'LOADING', FOUND: 'FOUND', NOT_FOUND: 'NOT_FOUND', ERROR: 'ERROR', } as const satisfies Record; type DataStatusType = (typeof DataStatus)[keyof typeof DataStatus]; const GRADE_COLORS = { A: extendedColors.gradeDistribution.a, 'A-': extendedColors.gradeDistribution.aminus, 'B+': extendedColors.gradeDistribution.bplus, B: extendedColors.gradeDistribution.b, 'B-': extendedColors.gradeDistribution.bminus, 'C+': extendedColors.gradeDistribution.cplus, C: extendedColors.gradeDistribution.c, 'C-': extendedColors.gradeDistribution.cminus, 'D+': extendedColors.gradeDistribution.dplus, D: extendedColors.gradeDistribution.d, 'D-': extendedColors.gradeDistribution.dminus, F: extendedColors.gradeDistribution.f, Other: extendedColors.gradeDistribution.other, } as const satisfies Record; /** * Renders the grade distribution chart for a specific course. * * @param course - The course for which to display the grade distribution. * @returns The grade distribution chart component. */ export default function GradeDistribution({ course }: GradeDistributionProps): JSX.Element { const [semester, setSemester] = useState('Aggregate'); type Distributions = Record; const [distributions, setDistributions] = useState({}); const [status, setStatus] = useState(DataStatus.LOADING); const ref = useRef(null); const chartData = useMemo(() => { if (status === DataStatus.FOUND && distributions[semester]) { return Object.entries(distributions[semester]!.data).map(([grade, count]) => ({ y: count, color: GRADE_COLORS[grade as LetterGrade], })); } return Array(13).fill(0); }, [distributions, semester, status]); useEffect(() => { const fetchInitialData = async () => { try { const [aggregateDist, semesters, instructorIncludedAggregate] = await queryAggregateDistribution(course); const initialDistributions: Distributions = { Aggregate: { data: aggregateDist, instructorIncluded: instructorIncludedAggregate }, }; const semesterPromises = semesters.map(semester => querySemesterDistribution(course, semester)); const semesterDistributions = await Promise.allSettled(semesterPromises); semesters.forEach((semester, i) => { const distributionResult = semesterDistributions[i]; if (!distributionResult) { throw new Error('Distribution result is undefined'); } if (distributionResult.status === 'fulfilled') { const [distribution, instructorIncluded] = distributionResult.value; initialDistributions[`${semester.season} ${semester.year}`] = { data: distribution, instructorIncluded, }; } }); setDistributions(initialDistributions); setStatus(DataStatus.FOUND); } catch (e) { console.error(e); if (e instanceof NoDataError) { setStatus(DataStatus.NOT_FOUND); } else { setStatus(DataStatus.ERROR); } } }; fetchInitialData(); }, [course]); const handleSelectSemester = (event: ChangeEvent) => { setSemester(event.target.value); }; const chartOptions: Highcharts.Options = { title: { text: undefined }, subtitle: { text: undefined }, legend: { enabled: false }, xAxis: { labels: { y: 20, style: { fontSize: '0.6875rem', fontWeight: '500', letterSpacing: '0', lineHeight: 'normal', fontStyle: 'normal', }, }, title: { text: 'Grades', style: { color: '#333F48', fontSize: '0.80rem', fontWeight: '400', }, }, categories: ['A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D+', 'D', 'D-', 'F', 'Other'], tickInterval: 1, tickWidth: 1, tickLength: 10, tickColor: '#9CADB7', crosshair: { color: `${extendedColors.theme.offwhite}50` }, lineColor: '#9CADB7', }, yAxis: { labels: { style: { fontSize: '0.80rem', fontWeight: '400', color: '#333F48', lineHeight: '100%', fontStyle: 'normal', }, }, min: 0, title: { text: 'Students', style: { color: '#333F48', fontSize: '0.80rem', fontWeight: '400', }, }, }, chart: { style: { fontFamily: 'Roboto Flex, Roboto Flex Local', fontWeight: '600' }, spacingBottom: 25, spacingTop: 25, spacingLeft: 1.5, height: 250, }, credits: { enabled: false }, accessibility: { enabled: true }, tooltip: { headerFormat: '{point.key}', pointFormat: '{point.y:.0f} Students', shared: true, useHTML: true, style: { color: 'var(--Other-Colors-UTRP-Black, #1A2024)', textAlign: 'center', fontFamily: 'Roboto Flex, Roboto Flex Local', fontSize: '0.88875rem', lineHeight: 'normal', }, backgroundColor: 'white', borderRadius: 4, shadow: { offsetX: 0, offsetY: 1, color: 'rgba(51, 63, 72, 0.30)', }, }, plotOptions: { bar: { pointPadding: 0.2, borderWidth: 0 }, series: { animation: { duration: 700 } }, }, series: [ { type: 'column', name: 'Grades', data: chartData, }, ], }; return (
{status === DataStatus.LOADING && } {status === DataStatus.NOT_FOUND && ( )} {status === DataStatus.ERROR && Error fetching grade distribution data} {status === DataStatus.FOUND && ( <>
Grade Distribution for{' '} {course.department} {course.getNumberWithoutTerm()} Data Source
{distributions[semester] && !distributions[semester]!.instructorIncluded && (
We couldn't find {semester !== 'Aggregate' && ` ${semester}`} grades for this instructor, so here are the grades for all {course.department}{' '} {course.getNumberWithoutTerm()} sections.
)} )}
); }