mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18: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,
|
"when": 1788168000000,
|
||||||
"tag": "0019_quiz",
|
"tag": "0019_quiz",
|
||||||
"breakpoints": true
|
"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 };
|
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 }>(
|
const questions = await client.query<{ id: string }>(
|
||||||
'select id from quiz_questions where is_active = true order by sort_order, id limit $1',
|
`select q.id
|
||||||
[QUESTIONS_PER_SESSION],
|
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);
|
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 couple = '55555555-5555-4555-8555-555555555555';
|
||||||
const soloCouple = '66666666-6666-4666-8666-666666666666';
|
const soloCouple = '66666666-6666-4666-8666-666666666666';
|
||||||
const otherCouple = '77777777-7777-4777-8777-777777777777';
|
const otherCouple = '77777777-7777-4777-8777-777777777777';
|
||||||
|
const userC = '88888888-8888-4888-8888-888888888888';
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await admin.query(`create schema "${schema}"`);
|
await admin.query(`create schema "${schema}"`);
|
||||||
@@ -44,8 +45,8 @@ describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
|
|||||||
);
|
);
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`insert into profiles(id,couple_id) values
|
`insert into profiles(id,couple_id) values
|
||||||
($1,$5),($2,$5),($3,$6),($4,$7)`,
|
($1,$5),($2,$5),($3,$6),($4,$7),($8,$7)`,
|
||||||
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple],
|
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple, userC],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,4 +207,44 @@ describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
|
|||||||
const current = await repo.currentSession(userB);
|
const current = await repo.currentSession(userB);
|
||||||
expect(current?.id).toBe(session.id);
|
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 |
|
| 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` |
|
| HTTP contract | `apps/api/src/domains/quiz/routes.ts` |
|
||||||
| Session and scoring rules | `apps/api/src/domains/quiz/repository.ts` |
|
| Session and scoring rules | `apps/api/src/domains/quiz/repository.ts` |
|
||||||
| Pure scoring function | `apps/api/src/domains/quiz/service.ts` (`computeQuizScore`) |
|
| 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
|
creating a second one; two simultaneous starts race safely because the
|
||||||
partial unique index, not application locking, is the source of truth.
|
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
|
Answers are submitted as one complete batch per call (`quiz_answers`, upsert
|
||||||
on `(session_id, user_id, question_id)`). A submission must cover every
|
on `(session_id, user_id, question_id)`). A submission must cover every
|
||||||
question in the session exactly once, with every index inside that
|
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.routes.test.ts`: HTTP contract and error mapping
|
||||||
- `apps/api/test/quiz.integration.test.ts`: real PostgreSQL, covering the full
|
- `apps/api/test/quiz.integration.test.ts`: real PostgreSQL, covering the full
|
||||||
happy path with a hand-computed score, idempotent session start, couple
|
happy path with a hand-computed score, idempotent session start, couple
|
||||||
isolation, incomplete answer set rejection, and result blocked before
|
isolation, incomplete answer set rejection, result blocked before
|
||||||
completion
|
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
|
## Mobile client
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user