DEV Community

Cover image for The Math Behind Weighted Grades, in Next.js
QuickGradeCalc
QuickGradeCalc

Posted on

The Math Behind Weighted Grades, in Next.js

I built a free grade calculator with Next.js, and the part that surprised me most was how little of it is framework code. Nearly all of the logic is plain math that fits in three small functions. Here they are, with the edge cases that tripped me up, and a privacy-first component that uses them.

1. Weighted grade

Courses usually weight categories (homework 20%, exams 40%, and so on). The trick is to divide by the weight you have actually completed, not by 100, so a half-finished course still gives a sensible "grade so far".

// lib/grades.js
// items: [{ score: 92, weight: 20 }, { score: 84, weight: 30 }]
export function weightedGrade(items) {
  const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
  if (totalWeight <= 0) return null;

  const earned = items.reduce((sum, i) => sum + i.score * i.weight, 0);
  return earned / totalWeight;
}
Enter fullscreen mode Exit fullscreen mode

Example: a 90 worth 25% and an 80 worth 25% give (90 * 25 + 80 * 25) / 50 = 85. Only half the course is graded, so 85 is the grade so far, not a prediction.

2. The score you need on a final

This is the question students ask most. If the final is worth weight percent and you want target overall:

// weight is a percentage, like 30
export function neededScore(current, target, weight) {
  if (!(weight > 0 && weight <= 100)) return null;

  const w = weight / 100;
  return (target - current * (1 - w)) / w;
}
Enter fullscreen mode Exit fullscreen mode

Example: a current grade of 83, a target of 85, and a final worth 30%:

(85 - 83 * 0.70) / 0.30 = 89.67
Enter fullscreen mode Exit fullscreen mode

The student needs about 89.7% on the final. If the result is over 100, the target can't be reached through the final alone, and the UI should say that plainly instead of showing a number like 112%.

3. Quick grading for a stack of tests

For a teacher, counting wrong answers is faster than counting right ones:

export function percentFromWrong(totalQuestions, wrong) {
  if (!(totalQuestions > 0)) return null;

  const correct = Math.max(totalQuestions - wrong, 0);
  return (correct / totalQuestions) * 100;
}
Enter fullscreen mode Exit fullscreen mode

On a 30-question test with 4 wrong, that returns 86.67.

Test the math, not the UI

Pure functions are easy to test. A few lines with Vitest or Jest catch most bugs:

import { describe, it, expect } from "vitest";
import { weightedGrade, neededScore, percentFromWrong } from "./grades";

describe("grades", () => {
  it("handles a partly graded course", () => {
    expect(
      weightedGrade([
        { score: 90, weight: 25 },
        { score: 80, weight: 25 },
      ])
    ).toBe(85);
  });

  it("finds the score needed on a final", () => {
    expect(neededScore(83, 85, 30)).toBeCloseTo(89.67, 2);
  });

  it("rejects invalid weights", () => {
    expect(neededScore(83, 85, 0)).toBeNull();
  });

  it("converts wrong answers to a percentage", () => {
    expect(percentFromWrong(30, 4)).toBeCloseTo(86.67, 2);
  });
});
Enter fullscreen mode Exit fullscreen mode

A privacy-first component

A grade calculator handles personal data, so I made it work entirely in the browser: no network requests, no cookies, no localStorage, and no values in the URL. Inputs are validated before any math runs.

// components/FinalCalculator.jsx
"use client";

import { useState } from "react";
import { neededScore } from "@/lib/grades";

// Turns raw text into a safe number, or null if it isn't usable.
function parseValue(raw, min, max) {
  const cleaned = String(raw).replace(",", ".").trim();
  if (cleaned === "" || !/^\d{0,3}(\.\d{0,2})?$/.test(cleaned)) return null;
  const n = Number(cleaned);
  if (!Number.isFinite(n) || n < min || n > max) return null;
  return n;
}

function Field({ label, value, onChange }) {
  return (
    <label style={{ display: "block", marginBottom: 12 }}>
      {label}
      <input
        type="text"
        inputMode="decimal"
        autoComplete="off"
        spellCheck={false}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        style={{ display: "block", width: "100%", padding: 8 }}
      />
    </label>
  );
}

export default function FinalCalculator() {
  const [current, setCurrent] = useState("");
  const [target, setTarget] = useState("");
  const [weight, setWeight] = useState("");

  const c = parseValue(current, 0, 200);
  const t = parseValue(target, 0, 200);
  const w = parseValue(weight, 1, 100);

  let message = "Enter your numbers to see the result.";
  if (c !== null && t !== null && w !== null) {
    const result = neededScore(c, t, w);
    if (result === null || !Number.isFinite(result)) {
      message = "Please check your numbers.";
    } else if (result > 100) {
      message = "That target can't be reached with this exam alone.";
    } else {
      message = `You need about ${Math.max(result, 0).toFixed(1)}% on the final.`;
    }
  }

  return (
    <section>
      <h2>Final exam calculator</h2>

      {/* No <form> and no submit, so values never leave this component. */}
      <Field label="Current grade (%)" value={current} onChange={setCurrent} />
      <Field label="Target grade (%)" value={target} onChange={setTarget} />
      <Field label="Exam weight (%)" value={weight} onChange={setWeight} />

      <p aria-live="polite">{message}</p>

      <button
        type="button"
        onClick={() => {
          setCurrent("");
          setTarget("");
          setWeight("");
        }}
      >
        Clear
      </button>
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

To stop the page from sending data anywhere else, I also added a strict Content Security Policy in next.config.js:

// next.config.js
const csp = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline'",
  "style-src 'self' 'unsafe-inline'",
  "img-src 'self' data:",
  "connect-src 'self'", // blocks sending data to other sites
  "frame-ancestors 'none'",
  "base-uri 'self'",
  "form-action 'self'",
].join("; ");

module.exports = {
  poweredByHeader: false,
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "Content-Security-Policy", value: csp },
          { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
          { key: "X-Content-Type-Options", value: "nosniff" },
        ],
      },
    ];
  },
};
Enter fullscreen mode Exit fullscreen mode

Note that in development mode Next.js also needs 'unsafe-eval' in script-src for hot reload, and any analytics or ad script has to be added to the policy.

What I learned

  • Keep the math in pure functions. They are easy to test, and the component stays thin.
  • Be deliberate about edge cases. Zero weights, scores over 100, empty inputs, and NaN all show up with real users.
  • Treat grades as personal data. Doing everything client-side means there is nothing to leak.
  • One page per search intent. Separate pages for "percentage to letter grade" and "final grade" were clearer for users and for search engines than one page that did everything.

If you want to see it working, you can try the calculator at QuickGradeCalc. I'd also like to hear how grading works where you live (GPA scales, letter bands, and so on), since it varies a lot between countries.

What would you add to a tool like this?

Top comments (1)

Collapse
 
quickgradecalc profile image
QuickGradeCalc •

Thanks for reading! A few things I'm still figuring out with this project:

  1. Grading systems vary a lot between countries (GPA scales, letter bands, percentage cutoffs). How would you structure the code so users can switch between them without a giant if/else?

  2. I kept all the math in pure functions so it's easy to test. Is there an edge case in the weighted grade or final-exam formulas that I missed?

If you're building a small tool with Next.js, I'd love to hear what you struggled with most. Happy to answer questions about any part of this.