Every admission season in Pakistan, students across the country ask the same question: “Merit kitni banegi?” (What will my merit be?)
The answer should be simple. It isn’t. Every university combines Matric, Intermediate and entry test marks with its own weights, and some add their own rules on top. Students end up doing the maths by hand, for every university they apply to, and one wrong number can mean a wasted application.
So I built Merit Calculator: enter your marks once and see your aggregate for every university you’re considering, each with its own formula.
This post covers how it works under the hood: a data-driven formula engine, an Android app built entirely in the cloud, two bugs that taught me something, and how I set up SEO from day one.
The problem: 38 formulas, no standard
Here’s a small sample of what “merit” means at different universities:
| University | Matric | Inter | Entry test |
|---|---|---|---|
| UET Lahore (ECAT) | 17% | 50% | 33% |
| NUST (NET) | 10% | 15% | 75% |
| FAST-NUCES | 10% | 40% | 50% |
| University of Gujrat, Computing | – | 100% (Part-I) | – |
| MDCAT (all medical colleges) | 10% | 40% | 50% |
And then there are the special cases:
-
Punjab University builds an “academic record” first:
(¼ × Matric + Inter + Hafiz bonus) ÷ (¼ × Matric total + Inter total), then gives it 75% and the entry test 25%. - GCU Lahore combines Matric and Inter Part-I marks into one academic score.
- GIKI gives 15% to the average of Matric and Inter, and 85% to its own test.
- QAU and UVAS use no entry test at all, only Matric and Inter Part-I marks.
A simple “weights × percentages” calculator can’t handle all of this. I needed a small formula engine.
My constraints
Before writing code, I set myself a few rules:
- No backend. No server to maintain, pay for or secure.
- Free hosting.
- Works offline and on low-end phones.
- An installable Android app, without installing Android Studio. My laptop isn’t powerful enough to run it comfortably.
- Honest data. If a university doesn’t publish its formula, the app must say so.
Architecture
Everything comes from one source of truth: a single JavaScript array of formulas.
Formula file (one JS array)
│
┌───────────────────┼────────────────────┐
▼ ▼ ▼
Web app 25 SEO pages Android APK
(/app/) (generated, static) (Capacitor wrapper)
│ │ │
└──── Cloudflare Pages ────┘ GitHub Actions → Releases
- The web app is plain HTML, CSS and vanilla JavaScript. No framework, no build step.
- The SEO pages are generated from the same data by a small Python script.
- The Android app is the same web code wrapped with Capacitor, built and signed by GitHub Actions.
The formula engine
Each admission route is one object:
{ id: "uet", city: "Lahore", uni: "UET Lahore", prog: "Engineering (ECAT)",
kind: "std", w: [17, 50, 33], test: "ECAT", status: "official" }
w holds the weights for Matric, Inter and the test. kind decides how those weights are applied:
| kind | Used for | Rule |
|---|---|---|
std |
Most universities | Weighted sum of three percentages |
pu |
Punjab University | ¼ Matric academic record + test |
gcu |
GCU Lahore | Combined Matric + Inter Part-I academics + test |
avg |
GIKI | Average of Matric and Inter + test |
The compute function turns each formula into a list of parts, each with a weight and a percentage:
function compute(u) {
const m = pct(S.ssc), h = pct(S.hssc);
const t = u.test ? pct(testScore(u.test)) : null;
const parts = [];
if (u.kind === "pu") {
// Punjab University's academic record
const acad = (mo / 4 + io + (S.hafiz ? 20 : 0)) / (mt / 4 + it) * 100;
parts.push({ label: "Academic (¼ Matric + Inter)", w: u.w[0], p: acad });
if (u.w[1] > 0) parts.push({ label: TESTS[u.test].name, w: u.w[1], p: t });
} else {
if (u.w[0] > 0) parts.push({ label: "Matric", w: u.w[0], p: m });
if (u.w[1] > 0) parts.push({ label: "Inter", w: u.w[1], p: h });
if (u.w[2] > 0) parts.push({ label: TESTS[u.test].name, w: u.w[2], p: t });
}
parts.forEach(p => p.c = p.p == null ? null : p.p * p.w / 100);
const agg = parts.some(p => p.c == null) ? null
: parts.reduce((a, p) => a + p.c, 0);
return { parts, agg };
}
Splitting the result into parts made everything else easy: the breakdown bars, the “add your ECAT score” prompts, and the target finder.
The target score finder
The most useful feature turned out to be the simplest maths. If a student wants an 80% aggregate, how many entry test marks do they need?
const base = parts.filter(p => p.k !== "test")
.reduce((a, p) => a + p.c, 0); // what Matric + Inter already give
const needPct = (target - base) / (testWeight / 100);
const needMarks = Math.ceil(needPct / 100 * testTotal);
With 1020/1100 in Matric and 980/1100 in Inter, a UET applicant needs 239/400 in ECAT for 80%. That’s far more useful than a vague “work hard”.
Official vs estimate
Some private universities admit on “academics + our own test” but never publish the exact weights. Instead of inventing numbers, every formula has a status:
- Official: taken from the university’s own admission notice.
- Estimate: a common pattern, clearly labelled, with weights the user can adjust.
While verifying formulas for the SEO pages, I found that Punjab University’s 2024 and 2025 notices reserve 25% for the entry test. I had been using 30%, taken from an older programme page. Checking sources pays off.
An app that feels like an app
A calculator form on a web page isn’t something students install. So the UI became a proper mobile app shell:
- A splash screen and a three-slide onboarding flow on first launch
- A four-step wizard: pick universities → Matric → Inter → only the entry tests those universities use
- Bottom tabs: Calculate, My merit (ranked results) and Universities
- A bottom sheet for each university with the breakdown, target finder and adjustable estimate weights
-
inputmode="decimal"for number keyboards, safe-area insets for notches, and light/dark themes
Marks are saved in localStorage, so a student can close the app and come back later. Nothing ever leaves the device.
Building an Android APK without Android Studio
This was the part I was most unsure about. The solution: let GitHub build it.
Capacitor generates a normal Android project around the web files. GitHub’s Ubuntu runners already ship with the Android SDK, so a workflow can build the APK on every push and attach it to a GitHub Release:
jobs:
build:
runs-on: ubuntu-24.04 # pinned, see the lesson below
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with: { node-version: 22 }
- uses: actions/setup-java@v5
with: { distribution: temurin, java-version: 17 }
- run: npm ci && npx cap sync android
- run: cd android && ./gradlew assembleRelease --no-daemon
- uses: softprops/action-gh-release@v2
with:
tag_name: v1.0.<run number> # github.run_number in the real file
files: MeritCalculator.apk
The signing keystore lives in GitHub Secrets as base64. The workflow decodes it, verifies it with keytool, and falls back to a debug build if anything is wrong, so a bad secret never blocks a release.
The download link never changes, because GitHub redirects /releases/latest/download/<file> to the newest build:
https://github.com/<user>/<repo>/releases/latest/download/MeritCalculator.apk
Lesson 1: pin your runner
My first builds failed for no obvious reason. GitHub had just moved ubuntu-latest to a new image. Pinning runs-on: ubuntu-24.04 fixed it, and I now pin runners in every project.
Lesson 2: Groovy will read your parentheses differently
I wanted each build to get an automatic, increasing version code:
// ❌ Groovy parses this as versionCode("12").toInteger()
versionCode (System.getenv("GITHUB_RUN_NUMBER") ?: "1").toInteger()
// ✅
def runNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "1").toInteger()
versionCode runNumber
versionName "1.0.${runNumber}"
With a space before the parentheses, Groovy treats them as the method call’s arguments. The build failed with No signature of method: versionCode(), a message that makes sense only once you know the trap.
The green screen bug
After I added a marketing landing page, the Android app opened to a solid green screen. Clearing the cache didn’t help, and neither did reinstalling.
The APK bundles the whole website, so the app first loaded the landing page, which redirected to the calculator:
if (window.Capacitor?.isNativePlatform()) location.replace("app/");
On the web, /app/ serves /app/index.html. But Capacitor’s local server runs in “HTML5 mode”: any path without a file extension returns the root index.html. So /app/ loaded the landing page again, which redirected again, forever. The green was just the WebView’s background colour.
// ✅ Point at the actual file
location.replace("/app/index.html");
I reproduced it locally with a tiny Python server that mimics Capacitor’s fallback rule, confirmed the endless navigation loop, and confirmed that the fix loads the calculator in two navigations. When a bug only happens inside a wrapper, simulate the wrapper.
In-app updates without the Play Store
Sideloaded APKs don’t update themselves. I wanted users on the latest formulas without asking them to check a website.
The workflow stamps the build number into the app:
- run: echo "window.APP_BUILD = $GITHUB_RUN_NUMBER;" > www/app/build.js
On launch, the app compares it with the latest GitHub Release:
const r = await fetch("https://api.github.com/repos/<user>/<repo>/releases/latest");
const { tag_name } = await r.json(); // "v1.0.42"
const latest = parseInt(tag_name.split(".").pop(), 10);
if (latest > window.APP_BUILD) showUpdateBanner(); // links to the latest APK
Because versionCode is the same run number, Android installs the new APK over the old one and keeps the saved marks.
SEO from day one
Students don’t search for “merit calculator”. They search for “UOG merit calculator”, “UET aggregate calculator” or “MDCAT aggregate calculator 2026”. One homepage can’t rank for 25 different searches, so each university got its own page.
A Python script generates 25 static pages from the same formula data. Each one has:
- A title in the format students search, like University of Gujrat (UOG) Merit Calculator 2026
- A working mini calculator for that university
- The formula table and a step-by-step worked example
- FAQs written from the data, for example “What ECAT score do I need for 80%?”
-
JSON-LD for
WebApplication,FAQPageandBreadcrumbList - Its own Open Graph image, rendered from an HTML template with Playwright, so a shared UOG link shows a UOG banner
On top of that: a sitemap with image entries, canonical URLs, robots.txt, a custom 404 page and caching headers on Cloudflare Pages.
I also wrote a small automated audit that loads every page in a headless browser and checks for:
- titles over 60 characters and descriptions over 160
- exactly one
<h1>per page - valid JSON-LD and existing share images
- broken internal links and JavaScript errors
- horizontal scrolling at 360 px and 390 px widths
That audit caught a programme dropdown pushing a page wider than the phone screen. I would never have noticed it on my laptop.
What I learned
- Data quality beats features. The hardest part wasn’t code, it was verifying 38 formulas against official notices.
- Be honest about uncertainty. An “Estimate” label builds more trust than false precision.
- CI can replace heavy local tooling. I shipped a signed Android app without ever opening Android Studio.
- Pin everything: runners, action versions, library versions.
- Simulate the environment you can’t easily debug. A twenty-line server explained the green screen in minutes.
- One source of truth scales. The app, the APK and 25 SEO pages all come from a single array.
Try it
- 🌐 Website: meritkitni.pages.dev
- 📱 Web app: meritkitni.pages.dev/app
- 🤖 Android APK: available from the website
If you know a student applying to university this year, please share it with them. And if your university is missing, tell me in the comments with a link to its admission notice, and I’ll add it.
I’m Saad Ahmad, a Senior Frontend Developer. I write about building fast, practical web products. Find more of my work at isaadahmad.com.












