From ccc4747c8fe9485642e7453f10cbc3f7ffd00f48 Mon Sep 17 00:00:00 2001 From: Jeremiah <40682211+fiveminus1@users.noreply.github.com> Date: Wed, 27 May 2026 01:49:48 -0700 Subject: [PATCH 01/33] chore: remove pie --- site/src/component/GradeDist/GradeDist.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/site/src/component/GradeDist/GradeDist.tsx b/site/src/component/GradeDist/GradeDist.tsx index d9c9a149e..f118d5412 100644 --- a/site/src/component/GradeDist/GradeDist.tsx +++ b/site/src/component/GradeDist/GradeDist.tsx @@ -1,6 +1,5 @@ import { FC, useState, useEffect, useCallback } from 'react'; import Chart from './Chart'; -import Pie from './Pie'; import './GradeDist.scss'; import { CourseGQLData, ProfessorGQLData } from '../../types/types'; @@ -245,11 +244,6 @@ const GradeDist: FC = (props) => { )} - {((props.minify && chartType == 'pie') || !props.minify) && ( -
- -
- )} ); From fd3cee39b56b73fea56167cf3c902a0c284736f2 Mon Sep 17 00:00:00 2001 From: Jeremiah <40682211+fiveminus1@users.noreply.github.com> Date: Wed, 27 May 2026 02:24:31 -0700 Subject: [PATCH 02/33] feat: common feedback bars --- .../component/GradeDist/CommonFeedback.scss | 23 +++ .../component/GradeDist/CommonFeedback.tsx | 59 +++++++ site/src/component/GradeDist/GradeDist.tsx | 2 + site/src/component/GradeDist/Pie.tsx | 147 ------------------ 4 files changed, 84 insertions(+), 147 deletions(-) create mode 100644 site/src/component/GradeDist/CommonFeedback.scss create mode 100644 site/src/component/GradeDist/CommonFeedback.tsx delete mode 100644 site/src/component/GradeDist/Pie.tsx diff --git a/site/src/component/GradeDist/CommonFeedback.scss b/site/src/component/GradeDist/CommonFeedback.scss new file mode 100644 index 000000000..53fb92a27 --- /dev/null +++ b/site/src/component/GradeDist/CommonFeedback.scss @@ -0,0 +1,23 @@ +.common-feedback { + width: 100%; + + .common-feedback-header { + display: flex; + justify-content: space-between; + } + + .num-reviews { + color: var(--mui-palette-text-secondary); + } + + .common-feedback-bars { + display: flex; + flex-direction: column; + gap: 12px; + + .bar-label { + display: flex; + justify-content: space-between; + } + } +} diff --git a/site/src/component/GradeDist/CommonFeedback.tsx b/site/src/component/GradeDist/CommonFeedback.tsx new file mode 100644 index 000000000..aace3a2a5 --- /dev/null +++ b/site/src/component/GradeDist/CommonFeedback.tsx @@ -0,0 +1,59 @@ +import { ReviewData, ReviewTags } from '@peterportal/types'; +import { FC, useCallback, useEffect, useState } from 'react'; +import trpc from '../../trpc'; +import { CourseGQLData, ProfessorGQLData } from '../../types/types'; +import { LinearProgress } from '@mui/material'; +import './CommonFeedback.scss'; + +interface CommonFeedbackProps { + course?: CourseGQLData; + professor?: ProfessorGQLData; +} + +const CommonFeedback: FC = ({ course, professor }) => { + const [reviews, setReviews] = useState([]); + + const fetchReviews = useCallback(async () => { + const params: { courseId?: string; professorId?: string } = {}; + if (course) params.courseId = course.id; + if (professor) params.professorId = professor.ucinetid; + const data = await trpc.reviews.get.query(params); + setReviews(data); + }, [course, professor]); + + useEffect(() => { + setReviews([]); + fetchReviews(); + }, [fetchReviews]); + + const tagCounts = new Map(); + reviews.forEach((review) => review.tags.forEach((tag) => tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1))); + + const tagStats = Array.from(tagCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([tag, count]) => ({ label: tag, count })); + + const maxCount = tagStats[0]?.count ?? 1; + + return ( +
+
+

Common Feedback

+

{reviews.length} reviews

+
+
+ {tagStats.map(({ label, count }) => ( +
+
+

{label}

+

{count}

+
+ +
+ ))} +
+
+ ); +}; + +export default CommonFeedback; diff --git a/site/src/component/GradeDist/GradeDist.tsx b/site/src/component/GradeDist/GradeDist.tsx index f118d5412..dfc5f059a 100644 --- a/site/src/component/GradeDist/GradeDist.tsx +++ b/site/src/component/GradeDist/GradeDist.tsx @@ -6,6 +6,7 @@ import { CourseGQLData, ProfessorGQLData } from '../../types/types'; import { GradesRaw, QuarterName } from '@peterportal/types'; import trpc from '../../trpc'; import { Autocomplete, MenuItem, Select, TextField } from '@mui/material'; +import CommonFeedback from './CommonFeedback'; interface GradeDistProps { course?: CourseGQLData; @@ -245,6 +246,7 @@ const GradeDist: FC = (props) => { )} + ); } else if (gradeDistData == null) { diff --git a/site/src/component/GradeDist/Pie.tsx b/site/src/component/GradeDist/Pie.tsx deleted file mode 100644 index 88c3c7dec..000000000 --- a/site/src/component/GradeDist/Pie.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { Component } from 'react'; -import { ResponsivePie, PieTooltipProps } from '@nivo/pie'; - -import { GradesRaw } from '@peterportal/types'; -import { getAggregateGradeData } from '../../helpers/gradeDist.ts'; -import ChartTooltip from '../ChartTooltip/ChartTooltip.tsx'; -import { getCssVariable } from '../../helpers/styling.ts'; - -interface Slice { - id: 'A' | 'B' | 'C' | 'D' | 'F' | 'P' | 'NP'; - value: number; - label: string; - color: string; -} - -interface PieProps { - gradeData: GradesRaw; - quarter: string; - professor?: string; - course?: string; -} - -export default class Pie extends Component { - total = 0; - totalPNP = 0; - averageGPA = ''; - averageGrade = ''; - averagePNP = ''; - - getClassData = (): Slice[] => { - const { professor, quarter, course } = this.props; - - const aggregateGradeData = getAggregateGradeData(this.props.gradeData, professor, quarter, course); - this.total = aggregateGradeData.total; - this.totalPNP = aggregateGradeData.totalPNP; - this.averageGPA = aggregateGradeData.averageGPA; - this.averageGrade = aggregateGradeData.averageGrade; - this.averagePNP = aggregateGradeData.averagePNP; - - const pnpData: Slice[] = [ - { - id: 'P', - label: 'P', - value: aggregateGradeData.gradePCount, - color: getCssVariable('--mui-palette-chart-pass'), - }, - { - id: 'NP', - label: 'NP', - value: aggregateGradeData.gradeNPCount, - color: getCssVariable('--mui-palette-chart-noPass'), - }, - ]; - - if (this.totalPNP == this.total) { - return pnpData; - } - - const gradeData: Slice[] = [ - { - id: 'A', - label: 'A', - value: aggregateGradeData.gradeACount, - color: getCssVariable('--mui-palette-chart-blue'), - }, - { - id: 'B', - label: 'B', - value: aggregateGradeData.gradeBCount, - color: getCssVariable('--mui-palette-chart-green'), - }, - { - id: 'C', - label: 'C', - value: aggregateGradeData.gradeCCount, - color: getCssVariable('--mui-palette-chart-yellow'), - }, - { - id: 'D', - label: 'D', - value: aggregateGradeData.gradeDCount, - color: getCssVariable('--mui-palette-chart-orange'), - }, - { - id: 'F', - label: 'F', - value: aggregateGradeData.gradeFCount, - color: getCssVariable('--mui-palette-chart-red'), - }, - ]; - - return gradeData.concat(pnpData).filter((slice) => slice.value !== 0); - }; - - styleTooltip = (props: PieTooltipProps) => { - const gradePercent = ((props.datum.value / this.total) * 100).toFixed(2) + '%'; - return ; - }; - - render() { - const gradeDistribution = this.getClassData(); - return ( -
- - data={gradeDistribution} - margin={{ - top: 50, - bottom: 50, - left: 15, - right: 15, - }} - enableArcLabels={false} - enableArcLinkLabels={false} - innerRadius={0.8} - padAngle={2} - colors={gradeDistribution.map((grade) => grade.color)} - cornerRadius={3} - borderWidth={1} - borderColor={{ from: 'color', modifiers: [['darker', 0.2]] }} - tooltip={this.styleTooltip} - /> -
- {this.totalPNP == this.total ?

Average Grade: {this.averagePNP}

: null} - {this.totalPNP != this.total ? ( -

- Average Grade: {this.averageGrade} ({this.averageGPA}) -

- ) : null} -

- Total Enrolled: {this.total} -

- {this.totalPNP > 0 ? {this.totalPNP} enrolled as P/NP : null} -
-
- ); - } -} From 3324d6db8eeb01952601ef330fdb500eda4a2c38 Mon Sep 17 00:00:00 2001 From: Jeremiah <40682211+fiveminus1@users.noreply.github.com> Date: Wed, 27 May 2026 02:30:25 -0700 Subject: [PATCH 03/33] feat: view more --- site/src/component/GradeDist/CommonFeedback.scss | 7 +++++++ site/src/component/GradeDist/CommonFeedback.tsx | 11 ++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/site/src/component/GradeDist/CommonFeedback.scss b/site/src/component/GradeDist/CommonFeedback.scss index 53fb92a27..bb3401cff 100644 --- a/site/src/component/GradeDist/CommonFeedback.scss +++ b/site/src/component/GradeDist/CommonFeedback.scss @@ -20,4 +20,11 @@ justify-content: space-between; } } + + .view-more-btn { + margin-top: 20px; + color: var(--mui-palette-text-secondary); + cursor: pointer; + text-align: center; + } } diff --git a/site/src/component/GradeDist/CommonFeedback.tsx b/site/src/component/GradeDist/CommonFeedback.tsx index aace3a2a5..df26de2ab 100644 --- a/site/src/component/GradeDist/CommonFeedback.tsx +++ b/site/src/component/GradeDist/CommonFeedback.tsx @@ -4,6 +4,7 @@ import trpc from '../../trpc'; import { CourseGQLData, ProfessorGQLData } from '../../types/types'; import { LinearProgress } from '@mui/material'; import './CommonFeedback.scss'; +import ClickableDiv from '../ClickableDiv/ClickableDiv'; interface CommonFeedbackProps { course?: CourseGQLData; @@ -12,6 +13,7 @@ interface CommonFeedbackProps { const CommonFeedback: FC = ({ course, professor }) => { const [reviews, setReviews] = useState([]); + const [showAll, setShowAll] = useState(false); const fetchReviews = useCallback(async () => { const params: { courseId?: string; professorId?: string } = {}; @@ -35,6 +37,8 @@ const CommonFeedback: FC = ({ course, professor }) => { const maxCount = tagStats[0]?.count ?? 1; + const visibleStats = showAll ? tagStats : tagStats.slice(0, 3); + return (
@@ -42,7 +46,7 @@ const CommonFeedback: FC = ({ course, professor }) => {

{reviews.length} reviews

- {tagStats.map(({ label, count }) => ( + {visibleStats.map(({ label, count }) => (

{label}

@@ -52,6 +56,11 @@ const CommonFeedback: FC = ({ course, professor }) => {
))}
+ {tagStats.length > 3 && ( + setShowAll((prev) => !prev)}> + {showAll ? 'View less' : 'View more'} + + )}
); }; From 21ebf320ae05860e355aa8d2684f7a2c6d878115 Mon Sep 17 00:00:00 2001 From: Jeremiah <40682211+fiveminus1@users.noreply.github.com> Date: Wed, 27 May 2026 03:12:43 -0700 Subject: [PATCH 04/33] refactor: migrate to recharts --- pnpm-lock.yaml | 128 +++++++++++++++++- site/package.json | 3 +- site/src/component/GradeDist/Chart.tsx | 173 +++++++------------------ 3 files changed, 175 insertions(+), 129 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11f2aab43..74f47c79c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,6 +211,9 @@ importers: react-twemoji: specifier: ^0.6.0 version: 0.6.0(react@18.3.1) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react@18.3.14)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.0)(react@18.3.1)(redux@5.0.1) devDependencies: '@next/eslint-plugin-next': specifier: ^15.4.5 @@ -2807,12 +2810,21 @@ packages: '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + '@types/d3-format@1.4.5': resolution: {integrity: sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA==} + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + '@types/d3-path@2.0.4': resolution: {integrity: sha512-jjZVLBjEX4q6xneKMmv62UocaFJFOTQSb/1aTzs3m3ICTOFoVaqGBHpNLm/4dVi0/FTltfBKgmOK1ECj3/gGjA==} @@ -2837,6 +2849,12 @@ packages: '@types/d3-time@2.1.4': resolution: {integrity: sha512-BTfLsxTeo7yFxI/haOOf1ZwJ6xKgQLT9dCp+EcmQv87Gox6X+oKl4mLKfO6fnWm3P22+A6DknMNEZany8ql2Rw==} + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/dotenv-flow@3.3.3': resolution: {integrity: sha512-aJjBsKw4bfGjvaRwrxBtEOfYZxCAq+LiFTpZ4DGTEK2b9eLVt/IAClapSxMfgV4Mi/2bIBKKjoTCO0lOh4ACLg==} @@ -3666,10 +3684,18 @@ packages: d3-array@2.12.1: resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + d3-format@1.4.5: resolution: {integrity: sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==} @@ -3705,6 +3731,14 @@ packages: d3-time@2.1.1: resolution: {integrity: sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ==} + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -3759,6 +3793,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -4043,6 +4080,9 @@ packages: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} + es-toolkit@1.47.0: + resolution: {integrity: sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -5742,6 +5782,14 @@ packages: resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} engines: {node: '>= 14.16.0'} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -5780,6 +5828,9 @@ packages: reselect@5.1.0: resolution: {integrity: sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg==} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -6192,6 +6243,9 @@ packages: tiny-invariant@1.2.0: resolution: {integrity: sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} @@ -6388,6 +6442,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -9624,10 +9681,18 @@ snapshots: '@types/cookie@0.6.0': {} + '@types/d3-array@3.2.2': {} + '@types/d3-color@3.1.3': {} + '@types/d3-ease@3.0.2': {} + '@types/d3-format@1.4.5': {} + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + '@types/d3-path@2.0.4': {} '@types/d3-scale-chromatic@3.1.0': {} @@ -9648,6 +9713,10 @@ snapshots: '@types/d3-time@2.1.4': {} + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/dotenv-flow@3.3.3': {} '@types/express-serve-static-core@5.0.2': @@ -10576,8 +10645,14 @@ snapshots: dependencies: internmap: 1.0.1 + d3-array@3.2.4: + dependencies: + internmap: 1.0.1 + d3-color@3.1.0: {} + d3-ease@3.0.1: {} + d3-format@1.4.5: {} d3-format@2.0.0: {} @@ -10615,6 +10690,12 @@ snapshots: dependencies: d3-array: 2.12.1 + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-urls@5.0.0: @@ -10668,6 +10749,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} dedent@1.6.0(babel-plugin-macros@3.1.0): @@ -10909,6 +10992,8 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 + es-toolkit@1.47.0: {} + esbuild-register@3.6.0(esbuild@0.19.12): dependencies: debug: 4.3.4 @@ -10918,7 +11003,7 @@ snapshots: esbuild-register@3.6.0(esbuild@0.25.9): dependencies: - debug: 4.4.1 + debug: 4.3.4 esbuild: 0.25.9 transitivePeerDependencies: - supports-color @@ -13058,6 +13143,26 @@ snapshots: readdirp@4.0.2: {} + recharts@3.8.1(@types/react@18.3.14)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.0)(react@18.3.1)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.4.0(react-redux@9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1))(react@18.3.1) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.47.0 + eventemitter3: 5.0.1 + immer: 10.1.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 19.2.0 + react-redux: 9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.2.2(react@18.3.1) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -13102,6 +13207,8 @@ snapshots: reselect@5.1.0: {} + reselect@5.1.1: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -13586,6 +13693,8 @@ snapshots: tiny-invariant@1.2.0: {} + tiny-invariant@1.3.3: {} + tldts-core@6.1.86: {} tldts@6.1.86: @@ -13802,6 +13911,23 @@ snapshots: vary@1.1.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.8 + '@types/d3-shape': 3.1.6 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/site/package.json b/site/package.json index 183e99992..cecc864da 100644 --- a/site/package.json +++ b/site/package.json @@ -27,7 +27,8 @@ "react-router-dom": "^7.0.2", "react-sortablejs": "^6.1.4", "react-transition-group": "^4.4.5", - "react-twemoji": "^0.6.0" + "react-twemoji": "^0.6.0", + "recharts": "^3.8.1" }, "scripts": { "start": "next start", diff --git a/site/src/component/GradeDist/Chart.tsx b/site/src/component/GradeDist/Chart.tsx index 6949c7311..ea7814e9f 100644 --- a/site/src/component/GradeDist/Chart.tsx +++ b/site/src/component/GradeDist/Chart.tsx @@ -1,11 +1,10 @@ -import { Component } from 'react'; -import { ResponsiveBar, BarTooltipProps, BarDatum } from '@nivo/bar'; - +import { useContext } from 'react'; +import { BarChart, Bar, XAxis, Tooltip, ResponsiveContainer, LabelList } from 'recharts'; import ThemeContext from '../../style/theme-context'; import { GradesRaw } from '@peterportal/types'; import { getAggregateGradeData } from '../../helpers/gradeDist.ts'; import ChartTooltip from '../ChartTooltip/ChartTooltip.tsx'; -import { getChartTheme, getCssVariable } from '../../helpers/styling.ts'; +import { getCssVariable } from '../../helpers/styling.ts'; interface ChartProps { gradeData: GradesRaw; @@ -14,127 +13,47 @@ interface ChartProps { course?: string; } -export default class Chart extends Component { - /* - * Initialize the grade distribution chart on the webpage. - */ - - /* - * Create an array of objects to feed into the chart. - * @return an array of JSON objects detailing the grades for each class - */ - getClassData = (): BarDatum[] => { - const { professor, quarter, course } = this.props; - - const aggregateGradeData = getAggregateGradeData(this.props.gradeData, professor, quarter, course); - - return [ - { - id: 'A', - label: 'A', - A: aggregateGradeData.gradeACount, - color: getCssVariable('--mui-palette-chart-blue'), - }, - { - id: 'B', - label: 'B', - B: aggregateGradeData.gradeBCount, - color: getCssVariable('--mui-palette-chart-green'), - }, - { - id: 'C', - label: 'C', - C: aggregateGradeData.gradeCCount, - color: getCssVariable('--mui-palette-chart-yellow'), - }, - { - id: 'D', - label: 'D', - D: aggregateGradeData.gradeDCount, - color: getCssVariable('--mui-palette-chart-orange'), - }, - { - id: 'F', - label: 'F', - F: aggregateGradeData.gradeFCount, - color: getCssVariable('--mui-palette-chart-red'), - }, - { - id: 'P', - label: 'P', - P: aggregateGradeData.gradePCount, - color: getCssVariable('--mui-palette-chart-pass'), - }, - { - id: 'NP', - label: 'NP', - NP: aggregateGradeData.gradeNPCount, - color: getCssVariable('--mui-palette-chart-noPass'), - }, - ]; - }; - - /* - * Indicate how the tooltip should look like when users hover over the bar - * Code is slightly modified from: https://codesandbox.io/s/nivo-scatterplot- - * vs-bar-custom-tooltip-7u6qg?file=/src/index.js:1193-1265 - * @param event an event object recording the mouse movement, etc. - * @return a JSX block styling the chart - */ - styleTooltip = (props: BarTooltipProps) => { - return ; - }; - - /* - * Display the grade distribution chart. - * @return a JSX block rendering the chart - */ - render() { - const gradeDistribution = this.getClassData(); - - // greatestCount calculates the upper bound of the graph (i.e. the greatest number of students in a single grade) - const greatestCount = gradeDistribution.reduce( - (max, grade) => ((grade[grade.id] as number) > max ? (grade[grade.id] as number) : max), - 0, - ); - - // The base marginX is 30, with increments of 5 added on for every order of magnitude greater than 100 to accomadate for larger axis labels (1,000, 10,000, etc) - // For example, if greatestCount is 5173 it is (when rounding down (i.e. floor)), one magnitude (calculated with log_10) greater than 100, therefore we add one increment of 5px to our base marginX of 30px - // Math.max() ensures that we're not finding the log of a non-positive number - const marginX = 30 + 5 * Math.floor(Math.log10(Math.max(100, greatestCount) / 100)); - - return ( - <> - - {({ darkMode }) => ( - String(grade.color))} - theme={getChartTheme(darkMode)} - tooltipLabel={(datum) => String(datum.id)} - tooltip={this.styleTooltip} - /> - )} - - - ); - } +const GRADE_COLORS: Record = { + A: '--mui-palette-chart-blue', + B: '--mui-palette-chart-green', + C: '--mui-palette-chart-yellow', + D: '--mui-palette-chart-orange', + F: '--mui-palette-chart-red', + P: '--mui-palette-chart-pass', + NP: '--mui-palette-chart-noPass', +}; + +export default function Chart({ gradeData, quarter, professor, course }: ChartProps) { + const { darkMode } = useContext(ThemeContext); + + const aggregateGradeData = getAggregateGradeData(gradeData, professor, quarter, course); + + const data = [ + { grade: 'A', count: aggregateGradeData.gradeACount, fill: getCssVariable(GRADE_COLORS.A) }, + { grade: 'B', count: aggregateGradeData.gradeBCount, fill: getCssVariable(GRADE_COLORS.B) }, + { grade: 'C', count: aggregateGradeData.gradeCCount, fill: getCssVariable(GRADE_COLORS.C) }, + { grade: 'D', count: aggregateGradeData.gradeDCount, fill: getCssVariable(GRADE_COLORS.D) }, + { grade: 'F', count: aggregateGradeData.gradeFCount, fill: getCssVariable(GRADE_COLORS.F) }, + { grade: 'P', count: aggregateGradeData.gradePCount, fill: getCssVariable(GRADE_COLORS.P) }, + { grade: 'NP', count: aggregateGradeData.gradeNPCount, fill: getCssVariable(GRADE_COLORS.NP) }, + ]; + + return ( + + + + { + if (!active || !payload?.length) return null; + const { grade, count } = payload[0].payload; + return ; + }} + /> + + + + + + ); } From 323a25dcdcd24df7ae2408a0d7354424647115df Mon Sep 17 00:00:00 2001 From: Jeremiah <40682211+fiveminus1@users.noreply.github.com> Date: Wed, 27 May 2026 03:16:01 -0700 Subject: [PATCH 05/33] chore: disable focus ring on chart --- site/src/component/GradeDist/Chart.scss | 10 ++++++++++ site/src/component/GradeDist/Chart.tsx | 1 + 2 files changed, 11 insertions(+) create mode 100644 site/src/component/GradeDist/Chart.scss diff --git a/site/src/component/GradeDist/Chart.scss b/site/src/component/GradeDist/Chart.scss new file mode 100644 index 000000000..30d24277b --- /dev/null +++ b/site/src/component/GradeDist/Chart.scss @@ -0,0 +1,10 @@ +.recharts-wrapper:focus, +.recharts-wrapper *:focus { + outline: none; +} + +/* Keep outline for users navigating with a keyboard (Accessibility) */ +.recharts-wrapper:focus-visible, +.recharts-wrapper *:focus-visible { + outline: revert; +} diff --git a/site/src/component/GradeDist/Chart.tsx b/site/src/component/GradeDist/Chart.tsx index ea7814e9f..f80b0dd14 100644 --- a/site/src/component/GradeDist/Chart.tsx +++ b/site/src/component/GradeDist/Chart.tsx @@ -5,6 +5,7 @@ import { GradesRaw } from '@peterportal/types'; import { getAggregateGradeData } from '../../helpers/gradeDist.ts'; import ChartTooltip from '../ChartTooltip/ChartTooltip.tsx'; import { getCssVariable } from '../../helpers/styling.ts'; +import './Chart.scss'; interface ChartProps { gradeData: GradesRaw; From c5494567dbcd96ed1f546dff72e3b38481233dfb Mon Sep 17 00:00:00 2001 From: Jeremiah <40682211+fiveminus1@users.noreply.github.com> Date: Wed, 27 May 2026 03:19:21 -0700 Subject: [PATCH 06/33] fix: pass props to commonfeedback --- site/src/component/GradeDist/CommonFeedback.tsx | 1 + site/src/component/GradeDist/GradeDist.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/component/GradeDist/CommonFeedback.tsx b/site/src/component/GradeDist/CommonFeedback.tsx index df26de2ab..0062f98b7 100644 --- a/site/src/component/GradeDist/CommonFeedback.tsx +++ b/site/src/component/GradeDist/CommonFeedback.tsx @@ -25,6 +25,7 @@ const CommonFeedback: FC = ({ course, professor }) => { useEffect(() => { setReviews([]); + setShowAll(false); fetchReviews(); }, [fetchReviews]); diff --git a/site/src/component/GradeDist/GradeDist.tsx b/site/src/component/GradeDist/GradeDist.tsx index dfc5f059a..6f5194456 100644 --- a/site/src/component/GradeDist/GradeDist.tsx +++ b/site/src/component/GradeDist/GradeDist.tsx @@ -246,7 +246,7 @@ const GradeDist: FC = (props) => {
)} - + ); } else if (gradeDistData == null) { From cf13a27b5a7ed1e96d0c8e602b87187d02b27477 Mon Sep 17 00:00:00 2001 From: Jeremiah <40682211+fiveminus1@users.noreply.github.com> Date: Wed, 27 May 2026 03:24:36 -0700 Subject: [PATCH 07/33] feat: linearprogress styling --- site/src/component/GradeDist/CommonFeedback.scss | 6 ++++++ site/src/component/GradeDist/CommonFeedback.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/site/src/component/GradeDist/CommonFeedback.scss b/site/src/component/GradeDist/CommonFeedback.scss index bb3401cff..a586f61b1 100644 --- a/site/src/component/GradeDist/CommonFeedback.scss +++ b/site/src/component/GradeDist/CommonFeedback.scss @@ -19,6 +19,12 @@ display: flex; justify-content: space-between; } + + .MuiLinearProgress-root { + height: 10px; + border-radius: 5px; + background-color: var(--mui-palette-overlay-overlay2); + } } .view-more-btn { diff --git a/site/src/component/GradeDist/CommonFeedback.tsx b/site/src/component/GradeDist/CommonFeedback.tsx index 0062f98b7..246554060 100644 --- a/site/src/component/GradeDist/CommonFeedback.tsx +++ b/site/src/component/GradeDist/CommonFeedback.tsx @@ -53,7 +53,7 @@ const CommonFeedback: FC = ({ course, professor }) => {

{label}

{count}

- + ))} From b7c6ed91e77d805aca1021b0c0a9d920fb951fc7 Mon Sep 17 00:00:00 2001 From: Jeremiah <40682211+fiveminus1@users.noreply.github.com> Date: Wed, 27 May 2026 03:32:09 -0700 Subject: [PATCH 08/33] feat: common feedback background styling --- site/src/component/GradeDist/Chart.tsx | 2 +- site/src/component/GradeDist/CommonFeedback.scss | 4 ++++ site/src/component/GradeDist/CommonFeedback.tsx | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/site/src/component/GradeDist/Chart.tsx b/site/src/component/GradeDist/Chart.tsx index f80b0dd14..4bbbbb861 100644 --- a/site/src/component/GradeDist/Chart.tsx +++ b/site/src/component/GradeDist/Chart.tsx @@ -41,7 +41,7 @@ export default function Chart({ gradeData, quarter, professor, course }: ChartPr return ( - + = ({ course, professor }) => { const visibleStats = showAll ? tagStats : tagStats.slice(0, 3); + //@todo: loading skeleton return (
From eb1c37d99849dbf9d7de4904797a01cfd0bc6292 Mon Sep 17 00:00:00 2001 From: Jeremiah <40682211+fiveminus1@users.noreply.github.com> Date: Wed, 27 May 2026 13:37:05 -0700 Subject: [PATCH 09/33] feat: init avggpacard --- site/src/component/GradeDist/GradeDist.scss | 15 +++++++++++ site/src/component/GradeDist/GradeDist.tsx | 29 ++++++++++++++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/site/src/component/GradeDist/GradeDist.scss b/site/src/component/GradeDist/GradeDist.scss index fc173e1b4..f102e96f7 100644 --- a/site/src/component/GradeDist/GradeDist.scss +++ b/site/src/component/GradeDist/GradeDist.scss @@ -81,6 +81,21 @@ } } +.avg-gpa-card { + .avg-gpa { + color: var(--mui-palette-text-secondary); + } + + .grade-row { + display: flex; + justify-content: space-between; + + .letter-grade { + color: var(--mui-palette-text-secondary); + } + } +} + @media only screen and (max-width: 600px) { #chart { flex-direction: column; diff --git a/site/src/component/GradeDist/GradeDist.tsx b/site/src/component/GradeDist/GradeDist.tsx index 6f5194456..838998101 100644 --- a/site/src/component/GradeDist/GradeDist.tsx +++ b/site/src/component/GradeDist/GradeDist.tsx @@ -5,8 +5,9 @@ import './GradeDist.scss'; import { CourseGQLData, ProfessorGQLData } from '../../types/types'; import { GradesRaw, QuarterName } from '@peterportal/types'; import trpc from '../../trpc'; -import { Autocomplete, MenuItem, Select, TextField } from '@mui/material'; +import { Autocomplete, Card, CardContent, MenuItem, Select, TextField, Typography } from '@mui/material'; import CommonFeedback from './CommonFeedback'; +import { getAggregateGradeData } from '../../helpers/gradeDist'; interface GradeDistProps { course?: CourseGQLData; @@ -229,6 +230,29 @@ const GradeDist: FC = (props) => {
); + interface AverageGPACardProps { + gpa: string; + letterGrade: string; + } + + const AverageGPACard: FC = (props) => { + return ( + + + Avg GPA +
+ + {props.gpa} + + + {props.letterGrade} + +
+
+
+ ); + }; + if (gradeDistData?.length) { const graphProps = { gradeData: gradeDistData, @@ -236,9 +260,12 @@ const GradeDist: FC = (props) => { course: currentCourse, professor: currentProf, }; + const aggregateGradeData = getAggregateGradeData(gradeDistData, currentProf, currentQuarter, currentCourse); + return (
{optionsRow} +
{((props.minify && chartType == 'bar') || !props.minify) && (
From 280ebe9b4631e842216d9b3dc7e8b650c94d6ff9 Mon Sep 17 00:00:00 2001 From: Jeremiah <40682211+fiveminus1@users.noreply.github.com> Date: Thu, 28 May 2026 00:20:00 -0700 Subject: [PATCH 10/33] feat: gpa comparison --- site/src/component/GradeDist/GradeDist.scss | 2 +- site/src/component/GradeDist/GradeDist.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/component/GradeDist/GradeDist.scss b/site/src/component/GradeDist/GradeDist.scss index f102e96f7..8dc7b2ec3 100644 --- a/site/src/component/GradeDist/GradeDist.scss +++ b/site/src/component/GradeDist/GradeDist.scss @@ -88,7 +88,7 @@ .grade-row { display: flex; - justify-content: space-between; + gap: 8px; .letter-grade { color: var(--mui-palette-text-secondary); diff --git a/site/src/component/GradeDist/GradeDist.tsx b/site/src/component/GradeDist/GradeDist.tsx index 838998101..64399c3b6 100644 --- a/site/src/component/GradeDist/GradeDist.tsx +++ b/site/src/component/GradeDist/GradeDist.tsx @@ -60,6 +60,8 @@ const GradeDist: FC = (props) => { const [courseEntries, setCourseEntries] = useState(); const [quarterEntries, setQuarterEntries] = useState(); + console.log(currentQuarter); + const fetchGradeData = useCallback(() => { fetchGradeDistData(props) .then(setGradeDistData) From 62bd22e2bf4cb72e6840d82baa6d2d502be2bc41 Mon Sep 17 00:00:00 2001 From: Jeremiah <40682211+fiveminus1@users.noreply.github.com> Date: Thu, 28 May 2026 00:44:32 -0700 Subject: [PATCH 11/33] feat: finished gpa comparison --- site/src/component/GradeDist/GradeDist.scss | 6 ++ site/src/component/GradeDist/GradeDist.tsx | 66 ++++++++++++--------- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/site/src/component/GradeDist/GradeDist.scss b/site/src/component/GradeDist/GradeDist.scss index 8dc7b2ec3..9e0e7b8e1 100644 --- a/site/src/component/GradeDist/GradeDist.scss +++ b/site/src/component/GradeDist/GradeDist.scss @@ -89,11 +89,17 @@ .grade-row { display: flex; gap: 8px; + align-items: center; .letter-grade { color: var(--mui-palette-text-secondary); } } + + .gpa-change-row { + display: flex; + gap: 4px; + } } @media only screen and (max-width: 600px) { diff --git a/site/src/component/GradeDist/GradeDist.tsx b/site/src/component/GradeDist/GradeDist.tsx index 64399c3b6..6d92d5412 100644 --- a/site/src/component/GradeDist/GradeDist.tsx +++ b/site/src/component/GradeDist/GradeDist.tsx @@ -8,6 +8,7 @@ import trpc from '../../trpc'; import { Autocomplete, Card, CardContent, MenuItem, Select, TextField, Typography } from '@mui/material'; import CommonFeedback from './CommonFeedback'; import { getAggregateGradeData } from '../../helpers/gradeDist'; +import { ArrowDownward, ArrowUpward } from '@mui/icons-material'; interface GradeDistProps { course?: CourseGQLData; @@ -53,15 +54,14 @@ const GradeDist: FC = (props) => { const [gradeDistData, setGradeDistData] = useState(); const [chartType, setChartType] = useState('bar'); - const [currentQuarter, setCurrentQuarter] = useState(''); + const [lastQuarter, setLastQuarter] = useState(''); + const [selectedQuarter, setSelectedQuarter] = useState(''); const [currentProf, setCurrentProf] = useState(''); const [profEntries, setProfEntries] = useState(); const [currentCourse, setCurrentCourse] = useState(''); const [courseEntries, setCourseEntries] = useState(); const [quarterEntries, setQuarterEntries] = useState(); - console.log(currentQuarter); - const fetchGradeData = useCallback(() => { fetchGradeDistData(props) .then(setGradeDistData) @@ -159,7 +159,8 @@ const GradeDist: FC = (props) => { } }), ); - setCurrentQuarter(result[0].value); + setSelectedQuarter(result[0].value); + setLastQuarter(result[1].value); }, [currentCourse, currentProf, gradeDistData]); // update list of quarters when new professor/course is chosen @@ -176,7 +177,7 @@ const GradeDist: FC = (props) => { else setCurrentCourse(value!); }; - const selectedQuarterName = quarterEntries?.find((q) => q.value === currentQuarter)?.text ?? 'Quarter'; + const selectedQuarterName = quarterEntries?.find((q) => q.value === selectedQuarter)?.text ?? 'Quarter'; const optionsRow = (
@@ -213,8 +214,8 @@ const GradeDist: FC = (props) => {