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

View source markdown ↗ generated by codex · diagrams mermaid

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.

  • StatusApproved · 10 August 2026
  • ThemeYour own data about questions
  • Schema costOne table + one column
  • Runtime modelServer-rendered; JavaScript optional
  • Predecessor2026-08-08-quiz-app-design.md

Favourites

one table

Explicit Save/Unsave actions, a favourites-only practice filter, and stars on practice and exam results.

Exam flags

one column

Mark a live-exam question for later review without submitting its answer.

Statistics

no schema

Progress, overall accuracy, and actionable weak tags based on the newest answer per question.

Community stats

display only

Always 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 constraint

Every state change is POST → 303 redirect → GET, and every page works with JavaScript disabled.

Exact-Origin CSRF

inherited

Existing middleware remains the only CSRF defence. New POST handlers add nothing and weaken nothing.

Ownership-only authorization

404, never 403

Another user's exam remains undiscoverable. Exam flag and result actions run only after existing shape and ownership guards.

No live-exam answers

permanent boundary

loadRedactedQuestion 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

PageRequired changeRedirect behavior
practice/[slug].astroStar in its own form, separate from the answer form; saving must not require answering.303 to here, preserving filters.
exam/[id]/result.astroOne 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.astroAdd 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

  1. Validate and establish ownership

    Run existing exam-id, ownership, and isValidPosition checks before mutation.

  2. Open a transaction and lock the exam

    Lock the owned row with SELECT ... FOR UPDATE, using the same serialization boundary as answer submission.

  3. Check lifecycle under the lock

    Require submitted_at IS NULL and transaction_timestamp() < deadline_at. Never use Date.now().

  4. Resolve closed exams

    Submitted exams redirect to results unchanged. Expired-but-unstamped exams are finalized through the existing lifecycle helper.

  5. Write the explicit state

    Only an active exam reaches UPDATE attempt SET flagged = $4, then redirects back to the same position.

Flag writes and exam finalisation share one row lock, preventing a flag from landing after submission
Flag writes and exam finalisation share one row lock, preventing a flag from landing after submission.

Surfaces and accessibility

SurfaceDataPresentationAccessibility
Numbered exam gridgetExamProgress 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 resultAttemptRow 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 question with retired_at IS NULL; attempted can never exceed bank size.

Three blocks

BlockExampleSource
Progress92 of 140 questions attempted · 48 never seenlatest vs non-retired bank count
Overall68 correct of 92 · 74%latest
Weak tagss3 — 7 wrong of 14latest × 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 fieldExisting sourceMissing value
Crowd-preferredcommunity_vote_label
Crowd agreementcommunity_pct (0–100), distinct from 0%
Published keypdf_suggested_label
  • all emptyHide the entire block.0
  • label mismatchCompare imported label with concatenated correct labels such as BC; render amber plus text disagrees.41
  • agreementStill 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 caseContract protected
Favourite twice → one row; unfavourite twice → no errorExplicit-action idempotency
Favourite an unknown slug → no row, no throwText slug avoids malformed-UUID crashes
favouritesOnly returns only saved questionsPractice filter correctness
Flag twice and unflag twice converge; grid reports each stateFlag idempotency and presentation data
Flag racing exam finalisation cannot write after submissionShared exam-row lock
flagged = true on a practice attempt is rejectedattempt_flag_exam_only
Wrong answer, then right retry, counts once as correctNewest-answer rule most likely to regress silently
Finalized blank exam answer replaces older correct practice answerCross-mode statistics semantics
Equal answered_at values choose a stable newest answerDeterministic tie-breakers
Retired question excluded from bank, attempted, and tagsBank-size invariant
Weak tags sort by wrong count and omit zero-wrong tagsActionable ranking
Migration 002 applies cleanly to a v1 databaseExisting migrate.test.ts pattern

JavaScript-disabled end to end

  1. Favourites · e2e/practice.spec.ts

    Save from practice, enable Favourites only, see the question, unsave it, and see the empty state.

  2. Flags · e2e/exam.spec.ts

    Flag mid-exam, see the numbered-grid mark, submit, and see the result's Flagged badge.

  3. Statistics · new e2e/stats.spec.ts

    Answer a question, render stats, and follow a weak-tag row to a filtered practice list.

Out of scope

Exam history

defer to phase 3

Date, 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 list

No 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.