Skip to content

Commit 39aa5c7

Browse files
authored
Merge pull request #1 from ultraviolet/main
Add GPA Widget + minor fix
2 parents 4e88018 + da5706b commit 39aa5c7

10 files changed

Lines changed: 260 additions & 2 deletions

File tree

‎.github/workflows/deploy.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ jobs:
2626
node-version: 22
2727
- name: Install Dependencies
2828
run: npm ci
29+
- name: Fetch GPA Data
30+
run: npm run fetch-gpa
2931
- name: Build Quartz
3032
run: npx quartz build
3133
- name: Upload artifact

‎.gitignore‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules
2+
public
3+
package-lock.json
4+
quartz/.quartz-cache
5+
quartz/static/gpa-processed.json
6+
data

‎content/classes/math464.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ last-modified: 2025-11-18
44
tags:
55
- probability
66
- cross-listed
7+
course: STAT 410
78
---
89

910
# Statistics and Probability II (STAT410/MATH464)
@@ -48,6 +49,8 @@ Mathematics and Computer Science, BS (Group I)
4849

4950
Statistics, BS
5051

52+
Statistics and Computer Science, BS
53+
5154
Electrical Engineering, BS
5255

5356
Computer Engineering, BS
@@ -70,4 +73,4 @@ This class can be substituted for STAT420 for both the math minor, stat
7073
minor and the data optimization concentration for the mathematics major.
7174

7275
### Recent Professors
73-
Alexey Stepanov, Susu Zhang, Feng Liang, Eren Kizildag, Joshua Agterberg, Yun Yang
76+
Alexey Stepanov, Susu Zhang, Feng Liang, Eren Kizildag, Joshua Agterberg, Yun Yang

