When I sat down to build the CGPA Calculator for RedoHub, the first real decision wasn't about the UI or the grading scales -- it was whether to send any of the student's data to a server at all. I decided early on that it wouldn't, and that single choice shaped almost every other decision in the tool.

No Backend Means No Liability

A CGPA calculator is exactly the kind of tool where a student pastes in their real subject names, real grades, and sometimes their real credit load for an entire degree. If I ran that through a PHP endpoint and logged it -- even accidentally, even just in an access log -- I'd be holding academic data I have no business holding. So the whole calculator is a single static page: one index.php for the SEO meta and markup, and one script.js that does all the actual work in the browser. There's no calculate.php, no database table, nothing to leak. The footer line "No data stored" isn't a privacy-policy platitude -- it's literally true because there's nowhere for the data to go.

Building the Semester Rows Without a Framework

The UI needs to generate an arbitrary number of semester cards, each containing an arbitrary number of subject rows, all added and removed dynamically. My first instinct was to reach for a small templating helper, but the whole calculator is one page with no build step, so I kept it to raw DOM manipulation with template literals:

function addSubject(semesterId) { const container = document.getElementById(`subjects-${semesterId}`); const subjectRow = document.createElement('div'); subjectRow.className = 'subject-row'; subjectRow.innerHTML = ` <div><input type="text" class="form-control subject-name"></div> <div><input type="number" class="form-control subject-gpa" step="0.01"></div> <div><input type="number" class="form-control subject-credits" value="3"></div> `; container.appendChild(subjectRow); }

It's not elegant, but it's honest about what it's doing, and there's no virtual DOM diffing overhead for what amounts to appending a handful of <div> elements a few times per page load. For a tool this small, a framework would have added more weight than it removed complexity.

The Weighted Average Is the Whole Point

The actual math is a single loop, but getting the loop right matters more than it looks. CGPA isn't a simple average of grades -- it's a credit-weighted average, so a 3-credit course with a 3.5 GPA should count for more than a 1-credit course with the same GPA:

subjects.forEach(subject => { const gpa = parseFloat(subject.querySelector('.subject-gpa').value); const credits = parseInt(subject.querySelector('.subject-credits').value); if (!isNaN(gpa) && !isNaN(credits) && gpa > 0 && credits > 0) { totalPoints += gpa * credits; totalCredits += credits; } }); const cgpa = totalPoints / totalCredits;

The gpa > 0 && credits > 0 guard is doing more work than it looks like too. Early on I only checked !isNaN(), and a student who left a subject's GPA field blank would have it silently coerced to 0 and dragged their entire average down. Requiring a positive value before it counts means an incomplete row is skipped instead of punished.

The Percentage Conversion Is a Deliberate Simplification

This is the trade-off I'm least comfortable with, so it's worth being upfront about it. The tool converts CGPA to a percentage with one formula regardless of which grading scale you picked:

const percentage = (cgpa / scale) * 100;

That's accurate for some institutions and only an approximation for others -- plenty of universities on a 10-point scale use their own official conversion formula (some subtract a fixed offset, some use a different multiplier entirely) rather than a straight linear scale. I chose the linear formula because it's the one that generalizes across the 4.0, 5.0, 7.0, and 10.0 scales the tool supports, and I say so implicitly by framing the result as an estimate rather than an official transcript equivalent. A more "correct" version of this tool would need a lookup table per institution, which is a different (and much bigger) project than a free calculator page.

Removing a Row Without Breaking the Animation

One small detail that took longer to get right than I expected: when you remove a subject row, it fades out with a CSS animation before actually leaving the DOM. If you just call .remove() immediately, the animation never gets a chance to play. The fix is a setTimeout whose delay matches the CSS transition duration exactly:

function removeSubject(button) { const row = button.closest('.subject-row'); row.style.animation = 'fadeOut 0.3s ease-out'; setTimeout(() => row.remove(), 300); }

The 300 in the JavaScript has to match the 0.3s in the CSS keyframe by hand -- there's no shared constant between the two files. It's a small coupling I'm not thrilled about, but for a tool this size, adding an animation-end event listener felt like more ceremony than the problem deserved.

Choosing a Default Scale Instead of Guessing

The grading-scale dropdown defaults to the 4.0 scale, not the 10.0 scale, even though 10-point scales are arguably more common globally across the universities RedoHub's traffic comes from. I went back and forth on this. In the end I picked 4.0 as the default because it's the scale most first-time visitors searching "CGPA calculator" in English are likely to recognize immediately, and changing a dropdown is a one-click fix if it's wrong for you -- but a confusing default that silently produces the wrong number for a majority of visitors is a much worse first impression. It's a small UX call, but it's the kind of decision that's easy to get backwards if you design for yourself instead of for the actual traffic.

What I'd Reconsider

If I rebuilt this today, I'd probably move the grading-scale-to-percentage logic into a small lookup object instead of one formula, since that's the part most likely to actually mislead someone. Everything else -- the no-backend architecture, the plain DOM manipulation, the credit-weighted math -- I'd keep exactly as it is. The constraint of "no server, no stored data" turned out to be a good forcing function: it kept the tool simple enough that I can still read the whole script.js file in about two minutes and know exactly what it does.