feat: add new db powered by UT_Grade_Parser (#163)

* feat: add new db powered by UT_Grade_Parser

* Merge branch 'main' of https://github.com/Longhorn-Developers/UT-Registration-Plus into feature/update-db

* feat: update db

* feat: update db handlers and types

Co-authored-by: Samuel Gunter <sgunter@utexas.edu>

* fix: type errors

* fix: add Other to grade dist

* fix: db with proper insertion order

* Merge branch 'main' of https://github.com/Longhorn-Developers/UT-Registration-Plus into feature/update-db

* chore: address PR comments

Co-Authored-By: Samuel Gunter <sgunter@utexas.edu>
This commit is contained in:
doprz
2024-03-24 00:21:18 -05:00
committed by GitHub
parent ee2b7c40b9
commit 60d1f48bd9
8 changed files with 152 additions and 113 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -56,6 +56,9 @@ const manifest = defineManifest(async () => ({
matches: ['*://*/*'], matches: ['*://*/*'],
}, },
], ],
content_security_policy: {
extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
},
})); }));
export default manifest; export default manifest;

View File

@@ -1,10 +1,7 @@
import { PointOptionsObject } from 'highcharts';
import { Semester } from './Course';
/** /**
* Each of the possible letter grades that can be given in a course * Each of the possible letter grades that can be given in a course
*/ */
export type LetterGrade = 'A' | 'A-' | 'B' | 'B+' | 'B-' | 'C' | 'C+' | 'C-' | 'D' | 'D+' | 'D-' | 'F'; export type LetterGrade = 'A' | 'A-' | 'B' | 'B+' | 'B-' | 'C' | 'C+' | 'C-' | 'D' | 'D+' | 'D-' | 'F' | 'Other';
/** /**
* A distribution of grades for a course, * A distribution of grades for a course,
@@ -18,23 +15,24 @@ export type Distribution = {
* This is a object-ified version of a row in the SQL table that is used to store the distribution data. * This is a object-ified version of a row in the SQL table that is used to store the distribution data.
*/ */
export type CourseSQLRow = { export type CourseSQLRow = {
sem?: string; Semester: string;
prof?: string; Section: number;
dept?: string; Department: string;
course_nbr?: string; Department_Code: string;
course_name?: string; Course_Number: string;
a1?: number; Course_Title: string;
a2?: number; Course_Full_Title: string;
a3?: number; A: number;
b1?: number; A_Minus: number;
b2?: number; B_Plus: number;
b3?: number; B: number;
c1?: number; B_Minus: number;
c2?: number; C_Plus: number;
c3?: number; C: number;
d1?: number; C_Minus: number;
d2?: number; D_Plus: number;
d3?: number; D: number;
f?: number; D_Minus: number;
semesters?: string; F: number;
Other: number;
}; };

View File

@@ -38,6 +38,7 @@ export const extendedColors = {
d: '#DC2626', d: '#DC2626',
dminus: '#B91C1C', dminus: '#B91C1C',
f: '#B91C1C', f: '#B91C1C',
other: '#6B7280',
}, },
} as const; } as const;

View File

