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