Quiz app — favourites, statistics, exam flags, community stats
Adds four server-rendered extensions to the shipped quiz app: saved questions, idempotent exam flags, newest-answer statistics, and an always-visible community evidence block. The design preserves the v1 security and no-JavaScript constraints while adding only one table and one column.
- Predecessor 2026-08-08-quiz-app-design.md
- Status Approved
Summary
Four focused additions turn the shipped quiz app's existing question and attempt data into personal study tools: saved questions, exam review flags, newest-answer statistics, and clearer community evidence. The design keeps the v1 server-rendered security model intact and costs one table plus one column.
Favourites
one tableExplicit Save/Unsave actions, a favourites-only practice filter, and stars on practice and exam results.
Exam flags
one columnMark a live-exam question for later review without submitting its answer.
Statistics
no schemaProgress, overall accuracy, and actionable weak tags based on the newest answer per question.
Community stats
display onlyAlways expose imported evidence on review screens, including agreement and missing-data cases.
Binding constraints
All predecessor constraints continue to apply. These four directly shape the implementation.
No client JavaScript
hard constraintEvery state change is POST → 303 redirect → GET, and every page works with JavaScript disabled.
Exact-Origin CSRF
inheritedExisting middleware remains the only CSRF defence. New POST handlers add nothing and weaken nothing.
Ownership-only authorization
404, never 403Another user's exam remains undiscoverable. Exam flag and result actions run only after existing shape and ownership guards.
No live-exam answers
permanent boundaryloadRedactedQuestion continues to omit answers, explanations, why-wrong, study notes, provenance, and community evidence.
Schema and migration
Add migrations/002_favourites_flags.sql. The versioned schema_migration runner applies files in filename order, so shipping requires no runner change; pnpm run migrate picks it up.
-- 002_favourites_flags.sql
CREATE TABLE favourite (
user_id uuid NOT NULL REFERENCES "user" (id) ON DELETE CASCADE,
question_id uuid NOT NULL REFERENCES question (id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, question_id)
);
ALTER TABLE attempt ADD COLUMN flagged boolean NOT NULL DEFAULT false;
ALTER TABLE attempt ADD CONSTRAINT attempt_flag_exam_only
CHECK (NOT flagged OR exam_id IS NOT NULL);
The composite key is the favourite design
(user_id, question_id) is both the uniqueness rule and the index for “what did I favourite?”
It prevents duplicate saves and supports the only required lookup without extra machinery.
Rejected: a separate id plus a separate unique index; both would be dead weight.
Flags belong only to exam attempts
attempt_flag_exam_only rejects flagged = true unless exam_id IS NOT NULL.
Practice uses favourites. Encoding the boundary in the schema makes future invalid writes fail loudly.
NOT NULL DEFAULT false avoids a backfill for existing attempts.
Feature 1 — Favourites
Explicit actions, not a toggle
Forms post action=favourite or action=unfavourite, based on server-rendered state.
Both operations are idempotent (幂等): retries, double taps, and stale pages converge on the requested state.
Rejected: read-then-write toggle semantics, which can flip the wrong way when a request is duplicated.
-- action=favourite
INSERT INTO favourite (user_id, question_id)
SELECT $1, q.id FROM question q WHERE q.slug = $2 AND q.retired_at IS NULL
ON CONFLICT DO NOTHING;
-- action=unfavourite
DELETE FROM favourite f
USING question q
WHERE q.slug = $2 AND f.question_id = q.id AND f.user_id = $1;
Surfaces and redirects
| Page | Required change | Redirect behavior |
|---|---|---|
practice/[slug].astro | Star in its own form, separate from the answer form; saving must not require answering. | 303 to here, preserving filters. |
exam/[id]/result.astro | One star form per question; this page gains its first POST handler. Each form carries hidden slug and action. | 303 to the result URL plus #q-<slug>; each question <article> gets the matching id. |
practice/index.astro | Add a Favourites only checkbox to the existing filter form. | ?fav=1 travels with examType and tag. |
Filtering and reading state
listPracticeQuestions, nextPracticeSlug, and randomPracticeSlug gain favouritesOnly?: boolean. Carrying the parameter through question and “Next question” links keeps a favourites-filtered session inside favourites.
AND ($4::boolean IS NOT TRUE
OR EXISTS (SELECT 1 FROM favourite f
WHERE f.user_id = $1 AND f.question_id = q.id))
IS NOT TRUE makes both NULL and false mean “no filter”, matching the other optional filters. getPracticeQuestion returns favourite: boolean. getExamResult avoids N+1 reads with one roster query:
SELECT question_id FROM favourite
WHERE user_id = $1 AND question_id = ANY($2)
The result is collected into a Set for per-question rendering.
Empty state and language
/practice?fav=1 reuses the existing “No questions match that filter” card and adds: “You have not saved any questions yet. Open a question and tap Save.”
- Schema, code, tests
- favourite: table and function names, plus query parameter
fav - User interface
- save / star: the expected friendly action and icon
- Domain reference
CONTEXT.md→ Quiz → Favourite
Feature 2 — Flag during an exam
The v1 numbered grid solves navigation but not intent: it lets users return to a question without marking which questions need review. Flags add that missing state and surface it in the grid.
The flag form is separate from the answer form
exam/[id]/[position].astro renders a small Flag/Unflag form below the existing answer-and-advance form.
A second submit button inside the answer form would also submit selected options, preventing the main use case: flagging before choosing an answer.
The existing POST handler gains an allowlisted action branch, following practice/[slug].astro.
UPDATE attempt SET flagged = $4
WHERE exam_id = $1 AND user_id = $2 AND position = $3;
The handler maps only action=flag and action=unflag to server-side booleans; it never accepts arbitrary client-supplied flagged. position must pass isValidPosition before reaching the integer parameter. A successful mutation redirects back to the same question rather than navigating.
Finished-exam race
- Validate and establish ownership
Run existing exam-id, ownership, and
isValidPositionchecks before mutation. - Open a transaction and lock the exam
Lock the owned row with
SELECT ... FOR UPDATE, using the same serialization boundary as answer submission. - Check lifecycle under the lock
Require
submitted_at IS NULLandtransaction_timestamp() < deadline_at. Never useDate.now(). - Resolve closed exams
Submitted exams redirect to results unchanged. Expired-but-unstamped exams are finalized through the existing lifecycle helper.
- Write the explicit state
Only an active exam reaches
UPDATE attempt SET flagged = $4, then redirects back to the same position.
Surfaces and accessibility
| Surface | Data | Presentation | Accessibility |
|---|---|---|---|
| Numbered exam grid | getExamProgress adds flagged per cell. | Unanswered flags use amber like btn-hint; answered + flagged keeps teal and gains an amber ring. | Stateful aria-label, e.g. “Question 12, answered, flagged”. |
| Exam result | AttemptRow gains flagged, populated by mapAttempt. | A small text badge says Flagged. | Text accompanies color; no icon-only signal. |
Feature 3 — Statistics screen
/stats answers three questions: how much of the bank have I attempted, how am I doing overall, and what should I practise next?
Only the newest answer to each question counts
Practice and exam attempts share one pool; the newest answered attempt wins, regardless of mode.
Counting every retry would let one hard question dominate totals and make accuracy fall while the user learns. Counting only correct attempts would let retries inflate the score.
Unanswered open attempts do not count. A finalized blank exam roster row does count as a wrong answer, matching the score of the completed exam.
WITH latest AS (
SELECT DISTINCT ON (a.question_id) a.question_id, a.is_correct
FROM attempt a
WHERE a.user_id = $1 AND a.answered_at IS NOT NULL
ORDER BY a.question_id, a.answered_at DESC, a.created_at DESC, a.id DESC
)
answered_at- Determines semantic recency.
created_at- Resolves the realistic tie where two modes answer at the same timestamp precision.
id- Makes synthetic and manually seeded ties deterministic.
- Retired questions
- Excluded everywhere by joining
questionwithretired_at IS NULL; attempted can never exceed bank size.
Three blocks
| Block | Example | Source |
|---|---|---|
| Progress | 92 of 140 questions attempted · 48 never seen | latest vs non-retired bank count |
| Overall | 68 correct of 92 · 74% | latest |
| Weak tags | s3 — 7 wrong of 14 | latest × unnest(tags) |
Run the two exported reads in Promise.all: getStatsTotals(userId) and getWeakTags(userId, limit). The weak-tag limit is 15.
-- getStatsTotals
WITH latest AS (...)
SELECT
(SELECT count(*) FROM question WHERE retired_at IS NULL) AS bank,
count(*) AS attempted,
count(*) FILTER (WHERE l.is_correct) AS correct
FROM latest l
JOIN question q ON q.id = l.question_id AND q.retired_at IS NULL;
-- getWeakTags
WITH latest AS (...)
SELECT t.tag,
count(*) FILTER (WHERE NOT l.is_correct) AS wrong,
count(*) AS attempted
FROM latest l
JOIN question q ON q.id = l.question_id AND q.retired_at IS NULL
CROSS JOIN LATERAL unnest(q.tags) AS t(tag)
GROUP BY t.tag
HAVING count(*) FILTER (WHERE NOT l.is_correct) > 0
ORDER BY wrong DESC, t.tag
LIMIT $2;
Weak tags rank by wrong count, not accuracy
ORDER BY wrong DESC, t.tag; omit zero-wrong tags.
With 447 distinct tags across 140 questions, accuracy would elevate many one-question tags at 0%. Wrong count naturally suppresses rare tags without an arbitrary minimum-attempt threshold, and alphabetical ties keep the list stable.
Rejected: accuracy ranking and “minimum 3 attempts”, which would hide a genuinely weak new topic.
A user with no answered attempts sees “Answer a few questions and your progress will show up here.” plus a link to /practice, not three zeroes. index.astro gains a Statistics link below Practice; QuizLayout keeps its two-item header.
Feature 4 — Community stats block
ExplanationPanel.astro stops collapsing imported evidence into a sentence shown only on disagreement. On answered practice and exam-result review screens, it exposes agreement, disagreement, and missing data distinctly.
| Displayed field | Existing source | Missing value |
|---|---|---|
| Crowd-preferred | community_vote_label | — |
| Crowd agreement | community_pct (0–100) | —, distinct from 0% |
| Published key | pdf_suggested_label | — |
all emptyHide the entire block.0label mismatchCompare imported label with concatenated correct labels such asBC; render amber plus textdisagrees.41agreementStill render the block so agreement is distinguishable from no imported data.3
The value 41 is the number of imported rows marked “PDF suggested answer looks wrong”; 3 denotes the three fields rendered, not a score.
Testing and success criteria
Extend the existing integration files rather than creating new ones: tests/integration/queries.test.ts, tests/integration/practice-pages.test.ts, and tests/integration/exam-pages.test.ts.
| Integration case | Contract protected |
|---|---|
| Favourite twice → one row; unfavourite twice → no error | Explicit-action idempotency |
| Favourite an unknown slug → no row, no throw | Text slug avoids malformed-UUID crashes |
favouritesOnly returns only saved questions | Practice filter correctness |
| Flag twice and unflag twice converge; grid reports each state | Flag idempotency and presentation data |
| Flag racing exam finalisation cannot write after submission | Shared exam-row lock |
flagged = true on a practice attempt is rejected | attempt_flag_exam_only |
| Wrong answer, then right retry, counts once as correct | Newest-answer rule most likely to regress silently |
| Finalized blank exam answer replaces older correct practice answer | Cross-mode statistics semantics |
Equal answered_at values choose a stable newest answer | Deterministic tie-breakers |
| Retired question excluded from bank, attempted, and tags | Bank-size invariant |
| Weak tags sort by wrong count and omit zero-wrong tags | Actionable ranking |
| Migration 002 applies cleanly to a v1 database | Existing migrate.test.ts pattern |
JavaScript-disabled end to end
- Favourites ·
e2e/practice.spec.tsSave from practice, enable Favourites only, see the question, unsave it, and see the empty state.
- Flags ·
e2e/exam.spec.tsFlag mid-exam, see the numbered-grid mark, submit, and see the result's
Flaggedbadge. - Statistics · new
e2e/stats.spec.tsAnswer a question, render stats, and follow a weak-tag row to a filtered practice list.
Out of scope
Exam history
defer to phase 3Date, score, time, and links to prior results wait with the predecessor's immutable question revisions.
Statistics expansion
deferred- Per-exam-type filter until a second exam type exists
- Streaks, charts, and trends over time
- Favourites count as a second entry point
Favourites organization
flat listNo notes, folders, ordering, or recently-saved view.
Live-exam expansion
excluded- No flag in practice; favourites cover “come back to this”.
- No community stats in a live exam—permanently, because they are an answer key.