mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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>
101 lines
4.5 KiB
Markdown
101 lines
4.5 KiB
Markdown
# 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.
|