i built tryCBT because the useful part of a CBT practice app is not the screen with A, B, C and D.
that part is necessary, yes. but it is not the hard part.
the hard part is what happens after a real student starts using it. the browser refreshes. the laptop sleeps. the network disappears. the timer keeps moving. the student answers questions across subjects, flags some, comes back later, and expects the software to remember where they were without behaving like a toy.
that is the part i wanted to build properly.
tryCBT is a local-first UTME practice application for web and Windows desktop. the first release simulates the core UTME shape: 180 questions, 120 minutes, Use of English, Biology, Chemistry and Physics, with subject navigation, keyboard controls, flags, scoring, review and attempt history.
the important decision was to treat it like real exam software from the beginning, even though the first question bank is only a starter practice set.
no backend first
my first instinct for many products is to think about the backend early.
accounts, sync, server-side storage, admin dashboards, analytics, deployments, authentication, all of that.
but for this first release, a backend would have added responsibility without solving the immediate user problem.
the immediate problem was simple: a candidate should be able to practise on a browser or a Windows computer, even when internet access is unreliable, without creating an account or depending on a server staying available.
so the architecture became one React and TypeScript application:
textReact application -> installable web PWA -> Tauri desktop shell Local data -> IndexedDB through Dexie
that choice made the product smaller in the right way.
there is still a deployment for the web version, but the exam flow itself does not need a live backend. once the app shell is available, the candidate profile, attempt state, answers and history live locally.
the exam engine is not the UI
one thing i cared about was separating the exam rules from the React screens.
the UI can be redesigned a hundred times. the exam lifecycle should not care.
the core domain code models the subjects, question IDs, attempt IDs, answers, attempt status, scoring and navigation rules. an attempt moves through a small state machine:
textnot_started -> in_progress -> submitted -> expired
only an in-progress attempt can change. once it is submitted or expired, the engine rejects mutation.
that sounds small, but it matters. without that boundary, it is too easy for UI state to accidentally reopen a completed attempt or let a late interaction change a result after submission.
the engine also owns subject allocation and scoring. the interface can show a beautiful grid of questions, but the rules deciding what belongs to the attempt and how the score is estimated should stay testable without the browser.
timers are where bad CBT apps lie
timers look simple until the browser gets involved.
the naive version is:
textremaining = remaining - 1 second
every interval, reduce the counter.
that works until the tab is backgrounded, the browser throttles timers, the system sleeps, the desktop WebView pauses, or the user restarts the app. then the counter becomes a suggestion. the candidate may get extra time, or the UI may show the wrong time, or the app may fail to submit when it should.
for tryCBT, the timer is based on an absolute expiry.
when the attempt starts, the app stores:
textstartedAt expiresAt
remaining time is always derived from:
textexpiresAt - currentTime
and clamped at zero.
the interval only refreshes the display. it is not the source of truth.
that means refresh, restart and delayed intervals do not reset the exam. when the derived time reaches zero, the attempt is submitted as expired.
this is one of those boring engineering decisions that users only notice when it is wrong.
persistence has to happen immediately
another thing i did not want: a candidate answers twenty questions, refreshes, and loses them because the app kept state in memory for too long.
every answer, flag, navigation change and final submission writes a replacement attempt snapshot into IndexedDB.
the first database schema is intentionally simple:
profilesattempts
answers live inside the attempt snapshot for now. if the project grows into deeper analytics, cross-attempt reporting or cloud sync, that can be normalized later. for the first release, the stronger requirement is that a single attempt can be restored reliably.
on startup, the app loads the candidate profile and checks for an in-progress attempt. if one exists, the dashboard offers a resume path.
again, this is not glamorous. but it is the difference between a practice app and a demo pretending to be a practice app.
content needed a boundary too
exam apps can get irresponsible very quickly around content.
people copy past questions from somewhere, mix them with generated questions, lose the correct answers, forget which source is licensed, and then the software looks more complete than it is.
i did not want that.
the starter bank in tryCBT is clearly treated as starter content. it validates the full exam workflow, but it is not presented as a licensed comprehensive JAMB archive.
the question format records subject, topic, options, correct option, explanation, difficulty and source type. the validator rejects malformed questions, duplicate IDs, missing option keys, invalid correct answers and missing passage references.
that gives the content pipeline a spine.
not every project needs a perfect content system on day one. but every educational product needs honesty about what its content is and where it came from.
one codebase, two distributions
the web app is deployed as a Cloudflare Worker with static assets at trycbt.nsisong.com.
the same application is also packaged with Tauri for desktop. the Rust side stays intentionally small; the product logic remains in the TypeScript app.
i like this shape because it avoids building two products too early.
the candidate gets a browser version and a Windows desktop version. i get one domain model, one content validator, one persistence strategy and one UI codebase to maintain.
PWA support gives the browser version offline behavior after the first successful load. Tauri gives a more native desktop packaging path for people who prefer an installed application.
the product does not need to pretend these are fundamentally different systems.
what i verified
for the first checkpoint release, the useful checks were not just "it builds."
the repo has TypeScript checks, linting, unit tests, question-bank validation and browser-flow tests.
the browser-flow test walks through the thing a candidate actually does:
- set up a profile;
- read instructions;
- start the mock;
- answer with keyboard controls;
- navigate;
- refresh;
- resume;
- switch subject;
- open and cancel submission;
- submit;
- review unanswered questions.
that test matters because exam software is mostly state transitions. a lot can look fine if you only click the happy path once.
what i like about the project
i like that tryCBT is useful without being loud.
there is no unnecessary account system. no fake AI layer. no overbuilt admin panel. no backend added just so the architecture looks bigger.
it is a focused product around a real workflow:
textstart practice -> answer questions -> survive refresh/restart -> keep timer honest -> submit safely -> review results
that is the kind of software i respect more as i build: small enough to understand, serious enough to trust, and honest about the parts that are still early.
the question bank can grow. content licensing can become more formal. sync can come later if it is worth the tradeoff.
but the core behavior is already shaped like real software.
and that was the point.