From 45d24911fb359e8ca7fc672aa27af96b28a1eea5 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 30 Aug 2026 18:21:55 +0100 Subject: [PATCH] feat(quiz): variety in quiz question selection Expand the seeded pool from 12 to 36 questions (migration 0020) and select 10 per session preferring questions the couple has never seen, then least recently used, with random tiebreaks, so repeat quizzes stop serving the same set. Co-Authored-By: Claude Fable 5 --- apps/api/drizzle/0020_quiz_question_pool.sql | 53 ++++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 +++ apps/api/src/domains/quiz/repository.ts | 17 ++++++- apps/api/test/quiz.integration.test.ts | 45 ++++++++++++++++- docs/quiz.md | 16 ++++-- 5 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 apps/api/drizzle/0020_quiz_question_pool.sql diff --git a/apps/api/drizzle/0020_quiz_question_pool.sql b/apps/api/drizzle/0020_quiz_question_pool.sql new file mode 100644 index 0000000..548e85d --- /dev/null +++ b/apps/api/drizzle/0020_quiz_question_pool.sql @@ -0,0 +1,53 @@ +-- The seeded quiz pool of 12 questions was small enough that repeat sessions for +-- the same couple felt almost identical. Widen the pool so the never-used-first +-- selection strategy in the repository has enough fresh material to draw from. +INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES + ('What is your ideal pet?', 'What is your partner''s ideal pet?', + ARRAY['A dog', 'A cat', 'Something low-maintenance', 'No pets, thanks'], 130), + ('What is your go-to coffee or tea order?', 'What is your partner''s go-to coffee or tea order?', + ARRAY['Strong and simple', 'Something sweet and milky', 'Herbal or decaf', 'I skip it entirely'], 140), + ('What feature would your dream house have?', 'What feature would your partner''s dream house have?', + ARRAY['A big kitchen', 'A garden or outdoor space', 'A cosy reading nook', 'A home cinema or games room'], 150), + ('What is your favourite season?', 'What is your partner''s favourite season?', + ARRAY['Spring', 'Summer', 'Autumn', 'Winter'], 160), + ('On a road trip, what role do you take?', 'On a road trip, what role does your partner take?', + ARRAY['Driver', 'Navigator', 'Playlist curator', 'Snack manager'], 170), + ('When a cooking attempt goes wrong, what do you do?', 'When a cooking attempt goes wrong, what does your partner do?', + ARRAY['Laugh it off and order in', 'Try to fix it anyway', 'Start again from scratch', 'Blame the recipe'], 180), + ('What is your worst phone habit?', 'What is your partner''s worst phone habit?', + ARRAY['Scrolling too long', 'Never replying quickly', 'Too many notifications on', 'Checking it during conversations'], 190), + ('What makes your ideal birthday?', 'What makes your partner''s ideal birthday?', + ARRAY['A big party with friends', 'A quiet day together', 'A surprise outing', 'Just a nice meal and cake'], 200), + ('How do you behave during a board game?', 'How does your partner behave during a board game?', + ARRAY['Fiercely competitive', 'Relaxed, just for fun', 'Overly strategic', 'Happy to lose as long as it is fun'], 210), + ('What is your packing style before a trip?', 'What is your partner''s packing style before a trip?', + ARRAY['Packed days in advance', 'Careful list-maker', 'Last-minute scramble', 'Overpacks just in case'], 220), + ('How well do you handle scary movies?', 'How well does your partner handle scary movies?', + ARRAY['Loves them, no problem', 'Watches through fingers', 'Needs the lights on after', 'Avoids them completely'], 230), + ('What is your dance floor style?', 'What is your partner''s dance floor style?', + ARRAY['First one out there', 'Needs a drink first', 'Sways from the sidelines', 'Stays firmly seated'], 240), + ('What would you do with a free hour to yourself?', 'What would your partner do with a free hour to themselves?', + ARRAY['Nap or rest', 'Read or watch something', 'Go for a walk or exercise', 'Tidy up or get things done'], 250), + ('Which type of celebrity would you want as a dinner guest?', 'Which type of celebrity would your partner want as a dinner guest?', + ARRAY['A comedian', 'A musician', 'An athlete', 'An author or thinker'], 260), + ('What childhood dream job says the most about you?', 'What childhood dream job says the most about your partner?', + ARRAY['Astronaut or explorer', 'Performer or artist', 'Something helping others', 'Something running the show'], 270), + ('How would you describe your tidiness style?', 'How would you describe your partner''s tidiness style?', + ARRAY['Everything has its place', 'Tidy in bursts', 'Organised chaos', 'Relaxed about mess'], 280), + ('Are you more spontaneous or a planner?', 'Is your partner more spontaneous or a planner?', + ARRAY['Fully spontaneous', 'Mostly spontaneous', 'Mostly a planner', 'Fully a planner'], 290), + ('What genre do you rewatch for comfort?', 'What genre does your partner rewatch for comfort?', + ARRAY['Sitcoms', 'Feel-good films', 'Fantasy or sci-fi', 'Reality shows'], 300), + ('How would you describe your social battery?', 'How would you describe your partner''s social battery?', + ARRAY['Energised by people', 'Fine in small doses', 'Drained quickly', 'Depends entirely on the day'], 310), + ('What is your gift-giving style?', 'What is your partner''s gift-giving style?', + ARRAY['Thoughtful and personal', 'Practical and useful', 'Big and generous', 'Last-minute but well-meant'], 320), + ('What kind of breakfast personality do you have?', 'What kind of breakfast personality does your partner have?', + ARRAY['Big cooked breakfast', 'Quick and light', 'Coffee only', 'Skips it most days'], 330), + ('What weather do you prefer?', 'What weather does your partner prefer?', + ARRAY['Warm and sunny', 'Cool and crisp', 'Rainy and cosy', 'Snowy'], 340), + ('If you won the lottery, what would you do first?', 'If your partner won the lottery, what would they do first?', + ARRAY['Pay off everything', 'Book a big trip', 'Treat the people close to them', 'Save it and think it over'], 350), + ('When lost in a new city, what do you do?', 'When lost in a new city, what does your partner do?', + ARRAY['Ask a local for help', 'Follow a map or app closely', 'Wander until something looks familiar', 'Stay calm and retrace your steps'], 360) +ON CONFLICT ("prompt_self") DO NOTHING; diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 6ea4c49..82eb5ac 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -141,6 +141,13 @@ "when": 1788168000000, "tag": "0019_quiz", "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1788168060000, + "tag": "0020_quiz_question_pool", + "breakpoints": true } ] } diff --git a/apps/api/src/domains/quiz/repository.ts b/apps/api/src/domains/quiz/repository.ts index f9c1ade..3eeb618 100644 --- a/apps/api/src/domains/quiz/repository.ts +++ b/apps/api/src/domains/quiz/repository.ts @@ -167,9 +167,22 @@ export class PostgresQuizRepository implements QuizRepository { return { session: await this.buildPayload(client, existing.rows[0], userId), created: false }; } + // Prefer questions this couple has never had in any prior session (completed or + // active), then the least-recently-used ones, with a random tiebreak within each + // group so repeat quizzes stay varied instead of always reusing the same pool. const questions = await client.query<{ id: string }>( - 'select id from quiz_questions where is_active = true order by sort_order, id limit $1', - [QUESTIONS_PER_SESSION], + `select q.id + from quiz_questions q + left join ( + select question_id, max(created_at) as last_used + from quiz_sessions, unnest(question_ids) as question_id + where couple_id = $1 + group by question_id + ) usage on usage.question_id = q.id + where q.is_active = true + order by (usage.last_used is not null), usage.last_used asc, random() + limit $2`, + [coupleId, QUESTIONS_PER_SESSION], ); const questionIds = questions.rows.map((row) => row.id); diff --git a/apps/api/test/quiz.integration.test.ts b/apps/api/test/quiz.integration.test.ts index a04ef44..75619a2 100644 --- a/apps/api/test/quiz.integration.test.ts +++ b/apps/api/test/quiz.integration.test.ts @@ -21,6 +21,7 @@ describe.skipIf(!url || !local)('quiz + PostgreSQL', () => { const couple = '55555555-5555-4555-8555-555555555555'; const soloCouple = '66666666-6666-4666-8666-666666666666'; const otherCouple = '77777777-7777-4777-8777-777777777777'; + const userC = '88888888-8888-4888-8888-888888888888'; beforeAll(async () => { await admin.query(`create schema "${schema}"`); @@ -44,8 +45,8 @@ describe.skipIf(!url || !local)('quiz + PostgreSQL', () => { ); await pool.query( `insert into profiles(id,couple_id) values - ($1,$5),($2,$5),($3,$6),($4,$7)`, - [userA, userB, soloUser, outsider, couple, soloCouple, otherCouple], + ($1,$5),($2,$5),($3,$6),($4,$7),($8,$7)`, + [userA, userB, soloUser, outsider, couple, soloCouple, otherCouple, userC], ); }); @@ -206,4 +207,44 @@ describe.skipIf(!url || !local)('quiz + PostgreSQL', () => { const current = await repo.currentSession(userB); expect(current?.id).toBe(session.id); }); + + it('draws the next session from never-used questions once a session completes', async () => { + const { session: first } = await repo.startSession(userA); + const firstQuestionIds = first.questions.map((question) => question.id); + expect(firstQuestionIds).toHaveLength(10); + + const answers = firstQuestionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 })); + await repo.submitAnswers(userA, first.id, answers); + const completed = await repo.submitAnswers(userB, first.id, answers); + expect(completed.status).toBe('completed'); + + const { session: second, created } = await repo.startSession(userA); + expect(created).toBe(true); + const secondQuestionIds = second.questions.map((question) => question.id); + expect(secondQuestionIds).toHaveLength(10); + + // The seeded pool has 36 active questions, so the never-used-first preference + // guarantees the second session's questions are wholly disjoint from the first. + const overlap = secondQuestionIds.filter((id) => firstQuestionIds.includes(id)); + expect(overlap).toEqual([]); + }); + + it('scopes selection to each couple independently', async () => { + const { session: coupleOneFirst } = await repo.startSession(userA); + const coupleOneFirstIds = coupleOneFirst.questions.map((question) => question.id); + const answers = coupleOneFirstIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 })); + await repo.submitAnswers(userA, coupleOneFirst.id, answers); + await repo.submitAnswers(userB, coupleOneFirst.id, answers); + + // A different couple with no prior sessions should still be able to draw a + // fresh set of 10 questions regardless of what the first couple has used, + // since the never-used-for-them preference is scoped per couple. + const { session: otherSession, created } = await repo.startSession(outsider); + expect(created).toBe(true); + expect(otherSession.questions).toHaveLength(10); + + const partnerView = await repo.startSession(userC); + expect(partnerView.created).toBe(false); + expect(partnerView.session.id).toBe(otherSession.id); + }); }); diff --git a/docs/quiz.md b/docs/quiz.md index b5aba3a..a9085fd 100644 --- a/docs/quiz.md +++ b/docs/quiz.md @@ -9,7 +9,7 @@ once both have finished. | Concern | Location | |---|---| -| Schema and seed content | `apps/api/drizzle/0019_quiz.sql` | +| Schema and seed content | `apps/api/drizzle/0019_quiz.sql`, `apps/api/drizzle/0020_quiz_question_pool.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`) | @@ -30,6 +30,15 @@ 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. +When a new session is created, the 10 questions are chosen from the active +pool with a couple-scoped preference order: questions this couple has never +had in any prior session (completed or active) come first, then the +least-recently-used ones, with a random tiebreak within each group. This +keeps repeat quizzes varied instead of always drawing the same +`sort_order`-first questions. The seeded pool (`0019_quiz.sql` plus +`0020_quiz_question_pool.sql`) has 36 active questions, so two consecutive +sessions for the same couple are guaranteed not to overlap. + 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 @@ -73,8 +82,9 @@ completed session. - `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 + isolation, incomplete answer set rejection, result blocked before + completion, no question overlap between a completed session and the next + one for the same couple, and that selection is scoped per couple ## Mobile client