‎package-lock.json‎

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
"check": "tsc --noEmit && npx prettier . --check",
1818
"format": "npx prettier . --write",
1919
"test": "tsx --test",
20-
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1"
20+
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1",
21+
"fetch-gpa": "node scripts/fetch-gpa-data.cjs",
22+
"prebuild": "npm run fetch-gpa"
2123
},
2224
"engines": {
2325
"npm": ">=10.9.2",
@@ -44,6 +46,7 @@
4446
"async-mutex": "^0.5.0",
4547
"chokidar": "^4.0.3",
4648
"cli-spinner": "^0.2.10",
49+
"csv-parser": "^3.0.0",
4750
"d3": "^7.9.0",
4851
"esbuild-sass-plugin": "^3.3.1",
4952
"flexsearch": "^0.8.205",

‎quartz.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { QuartzConfig } from "./quartz/cfg"
22
import * as Plugin from "./quartz/plugins"
3+
import GPAWidget from "./quartz/components/GPAWidget"
34

45
/**
56
* Quartz 4 Configuration

‎quartz.layout.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { PageLayout, SharedLayout } from "./quartz/cfg"
22
import * as Component from "./quartz/components"
3+
import GPAWidget from "./quartz/components/GPAWidget"
34

45
// components shared across all pages
56
export const sharedPageComponents: SharedLayout = {
@@ -22,12 +23,16 @@ export const defaultContentPageLayout: PageLayout = {
2223
condition: (page) => page.fileData.slug !== "index",
2324
}),
2425
Component.ArticleTitle(),
26+
2527
Component.ContentMeta(),
2628
Component.TagList(),
29+
GPAWidget(),
30+
2731
],
2832
left: [
2933
Component.PageTitle(),
3034
Component.MobileOnly(Component.Spacer()),
35+
3136
Component.Flex({
3237
components: [
3338
{
@@ -52,6 +57,7 @@ export const defaultListPageLayout: PageLayout = {
5257
left: [
5358
Component.PageTitle(),
5459
Component.MobileOnly(Component.Spacer()),
60+
5561
Component.Flex({
5662
components: [
5763
{

‎quartz/components/GPAWidget.tsx‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types"
2+
import gpaLoader from "./scripts/gpa-loader.inline"
3+
4+
export default (() => {
5+
const GPAWidget: QuartzComponent = ({ fileData }: QuartzComponentProps) => {
6+
const course = fileData.frontmatter?.course
7+
8+
if (!course) return null
9+
10+
return (
11+
<details class="gpa-widget">
12+
<summary>📊 Course Statistics</summary>
13+
<div id="gpa-data" data-course={course}>
14+
loading... ^-^
15+
</div>
16+
</details>
17+
)
18+
}
19+
20+
GPAWidget.afterDOMLoaded = gpaLoader
21+
22+
// not certified web designer
23+
GPAWidget.css = `
24+
.gpa-widget {
25+
background: var(--lightgray);
26+
border-radius: 8px;
27+
padding: 0.5rem 1rem;
28+
margin: 1rem 0;
29+
}
30+
.gpa-widget summary {
31+
cursor: pointer;
32+
font-weight: 600;
33+
padding: 0.5rem 0;
34+
}
35+
.gpa-widget summary:hover {
36+
color: var(--secondary);
37+
}
38+
#gpa-data {
39+
padding-top: 0.5rem;
40+
}
41+
.gpa-stats {
42+
display: grid;
43+
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
44+
gap: 0.5rem;
45+
margin-top: 0.5rem;
46+
}
47+
.gpa-stat {
48+
background: var(--light);
49+
padding: 0.5rem;
50+
border-radius: 4px;
51+
}
52+
.gpa-stat-value {
53+
font-size: 1.5rem;
54+
font-weight: bold;
55+
color: var(--secondary);
56+
}
57+
`
58+
59+
return GPAWidget
60+
}) satisfies QuartzComponentConstructor
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
document.addEventListener("nav", async () => {
2+
const gpaEl = document.getElementById("gpa-data")
3+
if (!gpaEl) return
4+
5+
const course = gpaEl.dataset.course
6+
if (!course) return
7+
8+
try {
9+
const response = await fetch('../static/gpa-processed.json')
10+
const data = await response.json()
11+
const courseData = data[course]
12+
13+
// unfortunately this doesnt handle class reskins very well, inherent weakness with the data is that e.g. ECE374 == CS374
14+
// but the data does not have duplicate so you have to use whatever name is the "official" course
15+
if (!courseData) {
16+
gpaEl.innerHTML = `<p>GPA data unavailable for ${course} :(</p>`
17+
return
18+
}
19+
20+
const recentSemesters = courseData.semesters.slice(0, 5)
21+
22+
gpaEl.innerHTML = `
23+
<div class="gpa-stats">
24+
<div class="gpa-stat">
25+
<div class="gpa-stat-label">Average GPA (recent)</div>
26+
<div class="gpa-stat-value">${courseData.avgGPA}</div>
27+
</div>
28+
<div class="gpa-stat">
29+
<div class="gpa-stat-label">4.0 Percentage</div>
30+
<div class="gpa-stat-value">${courseData.avgRate}%</div>
31+
</div>
32+
</div>
33+
<details style="margin-top: 1rem;">
34+
<summary>Recent Semesters</summary>
35+
<table style="width: 100%; margin-top: 0.5rem;">
36+
<thead>
37+
<tr>
38+
<th>Term</th>
39+
<th>GPA</th>
40+
<th>4.0 %</th>
41+
<th>Instructor</th>
42+
</tr>
43+
</thead>
44+
<tbody>
45+
${recentSemesters.map((s: any) => `
46+
<tr>
47+
<td>${s.term} ${s.year}</td>
48+
<td>${s.gpa}</td>
49+
<td>${s.rate}%</td>
50+
<td>${s.instructor}</td>
51+
</tr>
52+
`).join('')}
53+
</tbody>
54+
</table>
55+
</details>
56+
`
57+
} catch (e) {
58+
gpaEl.innerHTML = '<p>An error occurred whilst loading GPA data :(</p>'
59+
}
60+
})

‎scripts/fetch-gpa-data.cjs‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const fs = require('fs');
2+
const https = require('https');
3+
const csv = require('csv-parser');
4+
5+
const GPA_URL = 'https://raw.githubusercontent.com/wadefagen/datasets/master/gpa/uiuc-gpa-dataset.csv';
6+
7+
function download() {
8+
return new Promise((resolve, reject) => {
9+
const file = fs.createWriteStream('data/gpa-raw.csv');
10+
https.get(GPA_URL, res => {
11+
res.pipe(file);
12+
file.on('finish', () => {
13+
file.close();
14+
resolve();
15+
});
16+
}).on('error', reject);
17+
});
18+
}
19+
20+
function calculateGPA(row) {
21+
// as defined by UIUC
22+
// for some reason theres an average gpa in each term but not overall so i have to recalc?? what the shibal??
23+
const gradePoints = {
24+
'A+': 4.0, 'A': 4.0, 'A-': 3.67,
25+
'B+': 3.33, 'B': 3.0, 'B-': 2.67,
26+
'C+': 2.33, 'C': 2.0, 'C-': 1.67,
27+
'D+': 1.33, 'D': 1.0, 'D-': 0.67, 'F': 0.0
28+
};
29+
30+
let points = 0, students = 0;
31+
32+
for (const [grade, value] of Object.entries(gradePoints)) {
33+
const count = parseInt(row[grade]) || 0;
34+
points += count * value;
35+
students += count;
36+
}
37+
38+
return students > 0 ? (points / students).toFixed(2) : null;
39+
}
40+
41+
function calcRate(row) {
42+
const aPlus = parseInt(row['A+']) || 0;
43+
const a = parseInt(row['A']) || 0;
44+
const total = Object.keys(row).filter(k =>
45+
['A+','A','A-','B+','B','B-','C+','C','C-','D+','D','D-','F'].includes(k)
46+
).reduce((sum, grade) => sum + (parseInt(row[grade]) || 0), 0);
47+
48+
return total > 0 ? ((aPlus + a) / total * 100).toFixed(1) : null;
49+
}
50+
51+
async function process() {
52+
const courses = {};
53+
const currentYear = new Date().getFullYear();
54+
const threeYearsAgo = currentYear - 3;
55+
56+
await new Promise(resolve => {
57+
fs.createReadStream('data/gpa-raw.csv')
58+
.pipe(csv())
59+
.on('data', row => {
60+
const id = `${row.Subject} ${row.Number}`;
61+
62+
if (!courses[id]) {
63+
courses[id] = {
64+
subject: row.Subject,
65+
number: row.Number,
66+
title: row['Course Title'],
67+
semesters: []
68+
};
69+
}
70+
71+
courses[id].semesters.push({
72+
year: parseInt(row.Year),
73+
term: row.Term,
74+
gpa: calculateGPA(row),
75+
rate: calcRate(row),
76+
instructor: row['Primary Instructor'],
77+
students: row.Students
78+
});
79+
})
80+
.on('end', resolve);
81+
});
82+
83+
// calc averages (last 3 years only) and 4.0 rate
84+
for (const course of Object.values(courses)) {
85+
const recentSemesters = course.semesters.filter(s => s.year >= threeYearsAgo);
86+
87+
const gpas = recentSemesters.map(s => parseFloat(s.gpa)).filter(g => !isNaN(g));
88+
course.avgGPA = gpas.length ? (gpas.reduce((a, b) => a + b) / gpas.length).toFixed(2) : null;
89+
90+
const rates = recentSemesters.map(s => parseFloat(s.rate)).filter(r => !isNaN(r));
91+
course.avgRate = rates.length ? (rates.reduce((a, b) => a + b) / rates.length).toFixed(1) : null;
92+
}
93+
94+
// ok i think this is how quartz works?
95+
if (!fs.existsSync('quartz/static')) fs.mkdirSync('quartz/static', { recursive: true });
96+
fs.writeFileSync('quartz/static/gpa-processed.json', JSON.stringify(courses, null, 2));
97+
}
98+
99+
(async () => {
100+
if (!fs.existsSync('data')) fs.mkdirSync('data');
101+
await download();
102+
await process();
103+
console.log('done');
104+
})();

0 commit comments

Comments
 (0)