Grading a stack of tests is repetitive: count the misses, convert to a percentage, look up the letter, repeat. It's a nice small project because the logic is tiny, but a few details decide whether the tool feels good to use.
In this tutorial we'll build a grader in one HTML file with no dependencies. You set the question count once, tap a button (or press a key) for each wrong answer, and the percentage and letter update instantly.
1. The grading function
Keep the logic in one pure function, separate from the page:
const DEFAULT_SCALE = [
{ letter: 'A', min: 90 },
{ letter: 'B', min: 80 },
{ letter: 'C', min: 70 },
{ letter: 'D', min: 60 },
];
function gradeTest(total, wrong, scale = DEFAULT_SCALE) {
if (!Number.isInteger(total) || total <= 0) {
throw new RangeError('total must be a positive integer');
}
const missed = Math.min(Math.max(wrong, 0), total);
const correct = total - missed;
const sorted = [...scale].sort((a, b) => b.min - a.min);
const hit = sorted.find(({ min }) => correct * 100 >= min * total);
return {
correct,
percent: (correct * 100) / total,
letter: hit ? hit.letter : 'F',
};
}
Three choices are worth explaining:
-
Clamping.
wrongis forced into the range0..total, so a stray extra tap can never produce a negative score. -
Cross-multiplying. Instead of testing
correct / total * 100 >= min, the code testscorrect * 100 >= min * total. That stays in integers, so a score sitting exactly on a cutoff is never decided by floating-point rounding. When I compared the two approaches for tests of up to 200 questions, the division version happened to agree everywhere, but the integer version means you never have to wonder. It works as long as your cutoffs are whole numbers. - Sorting the scale. Sorting from the highest cutoff down means a custom scale works even if someone enters it out of order.
2. The page
<label>Questions <input id="total" type="number" min="1" value="20"></label>
<p class="result"><span id="percent">100%</span> <span id="letter">A</span></p>
<p>Wrong: <span id="wrong">0</span></p>
<div class="controls">
<button id="add">+1 wrong</button>
<button id="undo">Undo</button>
<button id="reset">Reset</button>
<p><small>Keys: W +1 wrong · E undo · R reset</small></p>
</div>
<h2>Quick chart</h2>
<table id="chart"></table>
The state is one number, wrong. The total comes from the input, so there is nothing else to keep in sync.
3. Rendering
const $ = (id) => document.getElementById(id);
let wrong = 0;
function render() {
const total = Math.max(1, Math.floor(Number($('total').value) || 1));
wrong = Math.min(wrong, total);
const { percent, letter } = gradeTest(total, wrong);
$('wrong').textContent = wrong;
$('percent').textContent = `${Math.round(percent * 10) / 10}%`;
$('letter').textContent = letter;
$('chart').innerHTML =
'<tr><th>Wrong</th><th>Score</th><th>Letter</th></tr>' +
Array.from({ length: total + 1 }, (_, w) => {
const r = gradeTest(total, w);
return `<tr><td>${w}</td><td>${Math.round(r.percent * 10) / 10}%</td><td>${r.letter}</td></tr>`;
}).join('');
}
If someone clears the input or types a decimal, the Math.max(1, Math.floor(...)) line turns it into a valid integer, so gradeTest never throws. The table doubles as a quick chart of every possible score.
4. Buttons and keyboard shortcuts
const add = () => { wrong++; render(); };
const undo = () => { wrong = Math.max(0, wrong - 1); render(); };
const reset = () => { wrong = 0; render(); };
$('add').addEventListener('click', add);
$('undo').addEventListener('click', undo);
$('reset').addEventListener('click', reset);
$('total').addEventListener('input', render);
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey || e.altKey) return; // keep Ctrl+R, Cmd+W, etc.
if (e.target instanceof HTMLInputElement) return; // don't hijack typing
({ w: add, e: undo, r: reset })[e.key.toLowerCase()]?.();
});
render();
Single-letter shortcuts have two classic bugs, and both are handled above:
-
Browser shortcuts.
Ron its own should reset the count, but Ctrl+R or Cmd+R should still reload the page. Returning early when a modifier key is held fixes that. -
Typing in the input. Without the
instanceof HTMLInputElementcheck, typing awinto a field would trigger a tap.
The lookup object { w: add, e: undo, r: reset } keeps the key map in one place, and the ?.() quietly ignores every other key.
5. Make the chart printable
A printed chart beside the papers beats flipping back to the screen. A few lines of CSS hide the buttons when printing:
@media print {
.controls { display: none; }
}
6. Test the logic
Because gradeTest is a pure function, tests are trivial. Move it to grade.js with export, then run this with Node:
import assert from 'node:assert/strict';
import { gradeTest } from './grade.js';
assert.equal(gradeTest(30, 3).letter, 'A'); // 27/30 = 90%, exactly on the cutoff
assert.equal(gradeTest(20, 3).percent, 85);
assert.equal(gradeTest(25, 5).letter, 'B'); // 80%, exactly on the cutoff
assert.equal(gradeTest(10, 15).letter, 'F'); // clamped to 0 correct
assert.equal(gradeTest(3, 1).letter, 'D'); // 66.67%
console.log('all good');
Always test the exact cutoff values, since those are where grading tools go wrong.
Where to take it next
- Let users edit the cutoffs and pass the result into
gradeTestasscale. - Support tests where questions have different point values (switch to points earned over points possible).
- Save the question count and scale in
localStorageso they survive a refresh.
Everything runs in the browser, with no backend, so student scores never leave the page.
This tutorial is a simplified version of the idea behind QuickGradeCalc, a free grading tool I run for teachers. If you'd rather use a finished version than build one, it works the same way: set the question count, then tap +1 wrong.
What would you add first: custom cutoffs, per-question points, or saved settings?
Top comments (1)
Want to try it before reading the code? Save the full file as an .html page and open it in your browser. Tell me what you’d change, and I’ll cover it in a follow-up post.