diff --git a/apps/backend/src/modules/grade-distribution/controller.ts b/apps/backend/src/modules/grade-distribution/controller.ts index ec376fdef..dfc075f69 100644 --- a/apps/backend/src/modules/grade-distribution/controller.ts +++ b/apps/backend/src/modules/grade-distribution/controller.ts @@ -300,14 +300,18 @@ export const getGradeDistributionByInstructorAndSemester = async ( "class.session.term.name": name, }); + const terms = await TermModel.find({ + name: name, + }); + if (sections.length === 0) throw new Error("No classes found"); const distributions = await GradeDistributionModel.find({ classNumber: { $in: sections.map((section) => section.id) }, + termId: { $in: terms.map((term) => term.id) }, }); - // if (distributions.length === 0) - // throw new Error("No grade distributions found"); + if (distributions.length === 0) throw new Error("No grades found"); const distribution = getDistribution(distributions); diff --git a/apps/backend/src/modules/grade-distribution/typedefs/grade-distribution.ts b/apps/backend/src/modules/grade-distribution/typedefs/grade-distribution.ts index d18835b87..a44d162f5 100644 --- a/apps/backend/src/modules/grade-distribution/typedefs/grade-distribution.ts +++ b/apps/backend/src/modules/grade-distribution/typedefs/grade-distribution.ts @@ -3,7 +3,7 @@ import { gql } from "graphql-tag"; export default gql` type GradeDistribution @cacheControl(maxAge: 1) { average: Float - distribution: [Grade!]! + distribution: [Grade!] } type Grade @cacheControl(maxAge: 1) { diff --git a/apps/frontend/src/app/GradeDistributions/GradeDistributions.module.scss b/apps/frontend/src/app/GradeDistributions/GradeDistributions.module.scss index 763627117..d27051527 100644 --- a/apps/frontend/src/app/GradeDistributions/GradeDistributions.module.scss +++ b/apps/frontend/src/app/GradeDistributions/GradeDistributions.module.scss @@ -15,11 +15,27 @@ flex-direction: column; overflow: auto; gap: 24px; + position: relative; .grid { display: grid; gap: 24px; grid-template-columns: repeat(auto-fit, minmax(384px, 1fr)); } + .legend { + margin-left: 30px; + color: var(--paragraph-color); + } + } + .empty { + position: absolute; + color: var(--label-color); + text-align: center; + font-size: 14px; + left: 0; + right: 0; + top: 25vh; + margin-inline: auto; + width: fit-content; } } diff --git a/apps/frontend/src/app/GradeDistributions/HoverInfo/HoverInfo.module.scss b/apps/frontend/src/app/GradeDistributions/HoverInfo/HoverInfo.module.scss new file mode 100644 index 000000000..d5bf308ee --- /dev/null +++ b/apps/frontend/src/app/GradeDistributions/HoverInfo/HoverInfo.module.scss @@ -0,0 +1,28 @@ +.info { + display: inline-block; + width: 250px; + + .heading { + margin-bottom: 5px; + + .color { + display: inline-block; + width: 12px; + height: 12px; + margin-right: 10px; + } + + .course { + color: var(--heading-color); + font-size: 18px; + } + } + .label { + font-family: Inter, sans-serif; + font-weight: 700; + color: var(--heading-color); + margin-top: 8px; + margin-bottom: 4px; + font-size: 14px; + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/GradeDistributions/HoverInfo/index.tsx b/apps/frontend/src/app/GradeDistributions/HoverInfo/index.tsx new file mode 100644 index 000000000..8b5b79868 --- /dev/null +++ b/apps/frontend/src/app/GradeDistributions/HoverInfo/index.tsx @@ -0,0 +1,168 @@ +import { useMemo } from "react"; + +import { AverageGrade, ColoredGrade } from "@/components/AverageGrade"; +import { useReadCourseGradeDist } from "@/hooks/api"; +import { GradeDistribution, Semester } from "@/lib/api"; + +import styles from "./HoverInfo.module.scss"; + +interface HoverInfoProps { + color: string; + subject: string; + courseNumber: string; + gradeDistribution: GradeDistribution; + givenName?: string; + familyName?: string; + semester?: Semester; + year?: number; + hoveredLetter: string | null; +} + +const GRADE_STYLE = { display: "inline-block", marginRight: "4px" }; +const GRADE_ORDER = [ + "A+", + "A", + "A-", + "B+", + "B", + "B-", + "C+", + "C", + "C-", + "D+", + "D", + "D-", + "F", +]; + +function addOrdinalSuffix(n: string) { + if (n === "11" || n === "12" || n === "13") return n + "th"; + + switch (n.charAt(n.length - 1)) { + case "1": + return n + "st"; + case "2": + return n + "nd"; + case "3": + return n + "rd"; + default: + return n + "th"; + } +} + +export default function HoverInfo({ + color, + subject, + courseNumber, + gradeDistribution, + givenName, + familyName, + semester, + year, + hoveredLetter, +}: HoverInfoProps) { + const { data: courseData } = useReadCourseGradeDist(subject, courseNumber); + + const courseGradeDist = useMemo( + () => courseData?.gradeDistribution ?? null, + [courseData] + ); + + const { + lower: lowerPercentile, + upper: upperPercentile, + count: hoveredCount, + total: gradeDistTotal, + } = useMemo(() => { + const ret: { + lower: string | null; + upper: string | null; + count: number | null; + total: number; + } = { lower: null, upper: null, count: null, total: 0 }; + if (!gradeDistribution || !hoveredLetter) return ret; + ret.total = gradeDistribution.distribution.reduce( + (acc, g) => acc + g.count, + 0 + ); + if (hoveredLetter === "NP" || hoveredLetter === "P") + return { + lower: "N/A", + upper: "N/A", + count: + gradeDistribution.distribution.find((g) => g.letter === hoveredLetter) + ?.count ?? 0, + total: ret.total, + }; + GRADE_ORDER.reduce((acc, grade) => { + if (grade === hoveredLetter) + ret.upper = addOrdinalSuffix( + (((ret.total - acc) * 100) / ret.total).toFixed(0) + ); + const count = + gradeDistribution.distribution.find((g) => g.letter === grade)?.count ?? + 0; + acc += count; + if (grade === hoveredLetter) { + ret.lower = addOrdinalSuffix( + (((ret.total - acc) * 100) / ret.total).toFixed(0) + ); + ret.count = count; + } + return acc; + }, 0); + return ret; + }, [hoveredLetter, gradeDistribution]); + + return ( +
+
+ + + {subject} {courseNumber} + +
+
+ {givenName && familyName + ? `${givenName} ${familyName} ` + : "All Instructors "} + •{semester && year ? ` ${semester} ${year}` : " All Semesters"} +
+
Course Average
+
+ {courseGradeDist ? ( + + + ({gradeDistribution.average?.toFixed(3)}) + + ) : ( + "(...)" + )} +
+
Section Average
+
+ + ({gradeDistribution.average?.toFixed(3)}) +
+ {hoveredLetter && ( +
+
+ {lowerPercentile} - {upperPercentile} Percentile +
+
+ ( + {hoveredCount}/{gradeDistTotal},{" "} + {(((hoveredCount ?? 0) / gradeDistTotal) * 100).toFixed(1)}%) +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/app/GradeDistributions/SideBar/GradesDrawer/GradesDrawer.module.scss b/apps/frontend/src/app/GradeDistributions/SideBar/GradesDrawer/GradesDrawer.module.scss new file mode 100644 index 000000000..6baeca7e5 --- /dev/null +++ b/apps/frontend/src/app/GradeDistributions/SideBar/GradesDrawer/GradesDrawer.module.scss @@ -0,0 +1,45 @@ +.content { + width: 500px; + .header { + padding: 24px 30px; + padding-bottom: 0; + background-color: var(--foreground-color); + border-bottom: 1px solid var(--border-color); + .row { + display: flex; + justify-content: space-between; + margin-bottom: 24px; + } + .heading { + font-size: 24px; + font-weight: 660; + font-feature-settings: + "cv05" on, + "cv13" on, + "ss07" on, + "cv12" on, + "cv06" on; + color: var(--heading-color); + margin-bottom: 8px; + } + .description { + font-size: 16px; + line-height: 1.5; + color: var(--paragraph-color); + margin-bottom: 24px; + } + } + .body { + padding: 20px; + .select-cont { + margin-bottom: 10px; + } + .button { + height: 48px; + padding: 0 16px; + justify-content: space-between; + width: 100%; + margin-bottom: 10px; + } + } +} \ No newline at end of file diff --git a/apps/frontend/src/app/GradeDistributions/SideBar/GradesDrawer/index.tsx b/apps/frontend/src/app/GradeDistributions/SideBar/GradesDrawer/index.tsx new file mode 100644 index 000000000..a75497677 --- /dev/null +++ b/apps/frontend/src/app/GradeDistributions/SideBar/GradesDrawer/index.tsx @@ -0,0 +1,220 @@ +import { useMemo, useState } from "react"; + +import { ArrowLeft, Plus, Xmark } from "iconoir-react"; +import Select, { SingleValue } from "react-select"; + +import { Button, IconButton } from "@repo/theme"; + +import Drawer from "@/components/Drawer"; +import { useReadCourseWithInstructor } from "@/hooks/api"; +import { ICourse } from "@/lib/api"; + +import styles from "./GradesDrawer.module.scss"; + +type OptionType = { + value: string; + label: string; +}; + +interface GradesDrawerProps { + open: boolean; + setOpen: React.Dispatch>; + addCourse: (course: ICourse, term: string, instructor: string) => void; + back: () => void; + course: ICourse; +} + +const DEFAULT_SELECTED_INSTRUCTOR = { value: "all", label: "All Instructors" }; +const DEFAULT_SELECTED_SEMESTER = { value: "all", label: "All Semesters" }; +const DEFAULT_BY_OPTION = { value: "instructor", label: "By Instructor" }; + +const byOptions = [ + { value: "instructor", label: "By Instructor" }, + { value: "semester", label: "By Semester" }, +]; + +export default function GradesDrawer({ + open, + setOpen, + addCourse, + back, + course, +}: GradesDrawerProps) { + const { data: courseData } = useReadCourseWithInstructor( + course.subject, + course.number + ); + + const [byData, setByData] = + useState>(DEFAULT_BY_OPTION); + const [selectedInstructor, setSelectedInstructor] = useState< + SingleValue + >(DEFAULT_SELECTED_INSTRUCTOR); + const [selectedSemester, setSelectedSemester] = useState< + SingleValue + >(DEFAULT_SELECTED_SEMESTER); + + // some crazy cyclic dependencies here, averted by the fact that options changes + // dpeend on the value of the "byData" + const instructorOptions: OptionType[] = useMemo(() => { + const list = [DEFAULT_SELECTED_INSTRUCTOR]; + if (!courseData) return list; + + const mySet = new Set(); + courseData?.classes.forEach((c) => { + if (byData?.value === "semester") { + if (`${c.semester} ${c.year}` !== selectedSemester?.value) return; + } + c.primarySection.meetings.forEach((m) => { + m.instructors.forEach((i) => { + mySet.add(`${i.familyName}, ${i.givenName}`); + }); + }); + }); + courseData?.classes.map((c) => { + return `${c.primarySection.meetings[0]} ${c.year}`; + }); + const opts = [...mySet].map((v) => { + return { value: v as string, label: v as string }; + }); + if (opts.length === 1 && byData?.value === "semester") { + if (selectedInstructor !== opts[0]) setSelectedInstructor(opts[0]); + return opts; + } + return [...list, ...opts]; + }, [courseData, selectedSemester, byData]); + + const semesterOptions: OptionType[] = useMemo(() => { + const list = [DEFAULT_SELECTED_SEMESTER]; + if (!courseData) return list; + const filteredClasses = + byData?.value === "semester" + ? courseData.classes + : selectedInstructor?.value === "all" + ? [] + : courseData.classes.filter((c) => + c.primarySection.meetings.find((m) => + m.instructors.find( + (i) => + selectedInstructor?.value === + `${i.familyName}, ${i.givenName}` + ) + ) + ); + const filteredOptions = Array.from( + new Set(filteredClasses.map((c) => `${c.semester} ${c.year}`)) + ).map((t) => { + return { + value: t, + label: t, + }; + }); + if (filteredOptions.length == 1) { + if (selectedSemester != filteredOptions[0]) + setSelectedSemester(filteredOptions[0]); + return filteredOptions; + } + return [...list, ...filteredOptions]; + }, [courseData, selectedInstructor, byData]); + + return ( + +
+
+ + + + { + setOpen(false); + }} + > + + +
+
+ {courseData.subject} {courseData.number} +
+
{courseData.title}
+
+
+
+ { + setSelectedInstructor(s); + }} + /> + ) : ( + { + setSelectedInstructor(s); + }} + /> + ) : ( +