feat(quiz): a real abandon endpoint, wired to Discard (#537 G4) - #591

Merged
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon
Aug 26, 2026
Merged

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4)#591
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes the G4 gap in docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
(R-3), whose spec row is updated in this PR to say so.

Refs #537

The problem

"Discard" on the quiz resume strip had no server side. It wrote a
dismissedAttempts entry to localStorage and left the attempt row
in_progress until D2's lazy 24h sweep found it — so the strip came back on
the student's phone, in a second tab, and in this browser the moment storage
was cleared. lib/quiz/session.ts carried the seam as
TODO(#537-followup: abandon endpoint).

The change

POST /api/quiz/attempts/{attempt_id}/abandon (backend/routes/quiz.py).
No schema change — it writes the same abandoned_at stamp _sweep_abandoned
already writes; the client is just allowed to say "now".

  • Owner-checked with require_self, exactly like the other attempt routes
    (404 unknown → 403 not yours, in that order).
  • 409QUIZ_ATTEMPT_ALREADY_COMPLETED on a submitted attempt: the score,
    mastery and XP are paid out and a discard could not take them back.
  • Idempotent: a second call is a 200 no-op returning the stamp already on
    the row, so a retry after a dropped response is free. A row the TTL sweep
    already claimed reports the sweep's timestamp, not a fresh one.
  • The write is submit's conditional claim (completed_at IS NULL AND abandoned_at IS NULL), so a concurrent submit and abandon cannot both win;
    the loser re-reads and 409s instead of reporting a discard that never
    happened.
  • status is produced by _attempt_status, never the literal — this endpoint
    must not be the one place that can disagree with the read paths.

Submit's claim is now symmetric. It filtered on completed_at IS NULL
alone, with _refuse_if_abandoned in front of it as a non-atomic pre-read.
This PR makes that interleaving reachable from the UI — a quiz open mid-question
in one tab, Discard pressed in another — and both claims would win, leaving the
row completed and abandoned. Submit's claim now requires abandoned_at IS NULL too, and a lost claim re-reads before choosing its 409 so a discarded
attempt answers QUIZ_ATTEMPT_ABANDONED instead of impersonating a double
submit (the client maps those codes to different copy).

Client: abandonAttempt in lib/quiz/api.ts, and useQuizHome::discard
as the whole gesture — hide locally (instant), clear the resume slot, abandon
(durable). No refresh: the merge-gate section below has why the original one
had to go. The screen just states the intent. A failed abandon is swallowed with a
console.warn: the student said discard, so resurrecting the quiz would be
the worse answer and the 24h sweep is still the backstop.

The resume strip needed no filter change.GET /attempts reports the
derived status, discoverResumable already offers only in_progress rows,
and getAttempt now answers resumable: false — so both discovery paths drop
the attempt on a reload and on any other device. GET /attempts deliberately
still lists abandoned rows: it is D4's history reader, and filtering it would
break history to fix the strip.

Test evidence

  • backend/tests/test_quiz_abandon_g4.pynew, 14 tests. RED 10 failed
    (route absent) → GREEN. Covers the 200 + stamp, the claim's filters, the
    idempotent repeat (and that it writes nothing twice), a TTL-swept row, 409 on
    completed, the concurrent-submit race, 404, 403 for a foreign attempt
    (real require_self restored — the lane stubs it), and that getAttempt,
    the listing, /answer and /submit all agree afterwards. Its fake models
    the PostgREST filter grammar rather than returning a canned value, so "the
    claim won" and "the claim was refused" are actually distinguishable.
  • The symmetric-claim fix has its own class: a discard stamped in the window
    between submit's pre-read and its claim leaves completed_at null, pays out
    no mastery and 409s as QUIZ_ATTEMPT_ABANDONED — RED without the added
    filter (the claim won and the request ran on past it), GREEN with it — plus a
    guard that an ordinary double submit still reads as
    QUIZ_ATTEMPT_ALREADY_COMPLETED.
  • Backend suite 2237 passed, 81 skipped; ruff check . clean.
  • backend/tests/integration/test_quiz_subcutaneous_db.py — abandon stamps the
    real row (read back through psycopg, not PostgREST), 409 after submit, and
    the IDOR negative extended to abandon. Integration-marked; not run locally.
  • Frontend: RED 7 failed with the implementation stashed → GREEN
    102 files / 1152 tests. tsc --noEmit clean, eslint 0 errors.
  • frontend/e2e/quiz-journeys.spec.ts — the resume journey now waits on the
    abandon POST, asserts abandoned_at is set in the DB, and reloads with both
    quiz storage keys wiped (the other-device case). What it pins there is the
    listing's own status for the attempt — in_progress as a positive
    control, abandoned after the discard — because every rendered consequence
    of that payload is a further round trip away, so a check on the strip alone
    would pass on timing. The strip's absence is kept as corroboration.
    Not run in this branch; the lane owns the stack.

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2237 passed / 81 skipped · eslint ✅ · tsc ✅ · vitest ✅ 1152
  • Playwright Chapter 1 ✅ 73 passed / 1 skipped (2.9m) — includes the new G4 journey (discard → DB abandoned_at set → server status abandoned on a fresh context → strip absent)
  • oracles ✅ clean · integration ✅ 73 passed (includes the new real-HTTP abandon cases)
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629622649
  • Review: task review (deviation on GET /attempts judged correct; 1 Important — the cross-device e2e assertion could fire before a regression surfaced → fixed by asserting the server's own status first; submit's atomic claim made symmetric with abandon's) + scoped re-review clean.

Merge-gate review (2026-08-26)

origin/main merged in (f6ed004 / 6225bba / 025474a — no conflicts; #590's
include_answer_key flip is in the generate handler, clear of these hunks).
Two commits on top, from a /code-review pass whose findings were verified
against the code before being acted on.

The race the PR re-created (the one that mattered)

useQuizHome::discard fired refresh()synchronously beside the abandon
POST. The re-read routinely predated the write and came back in_progress, so
the resume strip stayed hidden only because discoverResumable skips locally
dismissed ids — the localStorage single point of failure G4 exists to retire.
With storage unavailable (private window: session.ts::writeJson no-ops and
the abandon error is swallowed) the discarded quiz came straight back. It also
cost a full home-screen skeleton flash and a re-fired describeConcept per
discard.

Fixed by clearing the resumable slot directly and dropping the global re-read.
Nothing else on quiz home derives from an unfinished attempt — the ranking
and the "missed N last time" join both read COMPLETED attempts only
(proposals.ts) — so the re-read bought nothing and raced the write it meant
to observe. dismissAttempt still runs first, as the across-loads backstop.

Honest failures on both claim losers

  • abandon built a 200 out of an empty dict: (rows or [{}])[0] then
    abandoned_at = current.get(...) or now reported {status: "abandoned", abandoned_at: <now>} for a row that may not exist, with a timestamp nothing
    wrote. An empty re-read is now the same 404 the top of the route gives, and
    the branch never substitutes its own clock for a write it did not make.
  • submit had the same phantom dict (_refuse_if_abandoned({}) is a no-op,
    so a vanished row reported ALREADY_COMPLETED), and the new SELECT sat in a
    window that used to be infallible — losing the claim went straight to a 409.
    A transient PostgREST failure there now degrades back to that 409 rather
    than 500ing an ordinary double-click.
  • _load_owned_attempt and _refuse_if_completed replace the select+404+
    require_self preamble (verbatim in four routes) and the hand-rolled
    already-completed 409 (five copies).

Copy

QUIZ_ATTEMPT_ABANDONED still read "That quiz expired after a day" — copy
from the TTL-sweep era, and a bug to the student who pressed Discard thirty
seconds ago. abandoned_at records when a row closed, never who
closed it, so nothing on the wire can tell the two apart: both the client copy
and the server sentence now cover both. Contract table updated to match.

Test fidelity (why the above were invisible)

  • _Attempts.select / select_with_count ignored their filters and always
    returned the stored row, so no test could fail on a dropped or mis-built
    id=eq. / user_id=eq., and the history-listing assertion was pinned on a
    read nobody made. Both honour filters now, and the listing test asserts what
    the fake was asked for.
  • The fake ignored prefer_return_minimal, but db/connection.py returns []
    in that mode — a claim refactored to minimal would read as LOST on every
    request (every submit 409s, no payout) with the suite green. Modelled, plus
    an explicit assertion that abandon's claim is not minimal.
  • Three vitest assertions were vacuous: resumable was null merely because
    refresh() had bumped the load key. They now pin the ordering (strip gone
    while the POST is still in flight, status still "ready"), the read
    counts, and the storage-unavailable case that is the real regression.
  • The journey's otherDeviceVisit hardcoded both storage keys, re-declared
    the listing type and inlined openQuizHome's body — a key rename would have
    left the other-device leg green and vacuous. Now imports STORAGE_KEY /
    DISMISSED_KEY / AttemptsPage from @/lib (precedent:
    quiz-errors.spec.ts). The post-discard proposal wait got SUBMIT_TIMEOUT
    like every sibling.
  • The G4 origin story was retold at length in six places; one canonical
    telling (routes/quiz.py::abandon_attempt) plus pointers.

Follow-up: #597

POST /attempts/{id}/answer is the last quiz write path guarded by a pre-read
alone. G4 turns "the attempt closed mid-request" from a 24h TTL event into a
button, so a Discard landing between the refusals and the insert records a
graded response on a closed attempt. PostgREST cannot make that cross-table
INSERT conditional atomically — closing it needs a trigger or an RPC, i.e. a
migration — so it ships as an accepted risk with a comment at the insert.
Inert rather than harmful: an abandoned attempt can never be submitted
(submit's claim filters on abandoned_at IS NULL), so the orphan pays out no
mastery, XP or achievement. Filed as #597.

Two notes, deliberately not changes

  • abandoned_at on a TTL-dead row records the discard CLICK, not when the
    attempt went quiet. Noted at the claim. It matters only if a consumer ever
    reads the column as an elapsed time; none does today (the derived status
    only asks whether it is set), and "when did this go quiet" is created_at
    plus the last response — what _attempt_status already computes.
  • The journey's post-click toHaveCount(0) used to witness the skeleton
    rather than the optimistic hide, since refresh() blanked the screen. With
    the race fix there is no skeleton, so the paired
    expect(quiz-proposal).toBeVisible() is what makes it mean "gone from a
    rendered screen"; that is now stated where the assertion is.

One push-back

D2 also asked to drop the or now fallback on abandon's winning claim.
Left in place: there, now is the value the request itself PATCHed, so
preferring the echoed column and falling back to it yield the same timestamp —
it is not a fabrication. The fabricated one was the loser branch's, and that
is gone.

Verification

  • backend: pytest tests/ -q2247 passed / 82 skipped; ruff check .
  • frontend: vitest run1154 passed / 102 files; tsc --noEmit ✅;
    npm run lint ✅ 0 errors
  • npx playwright test e2e/quiz-journeys.spec.ts --list ✅ collects 9 tests
    (the @/lib imports resolve). The journey itself is unrun here — the
    stack is the controller's.

AndresL230and others added 3 commits August 23, 2026 04:13
Discard had no server side: quiz home wrote a localStorage flag and left
the row in_progress until D2's 24h sweep found it, so the resume strip
came back on every other device.
The route writes the same `abandoned_at` stamp `_sweep_abandoned` writes —
the client is just allowed to say "now". Owner-checked like the other
attempt routes, 409 on an already-submitted attempt, and idempotent: a
second call is a 200 no-op carrying the stamp already on the row, so a
retry after a dropped response is free. The write is the same conditional
claim submit uses (`completed_at IS NULL AND abandoned_at IS NULL`), so a
concurrent submit and abandon cannot both win; the loser re-reads and 409s
rather than reporting a discard that never happened.
No schema change — `abandoned_at` has existed since D2. `GET /attempts`
deliberately still LISTS abandoned rows (it is the history reader, D4);
what changes is the derived status, which is what the strip filters on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`useQuizHome::discard` is now the whole gesture: `dismissAttempt` hides the
row in this browser synchronously (so the strip is gone on the next render
whatever the network does), `abandonAttempt` makes it durable, and the
refresh re-reads the world. The screen just states the intent — splitting
the two writes across the component is how they drifted apart in the first
place, with the row hidden here and closed nowhere.
A failed abandon is swallowed with a console.warn rather than surfaced: the
student said discard, so putting the quiz back on screen (or a red toast
over a discard that visibly worked) would both be worse answers, and the
backend's 24h sweep is still the backstop.
The resume strip needed no filter change — `GET /attempts` reports the
derived status, `discoverResumable` already offers only `in_progress` rows,
and `getAttempt` now answers `resumable: false`, so both discovery paths
drop the attempt on a reload and on any other device.
The Chapter 1 resume journey is updated to match and is UNRUN here (the
controller owns the stack): it now asserts `abandoned_at` is set, and
proves the server is what hides the attempt by wiping both quiz storage
keys and reloading — with a positive control first, since "the strip is
absent" means nothing unless the same visit was just shown to offer it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…journey (#537 G4)
Important 1 — the cross-device assertion was one round trip too early.
`otherDeviceVisit` synced on the attempts listing and the proposal card, both
of which land at `setLoaded`; in the REGRESSED case the strip only renders
after `discoverResumable` awaits a further `GET /attempts/{id}`, so the count
check fired while the failure was still in flight. The helper now returns the
listing's parsed body and the journey asserts the SERVER'S status for the
attempt (`in_progress` on the positive control, `abandoned` after the
discard). The strip check stays as corroboration.
M2 — the DB read is synchronized to the abandon POST via the file's own
`page.waitForResponse` idiom, not to the click (the strip vanishes
optimistically, so the click races the write being checked).
M3 — submit's claim now filters `abandoned_at IS NULL` too, symmetric with
abandon's. G4 makes the interleaving reachable from the UI (mid-quiz in one
tab, Discard in another): the old single-null filter let both claims win and
left the row completed AND abandoned, with `_refuse_if_abandoned` only ever a
non-atomic pre-read. A lost claim now re-reads before choosing its 409, so a
discarded attempt answers QUIZ_ATTEMPT_ABANDONED rather than impersonating a
double submit — the frontend maps those codes to different copy.
M4 `import type { Page }`. M5 the R-3 row's "Cost if wrong" now describes the
failed-call fallback, which is all that is left of it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 27f4c739-d806-48a9-b900-ba224b02b98a

📥 Commits

Reviewing files that changed from the base of the PR and between 025474a and b4f1a35.

📒 Files selected for processing (19)
  • backend/routes/quiz.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_quiz_abandon_g4.py
  • backend/tests/test_quiz_routes.py
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb4f1a35Commit Preview URL

Branch Preview URL
Aug 26 2026, 06:40 PM

Merge-gate review of PR #591, backend half.
D2/D3 — both lost-claim branches read the row back as `(rows or [{}])[0]`,
which made a vanished row indistinguishable from an open one: abandon
answered 200 `{status: "abandoned", abandoned_at: <now>}` with a timestamp
nothing had written, and submit ran `_refuse_if_abandoned({})` as a no-op and
reported ALREADY_COMPLETED. An empty re-read is now the 404 the top of each
route already gives, and abandon no longer substitutes its own clock for a
write it did not make.
D3 also put a SELECT in a window that used to be infallible — losing submit's
claim went straight to a 409 with no further I/O. A transient PostgREST
failure there now degrades back to that 409 instead of 500ing an ordinary
double-click.
D10/D11 — the select-by-id + 404 + require_self preamble was verbatim in four
routes; the already-completed 409 was hand-rolled five times. Both are now
`_load_owned_attempt` / `_refuse_if_completed` (+ `_attempt_not_found` /
`_already_completed`).
D4 (backend half) — QUIZ_ATTEMPT_ABANDONED's sentence said "expired", which is
wrong on the path this PR creates. `abandoned_at` records when a row closed,
never who closed it, so the copy now covers both.
D7/D8/D12 — test fidelity, which is why the above were invisible. `_Attempts`
honours its filters on select/select_with_count (a fake that answers every
query cannot fail on a dropped `id=eq.`), models `prefer_return_minimal` (the
real client returns [] in that mode, so a claim refactored to minimal reads as
LOST on every request), and stops duplicating the attempt-row fixtures
byte-for-byte from test_quiz_lifecycle_d. `_factory`'s dead `responses` param
is gone.
D14 — accepted-risk comment at the /answer insert; filed as #597.
D15 — noted at the claim that a TTL-dead row's stamp records the click.
Refs #537, #591, #597
Merge-gate review of PR #591, frontend half.
D1 — `discard` fired `refresh()` synchronously beside the abandon POST, so the
re-read routinely PREDATED the write and came back `in_progress`. The strip
then stayed hidden only because `discoverResumable` skips locally dismissed
ids — the exact localStorage single point of failure G4 exists to retire, and
one that fails outright in a private window (session.ts's writeJson no-ops and
the abandon error is swallowed, so the quiz the student just discarded comes
straight back). It also flashed the whole home screen to a skeleton and
re-fired describeConcept to change one row.
The slot is now cleared directly. Nothing else on quiz home derives from an
unfinished attempt — the ranking and the "missed N last time" join both read
COMPLETED attempts only (proposals.ts) — so the re-read bought nothing and
raced the write it meant to observe.
D9 — the three vitest assertions guarding this passed for the wrong reason:
`resumable` was null merely because refresh() had invalidated the load key.
They now pin the ordering (the strip is gone while the POST is still in
flight, with `status` still "ready"), the read counts, and — the real
regression — that the strip stays gone with localStorage unavailable.
D4 (frontend half) — QUIZ_ATTEMPT_ABANDONED said "That quiz expired after a
day", copy from the TTL-sweep era that reads as a bug to the student who
pressed Discard thirty seconds ago.
D5/D6 — the journey's `otherDeviceVisit` hardcoded both storage keys,
re-declared the listing type and inlined openQuizHome's body; a key rename
left the other-device leg green and vacuous. It now imports STORAGE_KEY /
DISMISSED_KEY / AttemptsPage from @/lib (precedent: quiz-errors.spec.ts) and
calls openQuizHome. The post-discard proposal wait gets SUBMIT_TIMEOUT like
every sibling.
D13 — the G4 origin story was retold at length in six places; one canonical
telling (routes/quiz.py::abandon_attempt) plus pointers.
Refs #537, #591
…G4)
Merge-gate re-review, two blockers + a ride-along.
B1 — the D1 regression test ("hides it on the server's word, not on
localStorage's") passed locally and FAILED CI: `expect(isDismissed("open"))
.toBe(false)` got `true`, because the block never took. jsdom is lockfile
pinned, so the only difference is the Node version — CI is on 22 (ci.yml:86),
this box on 26 — and `vi.spyOn(window.localStorage, "setItem")` intercepts on
one and not the other. (`Storage.prototype` intercepts on neither: jsdom hands
out a Proxy.) The premise silently evaporated, leaving the flagship test for
the discard/refresh race asserting nothing on CI.
Blocked at the ACCESSOR instead — `Object.defineProperty(window,
"localStorage", { get() { throw ... } })` — which is what a browser with site
data disabled actually does and what `session.ts::storage()` already catches
into `null`. No dependency on how jsdom hands out the Storage object.
Restored in a `finally` so a failure cannot leak broken storage into the rest
of the file. Re-verified against the pre-fix `discard`: still fails on the
real assertion (the strip comes back), not on the premise.
B2 — the contract spec's R-3 still described Discard as "…then the abandon
call, then a refresh". There is no refresh. Rewritten, and it now also records
that `AbandonResult.abandoned_at` is nullable and that a 200 can carry
`status: "in_progress"`.
Ride-along — the D8 protection did not cover submit's WINNING claim: every
submit test in test_quiz_abandon_g4.py loses the claim for other reasons, so
all five stay green under a minimal-mode refactor. Asserted at the one test
that wins it, the way abandon's claim already does.
Refs #537, #591
@AndresL230
AndresL230 merged commit d0786e2 into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Two add/add conflicts, both resolved by keeping BOTH sides:
* routes/quiz.py — G4 (#591) appended `abandon_attempt` and this branch
appended `_gamification_block` at the same point in the file. Kept the
route where G4 put it and the helper directly above its caller.
* tests/integration/test_quiz_subcutaneous_db.py — G4's abandon tests and
G8's inline-XP test landed in the same slot. Both kept.
Re-verified against G4's rework of submit: the atomic claim still filters on
both `completed_at is.null` and `abandoned_at is.null`, the lost-claim path
still re-reads to separate 409-completed from 409-abandoned from 404-deleted,
and `_gamification_block` still runs after both `award_xp_safe` and the
`check_achievements` pass. TestTheSnapshotIsTakenLast re-verified by mutation
(hoisting the call above the award turns it red).
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4) - #591

Merged
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon
Aug 26, 2026
Merged

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4)#591
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes the G4 gap in docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
(R-3), whose spec row is updated in this PR to say so.

Refs #537

The problem

"Discard" on the quiz resume strip had no server side. It wrote a
dismissedAttempts entry to localStorage and left the attempt row
in_progress until D2's lazy 24h sweep found it — so the strip came back on
the student's phone, in a second tab, and in this browser the moment storage
was cleared. lib/quiz/session.ts carried the seam as
TODO(#537-followup: abandon endpoint).

The change

POST /api/quiz/attempts/{attempt_id}/abandon (backend/routes/quiz.py).
No schema change — it writes the same abandoned_at stamp _sweep_abandoned
already writes; the client is just allowed to say "now".

  • Owner-checked with require_self, exactly like the other attempt routes
    (404 unknown → 403 not yours, in that order).
  • 409QUIZ_ATTEMPT_ALREADY_COMPLETED on a submitted attempt: the score,
    mastery and XP are paid out and a discard could not take them back.
  • Idempotent: a second call is a 200 no-op returning the stamp already on
    the row, so a retry after a dropped response is free. A row the TTL sweep
    already claimed reports the sweep's timestamp, not a fresh one.
  • The write is submit's conditional claim (completed_at IS NULL AND abandoned_at IS NULL), so a concurrent submit and abandon cannot both win;
    the loser re-reads and 409s instead of reporting a discard that never
    happened.
  • status is produced by _attempt_status, never the literal — this endpoint
    must not be the one place that can disagree with the read paths.

Submit's claim is now symmetric. It filtered on completed_at IS NULL
alone, with _refuse_if_abandoned in front of it as a non-atomic pre-read.
This PR makes that interleaving reachable from the UI — a quiz open mid-question
in one tab, Discard pressed in another — and both claims would win, leaving the
row completed and abandoned. Submit's claim now requires abandoned_at IS NULL too, and a lost claim re-reads before choosing its 409 so a discarded
attempt answers QUIZ_ATTEMPT_ABANDONED instead of impersonating a double
submit (the client maps those codes to different copy).

Client: abandonAttempt in lib/quiz/api.ts, and useQuizHome::discard
as the whole gesture — hide locally (instant), clear the resume slot, abandon
(durable). No refresh: the merge-gate section below has why the original one
had to go. The screen just states the intent. A failed abandon is swallowed with a
console.warn: the student said discard, so resurrecting the quiz would be
the worse answer and the 24h sweep is still the backstop.

The resume strip needed no filter change.GET /attempts reports the
derived status, discoverResumable already offers only in_progress rows,
and getAttempt now answers resumable: false — so both discovery paths drop
the attempt on a reload and on any other device. GET /attempts deliberately
still lists abandoned rows: it is D4's history reader, and filtering it would
break history to fix the strip.

Test evidence

  • backend/tests/test_quiz_abandon_g4.pynew, 14 tests. RED 10 failed
    (route absent) → GREEN. Covers the 200 + stamp, the claim's filters, the
    idempotent repeat (and that it writes nothing twice), a TTL-swept row, 409 on
    completed, the concurrent-submit race, 404, 403 for a foreign attempt
    (real require_self restored — the lane stubs it), and that getAttempt,
    the listing, /answer and /submit all agree afterwards. Its fake models
    the PostgREST filter grammar rather than returning a canned value, so "the
    claim won" and "the claim was refused" are actually distinguishable.
  • The symmetric-claim fix has its own class: a discard stamped in the window
    between submit's pre-read and its claim leaves completed_at null, pays out
    no mastery and 409s as QUIZ_ATTEMPT_ABANDONED — RED without the added
    filter (the claim won and the request ran on past it), GREEN with it — plus a
    guard that an ordinary double submit still reads as
    QUIZ_ATTEMPT_ALREADY_COMPLETED.
  • Backend suite 2237 passed, 81 skipped; ruff check . clean.
  • backend/tests/integration/test_quiz_subcutaneous_db.py — abandon stamps the
    real row (read back through psycopg, not PostgREST), 409 after submit, and
    the IDOR negative extended to abandon. Integration-marked; not run locally.
  • Frontend: RED 7 failed with the implementation stashed → GREEN
    102 files / 1152 tests. tsc --noEmit clean, eslint 0 errors.
  • frontend/e2e/quiz-journeys.spec.ts — the resume journey now waits on the
    abandon POST, asserts abandoned_at is set in the DB, and reloads with both
    quiz storage keys wiped (the other-device case). What it pins there is the
    listing's own status for the attempt — in_progress as a positive
    control, abandoned after the discard — because every rendered consequence
    of that payload is a further round trip away, so a check on the strip alone
    would pass on timing. The strip's absence is kept as corroboration.
    Not run in this branch; the lane owns the stack.

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2237 passed / 81 skipped · eslint ✅ · tsc ✅ · vitest ✅ 1152
  • Playwright Chapter 1 ✅ 73 passed / 1 skipped (2.9m) — includes the new G4 journey (discard → DB abandoned_at set → server status abandoned on a fresh context → strip absent)
  • oracles ✅ clean · integration ✅ 73 passed (includes the new real-HTTP abandon cases)
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629622649
  • Review: task review (deviation on GET /attempts judged correct; 1 Important — the cross-device e2e assertion could fire before a regression surfaced → fixed by asserting the server's own status first; submit's atomic claim made symmetric with abandon's) + scoped re-review clean.

Merge-gate review (2026-08-26)

origin/main merged in (f6ed004 / 6225bba / 025474a — no conflicts; #590's
include_answer_key flip is in the generate handler, clear of these hunks).
Two commits on top, from a /code-review pass whose findings were verified
against the code before being acted on.

The race the PR re-created (the one that mattered)

useQuizHome::discard fired refresh()synchronously beside the abandon
POST. The re-read routinely predated the write and came back in_progress, so
the resume strip stayed hidden only because discoverResumable skips locally
dismissed ids — the localStorage single point of failure G4 exists to retire.
With storage unavailable (private window: session.ts::writeJson no-ops and
the abandon error is swallowed) the discarded quiz came straight back. It also
cost a full home-screen skeleton flash and a re-fired describeConcept per
discard.

Fixed by clearing the resumable slot directly and dropping the global re-read.
Nothing else on quiz home derives from an unfinished attempt — the ranking
and the "missed N last time" join both read COMPLETED attempts only
(proposals.ts) — so the re-read bought nothing and raced the write it meant
to observe. dismissAttempt still runs first, as the across-loads backstop.

Honest failures on both claim losers

  • abandon built a 200 out of an empty dict: (rows or [{}])[0] then
    abandoned_at = current.get(...) or now reported {status: "abandoned", abandoned_at: <now>} for a row that may not exist, with a timestamp nothing
    wrote. An empty re-read is now the same 404 the top of the route gives, and
    the branch never substitutes its own clock for a write it did not make.
  • submit had the same phantom dict (_refuse_if_abandoned({}) is a no-op,
    so a vanished row reported ALREADY_COMPLETED), and the new SELECT sat in a
    window that used to be infallible — losing the claim went straight to a 409.
    A transient PostgREST failure there now degrades back to that 409 rather
    than 500ing an ordinary double-click.
  • _load_owned_attempt and _refuse_if_completed replace the select+404+
    require_self preamble (verbatim in four routes) and the hand-rolled
    already-completed 409 (five copies).

Copy

QUIZ_ATTEMPT_ABANDONED still read "That quiz expired after a day" — copy
from the TTL-sweep era, and a bug to the student who pressed Discard thirty
seconds ago. abandoned_at records when a row closed, never who
closed it, so nothing on the wire can tell the two apart: both the client copy
and the server sentence now cover both. Contract table updated to match.

Test fidelity (why the above were invisible)

  • _Attempts.select / select_with_count ignored their filters and always
    returned the stored row, so no test could fail on a dropped or mis-built
    id=eq. / user_id=eq., and the history-listing assertion was pinned on a
    read nobody made. Both honour filters now, and the listing test asserts what
    the fake was asked for.
  • The fake ignored prefer_return_minimal, but db/connection.py returns []
    in that mode — a claim refactored to minimal would read as LOST on every
    request (every submit 409s, no payout) with the suite green. Modelled, plus
    an explicit assertion that abandon's claim is not minimal.
  • Three vitest assertions were vacuous: resumable was null merely because
    refresh() had bumped the load key. They now pin the ordering (strip gone
    while the POST is still in flight, status still "ready"), the read
    counts, and the storage-unavailable case that is the real regression.
  • The journey's otherDeviceVisit hardcoded both storage keys, re-declared
    the listing type and inlined openQuizHome's body — a key rename would have
    left the other-device leg green and vacuous. Now imports STORAGE_KEY /
    DISMISSED_KEY / AttemptsPage from @/lib (precedent:
    quiz-errors.spec.ts). The post-discard proposal wait got SUBMIT_TIMEOUT
    like every sibling.
  • The G4 origin story was retold at length in six places; one canonical
    telling (routes/quiz.py::abandon_attempt) plus pointers.

Follow-up: #597

POST /attempts/{id}/answer is the last quiz write path guarded by a pre-read
alone. G4 turns "the attempt closed mid-request" from a 24h TTL event into a
button, so a Discard landing between the refusals and the insert records a
graded response on a closed attempt. PostgREST cannot make that cross-table
INSERT conditional atomically — closing it needs a trigger or an RPC, i.e. a
migration — so it ships as an accepted risk with a comment at the insert.
Inert rather than harmful: an abandoned attempt can never be submitted
(submit's claim filters on abandoned_at IS NULL), so the orphan pays out no
mastery, XP or achievement. Filed as #597.

Two notes, deliberately not changes

  • abandoned_at on a TTL-dead row records the discard CLICK, not when the
    attempt went quiet. Noted at the claim. It matters only if a consumer ever
    reads the column as an elapsed time; none does today (the derived status
    only asks whether it is set), and "when did this go quiet" is created_at
    plus the last response — what _attempt_status already computes.
  • The journey's post-click toHaveCount(0) used to witness the skeleton
    rather than the optimistic hide, since refresh() blanked the screen. With
    the race fix there is no skeleton, so the paired
    expect(quiz-proposal).toBeVisible() is what makes it mean "gone from a
    rendered screen"; that is now stated where the assertion is.

One push-back

D2 also asked to drop the or now fallback on abandon's winning claim.
Left in place: there, now is the value the request itself PATCHed, so
preferring the echoed column and falling back to it yield the same timestamp —
it is not a fabrication. The fabricated one was the loser branch's, and that
is gone.

Verification

  • backend: pytest tests/ -q2247 passed / 82 skipped; ruff check .
  • frontend: vitest run1154 passed / 102 files; tsc --noEmit ✅;
    npm run lint ✅ 0 errors
  • npx playwright test e2e/quiz-journeys.spec.ts --list ✅ collects 9 tests
    (the @/lib imports resolve). The journey itself is unrun here — the
    stack is the controller's.

AndresL230and others added 3 commits August 23, 2026 04:13
Discard had no server side: quiz home wrote a localStorage flag and left
the row in_progress until D2's 24h sweep found it, so the resume strip
came back on every other device.
The route writes the same `abandoned_at` stamp `_sweep_abandoned` writes —
the client is just allowed to say "now". Owner-checked like the other
attempt routes, 409 on an already-submitted attempt, and idempotent: a
second call is a 200 no-op carrying the stamp already on the row, so a
retry after a dropped response is free. The write is the same conditional
claim submit uses (`completed_at IS NULL AND abandoned_at IS NULL`), so a
concurrent submit and abandon cannot both win; the loser re-reads and 409s
rather than reporting a discard that never happened.
No schema change — `abandoned_at` has existed since D2. `GET /attempts`
deliberately still LISTS abandoned rows (it is the history reader, D4);
what changes is the derived status, which is what the strip filters on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`useQuizHome::discard` is now the whole gesture: `dismissAttempt` hides the
row in this browser synchronously (so the strip is gone on the next render
whatever the network does), `abandonAttempt` makes it durable, and the
refresh re-reads the world. The screen just states the intent — splitting
the two writes across the component is how they drifted apart in the first
place, with the row hidden here and closed nowhere.
A failed abandon is swallowed with a console.warn rather than surfaced: the
student said discard, so putting the quiz back on screen (or a red toast
over a discard that visibly worked) would both be worse answers, and the
backend's 24h sweep is still the backstop.
The resume strip needed no filter change — `GET /attempts` reports the
derived status, `discoverResumable` already offers only `in_progress` rows,
and `getAttempt` now answers `resumable: false`, so both discovery paths
drop the attempt on a reload and on any other device.
The Chapter 1 resume journey is updated to match and is UNRUN here (the
controller owns the stack): it now asserts `abandoned_at` is set, and
proves the server is what hides the attempt by wiping both quiz storage
keys and reloading — with a positive control first, since "the strip is
absent" means nothing unless the same visit was just shown to offer it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…journey (#537 G4)
Important 1 — the cross-device assertion was one round trip too early.
`otherDeviceVisit` synced on the attempts listing and the proposal card, both
of which land at `setLoaded`; in the REGRESSED case the strip only renders
after `discoverResumable` awaits a further `GET /attempts/{id}`, so the count
check fired while the failure was still in flight. The helper now returns the
listing's parsed body and the journey asserts the SERVER'S status for the
attempt (`in_progress` on the positive control, `abandoned` after the
discard). The strip check stays as corroboration.
M2 — the DB read is synchronized to the abandon POST via the file's own
`page.waitForResponse` idiom, not to the click (the strip vanishes
optimistically, so the click races the write being checked).
M3 — submit's claim now filters `abandoned_at IS NULL` too, symmetric with
abandon's. G4 makes the interleaving reachable from the UI (mid-quiz in one
tab, Discard in another): the old single-null filter let both claims win and
left the row completed AND abandoned, with `_refuse_if_abandoned` only ever a
non-atomic pre-read. A lost claim now re-reads before choosing its 409, so a
discarded attempt answers QUIZ_ATTEMPT_ABANDONED rather than impersonating a
double submit — the frontend maps those codes to different copy.
M4 `import type { Page }`. M5 the R-3 row's "Cost if wrong" now describes the
failed-call fallback, which is all that is left of it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 27f4c739-d806-48a9-b900-ba224b02b98a

📥 Commits

Reviewing files that changed from the base of the PR and between 025474a and b4f1a35.

📒 Files selected for processing (19)
  • backend/routes/quiz.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_quiz_abandon_g4.py
  • backend/tests/test_quiz_routes.py
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb4f1a35Commit Preview URL

Branch Preview URL
Aug 26 2026, 06:40 PM

Merge-gate review of PR #591, backend half.
D2/D3 — both lost-claim branches read the row back as `(rows or [{}])[0]`,
which made a vanished row indistinguishable from an open one: abandon
answered 200 `{status: "abandoned", abandoned_at: <now>}` with a timestamp
nothing had written, and submit ran `_refuse_if_abandoned({})` as a no-op and
reported ALREADY_COMPLETED. An empty re-read is now the 404 the top of each
route already gives, and abandon no longer substitutes its own clock for a
write it did not make.
D3 also put a SELECT in a window that used to be infallible — losing submit's
claim went straight to a 409 with no further I/O. A transient PostgREST
failure there now degrades back to that 409 instead of 500ing an ordinary
double-click.
D10/D11 — the select-by-id + 404 + require_self preamble was verbatim in four
routes; the already-completed 409 was hand-rolled five times. Both are now
`_load_owned_attempt` / `_refuse_if_completed` (+ `_attempt_not_found` /
`_already_completed`).
D4 (backend half) — QUIZ_ATTEMPT_ABANDONED's sentence said "expired", which is
wrong on the path this PR creates. `abandoned_at` records when a row closed,
never who closed it, so the copy now covers both.
D7/D8/D12 — test fidelity, which is why the above were invisible. `_Attempts`
honours its filters on select/select_with_count (a fake that answers every
query cannot fail on a dropped `id=eq.`), models `prefer_return_minimal` (the
real client returns [] in that mode, so a claim refactored to minimal reads as
LOST on every request), and stops duplicating the attempt-row fixtures
byte-for-byte from test_quiz_lifecycle_d. `_factory`'s dead `responses` param
is gone.
D14 — accepted-risk comment at the /answer insert; filed as #597.
D15 — noted at the claim that a TTL-dead row's stamp records the click.
Refs #537, #591, #597
Merge-gate review of PR #591, frontend half.
D1 — `discard` fired `refresh()` synchronously beside the abandon POST, so the
re-read routinely PREDATED the write and came back `in_progress`. The strip
then stayed hidden only because `discoverResumable` skips locally dismissed
ids — the exact localStorage single point of failure G4 exists to retire, and
one that fails outright in a private window (session.ts's writeJson no-ops and
the abandon error is swallowed, so the quiz the student just discarded comes
straight back). It also flashed the whole home screen to a skeleton and
re-fired describeConcept to change one row.
The slot is now cleared directly. Nothing else on quiz home derives from an
unfinished attempt — the ranking and the "missed N last time" join both read
COMPLETED attempts only (proposals.ts) — so the re-read bought nothing and
raced the write it meant to observe.
D9 — the three vitest assertions guarding this passed for the wrong reason:
`resumable` was null merely because refresh() had invalidated the load key.
They now pin the ordering (the strip is gone while the POST is still in
flight, with `status` still "ready"), the read counts, and — the real
regression — that the strip stays gone with localStorage unavailable.
D4 (frontend half) — QUIZ_ATTEMPT_ABANDONED said "That quiz expired after a
day", copy from the TTL-sweep era that reads as a bug to the student who
pressed Discard thirty seconds ago.
D5/D6 — the journey's `otherDeviceVisit` hardcoded both storage keys,
re-declared the listing type and inlined openQuizHome's body; a key rename
left the other-device leg green and vacuous. It now imports STORAGE_KEY /
DISMISSED_KEY / AttemptsPage from @/lib (precedent: quiz-errors.spec.ts) and
calls openQuizHome. The post-discard proposal wait gets SUBMIT_TIMEOUT like
every sibling.
D13 — the G4 origin story was retold at length in six places; one canonical
telling (routes/quiz.py::abandon_attempt) plus pointers.
Refs #537, #591
…G4)
Merge-gate re-review, two blockers + a ride-along.
B1 — the D1 regression test ("hides it on the server's word, not on
localStorage's") passed locally and FAILED CI: `expect(isDismissed("open"))
.toBe(false)` got `true`, because the block never took. jsdom is lockfile
pinned, so the only difference is the Node version — CI is on 22 (ci.yml:86),
this box on 26 — and `vi.spyOn(window.localStorage, "setItem")` intercepts on
one and not the other. (`Storage.prototype` intercepts on neither: jsdom hands
out a Proxy.) The premise silently evaporated, leaving the flagship test for
the discard/refresh race asserting nothing on CI.
Blocked at the ACCESSOR instead — `Object.defineProperty(window,
"localStorage", { get() { throw ... } })` — which is what a browser with site
data disabled actually does and what `session.ts::storage()` already catches
into `null`. No dependency on how jsdom hands out the Storage object.
Restored in a `finally` so a failure cannot leak broken storage into the rest
of the file. Re-verified against the pre-fix `discard`: still fails on the
real assertion (the strip comes back), not on the premise.
B2 — the contract spec's R-3 still described Discard as "…then the abandon
call, then a refresh". There is no refresh. Rewritten, and it now also records
that `AbandonResult.abandoned_at` is nullable and that a 200 can carry
`status: "in_progress"`.
Ride-along — the D8 protection did not cover submit's WINNING claim: every
submit test in test_quiz_abandon_g4.py loses the claim for other reasons, so
all five stay green under a minimal-mode refactor. Asserted at the one test
that wins it, the way abandon's claim already does.
Refs #537, #591
@AndresL230
AndresL230 merged commit d0786e2 into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Two add/add conflicts, both resolved by keeping BOTH sides:
* routes/quiz.py — G4 (#591) appended `abandon_attempt` and this branch
appended `_gamification_block` at the same point in the file. Kept the
route where G4 put it and the helper directly above its caller.
* tests/integration/test_quiz_subcutaneous_db.py — G4's abandon tests and
G8's inline-XP test landed in the same slot. Both kept.
Re-verified against G4's rework of submit: the atomic claim still filters on
both `completed_at is.null` and `abandoned_at is.null`, the lost-claim path
still re-reads to separate 409-completed from 409-abandoned from 404-deleted,
and `_gamification_block` still runs after both `award_xp_safe` and the
`check_achievements` pass. TestTheSnapshotIsTakenLast re-verified by mutation
(hoisting the call above the award turns it red).
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4) - #591

Merged
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon
Aug 26, 2026
Merged

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4)#591
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes the G4 gap in docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
(R-3), whose spec row is updated in this PR to say so.

Refs #537

The problem

"Discard" on the quiz resume strip had no server side. It wrote a
dismissedAttempts entry to localStorage and left the attempt row
in_progress until D2's lazy 24h sweep found it — so the strip came back on
the student's phone, in a second tab, and in this browser the moment storage
was cleared. lib/quiz/session.ts carried the seam as
TODO(#537-followup: abandon endpoint).

The change

POST /api/quiz/attempts/{attempt_id}/abandon (backend/routes/quiz.py).
No schema change — it writes the same abandoned_at stamp _sweep_abandoned
already writes; the client is just allowed to say "now".

  • Owner-checked with require_self, exactly like the other attempt routes
    (404 unknown → 403 not yours, in that order).
  • 409QUIZ_ATTEMPT_ALREADY_COMPLETED on a submitted attempt: the score,
    mastery and XP are paid out and a discard could not take them back.
  • Idempotent: a second call is a 200 no-op returning the stamp already on
    the row, so a retry after a dropped response is free. A row the TTL sweep
    already claimed reports the sweep's timestamp, not a fresh one.
  • The write is submit's conditional claim (completed_at IS NULL AND abandoned_at IS NULL), so a concurrent submit and abandon cannot both win;
    the loser re-reads and 409s instead of reporting a discard that never
    happened.
  • status is produced by _attempt_status, never the literal — this endpoint
    must not be the one place that can disagree with the read paths.

Submit's claim is now symmetric. It filtered on completed_at IS NULL
alone, with _refuse_if_abandoned in front of it as a non-atomic pre-read.
This PR makes that interleaving reachable from the UI — a quiz open mid-question
in one tab, Discard pressed in another — and both claims would win, leaving the
row completed and abandoned. Submit's claim now requires abandoned_at IS NULL too, and a lost claim re-reads before choosing its 409 so a discarded
attempt answers QUIZ_ATTEMPT_ABANDONED instead of impersonating a double
submit (the client maps those codes to different copy).

Client: abandonAttempt in lib/quiz/api.ts, and useQuizHome::discard
as the whole gesture — hide locally (instant), clear the resume slot, abandon
(durable). No refresh: the merge-gate section below has why the original one
had to go. The screen just states the intent. A failed abandon is swallowed with a
console.warn: the student said discard, so resurrecting the quiz would be
the worse answer and the 24h sweep is still the backstop.

The resume strip needed no filter change.GET /attempts reports the
derived status, discoverResumable already offers only in_progress rows,
and getAttempt now answers resumable: false — so both discovery paths drop
the attempt on a reload and on any other device. GET /attempts deliberately
still lists abandoned rows: it is D4's history reader, and filtering it would
break history to fix the strip.

Test evidence

  • backend/tests/test_quiz_abandon_g4.pynew, 14 tests. RED 10 failed
    (route absent) → GREEN. Covers the 200 + stamp, the claim's filters, the
    idempotent repeat (and that it writes nothing twice), a TTL-swept row, 409 on
    completed, the concurrent-submit race, 404, 403 for a foreign attempt
    (real require_self restored — the lane stubs it), and that getAttempt,
    the listing, /answer and /submit all agree afterwards. Its fake models
    the PostgREST filter grammar rather than returning a canned value, so "the
    claim won" and "the claim was refused" are actually distinguishable.
  • The symmetric-claim fix has its own class: a discard stamped in the window
    between submit's pre-read and its claim leaves completed_at null, pays out
    no mastery and 409s as QUIZ_ATTEMPT_ABANDONED — RED without the added
    filter (the claim won and the request ran on past it), GREEN with it — plus a
    guard that an ordinary double submit still reads as
    QUIZ_ATTEMPT_ALREADY_COMPLETED.
  • Backend suite 2237 passed, 81 skipped; ruff check . clean.
  • backend/tests/integration/test_quiz_subcutaneous_db.py — abandon stamps the
    real row (read back through psycopg, not PostgREST), 409 after submit, and
    the IDOR negative extended to abandon. Integration-marked; not run locally.
  • Frontend: RED 7 failed with the implementation stashed → GREEN
    102 files / 1152 tests. tsc --noEmit clean, eslint 0 errors.
  • frontend/e2e/quiz-journeys.spec.ts — the resume journey now waits on the
    abandon POST, asserts abandoned_at is set in the DB, and reloads with both
    quiz storage keys wiped (the other-device case). What it pins there is the
    listing's own status for the attempt — in_progress as a positive
    control, abandoned after the discard — because every rendered consequence
    of that payload is a further round trip away, so a check on the strip alone
    would pass on timing. The strip's absence is kept as corroboration.
    Not run in this branch; the lane owns the stack.

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2237 passed / 81 skipped · eslint ✅ · tsc ✅ · vitest ✅ 1152
  • Playwright Chapter 1 ✅ 73 passed / 1 skipped (2.9m) — includes the new G4 journey (discard → DB abandoned_at set → server status abandoned on a fresh context → strip absent)
  • oracles ✅ clean · integration ✅ 73 passed (includes the new real-HTTP abandon cases)
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629622649
  • Review: task review (deviation on GET /attempts judged correct; 1 Important — the cross-device e2e assertion could fire before a regression surfaced → fixed by asserting the server's own status first; submit's atomic claim made symmetric with abandon's) + scoped re-review clean.

Merge-gate review (2026-08-26)

origin/main merged in (f6ed004 / 6225bba / 025474a — no conflicts; #590's
include_answer_key flip is in the generate handler, clear of these hunks).
Two commits on top, from a /code-review pass whose findings were verified
against the code before being acted on.

The race the PR re-created (the one that mattered)

useQuizHome::discard fired refresh()synchronously beside the abandon
POST. The re-read routinely predated the write and came back in_progress, so
the resume strip stayed hidden only because discoverResumable skips locally
dismissed ids — the localStorage single point of failure G4 exists to retire.
With storage unavailable (private window: session.ts::writeJson no-ops and
the abandon error is swallowed) the discarded quiz came straight back. It also
cost a full home-screen skeleton flash and a re-fired describeConcept per
discard.

Fixed by clearing the resumable slot directly and dropping the global re-read.
Nothing else on quiz home derives from an unfinished attempt — the ranking
and the "missed N last time" join both read COMPLETED attempts only
(proposals.ts) — so the re-read bought nothing and raced the write it meant
to observe. dismissAttempt still runs first, as the across-loads backstop.

Honest failures on both claim losers

  • abandon built a 200 out of an empty dict: (rows or [{}])[0] then
    abandoned_at = current.get(...) or now reported {status: "abandoned", abandoned_at: <now>} for a row that may not exist, with a timestamp nothing
    wrote. An empty re-read is now the same 404 the top of the route gives, and
    the branch never substitutes its own clock for a write it did not make.
  • submit had the same phantom dict (_refuse_if_abandoned({}) is a no-op,
    so a vanished row reported ALREADY_COMPLETED), and the new SELECT sat in a
    window that used to be infallible — losing the claim went straight to a 409.
    A transient PostgREST failure there now degrades back to that 409 rather
    than 500ing an ordinary double-click.
  • _load_owned_attempt and _refuse_if_completed replace the select+404+
    require_self preamble (verbatim in four routes) and the hand-rolled
    already-completed 409 (five copies).

Copy

QUIZ_ATTEMPT_ABANDONED still read "That quiz expired after a day" — copy
from the TTL-sweep era, and a bug to the student who pressed Discard thirty
seconds ago. abandoned_at records when a row closed, never who
closed it, so nothing on the wire can tell the two apart: both the client copy
and the server sentence now cover both. Contract table updated to match.

Test fidelity (why the above were invisible)

  • _Attempts.select / select_with_count ignored their filters and always
    returned the stored row, so no test could fail on a dropped or mis-built
    id=eq. / user_id=eq., and the history-listing assertion was pinned on a
    read nobody made. Both honour filters now, and the listing test asserts what
    the fake was asked for.
  • The fake ignored prefer_return_minimal, but db/connection.py returns []
    in that mode — a claim refactored to minimal would read as LOST on every
    request (every submit 409s, no payout) with the suite green. Modelled, plus
    an explicit assertion that abandon's claim is not minimal.
  • Three vitest assertions were vacuous: resumable was null merely because
    refresh() had bumped the load key. They now pin the ordering (strip gone
    while the POST is still in flight, status still "ready"), the read
    counts, and the storage-unavailable case that is the real regression.
  • The journey's otherDeviceVisit hardcoded both storage keys, re-declared
    the listing type and inlined openQuizHome's body — a key rename would have
    left the other-device leg green and vacuous. Now imports STORAGE_KEY /
    DISMISSED_KEY / AttemptsPage from @/lib (precedent:
    quiz-errors.spec.ts). The post-discard proposal wait got SUBMIT_TIMEOUT
    like every sibling.
  • The G4 origin story was retold at length in six places; one canonical
    telling (routes/quiz.py::abandon_attempt) plus pointers.

Follow-up: #597

POST /attempts/{id}/answer is the last quiz write path guarded by a pre-read
alone. G4 turns "the attempt closed mid-request" from a 24h TTL event into a
button, so a Discard landing between the refusals and the insert records a
graded response on a closed attempt. PostgREST cannot make that cross-table
INSERT conditional atomically — closing it needs a trigger or an RPC, i.e. a
migration — so it ships as an accepted risk with a comment at the insert.
Inert rather than harmful: an abandoned attempt can never be submitted
(submit's claim filters on abandoned_at IS NULL), so the orphan pays out no
mastery, XP or achievement. Filed as #597.

Two notes, deliberately not changes

  • abandoned_at on a TTL-dead row records the discard CLICK, not when the
    attempt went quiet. Noted at the claim. It matters only if a consumer ever
    reads the column as an elapsed time; none does today (the derived status
    only asks whether it is set), and "when did this go quiet" is created_at
    plus the last response — what _attempt_status already computes.
  • The journey's post-click toHaveCount(0) used to witness the skeleton
    rather than the optimistic hide, since refresh() blanked the screen. With
    the race fix there is no skeleton, so the paired
    expect(quiz-proposal).toBeVisible() is what makes it mean "gone from a
    rendered screen"; that is now stated where the assertion is.

One push-back

D2 also asked to drop the or now fallback on abandon's winning claim.
Left in place: there, now is the value the request itself PATCHed, so
preferring the echoed column and falling back to it yield the same timestamp —
it is not a fabrication. The fabricated one was the loser branch's, and that
is gone.

Verification

  • backend: pytest tests/ -q2247 passed / 82 skipped; ruff check .
  • frontend: vitest run1154 passed / 102 files; tsc --noEmit ✅;
    npm run lint ✅ 0 errors
  • npx playwright test e2e/quiz-journeys.spec.ts --list ✅ collects 9 tests
    (the @/lib imports resolve). The journey itself is unrun here — the
    stack is the controller's.

AndresL230and others added 3 commits August 23, 2026 04:13
Discard had no server side: quiz home wrote a localStorage flag and left
the row in_progress until D2's 24h sweep found it, so the resume strip
came back on every other device.
The route writes the same `abandoned_at` stamp `_sweep_abandoned` writes —
the client is just allowed to say "now". Owner-checked like the other
attempt routes, 409 on an already-submitted attempt, and idempotent: a
second call is a 200 no-op carrying the stamp already on the row, so a
retry after a dropped response is free. The write is the same conditional
claim submit uses (`completed_at IS NULL AND abandoned_at IS NULL`), so a
concurrent submit and abandon cannot both win; the loser re-reads and 409s
rather than reporting a discard that never happened.
No schema change — `abandoned_at` has existed since D2. `GET /attempts`
deliberately still LISTS abandoned rows (it is the history reader, D4);
what changes is the derived status, which is what the strip filters on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`useQuizHome::discard` is now the whole gesture: `dismissAttempt` hides the
row in this browser synchronously (so the strip is gone on the next render
whatever the network does), `abandonAttempt` makes it durable, and the
refresh re-reads the world. The screen just states the intent — splitting
the two writes across the component is how they drifted apart in the first
place, with the row hidden here and closed nowhere.
A failed abandon is swallowed with a console.warn rather than surfaced: the
student said discard, so putting the quiz back on screen (or a red toast
over a discard that visibly worked) would both be worse answers, and the
backend's 24h sweep is still the backstop.
The resume strip needed no filter change — `GET /attempts` reports the
derived status, `discoverResumable` already offers only `in_progress` rows,
and `getAttempt` now answers `resumable: false`, so both discovery paths
drop the attempt on a reload and on any other device.
The Chapter 1 resume journey is updated to match and is UNRUN here (the
controller owns the stack): it now asserts `abandoned_at` is set, and
proves the server is what hides the attempt by wiping both quiz storage
keys and reloading — with a positive control first, since "the strip is
absent" means nothing unless the same visit was just shown to offer it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…journey (#537 G4)
Important 1 — the cross-device assertion was one round trip too early.
`otherDeviceVisit` synced on the attempts listing and the proposal card, both
of which land at `setLoaded`; in the REGRESSED case the strip only renders
after `discoverResumable` awaits a further `GET /attempts/{id}`, so the count
check fired while the failure was still in flight. The helper now returns the
listing's parsed body and the journey asserts the SERVER'S status for the
attempt (`in_progress` on the positive control, `abandoned` after the
discard). The strip check stays as corroboration.
M2 — the DB read is synchronized to the abandon POST via the file's own
`page.waitForResponse` idiom, not to the click (the strip vanishes
optimistically, so the click races the write being checked).
M3 — submit's claim now filters `abandoned_at IS NULL` too, symmetric with
abandon's. G4 makes the interleaving reachable from the UI (mid-quiz in one
tab, Discard in another): the old single-null filter let both claims win and
left the row completed AND abandoned, with `_refuse_if_abandoned` only ever a
non-atomic pre-read. A lost claim now re-reads before choosing its 409, so a
discarded attempt answers QUIZ_ATTEMPT_ABANDONED rather than impersonating a
double submit — the frontend maps those codes to different copy.
M4 `import type { Page }`. M5 the R-3 row's "Cost if wrong" now describes the
failed-call fallback, which is all that is left of it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 27f4c739-d806-48a9-b900-ba224b02b98a

📥 Commits

Reviewing files that changed from the base of the PR and between 025474a and b4f1a35.

📒 Files selected for processing (19)
  • backend/routes/quiz.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_quiz_abandon_g4.py
  • backend/tests/test_quiz_routes.py
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb4f1a35Commit Preview URL

Branch Preview URL
Aug 26 2026, 06:40 PM

Merge-gate review of PR #591, backend half.
D2/D3 — both lost-claim branches read the row back as `(rows or [{}])[0]`,
which made a vanished row indistinguishable from an open one: abandon
answered 200 `{status: "abandoned", abandoned_at: <now>}` with a timestamp
nothing had written, and submit ran `_refuse_if_abandoned({})` as a no-op and
reported ALREADY_COMPLETED. An empty re-read is now the 404 the top of each
route already gives, and abandon no longer substitutes its own clock for a
write it did not make.
D3 also put a SELECT in a window that used to be infallible — losing submit's
claim went straight to a 409 with no further I/O. A transient PostgREST
failure there now degrades back to that 409 instead of 500ing an ordinary
double-click.
D10/D11 — the select-by-id + 404 + require_self preamble was verbatim in four
routes; the already-completed 409 was hand-rolled five times. Both are now
`_load_owned_attempt` / `_refuse_if_completed` (+ `_attempt_not_found` /
`_already_completed`).
D4 (backend half) — QUIZ_ATTEMPT_ABANDONED's sentence said "expired", which is
wrong on the path this PR creates. `abandoned_at` records when a row closed,
never who closed it, so the copy now covers both.
D7/D8/D12 — test fidelity, which is why the above were invisible. `_Attempts`
honours its filters on select/select_with_count (a fake that answers every
query cannot fail on a dropped `id=eq.`), models `prefer_return_minimal` (the
real client returns [] in that mode, so a claim refactored to minimal reads as
LOST on every request), and stops duplicating the attempt-row fixtures
byte-for-byte from test_quiz_lifecycle_d. `_factory`'s dead `responses` param
is gone.
D14 — accepted-risk comment at the /answer insert; filed as #597.
D15 — noted at the claim that a TTL-dead row's stamp records the click.
Refs #537, #591, #597
Merge-gate review of PR #591, frontend half.
D1 — `discard` fired `refresh()` synchronously beside the abandon POST, so the
re-read routinely PREDATED the write and came back `in_progress`. The strip
then stayed hidden only because `discoverResumable` skips locally dismissed
ids — the exact localStorage single point of failure G4 exists to retire, and
one that fails outright in a private window (session.ts's writeJson no-ops and
the abandon error is swallowed, so the quiz the student just discarded comes
straight back). It also flashed the whole home screen to a skeleton and
re-fired describeConcept to change one row.
The slot is now cleared directly. Nothing else on quiz home derives from an
unfinished attempt — the ranking and the "missed N last time" join both read
COMPLETED attempts only (proposals.ts) — so the re-read bought nothing and
raced the write it meant to observe.
D9 — the three vitest assertions guarding this passed for the wrong reason:
`resumable` was null merely because refresh() had invalidated the load key.
They now pin the ordering (the strip is gone while the POST is still in
flight, with `status` still "ready"), the read counts, and — the real
regression — that the strip stays gone with localStorage unavailable.
D4 (frontend half) — QUIZ_ATTEMPT_ABANDONED said "That quiz expired after a
day", copy from the TTL-sweep era that reads as a bug to the student who
pressed Discard thirty seconds ago.
D5/D6 — the journey's `otherDeviceVisit` hardcoded both storage keys,
re-declared the listing type and inlined openQuizHome's body; a key rename
left the other-device leg green and vacuous. It now imports STORAGE_KEY /
DISMISSED_KEY / AttemptsPage from @/lib (precedent: quiz-errors.spec.ts) and
calls openQuizHome. The post-discard proposal wait gets SUBMIT_TIMEOUT like
every sibling.
D13 — the G4 origin story was retold at length in six places; one canonical
telling (routes/quiz.py::abandon_attempt) plus pointers.
Refs #537, #591
…G4)
Merge-gate re-review, two blockers + a ride-along.
B1 — the D1 regression test ("hides it on the server's word, not on
localStorage's") passed locally and FAILED CI: `expect(isDismissed("open"))
.toBe(false)` got `true`, because the block never took. jsdom is lockfile
pinned, so the only difference is the Node version — CI is on 22 (ci.yml:86),
this box on 26 — and `vi.spyOn(window.localStorage, "setItem")` intercepts on
one and not the other. (`Storage.prototype` intercepts on neither: jsdom hands
out a Proxy.) The premise silently evaporated, leaving the flagship test for
the discard/refresh race asserting nothing on CI.
Blocked at the ACCESSOR instead — `Object.defineProperty(window,
"localStorage", { get() { throw ... } })` — which is what a browser with site
data disabled actually does and what `session.ts::storage()` already catches
into `null`. No dependency on how jsdom hands out the Storage object.
Restored in a `finally` so a failure cannot leak broken storage into the rest
of the file. Re-verified against the pre-fix `discard`: still fails on the
real assertion (the strip comes back), not on the premise.
B2 — the contract spec's R-3 still described Discard as "…then the abandon
call, then a refresh". There is no refresh. Rewritten, and it now also records
that `AbandonResult.abandoned_at` is nullable and that a 200 can carry
`status: "in_progress"`.
Ride-along — the D8 protection did not cover submit's WINNING claim: every
submit test in test_quiz_abandon_g4.py loses the claim for other reasons, so
all five stay green under a minimal-mode refactor. Asserted at the one test
that wins it, the way abandon's claim already does.
Refs #537, #591
@AndresL230
AndresL230 merged commit d0786e2 into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Two add/add conflicts, both resolved by keeping BOTH sides:
* routes/quiz.py — G4 (#591) appended `abandon_attempt` and this branch
appended `_gamification_block` at the same point in the file. Kept the
route where G4 put it and the helper directly above its caller.
* tests/integration/test_quiz_subcutaneous_db.py — G4's abandon tests and
G8's inline-XP test landed in the same slot. Both kept.
Re-verified against G4's rework of submit: the atomic claim still filters on
both `completed_at is.null` and `abandoned_at is.null`, the lost-claim path
still re-reads to separate 409-completed from 409-abandoned from 404-deleted,
and `_gamification_block` still runs after both `award_xp_safe` and the
`check_achievements` pass. TestTheSnapshotIsTakenLast re-verified by mutation
(hoisting the call above the award turns it red).
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4) - #591

Merged
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon
Aug 26, 2026
Merged

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4)#591
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes the G4 gap in docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
(R-3), whose spec row is updated in this PR to say so.

Refs #537

The problem

"Discard" on the quiz resume strip had no server side. It wrote a
dismissedAttempts entry to localStorage and left the attempt row
in_progress until D2's lazy 24h sweep found it — so the strip came back on
the student's phone, in a second tab, and in this browser the moment storage
was cleared. lib/quiz/session.ts carried the seam as
TODO(#537-followup: abandon endpoint).

The change

POST /api/quiz/attempts/{attempt_id}/abandon (backend/routes/quiz.py).
No schema change — it writes the same abandoned_at stamp _sweep_abandoned
already writes; the client is just allowed to say "now".

  • Owner-checked with require_self, exactly like the other attempt routes
    (404 unknown → 403 not yours, in that order).
  • 409QUIZ_ATTEMPT_ALREADY_COMPLETED on a submitted attempt: the score,
    mastery and XP are paid out and a discard could not take them back.
  • Idempotent: a second call is a 200 no-op returning the stamp already on
    the row, so a retry after a dropped response is free. A row the TTL sweep
    already claimed reports the sweep's timestamp, not a fresh one.
  • The write is submit's conditional claim (completed_at IS NULL AND abandoned_at IS NULL), so a concurrent submit and abandon cannot both win;
    the loser re-reads and 409s instead of reporting a discard that never
    happened.
  • status is produced by _attempt_status, never the literal — this endpoint
    must not be the one place that can disagree with the read paths.

Submit's claim is now symmetric. It filtered on completed_at IS NULL
alone, with _refuse_if_abandoned in front of it as a non-atomic pre-read.
This PR makes that interleaving reachable from the UI — a quiz open mid-question
in one tab, Discard pressed in another — and both claims would win, leaving the
row completed and abandoned. Submit's claim now requires abandoned_at IS NULL too, and a lost claim re-reads before choosing its 409 so a discarded
attempt answers QUIZ_ATTEMPT_ABANDONED instead of impersonating a double
submit (the client maps those codes to different copy).

Client: abandonAttempt in lib/quiz/api.ts, and useQuizHome::discard
as the whole gesture — hide locally (instant), clear the resume slot, abandon
(durable). No refresh: the merge-gate section below has why the original one
had to go. The screen just states the intent. A failed abandon is swallowed with a
console.warn: the student said discard, so resurrecting the quiz would be
the worse answer and the 24h sweep is still the backstop.

The resume strip needed no filter change.GET /attempts reports the
derived status, discoverResumable already offers only in_progress rows,
and getAttempt now answers resumable: false — so both discovery paths drop
the attempt on a reload and on any other device. GET /attempts deliberately
still lists abandoned rows: it is D4's history reader, and filtering it would
break history to fix the strip.

Test evidence

  • backend/tests/test_quiz_abandon_g4.pynew, 14 tests. RED 10 failed
    (route absent) → GREEN. Covers the 200 + stamp, the claim's filters, the
    idempotent repeat (and that it writes nothing twice), a TTL-swept row, 409 on
    completed, the concurrent-submit race, 404, 403 for a foreign attempt
    (real require_self restored — the lane stubs it), and that getAttempt,
    the listing, /answer and /submit all agree afterwards. Its fake models
    the PostgREST filter grammar rather than returning a canned value, so "the
    claim won" and "the claim was refused" are actually distinguishable.
  • The symmetric-claim fix has its own class: a discard stamped in the window
    between submit's pre-read and its claim leaves completed_at null, pays out
    no mastery and 409s as QUIZ_ATTEMPT_ABANDONED — RED without the added
    filter (the claim won and the request ran on past it), GREEN with it — plus a
    guard that an ordinary double submit still reads as
    QUIZ_ATTEMPT_ALREADY_COMPLETED.
  • Backend suite 2237 passed, 81 skipped; ruff check . clean.
  • backend/tests/integration/test_quiz_subcutaneous_db.py — abandon stamps the
    real row (read back through psycopg, not PostgREST), 409 after submit, and
    the IDOR negative extended to abandon. Integration-marked; not run locally.
  • Frontend: RED 7 failed with the implementation stashed → GREEN
    102 files / 1152 tests. tsc --noEmit clean, eslint 0 errors.
  • frontend/e2e/quiz-journeys.spec.ts — the resume journey now waits on the
    abandon POST, asserts abandoned_at is set in the DB, and reloads with both
    quiz storage keys wiped (the other-device case). What it pins there is the
    listing's own status for the attempt — in_progress as a positive
    control, abandoned after the discard — because every rendered consequence
    of that payload is a further round trip away, so a check on the strip alone
    would pass on timing. The strip's absence is kept as corroboration.
    Not run in this branch; the lane owns the stack.

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2237 passed / 81 skipped · eslint ✅ · tsc ✅ · vitest ✅ 1152
  • Playwright Chapter 1 ✅ 73 passed / 1 skipped (2.9m) — includes the new G4 journey (discard → DB abandoned_at set → server status abandoned on a fresh context → strip absent)
  • oracles ✅ clean · integration ✅ 73 passed (includes the new real-HTTP abandon cases)
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629622649
  • Review: task review (deviation on GET /attempts judged correct; 1 Important — the cross-device e2e assertion could fire before a regression surfaced → fixed by asserting the server's own status first; submit's atomic claim made symmetric with abandon's) + scoped re-review clean.

Merge-gate review (2026-08-26)

origin/main merged in (f6ed004 / 6225bba / 025474a — no conflicts; #590's
include_answer_key flip is in the generate handler, clear of these hunks).
Two commits on top, from a /code-review pass whose findings were verified
against the code before being acted on.

The race the PR re-created (the one that mattered)

useQuizHome::discard fired refresh()synchronously beside the abandon
POST. The re-read routinely predated the write and came back in_progress, so
the resume strip stayed hidden only because discoverResumable skips locally
dismissed ids — the localStorage single point of failure G4 exists to retire.
With storage unavailable (private window: session.ts::writeJson no-ops and
the abandon error is swallowed) the discarded quiz came straight back. It also
cost a full home-screen skeleton flash and a re-fired describeConcept per
discard.

Fixed by clearing the resumable slot directly and dropping the global re-read.
Nothing else on quiz home derives from an unfinished attempt — the ranking
and the "missed N last time" join both read COMPLETED attempts only
(proposals.ts) — so the re-read bought nothing and raced the write it meant
to observe. dismissAttempt still runs first, as the across-loads backstop.

Honest failures on both claim losers

  • abandon built a 200 out of an empty dict: (rows or [{}])[0] then
    abandoned_at = current.get(...) or now reported {status: "abandoned", abandoned_at: <now>} for a row that may not exist, with a timestamp nothing
    wrote. An empty re-read is now the same 404 the top of the route gives, and
    the branch never substitutes its own clock for a write it did not make.
  • submit had the same phantom dict (_refuse_if_abandoned({}) is a no-op,
    so a vanished row reported ALREADY_COMPLETED), and the new SELECT sat in a
    window that used to be infallible — losing the claim went straight to a 409.
    A transient PostgREST failure there now degrades back to that 409 rather
    than 500ing an ordinary double-click.
  • _load_owned_attempt and _refuse_if_completed replace the select+404+
    require_self preamble (verbatim in four routes) and the hand-rolled
    already-completed 409 (five copies).

Copy

QUIZ_ATTEMPT_ABANDONED still read "That quiz expired after a day" — copy
from the TTL-sweep era, and a bug to the student who pressed Discard thirty
seconds ago. abandoned_at records when a row closed, never who
closed it, so nothing on the wire can tell the two apart: both the client copy
and the server sentence now cover both. Contract table updated to match.

Test fidelity (why the above were invisible)

  • _Attempts.select / select_with_count ignored their filters and always
    returned the stored row, so no test could fail on a dropped or mis-built
    id=eq. / user_id=eq., and the history-listing assertion was pinned on a
    read nobody made. Both honour filters now, and the listing test asserts what
    the fake was asked for.
  • The fake ignored prefer_return_minimal, but db/connection.py returns []
    in that mode — a claim refactored to minimal would read as LOST on every
    request (every submit 409s, no payout) with the suite green. Modelled, plus
    an explicit assertion that abandon's claim is not minimal.
  • Three vitest assertions were vacuous: resumable was null merely because
    refresh() had bumped the load key. They now pin the ordering (strip gone
    while the POST is still in flight, status still "ready"), the read
    counts, and the storage-unavailable case that is the real regression.
  • The journey's otherDeviceVisit hardcoded both storage keys, re-declared
    the listing type and inlined openQuizHome's body — a key rename would have
    left the other-device leg green and vacuous. Now imports STORAGE_KEY /
    DISMISSED_KEY / AttemptsPage from @/lib (precedent:
    quiz-errors.spec.ts). The post-discard proposal wait got SUBMIT_TIMEOUT
    like every sibling.
  • The G4 origin story was retold at length in six places; one canonical
    telling (routes/quiz.py::abandon_attempt) plus pointers.

Follow-up: #597

POST /attempts/{id}/answer is the last quiz write path guarded by a pre-read
alone. G4 turns "the attempt closed mid-request" from a 24h TTL event into a
button, so a Discard landing between the refusals and the insert records a
graded response on a closed attempt. PostgREST cannot make that cross-table
INSERT conditional atomically — closing it needs a trigger or an RPC, i.e. a
migration — so it ships as an accepted risk with a comment at the insert.
Inert rather than harmful: an abandoned attempt can never be submitted
(submit's claim filters on abandoned_at IS NULL), so the orphan pays out no
mastery, XP or achievement. Filed as #597.

Two notes, deliberately not changes

  • abandoned_at on a TTL-dead row records the discard CLICK, not when the
    attempt went quiet. Noted at the claim. It matters only if a consumer ever
    reads the column as an elapsed time; none does today (the derived status
    only asks whether it is set), and "when did this go quiet" is created_at
    plus the last response — what _attempt_status already computes.
  • The journey's post-click toHaveCount(0) used to witness the skeleton
    rather than the optimistic hide, since refresh() blanked the screen. With
    the race fix there is no skeleton, so the paired
    expect(quiz-proposal).toBeVisible() is what makes it mean "gone from a
    rendered screen"; that is now stated where the assertion is.

One push-back

D2 also asked to drop the or now fallback on abandon's winning claim.
Left in place: there, now is the value the request itself PATCHed, so
preferring the echoed column and falling back to it yield the same timestamp —
it is not a fabrication. The fabricated one was the loser branch's, and that
is gone.

Verification

  • backend: pytest tests/ -q2247 passed / 82 skipped; ruff check .
  • frontend: vitest run1154 passed / 102 files; tsc --noEmit ✅;
    npm run lint ✅ 0 errors
  • npx playwright test e2e/quiz-journeys.spec.ts --list ✅ collects 9 tests
    (the @/lib imports resolve). The journey itself is unrun here — the
    stack is the controller's.

AndresL230and others added 3 commits August 23, 2026 04:13
Discard had no server side: quiz home wrote a localStorage flag and left
the row in_progress until D2's 24h sweep found it, so the resume strip
came back on every other device.
The route writes the same `abandoned_at` stamp `_sweep_abandoned` writes —
the client is just allowed to say "now". Owner-checked like the other
attempt routes, 409 on an already-submitted attempt, and idempotent: a
second call is a 200 no-op carrying the stamp already on the row, so a
retry after a dropped response is free. The write is the same conditional
claim submit uses (`completed_at IS NULL AND abandoned_at IS NULL`), so a
concurrent submit and abandon cannot both win; the loser re-reads and 409s
rather than reporting a discard that never happened.
No schema change — `abandoned_at` has existed since D2. `GET /attempts`
deliberately still LISTS abandoned rows (it is the history reader, D4);
what changes is the derived status, which is what the strip filters on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`useQuizHome::discard` is now the whole gesture: `dismissAttempt` hides the
row in this browser synchronously (so the strip is gone on the next render
whatever the network does), `abandonAttempt` makes it durable, and the
refresh re-reads the world. The screen just states the intent — splitting
the two writes across the component is how they drifted apart in the first
place, with the row hidden here and closed nowhere.
A failed abandon is swallowed with a console.warn rather than surfaced: the
student said discard, so putting the quiz back on screen (or a red toast
over a discard that visibly worked) would both be worse answers, and the
backend's 24h sweep is still the backstop.
The resume strip needed no filter change — `GET /attempts` reports the
derived status, `discoverResumable` already offers only `in_progress` rows,
and `getAttempt` now answers `resumable: false`, so both discovery paths
drop the attempt on a reload and on any other device.
The Chapter 1 resume journey is updated to match and is UNRUN here (the
controller owns the stack): it now asserts `abandoned_at` is set, and
proves the server is what hides the attempt by wiping both quiz storage
keys and reloading — with a positive control first, since "the strip is
absent" means nothing unless the same visit was just shown to offer it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…journey (#537 G4)
Important 1 — the cross-device assertion was one round trip too early.
`otherDeviceVisit` synced on the attempts listing and the proposal card, both
of which land at `setLoaded`; in the REGRESSED case the strip only renders
after `discoverResumable` awaits a further `GET /attempts/{id}`, so the count
check fired while the failure was still in flight. The helper now returns the
listing's parsed body and the journey asserts the SERVER'S status for the
attempt (`in_progress` on the positive control, `abandoned` after the
discard). The strip check stays as corroboration.
M2 — the DB read is synchronized to the abandon POST via the file's own
`page.waitForResponse` idiom, not to the click (the strip vanishes
optimistically, so the click races the write being checked).
M3 — submit's claim now filters `abandoned_at IS NULL` too, symmetric with
abandon's. G4 makes the interleaving reachable from the UI (mid-quiz in one
tab, Discard in another): the old single-null filter let both claims win and
left the row completed AND abandoned, with `_refuse_if_abandoned` only ever a
non-atomic pre-read. A lost claim now re-reads before choosing its 409, so a
discarded attempt answers QUIZ_ATTEMPT_ABANDONED rather than impersonating a
double submit — the frontend maps those codes to different copy.
M4 `import type { Page }`. M5 the R-3 row's "Cost if wrong" now describes the
failed-call fallback, which is all that is left of it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 27f4c739-d806-48a9-b900-ba224b02b98a

📥 Commits

Reviewing files that changed from the base of the PR and between 025474a and b4f1a35.

📒 Files selected for processing (19)
  • backend/routes/quiz.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_quiz_abandon_g4.py
  • backend/tests/test_quiz_routes.py
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb4f1a35Commit Preview URL

Branch Preview URL
Aug 26 2026, 06:40 PM

Merge-gate review of PR #591, backend half.
D2/D3 — both lost-claim branches read the row back as `(rows or [{}])[0]`,
which made a vanished row indistinguishable from an open one: abandon
answered 200 `{status: "abandoned", abandoned_at: <now>}` with a timestamp
nothing had written, and submit ran `_refuse_if_abandoned({})` as a no-op and
reported ALREADY_COMPLETED. An empty re-read is now the 404 the top of each
route already gives, and abandon no longer substitutes its own clock for a
write it did not make.
D3 also put a SELECT in a window that used to be infallible — losing submit's
claim went straight to a 409 with no further I/O. A transient PostgREST
failure there now degrades back to that 409 instead of 500ing an ordinary
double-click.
D10/D11 — the select-by-id + 404 + require_self preamble was verbatim in four
routes; the already-completed 409 was hand-rolled five times. Both are now
`_load_owned_attempt` / `_refuse_if_completed` (+ `_attempt_not_found` /
`_already_completed`).
D4 (backend half) — QUIZ_ATTEMPT_ABANDONED's sentence said "expired", which is
wrong on the path this PR creates. `abandoned_at` records when a row closed,
never who closed it, so the copy now covers both.
D7/D8/D12 — test fidelity, which is why the above were invisible. `_Attempts`
honours its filters on select/select_with_count (a fake that answers every
query cannot fail on a dropped `id=eq.`), models `prefer_return_minimal` (the
real client returns [] in that mode, so a claim refactored to minimal reads as
LOST on every request), and stops duplicating the attempt-row fixtures
byte-for-byte from test_quiz_lifecycle_d. `_factory`'s dead `responses` param
is gone.
D14 — accepted-risk comment at the /answer insert; filed as #597.
D15 — noted at the claim that a TTL-dead row's stamp records the click.
Refs #537, #591, #597
Merge-gate review of PR #591, frontend half.
D1 — `discard` fired `refresh()` synchronously beside the abandon POST, so the
re-read routinely PREDATED the write and came back `in_progress`. The strip
then stayed hidden only because `discoverResumable` skips locally dismissed
ids — the exact localStorage single point of failure G4 exists to retire, and
one that fails outright in a private window (session.ts's writeJson no-ops and
the abandon error is swallowed, so the quiz the student just discarded comes
straight back). It also flashed the whole home screen to a skeleton and
re-fired describeConcept to change one row.
The slot is now cleared directly. Nothing else on quiz home derives from an
unfinished attempt — the ranking and the "missed N last time" join both read
COMPLETED attempts only (proposals.ts) — so the re-read bought nothing and
raced the write it meant to observe.
D9 — the three vitest assertions guarding this passed for the wrong reason:
`resumable` was null merely because refresh() had invalidated the load key.
They now pin the ordering (the strip is gone while the POST is still in
flight, with `status` still "ready"), the read counts, and — the real
regression — that the strip stays gone with localStorage unavailable.
D4 (frontend half) — QUIZ_ATTEMPT_ABANDONED said "That quiz expired after a
day", copy from the TTL-sweep era that reads as a bug to the student who
pressed Discard thirty seconds ago.
D5/D6 — the journey's `otherDeviceVisit` hardcoded both storage keys,
re-declared the listing type and inlined openQuizHome's body; a key rename
left the other-device leg green and vacuous. It now imports STORAGE_KEY /
DISMISSED_KEY / AttemptsPage from @/lib (precedent: quiz-errors.spec.ts) and
calls openQuizHome. The post-discard proposal wait gets SUBMIT_TIMEOUT like
every sibling.
D13 — the G4 origin story was retold at length in six places; one canonical
telling (routes/quiz.py::abandon_attempt) plus pointers.
Refs #537, #591
…G4)
Merge-gate re-review, two blockers + a ride-along.
B1 — the D1 regression test ("hides it on the server's word, not on
localStorage's") passed locally and FAILED CI: `expect(isDismissed("open"))
.toBe(false)` got `true`, because the block never took. jsdom is lockfile
pinned, so the only difference is the Node version — CI is on 22 (ci.yml:86),
this box on 26 — and `vi.spyOn(window.localStorage, "setItem")` intercepts on
one and not the other. (`Storage.prototype` intercepts on neither: jsdom hands
out a Proxy.) The premise silently evaporated, leaving the flagship test for
the discard/refresh race asserting nothing on CI.
Blocked at the ACCESSOR instead — `Object.defineProperty(window,
"localStorage", { get() { throw ... } })` — which is what a browser with site
data disabled actually does and what `session.ts::storage()` already catches
into `null`. No dependency on how jsdom hands out the Storage object.
Restored in a `finally` so a failure cannot leak broken storage into the rest
of the file. Re-verified against the pre-fix `discard`: still fails on the
real assertion (the strip comes back), not on the premise.
B2 — the contract spec's R-3 still described Discard as "…then the abandon
call, then a refresh". There is no refresh. Rewritten, and it now also records
that `AbandonResult.abandoned_at` is nullable and that a 200 can carry
`status: "in_progress"`.
Ride-along — the D8 protection did not cover submit's WINNING claim: every
submit test in test_quiz_abandon_g4.py loses the claim for other reasons, so
all five stay green under a minimal-mode refactor. Asserted at the one test
that wins it, the way abandon's claim already does.
Refs #537, #591
@AndresL230
AndresL230 merged commit d0786e2 into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Two add/add conflicts, both resolved by keeping BOTH sides:
* routes/quiz.py — G4 (#591) appended `abandon_attempt` and this branch
appended `_gamification_block` at the same point in the file. Kept the
route where G4 put it and the helper directly above its caller.
* tests/integration/test_quiz_subcutaneous_db.py — G4's abandon tests and
G8's inline-XP test landed in the same slot. Both kept.
Re-verified against G4's rework of submit: the atomic claim still filters on
both `completed_at is.null` and `abandoned_at is.null`, the lost-claim path
still re-reads to separate 409-completed from 409-abandoned from 404-deleted,
and `_gamification_block` still runs after both `award_xp_safe` and the
`check_achievements` pass. TestTheSnapshotIsTakenLast re-verified by mutation
(hoisting the call above the award turns it red).
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4) - #591

Merged
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon
Aug 26, 2026
Merged

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4)#591
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes the G4 gap in docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
(R-3), whose spec row is updated in this PR to say so.

Refs #537

The problem

"Discard" on the quiz resume strip had no server side. It wrote a
dismissedAttempts entry to localStorage and left the attempt row
in_progress until D2's lazy 24h sweep found it — so the strip came back on
the student's phone, in a second tab, and in this browser the moment storage
was cleared. lib/quiz/session.ts carried the seam as
TODO(#537-followup: abandon endpoint).

The change

POST /api/quiz/attempts/{attempt_id}/abandon (backend/routes/quiz.py).
No schema change — it writes the same abandoned_at stamp _sweep_abandoned
already writes; the client is just allowed to say "now".

  • Owner-checked with require_self, exactly like the other attempt routes
    (404 unknown → 403 not yours, in that order).
  • 409QUIZ_ATTEMPT_ALREADY_COMPLETED on a submitted attempt: the score,
    mastery and XP are paid out and a discard could not take them back.
  • Idempotent: a second call is a 200 no-op returning the stamp already on
    the row, so a retry after a dropped response is free. A row the TTL sweep
    already claimed reports the sweep's timestamp, not a fresh one.
  • The write is submit's conditional claim (completed_at IS NULL AND abandoned_at IS NULL), so a concurrent submit and abandon cannot both win;
    the loser re-reads and 409s instead of reporting a discard that never
    happened.
  • status is produced by _attempt_status, never the literal — this endpoint
    must not be the one place that can disagree with the read paths.

Submit's claim is now symmetric. It filtered on completed_at IS NULL
alone, with _refuse_if_abandoned in front of it as a non-atomic pre-read.
This PR makes that interleaving reachable from the UI — a quiz open mid-question
in one tab, Discard pressed in another — and both claims would win, leaving the
row completed and abandoned. Submit's claim now requires abandoned_at IS NULL too, and a lost claim re-reads before choosing its 409 so a discarded
attempt answers QUIZ_ATTEMPT_ABANDONED instead of impersonating a double
submit (the client maps those codes to different copy).

Client: abandonAttempt in lib/quiz/api.ts, and useQuizHome::discard
as the whole gesture — hide locally (instant), clear the resume slot, abandon
(durable). No refresh: the merge-gate section below has why the original one
had to go. The screen just states the intent. A failed abandon is swallowed with a
console.warn: the student said discard, so resurrecting the quiz would be
the worse answer and the 24h sweep is still the backstop.

The resume strip needed no filter change.GET /attempts reports the
derived status, discoverResumable already offers only in_progress rows,
and getAttempt now answers resumable: false — so both discovery paths drop
the attempt on a reload and on any other device. GET /attempts deliberately
still lists abandoned rows: it is D4's history reader, and filtering it would
break history to fix the strip.

Test evidence

  • backend/tests/test_quiz_abandon_g4.pynew, 14 tests. RED 10 failed
    (route absent) → GREEN. Covers the 200 + stamp, the claim's filters, the
    idempotent repeat (and that it writes nothing twice), a TTL-swept row, 409 on
    completed, the concurrent-submit race, 404, 403 for a foreign attempt
    (real require_self restored — the lane stubs it), and that getAttempt,
    the listing, /answer and /submit all agree afterwards. Its fake models
    the PostgREST filter grammar rather than returning a canned value, so "the
    claim won" and "the claim was refused" are actually distinguishable.
  • The symmetric-claim fix has its own class: a discard stamped in the window
    between submit's pre-read and its claim leaves completed_at null, pays out
    no mastery and 409s as QUIZ_ATTEMPT_ABANDONED — RED without the added
    filter (the claim won and the request ran on past it), GREEN with it — plus a
    guard that an ordinary double submit still reads as
    QUIZ_ATTEMPT_ALREADY_COMPLETED.
  • Backend suite 2237 passed, 81 skipped; ruff check . clean.
  • backend/tests/integration/test_quiz_subcutaneous_db.py — abandon stamps the
    real row (read back through psycopg, not PostgREST), 409 after submit, and
    the IDOR negative extended to abandon. Integration-marked; not run locally.
  • Frontend: RED 7 failed with the implementation stashed → GREEN
    102 files / 1152 tests. tsc --noEmit clean, eslint 0 errors.
  • frontend/e2e/quiz-journeys.spec.ts — the resume journey now waits on the
    abandon POST, asserts abandoned_at is set in the DB, and reloads with both
    quiz storage keys wiped (the other-device case). What it pins there is the
    listing's own status for the attempt — in_progress as a positive
    control, abandoned after the discard — because every rendered consequence
    of that payload is a further round trip away, so a check on the strip alone
    would pass on timing. The strip's absence is kept as corroboration.
    Not run in this branch; the lane owns the stack.

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2237 passed / 81 skipped · eslint ✅ · tsc ✅ · vitest ✅ 1152
  • Playwright Chapter 1 ✅ 73 passed / 1 skipped (2.9m) — includes the new G4 journey (discard → DB abandoned_at set → server status abandoned on a fresh context → strip absent)
  • oracles ✅ clean · integration ✅ 73 passed (includes the new real-HTTP abandon cases)
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629622649
  • Review: task review (deviation on GET /attempts judged correct; 1 Important — the cross-device e2e assertion could fire before a regression surfaced → fixed by asserting the server's own status first; submit's atomic claim made symmetric with abandon's) + scoped re-review clean.

Merge-gate review (2026-08-26)

origin/main merged in (f6ed004 / 6225bba / 025474a — no conflicts; #590's
include_answer_key flip is in the generate handler, clear of these hunks).
Two commits on top, from a /code-review pass whose findings were verified
against the code before being acted on.

The race the PR re-created (the one that mattered)

useQuizHome::discard fired refresh()synchronously beside the abandon
POST. The re-read routinely predated the write and came back in_progress, so
the resume strip stayed hidden only because discoverResumable skips locally
dismissed ids — the localStorage single point of failure G4 exists to retire.
With storage unavailable (private window: session.ts::writeJson no-ops and
the abandon error is swallowed) the discarded quiz came straight back. It also
cost a full home-screen skeleton flash and a re-fired describeConcept per
discard.

Fixed by clearing the resumable slot directly and dropping the global re-read.
Nothing else on quiz home derives from an unfinished attempt — the ranking
and the "missed N last time" join both read COMPLETED attempts only
(proposals.ts) — so the re-read bought nothing and raced the write it meant
to observe. dismissAttempt still runs first, as the across-loads backstop.

Honest failures on both claim losers

  • abandon built a 200 out of an empty dict: (rows or [{}])[0] then
    abandoned_at = current.get(...) or now reported {status: "abandoned", abandoned_at: <now>} for a row that may not exist, with a timestamp nothing
    wrote. An empty re-read is now the same 404 the top of the route gives, and
    the branch never substitutes its own clock for a write it did not make.
  • submit had the same phantom dict (_refuse_if_abandoned({}) is a no-op,
    so a vanished row reported ALREADY_COMPLETED), and the new SELECT sat in a
    window that used to be infallible — losing the claim went straight to a 409.
    A transient PostgREST failure there now degrades back to that 409 rather
    than 500ing an ordinary double-click.
  • _load_owned_attempt and _refuse_if_completed replace the select+404+
    require_self preamble (verbatim in four routes) and the hand-rolled
    already-completed 409 (five copies).

Copy

QUIZ_ATTEMPT_ABANDONED still read "That quiz expired after a day" — copy
from the TTL-sweep era, and a bug to the student who pressed Discard thirty
seconds ago. abandoned_at records when a row closed, never who
closed it, so nothing on the wire can tell the two apart: both the client copy
and the server sentence now cover both. Contract table updated to match.

Test fidelity (why the above were invisible)

  • _Attempts.select / select_with_count ignored their filters and always
    returned the stored row, so no test could fail on a dropped or mis-built
    id=eq. / user_id=eq., and the history-listing assertion was pinned on a
    read nobody made. Both honour filters now, and the listing test asserts what
    the fake was asked for.
  • The fake ignored prefer_return_minimal, but db/connection.py returns []
    in that mode — a claim refactored to minimal would read as LOST on every
    request (every submit 409s, no payout) with the suite green. Modelled, plus
    an explicit assertion that abandon's claim is not minimal.
  • Three vitest assertions were vacuous: resumable was null merely because
    refresh() had bumped the load key. They now pin the ordering (strip gone
    while the POST is still in flight, status still "ready"), the read
    counts, and the storage-unavailable case that is the real regression.
  • The journey's otherDeviceVisit hardcoded both storage keys, re-declared
    the listing type and inlined openQuizHome's body — a key rename would have
    left the other-device leg green and vacuous. Now imports STORAGE_KEY /
    DISMISSED_KEY / AttemptsPage from @/lib (precedent:
    quiz-errors.spec.ts). The post-discard proposal wait got SUBMIT_TIMEOUT
    like every sibling.
  • The G4 origin story was retold at length in six places; one canonical
    telling (routes/quiz.py::abandon_attempt) plus pointers.

Follow-up: #597

POST /attempts/{id}/answer is the last quiz write path guarded by a pre-read
alone. G4 turns "the attempt closed mid-request" from a 24h TTL event into a
button, so a Discard landing between the refusals and the insert records a
graded response on a closed attempt. PostgREST cannot make that cross-table
INSERT conditional atomically — closing it needs a trigger or an RPC, i.e. a
migration — so it ships as an accepted risk with a comment at the insert.
Inert rather than harmful: an abandoned attempt can never be submitted
(submit's claim filters on abandoned_at IS NULL), so the orphan pays out no
mastery, XP or achievement. Filed as #597.

Two notes, deliberately not changes

  • abandoned_at on a TTL-dead row records the discard CLICK, not when the
    attempt went quiet. Noted at the claim. It matters only if a consumer ever
    reads the column as an elapsed time; none does today (the derived status
    only asks whether it is set), and "when did this go quiet" is created_at
    plus the last response — what _attempt_status already computes.
  • The journey's post-click toHaveCount(0) used to witness the skeleton
    rather than the optimistic hide, since refresh() blanked the screen. With
    the race fix there is no skeleton, so the paired
    expect(quiz-proposal).toBeVisible() is what makes it mean "gone from a
    rendered screen"; that is now stated where the assertion is.

One push-back

D2 also asked to drop the or now fallback on abandon's winning claim.
Left in place: there, now is the value the request itself PATCHed, so
preferring the echoed column and falling back to it yield the same timestamp —
it is not a fabrication. The fabricated one was the loser branch's, and that
is gone.

Verification

  • backend: pytest tests/ -q2247 passed / 82 skipped; ruff check .
  • frontend: vitest run1154 passed / 102 files; tsc --noEmit ✅;
    npm run lint ✅ 0 errors
  • npx playwright test e2e/quiz-journeys.spec.ts --list ✅ collects 9 tests
    (the @/lib imports resolve). The journey itself is unrun here — the
    stack is the controller's.

AndresL230and others added 3 commits August 23, 2026 04:13
Discard had no server side: quiz home wrote a localStorage flag and left
the row in_progress until D2's 24h sweep found it, so the resume strip
came back on every other device.
The route writes the same `abandoned_at` stamp `_sweep_abandoned` writes —
the client is just allowed to say "now". Owner-checked like the other
attempt routes, 409 on an already-submitted attempt, and idempotent: a
second call is a 200 no-op carrying the stamp already on the row, so a
retry after a dropped response is free. The write is the same conditional
claim submit uses (`completed_at IS NULL AND abandoned_at IS NULL`), so a
concurrent submit and abandon cannot both win; the loser re-reads and 409s
rather than reporting a discard that never happened.
No schema change — `abandoned_at` has existed since D2. `GET /attempts`
deliberately still LISTS abandoned rows (it is the history reader, D4);
what changes is the derived status, which is what the strip filters on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`useQuizHome::discard` is now the whole gesture: `dismissAttempt` hides the
row in this browser synchronously (so the strip is gone on the next render
whatever the network does), `abandonAttempt` makes it durable, and the
refresh re-reads the world. The screen just states the intent — splitting
the two writes across the component is how they drifted apart in the first
place, with the row hidden here and closed nowhere.
A failed abandon is swallowed with a console.warn rather than surfaced: the
student said discard, so putting the quiz back on screen (or a red toast
over a discard that visibly worked) would both be worse answers, and the
backend's 24h sweep is still the backstop.
The resume strip needed no filter change — `GET /attempts` reports the
derived status, `discoverResumable` already offers only `in_progress` rows,
and `getAttempt` now answers `resumable: false`, so both discovery paths
drop the attempt on a reload and on any other device.
The Chapter 1 resume journey is updated to match and is UNRUN here (the
controller owns the stack): it now asserts `abandoned_at` is set, and
proves the server is what hides the attempt by wiping both quiz storage
keys and reloading — with a positive control first, since "the strip is
absent" means nothing unless the same visit was just shown to offer it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…journey (#537 G4)
Important 1 — the cross-device assertion was one round trip too early.
`otherDeviceVisit` synced on the attempts listing and the proposal card, both
of which land at `setLoaded`; in the REGRESSED case the strip only renders
after `discoverResumable` awaits a further `GET /attempts/{id}`, so the count
check fired while the failure was still in flight. The helper now returns the
listing's parsed body and the journey asserts the SERVER'S status for the
attempt (`in_progress` on the positive control, `abandoned` after the
discard). The strip check stays as corroboration.
M2 — the DB read is synchronized to the abandon POST via the file's own
`page.waitForResponse` idiom, not to the click (the strip vanishes
optimistically, so the click races the write being checked).
M3 — submit's claim now filters `abandoned_at IS NULL` too, symmetric with
abandon's. G4 makes the interleaving reachable from the UI (mid-quiz in one
tab, Discard in another): the old single-null filter let both claims win and
left the row completed AND abandoned, with `_refuse_if_abandoned` only ever a
non-atomic pre-read. A lost claim now re-reads before choosing its 409, so a
discarded attempt answers QUIZ_ATTEMPT_ABANDONED rather than impersonating a
double submit — the frontend maps those codes to different copy.
M4 `import type { Page }`. M5 the R-3 row's "Cost if wrong" now describes the
failed-call fallback, which is all that is left of it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 27f4c739-d806-48a9-b900-ba224b02b98a

📥 Commits

Reviewing files that changed from the base of the PR and between 025474a and b4f1a35.

📒 Files selected for processing (19)
  • backend/routes/quiz.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_quiz_abandon_g4.py
  • backend/tests/test_quiz_routes.py
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb4f1a35Commit Preview URL

Branch Preview URL
Aug 26 2026, 06:40 PM

Merge-gate review of PR #591, backend half.
D2/D3 — both lost-claim branches read the row back as `(rows or [{}])[0]`,
which made a vanished row indistinguishable from an open one: abandon
answered 200 `{status: "abandoned", abandoned_at: <now>}` with a timestamp
nothing had written, and submit ran `_refuse_if_abandoned({})` as a no-op and
reported ALREADY_COMPLETED. An empty re-read is now the 404 the top of each
route already gives, and abandon no longer substitutes its own clock for a
write it did not make.
D3 also put a SELECT in a window that used to be infallible — losing submit's
claim went straight to a 409 with no further I/O. A transient PostgREST
failure there now degrades back to that 409 instead of 500ing an ordinary
double-click.
D10/D11 — the select-by-id + 404 + require_self preamble was verbatim in four
routes; the already-completed 409 was hand-rolled five times. Both are now
`_load_owned_attempt` / `_refuse_if_completed` (+ `_attempt_not_found` /
`_already_completed`).
D4 (backend half) — QUIZ_ATTEMPT_ABANDONED's sentence said "expired", which is
wrong on the path this PR creates. `abandoned_at` records when a row closed,
never who closed it, so the copy now covers both.
D7/D8/D12 — test fidelity, which is why the above were invisible. `_Attempts`
honours its filters on select/select_with_count (a fake that answers every
query cannot fail on a dropped `id=eq.`), models `prefer_return_minimal` (the
real client returns [] in that mode, so a claim refactored to minimal reads as
LOST on every request), and stops duplicating the attempt-row fixtures
byte-for-byte from test_quiz_lifecycle_d. `_factory`'s dead `responses` param
is gone.
D14 — accepted-risk comment at the /answer insert; filed as #597.
D15 — noted at the claim that a TTL-dead row's stamp records the click.
Refs #537, #591, #597
Merge-gate review of PR #591, frontend half.
D1 — `discard` fired `refresh()` synchronously beside the abandon POST, so the
re-read routinely PREDATED the write and came back `in_progress`. The strip
then stayed hidden only because `discoverResumable` skips locally dismissed
ids — the exact localStorage single point of failure G4 exists to retire, and
one that fails outright in a private window (session.ts's writeJson no-ops and
the abandon error is swallowed, so the quiz the student just discarded comes
straight back). It also flashed the whole home screen to a skeleton and
re-fired describeConcept to change one row.
The slot is now cleared directly. Nothing else on quiz home derives from an
unfinished attempt — the ranking and the "missed N last time" join both read
COMPLETED attempts only (proposals.ts) — so the re-read bought nothing and
raced the write it meant to observe.
D9 — the three vitest assertions guarding this passed for the wrong reason:
`resumable` was null merely because refresh() had invalidated the load key.
They now pin the ordering (the strip is gone while the POST is still in
flight, with `status` still "ready"), the read counts, and — the real
regression — that the strip stays gone with localStorage unavailable.
D4 (frontend half) — QUIZ_ATTEMPT_ABANDONED said "That quiz expired after a
day", copy from the TTL-sweep era that reads as a bug to the student who
pressed Discard thirty seconds ago.
D5/D6 — the journey's `otherDeviceVisit` hardcoded both storage keys,
re-declared the listing type and inlined openQuizHome's body; a key rename
left the other-device leg green and vacuous. It now imports STORAGE_KEY /
DISMISSED_KEY / AttemptsPage from @/lib (precedent: quiz-errors.spec.ts) and
calls openQuizHome. The post-discard proposal wait gets SUBMIT_TIMEOUT like
every sibling.
D13 — the G4 origin story was retold at length in six places; one canonical
telling (routes/quiz.py::abandon_attempt) plus pointers.
Refs #537, #591
…G4)
Merge-gate re-review, two blockers + a ride-along.
B1 — the D1 regression test ("hides it on the server's word, not on
localStorage's") passed locally and FAILED CI: `expect(isDismissed("open"))
.toBe(false)` got `true`, because the block never took. jsdom is lockfile
pinned, so the only difference is the Node version — CI is on 22 (ci.yml:86),
this box on 26 — and `vi.spyOn(window.localStorage, "setItem")` intercepts on
one and not the other. (`Storage.prototype` intercepts on neither: jsdom hands
out a Proxy.) The premise silently evaporated, leaving the flagship test for
the discard/refresh race asserting nothing on CI.
Blocked at the ACCESSOR instead — `Object.defineProperty(window,
"localStorage", { get() { throw ... } })` — which is what a browser with site
data disabled actually does and what `session.ts::storage()` already catches
into `null`. No dependency on how jsdom hands out the Storage object.
Restored in a `finally` so a failure cannot leak broken storage into the rest
of the file. Re-verified against the pre-fix `discard`: still fails on the
real assertion (the strip comes back), not on the premise.
B2 — the contract spec's R-3 still described Discard as "…then the abandon
call, then a refresh". There is no refresh. Rewritten, and it now also records
that `AbandonResult.abandoned_at` is nullable and that a 200 can carry
`status: "in_progress"`.
Ride-along — the D8 protection did not cover submit's WINNING claim: every
submit test in test_quiz_abandon_g4.py loses the claim for other reasons, so
all five stay green under a minimal-mode refactor. Asserted at the one test
that wins it, the way abandon's claim already does.
Refs #537, #591
@AndresL230
AndresL230 merged commit d0786e2 into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Two add/add conflicts, both resolved by keeping BOTH sides:
* routes/quiz.py — G4 (#591) appended `abandon_attempt` and this branch
appended `_gamification_block` at the same point in the file. Kept the
route where G4 put it and the helper directly above its caller.
* tests/integration/test_quiz_subcutaneous_db.py — G4's abandon tests and
G8's inline-XP test landed in the same slot. Both kept.
Re-verified against G4's rework of submit: the atomic claim still filters on
both `completed_at is.null` and `abandoned_at is.null`, the lost-claim path
still re-reads to separate 409-completed from 409-abandoned from 404-deleted,
and `_gamification_block` still runs after both `award_xp_safe` and the
`check_achievements` pass. TestTheSnapshotIsTakenLast re-verified by mutation
(hoisting the call above the award turns it red).
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4) - #591

Merged
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon
Aug 26, 2026
Merged

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4)#591
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes the G4 gap in docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
(R-3), whose spec row is updated in this PR to say so.

Refs #537

The problem

"Discard" on the quiz resume strip had no server side. It wrote a
dismissedAttempts entry to localStorage and left the attempt row
in_progress until D2's lazy 24h sweep found it — so the strip came back on
the student's phone, in a second tab, and in this browser the moment storage
was cleared. lib/quiz/session.ts carried the seam as
TODO(#537-followup: abandon endpoint).

The change

POST /api/quiz/attempts/{attempt_id}/abandon (backend/routes/quiz.py).
No schema change — it writes the same abandoned_at stamp _sweep_abandoned
already writes; the client is just allowed to say "now".

  • Owner-checked with require_self, exactly like the other attempt routes
    (404 unknown → 403 not yours, in that order).
  • 409QUIZ_ATTEMPT_ALREADY_COMPLETED on a submitted attempt: the score,
    mastery and XP are paid out and a discard could not take them back.
  • Idempotent: a second call is a 200 no-op returning the stamp already on
    the row, so a retry after a dropped response is free. A row the TTL sweep
    already claimed reports the sweep's timestamp, not a fresh one.
  • The write is submit's conditional claim (completed_at IS NULL AND abandoned_at IS NULL), so a concurrent submit and abandon cannot both win;
    the loser re-reads and 409s instead of reporting a discard that never
    happened.
  • status is produced by _attempt_status, never the literal — this endpoint
    must not be the one place that can disagree with the read paths.

Submit's claim is now symmetric. It filtered on completed_at IS NULL
alone, with _refuse_if_abandoned in front of it as a non-atomic pre-read.
This PR makes that interleaving reachable from the UI — a quiz open mid-question
in one tab, Discard pressed in another — and both claims would win, leaving the
row completed and abandoned. Submit's claim now requires abandoned_at IS NULL too, and a lost claim re-reads before choosing its 409 so a discarded
attempt answers QUIZ_ATTEMPT_ABANDONED instead of impersonating a double
submit (the client maps those codes to different copy).

Client: abandonAttempt in lib/quiz/api.ts, and useQuizHome::discard
as the whole gesture — hide locally (instant), clear the resume slot, abandon
(durable). No refresh: the merge-gate section below has why the original one
had to go. The screen just states the intent. A failed abandon is swallowed with a
console.warn: the student said discard, so resurrecting the quiz would be
the worse answer and the 24h sweep is still the backstop.

The resume strip needed no filter change.GET /attempts reports the
derived status, discoverResumable already offers only in_progress rows,
and getAttempt now answers resumable: false — so both discovery paths drop
the attempt on a reload and on any other device. GET /attempts deliberately
still lists abandoned rows: it is D4's history reader, and filtering it would
break history to fix the strip.

Test evidence

  • backend/tests/test_quiz_abandon_g4.pynew, 14 tests. RED 10 failed
    (route absent) → GREEN. Covers the 200 + stamp, the claim's filters, the
    idempotent repeat (and that it writes nothing twice), a TTL-swept row, 409 on
    completed, the concurrent-submit race, 404, 403 for a foreign attempt
    (real require_self restored — the lane stubs it), and that getAttempt,
    the listing, /answer and /submit all agree afterwards. Its fake models
    the PostgREST filter grammar rather than returning a canned value, so "the
    claim won" and "the claim was refused" are actually distinguishable.
  • The symmetric-claim fix has its own class: a discard stamped in the window
    between submit's pre-read and its claim leaves completed_at null, pays out
    no mastery and 409s as QUIZ_ATTEMPT_ABANDONED — RED without the added
    filter (the claim won and the request ran on past it), GREEN with it — plus a
    guard that an ordinary double submit still reads as
    QUIZ_ATTEMPT_ALREADY_COMPLETED.
  • Backend suite 2237 passed, 81 skipped; ruff check . clean.
  • backend/tests/integration/test_quiz_subcutaneous_db.py — abandon stamps the
    real row (read back through psycopg, not PostgREST), 409 after submit, and
    the IDOR negative extended to abandon. Integration-marked; not run locally.
  • Frontend: RED 7 failed with the implementation stashed → GREEN
    102 files / 1152 tests. tsc --noEmit clean, eslint 0 errors.
  • frontend/e2e/quiz-journeys.spec.ts — the resume journey now waits on the
    abandon POST, asserts abandoned_at is set in the DB, and reloads with both
    quiz storage keys wiped (the other-device case). What it pins there is the
    listing's own status for the attempt — in_progress as a positive
    control, abandoned after the discard — because every rendered consequence
    of that payload is a further round trip away, so a check on the strip alone
    would pass on timing. The strip's absence is kept as corroboration.
    Not run in this branch; the lane owns the stack.

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2237 passed / 81 skipped · eslint ✅ · tsc ✅ · vitest ✅ 1152
  • Playwright Chapter 1 ✅ 73 passed / 1 skipped (2.9m) — includes the new G4 journey (discard → DB abandoned_at set → server status abandoned on a fresh context → strip absent)
  • oracles ✅ clean · integration ✅ 73 passed (includes the new real-HTTP abandon cases)
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629622649
  • Review: task review (deviation on GET /attempts judged correct; 1 Important — the cross-device e2e assertion could fire before a regression surfaced → fixed by asserting the server's own status first; submit's atomic claim made symmetric with abandon's) + scoped re-review clean.

Merge-gate review (2026-08-26)

origin/main merged in (f6ed004 / 6225bba / 025474a — no conflicts; #590's
include_answer_key flip is in the generate handler, clear of these hunks).
Two commits on top, from a /code-review pass whose findings were verified
against the code before being acted on.

The race the PR re-created (the one that mattered)

useQuizHome::discard fired refresh()synchronously beside the abandon
POST. The re-read routinely predated the write and came back in_progress, so
the resume strip stayed hidden only because discoverResumable skips locally
dismissed ids — the localStorage single point of failure G4 exists to retire.
With storage unavailable (private window: session.ts::writeJson no-ops and
the abandon error is swallowed) the discarded quiz came straight back. It also
cost a full home-screen skeleton flash and a re-fired describeConcept per
discard.

Fixed by clearing the resumable slot directly and dropping the global re-read.
Nothing else on quiz home derives from an unfinished attempt — the ranking
and the "missed N last time" join both read COMPLETED attempts only
(proposals.ts) — so the re-read bought nothing and raced the write it meant
to observe. dismissAttempt still runs first, as the across-loads backstop.

Honest failures on both claim losers

  • abandon built a 200 out of an empty dict: (rows or [{}])[0] then
    abandoned_at = current.get(...) or now reported {status: "abandoned", abandoned_at: <now>} for a row that may not exist, with a timestamp nothing
    wrote. An empty re-read is now the same 404 the top of the route gives, and
    the branch never substitutes its own clock for a write it did not make.
  • submit had the same phantom dict (_refuse_if_abandoned({}) is a no-op,
    so a vanished row reported ALREADY_COMPLETED), and the new SELECT sat in a
    window that used to be infallible — losing the claim went straight to a 409.
    A transient PostgREST failure there now degrades back to that 409 rather
    than 500ing an ordinary double-click.
  • _load_owned_attempt and _refuse_if_completed replace the select+404+
    require_self preamble (verbatim in four routes) and the hand-rolled
    already-completed 409 (five copies).

Copy

QUIZ_ATTEMPT_ABANDONED still read "That quiz expired after a day" — copy
from the TTL-sweep era, and a bug to the student who pressed Discard thirty
seconds ago. abandoned_at records when a row closed, never who
closed it, so nothing on the wire can tell the two apart: both the client copy
and the server sentence now cover both. Contract table updated to match.

Test fidelity (why the above were invisible)

  • _Attempts.select / select_with_count ignored their filters and always
    returned the stored row, so no test could fail on a dropped or mis-built
    id=eq. / user_id=eq., and the history-listing assertion was pinned on a
    read nobody made. Both honour filters now, and the listing test asserts what
    the fake was asked for.
  • The fake ignored prefer_return_minimal, but db/connection.py returns []
    in that mode — a claim refactored to minimal would read as LOST on every
    request (every submit 409s, no payout) with the suite green. Modelled, plus
    an explicit assertion that abandon's claim is not minimal.
  • Three vitest assertions were vacuous: resumable was null merely because
    refresh() had bumped the load key. They now pin the ordering (strip gone
    while the POST is still in flight, status still "ready"), the read
    counts, and the storage-unavailable case that is the real regression.
  • The journey's otherDeviceVisit hardcoded both storage keys, re-declared
    the listing type and inlined openQuizHome's body — a key rename would have
    left the other-device leg green and vacuous. Now imports STORAGE_KEY /
    DISMISSED_KEY / AttemptsPage from @/lib (precedent:
    quiz-errors.spec.ts). The post-discard proposal wait got SUBMIT_TIMEOUT
    like every sibling.
  • The G4 origin story was retold at length in six places; one canonical
    telling (routes/quiz.py::abandon_attempt) plus pointers.

Follow-up: #597

POST /attempts/{id}/answer is the last quiz write path guarded by a pre-read
alone. G4 turns "the attempt closed mid-request" from a 24h TTL event into a
button, so a Discard landing between the refusals and the insert records a
graded response on a closed attempt. PostgREST cannot make that cross-table
INSERT conditional atomically — closing it needs a trigger or an RPC, i.e. a
migration — so it ships as an accepted risk with a comment at the insert.
Inert rather than harmful: an abandoned attempt can never be submitted
(submit's claim filters on abandoned_at IS NULL), so the orphan pays out no
mastery, XP or achievement. Filed as #597.

Two notes, deliberately not changes

  • abandoned_at on a TTL-dead row records the discard CLICK, not when the
    attempt went quiet. Noted at the claim. It matters only if a consumer ever
    reads the column as an elapsed time; none does today (the derived status
    only asks whether it is set), and "when did this go quiet" is created_at
    plus the last response — what _attempt_status already computes.
  • The journey's post-click toHaveCount(0) used to witness the skeleton
    rather than the optimistic hide, since refresh() blanked the screen. With
    the race fix there is no skeleton, so the paired
    expect(quiz-proposal).toBeVisible() is what makes it mean "gone from a
    rendered screen"; that is now stated where the assertion is.

One push-back

D2 also asked to drop the or now fallback on abandon's winning claim.
Left in place: there, now is the value the request itself PATCHed, so
preferring the echoed column and falling back to it yield the same timestamp —
it is not a fabrication. The fabricated one was the loser branch's, and that
is gone.

Verification

  • backend: pytest tests/ -q2247 passed / 82 skipped; ruff check .
  • frontend: vitest run1154 passed / 102 files; tsc --noEmit ✅;
    npm run lint ✅ 0 errors
  • npx playwright test e2e/quiz-journeys.spec.ts --list ✅ collects 9 tests
    (the @/lib imports resolve). The journey itself is unrun here — the
    stack is the controller's.

AndresL230and others added 3 commits August 23, 2026 04:13
Discard had no server side: quiz home wrote a localStorage flag and left
the row in_progress until D2's 24h sweep found it, so the resume strip
came back on every other device.
The route writes the same `abandoned_at` stamp `_sweep_abandoned` writes —
the client is just allowed to say "now". Owner-checked like the other
attempt routes, 409 on an already-submitted attempt, and idempotent: a
second call is a 200 no-op carrying the stamp already on the row, so a
retry after a dropped response is free. The write is the same conditional
claim submit uses (`completed_at IS NULL AND abandoned_at IS NULL`), so a
concurrent submit and abandon cannot both win; the loser re-reads and 409s
rather than reporting a discard that never happened.
No schema change — `abandoned_at` has existed since D2. `GET /attempts`
deliberately still LISTS abandoned rows (it is the history reader, D4);
what changes is the derived status, which is what the strip filters on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`useQuizHome::discard` is now the whole gesture: `dismissAttempt` hides the
row in this browser synchronously (so the strip is gone on the next render
whatever the network does), `abandonAttempt` makes it durable, and the
refresh re-reads the world. The screen just states the intent — splitting
the two writes across the component is how they drifted apart in the first
place, with the row hidden here and closed nowhere.
A failed abandon is swallowed with a console.warn rather than surfaced: the
student said discard, so putting the quiz back on screen (or a red toast
over a discard that visibly worked) would both be worse answers, and the
backend's 24h sweep is still the backstop.
The resume strip needed no filter change — `GET /attempts` reports the
derived status, `discoverResumable` already offers only `in_progress` rows,
and `getAttempt` now answers `resumable: false`, so both discovery paths
drop the attempt on a reload and on any other device.
The Chapter 1 resume journey is updated to match and is UNRUN here (the
controller owns the stack): it now asserts `abandoned_at` is set, and
proves the server is what hides the attempt by wiping both quiz storage
keys and reloading — with a positive control first, since "the strip is
absent" means nothing unless the same visit was just shown to offer it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…journey (#537 G4)
Important 1 — the cross-device assertion was one round trip too early.
`otherDeviceVisit` synced on the attempts listing and the proposal card, both
of which land at `setLoaded`; in the REGRESSED case the strip only renders
after `discoverResumable` awaits a further `GET /attempts/{id}`, so the count
check fired while the failure was still in flight. The helper now returns the
listing's parsed body and the journey asserts the SERVER'S status for the
attempt (`in_progress` on the positive control, `abandoned` after the
discard). The strip check stays as corroboration.
M2 — the DB read is synchronized to the abandon POST via the file's own
`page.waitForResponse` idiom, not to the click (the strip vanishes
optimistically, so the click races the write being checked).
M3 — submit's claim now filters `abandoned_at IS NULL` too, symmetric with
abandon's. G4 makes the interleaving reachable from the UI (mid-quiz in one
tab, Discard in another): the old single-null filter let both claims win and
left the row completed AND abandoned, with `_refuse_if_abandoned` only ever a
non-atomic pre-read. A lost claim now re-reads before choosing its 409, so a
discarded attempt answers QUIZ_ATTEMPT_ABANDONED rather than impersonating a
double submit — the frontend maps those codes to different copy.
M4 `import type { Page }`. M5 the R-3 row's "Cost if wrong" now describes the
failed-call fallback, which is all that is left of it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 27f4c739-d806-48a9-b900-ba224b02b98a

📥 Commits

Reviewing files that changed from the base of the PR and between 025474a and b4f1a35.

📒 Files selected for processing (19)
  • backend/routes/quiz.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_quiz_abandon_g4.py
  • backend/tests/test_quiz_routes.py
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb4f1a35Commit Preview URL

Branch Preview URL
Aug 26 2026, 06:40 PM

Merge-gate review of PR #591, backend half.
D2/D3 — both lost-claim branches read the row back as `(rows or [{}])[0]`,
which made a vanished row indistinguishable from an open one: abandon
answered 200 `{status: "abandoned", abandoned_at: <now>}` with a timestamp
nothing had written, and submit ran `_refuse_if_abandoned({})` as a no-op and
reported ALREADY_COMPLETED. An empty re-read is now the 404 the top of each
route already gives, and abandon no longer substitutes its own clock for a
write it did not make.
D3 also put a SELECT in a window that used to be infallible — losing submit's
claim went straight to a 409 with no further I/O. A transient PostgREST
failure there now degrades back to that 409 instead of 500ing an ordinary
double-click.
D10/D11 — the select-by-id + 404 + require_self preamble was verbatim in four
routes; the already-completed 409 was hand-rolled five times. Both are now
`_load_owned_attempt` / `_refuse_if_completed` (+ `_attempt_not_found` /
`_already_completed`).
D4 (backend half) — QUIZ_ATTEMPT_ABANDONED's sentence said "expired", which is
wrong on the path this PR creates. `abandoned_at` records when a row closed,
never who closed it, so the copy now covers both.
D7/D8/D12 — test fidelity, which is why the above were invisible. `_Attempts`
honours its filters on select/select_with_count (a fake that answers every
query cannot fail on a dropped `id=eq.`), models `prefer_return_minimal` (the
real client returns [] in that mode, so a claim refactored to minimal reads as
LOST on every request), and stops duplicating the attempt-row fixtures
byte-for-byte from test_quiz_lifecycle_d. `_factory`'s dead `responses` param
is gone.
D14 — accepted-risk comment at the /answer insert; filed as #597.
D15 — noted at the claim that a TTL-dead row's stamp records the click.
Refs #537, #591, #597
Merge-gate review of PR #591, frontend half.
D1 — `discard` fired `refresh()` synchronously beside the abandon POST, so the
re-read routinely PREDATED the write and came back `in_progress`. The strip
then stayed hidden only because `discoverResumable` skips locally dismissed
ids — the exact localStorage single point of failure G4 exists to retire, and
one that fails outright in a private window (session.ts's writeJson no-ops and
the abandon error is swallowed, so the quiz the student just discarded comes
straight back). It also flashed the whole home screen to a skeleton and
re-fired describeConcept to change one row.
The slot is now cleared directly. Nothing else on quiz home derives from an
unfinished attempt — the ranking and the "missed N last time" join both read
COMPLETED attempts only (proposals.ts) — so the re-read bought nothing and
raced the write it meant to observe.
D9 — the three vitest assertions guarding this passed for the wrong reason:
`resumable` was null merely because refresh() had invalidated the load key.
They now pin the ordering (the strip is gone while the POST is still in
flight, with `status` still "ready"), the read counts, and — the real
regression — that the strip stays gone with localStorage unavailable.
D4 (frontend half) — QUIZ_ATTEMPT_ABANDONED said "That quiz expired after a
day", copy from the TTL-sweep era that reads as a bug to the student who
pressed Discard thirty seconds ago.
D5/D6 — the journey's `otherDeviceVisit` hardcoded both storage keys,
re-declared the listing type and inlined openQuizHome's body; a key rename
left the other-device leg green and vacuous. It now imports STORAGE_KEY /
DISMISSED_KEY / AttemptsPage from @/lib (precedent: quiz-errors.spec.ts) and
calls openQuizHome. The post-discard proposal wait gets SUBMIT_TIMEOUT like
every sibling.
D13 — the G4 origin story was retold at length in six places; one canonical
telling (routes/quiz.py::abandon_attempt) plus pointers.
Refs #537, #591
…G4)
Merge-gate re-review, two blockers + a ride-along.
B1 — the D1 regression test ("hides it on the server's word, not on
localStorage's") passed locally and FAILED CI: `expect(isDismissed("open"))
.toBe(false)` got `true`, because the block never took. jsdom is lockfile
pinned, so the only difference is the Node version — CI is on 22 (ci.yml:86),
this box on 26 — and `vi.spyOn(window.localStorage, "setItem")` intercepts on
one and not the other. (`Storage.prototype` intercepts on neither: jsdom hands
out a Proxy.) The premise silently evaporated, leaving the flagship test for
the discard/refresh race asserting nothing on CI.
Blocked at the ACCESSOR instead — `Object.defineProperty(window,
"localStorage", { get() { throw ... } })` — which is what a browser with site
data disabled actually does and what `session.ts::storage()` already catches
into `null`. No dependency on how jsdom hands out the Storage object.
Restored in a `finally` so a failure cannot leak broken storage into the rest
of the file. Re-verified against the pre-fix `discard`: still fails on the
real assertion (the strip comes back), not on the premise.
B2 — the contract spec's R-3 still described Discard as "…then the abandon
call, then a refresh". There is no refresh. Rewritten, and it now also records
that `AbandonResult.abandoned_at` is nullable and that a 200 can carry
`status: "in_progress"`.
Ride-along — the D8 protection did not cover submit's WINNING claim: every
submit test in test_quiz_abandon_g4.py loses the claim for other reasons, so
all five stay green under a minimal-mode refactor. Asserted at the one test
that wins it, the way abandon's claim already does.
Refs #537, #591
@AndresL230
AndresL230 merged commit d0786e2 into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Two add/add conflicts, both resolved by keeping BOTH sides:
* routes/quiz.py — G4 (#591) appended `abandon_attempt` and this branch
appended `_gamification_block` at the same point in the file. Kept the
route where G4 put it and the helper directly above its caller.
* tests/integration/test_quiz_subcutaneous_db.py — G4's abandon tests and
G8's inline-XP test landed in the same slot. Both kept.
Re-verified against G4's rework of submit: the atomic claim still filters on
both `completed_at is.null` and `abandoned_at is.null`, the lost-claim path
still re-reads to separate 409-completed from 409-abandoned from 404-deleted,
and `_gamification_block` still runs after both `award_xp_safe` and the
`check_achievements` pass. TestTheSnapshotIsTakenLast re-verified by mutation
(hoisting the call above the award turns it red).
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4) - #591

Merged
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon
Aug 26, 2026
Merged

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4)#591
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes the G4 gap in docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
(R-3), whose spec row is updated in this PR to say so.

Refs #537

The problem

"Discard" on the quiz resume strip had no server side. It wrote a
dismissedAttempts entry to localStorage and left the attempt row
in_progress until D2's lazy 24h sweep found it — so the strip came back on
the student's phone, in a second tab, and in this browser the moment storage
was cleared. lib/quiz/session.ts carried the seam as
TODO(#537-followup: abandon endpoint).

The change

POST /api/quiz/attempts/{attempt_id}/abandon (backend/routes/quiz.py).
No schema change — it writes the same abandoned_at stamp _sweep_abandoned
already writes; the client is just allowed to say "now".

  • Owner-checked with require_self, exactly like the other attempt routes
    (404 unknown → 403 not yours, in that order).
  • 409QUIZ_ATTEMPT_ALREADY_COMPLETED on a submitted attempt: the score,
    mastery and XP are paid out and a discard could not take them back.
  • Idempotent: a second call is a 200 no-op returning the stamp already on
    the row, so a retry after a dropped response is free. A row the TTL sweep
    already claimed reports the sweep's timestamp, not a fresh one.
  • The write is submit's conditional claim (completed_at IS NULL AND abandoned_at IS NULL), so a concurrent submit and abandon cannot both win;
    the loser re-reads and 409s instead of reporting a discard that never
    happened.
  • status is produced by _attempt_status, never the literal — this endpoint
    must not be the one place that can disagree with the read paths.

Submit's claim is now symmetric. It filtered on completed_at IS NULL
alone, with _refuse_if_abandoned in front of it as a non-atomic pre-read.
This PR makes that interleaving reachable from the UI — a quiz open mid-question
in one tab, Discard pressed in another — and both claims would win, leaving the
row completed and abandoned. Submit's claim now requires abandoned_at IS NULL too, and a lost claim re-reads before choosing its 409 so a discarded
attempt answers QUIZ_ATTEMPT_ABANDONED instead of impersonating a double
submit (the client maps those codes to different copy).

Client: abandonAttempt in lib/quiz/api.ts, and useQuizHome::discard
as the whole gesture — hide locally (instant), clear the resume slot, abandon
(durable). No refresh: the merge-gate section below has why the original one
had to go. The screen just states the intent. A failed abandon is swallowed with a
console.warn: the student said discard, so resurrecting the quiz would be
the worse answer and the 24h sweep is still the backstop.

The resume strip needed no filter change.GET /attempts reports the
derived status, discoverResumable already offers only in_progress rows,
and getAttempt now answers resumable: false — so both discovery paths drop
the attempt on a reload and on any other device. GET /attempts deliberately
still lists abandoned rows: it is D4's history reader, and filtering it would
break history to fix the strip.

Test evidence

  • backend/tests/test_quiz_abandon_g4.pynew, 14 tests. RED 10 failed
    (route absent) → GREEN. Covers the 200 + stamp, the claim's filters, the
    idempotent repeat (and that it writes nothing twice), a TTL-swept row, 409 on
    completed, the concurrent-submit race, 404, 403 for a foreign attempt
    (real require_self restored — the lane stubs it), and that getAttempt,
    the listing, /answer and /submit all agree afterwards. Its fake models
    the PostgREST filter grammar rather than returning a canned value, so "the
    claim won" and "the claim was refused" are actually distinguishable.
  • The symmetric-claim fix has its own class: a discard stamped in the window
    between submit's pre-read and its claim leaves completed_at null, pays out
    no mastery and 409s as QUIZ_ATTEMPT_ABANDONED — RED without the added
    filter (the claim won and the request ran on past it), GREEN with it — plus a
    guard that an ordinary double submit still reads as
    QUIZ_ATTEMPT_ALREADY_COMPLETED.
  • Backend suite 2237 passed, 81 skipped; ruff check . clean.
  • backend/tests/integration/test_quiz_subcutaneous_db.py — abandon stamps the
    real row (read back through psycopg, not PostgREST), 409 after submit, and
    the IDOR negative extended to abandon. Integration-marked; not run locally.
  • Frontend: RED 7 failed with the implementation stashed → GREEN
    102 files / 1152 tests. tsc --noEmit clean, eslint 0 errors.
  • frontend/e2e/quiz-journeys.spec.ts — the resume journey now waits on the
    abandon POST, asserts abandoned_at is set in the DB, and reloads with both
    quiz storage keys wiped (the other-device case). What it pins there is the
    listing's own status for the attempt — in_progress as a positive
    control, abandoned after the discard — because every rendered consequence
    of that payload is a further round trip away, so a check on the strip alone
    would pass on timing. The strip's absence is kept as corroboration.
    Not run in this branch; the lane owns the stack.

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2237 passed / 81 skipped · eslint ✅ · tsc ✅ · vitest ✅ 1152
  • Playwright Chapter 1 ✅ 73 passed / 1 skipped (2.9m) — includes the new G4 journey (discard → DB abandoned_at set → server status abandoned on a fresh context → strip absent)
  • oracles ✅ clean · integration ✅ 73 passed (includes the new real-HTTP abandon cases)
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629622649
  • Review: task review (deviation on GET /attempts judged correct; 1 Important — the cross-device e2e assertion could fire before a regression surfaced → fixed by asserting the server's own status first; submit's atomic claim made symmetric with abandon's) + scoped re-review clean.

Merge-gate review (2026-08-26)

origin/main merged in (f6ed004 / 6225bba / 025474a — no conflicts; #590's
include_answer_key flip is in the generate handler, clear of these hunks).
Two commits on top, from a /code-review pass whose findings were verified
against the code before being acted on.

The race the PR re-created (the one that mattered)

useQuizHome::discard fired refresh()synchronously beside the abandon
POST. The re-read routinely predated the write and came back in_progress, so
the resume strip stayed hidden only because discoverResumable skips locally
dismissed ids — the localStorage single point of failure G4 exists to retire.
With storage unavailable (private window: session.ts::writeJson no-ops and
the abandon error is swallowed) the discarded quiz came straight back. It also
cost a full home-screen skeleton flash and a re-fired describeConcept per
discard.

Fixed by clearing the resumable slot directly and dropping the global re-read.
Nothing else on quiz home derives from an unfinished attempt — the ranking
and the "missed N last time" join both read COMPLETED attempts only
(proposals.ts) — so the re-read bought nothing and raced the write it meant
to observe. dismissAttempt still runs first, as the across-loads backstop.

Honest failures on both claim losers

  • abandon built a 200 out of an empty dict: (rows or [{}])[0] then
    abandoned_at = current.get(...) or now reported {status: "abandoned", abandoned_at: <now>} for a row that may not exist, with a timestamp nothing
    wrote. An empty re-read is now the same 404 the top of the route gives, and
    the branch never substitutes its own clock for a write it did not make.
  • submit had the same phantom dict (_refuse_if_abandoned({}) is a no-op,
    so a vanished row reported ALREADY_COMPLETED), and the new SELECT sat in a
    window that used to be infallible — losing the claim went straight to a 409.
    A transient PostgREST failure there now degrades back to that 409 rather
    than 500ing an ordinary double-click.
  • _load_owned_attempt and _refuse_if_completed replace the select+404+
    require_self preamble (verbatim in four routes) and the hand-rolled
    already-completed 409 (five copies).

Copy

QUIZ_ATTEMPT_ABANDONED still read "That quiz expired after a day" — copy
from the TTL-sweep era, and a bug to the student who pressed Discard thirty
seconds ago. abandoned_at records when a row closed, never who
closed it, so nothing on the wire can tell the two apart: both the client copy
and the server sentence now cover both. Contract table updated to match.

Test fidelity (why the above were invisible)

  • _Attempts.select / select_with_count ignored their filters and always
    returned the stored row, so no test could fail on a dropped or mis-built
    id=eq. / user_id=eq., and the history-listing assertion was pinned on a
    read nobody made. Both honour filters now, and the listing test asserts what
    the fake was asked for.
  • The fake ignored prefer_return_minimal, but db/connection.py returns []
    in that mode — a claim refactored to minimal would read as LOST on every
    request (every submit 409s, no payout) with the suite green. Modelled, plus
    an explicit assertion that abandon's claim is not minimal.
  • Three vitest assertions were vacuous: resumable was null merely because
    refresh() had bumped the load key. They now pin the ordering (strip gone
    while the POST is still in flight, status still "ready"), the read
    counts, and the storage-unavailable case that is the real regression.
  • The journey's otherDeviceVisit hardcoded both storage keys, re-declared
    the listing type and inlined openQuizHome's body — a key rename would have
    left the other-device leg green and vacuous. Now imports STORAGE_KEY /
    DISMISSED_KEY / AttemptsPage from @/lib (precedent:
    quiz-errors.spec.ts). The post-discard proposal wait got SUBMIT_TIMEOUT
    like every sibling.
  • The G4 origin story was retold at length in six places; one canonical
    telling (routes/quiz.py::abandon_attempt) plus pointers.

Follow-up: #597

POST /attempts/{id}/answer is the last quiz write path guarded by a pre-read
alone. G4 turns "the attempt closed mid-request" from a 24h TTL event into a
button, so a Discard landing between the refusals and the insert records a
graded response on a closed attempt. PostgREST cannot make that cross-table
INSERT conditional atomically — closing it needs a trigger or an RPC, i.e. a
migration — so it ships as an accepted risk with a comment at the insert.
Inert rather than harmful: an abandoned attempt can never be submitted
(submit's claim filters on abandoned_at IS NULL), so the orphan pays out no
mastery, XP or achievement. Filed as #597.

Two notes, deliberately not changes

  • abandoned_at on a TTL-dead row records the discard CLICK, not when the
    attempt went quiet. Noted at the claim. It matters only if a consumer ever
    reads the column as an elapsed time; none does today (the derived status
    only asks whether it is set), and "when did this go quiet" is created_at
    plus the last response — what _attempt_status already computes.
  • The journey's post-click toHaveCount(0) used to witness the skeleton
    rather than the optimistic hide, since refresh() blanked the screen. With
    the race fix there is no skeleton, so the paired
    expect(quiz-proposal).toBeVisible() is what makes it mean "gone from a
    rendered screen"; that is now stated where the assertion is.

One push-back

D2 also asked to drop the or now fallback on abandon's winning claim.
Left in place: there, now is the value the request itself PATCHed, so
preferring the echoed column and falling back to it yield the same timestamp —
it is not a fabrication. The fabricated one was the loser branch's, and that
is gone.

Verification

  • backend: pytest tests/ -q2247 passed / 82 skipped; ruff check .
  • frontend: vitest run1154 passed / 102 files; tsc --noEmit ✅;
    npm run lint ✅ 0 errors
  • npx playwright test e2e/quiz-journeys.spec.ts --list ✅ collects 9 tests
    (the @/lib imports resolve). The journey itself is unrun here — the
    stack is the controller's.

AndresL230and others added 3 commits August 23, 2026 04:13
Discard had no server side: quiz home wrote a localStorage flag and left
the row in_progress until D2's 24h sweep found it, so the resume strip
came back on every other device.
The route writes the same `abandoned_at` stamp `_sweep_abandoned` writes —
the client is just allowed to say "now". Owner-checked like the other
attempt routes, 409 on an already-submitted attempt, and idempotent: a
second call is a 200 no-op carrying the stamp already on the row, so a
retry after a dropped response is free. The write is the same conditional
claim submit uses (`completed_at IS NULL AND abandoned_at IS NULL`), so a
concurrent submit and abandon cannot both win; the loser re-reads and 409s
rather than reporting a discard that never happened.
No schema change — `abandoned_at` has existed since D2. `GET /attempts`
deliberately still LISTS abandoned rows (it is the history reader, D4);
what changes is the derived status, which is what the strip filters on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`useQuizHome::discard` is now the whole gesture: `dismissAttempt` hides the
row in this browser synchronously (so the strip is gone on the next render
whatever the network does), `abandonAttempt` makes it durable, and the
refresh re-reads the world. The screen just states the intent — splitting
the two writes across the component is how they drifted apart in the first
place, with the row hidden here and closed nowhere.
A failed abandon is swallowed with a console.warn rather than surfaced: the
student said discard, so putting the quiz back on screen (or a red toast
over a discard that visibly worked) would both be worse answers, and the
backend's 24h sweep is still the backstop.
The resume strip needed no filter change — `GET /attempts` reports the
derived status, `discoverResumable` already offers only `in_progress` rows,
and `getAttempt` now answers `resumable: false`, so both discovery paths
drop the attempt on a reload and on any other device.
The Chapter 1 resume journey is updated to match and is UNRUN here (the
controller owns the stack): it now asserts `abandoned_at` is set, and
proves the server is what hides the attempt by wiping both quiz storage
keys and reloading — with a positive control first, since "the strip is
absent" means nothing unless the same visit was just shown to offer it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…journey (#537 G4)
Important 1 — the cross-device assertion was one round trip too early.
`otherDeviceVisit` synced on the attempts listing and the proposal card, both
of which land at `setLoaded`; in the REGRESSED case the strip only renders
after `discoverResumable` awaits a further `GET /attempts/{id}`, so the count
check fired while the failure was still in flight. The helper now returns the
listing's parsed body and the journey asserts the SERVER'S status for the
attempt (`in_progress` on the positive control, `abandoned` after the
discard). The strip check stays as corroboration.
M2 — the DB read is synchronized to the abandon POST via the file's own
`page.waitForResponse` idiom, not to the click (the strip vanishes
optimistically, so the click races the write being checked).
M3 — submit's claim now filters `abandoned_at IS NULL` too, symmetric with
abandon's. G4 makes the interleaving reachable from the UI (mid-quiz in one
tab, Discard in another): the old single-null filter let both claims win and
left the row completed AND abandoned, with `_refuse_if_abandoned` only ever a
non-atomic pre-read. A lost claim now re-reads before choosing its 409, so a
discarded attempt answers QUIZ_ATTEMPT_ABANDONED rather than impersonating a
double submit — the frontend maps those codes to different copy.
M4 `import type { Page }`. M5 the R-3 row's "Cost if wrong" now describes the
failed-call fallback, which is all that is left of it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 27f4c739-d806-48a9-b900-ba224b02b98a

📥 Commits

Reviewing files that changed from the base of the PR and between 025474a and b4f1a35.

📒 Files selected for processing (19)
  • backend/routes/quiz.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_quiz_abandon_g4.py
  • backend/tests/test_quiz_routes.py
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb4f1a35Commit Preview URL

Branch Preview URL
Aug 26 2026, 06:40 PM

Merge-gate review of PR #591, backend half.
D2/D3 — both lost-claim branches read the row back as `(rows or [{}])[0]`,
which made a vanished row indistinguishable from an open one: abandon
answered 200 `{status: "abandoned", abandoned_at: <now>}` with a timestamp
nothing had written, and submit ran `_refuse_if_abandoned({})` as a no-op and
reported ALREADY_COMPLETED. An empty re-read is now the 404 the top of each
route already gives, and abandon no longer substitutes its own clock for a
write it did not make.
D3 also put a SELECT in a window that used to be infallible — losing submit's
claim went straight to a 409 with no further I/O. A transient PostgREST
failure there now degrades back to that 409 instead of 500ing an ordinary
double-click.
D10/D11 — the select-by-id + 404 + require_self preamble was verbatim in four
routes; the already-completed 409 was hand-rolled five times. Both are now
`_load_owned_attempt` / `_refuse_if_completed` (+ `_attempt_not_found` /
`_already_completed`).
D4 (backend half) — QUIZ_ATTEMPT_ABANDONED's sentence said "expired", which is
wrong on the path this PR creates. `abandoned_at` records when a row closed,
never who closed it, so the copy now covers both.
D7/D8/D12 — test fidelity, which is why the above were invisible. `_Attempts`
honours its filters on select/select_with_count (a fake that answers every
query cannot fail on a dropped `id=eq.`), models `prefer_return_minimal` (the
real client returns [] in that mode, so a claim refactored to minimal reads as
LOST on every request), and stops duplicating the attempt-row fixtures
byte-for-byte from test_quiz_lifecycle_d. `_factory`'s dead `responses` param
is gone.
D14 — accepted-risk comment at the /answer insert; filed as #597.
D15 — noted at the claim that a TTL-dead row's stamp records the click.
Refs #537, #591, #597
Merge-gate review of PR #591, frontend half.
D1 — `discard` fired `refresh()` synchronously beside the abandon POST, so the
re-read routinely PREDATED the write and came back `in_progress`. The strip
then stayed hidden only because `discoverResumable` skips locally dismissed
ids — the exact localStorage single point of failure G4 exists to retire, and
one that fails outright in a private window (session.ts's writeJson no-ops and
the abandon error is swallowed, so the quiz the student just discarded comes
straight back). It also flashed the whole home screen to a skeleton and
re-fired describeConcept to change one row.
The slot is now cleared directly. Nothing else on quiz home derives from an
unfinished attempt — the ranking and the "missed N last time" join both read
COMPLETED attempts only (proposals.ts) — so the re-read bought nothing and
raced the write it meant to observe.
D9 — the three vitest assertions guarding this passed for the wrong reason:
`resumable` was null merely because refresh() had invalidated the load key.
They now pin the ordering (the strip is gone while the POST is still in
flight, with `status` still "ready"), the read counts, and — the real
regression — that the strip stays gone with localStorage unavailable.
D4 (frontend half) — QUIZ_ATTEMPT_ABANDONED said "That quiz expired after a
day", copy from the TTL-sweep era that reads as a bug to the student who
pressed Discard thirty seconds ago.
D5/D6 — the journey's `otherDeviceVisit` hardcoded both storage keys,
re-declared the listing type and inlined openQuizHome's body; a key rename
left the other-device leg green and vacuous. It now imports STORAGE_KEY /
DISMISSED_KEY / AttemptsPage from @/lib (precedent: quiz-errors.spec.ts) and
calls openQuizHome. The post-discard proposal wait gets SUBMIT_TIMEOUT like
every sibling.
D13 — the G4 origin story was retold at length in six places; one canonical
telling (routes/quiz.py::abandon_attempt) plus pointers.
Refs #537, #591
…G4)
Merge-gate re-review, two blockers + a ride-along.
B1 — the D1 regression test ("hides it on the server's word, not on
localStorage's") passed locally and FAILED CI: `expect(isDismissed("open"))
.toBe(false)` got `true`, because the block never took. jsdom is lockfile
pinned, so the only difference is the Node version — CI is on 22 (ci.yml:86),
this box on 26 — and `vi.spyOn(window.localStorage, "setItem")` intercepts on
one and not the other. (`Storage.prototype` intercepts on neither: jsdom hands
out a Proxy.) The premise silently evaporated, leaving the flagship test for
the discard/refresh race asserting nothing on CI.
Blocked at the ACCESSOR instead — `Object.defineProperty(window,
"localStorage", { get() { throw ... } })` — which is what a browser with site
data disabled actually does and what `session.ts::storage()` already catches
into `null`. No dependency on how jsdom hands out the Storage object.
Restored in a `finally` so a failure cannot leak broken storage into the rest
of the file. Re-verified against the pre-fix `discard`: still fails on the
real assertion (the strip comes back), not on the premise.
B2 — the contract spec's R-3 still described Discard as "…then the abandon
call, then a refresh". There is no refresh. Rewritten, and it now also records
that `AbandonResult.abandoned_at` is nullable and that a 200 can carry
`status: "in_progress"`.
Ride-along — the D8 protection did not cover submit's WINNING claim: every
submit test in test_quiz_abandon_g4.py loses the claim for other reasons, so
all five stay green under a minimal-mode refactor. Asserted at the one test
that wins it, the way abandon's claim already does.
Refs #537, #591
@AndresL230
AndresL230 merged commit d0786e2 into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Two add/add conflicts, both resolved by keeping BOTH sides:
* routes/quiz.py — G4 (#591) appended `abandon_attempt` and this branch
appended `_gamification_block` at the same point in the file. Kept the
route where G4 put it and the helper directly above its caller.
* tests/integration/test_quiz_subcutaneous_db.py — G4's abandon tests and
G8's inline-XP test landed in the same slot. Both kept.
Re-verified against G4's rework of submit: the atomic claim still filters on
both `completed_at is.null` and `abandoned_at is.null`, the lost-claim path
still re-reads to separate 409-completed from 409-abandoned from 404-deleted,
and `_gamification_block` still runs after both `award_xp_safe` and the
`check_achievements` pass. TestTheSnapshotIsTakenLast re-verified by mutation
(hoisting the call above the award turns it red).
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4) - #591

Merged
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon
Aug 26, 2026
Merged

feat(quiz): a real abandon endpoint, wired to Discard (#537 G4)#591
AndresL230 merged 7 commits into
mainfrom
feat/g4-quiz-abandon

Conversation

@AndresL230

@AndresL230AndresL230 commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes the G4 gap in docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
(R-3), whose spec row is updated in this PR to say so.

Refs #537

The problem

"Discard" on the quiz resume strip had no server side. It wrote a
dismissedAttempts entry to localStorage and left the attempt row
in_progress until D2's lazy 24h sweep found it — so the strip came back on
the student's phone, in a second tab, and in this browser the moment storage
was cleared. lib/quiz/session.ts carried the seam as
TODO(#537-followup: abandon endpoint).

The change

POST /api/quiz/attempts/{attempt_id}/abandon (backend/routes/quiz.py).
No schema change — it writes the same abandoned_at stamp _sweep_abandoned
already writes; the client is just allowed to say "now".

  • Owner-checked with require_self, exactly like the other attempt routes
    (404 unknown → 403 not yours, in that order).
  • 409QUIZ_ATTEMPT_ALREADY_COMPLETED on a submitted attempt: the score,
    mastery and XP are paid out and a discard could not take them back.
  • Idempotent: a second call is a 200 no-op returning the stamp already on
    the row, so a retry after a dropped response is free. A row the TTL sweep
    already claimed reports the sweep's timestamp, not a fresh one.
  • The write is submit's conditional claim (completed_at IS NULL AND abandoned_at IS NULL), so a concurrent submit and abandon cannot both win;
    the loser re-reads and 409s instead of reporting a discard that never
    happened.
  • status is produced by _attempt_status, never the literal — this endpoint
    must not be the one place that can disagree with the read paths.

Submit's claim is now symmetric. It filtered on completed_at IS NULL
alone, with _refuse_if_abandoned in front of it as a non-atomic pre-read.
This PR makes that interleaving reachable from the UI — a quiz open mid-question
in one tab, Discard pressed in another — and both claims would win, leaving the
row completed and abandoned. Submit's claim now requires abandoned_at IS NULL too, and a lost claim re-reads before choosing its 409 so a discarded
attempt answers QUIZ_ATTEMPT_ABANDONED instead of impersonating a double
submit (the client maps those codes to different copy).

Client: abandonAttempt in lib/quiz/api.ts, and useQuizHome::discard
as the whole gesture — hide locally (instant), clear the resume slot, abandon
(durable). No refresh: the merge-gate section below has why the original one
had to go. The screen just states the intent. A failed abandon is swallowed with a
console.warn: the student said discard, so resurrecting the quiz would be
the worse answer and the 24h sweep is still the backstop.

The resume strip needed no filter change.GET /attempts reports the
derived status, discoverResumable already offers only in_progress rows,
and getAttempt now answers resumable: false — so both discovery paths drop
the attempt on a reload and on any other device. GET /attempts deliberately
still lists abandoned rows: it is D4's history reader, and filtering it would
break history to fix the strip.

Test evidence

  • backend/tests/test_quiz_abandon_g4.pynew, 14 tests. RED 10 failed
    (route absent) → GREEN. Covers the 200 + stamp, the claim's filters, the
    idempotent repeat (and that it writes nothing twice), a TTL-swept row, 409 on
    completed, the concurrent-submit race, 404, 403 for a foreign attempt
    (real require_self restored — the lane stubs it), and that getAttempt,
    the listing, /answer and /submit all agree afterwards. Its fake models
    the PostgREST filter grammar rather than returning a canned value, so "the
    claim won" and "the claim was refused" are actually distinguishable.
  • The symmetric-claim fix has its own class: a discard stamped in the window
    between submit's pre-read and its claim leaves completed_at null, pays out
    no mastery and 409s as QUIZ_ATTEMPT_ABANDONED — RED without the added
    filter (the claim won and the request ran on past it), GREEN with it — plus a
    guard that an ordinary double submit still reads as
    QUIZ_ATTEMPT_ALREADY_COMPLETED.
  • Backend suite 2237 passed, 81 skipped; ruff check . clean.
  • backend/tests/integration/test_quiz_subcutaneous_db.py — abandon stamps the
    real row (read back through psycopg, not PostgREST), 409 after submit, and
    the IDOR negative extended to abandon. Integration-marked; not run locally.
  • Frontend: RED 7 failed with the implementation stashed → GREEN
    102 files / 1152 tests. tsc --noEmit clean, eslint 0 errors.
  • frontend/e2e/quiz-journeys.spec.ts — the resume journey now waits on the
    abandon POST, asserts abandoned_at is set in the DB, and reloads with both
    quiz storage keys wiped (the other-device case). What it pins there is the
    listing's own status for the attempt — in_progress as a positive
    control, abandoned after the discard — because every rendered consequence
    of that payload is a further round trip away, so a check on the strip alone
    would pass on timing. The strip's absence is kept as corroboration.
    Not run in this branch; the lane owns the stack.

Lane runs (overnight 2026-08-23, local stack under the flock, function mode)

  • ruff ✅ · hermetic pytest ✅ 2237 passed / 81 skipped · eslint ✅ · tsc ✅ · vitest ✅ 1152
  • Playwright Chapter 1 ✅ 73 passed / 1 skipped (2.9m) — includes the new G4 journey (discard → DB abandoned_at set → server status abandoned on a fresh context → strip absent)
  • oracles ✅ clean · integration ✅ 73 passed (includes the new real-HTTP abandon cases)
  • CI browser lane dispatched on this branch: https://github.com/SaplingLearn/Sapling/actions/runs/32629622649
  • Review: task review (deviation on GET /attempts judged correct; 1 Important — the cross-device e2e assertion could fire before a regression surfaced → fixed by asserting the server's own status first; submit's atomic claim made symmetric with abandon's) + scoped re-review clean.

Merge-gate review (2026-08-26)

origin/main merged in (f6ed004 / 6225bba / 025474a — no conflicts; #590's
include_answer_key flip is in the generate handler, clear of these hunks).
Two commits on top, from a /code-review pass whose findings were verified
against the code before being acted on.

The race the PR re-created (the one that mattered)

useQuizHome::discard fired refresh()synchronously beside the abandon
POST. The re-read routinely predated the write and came back in_progress, so
the resume strip stayed hidden only because discoverResumable skips locally
dismissed ids — the localStorage single point of failure G4 exists to retire.
With storage unavailable (private window: session.ts::writeJson no-ops and
the abandon error is swallowed) the discarded quiz came straight back. It also
cost a full home-screen skeleton flash and a re-fired describeConcept per
discard.

Fixed by clearing the resumable slot directly and dropping the global re-read.
Nothing else on quiz home derives from an unfinished attempt — the ranking
and the "missed N last time" join both read COMPLETED attempts only
(proposals.ts) — so the re-read bought nothing and raced the write it meant
to observe. dismissAttempt still runs first, as the across-loads backstop.

Honest failures on both claim losers

  • abandon built a 200 out of an empty dict: (rows or [{}])[0] then
    abandoned_at = current.get(...) or now reported {status: "abandoned", abandoned_at: <now>} for a row that may not exist, with a timestamp nothing
    wrote. An empty re-read is now the same 404 the top of the route gives, and
    the branch never substitutes its own clock for a write it did not make.
  • submit had the same phantom dict (_refuse_if_abandoned({}) is a no-op,
    so a vanished row reported ALREADY_COMPLETED), and the new SELECT sat in a
    window that used to be infallible — losing the claim went straight to a 409.
    A transient PostgREST failure there now degrades back to that 409 rather
    than 500ing an ordinary double-click.
  • _load_owned_attempt and _refuse_if_completed replace the select+404+
    require_self preamble (verbatim in four routes) and the hand-rolled
    already-completed 409 (five copies).

Copy

QUIZ_ATTEMPT_ABANDONED still read "That quiz expired after a day" — copy
from the TTL-sweep era, and a bug to the student who pressed Discard thirty
seconds ago. abandoned_at records when a row closed, never who
closed it, so nothing on the wire can tell the two apart: both the client copy
and the server sentence now cover both. Contract table updated to match.

Test fidelity (why the above were invisible)

  • _Attempts.select / select_with_count ignored their filters and always
    returned the stored row, so no test could fail on a dropped or mis-built
    id=eq. / user_id=eq., and the history-listing assertion was pinned on a
    read nobody made. Both honour filters now, and the listing test asserts what
    the fake was asked for.
  • The fake ignored prefer_return_minimal, but db/connection.py returns []
    in that mode — a claim refactored to minimal would read as LOST on every
    request (every submit 409s, no payout) with the suite green. Modelled, plus
    an explicit assertion that abandon's claim is not minimal.
  • Three vitest assertions were vacuous: resumable was null merely because
    refresh() had bumped the load key. They now pin the ordering (strip gone
    while the POST is still in flight, status still "ready"), the read
    counts, and the storage-unavailable case that is the real regression.
  • The journey's otherDeviceVisit hardcoded both storage keys, re-declared
    the listing type and inlined openQuizHome's body — a key rename would have
    left the other-device leg green and vacuous. Now imports STORAGE_KEY /
    DISMISSED_KEY / AttemptsPage from @/lib (precedent:
    quiz-errors.spec.ts). The post-discard proposal wait got SUBMIT_TIMEOUT
    like every sibling.
  • The G4 origin story was retold at length in six places; one canonical
    telling (routes/quiz.py::abandon_attempt) plus pointers.

Follow-up: #597

POST /attempts/{id}/answer is the last quiz write path guarded by a pre-read
alone. G4 turns "the attempt closed mid-request" from a 24h TTL event into a
button, so a Discard landing between the refusals and the insert records a
graded response on a closed attempt. PostgREST cannot make that cross-table
INSERT conditional atomically — closing it needs a trigger or an RPC, i.e. a
migration — so it ships as an accepted risk with a comment at the insert.
Inert rather than harmful: an abandoned attempt can never be submitted
(submit's claim filters on abandoned_at IS NULL), so the orphan pays out no
mastery, XP or achievement. Filed as #597.

Two notes, deliberately not changes

  • abandoned_at on a TTL-dead row records the discard CLICK, not when the
    attempt went quiet. Noted at the claim. It matters only if a consumer ever
    reads the column as an elapsed time; none does today (the derived status
    only asks whether it is set), and "when did this go quiet" is created_at
    plus the last response — what _attempt_status already computes.
  • The journey's post-click toHaveCount(0) used to witness the skeleton
    rather than the optimistic hide, since refresh() blanked the screen. With
    the race fix there is no skeleton, so the paired
    expect(quiz-proposal).toBeVisible() is what makes it mean "gone from a
    rendered screen"; that is now stated where the assertion is.

One push-back

D2 also asked to drop the or now fallback on abandon's winning claim.
Left in place: there, now is the value the request itself PATCHed, so
preferring the echoed column and falling back to it yield the same timestamp —
it is not a fabrication. The fabricated one was the loser branch's, and that
is gone.

Verification

  • backend: pytest tests/ -q2247 passed / 82 skipped; ruff check .
  • frontend: vitest run1154 passed / 102 files; tsc --noEmit ✅;
    npm run lint ✅ 0 errors
  • npx playwright test e2e/quiz-journeys.spec.ts --list ✅ collects 9 tests
    (the @/lib imports resolve). The journey itself is unrun here — the
    stack is the controller's.

AndresL230and others added 3 commits August 23, 2026 04:13
Discard had no server side: quiz home wrote a localStorage flag and left
the row in_progress until D2's 24h sweep found it, so the resume strip
came back on every other device.
The route writes the same `abandoned_at` stamp `_sweep_abandoned` writes —
the client is just allowed to say "now". Owner-checked like the other
attempt routes, 409 on an already-submitted attempt, and idempotent: a
second call is a 200 no-op carrying the stamp already on the row, so a
retry after a dropped response is free. The write is the same conditional
claim submit uses (`completed_at IS NULL AND abandoned_at IS NULL`), so a
concurrent submit and abandon cannot both win; the loser re-reads and 409s
rather than reporting a discard that never happened.
No schema change — `abandoned_at` has existed since D2. `GET /attempts`
deliberately still LISTS abandoned rows (it is the history reader, D4);
what changes is the derived status, which is what the strip filters on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`useQuizHome::discard` is now the whole gesture: `dismissAttempt` hides the
row in this browser synchronously (so the strip is gone on the next render
whatever the network does), `abandonAttempt` makes it durable, and the
refresh re-reads the world. The screen just states the intent — splitting
the two writes across the component is how they drifted apart in the first
place, with the row hidden here and closed nowhere.
A failed abandon is swallowed with a console.warn rather than surfaced: the
student said discard, so putting the quiz back on screen (or a red toast
over a discard that visibly worked) would both be worse answers, and the
backend's 24h sweep is still the backstop.
The resume strip needed no filter change — `GET /attempts` reports the
derived status, `discoverResumable` already offers only `in_progress` rows,
and `getAttempt` now answers `resumable: false`, so both discovery paths
drop the attempt on a reload and on any other device.
The Chapter 1 resume journey is updated to match and is UNRUN here (the
controller owns the stack): it now asserts `abandoned_at` is set, and
proves the server is what hides the attempt by wiping both quiz storage
keys and reloading — with a positive control first, since "the strip is
absent" means nothing unless the same visit was just shown to offer it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…journey (#537 G4)
Important 1 — the cross-device assertion was one round trip too early.
`otherDeviceVisit` synced on the attempts listing and the proposal card, both
of which land at `setLoaded`; in the REGRESSED case the strip only renders
after `discoverResumable` awaits a further `GET /attempts/{id}`, so the count
check fired while the failure was still in flight. The helper now returns the
listing's parsed body and the journey asserts the SERVER'S status for the
attempt (`in_progress` on the positive control, `abandoned` after the
discard). The strip check stays as corroboration.
M2 — the DB read is synchronized to the abandon POST via the file's own
`page.waitForResponse` idiom, not to the click (the strip vanishes
optimistically, so the click races the write being checked).
M3 — submit's claim now filters `abandoned_at IS NULL` too, symmetric with
abandon's. G4 makes the interleaving reachable from the UI (mid-quiz in one
tab, Discard in another): the old single-null filter let both claims win and
left the row completed AND abandoned, with `_refuse_if_abandoned` only ever a
non-atomic pre-read. A lost claim now re-reads before choosing its 409, so a
discarded attempt answers QUIZ_ATTEMPT_ABANDONED rather than impersonating a
double submit — the frontend maps those codes to different copy.
M4 `import type { Page }`. M5 the R-3 row's "Cost if wrong" now describes the
failed-call fallback, which is all that is left of it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitaiBot commented Aug 23, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 12 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 27f4c739-d806-48a9-b900-ba224b02b98a

📥 Commits

Reviewing files that changed from the base of the PR and between 025474a and b4f1a35.

📒 Files selected for processing (19)
  • backend/routes/quiz.py
  • backend/tests/integration/test_quiz_subcutaneous_db.py
  • backend/tests/test_quiz_abandon_g4.py
  • backend/tests/test_quiz_routes.py
  • docs/frontend-testids.md
  • docs/superpowers/specs/2026-08-22-quiz-frontend-contract.md
  • frontend/e2e/quiz-journeys.spec.ts
  • frontend/src/components/quiz/home/QuizHome.test.tsx
  • frontend/src/components/quiz/home/QuizHome.tsx
  • frontend/src/lib/quiz/api.test.ts
  • frontend/src/lib/quiz/api.ts
  • frontend/src/lib/quiz/errors.test.ts
  • frontend/src/lib/quiz/errors.ts
  • frontend/src/lib/quiz/machine.ts
  • frontend/src/lib/quiz/session.ts
  • frontend/src/lib/quiz/types.ts
  • frontend/src/lib/quiz/useQuizHome.test.ts
  • frontend/src/lib/quiz/useQuizHome.ts
  • frontend/src/lib/quiz/useQuizSession.test.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@supabase

supabaseBot commented Aug 23, 2026

Copy link
Copy Markdown

This pull request has been ignored for the connected project ybgqdonkoqftwrmweuyv because there are no changes detected in supabase directory. You can change this behaviour in Project Integrations Settings ↗︎.


Preview Branches by Supabase.
Learn more about Supabase Branching ↗︎.

@cloudflare-workers-and-pages

cloudflare-workers-and-pagesBot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying with Cloudflare Workers Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

StatusNameLatest CommitPreview URLUpdated (UTC)
✅ Deployment successful!
View logs
frontend-stagingb4f1a35Commit Preview URL

Branch Preview URL
Aug 26 2026, 06:40 PM

Merge-gate review of PR #591, backend half.
D2/D3 — both lost-claim branches read the row back as `(rows or [{}])[0]`,
which made a vanished row indistinguishable from an open one: abandon
answered 200 `{status: "abandoned", abandoned_at: <now>}` with a timestamp
nothing had written, and submit ran `_refuse_if_abandoned({})` as a no-op and
reported ALREADY_COMPLETED. An empty re-read is now the 404 the top of each
route already gives, and abandon no longer substitutes its own clock for a
write it did not make.
D3 also put a SELECT in a window that used to be infallible — losing submit's
claim went straight to a 409 with no further I/O. A transient PostgREST
failure there now degrades back to that 409 instead of 500ing an ordinary
double-click.
D10/D11 — the select-by-id + 404 + require_self preamble was verbatim in four
routes; the already-completed 409 was hand-rolled five times. Both are now
`_load_owned_attempt` / `_refuse_if_completed` (+ `_attempt_not_found` /
`_already_completed`).
D4 (backend half) — QUIZ_ATTEMPT_ABANDONED's sentence said "expired", which is
wrong on the path this PR creates. `abandoned_at` records when a row closed,
never who closed it, so the copy now covers both.
D7/D8/D12 — test fidelity, which is why the above were invisible. `_Attempts`
honours its filters on select/select_with_count (a fake that answers every
query cannot fail on a dropped `id=eq.`), models `prefer_return_minimal` (the
real client returns [] in that mode, so a claim refactored to minimal reads as
LOST on every request), and stops duplicating the attempt-row fixtures
byte-for-byte from test_quiz_lifecycle_d. `_factory`'s dead `responses` param
is gone.
D14 — accepted-risk comment at the /answer insert; filed as #597.
D15 — noted at the claim that a TTL-dead row's stamp records the click.
Refs #537, #591, #597
Merge-gate review of PR #591, frontend half.
D1 — `discard` fired `refresh()` synchronously beside the abandon POST, so the
re-read routinely PREDATED the write and came back `in_progress`. The strip
then stayed hidden only because `discoverResumable` skips locally dismissed
ids — the exact localStorage single point of failure G4 exists to retire, and
one that fails outright in a private window (session.ts's writeJson no-ops and
the abandon error is swallowed, so the quiz the student just discarded comes
straight back). It also flashed the whole home screen to a skeleton and
re-fired describeConcept to change one row.
The slot is now cleared directly. Nothing else on quiz home derives from an
unfinished attempt — the ranking and the "missed N last time" join both read
COMPLETED attempts only (proposals.ts) — so the re-read bought nothing and
raced the write it meant to observe.
D9 — the three vitest assertions guarding this passed for the wrong reason:
`resumable` was null merely because refresh() had invalidated the load key.
They now pin the ordering (the strip is gone while the POST is still in
flight, with `status` still "ready"), the read counts, and — the real
regression — that the strip stays gone with localStorage unavailable.
D4 (frontend half) — QUIZ_ATTEMPT_ABANDONED said "That quiz expired after a
day", copy from the TTL-sweep era that reads as a bug to the student who
pressed Discard thirty seconds ago.
D5/D6 — the journey's `otherDeviceVisit` hardcoded both storage keys,
re-declared the listing type and inlined openQuizHome's body; a key rename
left the other-device leg green and vacuous. It now imports STORAGE_KEY /
DISMISSED_KEY / AttemptsPage from @/lib (precedent: quiz-errors.spec.ts) and
calls openQuizHome. The post-discard proposal wait gets SUBMIT_TIMEOUT like
every sibling.
D13 — the G4 origin story was retold at length in six places; one canonical
telling (routes/quiz.py::abandon_attempt) plus pointers.
Refs #537, #591
…G4)
Merge-gate re-review, two blockers + a ride-along.
B1 — the D1 regression test ("hides it on the server's word, not on
localStorage's") passed locally and FAILED CI: `expect(isDismissed("open"))
.toBe(false)` got `true`, because the block never took. jsdom is lockfile
pinned, so the only difference is the Node version — CI is on 22 (ci.yml:86),
this box on 26 — and `vi.spyOn(window.localStorage, "setItem")` intercepts on
one and not the other. (`Storage.prototype` intercepts on neither: jsdom hands
out a Proxy.) The premise silently evaporated, leaving the flagship test for
the discard/refresh race asserting nothing on CI.
Blocked at the ACCESSOR instead — `Object.defineProperty(window,
"localStorage", { get() { throw ... } })` — which is what a browser with site
data disabled actually does and what `session.ts::storage()` already catches
into `null`. No dependency on how jsdom hands out the Storage object.
Restored in a `finally` so a failure cannot leak broken storage into the rest
of the file. Re-verified against the pre-fix `discard`: still fails on the
real assertion (the strip comes back), not on the premise.
B2 — the contract spec's R-3 still described Discard as "…then the abandon
call, then a refresh". There is no refresh. Rewritten, and it now also records
that `AbandonResult.abandoned_at` is nullable and that a 200 can carry
`status: "in_progress"`.
Ride-along — the D8 protection did not cover submit's WINNING claim: every
submit test in test_quiz_abandon_g4.py loses the claim for other reasons, so
all five stay green under a minimal-mode refactor. Asserted at the one test
that wins it, the way abandon's claim already does.
Refs #537, #591
@AndresL230
AndresL230 merged commit d0786e2 into mainAug 26, 2026
8 checks passed
AndresL230 added a commit that referenced this pull request Aug 26, 2026
Two add/add conflicts, both resolved by keeping BOTH sides:
* routes/quiz.py — G4 (#591) appended `abandon_attempt` and this branch
appended `_gamification_block` at the same point in the file. Kept the
route where G4 put it and the helper directly above its caller.
* tests/integration/test_quiz_subcutaneous_db.py — G4's abandon tests and
G8's inline-XP test landed in the same slot. Both kept.
Re-verified against G4's rework of submit: the atomic claim still filters on
both `completed_at is.null` and `abandoned_at is.null`, the lost-claim path
still re-reads to separate 409-completed from 409-abandoned from 404-deleted,
and `_gamification_block` still runs after both `award_xp_safe` and the
`check_achievements` pass. TestTheSnapshotIsTakenLast re-verified by mutation
(hoisting the call above the award turns it red).
AndresL230 added a commit that referenced this pull request Aug 28, 2026
Two conflicts, both in the files #590 and this branch both edited.
models/__init__.py: #590 flipped `include_answer_key`'s default to false and
replaced its comment with the canonical lifecycle account; this branch added
`source_attempt_id` / `missed_question_hashes` and the validator that ties
them together. Kept both — the flipped default and its prose verbatim, the
G5 fields ahead of it, the validator after.
routes/quiz.py: this branch relocated the agent-call except ladder out of
`generate_quiz` into `_generate_or_502` so the re-serve branch can catch a
failed top-up and still serve what it recovered; main (#592) added
`has_graph=True` to the same call. Kept the relocation and carried the new
argument (and its rationale) into the helper. #590's keyless projection,
#591's attempt helpers and #592's hoisted lookups merged cleanly and are
untouched — re-served questions still flow through the single
`_client_questions` call, which strips `question_hash`/`provenance` on both
the keyless and the opt-in keyed branch.
Two G5 tests were written against the pre-flip default and are re-pointed:
the keyed-projection test now opts in explicitly, and the keyless one adopts
main's shared `assert_keyless_projection` fixture, which grounds the check in
the answer key actually stored for the attempt. That fixture then caught G5's
conditional top-level `source` block, so the shared key literal now names
optional fields instead of requiring every response to carry them. The F5
assertions narrow to `quiz_reserve.missed_questions`: #592 makes an unrelated
reporter fire on the same request under these mocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@AndresL230