@@ -10,7 +10,8 @@ import {
} from '@views/lib/database/queryDistribution'; } from '@views/lib/database/queryDistribution';
import Highcharts from 'highcharts'; import Highcharts from 'highcharts';
import HighchartsReact from 'highcharts-react-official'; import HighchartsReact from 'highcharts-react-official';
import React from 'react'; import type { ChangeEvent } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
interface GradeDistributionProps { interface GradeDistributionProps {
course: Course; course: Course;
@@ -22,6 +23,7 @@ const DataStatus = {
NOT_FOUND: 'NOT_FOUND', NOT_FOUND: 'NOT_FOUND',
ERROR: 'ERROR', ERROR: 'ERROR',
} as const satisfies Record<string, string>; } as const satisfies Record<string, string>;
type DataStatusType = (typeof DataStatus)[keyof typeof DataStatus]; type DataStatusType = (typeof DataStatus)[keyof typeof DataStatus];
const GRADE_COLORS = { const GRADE_COLORS = {
@@ -37,6 +39,7 @@ const GRADE_COLORS = {
D: extendedColors.gradeDistribution.d, D: extendedColors.gradeDistribution.d,
'D-': extendedColors.gradeDistribution.dminus, 'D-': extendedColors.gradeDistribution.dminus,
F: extendedColors.gradeDistribution.f, F: extendedColors.gradeDistribution.f,
Other: extendedColors.gradeDistribution.other,
} as const satisfies Record<LetterGrade, string>; } as const satisfies Record<LetterGrade, string>;
/** /**
@@ -48,48 +51,55 @@ const GRADE_COLORS = {
* @returns {JSX.Element} The grade distribution chart component. * @returns {JSX.Element} The grade distribution chart component.
*/ */
export default function GradeDistribution({ course }: GradeDistributionProps): JSX.Element { export default function GradeDistribution({ course }: GradeDistributionProps): JSX.Element {
const [semester, setSemester] = React.useState('Aggregate'); const [semester, setSemester] = useState('Aggregate');
const [distributions, setDistributions] = React.useState<Record<string, Distribution>>({}); const [distributions, setDistributions] = useState<Record<string, Distribution>>({});
const [status, setStatus] = React.useState<DataStatusType>(DataStatus.LOADING); const [status, setStatus] = useState<DataStatusType>(DataStatus.LOADING);
const ref = React.useRef<HighchartsReact.RefObject>(null); const ref = useRef<HighchartsReact.RefObject>(null);
// const chartData = React.useMemo(() => { const chartData = useMemo(() => {
// if (status === DataStatus.FOUND && distributions[semester]) { if (status === DataStatus.FOUND && distributions[semester]) {
// return Object.entries(distributions[semester]).map(([grade, count]) => ({ return Object.entries(distributions[semester]!).map(([grade, count]) => ({
// y: count, y: count,
// color: GRADE_COLORS[grade as LetterGrade], color: GRADE_COLORS[grade as LetterGrade],
// })); }));
// } }
// return Array(12).fill(0); return Array(13).fill(0);
// }, [distributions, semester, status]); }, [distributions, semester, status]);
// const chartData: unknown[] = [];
// React.useEffect(() => { useEffect(() => {
// const fetchInitialData = async () => { const fetchInitialData = async () => {
// try { try {
// const [aggregateDist, semesters] = await queryAggregateDistribution(course); const [aggregateDist, semesters] = await queryAggregateDistribution(course);
// const initialDistributions: Record<string, Distribution> = { Aggregate: aggregateDist }; const initialDistributions: Record<string, Distribution> = { Aggregate: aggregateDist };
// const semesterPromises = semesters.map(semester => querySemesterDistribution(course, semester)); const semesterPromises = semesters.map(semester => querySemesterDistribution(course, semester));
// const semesterDistributions = await Promise.all(semesterPromises); const semesterDistributions = await Promise.allSettled(semesterPromises);
// semesters.forEach((semester, i) => { semesters.forEach((semester, i) => {
// initialDistributions[`${semester.season} ${semester.year}`] = semesterDistributions[i]; const distributionResult = semesterDistributions[i];
// });
// setDistributions(initialDistributions);
// setStatus(DataStatus.FOUND);
// } catch (e) {
// console.error(e);
// if (e instanceof NoDataError) {
// setStatus(DataStatus.NOT_FOUND);
// } else {
// setStatus(DataStatus.ERROR);
// }
// }
// };
// fetchInitialData(); if (!distributionResult) {
// }, [course]); throw new Error('Distribution result is undefined');
}
const handleSelectSemester = (event: React.ChangeEvent<HTMLSelectElement>) => { if (distributionResult.status === 'fulfilled') {
initialDistributions[`${semester.season} ${semester.year}`] = distributionResult.value;
}
});
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<HTMLSelectElement>) => {
setSemester(event.target.value); setSemester(event.target.value);
}; };
@@ -116,7 +126,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
fontWeight: '400', fontWeight: '400',
}, },
}, },
categories: ['A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D+', 'D', 'D-', 'F'], categories: ['A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D+', 'D', 'D-', 'F', 'Other'],
tickInterval: 1, tickInterval: 1,
tickWidth: 1.5, tickWidth: 1.5,
tickLength: 10, tickLength: 10,
@@ -168,7 +178,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
{ {
type: 'column', type: 'column',
name: 'Grades', name: 'Grades',
// data: chartData, data: chartData,
}, },
], ],
}; };
@@ -197,8 +207,8 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
<Text variant='small'> <Text variant='small'>
Grade Distribution for {course.department} {course.number} Grade Distribution for {course.department} {course.number}
</Text> </Text>
{/* <select <select
className='flex items-center py-1 px-1 gap-1 border border rounded border-solid' className='border border rounded-[4px] border-solid px-[12px] py-[8px]'
onChange={handleSelectSemester} onChange={handleSelectSemester}
> >
{Object.keys(distributions) {Object.keys(distributions)
@@ -209,19 +219,22 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J
if (k2 === 'Aggregate') { if (k2 === 'Aggregate') {
return 1; return 1;
} }
const [season1, year1] = k1.split(' '); const [season1, year1] = k1.split(' ');
const [, year2] = k2.split(' '); const [, year2] = k2.split(' ');
if (year1 !== year2) { if (year1 !== year2) {
return parseInt(year2, 10) - parseInt(year1, 10); return parseInt(year2 as string, 10) - parseInt(year1 as string, 10);
} }
return season1 === 'Fall' ? 1 : -1;
return season1 === 'Fall' ? -1 : 1;
}) })
.map(semester => ( .map(semester => (
<option key={semester} value={semester}> <option key={semester} value={semester}>
{semester} {semester}
</option> </option>
))} ))}
</select> */} </select>
</div> </div>
<HighchartsReact ref={ref} highcharts={Highcharts} options={chartOptions} /> <HighchartsReact ref={ref} highcharts={Highcharts} options={chartOptions} />
</> </>

