mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Merge quiz question variety into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
f22d4a3649
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;
|
||||
@@ -141,6 +141,13 @@
|
||||
"when": 1788168000000,
|
||||
"tag": "0019_quiz",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1788168060000,
|
||||
"tag": "0020_quiz_question_pool",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+13
-3
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user