diff --git a/public/database/grade_distributions.db b/public/database/grade_distributions.db new file mode 100644 index 000000000..4b929cc94 Binary files /dev/null and b/public/database/grade_distributions.db differ diff --git a/public/database/grades.db b/public/database/grades.db deleted file mode 100644 index 8048327dd..000000000 Binary files a/public/database/grades.db and /dev/null differ diff --git a/src/manifest.ts b/src/manifest.ts index 5e25b899b..f4da2c401 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -56,6 +56,9 @@ const manifest = defineManifest(async () => ({ matches: ['*://*/*'], }, ], + content_security_policy: { + extension_pages: "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", + }, })); export default manifest; diff --git a/src/shared/types/Distribution.ts b/src/shared/types/Distribution.ts index 9468e8ecd..f9e87022c 100644 --- a/src/shared/types/Distribution.ts +++ b/src/shared/types/Distribution.ts @@ -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 */ -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, @@ -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. */ export type CourseSQLRow = { - sem?: string; - prof?: string; - dept?: string; - course_nbr?: string; - course_name?: string; - a1?: number; - a2?: number; - a3?: number; - b1?: number; - b2?: number; - b3?: number; - c1?: number; - c2?: number; - c3?: number; - d1?: number; - d2?: number; - d3?: number; - f?: number; - semesters?: string; + Semester: string; + Section: number; + Department: string; + Department_Code: string; + Course_Number: string; + Course_Title: string; + Course_Full_Title: string; + A: number; + A_Minus: number; + B_Plus: number; + B: number; + B_Minus: number; + C_Plus: number; + C: number; + C_Minus: number; + D_Plus: number; + D: number; + D_Minus: number; + F: number; + Other: number; }; diff --git a/src/shared/types/ThemeColors.ts b/src/shared/types/ThemeColors.ts index 1e8eb155d..318f8fac4 100644 --- a/src/shared/types/ThemeColors.ts +++ b/src/shared/types/ThemeColors.ts @@ -38,6 +38,7 @@ export const extendedColors = { d: '#DC2626', dminus: '#B91C1C', f: '#B91C1C', + other: '#6B7280', }, } as const; diff --git a/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx b/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx index 1bb26766b..e808b5dbc 100644 --- a/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx +++ b/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx @@ -10,7 +10,8 @@ import { } from '@views/lib/database/queryDistribution'; import Highcharts from 'highcharts'; 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 { course: Course; @@ -22,6 +23,7 @@ const DataStatus = { NOT_FOUND: 'NOT_FOUND', ERROR: 'ERROR', } as const satisfies Record; + type DataStatusType = (typeof DataStatus)[keyof typeof DataStatus]; const GRADE_COLORS = { @@ -37,6 +39,7 @@ const GRADE_COLORS = { D: extendedColors.gradeDistribution.d, 'D-': extendedColors.gradeDistribution.dminus, F: extendedColors.gradeDistribution.f, + Other: extendedColors.gradeDistribution.other, } as const satisfies Record; /** @@ -48,48 +51,55 @@ const GRADE_COLORS = { * @returns {JSX.Element} The grade distribution chart component. */ export default function GradeDistribution({ course }: GradeDistributionProps): JSX.Element { - const [semester, setSemester] = React.useState('Aggregate'); - const [distributions, setDistributions] = React.useState>({}); - const [status, setStatus] = React.useState(DataStatus.LOADING); - const ref = React.useRef(null); - - // const chartData = React.useMemo(() => { - // if (status === DataStatus.FOUND && distributions[semester]) { - // return Object.entries(distributions[semester]).map(([grade, count]) => ({ - // y: count, - // color: GRADE_COLORS[grade as LetterGrade], - // })); - // } - // return Array(12).fill(0); - // }, [distributions, semester, status]); - // const chartData: unknown[] = []; - - // 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]; - // }); - // 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: React.ChangeEvent) => { + const [semester, setSemester] = useState('Aggregate'); + 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]!).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] = await queryAggregateDistribution(course); + const initialDistributions: Record = { Aggregate: aggregateDist }; + 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') { + 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) => { setSemester(event.target.value); }; @@ -116,7 +126,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J 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, tickWidth: 1.5, tickLength: 10, @@ -168,7 +178,7 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J { type: 'column', name: 'Grades', - // data: chartData, + data: chartData, }, ], }; @@ -197,8 +207,8 @@ export default function GradeDistribution({ course }: GradeDistributionProps): J Grade Distribution for {course.department} {course.number} - {/* */} + diff --git a/src/views/lib/database/initializeDB.ts b/src/views/lib/database/initializeDB.ts index c7cd7a8f1..96159edf1 100644 --- a/src/views/lib/database/initializeDB.ts +++ b/src/views/lib/database/initializeDB.ts @@ -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 WASM_FILE_URL from 'sql.js/dist/sql-wasm.wasm?url'; // import WASM_FILE_URL from '../../../../public/database/sql-wasm.wasm?url'; diff --git a/src/views/lib/database/queryDistribution.ts b/src/views/lib/database/queryDistribution.ts index 9bed96673..6406e1ba6 100644 --- a/src/views/lib/database/queryDistribution.ts +++ b/src/views/lib/database/queryDistribution.ts @@ -3,6 +3,14 @@ import type { CourseSQLRow, Distribution } from '@shared/types/Distribution'; 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 * @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); } - let row: Required = {} as Required; - res.columns.forEach((col, i) => { - row[col as keyof CourseSQLRow] = res.values[0]![i]! as never; - }); + const row: Required = {} as Required; + for (let i = 0; i < res.columns.length; i++) { + 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 = { - A: row.a2, - 'A-': row.a3, - 'B+': row.b1, - B: row.b2, - 'B-': row.b3, - 'C+': row.c1, - C: row.c2, - 'C-': row.c3, - 'D+': row.d1, - D: row.d2, - 'D-': row.d3, - F: row.f, + A: row.A, + 'A-': row.A_Minus, + 'B+': row.B_Plus, + B: row.B, + 'B-': row.B_Minus, + 'C+': row.C_Plus, + C: row.C, + 'C-': row.C_Minus, + 'D+': row.D_Plus, + D: row.D, + 'D-': row.D_Minus, + F: row.F, + Other: row.Other, }; - // the db file for some reason has duplicate semesters, so we use a set to remove duplicates - const rawSemesters = new Set(); - row.semesters.split(',').forEach((sem: string) => { - rawSemesters.add(sem); - }); + // get unique semesters from the data + const rawSemesters = res.values.reduce((acc, cur) => acc.add(cur[0] as string), new Set()); const semesters: Semester[] = []; @@ -64,15 +89,15 @@ export async function queryAggregateDistribution(course: Course): Promise<[Distr * @returns a SQL query 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 = ` - select * from ${semester ? 'grades' : 'agg'} - where dept like '%${course.department}%' - ${profName ? `and prof like '%${profName}%'` : ''} - and course_nbr like '%${course.number}%' - ${semester ? `and sem like '%${semester.season} ${semester.year}%'` : ''} - order by a1+a2+a3+b1+b2+b3+c1+c2+c3+d1+d2+d3+f desc + select * from ${semester ? `grade_distributions_${semester.year + yearDelta}_${semester.year + yearDelta + 1}` : `(select * from ${allTables.join(' union all select * from ')})`} + where Department_Code = '${course.department}' + and Course_Number = '${course.number}' + ${semester ? `and Semester = '${semester.season} ${semester.year}'` : ''} `; 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; }); - const distribution: Distribution = { - A: row.a2, - 'A-': row.a3, - 'B+': row.b1, - B: row.b2, - 'B-': row.b3, - 'C+': row.c1, - C: row.c2, - 'C-': row.c3, - 'D+': row.d1, - D: row.d2, - 'D-': row.d3, - F: row.f, + return { + A: row.A, + 'A-': row.A_Minus, + 'B+': row.B_Plus, + B: row.B, + 'B-': row.B_Minus, + 'C+': row.C_Plus, + C: row.C, + 'C-': row.C_Minus, + 'D+': row.D_Plus, + D: row.D, + 'D-': row.D_Minus, + F: row.F, + Other: row.Other, }; - - return distribution; } /**