View File

@@ -1,4 +1,4 @@
import DB_FILE_URL from '@public/database/grades.db?url'; import DB_FILE_URL from '@public/database/grade_distributions.db?url';
import initSqlJs from 'sql.js/dist/sql-wasm'; import initSqlJs from 'sql.js/dist/sql-wasm';
import WASM_FILE_URL from 'sql.js/dist/sql-wasm.wasm?url'; import WASM_FILE_URL from 'sql.js/dist/sql-wasm.wasm?url';
// import WASM_FILE_URL from '../../../../public/database/sql-wasm.wasm?url'; // import WASM_FILE_URL from '../../../../public/database/sql-wasm.wasm?url';

View File

@@ -3,6 +3,14 @@ import type { CourseSQLRow, Distribution } from '@shared/types/Distribution';
import { initializeDB } from './initializeDB'; import { initializeDB } from './initializeDB';
// TODO: in the future let's maybe refactor this to be reactive to the items in the db rather than being explicit
const allTables = [
'grade_distributions_2019_2020',
'grade_distributions_2020_2021',
'grade_distributions_2021_2022',
'grade_distributions_2022_2023',
] as const;
/** /**
* fetches the aggregate distribution of grades for a given course from the course db, and the semesters that we have data for * fetches the aggregate distribution of grades for a given course from the course db, and the semesters that we have data for
* @param course the course to fetch the distribution for * @param course the course to fetch the distribution for
@@ -18,31 +26,48 @@ export async function queryAggregateDistribution(course: Course): Promise<[Distr
throw new NoDataError(course); throw new NoDataError(course);
} }
let row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>; const row: Required<CourseSQLRow> = {} as Required<CourseSQLRow>;
res.columns.forEach((col, i) => { for (let i = 0; i < res.columns.length; i++) {
row[col as keyof CourseSQLRow] = res.values[0]![i]! as never; const col = res.columns[i] as keyof CourseSQLRow;
}); switch (col) {
case 'A':
case 'A_Minus':
case 'B_Plus':
case 'B':
case 'B_Minus':
case 'C_Plus':
case 'C':
case 'C_Minus':
case 'D_Plus':
case 'D':
case 'D_Minus':
case 'F':
case 'Other':
row[col] = res.values.reduce((acc, cur) => acc + (cur[i] as number), 0) as never;
break;
default:
row[col] = res.columns[i]![0]! as never;
}
}
const distribution: Distribution = { const distribution: Distribution = {
A: row.a2, A: row.A,
'A-': row.a3, 'A-': row.A_Minus,
'B+': row.b1, 'B+': row.B_Plus,
B: row.b2, B: row.B,
'B-': row.b3, 'B-': row.B_Minus,
'C+': row.c1, 'C+': row.C_Plus,
C: row.c2, C: row.C,
'C-': row.c3, 'C-': row.C_Minus,
'D+': row.d1, 'D+': row.D_Plus,
D: row.d2, D: row.D,
'D-': row.d3, 'D-': row.D_Minus,
F: row.f, F: row.F,
Other: row.Other,
}; };
// the db file for some reason has duplicate semesters, so we use a set to remove duplicates // get unique semesters from the data
const rawSemesters = new Set<string>(); const rawSemesters = res.values.reduce((acc, cur) => acc.add(cur[0] as string), new Set<string>());
row.semesters.split(',').forEach((sem: string) => {
rawSemesters.add(sem);
});
const semesters: Semester[] = []; const semesters: Semester[] = [];
@@ -64,15 +89,15 @@ export async function queryAggregateDistribution(course: Course): Promise<[Distr
* @returns a SQL query string * @returns a SQL query string
*/ */
function generateQuery(course: Course, semester: Semester | null): string { function generateQuery(course: Course, semester: Semester | null): string {
const profName = course.instructors[0]?.fullName; // const profName = course.instructors[0]?.fullName;
// eslint-disable-next-line no-nested-ternary
const yearDelta = semester ? (semester.season === 'Fall' ? 0 : -1) : 0;
const query = ` const query = `
select * from ${semester ? 'grades' : 'agg'} select * from ${semester ? `grade_distributions_${semester.year + yearDelta}_${semester.year + yearDelta + 1}` : `(select * from ${allTables.join(' union all select * from ')})`}
where dept like '%${course.department}%' where Department_Code = '${course.department}'
${profName ? `and prof like '%${profName}%'` : ''} and Course_Number = '${course.number}'
and course_nbr like '%${course.number}%' ${semester ? `and Semester = '${semester.season} ${semester.year}'` : ''}
${semester ? `and sem like '%${semester.season} ${semester.year}%'` : ''}
order by a1+a2+a3+b1+b2+b3+c1+c2+c3+d1+d2+d3+f desc
`; `;
return query; return query;
@@ -98,22 +123,21 @@ export async function querySemesterDistribution(course: Course, semester: Semest
row[col as keyof CourseSQLRow] = res.values[0]![i]! as never; row[col as keyof CourseSQLRow] = res.values[0]![i]! as never;
}); });
const distribution: Distribution = { return {
A: row.a2, A: row.A,
'A-': row.a3, 'A-': row.A_Minus,
'B+': row.b1, 'B+': row.B_Plus,
B: row.b2, B: row.B,
'B-': row.b3, 'B-': row.B_Minus,
'C+': row.c1, 'C+': row.C_Plus,
C: row.c2, C: row.C,
'C-': row.c3, 'C-': row.C_Minus,
'D+': row.d1, 'D+': row.D_Plus,
D: row.d2, D: row.D,
'D-': row.d3, 'D-': row.D_Minus,
F: row.f, F: row.F,
Other: row.Other,
}; };
return distribution;
} }
/** /**