* docs: add jsdoc * feat: change enums to as const objects * chore(test): add themeColors.test.ts * fix: fix tests and bugs with strings.ts util * fix: path alias imports and tsconfig file bug * fix: remove --max-warnings 0
225 lines
7.3 KiB
TypeScript
225 lines
7.3 KiB
TypeScript
import type { Course, Semester } from '@shared/types/Course';
|
|
import type { Distribution, LetterGrade } from '@shared/types/Distribution';
|
|
import Card from '@views/components/common/Card/Card';
|
|
import Icon from '@views/components/common/Icon/Icon';
|
|
import Spinner from '@views/components/common/Spinner/Spinner';
|
|
import Text from '@views/components/common/Text/Text';
|
|
import {
|
|
NoDataError,
|
|
queryAggregateDistribution,
|
|
querySemesterDistribution,
|
|
} from '@views/lib/database/queryDistribution';
|
|
import colors from '@views/styles/colors.module.scss';
|
|
import Highcharts from 'highcharts';
|
|
import HighchartsReact from 'highcharts-react-official';
|
|
import React, { useEffect, useRef, useState } from 'react';
|
|
|
|
import styles from './GradeDistribution.module.scss';
|
|
|
|
const DataStatus = {
|
|
LOADING: 'LOADING',
|
|
FOUND: 'FOUND',
|
|
NOT_FOUND: 'NOT_FOUND',
|
|
ERROR: 'ERROR',
|
|
} as const;
|
|
|
|
type DataStatusType = (typeof DataStatus)[keyof typeof DataStatus];
|
|
|
|
interface Props {
|
|
course: Course;
|
|
}
|
|
|
|
const GRADE_COLORS = {
|
|
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,
|
|
} as const satisfies Record<LetterGrade, string>;
|
|
|
|
/**
|
|
* A chart to fetch and display the grade distribution for a course
|
|
* @returns
|
|
*/
|
|
export default function GradeDistribution({ course }: Props) {
|
|
const ref = useRef<HighchartsReact.RefObject>(null);
|
|
const [semesters, setSemesters] = useState<Semester[]>([]);
|
|
const [selectedSemester, setSelectedSemester] = useState<Semester | null>(null);
|
|
const [distribution, setDistribution] = useState<Distribution | null>(null);
|
|
const [status, setStatus] = useState<DataStatusType>(DataStatus.LOADING);
|
|
|
|
const [chartOptions, setChartOptions] = useState<Highcharts.Options>({
|
|
title: {
|
|
text: undefined,
|
|
},
|
|
subtitle: {
|
|
text: undefined,
|
|
},
|
|
legend: {
|
|
enabled: false,
|
|
},
|
|
xAxis: {
|
|
title: {
|
|
text: 'Grades',
|
|
},
|
|
categories: ['A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D+', 'D', 'D-', 'F'],
|
|
crosshair: true,
|
|
},
|
|
yAxis: {
|
|
min: 0,
|
|
title: {
|
|
text: 'Students',
|
|
},
|
|
},
|
|
chart: {
|
|
style: {
|
|
fontFamily: 'Inter',
|
|
fontWeight: '600',
|
|
},
|
|
spacingBottom: 25,
|
|
spacingTop: 25,
|
|
height: 250,
|
|
},
|
|
credits: {
|
|
enabled: false,
|
|
},
|
|
accessibility: {
|
|
enabled: false,
|
|
},
|
|
tooltip: {
|
|
headerFormat: '<span style="font-size:small; font-weight:bold">{point.key}</span><table>',
|
|
pointFormat:
|
|
'<td style="color:{black};padding:0;font-size:small; font-weight:bold;"><b>{point.y:.0f} Students</b></td>',
|
|
footerFormat: '</table>',
|
|
shared: true,
|
|
useHTML: true,
|
|
},
|
|
plotOptions: {
|
|
bar: {
|
|
pointPadding: 0.2,
|
|
borderWidth: 0,
|
|
},
|
|
series: {
|
|
animation: {
|
|
duration: 700,
|
|
},
|
|
},
|
|
},
|
|
series: [
|
|
{
|
|
type: 'column',
|
|
name: 'Grades',
|
|
data: Array.from({ length: 12 }, () => 0),
|
|
},
|
|
],
|
|
});
|
|
|
|
const updateChart = (distribution: Distribution) => {
|
|
setChartOptions(options => ({
|
|
...options,
|
|
series: [
|
|
{
|
|
type: 'column',
|
|
name: 'Grades',
|
|
data: Object.entries(distribution).map(([grade, count]) => ({
|
|
y: count,
|
|
color: GRADE_COLORS[grade as LetterGrade],
|
|
})),
|
|
},
|
|
],
|
|
}));
|
|
window.dispatchEvent(new Event('resize'));
|
|
};
|
|
|
|
useEffect(() => {
|
|
queryAggregateDistribution(course)
|
|
.then(([distribution, semesters]) => {
|
|
setSemesters(semesters);
|
|
updateChart(distribution);
|
|
setStatus(DataStatus.FOUND);
|
|
})
|
|
.catch(err => {
|
|
if (err instanceof NoDataError) {
|
|
return setStatus(DataStatus.NOT_FOUND);
|
|
}
|
|
return setStatus(DataStatus.ERROR);
|
|
});
|
|
}, [course]);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
let distribution: Distribution;
|
|
if (selectedSemester) {
|
|
distribution = await querySemesterDistribution(course, selectedSemester);
|
|
} else {
|
|
[distribution] = await queryAggregateDistribution(course);
|
|
}
|
|
updateChart(distribution);
|
|
setStatus(DataStatus.FOUND);
|
|
})().catch(err => {
|
|
if (err instanceof NoDataError) {
|
|
return setStatus(DataStatus.NOT_FOUND);
|
|
}
|
|
return setStatus(DataStatus.ERROR);
|
|
});
|
|
}, [selectedSemester, course]);
|
|
|
|
const handleSelectSemester = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
|
const index = parseInt(event.target.value, 10);
|
|
if (index === 0) {
|
|
setSelectedSemester(null);
|
|
} else {
|
|
setSelectedSemester(semesters[index - 1]);
|
|
}
|
|
};
|
|
|
|
if (status === DataStatus.FOUND) {
|
|
return (
|
|
<Card className={styles.chartContainer}>
|
|
{semesters.length > 0 && (
|
|
<div className={styles.selectContainer}>
|
|
<select onChange={handleSelectSemester}>
|
|
<option value={0}>Aggregate</option>
|
|
{semesters.map((semester, index) => (
|
|
<option key={semester.season + semester.year} value={index + 1}>
|
|
{semester.season} {semester.year}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<HighchartsReact ref={ref} highcharts={Highcharts} options={chartOptions} />
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card className={styles.textContainer}>
|
|
{status === DataStatus.LOADING && <Spinner />}
|
|
{status === DataStatus.ERROR && (
|
|
<Card className={styles.text}>
|
|
<Text color='speedway_brick' /* size='medium' weight='semi_bold' */>
|
|
There was an error fetching the grade distribution data
|
|
</Text>
|
|
<Icon color='speedway_brick' /* size='large' */ name='sentiment_dissatisfied' />
|
|
</Card>
|
|
)}
|
|
{status === DataStatus.NOT_FOUND && (
|
|
<Card className={styles.text}>
|
|
<Text color='charcoal' /* size='medium' weight='semi_bold' */>
|
|
No grade distribution data was found for this course
|
|
</Text>
|
|
<Icon color='charcoal' /* size='x_large' */ name='search_off' />
|
|
</Card>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|