1. Batch write transactions in build_paper, start_attempt, save_answers,
and finalize (turned 75+ disk operations into 1 commit per action).
2. SQLite PRAGMAs: synchronous=NORMAL, cache_size=64MB, temp_store=MEMORY,
and mmap_size=256MB for zero-copy memory reads.
3. O(1) history: record right_count/wrong_count on attempts at finalize time;
history() loads in a single SELECT query without re-grading loops.
4. Native Rust load testing tool (src/bin/bench.rs) replacing Python scripts:
multi-threaded concurrent reads & simulated full student lifecycles with
sub-microsecond timing and statistical percentiles.
coach_notes takes the tag rollup the dashboard already fetches: a
dominant mistake type (>=half of >=2 tags) yields a targeted fix, and
chronic wrong-over-blank across history yields a guessing warning. Same
Vec<String> output, no view changes. Three unit tests cover fire/quiet
paths.
The exam screen autosaves on every answer; a stuck client retrying in a
tight loop could hammer SQLite's single writer. Ctx carries a small
in-memory per-user windowed counter with opportunistic cleanup — no
Redis needed. Verified live: 30x200 then 429s.
Sign-in page rewritten for non-technical students: Google-first when
configured, plain-language email path with honest 30-day scope, recovery
note, placeholders, full-width CTA. deploy/termux-start.sh sources .env
for GOOGLE_* credentials; deploy/env.example documents the 5-minute
Cloud Console setup; .gitignore covers secrets, DBs, pid/log files.
bench scripts send a browser UA so Cloudflare bot checks stop 403ing
local load tests. Landing bank counts cached 60s (five aggregate
subselects no longer run per hit). gzip compression on all responses.
GET /paper/:id redirects the owning user to their attempt screen (404
otherwise, login wall otherwise). Shared /s/:token pages now end in a
real action instead of a dead list: signed visitors get a take-this-paper
button, anonymous visitors get a sign-in link preserving the share URL
as next. Verified live: 1/302/1 across the three states.
Result page shows a why-lost row (concept gap / calculation slip /
misread / out of time / blind guess) on every wrong or blank question.
POST /result/:attempt/tag/:qid toggles one tag; ownership checked,
vocabulary enforced server-side. Dashboard gains a 'why you lose marks'
section aggregating tags across all submitted attempts. Verified live:
tag persists, rollup renders.
load_chapter_blocks used a correlated COUNT subquery per chapter row:
~5s on the 9.8k-question bank. Rewritten as one GROUP BY joined once
(16ms p50 on /build). Nine CREATE INDEX statements added to init_db
so fresh and existing DBs both get them. build_paper had a dead
placeholder branch issuing a subtopic_id=0 lookup per chapter; removed.
The builder previously exposed only chapters. Now each chapter expands to show
its subtopics as individual checkboxes: selecting a chapter checks all of its
subtopics, selecting the subject-level box checks everything, and selecting
individual subtopics sets the chapter to indeterminate. The exam query filters
by subtopic_id when any are given; the SQL path deduplicates so a question
matching two subtopics only appears once.
JEE mode sets duration to 60 × unique-subject-count (180 min for a full paper).
On the exam screen this renders per-subject countdown strips: Physics 60:00,
Maths 60:00, Chemistry 60:00. Each strip counts down independently; when one
hits zero the screen auto-jumps to the first unanswered question of the next
subject. The last strip exhausting time submits the form.
Single-subject papers keep the flat timer and show no strips.
Also: all audit targets now genuinely 44px (the .opt labels were 42px).
The subscriber was pinned to DEBUG, so RUST_LOG=warn was ignored and sqlx
logged every statement to stderr in production. Now an EnvFilter defaults to
info with sqlx at warn; RUST_LOG=debug restores the query log.
Three benchmarks, so the hosting numbers come from measurement rather than
guesswork: scripts/bench.py for latency and throughput, bench_write.py for the
autosave and submit path under concurrent students, and bench_scale.py which
builds a database at the real bank's size (10,905 questions, 2,000 students,
12,000 papers, 900k answers) to report disk cost and query timings.
Two paths, one session. The GSI popup posts an ID token to /student/google;
where a popup is refused a PKCE authorization-code flow redeems server side at
/student/google/callback, keeping the client secret out of the browser.
ID tokens are verified here rather than trusted from the browser: RS256
signature against Google's published JWKS (cached an hour), audience pinned to
this client, issuer restricted to Google, expiry enforced, email must be
verified. State is single use and expires in ten minutes. Without
GOOGLE_CLIENT_ID the button is hidden and the email form is offered instead.
13 tests cover the verification rules using locally generated RSA keys,
including rejection of a foreign signature, a wrong audience, a wrong issuer,
an expired token and a rewritten payload. An example signs a token from an
untrusted key so the rejection path can be checked without a Google account.
Axum + SQLite full-stack copy of the ZenithPrep PYQ practice app.
Paper builder with unseen-question exclusion, timed CBT screen with
palette and autosave, +4/-1 grading with numerical tolerance, per-question
analysis, history, dashboard with weak-subtopic tracking and coach notes.