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 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 18:21:55 +01:00
1 parent 74c1acfe2d
commit 45d24911fb
5 files changed
+131 -7

No files matched your search

@@ -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;
+7
View File
@@ -141,6 +141,13 @@
"when": 1788168000000,
"tag": "0019_quiz",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1788168060000,
"tag": "0020_quiz_question_pool",
"breakpoints": true
}
]
}
+15 -2
View File
@@ -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);
+43 -2
View File
@@ -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);
});
});