Count Working Days in JavaScript With UK Bank Holidays
Count and add working days in JavaScript with UTC dates, skip weekends and UK bank holidays from gov.uk's JSON, and avoid the clocks-change bug.
To work out your UK degree classification, take a credit-weighted average of your module marks for each year that counts, combine the years using your university's weighting (often 30:70 or 1:2 for second and final year), and compare the result with the boundaries: 70% or more is a First, 60–69% a 2:1, 50–59% a 2:2 and 40–49% a Third. Then check two rules that change between universities: how the overall mark is rounded, and what happens if you're just below a boundary.
I built a free UK degree classification calculator that does all of this in the browser (source on GitHub). In this post I go through each step with the TypeScript behind it and the output from running it, so you can follow the maths by hand or reuse the code.
The most common mistake is averaging the marks directly. UK modules have credits, usually 15, 20, 30 or 40, and a full-time year is 120 credits. A 40-credit dissertation should count twice as much as a 20-credit module. So you multiply each mark by its credits, add those up, and divide by the total credits:
// degree.ts: UK degree classification from module marks
export interface Module { credits: number; mark: number }
// Credit-weighted average: a 40-credit module counts twice a 20-credit one
export function weightedAverage(modules: Module[]): number | null {
const ok = modules.filter((m) => m.credits > 0 && m.mark >= 0 && m.mark <= 100);
const credits = ok.reduce((sum, m) => sum + m.credits, 0);
if (credits === 0) return null;
return ok.reduce((sum, m) => sum + m.credits * m.mark, 0) / credits;
}
const BOUNDARIES = [
{ min: 70, name: "First" },
{ min: 60, name: "2:1" },
{ min: 50, name: "2:2" },
{ min: 40, name: "Third" },
{ min: 0, name: "Fail" },
];
export function classify(mark: number): string {
return BOUNDARIES.find((b) => mark + 1e-9 >= b.min)!.name;
}
export type Rounding = "none" | "1dp" | "whole";
export function applyRounding(mark: number, rounding: Rounding): number {
const clean = Math.round(mark * 1e6) / 1e6; // 69.49999999999999 → 69.5
if (rounding === "whole") return Math.floor(clean + 0.5);
if (rounding === "1dp") return Math.floor(clean * 10 + 0.5) / 10;
return clean;
}
// weights = [second year, final year], e.g. [30, 70] or [1, 2]
export function overall(year2: Module[], year3: Module[], [w2, w3]: number[]): number | null {
const a2 = weightedAverage(year2);
const a3 = weightedAverage(year3);
if ((w2 > 0 && a2 === null) || (w3 > 0 && a3 === null)) return null;
return ((a2 ?? 0) * w2 + (a3 ?? 0) * w3) / (w2 + w3);
}
weightedAverage() uses reduce() twice, once for the total credits and once for the sum of credits × marks. If reduce() still feels strange, my post on JavaScript map, filter and reduce walks through it step by step. The filter() first drops rows with no credits or an impossible mark, so a half-filled form can't break the average.
Here's the difference it makes, with four second-year modules: 62 and 58 on 20 credits each, and 65 and 61 on 40 credits each:
import { applyRounding, classify, overall, weightedAverage } from "./degree";
import { markNeeded } from "./needed";
const year2 = [
{ credits: 20, mark: 62 }, { credits: 20, mark: 58 },
{ credits: 40, mark: 65 }, { credits: 40, mark: 61 },
];
const year3 = [{ credits: 40, mark: 68 }, { credits: 20, mark: 72 }, { credits: 20, mark: 80 }];
console.log("Year 2 average:", weightedAverage(year2));
console.log("Plain average:", year2.reduce((s, m) => s + m.mark, 0) / year2.length);
console.log("Year 3 so far:", weightedAverage(year3)?.toFixed(2));
for (const w of [[30, 70], [1, 2], [50, 50], [0, 100]]) {
const o = overall(year2, year3, w)!;
console.log(`${w.join(":")} → ${o.toFixed(2)} (${classify(o)})`);
}
console.log("69.5, no rounding:", classify(applyRounding(69.5, "none")));
console.log("69.5, whole marks:", classify(applyRounding(69.5, "whole")));
console.log("Float noise:", 69.49999999999999, "→", applyRounding(69.49999999999999, "whole"));
const need = markNeeded(year2, year3, 120, [30, 70], 70);
console.log("Needed for a First on the last 40 credits:", need);
const check = overall(year2, [...year3, { credits: 40, mark: need! }], [30, 70])!;
console.log("Check with that mark:", check.toFixed(3), classify(check));
// Output:
// Year 2 average: 62
// Plain average: 61.5
// Year 3 so far: 72.00
// 30:70 → 69.00 (2:1)
// 1:2 → 68.67 (2:1)
// 50:50 → 67.00 (2:1)
// 0:100 → 72.00 (First)
// 69.5, no rounding: 2:1
// 69.5, whole marks: First
// Float noise: 69.49999999999999 → 70
// Needed for a First on the last 40 credits: 76.3
// Check with that mark: 70.003 First
The plain average of the four marks is 61.5, but the real, credit-weighted second-year average is 62.0, because the two bigger modules have the higher marks. Half a mark doesn't sound like much until you're at 69.5.
Most three-year degrees in England, Wales and Northern Ireland don't count first year at all. The second and final years are combined, with more weight on the final year. Common splits are 30:70, 25:75, 1:2 (a third and two thirds), 40:60 and 50:50, and a few universities only count the final year. In Scotland, the last two years of a four-year honours degree usually count, so "second year" in the calculator means your penultimate year.
The overall() function takes the weights as a pair in any scale. [30, 70], [0.3, 0.7] and [3, 7] all give the same answer, because it divides by w2 + w3. That also makes 1:2 easy: pass [1, 2] instead of typing 33.333 and 66.667. The output above shows how much the weighting matters. With the same marks, the result goes from 67.00 at 50:50 to 69.00 at 30:70, and to 72.00 (a First) if only the final year counts.
If a year has a weight but no marks yet, overall() returns null rather than pretending that year is 0%. The interface then asks for more marks instead of showing a scary Third.
Is 69.5% a First? It depends on your university. Some round the overall mark to a whole number, so 69.5 becomes 70. Some keep one decimal place. Some don't round at all, so 69.5 is a 2:1 that may or may not be raised by a borderline review. The calculator has a setting for each, and applyRounding() is applied before classify().
Two details matter here. First, JavaScript's floating-point maths can produce numbers like 69.49999999999999 when the true answer is 69.5. If you round that directly, you get 69 and a 2:1. Snapping to six decimal places first (Math.round(mark * 1e6) / 1e6) cleans that up, which the "Float noise" line in the output shows. Second, I use Math.floor(x + 0.5) for "round half up", which matches how most regulations describe it: 69.5 goes up, 69.49 goes down.
classify() also adds a tiny 1e-9 before comparing with the boundary, for the same reason: a mark that's 70 in real maths but 69.99999999999999 in floating point should still be a First.
This is the question most students actually have. If your second year is finished and you have marks for some final-year modules, what average do you need on the rest? It's one line of algebra. Write the overall mark as a formula with the unknown mark x on the remaining credits, set it equal to the target, and solve for x:
import { weightedAverage, type Module } from "./degree";
// Average needed on the final-year credits that have no mark yet
export function markNeeded(year2: Module[], done: Module[], finalCredits: number, [w2, w3]: number[], target: number) {
const a2 = weightedAverage(year2) ?? 0;
const doneCredits = done.reduce((s, m) => s + m.credits, 0);
const doneSum = done.reduce((s, m) => s + m.credits * m.mark, 0);
const left = finalCredits - doneCredits;
if (left <= 0) return null;
// target = (a2*w2 + w3 * (doneSum + x*left) / finalCredits) / (w2 + w3), solved for x
const finalAvg = (target * (w2 + w3) - a2 * w2) / w3;
const x = (finalAvg * finalCredits - doneSum) / left;
return Math.ceil(x * 10 - 1e-9) / 10; // round UP so the answer is always enough
}
With the example marks, a 30:70 weighting and 80 of 120 final-year credits done, a First needs 76.3% on the last 40 credits. The function rounds up to one decimal place, because rounding 76.29 down to 76.2 would tell someone a mark that's just too low. The last line of the output checks the answer by putting 76.3 back in: the overall mark comes out at 70.003, a First.
In the calculator, the result says "out of reach" if the needed mark is over 100, and "already reached" if it's zero or less. It also reminds you that you still need to pass each module if the needed average is below 40.
The interface is a list of module rows for each year: a name, credits and a mark, plus a remove button. On a phone the name takes the full width and credits and mark sit side by side underneath. On a wider screen everything is on one line. That's a CSS Grid with different column templates at each breakpoint. I explain why Grid suits this kind of row better than Flexbox in CSS Flexbox vs Grid.
A few things I fixed after testing:
localStorage and restored after the page loads. Nothing is uploaded.The repo has 26 unit tests for the logic, run with Vitest. They cover every boundary (70 is a First, 69.99 isn't), both rounding modes, the float-noise case, missing years, zero weights, and a "mark needed" answer that's checked by feeding it back into the overall calculation. That last kind of test is useful for any "solve for x" code: it doesn't trust the algebra, it checks the result.
At most UK universities, no. It usually has to be passed, but the classification comes from the second and final years. Check your handbook, because some courses, such as integrated master's degrees, use different years.
Some universities drop your lowest-scoring credits, use the median instead of the mean, or apply a "preponderance" rule at borderlines. The calculator uses the common weighted-mean method, so treat it as a guide and use your university's published algorithm for the final word.
Working out a degree classification comes down to four steps: weight each module by its credits, combine the years with your university's weighting, round the way your university does, and compare with 70, 60, 50 and 40. In code, the pitfalls are floating-point noise right at the boundaries and remembering to round the "mark needed" up. Try your own marks in the degree classification calculator, or read the code and tests on GitHub.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Count and add working days in JavaScript with UTC dates, skip weekends and UK bank holidays from gov.uk's JSON, and avoid the clocks-change bug.
How UK student loan repayments are worked out in 2026/27: per-period thresholds, 9% over, rounding down, two plans at once and a floating-point trap.
A simple email regex for JavaScript forms, compared with a strict pattern and the browser's own check on ten addresses, plus a regex that froze for 1.5s.