Plan 13 Phase 3 - SekiroKenjii/Quizzivy GitHub Wiki
Deliverable (§16): assignment create/list, student home, intro, take-test engine, audio player, integrity capture. Exit criteria (§16): E2E 5, 6, 7, 8 pass.
The riskiest phase. features/take-test/, features/integrity/ and
features/media/ are the high-risk areas AGENTS.md names — run their unit tests
before and after every change, and do not refactor them opportunistically.
Scope history: auto_submit was deferred to T-5.1 when Phase 3 shipped.
The approved admin change request (spec v0.6, 2026-09-22) now requires immediate
submission with retained answers and recorded violations, superseding the old
countdown/cancellation proposal.
Depends on: T-2.9
Touches: migrations/
Size: M
Migrations: 00018_create_assignments.sql,
00019_create_assignment_targets.sql
Done when:
- Matches
20-data-model.md§9: flattened review and integrity policy columns with §10.3's defaults,closed_atand nostatuscolumn (D-18), and the composite FK totest_versions (id, test_id)(D-17) - Test:
constraints_test.go— an assignment pointing at a version of a different test is rejected (D-17) - Test:
constraints_test.go—closes_at <= opens_atis rejected - Test:
constraints_test.go— a new row's integrity defaults equal §10.3 - DDL reviewed against §13 and the Neon skill (§14)
Depends on: T-3.1
Touches: server/internal/assignments/
Size: L
Done when:
-
GET /admin/assignments,POST,GET /:id,PATCH /:idper §15 - Only a published test version may be assigned (§8)
-
statusis derived in the projection fromopens_at,closes_atandclosed_at— never stored (D-18) -
test_version_idmay be changed only while no attempt exists for the assignment; otherwise 409. Existing attempts keep their own version regardless (§7's invariant) - Target resolution unions
assignment_classes → class_memberswithassignment_students, de-duplicated, in one query — not one query per class (§13.8) -
GET /app/assignmentsreturns{ dueNow, upcoming, completed }(§15) - Test:
assignments/targets_test.go— a student in two targeted classes appears once - Test:
assignments/repoint_test.go— changing the version after an attempt exists is 409 - Test:
assignments/status_test.go— the three derived states at boundaries, including an earlyclosed_at -
EXPLAIN (ANALYZE, BUFFERS)on the target-resolution query recorded in the PR (§13.8)
Depends on: T-3.1
Touches: migrations/
Size: S
Migrations: 00020_create_attempts.sql
Done when:
- Matches
20-data-model.md§10, includingshuffle_seed(D-02),beacon_token_hash(D-03),attempts_one_live, and the two partial dashboard indexes - Test:
constraints_test.go— a secondin_progressattempt for the same (assignment, student) is rejected byattempts_one_live - Test:
constraints_test.go—deadline_at <= started_atis rejected; statusvoidedwithout a reason is rejected - DDL reviewed against §13 and the Neon skill (§14)
Depends on: T-3.3, T-3.2
Touches: server/internal/attempts/
Size: L
Done when:
-
POST /app/assignments/:id/attemptscreates or resumes, returning the attempt, the ordered questions,sessionIdand the beacon token (§15) - Eligibility checked at creation: assignment open, student targeted,
attempt_no <= max_attempts -
deadline_atis computed server-side asmin(now() + duration, closes_at)— see40-open-items.mdfor the in-flight-close default, which this encodes -
shuffle_seedis drawn once from a CSPRNG; question and option order are a pure function of(seed, id)so client, server and grading agree (D-02) - Resuming issues a new
session_id, writes aresumeevent, and — if the previous session was live — asession_takeoverevent (§10.1) - Idempotent under a double-tap: the
attempts_one_liveunique loses the race and the handler resumes - A fresh beacon token is minted per session and stored hashed (D-03)
- Test:
attempts/create_test.go— two concurrent creates yield one attempt - Test:
attempts/shuffle_test.go— the same seed yields the same order across 1000 runs, and different seeds differ - Test:
attempts/deadline_test.go— a 60-minute duration on an assignment closing in 10 minutes yields a 10-minute deadline - Test:
attempts/resume_test.go— resume supersedes the prior session
Depends on: T-3.4
Touches: server/internal/attempts/
Size: M
Migrations: 00021_create_attempt_answers.sql
Done when:
-
00019matches20-data-model.md§10, including the VIRTUALfinal_score,requires_manual,graded_by,graded_at(D-19) -
GET /app/attempts/:idreturns the attempt, questions in shuffled order,serverTime, andaudioPlays(§15) - Explicit column lists only.
is_correct,sample_answer,transcriptand accepted blank answers are not selected, and the Go response type has no field for them (§13.5) -
serverTimeis returned so the client can compute clock offset rather than trusting the device clock - Test:
attempts/payload_test.go— a recursive walk of the JSON response finds noisCorrect,sampleAnswer,acceptedAnswersortranscriptat any depth. This is E2E 9's unit-level twin; §13.5 requires it - Test:
attempts/payload_test.go— another student's attempt is 403 - DDL reviewed against §13 and the Neon skill (§14)
Depends on: T-3.5
Touches: server/internal/attempts/
Size: M
Done when:
-
PATCH /app/attempts/:id/answerstakes{ sessionId, answers[], events[] }(§15) and upserts answers by(attempt_id, question_id) - A
sessionIdthat is not the attempt's current session is rejected withSESSION_SUPERSEDED, which is how the first tab learns it lost (§10.1'ssession_takeover, E2E 7) - Writes after
deadline_atare rejected; the client is told to submit - Answers and events land in one transaction so a partial flush cannot record an event for an answer that was not saved
- Events are inserted with
ON CONFLICT (attempt_id, session_id, client_seq) DO NOTHING(D-01) - Test:
attempts/autosave_test.go— a superseded session gets 409 and writes nothing - Test:
attempts/autosave_test.go— replaying the identical batch twice is a no-op -
EXPLAIN (ANALYZE, BUFFERS)on the upsert recorded in the PR (§13.8)
Depends on: T-3.5
Touches: server/internal/attempts/
Size: S
Migrations: 00022_create_attempt_audio_plays.sql
Done when:
-
POST /app/attempts/:id/audio-playtakes{ questionId }and returns{ plays, maxPlays }(§15) - The increment is one
INSERT … ON CONFLICT DO UPDATE … RETURNING plays(20-data-model.md§10) - The server never rejects a play and never rejects a submit based on play count (§11.4). Over-limit plays are reported to the teacher, not enforced
- Test:
attempts/audio_plays_test.go— ten concurrent increments yield exactly ten plays - Test:
attempts/audio_plays_test.go— a play beyondmaxPlaysreturns 200 with the higher count, not an error
Depends on: T-3.6
Touches: server/internal/integrity/
Size: M
Migrations: 00023_create_attempt_events.sql, 00009_grant_app_role.sql
Done when:
-
00021matches20-data-model.md§10, with the(attempt_id, session_id, client_seq)unique (D-01) -
00022grantsquizzivy_appDML onappand then revokesUPDATEandDELETEonattempt_eventsandaudit_log— this is what turns §13.3's "noUPDATEorDELETEis ever issued" from a promise into a privilege. It runs last because it grantsON ALL TABLES, and addsALTER DEFAULT PRIVILEGESso later tables are covered (20-data-model.md§11) -
POST /app/attempts/:id/eventsaccepts both the bearer-authenticated JSON path and the beacon path:text/plainbody carryingbeaconToken(00-overview.md§4.3) - The beacon token is verified against
beacon_token_hashin constant time and grants append-only event access — no reads, no answer writes -
occurred_atis offset-corrected againstreceived_atusing the client's reported skew; both are stored (§13.3) - Unknown
kindvalues are stored, not rejected — this is telemetry, and a newer client must not fail against an older server - Test:
integrity/beacon_test.go— a valid beacon token appends events; an expired one is rejected; a beacon token cannot read the attempt - Test:
integrity/events_test.go— the sameclient_seqfrom two differentsession_ids both persist (the D-01 regression test) - Test:
integrity/events_test.go— a duplicate batch inserts nothing and returns 200 - Test:
db/grants_test.go— connected asquizzivy_app, anUPDATEand aDELETEagainstattempt_eventsandaudit_logare both refused - DDL reviewed against §13 and the Neon skill (§14)
Depends on: T-3.6, T-3.7
Touches: server/internal/attempts/, server/internal/grading/
Size: L
Done when:
-
POST /app/attempts/:id/submitis idempotent; a second call on a closed attempt returns 409 withATTEMPT_CLOSED(§15) - Auto-grading covers
single_choice,multiple_choice,true_falseandfill_blank;short_answersetsrequires_manual = true(D-19) -
multiple_choicescoring rule is all-or-nothing — stated explicitly here because §7 does not, and partial credit is a different product decision (recorded in40-open-items.md) -
fill_blankmatching trims surrounding whitespace, collapses internal runs, and compares case-insensitively unlesscase_sensitive - A deadline passing without a submit results in
timed_out, graded the same way, evaluated lazily on next read rather than by a scheduler -
attempts.score_earned/score_totalare written at submit;score_totalcomes from the frozentest_versions.total_points - Test:
grading/autograde_test.go— one case per type, including afill_blankwith a diacritic and a trailing space - Test:
attempts/submit_test.go— double submit returns one graded attempt - Test:
attempts/timeout_test.go— an attempt past its deadline reads back astimed_outwithout a background job
Depends on: T-3.5, T-0.12
Touches: web/src/features/take-test/store.ts
Size: L
Done when:
- Zustand store owning: question order, answers, dirty set, timer, session state, submit state
- Timer math uses the server offset, not the device clock: remaining is
deadlineAt - (Date.now() + offset), with the offset fromserverTime - Answers buffer locally and flush on a debounce; a failed flush retries with backoff and never loses the local value
- Resume merge: on reload, server answers are the base and any
un-flushed local answers newer than the server's
updated_atwin. Never the reverse — the spec's whole point is that a refresh does not lose work (§1.2) - Submit is idempotent client-side: a second tap is a no-op while one is in flight
-
SESSION_SUPERSEDEDputs the store into a read-only state with a plain explanation, rather than throwing (E2E 7) - Test:
take-test/timer.test.ts— remaining is correct with a device clock 5 minutes fast - Test:
take-test/resume.test.ts— local-newer wins, server-newer wins, and no answer is dropped in either direction - Test:
take-test/submit.test.ts— double submit issues one request - TypeScript strict, no
any(§14)
Depends on: T-3.10
Touches: web/src/features/take-test/components/
Size: L
Done when:
- A renderer per
QuestionType, reading from the store (§7) -
fill_blankinterleaves inputs into the Markdown at the{{n}}placeholders, preserving surrounding formatting -
FocusLayout: one question centered at max-width ~720px, spacious,leading-relaxed(§12) - Safe-area padding and a 360px-wide layout that does not overflow (§9, §16)
- Prompts render with react-markdown + rehype-sanitize (§2)
- Test: a renderer test per
QuestionType(§14's component level) - Test:
fill-blank-renderer.test.tsx— three blanks produce three labelled inputs in prompt order - Keyboard-operable; both locales; loading / error / empty states (§14)
Depends on: T-3.7, T-2.4
Touches: web/src/features/media/AudioPlayer.tsx
Size: L
Done when:
- Custom controls over a native
<audio>; no wavesurfer, no howler (§2) -
.play()is called synchronously inside the click handler — nothing is awaited before it, or iOS Safari blocks playback (§11.3) -
preload="metadata"so duration renders without downloading (§11.3) -
allowSeek: false: no range input, and anonSeekinghandler that resetscurrentTimeto the last known position. Anaudio_seekevent is recorded rather than pretending OS media controls cannot seek (§11.3) - Plays-remaining rendered from the server value, optimistically decremented on play and reconciled on the next fetch (§11.4)
- A failed play-event POST does not block playback (§11.4)
- The signed URL is treated as expiring: a 403 triggers a refetch (§11.2)
- Autoplay is never attempted and a blocked play is not an error state (§11.3)
- Real
<button>elements witharia-label, keyboard-operable, and anaria-liveannouncement of plays remaining (§11.3) - One instance per question; navigating away pauses and releases it (§11.3)
- Monochrome per §12: filled
zinc-900play button, thinzinc-200track withzinc-900fill. No waveform, no equaliser, no colour accents - Test:
AudioPlayer.test.tsx— states idle / playing / plays exhausted / seek blocked / load error (§14's component level) - Test:
AudioPlayer.test.tsx—play()is invoked in the same synchronous tick as the click, with no intervening microtask - Test:
audio-plays.test.ts— optimistic decrement reconciles to the server value on refetch (§14's unit level)
Depends on: T-3.10
Touches: web/src/features/integrity/
Size: L
Done when:
- One
useIntegrityMonitor(attemptId, policy)hook owns every listener, registered and torn down in a singleuseEffect. No scattered listeners (§10.6) - Captures every §10.1 signal:
tab_hidden/tab_visible,window_blur/window_focus,fullscreen_enter/fullscreen_exit,copy/cut/paste,context_menu,network_offline/network_online,audio_*,page_hide - Devtools-detection heuristics are not implemented (§10.1) — they false-positive on zoom and split-screen and are bypassed in seconds
- Away episodes store both endpoints so duration is visible, and a strike is
counted only when an episode exceeds
minAwayMs(default 3000) (§10.1) -
clientSeqis monotonic within a session and persisted alongside thesessionIdinsessionStorage, so a same-tab reload continues the sequence and a new session starts fresh — which is safe because the server key includessession_id(D-01) - Events buffer in memory +
sessionStorage, flush with the autosave batch, and flush immediately onpagehideviasendBeaconwith the beacon token (§10.6,00-overview.md§4.3) - Fire-and-forget: a failed flush never blocks answering or submitting, and integrity failure never blocks input (§10.6)
-
copy/cut/pasteare always recorded; blocked only whenblockCopyPaste(§10.1).context_menuis recorded, never blocked - Test:
integrity/buffer.test.ts— buffering and flush batching (§14) - Test:
integrity/strikes.test.ts— a 2-second blur is no strike, a 90-second blur is one (§10.1) - Test:
integrity/beacon.test.ts—pagehidecallssendBeaconwith atext/plainBlob containing the beacon token - Test:
integrity/failure.test.ts— a rejected flush leaves answering and submitting fully functional
Depends on: T-3.13
Touches: web/src/features/integrity/components/
Size: M
Done when:
- First violation shows a non-dismissible dialog stating what happened, strikes remaining and what happens at zero. The timer keeps running (§10.2)
- A small persistent indicator shows remaining strikes when
maxFocusLoss != 0(§10.2; -1 means no departures allowed) -
warn= dialog only;flag= attempt marked and the student told.auto_submitfollows the revised immediate-submission contract in T-5.1 - Fullscreen: entering happens on the "Bắt đầu" click, because browsers require a gesture (§10.2). Exit shows a "Quay lại toàn màn hình" button
- Never trap the student:
Escalways works, and there is always a visible way to leave and submit (§10.2) - Copy tone per §12: plain dialogs, plain text, no alarm iconography, no shame, no red banners
- Help text states §10.5's honest limits — browser monitoring sees this tab losing focus and nothing else
- Test:
integrity/dialog.test.tsx— dialog states (§14's component level) - Test:
integrity/dialog.test.tsx—Escis never swallowed and a submit path is always reachable - Both locales, Vietnamese written first; keyboard-operable (§14)
Depends on: T-3.2, T-3.14
Touches: web/src/features/assignments/, web/src/features/take-test/pages/
Size: M
Done when:
-
/app— Đến hạn / Sắp tới / Đã hoàn thành, each with its own empty state (§9) -
/app/assignments/:id— title, instructions, duration, attempts used/allowed, review policy, and the integrity rules stated plainly in Vietnamese before starting (§10.2) - Audio rules stated when the test has listening questions, e.g. "Mỗi câu nghe được phát tối đa 2 lần" (§9)
- Start / Resume reflects whether a live attempt exists
-
/app/classeslists joined classes with "Tham gia lớp mới" →/join(§9) - Test:
intro.test.tsx— the stated rules match the assignment's policy for each of the four policy combinations - Loading / error / empty states; both locales; keyboard-operable (§14)
Depends on: T-3.15, T-3.12, T-3.9
Touches: web/tests/e2e/
Size: M
Done when:
-
E2E 5 — timer expiry auto-submits (
timer-expiry.live.spec.ts) -
E2E 6 — tab switch fires
tab_hidden, the warning dialog appears, and the event is retrievable — asserted by reloading, since a reload discards the tab's own count and the spent allowance still comes back (integrity-away.live.spec.ts). The admin timeline itself is Phase 4. -
E2E 7 — a second tab on the same attempt supersedes the first; the first goes read-only (
session-takeover.live.spec.ts) -
E2E 8 — audio
maxPlays: play twice → reload → the allowance is still spent. This is the one that proves the counter is server-authoritative rather than client state (§11.4)**Deviation.** This task was written as "button disabled". §11.4 as implemented does not disable anything: "playback is optimistic … over-limit plays are reported to the teacher, not enforced retroactively", and blocking on a round trip "would punish bad wifi far more often than it would catch anyone". So the spec asserts what §11.4 actually promises and what the sentence above says the test is for — the count survives the reload that defeats a client-side counter. Mutation-checked: dropping `session.audioPlays` from the store's hydrate fails it. -
E2E 2's middle: answer, reload mid-test, confirm answers persist — automated as
answers-persist.live.spec.tsrather than passed by hand, since the half that needs Phase 4's result page is the only half that cannot be. The full E2E 2 still lands in Phase 4 -
Phase 1 and 2 E2Es still pass
-
release/phase-3merges tomainand back todevelop; deployable (§16) — released asv0.2.0-phase-3(main 0e8f2a0). The Fly release command ran the migrations against production before the new API took traffic; the Pages publish failed once with Cloudflare's "Unknown internal error" and succeeded on a re-run, which is the hiccup deploy.yml is written to tolerate.
Generated from docs/plan/13-phase-3.md. Edit that file in the repository; changes made on this page are overwritten.