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.
UK student loan repayments are calculated from your income, not from how much you owe. For each pay period your employer takes 9% of your pay above your plan's threshold (6% for a Postgraduate Loan) and rounds it down to the whole pound. In the 2026/27 tax year the yearly thresholds are £26,900 for Plan 1, £29,385 for Plan 2, £33,795 for Plan 4, £25,000 for Plan 5 and £21,000 for the Postgraduate Loan. So on Plan 2 with a £35,000 salary, paid monthly, you repay £42 a month.
I turned these rules into a free tool, the UK Student Loan Repayment Calculator (source on GitHub). It's a small Next.js app that runs entirely in the browser. In this post I explain how the calculation works, with the TypeScript I wrote for it and the test output. Every number below was checked against the worked examples on gov.uk.
There are two official sources, and you need both. The gov.uk page "How much you repay" gives the yearly thresholds, the 9% and 6% rates, and worked examples. HMRC's rates and thresholds for employers 2026 to 2027 gives the exact per-period thresholds that payroll software uses. I put the yearly figures in one file, so next April only one file changes:
// plans.ts: 2026/27 figures from gov.uk and HMRC's employer guidance
export type PlanId = "plan1" | "plan2" | "plan4" | "plan5" | "pgl";
export const PLANS: Record<PlanId, { name: string; threshold: number; rate: number }> = {
plan1: { name: "Plan 1", threshold: 26_900, rate: 0.09 },
plan2: { name: "Plan 2", threshold: 29_385, rate: 0.09 },
plan4: { name: "Plan 4", threshold: 33_795, rate: 0.09 },
plan5: { name: "Plan 5", threshold: 25_000, rate: 0.09 },
pgl: { name: "Postgraduate Loan", threshold: 21_000, rate: 0.06 },
};
export const PERIODS = { year: 1, month: 12, "four-weeks": 13, fortnight: 26, week: 52 } as const;
export type Period = keyof typeof PERIODS;
// Yearly threshold → one pay period, cut (not rounded) to whole pence
export function periodThreshold(yearly: number, period: Period): number {
return Math.floor((yearly * 100) / PERIODS[period]) / 100;
}
The interesting part is periodThreshold(). The gov.uk page rounds the monthly Plan 1 threshold to £2,241, but HMRC's employer table says £2,241.66. That's £26,900 ÷ 12 = 2,241.666…, cut to whole pence, not rounded up to .67. The weekly figure works the same way: £26,900 ÷ 52 = 517.307…, which HMRC lists as £517.30. Multiplying by 100, flooring and dividing by 100 gives exactly HMRC's numbers:
import { PLANS, periodThreshold, type PlanId } from "./plans";
for (const id of Object.keys(PLANS) as PlanId[]) {
const { name, threshold } = PLANS[id];
console.log(name.padEnd(18), periodThreshold(threshold, "month"), periodThreshold(threshold, "week"));
}
// Output:
// Plan 1 2241.66 517.3
// Plan 2 2448.75 565.09
// Plan 4 2816.25 649.9
// Plan 5 2083.33 480.76
// Postgraduate Loan 1750 403.84
Every one of those matches the employer table. I check them in the app's unit tests too, so a typo in next year's thresholds will fail the build.
Your employer doesn't look at your yearly salary. They look at what you're paid this month (or week), compare it with that period's threshold, and take 9% of the difference. The full rules, including what happens when you have more than one plan, fit in one function:
// repay.ts: one pay period's student loan deduction
import { PLANS, periodThreshold, type Period, type PlanId } from "./plans";
// 9% of whole pence has at most 4 decimals: snap to that, then round down
const floorPounds = (n: number) => Math.floor(Math.round(n * 10_000) / 10_000);
export function repayment(pay: number, plans: PlanId[], period: Period = "month") {
const ug = [...new Set(plans)]
.filter((p) => p !== "pgl")
.map((p) => ({ id: p, t: periodThreshold(PLANS[p].threshold, period) }))
.sort((a, b) => a.t - b.t);
// One 9% deduction over the LOWEST undergraduate threshold
const undergraduate = ug.length ? floorPounds(Math.max(0, pay - ug[0].t) * 0.09) : 0;
// Share it out: each lower plan gets at most 9% of the gap to the next threshold
const split: Record<string, number> = {};
let left = undergraduate;
ug.forEach((plan, i) => {
const next = ug[i + 1];
const cap = next ? floorPounds(Math.max(0, Math.min(pay, next.t) - plan.t) * 0.09) : Infinity;
split[plan.id] = Math.min(left, cap);
left -= split[plan.id];
});
// A Postgraduate Loan is separate: 6% over its own threshold
const postgraduate = plans.includes("pgl")
? floorPounds(Math.max(0, pay - periodThreshold(PLANS.pgl.threshold, period)) * 0.06)
: 0;
return { total: undergraduate + postgraduate, undergraduate, postgraduate, split };
}
There are four rules in there, and each one comes from a line on gov.uk:
floorPounds() uses Math.floor() and not Math.round().forEach loop handles this for any number of plans.If you're less used to chains like filter().map().sort(), my post on JavaScript map, filter and reduce explains how each step returns a new array for the next one.
The gov.uk page has four worked examples, which makes them perfect test cases. I also added edge cases: pay exactly at the threshold, and two weekly Plan 2 payments that broke my first two versions:
import { repayment } from "./repay";
// The worked examples on gov.uk/repaying-your-student-loan/what-you-pay
console.log("Plan 1, £2,750/month:", repayment(2750, ["plan1"]).total);
console.log("Plan 4, £3,000/month:", repayment(3000, ["plan4"]).total);
console.log("Plan 1 + 2, £3,200/month:", JSON.stringify(repayment(3200, ["plan1", "plan2"])));
console.log("PGL + Plan 2, £2,500/month:", JSON.stringify(repayment(2500, ["pgl", "plan2"])));
// Edge cases
console.log("Plan 5, at the threshold:", repayment(2083.33, ["plan5"]).total);
console.log("Plan 2 weekly, £1,065.09:", repayment(1065.09, ["plan2"], "week").total);
console.log("Plan 2 weekly, £1,065.04:", repayment(1065.04, ["plan2"], "week").total);
console.log("Naive maths:", (1065.09 - 565.09) * 0.09, "→", Math.floor((1065.09 - 565.09) * 0.09));
// Output:
// Plan 1, £2,750/month: 45
// Plan 4, £3,000/month: 16
// Plan 1 + 2, £3,200/month: {"total":86,"undergraduate":86,"postgraduate":0,"split":{"plan1":18,"plan2":68}}
// PGL + Plan 2, £2,500/month: {"total":49,"undergraduate":4,"postgraduate":45,"split":{"plan2":4}}
// Plan 5, at the threshold: 0
// Plan 2 weekly, £1,065.09: 45
// Plan 2 weekly, £1,065.04: 44
// Naive maths: 44.999999999999986 → 44
All four gov.uk examples come out right: £45 on Plan 1, £16 on Plan 4, £86 split into £18 and £68 for Plan 1 plus Plan 2, and £45 + £4 = £49 for a Postgraduate Loan with Plan 2.
The last lines are the reason floorPounds() doesn't just call Math.floor(). £1,065.09 a week is exactly £500 over the Plan 2 weekly threshold of £565.09, and 9% of £500 is £45. But computers store most decimals, like 1065.09, as a tiny bit off. The subtraction and multiplication give 44.999999999999986, and Math.floor() turns that into £44. Real payroll would take £45.
My first fix was to round to whole pence before flooring, and it was wrong too. On £1,065.04 the real answer is 9% of £499.95, which is £44.9955. Rounding that to pence gives £45.00, so I'd have taken a pound too much. The proper fix comes from the maths: pay and thresholds are whole pence, so 9% (or 6%) of the difference never has more than four decimal places. Snapping to four places with Math.round(n * 10_000) / 10_000 removes the float noise without changing any real value, and then Math.floor() is safe. Both cases are now unit tests in the repo.
Repayments don't depend on your balance, but whether you clear the loan does. Interest keeps being added even when your income is under the threshold. When I checked on 4 October 2026, gov.uk listed 4.1% for Plans 1, 4 and 5 and 6% for the Postgraduate Loan. Plan 2 is different: after you leave your course, the rate depends on your income. It's 4.1% up to £29,385, rises on a sliding scale, and reaches 6% at £52,885:
// Plan 2 interest after you leave your course: RPI + up to 3%, capped at 6%
const RPI = 0.041;
const LOWER = 29_385;
const UPPER = 52_885;
export function plan2Interest(income: number): number {
const share = Math.min(1, Math.max(0, (income - LOWER) / (UPPER - LOWER)));
return Math.min(0.06, RPI + 0.03 * share);
}
for (const income of [25_000, 35_000, 41_135, 60_000]) {
console.log(income, (plan2Interest(income) * 100).toFixed(2) + "%");
}
// Output:
// 25000 4.10%
// 35000 4.82%
// 41135 5.60%
// 60000 6.00%
The calculator uses this in a month-by-month loop. It adds a month of interest to the balance, takes off the monthly repayment, raises the salary once a year by the percentage you enter, and stops when the balance hits zero or the write-off date arrives. Plan 1 (for loans first paid from September 2006) is written off 25 years after the April you were first due to repay, Plans 2 and 4 and the Postgraduate Loan after 30 years, and Plan 5 after 40.
I was careful to call this an estimate, both in the tool and here. It keeps today's thresholds and interest rates fixed, while in reality both change most years. For a typical Plan 2 graduate on £35,000 with a £45,000 balance, it shows the loan being written off rather than paid off. That matches what most people on Plan 2 expect, but it isn't a forecast.
The interface is one form, and the result updates as you type. A few choices made it nicer to use on a phone:
inputMode="decimal", not type="number". Phones still show a number keypad, but people can type "£32,000" and the code strips the £ and commas. If you want to know where type="number" helps and where it gets in the way, see my guide to HTML form input types and validation.fieldset with a legend, and each one says who the plan is for ("England, courses starting on or after 1 August 2023"). Many people don't know which plan they're on.aria-live="polite", so screen readers announce the new amount after a change.PLANS object the maths uses, so the table and the results can't disagree.Everything is plain arithmetic, so there's no reason to send anyone's salary to a server. Next.js pre-renders the site as static HTML, the maths runs in the browser, and there are no API keys or analytics scripts. It can't leak data it never receives.
Usually because your pay changed that month. Overtime or a bonus raises that month's deduction, because only that pay period counts. If your income for the whole tax year ends up below the yearly threshold, you can ask the Student Loans Company for a refund.
No. Only your income does. A £20,000 balance and a £60,000 balance cost the same each month on the same salary.
The student loan rules are simple once you write them down: per-period thresholds cut to the penny, 9% (or 6%) over the threshold, round down, one shared deduction for undergraduate plans, and a separate one for a Postgraduate Loan. The hard parts are the details: HMRC's exact thresholds, the split between plans, and a floating-point error that costs a pound. Try your own numbers in the student loan repayment calculator, or read the full 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.
Work out a First, 2:1 or 2:2 from module marks: credit-weighted averages, 30:70 or 1:2 year weightings, rounding at 69.5 and the mark you need.
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.