Live: https://mentor-ai-bice.vercel.app
An offline-first web tutor for grades 6–8, covering math and ELA. Students work through lessons, quizzes, and timed "boss battles" with no network connection — all content and progress live in the browser's IndexedDB. When a connection is available, Gemini generates additional lessons and answers tutor questions.
Built at a 48-hour hackathon in April 2026 by a team of four, and continued since as a solo project.
The target device is a cheap Android phone — roughly 2018-era, 2GB of RAM, on an intermittent connection or none at all. That constraint drove most of the technical decisions, and it's worth stating up front because several of them look like omissions otherwise:
- No UI framework. The app is vanilla TypeScript with direct DOM manipulation. On this hardware, framework parse-and-execute time costs more than the download does. React or Vue would have been faster to write and slower to run.
- Network is never on the critical path. Every student action — answering a question, finishing a lesson, earning XP — completes against local storage. Nothing blocks on a request.
- TypeScript in strict mode, because types compile away and cost nothing at runtime.
The production bundle is 46.4 kB gzipped of JavaScript and 6.6 kB gzipped of CSS, plus ten lazily-loaded lesson chunks of roughly 0.6 kB each.
| Language | TypeScript (strict) |
| UI | Vanilla DOM, no framework |
| Storage | IndexedDB via Dexie 4 |
| Build | Vite 8 |
| Offline | vite-plugin-pwa (Workbox), 27 precached entries |
| AI | Google Gemini 3.6 Flash, behind a server-side proxy |
Dexie is the only runtime dependency. Everything else — routing, rendering, state, animation, icons — is hand-written or CSS.
Requires Node 20.19 or newer — the floor Vite 8 sets. CI runs the suite on 20 and 24.
npm install
npm run devTo build and preview the production output:
npm run build
npm run previewTo run the test suite:
npm testThe key is held server-side and never reaches the browser. Copy .env.example to .env in
the project root:
GEMINI_API_KEY=your_key_here
The name deliberately has no VITE_ prefix. Vite inlines every import.meta.env.VITE_*
value into the public bundle at build time, so a VITE_-prefixed key would be readable by
anyone who opens devtools. This one is read only in Node — by the serverless function in
api/gemini.ts in production, and by a small dev middleware in vite.config.ts locally, so
npm run dev and npm run preview both work without extra tooling. The browser only ever
talks to a same-origin /api/gemini.
When deploying, set GEMINI_API_KEY in your host's environment variables, not in the repo.
The app runs fine without one. The ten bundled lessons are seeded into IndexedDB independently of Gemini, so the entire lesson, quiz, and boss-battle flow works. Only the tutor chat and the "Generate a new lesson" button need a key — without one the tutor returns a connection message and a generate request lands in the sync queue, where it retries and is eventually dropped.
The piece of this project worth reading is utils/offline.ts.
The problem it solves: a student answers a question correctly, which should trigger a Gemini call to generate a harder replacement question. But the student is on a bus with no signal. The answer must still be recorded, XP must still be awarded, and the Gemini work has to happen later without the student ever waiting on it.
The approach is a durable job queue in IndexedDB rather than in-memory retries. In-memory retries die with the tab, and on a phone the tab is killed constantly — backgrounded, low-memory, browser restarted. Writing the intent to disk first means a queued job survives all of that.
student action ──▶ IndexedDB write (immediate, always succeeds)
│
└──▶ syncQueue row ──▶ drain on reconnect ──▶ Gemini
The queue. A Dexie table (syncQueue) where each row is a SyncQueueItem:
{ id, type, payload, timestamp, retries }type is one of four jobs — replace_question, generate_lesson, progress_report,
get_feedback — which processSyncItem routes to the matching Gemini call. Items drain
oldest-first by timestamp.
Drain triggers. Three, deliberately different:
| Trigger | Timing | Why |
|---|---|---|
online event |
debounced 1500 ms | Reconnects flap. Debouncing avoids firing into a connection that's still settling. |
visibilitychange |
immediate | The tab was backgrounded and is now foregrounded; the network is likely already stable. |
| App startup | immediate | Catches jobs queued in a previous session that never drained. |
Concurrency. A syncInProgress flag guards re-entry, so overlapping triggers can't
start two drains against the same rows. Items are processed serially, not in parallel —
on the target hardware, and against a rate-limited API, sequential is the right default.
Failure handling. A failed job increments retries and pauses 2000 ms before the next
item. At MAX_RETRIES (3) the job is dropped so one poisoned row can't block the queue
forever. If the connection drops mid-drain, the loop breaks and leaves the remaining rows
for the next trigger.
Timeouts, measured — and re-measured after the first answer turned out to be wrong.
Generating a five-question lesson with gemini-3.6-flash took 10.8s, 12.5s, 21.3s, 41.1s
and 51.8s across five runs. Both the client and the proxy were originally capped at 15s, so
the tail was cut off — and because both used the same 15s they raced, with the client
usually winning and turning the proxy's clean 504 into an opaque AbortError. Raising the
proxy to 30s and the client to 35s fixed the race.
It did not fix generation, and deploying proved why. generate_lesson came back as
504 FUNCTION_INVOCATION_TIMEOUT at 25.1s: Vercel caps a Hobby Edge function at 25s, so
a 30s abort could never fire. The platform killed the invocation first and answered with a
text/plain error page — neither the function's own JSON nor a status the client could
classify. Switching runtime buys nothing; Node functions on Hobby cap at 10s, so Edge is the
widest window available rather than a compromise.
Two changes, in order of certainty:
- The proxy aborts at 22s and the client waits 27s. 22s fires ~3s inside the platform ceiling, so the decision stays in application code and the caller gets structured JSON.
- Generated lessons ask for three questions, not five, and a shorter body. Output tokens
are the dominant latency term. Constraining the model's thinking was tried first and
measured worse (
thinkingLevel: "low"at 41.1s against a 21.3s baseline) — two samples against a noisy distribution, so it proved nothing and was dropped.
| 5 questions | 3 questions | |
|---|---|---|
| samples | 10.8, 12.5, 21.3, 41.1, 51.8s | 8.5, 9.5, 11.5, 14.2, 16.0s |
| median | 21.3s | 11.5s |
| worst | 51.8s | 16.0s |
| inside the 22s abort | ~2/5 | 5/5 |
The ten bundled JSON lessons still carry five questions; this only shapes what the model is asked to write. Anything still too slow is left to the queue, and because the latency varies this much, a retry genuinely resamples rather than repeating a doomed call.
withOfflineSupport wraps a call so it runs immediately when online and enqueues a
fallback when not, which keeps the branching out of the calling code. Its caller is the
"Generate a new lesson" button on each subject page: pressing it online writes the lesson
immediately, and pressing it with no connection — or with the proxy unreachable — turns
the request into a queued row and tells the student so. That is the whole engine in one
button, which is also what makes it demonstrable.
This replaced a preload.ts that generated ten lessons automatically on first boot. That
put the network on the boot path, and on a public deployment it spent ten generations of
quota per visitor who never asked for any of them.
Two ways in. Quiz answers and boss answers both route through markQuestionAnswered,
the only place in the app that enqueues work: a correct answer below max difficulty queues a
replace_question, and acing every question in a lesson queues a generate_lesson for the
next topic. The boss battle fed nothing until its question pool started carrying the lesson
id an answer has to be attributed to.
Visible state. A dot in the top-right corner is green online, red offline, and pulses gold
while the queue drains. It is mounted on <body> rather than the app root, because navigation
replaces the root's innerHTML wholesale and would otherwise destroy it mid-drain.
Tests. utils/offline.test.ts covers the queue's control flow —
work held offline and flushed on reconnect, a flapping connection debounced into one drain,
overlapping drains processing each item once, retry counting, the MAX_RETRIES give-up, a
connection lost mid-drain leaving the rest queued, and a throwing UI listener not stranding the
queue. The Gemini layer is mocked; Dexie runs for real against fake-indexeddb, so retry
counters are asserted against actual IndexedDB rows.
src/db.test.ts covers the layer underneath — the idempotence gate that
stops a retake re-queueing every question it re-answers, the difficulty and topic-catalogue
bounds on what gets enqueued at all, and the XP totals the profile is rebuilt from. The
version(1) → version(2) upgrade has its own file, because
testing it needs a database that has never been opened at version 2.
Navigation is the URL. src/router.ts is 178 lines with no dependencies:
it parses location.hash into a Route, writes one back, and hands every change to a
single resolver in main.ts. It costs about 0.5 kB gzipped — 45.9 kB to 46.4 kB — because
the click delegator and the workarounds it forced came out in the same change.
#/ dashboard
#/math #/ela subject lesson list
#/lesson/:id lesson + tutor chat
#/quiz/:id quiz in progress
#/boss/:subject boss battle
#/progress progress
#/settings settings
parseHash is total — every input resolves to something, and anything it cannot read falls
back to the dashboard and rewrites the address bar to match. A student's URL bar is
untrusted input like any other: ids are checked with Number.isInteger(n) && n > 0 rather
than coerced, because parseInt('7abc') would have routed a typo to lesson 7.
applyRoute is the only thing that writes navigation state. Screens no longer set a page
and re-render; they set the hash, and the hash lands there — which is what keeps the
address bar and the screen from disagreeing on Back, Forward and reload, since all three
arrive by exactly the path a click does.
Two screens hold state the URL cannot carry, and they answer a cold link differently:
#/quiz/:idis resumable only while the attempt is in memory. The answers so far, the hints left and the running score are module state inquiz.ts. Reloading or stepping Back into a quiz therefore lands on#/lesson/:idrather than silently restarting from question 1 with the score wiped.#/boss/:subjectalways starts a fresh battle. It belongs to no lesson and carries nothing to lose — it is a three-minute challenge over the whole subject, so the link is worth sharing.
Summary screens are deliberately transient. They have no URL of their own; the hash still
reads #/quiz/7 underneath, so a reload resolves the route beneath rather than re-showing
a score for an attempt already written to IndexedDB.
Onboarding is unrouted. It exists precisely because there is no student yet, so there is nothing to link to — every hash resolves to it until a profile exists.
Two things fell out of this. render() is now synchronous: every fetch a route needs is
awaited in applyRoute first, so the old hazard of awaiting between emptying #app and
appending to it — which let two renders each append a layout — is prevented by the
signature instead of by a comment asking for it. And a document-wide click delegator is
gone. It caught every [data-page], [data-action] and [data-route] and fed the value
to the navigator as though it were a page name; most were not (start-lesson-7,
continue-math, back), so it matched nothing, changed nothing, and re-rendered anyway.
Every Start Lesson click rendered the app root twice. Three screens carried comments
explaining which attribute names they had to avoid to stay out of its way; those are gone
with it.
One service worker, generated by vite-plugin-pwa in autoUpdate mode. A second
hand-written public/sw.js used to ship alongside it — nothing registered it, and Workbox
emitted to the same dist/sw.js path, so it was silently overwritten at build time. It's
gone, along with the duplicate public/manifest.json that was being precached for nothing.
/api/gemini is on the Workbox navigateFallbackDenylist. The proxy is a live network
call and must never be answered from cache.
The claim is tested by killing the server outright rather than by throttling devtools:
npm run build && npm run preview # load once, then stop the serverWith nothing listening on the port, a hard reload still serves index.html, the JS and CSS
bundles, every lazily-loaded lesson chunk, the manifest and the icons — all from the
service worker — and onboarding through to the dashboard works against IndexedDB. A
fetch('/api/gemini') in the same state fails, which is the denylist behaving correctly.
App icons are generated by scripts/generate-icons.mjs —
npm run icons — rather than committed as opaque binaries. It rasterises the mark and
encodes the PNGs with node:zlib alone, so no image library enters devDependencies for
four files. One full-bleed design serves both any and maskable because the mark sits
inside the 80% safe zone Android crops to.
The browser never sends prompt text. It names an action and passes typed parameters:
POST /api/gemini { action: 'generate_lesson', params: { subject, grade, topicIndex } }api/prompts.ts validates the parameters and assembles the prompt, and
api/gemini.ts forwards it. A request that fails validation is rejected
before any upstream call, so a bad one costs nothing.
This replaced a proxy that relayed whatever prompt string it was handed. It did check the
Origin header — but Origin is a header, and anything that isn't a browser sets it freely,
so the deployed function was a general-purpose Gemini endpoint billed to this project's key.
The origin check is still there; it is now a speed bump rather than the only thing standing
between the deployment and someone else's workload.
Three details worth naming:
- Topics are indices, not strings.
generate_lessontakes atopicIndexinto the shared catalogue insrc/topics.ts, which the proxy resolves against its own copy. The only thing a caller influences about that prompt is which of a fixed list of strings gets used. replace_questionsends no topic at all. It used to passlesson.title, which on a generated lesson is model output — so the model's own words became the topic line of the next prompt. Subject, grade and difficulty are enough, and none of them are free text.- The key moved from the query string to a header.
?key=ends up in access logs, proxy logs and error reports;x-goog-api-keygenerally does not.
Model output is treated as untrusted input on the way back. JSON.parse(...) as Question
was a promise to the compiler, not a check on the value.
src/utils/validate.ts verifies the shape by hand — no zod, which is
~13 kB gzipped against a 47 kB bundle on a device chosen for being slow. A response that fails
makes generateLesson throw, and because processSyncItem already catches and returns false,
the sync engine turns that into a retry and then a drop with no changes to it.
It also reaches the DOM as untrusted input. Every screen builds markup with template strings
and innerHTML, so lesson titles, question prompts, choices and hints are markup unless
something escapes them. src/utils/escape.ts is applied at every
interpolation of student or model text; it previously existed as three identical private
copies, which is how it ended up covering 6 of 33 sites.
The rule here is measure first, and report what the measurement says even when it says the
planned optimisation was not worth doing. Raw Lighthouse reports and the method are in
perf/.
Lighthouse, mobile preset, 6x CPU throttle, against the local production preview so the deltas isolate code changes from CDN variance:
| metric | before | after |
|---|---|---|
| Performance score | 99 | 99 |
| First Contentful Paint | 1.7s | 1.5s |
| Speed Index | 1.7s | 1.5s |
| Main-thread work | 0.4s | 0.3s |
| Render-blocking requests | 3 | 2 |
| Third-party origins | 1 | 0 |
The score did not move because it was already 99 with 0ms of Total Blocking Time. A 47 kB bundle with no framework does not have a cold-load problem, which is itself the answer to "why no framework".
Self-hosted fonts. The largest single blocker was not the app. Lighthouse measured the
Google Fonts @import at 785ms of render-blocking time and 65.7 kB of transfer — more bytes
than the entire JS bundle. scripts/fetch-fonts.mjs pulls the
latin subset locally; Google serves eight subsets for a UI that is English-only.
The first attempt made it worse: writing one file per weight produced five byte-identical
copies of Inter, because Inter is a variable font and Google returns the same file for every
weight. 274 kB, against the 65.7 kB it was replacing. Deduplicating by URL and emitting one
font-weight: 400 800 rule brought it to 85.5 kB on disk, of which a given page fetches only
the weights it uses — 64 kB on the onboarding screen.
Lighthouse predicted ~484ms of savings. The measured result was ~200ms off FCP and Speed
Index. The durable win is the other one: zero third-party origins, and woff2 added to
the Workbox glob so typography now survives offline instead of dropping to system fonts.
A compound index, and an untested migration. getLessonsForGrade runs
where({ grade, language }) on every subject navigation, and Dexie was warning at runtime
that it wanted a [grade+language] index. Adding one requires a new schema version:
| rows | before | after |
|---|---|---|
| 11 (a real student) | 0.5ms | 0.5ms |
| 3,011 (synthetic) | 12.5ms | 5.8ms |
It earns nothing at real data sizes. It is in the schema because the query is O(rows)
without it, and because version(1) with no migration ever run is a migration path nobody
has tested.
The optimisation that was planned and then abandoned. This README used to claim the full
innerHTML rebuild on every navigation was "a visible stutter on a Snapdragon 400". It is
not. At 74 DOM nodes a full teardown measures 1.1ms; the sidebar, which is rebuilt despite
nothing in it changing but one class, is 0.4ms of that. Even multiplied by 6 it stays inside
a 16.7ms frame, and Lighthouse independently reports 0ms Total Blocking Time. Keeping the
sidebar mounted would have saved ~0.4ms and added a class of staleness bug, so it was not
done and the claim was deleted.
One real bug, found while measuring it. Clicking "Start Lesson" replaced the app root
twice. setupSubjectPageHandlers ran inside render() — before app.appendChild(layout)
— so document.querySelector found nothing, and a 100ms setTimeout was papering over the
race. It also double-bound: the screen already wires that button through the callback it is
passed, so one click ran two conflicting handlers, rendering the quiz screen and immediately
replacing it with the lesson screen. Both deferred-binding helpers are gone, the screens own
their wiring, and one click now renders once. progress.ts gained the onNavigate call it
declared and never made.
This section is deliberately specific. Nothing below is fixed yet.
- Generated answers are checked for shape, not for truth.
isValidQuestionproves a question is presentable — four non-empty choices,correctIndexan integer inside the array, difficulty in range. Nothing verifies the marked answer is the mathematically correct one. A confidently wrong question still reaches the student; it just can't crash the grader any more. - The prompts still ask for JSON rather than using
responseSchema. Gemini's structured output would make malformed responses a non-event instead of something the validators have to catch. There is also no retry on 429 or 503 — those become failed queue items. - Rate limiting is per-instance. Two counters in
api/gemini.ts— 8/min per caller and 10/min for the instance, the latter sized to the Gemini free tier's ~10 requests a minute for this project. But edge instances do not share memory, so N instances permit N times the global ceiling. It approximates well for the traffic a portfolio demo sees and is not a real quota; that needs Upstash or Vercel KV. The actual protection against abuse is that there is no prompt parameter to abuse. - There is no teacher view, by design. A
src/screens/teacher.tsexisted but read the same browser's IndexedDB, so it could only ever show the student sitting at that device. It was unreachable from the UI and has been deleted rather than left as dead code — a real cross-device view needs a backend, which is out of scope for this project. - The boss battle still rebuilds its question pool on every render. Unmeasured, and likely irrelevant at this data size — but it is the one hot path Day 3 did not put a number on.
- Persistent storage is requested, not guaranteed.
navigator.storage.persist()runs at boot, but the browser decides. Chrome grants it on site-engagement heuristics, so a first visit is typically refused and an installed PWA typically is not. Nothing in the app can force it; without it, an OS low on space may evict IndexedDB and take a student's whole history with it. - Free-text prompts are mitigated, not solved. The tutor chat and writing feedback take text the student typed, which no enum can constrain. It is length-capped, delimited, and labelled as data with an instruction not to follow it — which lowers the odds of a successful injection without eliminating them. Everything else the app sends is an enum or an index.
- Test coverage stops short of the screens. The sync engine, the local database, the schema migration, the proxy, the prompt builders, the output validators, the escaper, the router and the failure classifiers are covered — 241 cases. The screens themselves are not: the router's tests cover parsing and dispatch, not what gets painted afterwards.
Built April 25–26, 2026 at a 48-hour hackathon by a team of four. The state at submission is
tagged v0.1-hackathon.
Everything after that tag is solo follow-on work.