Repository navigation
Platform rebuild: connect the frontend to the backend, and fix what that exposed - #17
Merged
Merged
Conversation
Phase 0 of the platform rebuild: remove everything the app asserted but could
not back, fix the bugs that corrupt a user's own results, and stop shipping
1.3 MB of JavaScript to every route.
Truthfulness
- Landing page presented "10,000+ Practice Questions", "95% Pass Rate",
"5,000+ Students" and "12 Subject Areas" as current metrics. Real figures
are 2,801 questions across 10 chapters, and no pass-rate data can exist
because there is no backend. The block is now explicitly labelled "Our 2026
goals" with the live figures stated directly underneath, derived from the
generated chapter manifest so they cannot drift.
- Removed the claim of a track record ("engineers who have successfully
passed ... with our platform") and of a community that does not exist.
- Removed leaked internal copy ("stoplight-ready proof of concept") from the
name dialog, and relabelled its button, which said "Continue to login"
while navigating to /app.
- ch4 was labelled "Operating Systems & DBMS" but contains computer
organization and embedded systems content (cache x229, embedded x89,
RISC/CISC x23, while SQL, normal form and file system appear zero times).
Relabelled to match the NEC ACtE04 syllabus chapter.
- bookChapters.generated.ts claimed ch4 had 57 questions against 424 actual;
regenerated.
Correctness
- Quiz navigation destroyed the user's answer. handleNext/handlePrev reset the
scalar selectedAnswer to null while restoring showResult from the answered
set, so revisiting an answered question compared null against correctIndex
and rendered "Incorrect" on questions the user had got right. Selections are
now one slot per question. Fixed in TopicPractice, QuickPractice and
CustomPractice; covered by regression tests.
- A mock test no longer loses a 45-minute exam on reload. The countdown was a
decrementing setInterval with no persistence, so a refresh reset the clock,
reshuffled the paper and discarded every answer, while the UI promised
"Your progress will be saved". The deadline is now absolute wall-clock time
and the attempt is persisted per answer and resumed on return; expiry
auto-submits whatever was answered.
- PracticeHub showed "0% complete" on every chapter: `const progress = 0 || 0`.
- Bookmark state was additive-only, so removing a bookmark left the in-session
set disagreeing with storage. Seeded once per question set instead.
- QuickPractice ignored ?count changes (missing dep) and could leave the
spinner up forever by returning before .finally.
- AppShell never closed the mobile drawer on navigation despite a comment
saying it did (empty deps, no useLocation).
- Logout cleared localStorage without clearing context, so returning to /app
still greeted the logged-out user.
- Guarded four score/questions.length divisions that could render "NaN%".
- Reset Progress cleared storage with no visible effect, and left any
in-progress mock draft behind.
Dead and dishonest UI
- Settings had four toggles (Email Notifications, Dark Mode, Sound Effects,
Auto-Advance) that were useState-only and controlled nothing; Dark Mode
implied a light theme that does not exist. Section removed rather than
shipping switches that lie. Real preferences land with the design system.
- Export Your Data was a dead button; it now downloads a JSON export.
- Footer linked to /terms, /privacy and /support, all of which bounced to the
landing page. Replaced with the repository, issue tracker and licence, plus
a factual statement of what is stored and where.
- "Sign In" navigated to /auth/login, a route that does not exist, so the
catch-all silently returned the user to the landing page. Removed until real
auth exists. "Learn More" had no handler; it now scrolls to the features
section, respecting prefers-reduced-motion.
- Removed three unreachable loading branches (`const loading = false`).
Performance
- Deleted ThreeBackground: 5,000 particles, three animated meshes and a 64x64
sphere on a useFrame loop that never paused, rendering as a flat black field
in practice. It was also mounted inside the app shell, not just the landing
page. Dropped three, @react-three/fiber, @react-three/drei and @types/three
(56 packages).
- useChapters re-fetched all ten chapter JSONs at runtime (~1 MB,
cache-busted with ?t=Date.now() so it could never be served from cache)
purely to read data.length, duplicating counts already in the generated
manifest. Eleven pages called it.
- Route-level React.lazy for everything behind /app, plus vendor chunking by
resolved path (the string-array form of manualChunks missed react-dom/client
and left the reconciler in the entry chunk).
- Initial-load JavaScript: 1,363 kB in one chunk (381 kB gzip) ->
363 kB across five cacheable chunks (118 kB gzip), a 69% reduction.
- index.html loaded the AdSense script above <meta charset>; moved to the end
of body. Added description, theme-color, OG tags and font preconnect.
Tooling
- ESLint was never run by any pipeline, which is how 15 errors reached main.
Wired into the build script; all 15 fixed, including 7
react-hooks/set-state-in-effect (resolved by adjusting state during render
rather than in effects) and a ternary abused as an expression statement.
- Added Vitest, jsdom and React Testing Library. First tests cover the quiz
navigation fix; verified by reintroducing the bug and confirming all three
fail.
- Split useUser out of UserContext.tsx so the module exports only a component.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r model Second phase of the platform rebuild. Ten parallel workstreams on disjoint file scopes: question-bank integrity, a token-based design system, accessible UI primitives, a shared quiz engine, the NEC syllabus taxonomy, a learner model, accessibility repairs, CI, and documentation. Question bank: 2,801 -> 2,312 rows - Deduplicated 332 groups where every copy keyed the same answer (489 rows removed), keeping the occurrence in the lowest-numbered chapter. This also resolves the 192 analog-electronics questions that had been pasted from ch1 into ch2, since ch2 held the later copies. - Did NOT deduplicate 59 groups whose copies key materially different answers (12 numeric contradictions, 46 divergent prose, 1 invalid key). Picking a copy would have been a coin flip on correctness, so all 128 rows are quarantined instead and recorded for adjudication. - Quarantine grew 23 -> 145 questions. Every entry is a question the app would mis-grade: 15 where correct_option names text absent from all four options, 2 with correct_option_index 5 against 4 options, 7 with duplicated option text, plus the 128 self-contradicting duplicates. - 13 resolutions recorded in duplicate-triage.json but deliberately NOT applied, because re-keying a licensing-exam question on derived physics is the owner's call: tau = RC = 100 us (not 1 ms), tau = L/R = 100 us (not 1 us), maximum power transfer efficiency 50 percent (not 25), phase angle at resonance 0 degrees (not 45), f0 = 1/(2*pi*sqrt(LC)) (not 2*pi*sqrt(LC)), unsigned 8-bit range 0..255 (not 0..256). Three are unanswerable as authored: an R-C network has no resonant frequency, a parallel-resonant question whose four options are all wrong, and a two-variable Karnaugh map whose fifth option was lost when the row was flattened to four columns. - ch2 and ch9 chapter names contradict the official syllabus (ch2 is "Digital Logic and Microprocessor", not "Digital Logic and Number Systems" - sub-chapters 2.4-2.6 are microprocessor content the shipped name erases). Reported, not renamed, since chapter titles are owner-facing copy. Answer-position bias eliminated by construction - As authored, the correct answer sits in option 2 for 32.6 percent of the bank (chi-square 155 on 3 df), reaching 64.8 in ch9, 56.3 in ch6 and 50.4 in ch8, while ch3 puts 47.0 in option 1. Options were never reordered anywhere, so a student could score 65 percent on ch9 by blind-guessing "2". - Per-question seeded permutation was tried first and is not sufficient: it only removes the bias in expectation. One real draw left ch1 at 33.5 percent, and because seeds derive from array position, deleting a single duplicate row re-rolls every seed in the file. - Replaced with chapter-level balanced assignment in a new optionOrdering module: slots are computed, not sampled. Every chapter now measures 25-26 percent and bank chi-square is 0.02, down from 155. "All of the above" options stay pinned last. Assignment is least-loaded over a hash-scrambled traversal rather than round-robin, which balances equally well but would make consecutive questions cycle 1,2,3,4 - a new tell replacing the old one. - Ordering is deterministic because stored answers are option indices; a permutation that varied between loads would re-point every saved answer. - The validator now imports this module directly from TypeScript rather than mirroring it, so the two cannot drift. CI moves to Node 22 for native type stripping. - Bias tests tightened, not loosened: chi-square below 1 (was 7.81), worst slot below 0.26 (was 0.28), and the n >= 100 skip removed - it had been exempting ch5, ch6, ch7 and ch9, and ch7 (35.8) and ch9 (35.2) were the worst chapters in the bank. Design system - Tailwind v4 theme token layer: one neutral and one brand ramp, semantic tokens for light and dark, 8 type steps (from 32 font sizes), 5 radii (from 17, including a 13px), a 4px spacing scale, mobile-first breakpoints. - Fixes the contrast failure: --text-muted #71717a measured 4.09:1 on the page background and 3.86:1 on --bg-secondary, failing AA across 65 usages. Now 7.66:1 dark and 7.41:1 light. Also found --accent-primary at 4.40:1 styling every link, and added a separate accent-text token at 6.59:1 and 7.57:1. - Legacy variable names are aliased so the existing 6,175-line App.css inherits the accessible palette untouched. The alias block uses :root:root because App.css declares its own :root and bundles afterwards. - Global :focus-visible and prefers-reduced-motion, neither of which existed. - Inter self-hosted as a variable font, replacing a render-blocking import that chained DNS and TLS to googleapis then gstatic - worth roughly 0.6-1.0s on 3G. - PWA with the full question bank cached offline. skipWaiting is off: a silent reload mid-mock-test would lose answers. New modules - 19 accessible UI primitives, including a real WAI-ARIA RadioGroup with roving tabindex and arrow-key selection, and a readOnly review mode using aria-disabled so a revealed correct answer stays in the accessibility tree. Dialog has a focus trap, scroll lock and focus restoration. - Shared quiz engine: reducer-based state machine, per-question timing with guess detection, flag-for-review, question palette, keyboard shortcuts, autosave and resume, pre-submit review screen. - NEC syllabus taxonomy: all 60 official sub-chapter codes lifted from the syllabus constant that was previously decorative, with 10x6 enforced at type level. Exam blueprint models 100 questions at 1 mark as confirmed, and duration and pass mark as needs-verification - reading an unverified field without acknowledgement is a compile error. - Learner model: append-only IndexedDB response log, Elo ability and item difficulty, mastery with empirical-Bayes shrinkage that reports "not assessed" below n_eff 3 rather than 0 percent, FSRS-shaped scheduler, and exam readiness capped by coverage so low coverage cannot show high confidence. Accessibility - Quick Practice and Custom Practice were clickable divs with no role or tabIndex, making both features unreachable by keyboard. Now links. - Below 1024px the closed mobile drawer stayed in the DOM, adding roughly 10 invisible tab stops to every mobile page. Now inert. - Collapsed-sidebar nav links and the logout button had no accessible name at all when their label span was unrendered. - Landing modal claimed aria-modal with no focus trap, initial focus, Escape handler, scroll lock or focus restoration. All added. - Hero gradient dropped to 4.42:1 at its worst point; reduced to one short word on a ramp measured at 6.62:1 minimum. - Bookmarks used a tablist role with no tabs; replaced with honest toggle buttons, since the filter has no tabpanel to point at. - Removed the search input and notifications bell, which were focusable dead ends wired to nothing. CI and docs - First .github in the repo. Jobs gate types, lint, tests, build, the question bank, and a 180 kB gzip budget on initial-load JavaScript, chosen because the audience is largely on entry-level Android and 3G. - README corrected: it advertised 1,380+ questions across 10 topics whose JSON files were deleted in 7ba0454, documented two data files that no longer exist, listed Firebase as optional auth when it is not a dependency, and documented an auth screens directory that has never existed. 358 tests passing (was 0 before this branch), tsc and ESLint clean, build green, question-bank validator exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rafts Two corrections surfaced while finishing the quiz engine and learner model. Adaptive difficulty target had the sign inverted The specification this was built from said `targetElo = chapterTheta + 240` would yield "roughly 80% success probability". Under the same document's own Elo formula, `expected = 1/(1 + 10^((itemElo - theta)/400))`, an item 240 points ABOVE the learner gives 0.201, not 0.8 - the learner would have been served questions they get wrong four times out of five. Verified both directions: itemElo = theta + 240 -> 0.201 itemElo = theta - 240 -> 0.799 elo for exactly 0.80 -> theta - 240.8 The 80% intent is kept and the magnitude is unchanged; only the sign moves. TARGET_SUCCESS_PROBABILITY is exported as the knob, the discrepancy is documented inline at the constant, and a test asserts both the chosen direction and that an item at theta+240 scores below 0.25. 80% is the band chosen for mastery practice deliberately: it is not the information-maximising 50% an IRT selector would pick, because a learner who gets half their practice wrong stops practising. clearAllData left the new quiz drafts behind Reset Progress swept only `eng_mock_draft_v1:`, which was the whole scheme when it was written. The quiz engine now writes `eng_quiz_draft_v1:`, so a reset would have cleared progress, bookmarks and history while leaving a resumable in-flight attempt - the user would delete everything and then be handed their old exam back. Both prefixes are now swept, importing the constants from the engine rather than re-typing the strings. 358 tests passing, tsc and ESLint clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…scoring The backend, built on Cloudflare Workers + D1 rather than Supabase. Cloudflare has no first-party managed Postgres - it partners with PlanetScale (no free tier) and routes Neon via Hyperdrive, a pooler - so D1 (serverless SQLite) is the only first-party option. D1 has no row-level security, which is a deliberate trade rather than a concession: there is no client-direct-to-database path at all, so the answer key has no exposure surface by default. ADR in docs/adr/0001-cloudflare-d1-over-supabase.md. NOT YET RECONCILED. The schema and the API were written concurrently against different assumptions and do not yet agree. Committed together so neither is lost, with the full delta recorded in worker/README.md and at the foot of worker/src/data/tables.ts. Four blockers, the first of which is silent: timestamps are seconds in the schema and milliseconds in the API, so a 45-minute deadline would expire in 45 milliseconds. Also: no anonymous-guest identity (users.email is NOT NULL), four tables the API needs are absent, and attempts has nowhere to hold a pre-deadline answer snapshot. A reconciliation pass follows; do not deploy this commit. Schema: 28 tables, 65 indexes, 11 triggers, 1 view, FTS5 search Verified three ways - node:sqlite 3.54, `wrangler d1 migrations apply --local` against Miniflare D1 (7/7 migrations, 28 STRICT tables), and 59 smoke assertions against the resulting file. A verifier confirms 308 columns, 65 indexes, 49 foreign keys and 35 enums identical between db/schema.ts and the SQL, including every TypeScript literal union against its CHECK ... IN (...). Seeded from the real bank, measured not estimated: 2,312 source rows -> 2,290 questions (22 exact duplicates collapsed), 9,160 options, 2,289 answer keys, 125 errata reports filed for the defects, 2,190 published / 100 flagged. The importer aborts if any published question lacks a key; that count is 0. The answer key is isolated structurally, not by convention: `questions` has no answer column and `question_options` has no is_correct. The key exists only in `question_answer_keys`, so `SELECT * FROM questions JOIN question_options` has nothing to leak, and disclosure requires naming that table in a FROM clause. A CHECK constrains a key to options of its own question. Two SQLite bugs the verification caught, both of which would have failed in production rather than in review: - `ON CONFLICT (user_id, dedupe_key) DO NOTHING` fails at prepare() against a PARTIAL unique index; SQLite requires the predicate restated. - The streaks CHECK `longest_length >= current_length` rejected the very statement that extends a streak. Replaced with a trigger ratchet. Archival is designed in rather than discovered later: attempt_answers measured at 132.4 bytes/row (68% index), so 3.9M rows fit the 500 MB free tier - but the 100k row-writes/day cap binds first, at roughly 1,500 monthly actives, since an answered question costs 4 writes. 90-day retention with batched export-then- delete, and deliberately no DELETE trigger on attempt_answers so a purge cannot cascade into the rollups. A test asserts that trigger's absence. API: 34 endpoints, 88 tests, measured query costs Every handler's D1 query count is measured and asserted, not estimated, because the free plan allows 50 queries per Worker invocation. mock/start costs 10 and mock/submit 13 - identically at 12 questions and at 102, which is the assertion that matters. 102 answers insert as one `INSERT ... SELECT FROM json_each(?)`, and the submit commit is 6 statements in a single batch. The Db wrapper throws past 45 queries and past D1's 100-bound-param cap, so an N+1 breaks the test suite instead of production. Mock tests are no longer cheatable. Today MockTestQuiz.tsx fetches the chapter JSON including correct_option_index, scores client-side, and runs the clock as a client setInterval: devtools yields the whole key and freezing the timer costs nothing. Now the paper is chosen server-side, options are named on the wire by an opaque 60-bit CSPRNG ref minted per (attempt, option), and raw option ids never leave the server. The expiry clamp carries `AND ?now <= expires_at` inside the UPDATE, so a stored snapshot is pre-deadline by construction with no read-then-write race; a submit past the 10-second grace discards the payload and scores the snapshot. There is deliberately no answeredAt field on the wire - a client-asserted answer time is precisely what a cheater would forge. A security hole found and fixed during the work, and the reason the id scheme has to change: the schema mints option ids as `question_id || '#' || ordinal`, and the content endpoint initially returned those as wire refs. Because the ordinal rides inside the id, anyone could join it against the public JSON's correct_option_index and recover the entire key with no database access, and the per-attempt permutation is no defence. Fixed with a keyed, non-reversible browse ref; the test harness now seeds #-encoded ids so every test guards it. Note what cannot be fixed: for the 2,290 questions already public in git history, the key is irrecoverably public. Tests are attack-shaped rather than happy-path - read the key from the start payload, join option position against the git JSON, reuse a ref from another attempt, freeze the timer and submit an hour late, farm the practice reveal through another user's session. 88/88 passing on Node 22 node:sqlite and node:test with zero dependencies and no network. Known gaps: worker/src/index.ts imports hono, which is not yet in the manifest, so that one file will not bundle until it is installed. Request validation uses a Zod-shaped local module; swapping to real Zod is a rename plus deleting one file. Production D1 itself is unverified - no account is provisioned, and database_id in wrangler.toml is a deliberate placeholder. Frontend untouched: tsc, ESLint and all 358 tests still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app keeps working with no backend configured. isBackendConfigured() reads VITE_API_BASE_URL, and with it unset every call short-circuits so the existing localStorage path in localData.ts stays the only one in play. Request and response types are shared with the Worker rather than restated, so a schema change breaks the build instead of a request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…0 minutes Exam format confirmed by the project owner All four fields of examFormat are now `confirmed` rather than `needs-verification`: 100 questions at 1 mark each, 120 minutes, pass at 50 of 100. Provenance is `kind: 'project-owner'` with a deliberately null sourceUrl - the official NEC syllabus PDF carries no marking scheme at all, so citing a URL would manufacture an authority that does not exist. The mock-test configs no longer restate the duration. They shipped 45 minutes against a 120-minute exam, which made every per-question pace figure wrong by 2.7x: 27 seconds per question instead of 72. Duration is now read from the blueprint, and a test asserts the 72-second budget so the two cannot drift apart again. The short assessment is derived from the same seconds-per-question rather than carrying its own arbitrary number. Also corrected a false description: "Full Mock Test 2" claimed a "different selection to Mock 1", but both draw from the same pool with the same unseeded shuffle, so they are not distinct papers. Real non-repetition across attempts needs per-learner history, which belongs in the backend; the comment says so. The unverified-field machinery is kept and still tested, against a synthetic field rather than a real one. Negative marking and per-chapter weighting are still unknown, so the next constant added is likelier to be a guess than a confirmation, and the path that refuses to display a guess as fact should already work when it arrives. 24 answer-key, option and stem fixes applied The project owner authorised applying the 13 recorded resolutions. Every arithmetic and physics claim was re-derived first, and every edit asserted the row still matched the state its resolution assumed, so a shifted index fails the run rather than silently re-keying the wrong question. No rows were added or removed, so positional ids stay stable. Re-keys onto an option already present (8): - maximum power transfer efficiency 50%, not 25% (R_load = R_th, so source and load dissipate equally) - tau = RC = 1 kOhm x 100 nF = 100 us, not 1 ms - tau = L/R = 10 mH / 100 Ohm = 100 us, not 1 us - f0 = 1/(2*pi*sqrt(1 mH x 100 nF)) = 15.9 kHz, so 16 kHz not 160 kHz - phase angle at resonance 0 degrees, not 45 (reactances cancel, Z is purely resistive) - the resonance formula is 1/(2*pi*sqrt(LC)), not its reciprocal - binary vs priority encoder: the keyed option claimed a priority encoder has n inputs and log2(n) outputs, which is false - both types have 2^n inputs and n outputs, and the real distinction is priority resolution - unsigned 8-bit range is 0..255; the option text said 0..256 while correct_option already said 0..255 Stem rewrites where the question was unanswerable as written (3 groups): - "the voltage across the Inductor lags behind the current by how many degrees" - inductor voltage LEADS current by 90 degrees (v = L di/dt), so no option could answer it. Stem now says leads; both copies key 90 degrees. - "A parallel R-C circuit ... What is the resonant frequency?" - an R-C network has no resonance. Stem now asks for the cutoff (corner) frequency, and the option text that had lost its magnitude is restored to 1.59 Hz (1/(2*pi*100k*1uF) = 1.59 Hz). One copy's correct_option already said "1.59 Hz", which confirms the authored intent. - four questions shared one stem while testing four different facts (default initial value, where initialization may appear, access syntax), so the bank appeared to contradict itself. Three stems now say what they ask; no key was wrong. Option restored where it had been lost (1 group): - the two-variable Karnaugh map question had all four options valid and correct_option naming "21", an option dropped when the row was flattened to four columns. Restored and keyed, which also clears the last two correct_option_index: 5 rows. Quarantine 145 -> 118, with 27 questions released quarantine.json is now derived from the bank rather than carried forward, so a repaired question is released automatically and cannot linger as a stale exclusion that withholds a healthy question while serving a broken one. All 118 remaining entries were re-verified to resolve to a real row and to carry a defect re-derivable from the data, independent of the list itself. Two deliberate departures from the triage, both recorded in duplicate-triage.json: - book-ch3_368 was NOT released. The triage classified all four static-data- member questions as a harmless stem collision. That holds for three, but this one has two true options: the keyed "They are initialized to zero by default" and the distractor "They cannot be initialized inside the class declaration", the latter being exactly what book-ch3_362 keys as correct. A single-answer MCQ cannot have two correct options, so it needs an author, not a re-key. It is the only quarantine entry no automated rule can re-derive, and it carries an explicit OPTION_MULTIPLE_TRUE code for that reason. - book-ch1_236 / 274 were fixed, but by changing a premise rather than a key, so this one needs checking against the source. As authored the stem gives L = 20 mH and C = 5 uF, for which f0 = 503 Hz, and none of the four options is close. The options form a decade ladder around 3.57 kHz, which is exactly what 20 mH gives for C = 0.1 uF, so the stem's capacitance was treated as the typo. Changing one number in the stem is a smaller edit than replacing four options, but it is still a change to what the question asks. Twelve KEY_CONFLICT rows remain quarantined and unadjudicated - mostly binary and hexadecimal arithmetic in ch2, where correct_option names text absent from every option and no answer can be derived without the source material, because the keyed option and the stated answer may both be wrong. Bank: 2,312 rows, 2,246 unique stems. Post-ordering answer position is 25/25/25/25 in every chapter, bank chi-square 0.0. 356 tests passing, tsc and ESLint clean, build green, validator exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
questions whose answer keys contradict each other
The schema and the API were written concurrently against different assumptions
and did not agree. They are now one system: `npm run check` applies the
migrations, seeds, verifies schema.ts against the SQL, smoke-tests, runs the
API suite and bundles the Worker, and exits 0.
Timestamps are milliseconds everywhere
The schema used `unixepoch()` (seconds) while the API used milliseconds, so a
45-minute deadline would have expired in 45 milliseconds - silent, not loud.
Milliseconds wins because every consumer is JavaScript and the frontend's
response log, quiz drafts and deadlines are already milliseconds. All 8
migrations were swept, including the derived expressions a plain grep misses:
the session-duration column, the archival predicates, the SRS forecast and five
trigger bodies. Every time column now carries its unit in a comment, and tests
assert a 45-minute deadline is still 45 minutes away after a round trip, that a
sync at +44 minutes is accepted and +46 is not, and that the stored type is
INTEGER.
Answer-key isolation kept, and the id leak closed at its root
`question_options.is_correct` is gone from the API; `question_answer_keys` is
the only place a key lives, and one function is the only thing that names that
table. Option ids no longer encode the authored slot: they carry a 12-character
CSPRNG `opt_key` instead of the ordinal, because an id of the form
`question_id#3` could be joined against the public question JSON to recover the
whole key with no database access, and per-attempt permutation was no defence.
Per-attempt refs are now derived from a keyed digest rather than stored, saving
roughly 60 bytes on each of ~3M rows a year.
Anonymous guests, four roles, one attempts table
`users.email` is nullable with `is_anonymous`, plus CHECKs making "log in as a
guest" structurally impossible - the app's whole UX is "type a name and start",
and server-scored mocks must work without a signup wall. Roles are
student/contributor/moderator/admin. The API's split mock/practice attempt
tables collapse onto the schema's single `attempts`, which gained
`synced_answers` and `last_sync_at` under a CHECK that `last_sync_at <=
deadline_at`, so the pre-deadline property is the database's rule rather than
one handler's WHERE clause. Three hand-written upserts were deleted because the
existing triggers already maintain those rollups.
Also adopted the schema's hashed sessions: the API had been storing the cookie
value itself, so a database dump yielded usable cookies.
The blueprint is sub-chapter level, matching the frontend's 60 quotas, and is
derived from the same largest-remainder rule so the two cannot drift. Verified
against the seeded bank: a full mock draws 100 unique published questions, 10
per chapter, in 3 queries.
hono and zod are now real dependencies, and `worker/src` is type-checked for
the first time - it had no tsconfig at all. Migrating off the Zod-shaped local
module was not the rename it looked like: Zod 4 strips unknown keys by default
(every schema is now `strictObject`), `safeParse` returns a structured issues
array rather than a path map, and `z.record` silently drops a `__proto__` key
while reporting success - so the "prototype-polluting map is rejected"
guarantee needed an explicit pre-check, because a refinement only observes the
output.
A correctness bug found while verifying: contradicting questions were published
`contentHash()` covers the stem and the sorted option texts but deliberately
not the answer key, so two source rows differing only in
`correct_option_index` hash to the same question id and the second was dropped
as an "identical question". The cross-row defect planner then skipped the group
because it required `questionIds.size >= 2` before looking at whether the
answers disagreed - and a collapsed pair produces exactly one id. The result:
12 questions were published carrying whichever answer happened to be read
first, silently resolving a contradiction by array order. Among them, series
R-L-C impedance at resonance keyed "infinite" against "at a minimum", the R-C
time constant keyed "R/C" against "RC", inductor voltage at steady state keyed
"infinite" against "zero", and power factor at resonance keyed "zero" against
"unity" - in each case one copy is simply wrong.
One id carrying two authored answers is a stronger signal than two ids
carrying two answers, not a weaker one. The size guard is gone, the misleading
"identical question" comment is corrected, and those questions are now flagged.
Two smaller divergences closed at the same time:
- The importer's duplicate grouping did not strip punctuation while the
frontend's did, so a pair of stems differing only in the quote marks around
"this" was withheld by the app and published here. A separate `stemKey()`
now matches the frontend; `norm()` is untouched because `contentHash()`
depends on it and changing it would re-mint every question id.
- The importer published a question a human had marked unanswerable because no
rule here can derive "two of these options are true". Codes that are human
adjudications are now honoured. The importer is otherwise right to distrust
positional ids, but `validate-questions.mjs --check-ids` gates every id for
range on each run, so a code it cannot re-derive is evidence rather than
noise - and publishing a question a human marked unanswerable is the worse
error.
Frontend and backend now withhold exactly the same set: quarantine cross-check
reports 106 agreed, 0 stale. The 12 unresolvable entries are rows collapsed as
exact duplicates, whose surviving twin is flagged.
The smoke suite was a one-shot that corrupted its own fixtures
Running `db:smoke` twice failed on `UNIQUE users.email_normalized` before the
first assertion, and - worse - it overwrote a real question's stem with
"Zzyzx smoke marker stem" and an option with "Quuxinator option marker" and
never restored them, so anyone who ran it and then looked at the database found
a corrupted question and no hint why. One `resetFixtures()` routine now runs
before the first fixture and again at the end, the borrowed question is
captured and restored, and new assertions prove the restore reindexed the FTS
table and that the marker text is gone. Verified by running it three times in
a row: 75/75 each time, leaving the database as the seed produced it.
A decision in the reconciliation brief was wrong, and is recorded as such
The brief asked for `stability` and `difficulty` columns to be added to
`srs_state`. They were already there, documented with FSRS units, since
0005_learning.sql. The real mismatch was the opposite: the API was SM-2 with
`ease`/`interval_days`, and mapping `ease` in [1.3, 2.8] onto `difficulty` in
[1, 10] - which runs in the opposite sense - would have had to round-trip on
every review and compound. The API moved to the schema's vocabulary instead,
which satisfies what the decision was for: the frontend's FSRS-shaped scheduler
drops in with no translation layer.
Measured: 32 tables + 1 view + 71 indexes + 11 triggers, 8/8 migrations apply
in Miniflare, schema.ts and the SQL agree across 332 columns / 71 indexes /
51 foreign keys / 36 enums, 2,290 questions seeded (2,184 published, 106
withheld, 0 published without a key), 75/75 smoke, 90/90 API tests against the
real DDL rather than an assumed one, Worker bundles at 170 KiB gzip. Frontend
unchanged: 356 tests, tsc and ESLint clean, validator exit 0.
`attempt_answers` row cost rose from 132.4 to 149.9 bytes - about 7 bytes for
the millisecond timestamp, which is both a column and an index key, and 13.5
for `selected_opt_key` TEXT over an INTEGER ordinal. 500 MB now holds 3.46M
answer rows rather than 3.92M. That is the price of the two decisions above and
is documented where the ceiling is stated.
Known gaps, unchanged by this commit: the three cron jobs in wrangler.toml
have no handler, so answer retention, attempt expiry and leaderboard snapshots
are specified and unimplemented - until retention exists `attempt_answers`
grows without bound. No production D1 is provisioned, so `unixepoch('subsec')`
is verified only against SQLite 3.54 and Miniflare. `questions.subchapter_id`
is NULL throughout, so all 60 blueprint rules fall back to chapter-level
sampling. `explanations` is empty, so every explanation on the wire is null.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…peline No question in the bank carries an explanation, so a student who answers wrong is told "the correct answer is B" and nothing else: 2,184 questions that can test but never teach. The owner has confirmed no authored explanations are coming, so these are generated, shipped with a visible unreviewed badge, and drained by a review tool. Shipping an unreviewed draft is a deliberate trade, and the three things that make it defensible are enforced in code rather than left to callers. Provenance is a column, not a convention `explanations` already had review state (`status`, `reviewed_by`) but nothing recording WHO WROTE a row, so a generated draft and a human-authored explanation were indistinguishable. Added `origin` (ai_draft | ai_reviewed | human) plus the generator record: model, prompt version, generated_at, and the model's own confidence in basis points. `status` and `origin` stay independent on purpose: a published ai_draft is shown WITH the badge, because a flagged draft beats "the answer is B", while a draft nobody has validated at all stays unpublished. Three CHECK constraints hold the combinations honest - an AI origin must carry its generator record, a human one must not carry a misleading model attribution, and an ai_reviewed row must name its reviewer and when. Each was negative-tested; the reviewed-pair CHECK then immediately caught a real bug in my own review tool, which set a review timestamp with no reviewer id. The delivery rule is written into the schema beside the table, because leaving it to each caller is how an unlabelled draft eventually ships: published rows are delivered in practice and post-exam review with `origin` on the wire; nothing is delivered during a timed mock test, not even a human-written explanation, because the explanation names the answer; `status = 'draft'` is never delivered. The generator treats the answer key as an input, not a question `scripts/generate-explanations.mjs`. The model is told which option the bank keys as correct and asked to explain THAT option. It is not asked to solve the question, because a model that disagrees with the key and explains its own answer produces an explanation contradicting the grading - the worst possible output, since it tells a student they were marked wrong unfairly while fixing nothing. Instead it must report `agrees_with_key`, and a disagreement publishes nothing and files a `question_reports` row for adjudication. That makes the pass a second, independent audit of all 2,184 answer keys at no extra cost - which matters, because the first audit found 17 questions whose own two answer fields contradicted each other, and 59 duplicate groups whose copies disagreed. Cost and correctness details that are easy to get wrong: - Batch API, 50% of standard rates. - One cached instruction prefix rather than per-question instructions. The prompt-cache minimum is 512 tokens on Claude Opus 5; the block clears it, so ~2,180 full-price reads of it become one write plus reads at a tenth. - `fallbacks` is deliberately absent - it is rejected on the Batches API. - A refusal is HTTP 200 with empty content, so `stop_reason` is checked before `content` is read; reading `content[0]` would throw on exactly the responses that need handling. - Distractor rationales are matched back to options by the label the prompt assigned, and a label that does not resolve is dropped rather than guessed. - Review order is `responses * (1 - pCorrect)` - the questions most students get wrong. Every question has zero recorded answers today, so that term is currently a no-op and chapter order decides; it starts working the moment attempt data exists, with no change to the query. The ingest path is split from the batch poll loop so it can run against a local file of results (`--ingest-file`), which is both how it was tested without spending anything and how a crashed run is re-ingested without re-submitting. Verified against the real seeded schema with synthetic results covering every branch: success with and without detail, a key disagreement, an unresolvable option label, an empty rationale, a refusal, a hard batch error, and unparseable JSON. All wrote correctly or were correctly skipped, and foreign_key_check stayed clean. The badge is not an asterisk ExplanationPanel now renders a visible warning-toned badge row, a plain-English disclaimer, distractor rationales keyed by option TEXT (options are reordered per student, so positional language is never used), and a report link - because students are the only review capacity these drafts will realistically have. Both defaults fail safe, which is the part worth keeping: `origin` defaults to `ai_draft`, so a caller that forgets it shows the badge rather than presenting a draft as checked; `context` defaults to `exam`, so a caller that forgets it renders nothing rather than leaking the answer into a timed paper. Nine tests cover the badge, the omitted-origin default, the exam gate across all three origins, the omitted-context default, and rationale rendering. The review tool, because a backlog with no tool never shrinks `scripts/review-explanations.mjs` shows the question, its options, the keyed answer and the draft, then records accept / edit / reject / flag. Accept promotes to ai_reviewed with a reviewer; edit rewrites the body as human-origin and clears every generator field, since leaving a model attribution on text a person wrote would be a false record; reject withdraws the row from delivery and is allowed without a reviewer, because pulling a bad draft should never be blocked on having a user row to attribute it to. It reads least-confident-first, and the generator is told its confidence sets that order, so a low score is the model asking to be checked. One bug found while testing it: `readline.question` does not queue, so with piped stdin every line arrives at once, only the prompt with a callback registered at that moment captures one, and the next await never settles - the process hung on an unsettled top-level await. Fixed by buffering stdin to EOF when it is not a TTY, which also makes the tool scriptable: `yes a | npm run explain:review -- --chapter ACtE03 --reviewer <id>`. Verification: 365 frontend tests (was 356), 90 worker tests, 75/75 smoke, schema.ts and the SQL agree across 337 columns / 72 indexes / 37 enums, Worker bundles, question-bank validator exit 0, tsc and ESLint clean. Not done: no explanation has actually been generated. The scripts need an API key, which this environment does not have, so the Batch submit and collect paths are unexercised against the live API - only the selection, request construction, and ingest paths are tested. Run `npm run explain:estimate` for a measured cost figure and `--limit 20` as a pilot before committing to a full run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The quiz engine, its components and 96 of its tests existed, but no page
imported it - grep -rln "features/quiz" src/pages/ returned nothing.
Every page still ran its own copy of the state machine, so flagging, the
question palette, keyboard shortcuts, autosave/resume, the pre-submit
review screen and the explanation badge were all unreachable by a user.
Tests passing on code nobody can run is not coverage.
All four pages now host the engine:
- TopicPractice - immediate feedback, attemptKey topic:{id}:{count}
- QuickPractice - immediate feedback, samples every chapter
- CustomPractice - keeps its own config screen; the engine stays disabled
until the student submits it
- MockTestQuiz - deferred feedback, 120-minute absolute deadline read
from examBlueprint, pre-submit review screen
1,403 lines of page code become 987, while the pages gain the five
features above. MockTestQuiz loses its 10-questions-per-page layout
deliberately: the engine tracks one current index, so a paginated host
would have to call goTo on every card interaction or every question's
elapsed time would be attributed to whichever index happened to be
current. One question plus the numbered palette is also closer to the
real NEC computer-based test.
Tests assert reachability through the real pages rather than the engine
in isolation: flagging reflected in the palette, palette jumps, a number
key selecting and Enter committing, a draft surviving unmount/remount
(waiting on the debounced autosave rather than assuming a synchronous
write), and the explanation panel admitting it has nothing. For the mock
paper: the 120-minute clock, nothing revealed mid-paper however the
student navigates, no explanation during the paper, a review screen that
counts what is blank, and a two-step confirm so that one misplaced click
cannot end a 120-minute exam.
365 -> 375 tests. tsc, eslint and the production build all clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rministically
No live API is used in this project, which left two problems with one cause:
the bank records which CHAPTER a question belongs to but not which
SUB-CHAPTER.
1. `questions.subchapter_id` was NULL for all 2,290 rows, so every one of
the 60 blueprint rules fell back to chapter sampling. A "Transport Layer"
paper was really a "chapter 5" paper.
2. No question has an explanation, so the post-answer panel said "No
explanation has been written for this question yet" and stopped. A
student who got it wrong had nothing to revise from.
A sub-chapter label fixes the first outright and gives the second something
true to say. It is derived, not generated.
HOW IT WORKS
The chapter is a fact - it is which file the question shipped in - so this is
a 1-of-6 decision, never 1-of-60, and within a chapter the six sub-chapters
are deliberately disjoint topics. Separating "Network Layer" from "Transport
Layer" needs `subnet`/`ospf` against `tcp`/`handshake`, not semantics. So:
60 term lists, weighted strong/weak, matched case-insensitively on word
boundaries with plural and hyphen/space tolerance.
Two guards must both pass before any label is returned: enough evidence
(MIN_SCORE) and a clear gap to the runner-up (MIN_MARGIN). Otherwise it
DECLINES, and a declined question keeps its chapter, which is always exact.
Refusing is the point - a wrong syllabus reference shown to a student is worse
than none.
Measured on the real bank: 66.4% labelled, all 60 sub-chapters in use, mean
confidence 5204 bp. Three measurement-driven fixes got it there from 53%:
* plural tolerance - `requirement` could not match "requirements", because
the word boundary sat before the `s`;
* hyphen/space equivalence - `open loop` could not match "open-loop";
* stem evidence now dominates option evidence. "NOT a phase in the project
life cycle?" names 10.4 outright, and scoring the distractor
"Depreciation" alongside it closed the margin and declined a question that
was never ambiguous. Options can now break a tie, never create one.
PROVENANCE, BECAUSE A HEURISTIC IS NOT A FACT
Migration 0009 adds `subchapter_source` ('human' | 'heuristic'),
`subchapter_confidence_bp` and `subchapter_classifier_version`, following the
shape `explanations.origin` already established. Writing a guess into
`subchapter_id` alone would make it indistinguishable from a librarian's
decision a year from now.
Three triggers enforce it: no provenance without a label, no label without
provenance, and a heuristic label may never overwrite a human one. The last
one caught my own restore statement in the smoke suite, which is the point -
undoing a human classification takes two deliberate statements and cannot
happen by re-import.
WHAT THIS BUYS, MEASURED
* All three shipped templates now satisfy 100% of their rules from the
sub-chapter pool. Previously 100% approximated. The planner needed no
change; it was correct and starved.
* Every question now shows a syllabus reference after answering - the
sub-chapter where one matched, the chapter where it did not. Phrasing
carries the confidence ("likely 5.4...") rather than a percentage, which
would imply a precision this does not have.
WHAT IS NOT CLAIMED
Coverage is measured; PRECISION IS NOT. There is no ground truth, so a term
list that confidently mislabels a whole topic would show up as excellent
coverage. `scripts/classify-subchapters.mjs --review` writes every label to a
TSV weakest-first so the least-defensible decisions are in the first
screenful; spot-checking the floor (3600 bp) found resonance -> 1.3,
structure member access -> 3.2, inline function -> 3.3 and cache -> 4.2, all
correct, but that is a spot check and not a measurement.
There is deliberately no generated map file. The app classifies at render time
and the importer at import time, through the same module, so a precomputed
artefact would have no consumer and nothing to keep it honest.
`scripts/ts-resolve.mjs` lets Node resolve the app's extensionless specifiers,
so the importer shares the app's classifier instead of mirroring it. The one
time the importer and the frontend disagreed about normalisation, the app
withheld questions this importer published.
Frontend 375 -> 399 tests; worker 90 tests, 75 -> 88 smoke checks. tsc, eslint,
db:verify, the production build and the question-bank validator all clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`wrangler.toml` declared three cron triggers and nothing implemented them.
The Worker exported the Hono app directly, which gives Cloudflare a `fetch`
handler and NO `scheduled` handler at all, so every trigger fired into
nothing - silently, because a missing handler is not an error. The visible
consequence was `attempt_answers` growing without bound toward D1's 500 MB
wall, where the first symptom is every write failing.
The schema had already designed all three jobs; this implements what
migrations 0004 and 0006 specify, and nothing beyond it.
ANSWER RETENTION (10 2 * * *)
Deletes raw answers past their window: `ANSWER_RETENTION_DAYS` (90) for a
submitted attempt, 7 days for an abandoned or expired one. Score, counts,
per-question history and SRS state are trigger-maintained on other tables, so
deleting a raw answer loses the question-by-question review of an old attempt
and nothing else - 0004's stated trade, and now a test.
Two properties make it safe to run unattended:
* It works to a ROW-WRITE BUDGET (25,000, a quarter of the free plan's daily
100,000) and stops cleanly when spent. D1 counts index rows as writes and
`attempt_answers` carries four indexes, so one deleted answer costs about
five. A retention pass that exhausts the day's allowance takes the whole
platform read-only, which is far worse than a backlog draining over
several nights.
* It deletes OLDEST FIRST, so an interrupted run always makes progress on
the rows closest to being a problem.
It deletes rather than archiving. `attempt_answers_archive` exists with an
`r2_object_key` column whose own comment says a NULL key means the row is
still only in D1; with no R2 bucket bound, copying rows there would move them
from a 4-index table to a 1-index table in the same database - paying write
amplification to bound nothing. Enabling archival is a two-part change (bind a
bucket, write the drain), not a flag.
NIGHTLY SWEEP (40 2 * * *)
Expires attempts past their deadline, deletes sessions past their ABSOLUTE
ceiling (not their idle one - the idle clock slides and a live tab can revive
it), prunes expired notifications, and sweeps idempotency keys through the
statement `data/infra.ts` already defines.
The attempt expiry is not housekeeping: the mock-start path refuses to begin a
second attempt while one is open, so a single abandoned paper locked a student
out of that template permanently. `elapsed_ms` is credited only up to the
deadline, never up to whenever the cron ran, because the student stopped
working at an unknown earlier point.
LEADERBOARD SNAPSHOT (5 * * * *)
Global board, four periods, bucketed in Nepal Standard Time like streaks are -
bucketing in UTC would put a 9pm Kathmandu session on the wrong day. Reads
`attempts`, not `attempt_answers`, so it survives the retention job deleting
the raw rows underneath it. `leaderboard_opt_in` is enforced here because it
is the only place it can be: once a name is in `top_json` no read-path filter
can take it back out.
TWO BUGS THE SCHEMA AND THE TESTS CAUGHT
* `leaderboard_snapshots` has a UNIQUE on (scope, scope_ref, period,
period_start). My first version INSERTed a row per run, so it worked on
the first firing of each day and failed on the unique index for the other
twenty-three - a bug that only shows up in production on day two. It now
upserts on the board key and replaces the entries wholesale, which is also
the right shape: the read path wants "today's board", not "the 14th
snapshot of today's board".
* `retentionDays` accepted "90 days" as 90, because `parseInt` stops at the
first non-digit. A value that was never a number would have become
policy. Digits-only now.
ALSO
* Removed SESSION_IDLE_TTL_SECONDS and SESSION_ABSOLUTE_TTL_SECONDS from
wrangler.toml. Nothing read them - the real values are constants in
`src/data/auth.ts` with per-call overrides - and config that looks
adjustable but is not is worse than no config.
* Added APP_ORIGIN, which `loadConfig` requires and which was absent, so
every request would have 500'd on a fresh deploy.
* Added the 0006 engagement tables to the `T` name map.
* An unregistered cron expression THROWS rather than defaulting to a job:
editing a schedule without editing the dispatch table should surface on
the first firing, not run the wrong work at the wrong hour.
Jobs get their own 48-query budget, not the request path's 45: a cron is not a
page load and may use D1's whole per-invocation limit. A test asserts all
three stay inside it.
90 -> 108 worker tests. typecheck, db:verify, 88/88 smoke and
`wrangler deploy --dry-run` all clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t needs Everything that requires the Cloudflare account is now one ordered checklist in `docs/deploy-cloudflare.md`: create D1, apply migrations, set secrets, deploy the Worker, load the question bank, deploy Pages, confirm the crons. Each step says what to paste where and what "it worked" looks like, and the file ends with a symptom-to-cause table and an explicit list of what NOT to provision (no Hyperdrive, no R2, no KV, no Durable Objects, no paid plan). The exporter exists because the obvious command is a trap. `wrangler d1 export --local` exports the database `wrangler dev` uses — `.wrangler/state/v3/d1/miniflare-*/`— which is NOT the one `db:seed:local` writes (`.local/necprep.sqlite`, per `db/apply.mjs`). It also emits CREATE TABLE statements, which fail against an already-migrated database. Both failures are quiet, and this is the step whose entire purpose is "put the questions in production". `db/seed/export-content-sql.mjs` reads the file the seed actually wrote and emits data only, chunked into numbered files that can each be retried independently. It exports content and nothing else — questions, options, answer keys — because content is derived from files in git and safe to replace wholesale, while users, sessions, attempts and progress are not, and a seeding script that could reach them is one that will eventually delete somebody's history. Every statement is INSERT OR REPLACE, and question ids are content hashes, so a replay after a bank update overwrites what changed and leaves the rest alone. Verified rather than assumed: applied all nine migrations to a fresh database, replayed the nine generated files, and got 2,290 questions / 2,184 published / 1,523 classified / 9,160 options / 2,290 answer keys / 8,736 delivery-view rows and zero foreign-key violations — identical to the local seed. Then replayed the whole set a second time and got the same counts, so the documented "safe to re-run" is a measurement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…te exam
`db/seed/generate-reference-sql.mjs` carried the durations as literals - 45
and 20 - copied from `frontend/src/data/topics.ts` back when that file also
held literals. `examBlueprint.ts` later became the single source of truth and
was confirmed at 120 minutes by the project owner; `topics.ts` was changed to
derive from it and this generator was not.
So the seeded templates claimed 100 questions in 45 minutes while the client's
own clock said 120. That is not a cosmetic disagreement: `attempts.deadline_at`
is computed server-side from `duration_minutes` and the submit path clamps
against it (`src/domain/expiry.ts`). The server owns the deadline, so the
server wins, and every candidate would have been cut off 75 minutes early in
what is meant to be a full-length rehearsal of a licence exam.
Found by reading what the deployed API actually returned, not from the code:
`/api/content/mock-templates` reported `45 min` for a 100-question paper.
FIXED IN TWO PLACES, DELIBERATELY
* The generator now DERIVES the duration via
`readConfirmed(examFormat.durationMinutes)`, and the quick test pro-rata on
question count, matching `topics.ts` exactly. `readConfirmed` refuses an
unverified value, so a future change that un-confirms the duration fails
the generator rather than silently seeding a guess. The regenerated 0007
now emits 120/120/60.
* Migration 0010 repairs databases where 0007 is already applied. Editing
0007 alone would fix new deployments and leave the live one wrong, which
is the worse half of the problem. It is guarded on inequality, so running
it against correct data is a no-op.
The generator's own comment said it could not import TypeScript because it
"runs under plain node with no TypeScript loader". That stopped being true
when `scripts/ts-resolve.mjs` landed, so `db:reference` now runs with it and
the duplicated literal is gone rather than merely corrected.
`pass_mark_bp` was already 5000 and `total_questions` already 100; only the
duration was wrong.
Verified against the deployed API after applying: `/api/content/mock-templates`
returns 120/120/60, and a real `POST /api/mock/start` returns
`durationSeconds: 7200`, 100 questions, 4 options each, and no answer key on
the wire.
db:verify, 88/88 smoke, 108 worker tests and typecheck all clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… frontend
Both values were placeholders that had to fail loudly until a human supplied
them, and now they exist:
* `database_id` - the `necprep` database, created in the APAC region, which
is the right side of the planet for a Nepali candidate. Not a secret: it
names a database that still requires an API token to touch, and wrangler
reads it from this file at deploy time, so it has to live here.
* `APP_ORIGIN` for production - `https://necprep.pages.dev`, the deployed
Pages project. This drives CORS, so it must match the frontend origin
exactly; the preview URLs Pages mints per deployment
(`<hash>.necprep.pages.dev`) are deliberately NOT allowed.
Deployed state after this change: the API is live at
`necprep-api.waglesameer5.workers.dev` with all three cron schedules
registered, all ten migrations applied, and the question bank loaded -
2,290 questions / 2,184 published / 1,523 sub-chapter classified / 9,160
options / 8,736 delivery rows, matching the local seed exactly.
The frontend does not call any of it yet: `services/api.ts` still has no
importers. That is the next change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`services/api.ts` had 43 typed methods and ZERO importers - `grep -rln
"services/api" src/` returned only the file itself. The whole backend was
unreachable from the UI, and the app ran entirely on static JSON plus
localStorage.
Auth is the right first cut, not an arbitrary one: every endpoint except the
content reads needs a session, so nothing else can be wired until this exists.
WHAT IS HERE
* `auth/AuthContext.tsx` - the provider. `auth/auth-context.ts` holds the
context, the types and the hooks, split because Fast Refresh can only swap
a module whose exports are all components (the same reason
`context/user-context.ts` is separate from `UserContext.tsx`).
* `auth/RequireSession.tsx` - the guard on `/app`.
* `pages/auth/SignIn.tsx` - sign in, sign up, or continue as a guest.
* `.env.example` rewritten - it still documented Supabase, abandoned in ADR
0001 - and `frontend/.env.production` added, gitignored. The value is
public by definition, but the stage guard treats every .env file as a
secret file and that rule is worth more than building from a clean
checkout without one extra step. docs/deploy-cloudflare.md step 6 now says
how to recreate it, and how to check the URL actually made it into the
bundle, because a build without it silently produces the local-only app.
THREE STATES, NOT TWO
The session is an HttpOnly cookie, so on a fresh page load the app cannot know
who the visitor is - it has to ask. `status` is therefore 'loading' | 'ready' |
'disabled', and conflating any two of them is a bug:
* Reading 'loading' as signed-out bounces every signed-in user to the
sign-in page on every refresh, then back again when the probe lands.
`RequireSession` renders a spinner and decides nothing.
* 'disabled' is a build with no `VITE_API_BASE_URL`. That is the deployment
that shipped until today, and it must not be shown a login wall it cannot
satisfy, so the guard passes it straight through and the provider issues no
requests at all.
THE GUEST DOOR IS THE PRIMARY ACTION
`POST /api/auth/anonymous` creates a real server-side identity whose id
survives being upgraded in place, so a candidate can sit a paper, then sign
up, and keep that attempt. A sign-up wall in front of the thing a visitor came
to do loses the people who were willing to start, so "Continue as guest" sits
above the form and the account menu offers the upgrade instead of nagging.
A guest counts as authenticated to the guard, because they are.
SIGNING OUT NOW ACTUALLY SIGNS OUT
`AppShell`'s logout cleared a localStorage display name and navigated home.
That was adequate with no backend and a lie with one: the session is a cookie
the browser keeps sending, so dropping a local string left a live session that
the next page load turned straight back into a signed-in user. It calls
`logout()` first and clears the local name after.
The greeting now prefers the server's `displayName` over the locally typed
one, and the menu shows the account email when there is one.
TWO BUGS FOUND WHILE WRITING THIS
* My sign-in form required a 10-character password. The Worker's signup
minimum is 8 (`validation/schemas.ts`), so it would have rejected valid
passwords - and because `canSubmit` gated the button, it would also have
locked out anyone whose existing password was 8 or 9 characters.
* Worse, I applied that minimum on SIGN-IN too. The server's login schema
deliberately has no minimum, with a comment saying why: rejecting a short
password tells an attacker their guess was too short to be anyone's. A
client-side minimum reintroduces exactly that oracle. Sign-in now requires
only a non-empty password, and a test asserts it.
A third was caught by a test: `initialState` was a module-level constant, so
the status was frozen at whenever the module first happened to be imported.
Identical in production, where the value is a build-time constant, but
arbitrary and untestable. It is a function called on mount now.
TESTED
14 new tests over the behaviour that is invisible in manual testing: the probe
races the guard, a failed probe is signed-out rather than an error, sign-out
reaches the server, a guest passes the guard, a local-only build makes no
requests and shows no wall, the minimum applies only to sign-up, and a stale
failure is cleared when switching mode ("Email or password is incorrect" under
a "Create an account" heading reads as though the account already exists).
`AppShell`'s own tests now wrap the provider - the shell reads the session, and
`useAuth` throwing outside a provider is deliberate.
399 -> 413 tests. tsc, eslint and the build all clean.
DEPLOYED AND VERIFIED
Frontend on Pages, API on Workers, both live. Verified against the deployed
pair: the production alias serves the new bundle, the CORS preflight returns
204 with `access-control-allow-origin: https://necprep.pages.dev` and
`access-control-allow-credentials: true` (without which the session cookie
would never be sent), and `POST /api/auth/anonymous` returns a real session.
STILL LOCAL
The content, practice and mock paths still read `localData`. Auth was the
gate; those are the next cut, and each one has a working endpoint waiting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nifest The generated manifest counts ROWS in each chapter JSON. The API counts PUBLISHED questions, which excludes the 118 withheld for answer-key defects and anything flagged since. Chapter 1 is 525 locally and 483 from the server, and 483 is the number a candidate can actually be asked - so the dashboard was advertising 42 questions that no practice set can ever serve. SYNCHRONOUS FIRST, REFINED AFTER Ten callers use useChapters() and all of them are synchronous. Returning a promise would push a loading state into the dashboard, the practice hub, progress, flashcards, the study hub and every quiz page, for a value that is already known well enough to render. So the first return is the manifest - instant, offline, correct about everything except one number - and the API's counts arrive on a later render. No caller changed. ONLY THE COUNT IS ADOPTED dataFile has no server equivalent and is what loadQuestionsSlice reads, so it has to survive; name, colour and icon are presentation the manifest already owns and sync-chapters.mjs regenerates. Taking the minimum keeps this from becoming a second, divergent source of truth for chapter metadata. An unrecognised chapter id is ignored rather than appended: a Topic with no dataFile would break every local load path, and a server that has grown a chapter the client does not know about is a deploy-order problem to fix, not a shape to render. The array identity is preserved when the server agrees, so a response that said nothing new does not re-render ten consumers. 7 tests, including the two cases that would otherwise regress silently: a failed request leaves the manifest intact, and a build with no backend configured issues no request at all. 413 -> 420 tests. tsc, eslint and the build clean. NOT YET WIRED, AND WHY IT IS NOT A SWAP Practice and mock delivery still read localData. PublicQuestion carries no answer key - deliberately, it is the whole point of the server-side scoring design - while the quiz engine scores locally from correctIndex. Moving those paths over means teaching the engine a remote mode where a submitted answer is scored by POST /api/practice/answer and the verdict comes back, not changing a data source. That is its own change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous section assumed the console was obvious. It is not, and the three
things people assume block them do not apply here, so the rewrite states them
first: the code requests only openid/email/profile, which are non-sensitive,
so Google needs no review, no demo video and no security assessment; no
billing is involved; and a third-party provider does not help, because its
Google 'developer keys' are documented as testing-only and production needs
your own client either way.
Adds the two things the old version left out, both of which are where this
actually stalls:
* Testing vs Published. Staying in Testing is a legitimate stop - up to 100
listed test users, no App-domain fields required - and the test-user list
is why a correctly configured app still refuses to sign anyone in.
Publishing needs a homepage and a privacy-policy URL, which this platform
does not have yet.
* A symptom-to-cause table for the five failures that actually occur,
including redirect_uri_mismatch and the 'app isn't verified' screen that
is expected rather than broken.
Plus a one-line curl that distinguishes 'configured' from 'not configured':
/api/auth/google/start 302s to accounts.google.com when all three secrets are
set and 404s when they are not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Licence-exam preparation happens on a phone, in gaps. Until now the only
navigation below 1024px was a hamburger opening an off-canvas drawer, so every
move between Practice, Mock Tests and Progress cost two taps and hid the page
behind an overlay.
MOCK TESTS WAS NOT IN THE NAVIGATION
Found while picking the bar's items: `navItems` listed Dashboard, Practice,
Books, Study, Flashcards, Progress and Bookmarks. No Mock Tests. The full
100-question, 120-minute rehearsal of the real paper - the product - was
reachable only from the dashboard and the practice hub. It is now in the
sidebar and on the bottom bar, with its own stopwatch icon, because the clock
is what makes it different from practice.
WHY FOUR ITEMS PLUS "More"
Eight destinations at phone width means ~40px targets. The bar carries Home,
Practice, Mocks and Progress - the loop a candidate actually repeats - and
More opens the existing drawer for Books, Study, Flashcards and Bookmarks,
which are browsing rather than the main loop. The bar complements the drawer;
it does not replace it.
DETAILS THAT MAKE IT USABLE RATHER THAN MERELY PRESENT
* Targets are at least 44px before padding.
* `env(safe-area-inset-bottom)` keeps the labels clear of an iPhone's home
indicator, and `<main>` reserves the same inset plus the bar's height - a
fixed bar would otherwise float over the end of a page, which on a quiz is
the submit button and on a results page is the whole detailed review.
* The current section is announced with `aria-current`, not only coloured.
Colour alone fails WCAG 1.4.1 and says nothing to a screen reader, so the
active item also carries a 2px indicator above its icon.
* The index link uses `end`, or `/app` matches every child path and two
items look current at once.
* More is a button with `aria-expanded` and `aria-controls`, not a link that
does not link.
* The landmark is labelled, because the shell now has two navigation
landmarks and a screen reader's landmark list has to tell them apart.
The bar stays visible during a timed paper. A stray tap out of a mock is
survivable by design - the attempt autosaves and the deadline is an absolute
wall-clock instant rather than a countdown that pauses - and hiding navigation
to protect an exam would trap someone who genuinely needs to leave.
8 tests. Visibility is a media query and is deliberately NOT tested: jsdom has
no layout, and a test that stubbed `matchMedia` would assert the stub rather
than the CSS. The 640px boundary is shared between BottomNav.module.css and
AppShell.module.css so the bar and the padding that makes room for it cannot
drift apart.
420 -> 428 tests. tsc, eslint and the build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`App.css` was 6,213 lines carrying the styling of features that no longer
exist: the Supabase sign-in UI abandoned in ADR 0001 (`login-*` 23 names,
`signin-*` 9, `auth-*` 4, plus `btn-google` and `google-icon`), the paginated
mock-test layout dropped when the quiz engine landed (`mock-*` 11,
`page-dot*`), and a dashboard activity feed that was removed (`activity-*` 10).
131 class names appeared nowhere outside this file. Removing their rules took
out 214 rules and 1,062 lines, 17% of the file, and the built stylesheet went
from 99.68 kB to 78.97 kB - 16.53 kB to 13.90 kB gzipped, which is bytes every
visitor on a Nepali mobile connection was downloading to style a sign-in page
that does not exist.
I had been quoting "98 dead selectors" from an earlier estimate. Measured, it
is 131 names and 214 rules; the earlier figure was wrong and is corrected
here.
THE TEST APPLIED
A selector containing a class name that appears NOWHERE in the sources cannot
match any element. Compound selectors count: `.page-dot.done` is dead because
nothing carries `.page-dot`, even though `.done` is a word used elsewhere in
the codebase. That accounts for the difference between 131 dead names and 136
names that disappeared - five generic ones (`done`, `low`, `bookmarked`,
`practice`, `study`) existed ONLY in compounds with a dead class, and were
checked individually before accepting the removal.
WHY IT IS A TOOL AND NOT A ONE-OFF
`scripts/find-dead-css.mjs` does the analysis and the rewrite. 5,151 lines
remain and the migration to the token layer will keep orphaning rules, so the
next pass should not have to re-derive any of this. It reports by default and
only writes with `--write`.
Every step of it is biased toward KEEPING a rule:
* "Used" means the name appears anywhere in any .tsx/.ts/.css or
index.html - including in a comment or a test. Tests count deliberately: a
class a test asserts is one the app is expected to render, and removing
its rule would leave the test passing while the page looked wrong.
* Class names composed at runtime are excluded. It scans template literals
for `prefix-${...}` and protects everything under such a prefix, because
`panel-${tone}` yields names that exist nowhere as literals. Seven such
prefixes are in use.
* A rule mixing dead and live selectors keeps the live ones.
* Only class selectors are examined; element, id, attribute and pseudo
selectors are never a reason to remove anything.
* @Keyframes, @font-face and @import pass through untouched; only @media,
@supports, @layer and @container are descended into.
Re-running it now reports 0 dead names, so it is idempotent and the file is
clean by its own measure.
428 tests, eslint, tsc and the production build unchanged and clean. The 27
class names referenced only by the new sign-in page and account menu survived,
which is the heuristic working: `.user-menu-email` was dead before this week
and is live now because `AppShell` renders it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things sent the setup down the wrong path. First, the guide implied an API had to be enabled. None does: sign-in talks to accounts.google.com and openidconnect.googleapis.com directly, with no project-level API involved. The name that looks right and is not is 'Cloud OAuth Service API' (cloudoauth.googleapis.com), which issues tokens for calling Google Cloud APIs as a Google Cloud user and has nothing to do with your users signing in. Landing on an API/Service Details page with Metrics and Quotas tabs is now listed as its own symptom. Second, 'OAuth consent screen' no longer exists under that name. It is Google Auth Platform now, split across Branding, Audience, Clients and Data access, so the step-by-step is renumbered to match what is actually on screen and the test-user list moved from a wizard step to the Audience page. Direct /auth/* URLs are included because the left-nav path is the part that has changed, not the destination. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Google client secret ended up in a loose file at the repo root. It was never
committed - checked against every commit on every ref - but nothing stopped it
being committed either, and that is the gap this closes.
* worker/.dev.vars and .dev.vars: wrangler reads these and puts their
contents on env exactly as deployed secrets arrive, which is what makes a
local OAuth test possible. They hold real credentials by design.
* *secret*, *.pem, *.key: a blunt catch for credentials that end up loose.
No tracked file matches any of them, so nothing legitimate is swept up.
None of this makes a secret in a file acceptable. A secret belongs in
'wrangler secret put', which reads from the terminal and stores the value on
Cloudflare without it ever touching the disk here. The wildcard is a backstop
for the times that does not happen.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`loadConfig` runs in the /api/* middleware, once per request, so anything it
throws for breaks every endpoint. It threw when SOME but not all of the three
Google values were set - so one missing secret 500'd the chapter list, guest
sign-in and paper submission, each reporting
Google OAuth needs GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET and
GOOGLE_REDIRECT_URI together, or none of them.
which is a baffling thing to read when you asked for a list of chapters. An
optional feature could take down a required one, and the failure named the
wrong subsystem.
Found by running `wrangler dev` against a .dev.vars with one of the three
filled in - which is exactly the state someone is in halfway through setting
up Google sign-in.
The three Google handlers already guarded on `google === null`, so nothing
needed to change there beyond telling the two cases apart. `loadConfig` now
records `googleConfigError` instead of throwing, and `requireGoogle` reports:
* 404 "not configured" when none of the three are set - the feature is off,
which is a normal deployment;
* 500 "misconfigured" when one or two are - someone is actively getting it
wrong, and saying NOT_FOUND there would be the least useful true statement
available.
Everything else - APP_ORIGIN, CONTENT_PERMUTATION_SECRET, the numeric
settings - still fails closed for every request, because the first is the CORS
policy, the second protects the option permutation from being reproduced
offline against the public chapter JSON, and neither has a safe default.
Also trims the Google values. A redirect URI with a trailing newline, pasted
from a terminal, is a classic cause of redirect_uri_mismatch, which Google
reports at its end, far from the cause. An empty or whitespace-only value now
counts as unset rather than set, which is the state `.dev.vars` and
`wrangler secret` both make easy to reach.
Verified against a running `wrangler dev` with exactly one of three set:
/api/content/chapters 200, /api/auth/anonymous 200, /api/auth/google/start 500
"Google sign-in is misconfigured". All three were 500 before.
8 tests in a new config.test.ts draw the line explicitly: what must still fail
hard, and what must not. 108 -> 116 worker tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was no way to run the API locally against real data, and therefore no
way to test the Google flow without deploying to production and clicking
around. `npm run dev:setup` then `npm run dev` now gives a working API on
localhost with all 2,290 questions.
THE TWO-DATABASE TRAP
The thing that made this not work, and that nothing explained:
.local/necprep.sqlite tests, db:smoke, db:verify
.wrangler/state/v3/d1/miniflare-<hash>.sqlite wrangler dev, --local
`db:seed:local` fills the first. `wrangler dev` reads the second and never
looks at the first. So a developer could run the full `db:check`, see 2,290
questions pass, start `wrangler dev` and get an empty API with no clue why.
`db/seed/load-local-d1.mjs` bridges them by replaying the same generated SQL
the production load uses, through `wrangler d1 execute --local` rather than
writing to the file directly - that file is named after a hash wrangler owns,
and wrangler keeps its own metadata database alongside.
GOOGLE LOCALLY
Google permits http://localhost redirect URIs as an explicit exception to its
HTTPS rule, so the real flow can be exercised locally. `.dev.vars.example`
documents it: one OAuth client with two redirect URIs, not two clients, since
a second client is a second secret to rotate and a second consent screen to
keep in step.
All three Google values are commented out in the example. Shipping it with
only the redirect URI pre-filled is what produced the half-configured state in
the first place.
DOCS
`docs/local-development.md` covers the daily loop, the two-database trap, the
Google setup, and a reset. It is explicit that local and deployed secrets are
separate stores that cannot see each other, and that a secret never belongs in
a file here: the .gitignore entries are a backstop, not the rule, and a secret
that reaches a file, a chat or a screenshot is burned - add a new one, set it,
delete the old one. Deleting the file changes nothing on its own.
Verified end to end: 10 chapters with correct published counts, templates at
120/120/60 minutes, a guest session created, and /api/auth/google/start
returning 404 while unconfigured rather than 500.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Worker has had working Google OAuth routes for some time and the sign-in page offered no way to reach them - guest, email and password only. The secrets are now set in production and /api/auth/google/start returns a 302 to accounts.google.com with PKCE S256, a CSRF state and the non-sensitive openid/email/profile scopes, so the button is the last missing piece. An anchor, not a button with a fetch. The OAuth flow leaves this origin for accounts.google.com and returns to the Worker's callback, which sets the session cookie and redirects back - there is nothing to await, and an anchor is what makes middle-click and Back behave. It carries the guard's stashed return path through as redirect_to, so a deep link into a mock test survives the round trip through Google instead of landing on the dashboard. Rendered unconditionally. Whether Google is configured is a server-side fact the client cannot read without an extra request on the critical path, and the route answers honestly either way: 404 when the feature is off, 500 when it is half-configured. Placed below the guest button and above the form. Most visitors should start as a guest - the thing they came to do is sit a paper - but a returning student with a Google account should not have to hunt. The Google mark carries its own fills rather than currentColor, because the brand colours are fixed and must not be re-tinted by the theme. 428 -> 429 tests. The new one asserts it is a link, points at /api/auth/google/start, and carries redirect_to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lly does
Publishing the Google consent screen requires a homepage and a privacy-policy
URL, and a public exam platform needs both regardless. These are written from
the code rather than from a template: the retention windows come from
worker/src/jobs/retention.ts, the cookie flags from lib/cookies.ts, the stored
columns from migrations/0001_identity.sql, and the absence of trackers was
verified by searching the whole frontend. A privacy policy that misdescribes
the system is worse than none - it is a statement to users that happens to be
false - so when the code changes these change with it.
Three things worth saying plainly, and said plainly:
* There are no analytics, no advertising trackers and no third-party
scripts. Nothing reports a student's behaviour anywhere.
* The IP address is never stored. handlers/auth.ts hashIp keeps a salted
SHA-256 truncated to 32 characters, enough to spot a session hopping
country and not reversible to an address.
* Individual answers are deleted after 90 days, 7 for an abandoned paper,
while scores are kept - with the consequence stated: an old paper can show
the score but not the question-by-question review.
The terms disclose what a student relying on this for a licence exam is
entitled to know before they start, not after a question misleads them: 118
questions withheld for answer-key contradictions, explanations that are
unreviewed AI drafts, sub-chapter labels derived by keyword matching. And that
this is not affiliated with or endorsed by the Nepal Engineering Council - a
preparation tool that leaves that ambiguous is trading on an endorsement it
does not have.
Both pages state what the platform CANNOT do yet - no password reset, no email
verification, deletion by request rather than a button - because a student who
discovers that by being locked out has been misled by omission.
WHAT IS DELIBERATELY ABSENT
No company number, registered office, data-protection officer, governing-law
or arbitration clause. Those are claims about legal status that would be false
if invented. legalMeta.ts holds the handful of values only the operator can
supply, and hasPlaceholders renders a visible banner on both pages while any
REPLACE_WITH remains - a TODO in a source file would not stop an unfinished
policy becoming the page Google shows every person who signs in.
Not legal advice. These describe the system accurately, which is the part
engineering can get right.
Routed outside the session guard, since somebody deciding whether to trust
this with their email must be able to read them without an account, and lazily
so they cost nothing on the path to the dashboard. Linked from the landing
footer alongside the non-affiliation line.
429 -> 440 tests, including that the stated retention windows match the job
that implements them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
/signin shipped with guest, Google and email/password on it, and nothing in
the application linked to it. Grepping Landing.tsx for signin, signup or
"Sign in" returned no matches: the only way to reach the login page was to
type the URL.
Worse than missing, the landing page actively routed around it. Its single
call to action opened a modal that asked for a display name, wrote it to
localStorage and navigated to /app - where the session guard then asked for a
sign-in anyway. Two identity prompts in a row, the first of which decided
nothing, under copy that read "No account needed - enter your name".
WHAT CHANGED
* A Sign in link in the navigation, which never existed.
* Both calls to action now go to /signin. That works in a build with no
backend configured too, because SignIn redirects to /app in that case.
* The name modal is removed entirely - the dialog, its focus trap, its
scroll lock, the open/close/submit handlers, six pieces of state and the
two helpers that existed only to serve it. Landing.tsx goes from 481 lines
to 313.
* Copy corrected: "continue as a guest" is what /signin actually offers.
ABOUT THE DELETED ACCESSIBILITY WORK
The modal's focus trap, Escape handling, scroll lock and focus restoration
were written to fix a dialog that claimed role="dialog" aria-modal="true" and
honoured none of it. Removing them is not a regression: the modal they
belonged to is gone, and /signin is a page rather than a dialog, so it needs
none of that machinery. The four tests covering them are replaced by seven
covering what was actually broken - that the nav links to /signin, that both
calls to action reach it, that no name is demanded before entry, and that the
footer links to /privacy and /terms and states the non-affiliation.
440 tests throughout: four dialog tests out, seven entry-point tests in, three
other Landing tests unchanged. tsc, eslint and the build clean. Initial JS
120.06 kB of the 180 kB budget, slightly down.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed and why
The prototype had two halves that never met. A complete Cloudflare Workers + D1
backend existed — 34 endpoints, auth, server-side scoring, anti-cheat — and the
frontend never called any of it.
services/api.tshad 43 typed methods andzero importers. In the same shape, the quiz engine had 96 passing tests and
no page imported it, so flag-for-review, the question palette, keyboard
shortcuts, autosave and the pre-submit review screen were all unreachable by a
real user. Tests passing on code nobody runs is not coverage.
This branch connects them and fixes what connecting them exposed.
The backend now actually runs.
wrangler.tomldeclared three cron triggersand the Worker exported a Hono app, which gives Cloudflare a
fetchhandler andno
scheduledhandler — so every trigger fired into nothing, silently.attempt_answerswas growing unbounded toward D1's 500 MB wall, where the firstsymptom is every write failing. The three jobs the schema had already designed
are implemented: answer retention (capped at 25,000 row writes/run so it can
never eat the free plan's 100k daily budget), a nightly sweep, and hourly
leaderboard snapshots.
The question bank is classified.
questions.subchapter_idwas NULL for all2,290 rows, so all 60 blueprint rules fell back to chapter sampling — a
"Transport Layer" paper was really a "chapter 5" paper. A deterministic
term-matching classifier now labels 66.4% of the bank and declines the rest;
all three mock templates went from 100% approximated to 0%. The labels are
heuristic and recorded as such (
subchapter_source = 'heuristic'), withtriggers that refuse any state where a label and its provenance disagree.
Auth works end to end. Guest, email/password and Google sign-in, with the
session as an HttpOnly cookie. The guest path matters:
POST /api/auth/anonymouscreates a real identity whose id survives being upgraded in place, so a
candidate can sit a paper, then sign up, and keep that attempt.
Three bugs a student would have hit:
attempts.deadline_atis computed server-side and the submit path clampsagainst it, so the server would have won and every candidate would have been
cut off 75 minutes early in a full-length rehearsal.
sign-in, paper submission — each reporting "Google OAuth needs
GOOGLE_CLIENT_ID…", because
loadConfigruns in the/api/*middleware. Anoptional feature could take down a required one.
have locked out valid accounts and reintroduced the length oracle the
server's login schema deliberately avoids.
What a student will notice: mock tests run for the right 120 minutes;
chapter counts show questions that can actually be served (483, not 525, because
118 are withheld for answer-key defects); every answered question shows a
syllabus reference instead of a dead end; there is a bottom navigation bar on
phones, with Mock Tests on it — it was missing from the navigation entirely;
and
/privacyand/termsexist and describe what the code actually does.How it was tested
Commands run on this branch, with their output.
Also exercised against the deployed stack, not only locally — several of
these bugs were only visible there:
GET /api/content/chapters→ 200, ten chapters with published countsGET /api/content/mock-templates→ 120/120/60 minutes (this is how the45-minute bug was found — by reading what the live API returned)
POST /api/mock/start→ 100 questions,durationSeconds: 7200, four optionseach, no answer key on the wire
GET /api/auth/google/start→ 302 toaccounts.google.comwith PKCES256,a CSRF
state, andopenid email profilehttps://necprep.pages.dev→ 204 withaccess-control-allow-credentials: true1,523 classified / 9,160 options — identical to local
Not yet done: a screenshot at 360px.
Explain the diff in your own words
It is large (271 files), so by area.
worker/src/jobs/*(new). The three cron handlers plus a dispatcher keyedon the cron expression. An unregistered expression throws rather than defaulting
to a job — editing a schedule without editing the table should surface on the
first firing, not run the wrong work at the wrong hour. Retention deletes rather
than archiving:
attempt_answers_archiveexists with anr2_object_keycolumn,but with no R2 bucket bound, copying rows there moves them from a 4-index table
to a 1-index table in the same database, paying write amplification to bound
nothing. Jobs get a 48-query budget rather than the request path's 45, because a
cron is not a page load.
worker/migrations/0009,0010. 0009 addssubchapter_source,subchapter_confidence_bpandsubchapter_classifier_version, following theshape
explanations.originalready set. Writing a guess intosubchapter_idalone would make it indistinguishable from a librarian's decision a year from
now. SQLite's
ALTER TABLEcannot add table-level CHECKs and rebuildingquestionswould mean recreating every dependent index and trigger, so thecross-column invariants are triggers. One of them caught my own restore
statement in the smoke suite, which is the point. 0010 repairs the durations in
databases where 0007 was already applied; the generator now derives them via
readConfirmedso the literal cannot drift again.frontend/src/services/subchapterClassifier.ts+data/subchapterTerms.ts.60 weighted term lists. The chapter is already known, so this is 1-of-6, never
1-of-60. Two guards must both pass or it declines — a wrong syllabus reference
shown to a student is worse than none. Stem evidence dominates option evidence
after a distractor reading "Depreciation" vetoed a stem that said "project life
cycle" outright.
frontend/src/auth/*,pages/auth/SignIn.tsx. Three states, not two:loadingmust not be read as signed-out or every refresh bounces a signed-inuser to the login page, and
disabled(no API configured) must not show a loginwall it cannot satisfy. The context and hooks are split from the provider
because Fast Refresh only swaps modules whose exports are all components.
frontend/src/pages/app/*. The four quiz pages went from four copies of thestate machine to one engine: 1,403 lines to 987, while gaining five features.
MockTestQuiz loses its 10-per-page layout deliberately — the engine tracks one
index, so a paginated host would misattribute per-question elapsed time.
frontend/src/App.css. 214 rules removed, 1,062 lines, via a tool kept atscripts/find-dead-css.mjsbecause 5,151 lines remain. Every step is biasedtoward keeping a rule; the five generic names that vanished (
done,low,bookmarked,practice,study) were each checked by hand — all existed onlyin compounds with a dead class.
Look hardest at
migrations/0009triggers. They refuse combinations rather than coercingthem, and a wrong predicate there blocks legitimate writes.
subchapterClassifier.tsprecision. Coverage is measured (66.4%);precision is not — there is no ground truth. A term list that confidently
mislabels a whole topic looks like excellent coverage.
scripts/classify-subchapters.mjs --reviewemits all 1,536 labelsweakest-first. I spot-checked the floor and found them correct, which is a
spot check, not a measurement.
jobs/retention.tswrite accounting.WRITE_COST_PER_ANSWER = 5is myestimate of row-plus-index writes. If it is low, the job overspends the daily
budget and takes the platform read-only.
Known incomplete, stated rather than hidden
/signin. The login page is unreachableexcept by typing the URL, and the landing's name-prompt modal predates auth.
This is the next commit.
legalMeta.tsstill hasREPLACE_WITH_A_REAL_EMAIL; both legal pages render avisible banner until it is filled.
localData.PublicQuestioncarries noanswer key by design, so moving them is an engine change, not a data-source
swap.
Checklist
npx tsc -b,npm run lint,npx vitest runandnpm run buildall pass locallynode scripts/validate-questions.mjspasses — 0 errorseslint-disable,@ts-ignore,@ts-expect-erroror skipped testbookChapters.generated.tsregenerated — n/a, chapter JSONs unchangeddocs/still describe what the code actually doesIf this changes question data
Question data is unchanged.
0010_exam_duration.sqlchanges mock-testdurations, not questions.