Files
Sauci/docs/quiz.md
T
jjbruntonandClaude Fable 5 6523ddeb1c feat(quiz): couples quiz with shareable watermarked results card
Replaces the notify-me quiz stub with a full loop: quiz sessions per
couple, answer-and-guess flow, dual-direction scoring, and a score-only
share card (no question or answer content) captured via ViewShot with
Sauci branding.

API: migration 0019 (quiz_questions, quiz_sessions, quiz_answers, 12
seeded questions), new quiz domain with session start / current /
answers / result routes, couple isolation, route + integration +
scoring unit tests.

Mobile: src/features/quiz feature (intro, per-question self+guess flow,
waiting-for-partner nudge, results with breakdown), quizStore with
sign-out clearing, QuizShareModal following SharePreviewModal, discovery
QuizTile, analytics events, and tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:09:19 +01:00

4.5 KiB

Couples quiz

Maintained reference for the shareable "how well do you know each other?" quiz: a couple works through the same set of questions, each partner answers about themselves and guesses about the other, and the pair only sees a score once both have finished.

Ownership

Concern Location
Schema and seed content apps/api/drizzle/0019_quiz.sql
HTTP contract apps/api/src/domains/quiz/routes.ts
Session and scoring rules apps/api/src/domains/quiz/repository.ts
Pure scoring function apps/api/src/domains/quiz/service.ts (computeQuizScore)

Session lifecycle

POST /v1/quiz/sessions ──► active session (10 questions, question order fixed at creation)
   │
   ├── each partner submits a full answer set (self + guess per question)
   │
   └── once BOTH partners have a complete set ──► session completes, score is computed

Only one active session exists per couple at a time (quiz_sessions_one_active_per_couple_idx, a partial unique index on couple_id where status = 'active'). Starting a quiz while one is already active returns that same session (200) instead of creating a second one; two simultaneous starts race safely because the partial unique index, not application locking, is the source of truth.

Answers are submitted as one complete batch per call (quiz_answers, upsert on (session_id, user_id, question_id)). A submission must cover every question in the session exactly once, with every index inside that question's option list, or it is rejected with 400 invalid_answers. A session that has already completed rejects further submissions with 409 session_completed.

Scoring

Each question contributes up to two hits: partner A's guess about B counts if it matches B's self-answer, and separately B's guess about A counts if it matches A's self-answer. score_percent = round(100 * hits / (2 * question_count)). computeQuizScore in service.ts is the pure, unit-tested implementation; the repository only assembles the per-question pairs and persists the result.

Couple isolation

Every session and result lookup is scoped to the caller's own couple. A session ID that belongs to another couple returns 404 session_not_found, never a hint that it exists elsewhere. Starting a quiz requires both a couple (409 no_couple) and a paired partner (409 partner_required).

HTTP contract

Method Path Notes
POST /v1/quiz/sessions 201 on a new session, 200 when an active one already exists
GET /v1/quiz/sessions/current { session: SessionPayload | null }, most recent session for the couple
POST /v1/quiz/sessions/:sessionId/answers { answers: [{ question_id, self_index, guess_index }] }
GET /v1/quiz/sessions/:sessionId/result 409 session_not_completed before both partners finish

SessionPayload never includes the partner's raw answers before completion; per-question partner detail (partner_self_index, partner_guess_index, partner_guessed_right) is only exposed by the /result endpoint of a completed session.

Tests

  • apps/api/test/quiz.service.test.ts: unit tests for computeQuizScore
  • apps/api/test/quiz.routes.test.ts: HTTP contract and error mapping
  • apps/api/test/quiz.integration.test.ts: real PostgreSQL, covering the full happy path with a hand-computed score, idempotent session start, couple isolation, incomplete answer set rejection, and result blocked before completion

Mobile client

Concern Location
Feature screens, hooks, API client apps/mobile/src/features/quiz/
Zustand store (generation-counter pattern, cleared on sign-out) apps/mobile/src/store/quizStore.ts
Discovery entry point apps/mobile/src/components/discovery/QuizTile.tsx
Route wrapper apps/mobile/app/(app)/quiz.tsx

The screen walks not-paired, intro, answering (one question at a time, self then guess), waiting-on-partner, and results states off useQuizScreen. Answers are held client-side per question until every question in the session has both a self and a guess index, then submitted as the one batch the API expects; the store never calls the answers endpoint more than once per session. Results render a shareable card (QuizResultsShareCard / QuizShareModal, following the SharePreviewModal capture pattern) carrying only the match score and Sauci branding, never question text or answers, so a screenshot cannot leak either partner's content.

Not in this version

Question authoring tooling in the admin app is out of scope for this change.