feat(quiz): couples quiz with shareable watermarked results card

Replaces the notify-me quiz stub with a full loop: quiz sessions per
couple, answer-and-guess flow, dual-direction scoring, and a score-only
share card (no question or answer content) captured via ViewShot with
Sauci branding.

API: migration 0019 (quiz_questions, quiz_sessions, quiz_answers, 12
seeded questions), new quiz domain with session start / current /
answers / result routes, couple isolation, route + integration +
scoring unit tests.

Mobile: src/features/quiz feature (intro, per-question self+guess flow,
waiting-for-partner nudge, results with breakdown), quizStore with
sign-out clearing, QuizShareModal following SharePreviewModal, discovery
QuizTile, analytics events, and tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 18:09:19 +01:00
1 parent 30aa7bc386
commit 6523ddeb1c
40 files changed
+2973 -237

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
-- Couples quiz: a shareable "how well do you know each other?" loop. Each partner
-- answers every question twice, once about themselves and once guessing their
-- partner, and the session only scores once both partners have a complete set.
CREATE TABLE IF NOT EXISTS "quiz_questions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"prompt_self" text NOT NULL UNIQUE,
"prompt_guess" text NOT NULL,
"options" text[] NOT NULL CHECK (array_length("options", 1) BETWEEN 2 AND 4),
"sort_order" integer NOT NULL DEFAULT 0,
"is_active" boolean NOT NULL DEFAULT true,
"created_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "quiz_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
"status" text NOT NULL DEFAULT 'active' CHECK ("status" IN ('active', 'completed')),
"question_ids" uuid[] NOT NULL,
"score_percent" integer,
"completed_at" timestamptz,
"created_at" timestamptz NOT NULL DEFAULT now()
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "quiz_sessions_one_active_per_couple_idx"
ON "quiz_sessions" ("couple_id") WHERE "status" = 'active';
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "quiz_sessions_couple_id_created_at_idx"
ON "quiz_sessions" ("couple_id", "created_at" DESC);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "quiz_answers" (
"session_id" uuid NOT NULL REFERENCES "quiz_sessions"("id") ON DELETE CASCADE,
"user_id" uuid NOT NULL,
"question_id" uuid NOT NULL,
"self_index" integer NOT NULL CHECK ("self_index" >= 0),
"guess_index" integer NOT NULL CHECK ("guess_index" >= 0),
"created_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("session_id", "user_id", "question_id")
);
--> statement-breakpoint
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
('What is your idea of a perfect date night?', 'What is your partner''s idea of a perfect date night?',
ARRAY['Cosy night in', 'Fancy dinner out', 'Live music or a show', 'Something active or outdoorsy'], 10),
('What comfort food do you crave the most?', 'What comfort food does your partner crave the most?',
ARRAY['Pizza', 'Ice cream', 'A home-cooked classic', 'Salty snacks'], 20),
('How do you most like to receive love?', 'How does your partner most like to receive love?',
ARRAY['Words of affirmation', 'Quality time', 'Physical touch', 'Thoughtful gifts'], 30),
('What is your biggest pet peeve at home?', 'What is your partner''s biggest pet peeve at home?',
ARRAY['Dishes left in the sink', 'Clothes on the floor', 'Being interrupted', 'Loud noises while resting'], 40),
('If you could go anywhere on a dream holiday, where would it be?', 'If your partner could go anywhere on a dream holiday, where would it be?',
ARRAY['A beach escape', 'A big city adventure', 'A mountain retreat', 'A road trip somewhere new'], 50),
('What movie genre do you reach for most?', 'What movie genre does your partner reach for most?',
ARRAY['Comedy', 'Romance', 'Action or thriller', 'Documentary'], 60),
('Are you more of a morning person or a night person?', 'Is your partner more of a morning person or a night person?',
ARRAY['Morning person', 'Night person', 'A bit of both', 'Neither, honestly'], 70),
('What hidden talent are you secretly proud of?', 'What hidden talent is your partner secretly proud of?',
ARRAY['Cooking or baking', 'Singing or dancing', 'A sport or game', 'Art or a craft'], 80),
('What helps you relieve stress the most?', 'What helps your partner relieve stress the most?',
ARRAY['A quiet walk', 'Talking it out', 'Exercise', 'Watching something and switching off'], 90),
('If you splurged on one thing, what would it be?', 'If your partner splurged on one thing, what would it be?',
ARRAY['New clothes', 'A gadget', 'A trip', 'A nice meal out'], 100),
('What is your go-to karaoke song vibe?', 'What is your partner''s go-to karaoke song vibe?',
ARRAY['A power ballad', 'An upbeat pop anthem', 'An old classic', 'I would never do karaoke'], 110),
('If you had one superpower, which would you pick?', 'If your partner had one superpower, which would they pick?',
ARRAY['Flight', 'Invisibility', 'Reading minds', 'Time travel'], 120)
ON CONFLICT ("prompt_self") DO NOTHING;
+7
View File
@@ -134,6 +134,13 @@
"when": 1788081480000,
"tag": "0018_cutover_source_parity",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1788168000000,
"tag": "0019_quiz",
"breakpoints": true
}
]
}
+4
View File
@@ -15,6 +15,8 @@ import type { ChatRepository } from './domains/chat/repository.js';
import { registerChatRoutes } from './domains/chat/routes.js';
import type { DaresRepository } from './domains/dares/repository.js';
import { registerDareRoutes } from './domains/dares/routes.js';
import type { QuizRepository } from './domains/quiz/repository.js';
import { registerQuizRoutes } from './domains/quiz/routes.js';
import { registerCoupleRoutes } from './domains/couples/routes.js';
import type { CoupleService } from './domains/couples/service.js';
import type { PacksRepository } from './domains/packs/repository.js';
@@ -39,6 +41,7 @@ export interface AppDependencies {
answersRepository?: AnswersRepository;
chatRepository?: ChatRepository;
daresRepository?: DaresRepository;
quizRepository?: QuizRepository;
profileSettingsRepository?: ProfileSettingsRepository;
accountOperationsService?: AccountOperationsService;
mediaRepository?: MediaRepository;
@@ -126,6 +129,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
if (deps.quizRepository) registerQuizRoutes(app, deps.quizRepository);
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
+1
View File
@@ -4,3 +4,4 @@ export * from '../domains/packs/schema.js';
export * from '../domains/answers/schema.js';
export * from '../domains/chat/schema.js';
export * from '../domains/app-data/schema.js';
export * from '../domains/quiz/schema.js';
+376
View File
@@ -0,0 +1,376 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { computeQuizScore, type QuizAnswerPair } from './service.js';
import {
QuizError,
type QuizAnswerInput,
type QuizQuestionSummary,
type QuizResultPayload,
type QuizSessionPayload,
type QuizSessionStatus,
} from './types.js';
interface QuizQuestionRow extends QueryResultRow {
id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
}
interface QuizSessionRow extends QueryResultRow {
id: string;
couple_id: string;
status: QuizSessionStatus;
question_ids: string[];
score_percent: number | null;
completed_at: Date | null;
created_at: Date;
}
interface QuizAnswerRow extends QueryResultRow {
user_id: string;
question_id: string;
self_index: number;
guess_index: number;
}
export interface QuizRepository {
startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }>;
currentSession(userId: string): Promise<QuizSessionPayload | null>;
submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload>;
result(userId: string, sessionId: string): Promise<QuizResultPayload>;
close(): Promise<void>;
}
const QUESTIONS_PER_SESSION = 10;
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await work(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
function orderByIds<T extends { id: string }>(rows: T[], ids: string[]): T[] {
const byId = new Map(rows.map((row) => [row.id, row]));
return ids.map((id) => byId.get(id)).filter((row): row is T => row !== undefined);
}
export class PostgresQuizRepository implements QuizRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
private async coupleOf(client: Pool | PoolClient, userId: string): Promise<string | null> {
const result = await client.query<{ couple_id: string | null }>(
'select couple_id from profiles where id = $1',
[userId],
);
return result.rows[0]?.couple_id ?? null;
}
private async partnerOf(client: Pool | PoolClient, coupleId: string, userId: string): Promise<string | null> {
const result = await client.query<{ id: string }>(
'select id from profiles where couple_id = $1 and id <> $2 order by id asc limit 1',
[coupleId, userId],
);
return result.rows[0]?.id ?? null;
}
private async questionsFor(client: Pool | PoolClient, questionIds: string[]): Promise<QuizQuestionRow[]> {
const result = await client.query<QuizQuestionRow>(
'select id, prompt_self, prompt_guess, options from quiz_questions where id = any($1::uuid[])',
[questionIds],
);
return orderByIds(result.rows, questionIds);
}
private async buildPayload(
client: Pool | PoolClient,
session: QuizSessionRow,
userId: string,
): Promise<QuizSessionPayload> {
const questions = await this.questionsFor(client, session.question_ids);
const [myAnswers, counts] = await Promise.all([
client.query<QuizAnswerRow>(
'select question_id, self_index, guess_index from quiz_answers where session_id = $1 and user_id = $2',
[session.id, userId],
),
client.query<{ user_id: string; count: string }>(
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
[session.id],
),
]);
const coupleId = session.couple_id;
const partnerId = await this.partnerOf(client, coupleId, userId);
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
const questionCount = session.question_ids.length;
const answersByQuestion = new Map(myAnswers.rows.map((row) => [row.question_id, row]));
const orderedAnswers: QuizAnswerInput[] = session.question_ids
.map((questionId) => answersByQuestion.get(questionId))
.filter((row): row is QuizAnswerRow => row !== undefined)
.map((row) => ({ question_id: row.question_id, self_index: row.self_index, guess_index: row.guess_index }));
const questionSummaries: QuizQuestionSummary[] = questions.map((row) => ({
id: row.id,
prompt_self: row.prompt_self,
prompt_guess: row.prompt_guess,
options: row.options,
}));
return {
id: session.id,
status: session.status,
created_at: session.created_at.toISOString(),
completed_at: session.completed_at?.toISOString() ?? null,
score_percent: session.score_percent,
questions: questionSummaries,
my_answers: orderedAnswers,
partner_completed: partnerId ? (answerCounts.get(partnerId) ?? 0) === questionCount : false,
i_completed: (answerCounts.get(userId) ?? 0) === questionCount,
};
}
async startSession(userId: string): Promise<{ session: QuizSessionPayload; created: boolean }> {
return transaction(this.pool, async (client) => {
const coupleId = await this.coupleOf(client, userId);
if (!coupleId) throw new QuizError('no_couple', 'Join a couple before starting a quiz', 409);
const members = await client.query<{ count: string }>(
'select count(*)::text as count from profiles where couple_id = $1',
[coupleId],
);
if (Number(members.rows[0]?.count ?? 0) < 2) {
throw new QuizError('partner_required', 'Both partners must be paired to start a quiz', 409);
}
const existing = await client.query<QuizSessionRow>(
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
[coupleId],
);
if (existing.rows[0]) {
return { session: await this.buildPayload(client, existing.rows[0], userId), created: false };
}
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],
);
const questionIds = questions.rows.map((row) => row.id);
// The partial unique index (one active session per couple) is the source of
// truth for the race between two simultaneous starts; ON CONFLICT DO NOTHING
// lets the loser fall through to reading the winner's row instead of erroring.
const inserted = await client.query<QuizSessionRow>(
`insert into quiz_sessions (couple_id, question_ids)
values ($1, $2::uuid[])
on conflict (couple_id) where status = 'active' do nothing
returning *`,
[coupleId, questionIds],
);
let sessionRow = inserted.rows[0];
let created = true;
if (!sessionRow) {
created = false;
const raced = await client.query<QuizSessionRow>(
`select * from quiz_sessions where couple_id = $1 and status = 'active' order by created_at desc limit 1`,
[coupleId],
);
sessionRow = raced.rows[0]!;
}
return { session: await this.buildPayload(client, sessionRow, userId), created };
});
}
async currentSession(userId: string): Promise<QuizSessionPayload | null> {
const coupleId = await this.coupleOf(this.pool, userId);
if (!coupleId) return null;
const result = await this.pool.query<QuizSessionRow>(
'select * from quiz_sessions where couple_id = $1 order by created_at desc limit 1',
[coupleId],
);
const row = result.rows[0];
if (!row) return null;
return this.buildPayload(this.pool, row, userId);
}
async submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise<QuizSessionPayload> {
return transaction(this.pool, async (client) => {
const sessionResult = await client.query<QuizSessionRow>(
'select * from quiz_sessions where id = $1 for update',
[sessionId],
);
const session = sessionResult.rows[0];
const coupleId = await this.coupleOf(client, userId);
if (!session || !coupleId || session.couple_id !== coupleId) {
throw new QuizError('session_not_found', 'Quiz session not found', 404);
}
if (session.status !== 'active') {
throw new QuizError('session_completed', 'This quiz session has already finished', 409);
}
const questionIds = new Set(session.question_ids);
const answeredIds = new Set(answers.map((answer) => answer.question_id));
if (answeredIds.size !== answers.length || answeredIds.size !== questionIds.size) {
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
}
for (const id of answeredIds) {
if (!questionIds.has(id)) {
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
}
}
const questions = await this.questionsFor(client, session.question_ids);
const optionsByQuestion = new Map(questions.map((question) => [question.id, question.options]));
for (const answer of answers) {
const options = optionsByQuestion.get(answer.question_id);
if (!options) {
throw new QuizError('invalid_answers', 'Answers must cover every question in the session exactly once', 400);
}
if (
answer.self_index < 0 || answer.self_index >= options.length ||
answer.guess_index < 0 || answer.guess_index >= options.length
) {
throw new QuizError('invalid_answers', 'Answer indexes must be within the question options', 400);
}
}
for (const answer of answers) {
await client.query(
`insert into quiz_answers (session_id, user_id, question_id, self_index, guess_index)
values ($1, $2, $3, $4, $5)
on conflict (session_id, user_id, question_id)
do update set self_index = excluded.self_index, guess_index = excluded.guess_index`,
[sessionId, userId, answer.question_id, answer.self_index, answer.guess_index],
);
}
const partnerId = await this.partnerOf(client, coupleId, userId);
let updatedSession = session;
if (partnerId) {
const counts = await client.query<{ user_id: string; count: string }>(
'select user_id, count(*)::text as count from quiz_answers where session_id = $1 group by user_id',
[sessionId],
);
const answerCounts = new Map(counts.rows.map((row) => [row.user_id, Number(row.count)]));
const questionCount = session.question_ids.length;
const bothComplete =
(answerCounts.get(userId) ?? 0) === questionCount && (answerCounts.get(partnerId) ?? 0) === questionCount;
if (bothComplete) {
const allAnswers = await client.query<QuizAnswerRow>(
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
[sessionId],
);
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
for (const row of allAnswers.rows) {
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
byUser.get(row.user_id)!.set(row.question_id, row);
}
const mine = byUser.get(userId)!;
const theirs = byUser.get(partnerId)!;
const pairs: QuizAnswerPair[] = session.question_ids.map((questionId) => ({
questionId,
aSelfIndex: mine.get(questionId)!.self_index,
aGuessIndex: mine.get(questionId)!.guess_index,
bSelfIndex: theirs.get(questionId)!.self_index,
bGuessIndex: theirs.get(questionId)!.guess_index,
}));
const { scorePercent } = computeQuizScore(pairs);
const completed = await client.query<QuizSessionRow>(
`update quiz_sessions set status = 'completed', score_percent = $2, completed_at = now()
where id = $1
returning *`,
[sessionId, scorePercent],
);
updatedSession = completed.rows[0]!;
}
}
return this.buildPayload(client, updatedSession, userId);
});
}
async result(userId: string, sessionId: string): Promise<QuizResultPayload> {
const sessionResult = await this.pool.query<QuizSessionRow>(
'select * from quiz_sessions where id = $1',
[sessionId],
);
const session = sessionResult.rows[0];
const coupleId = await this.coupleOf(this.pool, userId);
if (!session || !coupleId || session.couple_id !== coupleId) {
throw new QuizError('session_not_found', 'Quiz session not found', 404);
}
if (session.status !== 'completed') {
throw new QuizError('session_not_completed', 'This quiz session has not finished yet', 409);
}
const partnerId = await this.partnerOf(this.pool, coupleId, userId);
if (!partnerId) throw new QuizError('session_not_found', 'Quiz session not found', 404);
const [questions, allAnswers] = await Promise.all([
this.questionsFor(this.pool, session.question_ids),
this.pool.query<QuizAnswerRow>(
'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1',
[sessionId],
),
]);
const byUser = new Map<string, Map<string, QuizAnswerRow>>();
for (const row of allAnswers.rows) {
if (!byUser.has(row.user_id)) byUser.set(row.user_id, new Map());
byUser.get(row.user_id)!.set(row.question_id, row);
}
const mine = byUser.get(userId) ?? new Map<string, QuizAnswerRow>();
const theirs = byUser.get(partnerId) ?? new Map<string, QuizAnswerRow>();
const questionsPayload = questions.map((question) => {
const my = mine.get(question.id);
const partner = theirs.get(question.id);
const mySelf = my?.self_index ?? 0;
const myGuess = my?.guess_index ?? 0;
const partnerSelf = partner?.self_index ?? 0;
const partnerGuess = partner?.guess_index ?? 0;
return {
question_id: question.id,
prompt_self: question.prompt_self,
prompt_guess: question.prompt_guess,
options: question.options,
my_self_index: mySelf,
my_guess_index: myGuess,
partner_self_index: partnerSelf,
partner_guess_index: partnerGuess,
i_guessed_right: myGuess === partnerSelf,
partner_guessed_right: partnerGuess === mySelf,
};
});
return {
score_percent: session.score_percent ?? 0,
completed_at: session.completed_at?.toISOString() ?? null,
questions: questionsPayload,
};
}
async close(): Promise<void> {
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { QuizError, type QuizAnswerInput } from './types.js';
import type { QuizRepository } from './repository.js';
type QuizApp = Hono<{ Variables: { identity: AuthIdentity } }>;
const id = z.string().uuid();
const answerEntry = z
.object({
question_id: id,
self_index: z.number().int().min(0),
guess_index: z.number().int().min(0),
})
.strict();
const submitBody = z.object({ answers: z.array(answerEntry).min(1) }).strict();
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
async function result<T>(operation: () => Promise<T>) {
try {
return { ok: true as const, value: await operation() };
} catch (cause) {
if (cause instanceof QuizError) return { ok: false as const, cause };
throw cause;
}
}
export function registerQuizRoutes(app: QuizApp, repository: QuizRepository): void {
app.post('/v1/quiz/sessions', async (c) => {
const response = await result(() => repository.startSession(c.get('identity').id));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json({ session: response.value.session }, response.value.created ? 201 : 200);
});
app.get('/v1/quiz/sessions/current', async (c) => {
const response = await result(() => repository.currentSession(c.get('identity').id));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json({ session: response.value });
});
app.post('/v1/quiz/sessions/:sessionId/answers', async (c) => {
const sessionId = id.safeParse(c.req.param('sessionId'));
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
const body = submitBody.safeParse(await c.req.json().catch(() => ({})));
if (!body.success) return c.json(error('invalid_answers', 'Provide the full set of question answers'), 400);
const answers: QuizAnswerInput[] = body.data.answers;
const response = await result(() => repository.submitAnswers(c.get('identity').id, sessionId.data, answers));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json({ session: response.value });
});
app.get('/v1/quiz/sessions/:sessionId/result', async (c) => {
const sessionId = id.safeParse(c.req.param('sessionId'));
if (!sessionId.success) return c.json(error('session_not_found', 'Quiz session not found'), 404);
const response = await result(() => repository.result(c.get('identity').id, sessionId.data));
if (!response.ok) return c.json(error(response.cause.code, response.cause.message), response.cause.status);
return c.json(response.value);
});
}
+51
View File
@@ -0,0 +1,51 @@
import { boolean, check, index, integer, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { couples } from '../couples/schema.js';
/**
* Couples quiz catalogue. `promptSelf` and `promptGuess` are two phrasings of the
* same question so a session can ask each partner about themselves and about the
* other without a second row.
*/
export const quizQuestions = pgTable('quiz_questions', {
id: uuid('id').primaryKey().defaultRandom(),
promptSelf: text('prompt_self').notNull().unique(),
promptGuess: text('prompt_guess').notNull(),
options: text('options').array().notNull(),
sortOrder: integer('sort_order').notNull().default(0),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
check('quiz_questions_options_length_check', sql`array_length(${table.options}, 1) between 2 and 4`),
]);
export const quizSessions = pgTable('quiz_sessions', {
id: uuid('id').primaryKey().defaultRandom(),
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
status: text('status').notNull().default('active'),
questionIds: uuid('question_ids').array().notNull(),
scorePercent: integer('score_percent'),
completedAt: timestamp('completed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
index('quiz_sessions_couple_id_created_at_idx').on(table.coupleId, table.createdAt),
uniqueIndex('quiz_sessions_one_active_per_couple_idx').on(table.coupleId).where(sql`${table.status} = 'active'`),
check('quiz_sessions_status_check', sql`${table.status} in ('active','completed')`),
]);
export const quizAnswers = pgTable('quiz_answers', {
sessionId: uuid('session_id').notNull().references(() => quizSessions.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull(),
questionId: uuid('question_id').notNull(),
selfIndex: integer('self_index').notNull(),
guessIndex: integer('guess_index').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
primaryKey({ columns: [table.sessionId, table.userId, table.questionId] }),
check('quiz_answers_self_index_check', sql`${table.selfIndex} >= 0`),
check('quiz_answers_guess_index_check', sql`${table.guessIndex} >= 0`),
]);
export type QuizQuestionRow = typeof quizQuestions.$inferSelect;
export type QuizSessionRow = typeof quizSessions.$inferSelect;
export type QuizAnswerRow = typeof quizAnswers.$inferSelect;
+41
View File
@@ -0,0 +1,41 @@
/**
* One partner's guess of the other's self-answer, for a single question. Scoring
* always runs both directions: A guessing B and B guessing A each count separately,
* so a couple who only ever gets half the guesses right lands at 50%, not 100%.
*/
export interface QuizAnswerPair {
questionId: string;
aSelfIndex: number;
aGuessIndex: number;
bSelfIndex: number;
bGuessIndex: number;
}
export interface QuizScoreQuestionResult extends QuizAnswerPair {
aGuessedRight: boolean;
bGuessedRight: boolean;
}
export interface QuizScoreResult {
scorePercent: number;
perQuestion: QuizScoreQuestionResult[];
}
/**
* Pure scoring function for a completed quiz session. Each question contributes
* up to two hits (partner A's guess about B, and B's guess about A), so the
* denominator is twice the question count.
*/
export function computeQuizScore(pairs: QuizAnswerPair[]): QuizScoreResult {
const perQuestion = pairs.map((pair) => ({
...pair,
aGuessedRight: pair.aGuessIndex === pair.bSelfIndex,
bGuessedRight: pair.bGuessIndex === pair.aSelfIndex,
}));
const hits = perQuestion.reduce(
(sum, question) => sum + (question.aGuessedRight ? 1 : 0) + (question.bGuessedRight ? 1 : 0),
0,
);
const scorePercent = pairs.length === 0 ? 0 : Math.round((100 * hits) / (2 * pairs.length));
return { scorePercent, perQuestion };
}
+64
View File
@@ -0,0 +1,64 @@
export type QuizSessionStatus = 'active' | 'completed';
export type QuizErrorCode =
| 'no_couple'
| 'partner_required'
| 'session_not_found'
| 'session_completed'
| 'invalid_answers'
| 'session_not_completed';
export class QuizError extends Error {
constructor(
readonly code: QuizErrorCode,
message: string,
readonly status: 400 | 404 | 409,
) {
super(message);
this.name = 'QuizError';
}
}
export interface QuizAnswerInput {
question_id: string;
self_index: number;
guess_index: number;
}
export interface QuizQuestionSummary {
id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
}
export interface QuizSessionPayload {
id: string;
status: QuizSessionStatus;
created_at: string;
completed_at: string | null;
score_percent: number | null;
questions: QuizQuestionSummary[];
my_answers: QuizAnswerInput[];
partner_completed: boolean;
i_completed: boolean;
}
export interface QuizResultQuestion {
question_id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
my_self_index: number;
my_guess_index: number;
partner_self_index: number;
partner_guess_index: number;
i_guessed_right: boolean;
partner_guessed_right: boolean;
}
export interface QuizResultPayload {
score_percent: number;
completed_at: string | null;
questions: QuizResultQuestion[];
}
+3
View File
@@ -20,6 +20,7 @@ import {
import { PostgresAnswersRepository } from './domains/answers/repository.js';
import { PostgresChatRepository } from './domains/chat/repository.js';
import { PostgresDaresRepository } from './domains/dares/repository.js';
import { PostgresQuizRepository } from './domains/quiz/repository.js';
import { PostgresCoupleRepository } from './domains/couples/repository.js';
import { CoupleService } from './domains/couples/service.js';
import { PostgresPacksRepository } from './domains/packs/repository.js';
@@ -38,6 +39,7 @@ const packsRepository = new PostgresPacksRepository(pool);
const answersRepository = new PostgresAnswersRepository(pool);
const chatRepository = new PostgresChatRepository(pool);
const daresRepository = new PostgresDaresRepository(pool);
const quizRepository = new PostgresQuizRepository(pool);
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
const accountOperationsService = new AccountOperationsService(
@@ -68,6 +70,7 @@ const app = createApp({
answersRepository,
chatRepository,
daresRepository,
quizRepository,
profileSettingsRepository,
accountOperationsService,
mediaRepository,
+209
View File
@@ -0,0 +1,209 @@
import { randomUUID } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import { Pool } from 'pg';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { PostgresQuizRepository } from '../src/domains/quiz/repository.js';
const url = process.env.DATABASE_URL;
const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false;
if (url && !local) throw new Error('Quiz integration tests only permit localhost');
describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
const admin = new Pool({ connectionString: url });
const schema = `quiz_${randomUUID().replaceAll('-', '')}`;
let pool: Pool;
let repo: PostgresQuizRepository;
const userA = '11111111-1111-4111-8111-111111111111';
const userB = '22222222-2222-4222-8222-222222222222';
const soloUser = '33333333-3333-4333-8333-333333333333';
const outsider = '44444444-4444-4444-8444-444444444444';
const couple = '55555555-5555-4555-8555-555555555555';
const soloCouple = '66666666-6666-4666-8666-666666666666';
const otherCouple = '77777777-7777-4777-8777-777777777777';
beforeAll(async () => {
await admin.query(`create schema "${schema}"`);
const isolated = new URL(url!);
isolated.searchParams.set('options', `-c search_path=${schema}`);
pool = new Pool({ connectionString: isolated.toString() });
repo = new PostgresQuizRepository(isolated.toString());
const dir = new URL('../drizzle/', import.meta.url);
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
for (const name of files) {
const sql = await readFile(new URL(name, dir), 'utf8');
for (const statement of sql.split('--> statement-breakpoint')) {
if (statement.trim()) await pool.query(statement);
}
}
await pool.query(
'insert into couples(id,invite_code) values($1,$2),($3,$4),($5,$6)',
[couple, 'QUIZAAA1', soloCouple, 'QUIZAAA2', otherCouple, 'QUIZAAA3'],
);
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],
);
});
beforeEach(async () => {
await pool.query('delete from quiz_answers');
await pool.query('delete from quiz_sessions');
});
afterAll(async () => {
await repo.close();
await pool.end();
await admin.query(`drop schema "${schema}" cascade`);
await admin.end();
});
it('refuses to start a quiz without a couple or without a partner', async () => {
await expect(repo.startSession(randomUUID())).rejects.toMatchObject({ code: 'no_couple' });
await expect(repo.startSession(soloUser)).rejects.toMatchObject({ code: 'partner_required' });
});
it('starts a session with ten active questions and returns it idempotently', async () => {
const first = await repo.startSession(userA);
expect(first.created).toBe(true);
expect(first.session.status).toBe('active');
expect(first.session.questions).toHaveLength(10);
expect(first.session.score_percent).toBeNull();
expect(first.session.my_answers).toEqual([]);
expect(first.session.i_completed).toBe(false);
expect(first.session.partner_completed).toBe(false);
const second = await repo.startSession(userA);
expect(second.created).toBe(false);
expect(second.session.id).toBe(first.session.id);
// The partner starting also resolves to the same active session.
const partnerView = await repo.startSession(userB);
expect(partnerView.created).toBe(false);
expect(partnerView.session.id).toBe(first.session.id);
});
it('walks the full happy path: both partners answer, session completes, score is exact', async () => {
const { session } = await repo.startSession(userA);
const questionIds = session.questions.map((question) => question.id);
expect(questionIds).toHaveLength(10);
// Self-answers alternate 0/1 per question so a guess cannot accidentally match
// by coincidence. A guesses B's self-answer correctly on the first 6 questions
// and wrong on the rest; B guesses A's self-answer correctly on the first 3
// questions and wrong on the rest.
const bSelf = questionIds.map((_, index) => index % 2);
const aSelf = questionIds.map((_, index) => 1 - (index % 2));
const aGuess = bSelf.map((value, index) => (index < 6 ? value : 1 - value));
const bGuess = aSelf.map((value, index) => (index < 3 ? value : 1 - value));
const aAnswers = questionIds.map((questionId, index) => ({
question_id: questionId,
self_index: aSelf[index]!,
guess_index: aGuess[index]!,
}));
const bAnswers = questionIds.map((questionId, index) => ({
question_id: questionId,
self_index: bSelf[index]!,
guess_index: bGuess[index]!,
}));
const afterA = await repo.submitAnswers(userA, session.id, aAnswers);
expect(afterA.status).toBe('active');
expect(afterA.i_completed).toBe(true);
expect(afterA.partner_completed).toBe(false);
expect(afterA.my_answers).toHaveLength(10);
const afterB = await repo.submitAnswers(userB, session.id, bAnswers);
expect(afterB.status).toBe('completed');
expect(afterB.completed_at).not.toBeNull();
expect(afterB.i_completed).toBe(true);
expect(afterB.partner_completed).toBe(true);
// A guessed right (guess=1 matches B's self=1) on questions 0-5: 6 hits.
// B guessed right (guess=0 matches A's self=0) on questions 0-2: 3 hits.
// Total 9 hits out of 20 possible = 45%.
expect(afterB.score_percent).toBe(45);
const result = await repo.result(userA, session.id);
expect(result.score_percent).toBe(45);
expect(result.completed_at).not.toBeNull();
expect(result.questions).toHaveLength(10);
expect(result.questions[0]).toMatchObject({
my_self_index: aSelf[0],
my_guess_index: aGuess[0],
partner_self_index: bSelf[0],
partner_guess_index: bGuess[0],
i_guessed_right: true,
partner_guessed_right: true,
});
expect(result.questions[9]).toMatchObject({
i_guessed_right: false,
partner_guessed_right: false,
});
// The partner's result view mirrors the same score with roles swapped.
const partnerResult = await repo.result(userB, session.id);
expect(partnerResult.score_percent).toBe(45);
});
it('rejects an incomplete or mismatched answer set', async () => {
const { session } = await repo.startSession(userA);
const questionIds = session.questions.map((question) => question.id);
await expect(
repo.submitAnswers(userA, session.id, [
{ question_id: questionIds[0]!, self_index: 0, guess_index: 0 },
]),
).rejects.toMatchObject({ code: 'invalid_answers' });
await expect(
repo.submitAnswers(userA, session.id, [
...questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 })),
{ question_id: randomUUID(), self_index: 0, guess_index: 0 },
]),
).rejects.toMatchObject({ code: 'invalid_answers' });
const options = session.questions[0]!.options;
await expect(
repo.submitAnswers(userA, session.id, questionIds.map((id) => ({
question_id: id,
self_index: options.length,
guess_index: 0,
}))),
).rejects.toMatchObject({ code: 'invalid_answers' });
});
it('isolates sessions to their own couple', async () => {
const { session } = await repo.startSession(userA);
await expect(repo.submitAnswers(outsider, session.id, [])).rejects.toMatchObject({ code: 'session_not_found' });
await expect(repo.result(outsider, session.id)).rejects.toMatchObject({ code: 'session_not_found' });
expect(await repo.currentSession(outsider)).toBeNull();
});
it('blocks the answers endpoint once the session has completed', async () => {
const { session } = await repo.startSession(userA);
const questionIds = session.questions.map((question) => question.id);
const answers = questionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
await repo.submitAnswers(userA, session.id, answers);
const completed = await repo.submitAnswers(userB, session.id, answers);
expect(completed.status).toBe('completed');
await expect(repo.submitAnswers(userA, session.id, answers)).rejects.toMatchObject({ code: 'session_completed' });
});
it('blocks the result endpoint before the session has completed', async () => {
const { session } = await repo.startSession(userA);
await expect(repo.result(userA, session.id)).rejects.toMatchObject({ code: 'session_not_completed' });
});
it('returns the couple\'s most recent session from currentSession', async () => {
expect(await repo.currentSession(userA)).toBeNull();
const { session } = await repo.startSession(userA);
const current = await repo.currentSession(userB);
expect(current?.id).toBe(session.id);
});
});
+144
View File
@@ -0,0 +1,144 @@
import { Hono } from 'hono';
import { describe, expect, it, vi } from 'vitest';
import type { AuthIdentity } from '../src/auth.js';
import type { QuizRepository } from '../src/domains/quiz/repository.js';
import { registerQuizRoutes } from '../src/domains/quiz/routes.js';
import { QuizError, type QuizSessionPayload } from '../src/domains/quiz/types.js';
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
const sessionId = '22222222-2222-4222-8222-222222222222';
const questionId = '33333333-3333-4333-8333-333333333333';
const session: QuizSessionPayload = {
id: sessionId,
status: 'active',
created_at: '2026-08-27T00:00:00.000Z',
completed_at: null,
score_percent: null,
questions: [
{ id: questionId, prompt_self: 'Self prompt', prompt_guess: 'Guess prompt', options: ['A', 'B'] },
],
my_answers: [],
partner_completed: false,
i_completed: false,
};
function setup(overrides: Partial<QuizRepository> = {}) {
const repository: QuizRepository = {
startSession: vi.fn(async () => ({ session, created: true })),
currentSession: vi.fn(async () => session),
submitAnswers: vi.fn(async () => session),
result: vi.fn(async () => ({
score_percent: 100,
completed_at: '2026-08-27T00:00:00.000Z',
questions: [],
})),
close: vi.fn(async () => undefined),
...overrides,
};
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
registerQuizRoutes(app, repository);
return { app, repository };
}
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
return app.request(path, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe('quiz routes', () => {
it('starts a session and returns 201 when a new one is created', async () => {
const { app, repository } = setup();
const response = await post(app, '/v1/quiz/sessions');
expect(response.status).toBe(201);
expect(await response.json()).toEqual({ session });
expect(repository.startSession).toHaveBeenCalledWith(identity.id);
});
it('returns 200 when starting resolves to an existing active session', async () => {
const { app } = setup({ startSession: vi.fn(async () => ({ session, created: false })) });
const response = await post(app, '/v1/quiz/sessions');
expect(response.status).toBe(200);
});
it('maps no_couple and partner_required to 409', async () => {
const { app } = setup({
startSession: vi.fn(async () => { throw new QuizError('no_couple', 'Join a couple first', 409); }),
});
const response = await post(app, '/v1/quiz/sessions');
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ error: { code: 'no_couple' } });
});
it('returns the current session, including null when none exists', async () => {
const { app } = setup({ currentSession: vi.fn(async () => null) });
const response = await app.request('/v1/quiz/sessions/current');
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ session: null });
});
it('rejects a malformed session id on the answers route', async () => {
const { app, repository } = setup();
const response = await post(app, '/v1/quiz/sessions/not-a-uuid/answers', {
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
});
expect(response.status).toBe(404);
expect(repository.submitAnswers).not.toHaveBeenCalled();
});
it('rejects an empty or malformed answer body', async () => {
const { app, repository } = setup();
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers: [] })).status).toBe(400);
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {})).status).toBe(400);
expect((await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
answers: [{ question_id: questionId, self_index: 0, guess_index: 1, extra: true }],
})).status).toBe(400);
expect(repository.submitAnswers).not.toHaveBeenCalled();
});
it('submits valid answers and surfaces the updated session', async () => {
const { app, repository } = setup();
const answers = [{ question_id: questionId, self_index: 0, guess_index: 1 }];
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, { answers });
expect(response.status).toBe(200);
expect(repository.submitAnswers).toHaveBeenCalledWith(identity.id, sessionId, answers);
});
it('maps session errors to their status codes', async () => {
const { app } = setup({
submitAnswers: vi.fn(async () => { throw new QuizError('session_completed', 'Already finished', 409); }),
});
const response = await post(app, `/v1/quiz/sessions/${sessionId}/answers`, {
answers: [{ question_id: questionId, self_index: 0, guess_index: 1 }],
});
expect(response.status).toBe(409);
expect(await response.json()).toMatchObject({ error: { code: 'session_completed' } });
});
it('returns the result payload for a completed session', async () => {
const { app, repository } = setup();
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ score_percent: 100 });
expect(repository.result).toHaveBeenCalledWith(identity.id, sessionId);
});
it('maps session_not_completed to 409 on the result route', async () => {
const { app } = setup({
result: vi.fn(async () => { throw new QuizError('session_not_completed', 'Not finished yet', 409); }),
});
const response = await app.request(`/v1/quiz/sessions/${sessionId}/result`);
expect(response.status).toBe(409);
});
it('rejects a malformed session id on the result route', async () => {
const { app, repository } = setup();
const response = await app.request('/v1/quiz/sessions/not-a-uuid/result');
expect(response.status).toBe(404);
expect(repository.result).not.toHaveBeenCalled();
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { computeQuizScore, type QuizAnswerPair } from '../src/domains/quiz/service.js';
function pair(overrides: Partial<QuizAnswerPair> = {}): QuizAnswerPair {
return {
questionId: 'q1',
aSelfIndex: 0,
aGuessIndex: 0,
bSelfIndex: 0,
bGuessIndex: 0,
...overrides,
};
}
describe('computeQuizScore', () => {
it('scores 100% when every guess matches the partner self-answer in both directions', () => {
const pairs: QuizAnswerPair[] = [
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 1 }),
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 3, bGuessIndex: 0 }),
];
const { scorePercent } = computeQuizScore(pairs);
expect(scorePercent).toBe(100);
});
it('scores 0% when nobody guesses their partner correctly', () => {
const pairs: QuizAnswerPair[] = [
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 2, bGuessIndex: 3 }),
];
expect(computeQuizScore(pairs).scorePercent).toBe(0);
});
it('counts each direction independently, so a half-right couple lands at 50%', () => {
// A guesses B correctly, but B guesses A incorrectly: one hit out of two possible.
const pairs: QuizAnswerPair[] = [
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 2, bSelfIndex: 2, bGuessIndex: 0 }),
];
const result = computeQuizScore(pairs);
expect(result.scorePercent).toBe(50);
expect(result.perQuestion[0]).toMatchObject({ aGuessedRight: true, bGuessedRight: false });
});
it('rounds to the nearest whole percent', () => {
// 1 hit out of 6 possible (3 questions * 2 directions) = 16.67%, rounds to 17.
const pairs: QuizAnswerPair[] = [
// aGuess (0) matches bSelf (0): one hit. bGuess (3) does not match aSelf (1).
pair({ questionId: 'q1', aSelfIndex: 1, aGuessIndex: 0, bSelfIndex: 0, bGuessIndex: 3 }),
pair({ questionId: 'q2', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
pair({ questionId: 'q3', aSelfIndex: 0, aGuessIndex: 3, bSelfIndex: 0, bGuessIndex: 3 }),
];
expect(computeQuizScore(pairs).scorePercent).toBe(17);
});
it('returns 0% for an empty question set instead of dividing by zero', () => {
expect(computeQuizScore([]).scorePercent).toBe(0);
});
});
+4 -1
View File
@@ -8,7 +8,7 @@ import * as Haptics from 'expo-haptics';
import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard } from '../../src/components/discovery';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard, QuizTile } from '../../src/components/discovery';
import { StreakDisplay } from '../../src/components/StreakDisplay';
import { Events } from '../../src/lib/analytics';
import { colors, spacing, typography } from '../../src/theme';
@@ -166,6 +166,9 @@ export default function DiscoveryScreen() {
{/* Dares */}
<DaresTile delay={200} />
{/* Quiz */}
<QuizTile delay={225} />
{/* Content Rows by Category */}
{packsByCategory.map((group, index) => (
<ContentRow
+5 -232
View File
@@ -1,232 +1,5 @@
import { View, Text, StyleSheet, Platform, Pressable, ActivityIndicator } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeInDown, FadeIn, useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { GradientBackground } from "../../src/components/ui";
import { colors, spacing, radius, typography, animations } from "../../src/theme";
import { useFeatureInterest } from "../../src/hooks/useFeatureInterest";
const ACCENT_COLOR = colors.premium.rose;
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
export default function QuizScreen() {
const { isInterested, isLoading, isToggling, toggleInterest, isAuthenticated } =
useFeatureInterest("quiz");
const scale = useSharedValue(1);
const animatedButtonStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withSpring(0.96, animations.spring);
};
const handlePressOut = () => {
scale.value = withSpring(1, animations.spring);
};
return (
<GradientBackground>
<View style={styles.container}>
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.content}
>
{/* Title section */}
<Text style={styles.label}>CONNECTION</Text>
<Text style={styles.title}>Quiz</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Coming soon badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.badge}
>
<Text style={styles.badgeText}>ARRIVING SOON</Text>
</Animated.View>
{/* Description */}
<Text style={styles.description}>
Discover the depths of your connection through carefully crafted questions that reveal how well you truly know each other.
</Text>
{/* Feature hints */}
<View style={styles.features}>
<View style={styles.featureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Learn what makes each other tick</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="flame-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Deepen your bond</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="ribbon-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Track your journey</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.teaser}>Something special is being crafted for you</Text>
{/* Interest button - always reserve space to prevent layout shift */}
<View style={styles.interestButtonContainer}>
{isAuthenticated && !isLoading && (
<Animated.View entering={FadeIn.delay(500).duration(400)}>
<AnimatedPressable
onPress={toggleInterest}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={isToggling}
style={[
styles.interestButton,
isInterested && styles.interestButtonActive,
animatedButtonStyle,
]}
>
{isToggling ? (
<ActivityIndicator size="small" color={ACCENT_COLOR} />
) : (
<>
<Ionicons
name={isInterested ? "checkmark-circle" : "notifications-outline"}
size={16}
color={ACCENT_COLOR}
/>
<Text style={styles.interestButtonText}>
{isInterested ? "You're on the list!" : "I'm interested"}
</Text>
</>
)}
</AnimatedPressable>
</Animated.View>
)}
</View>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: 60,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
content: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
},
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: ACCENT_COLOR,
textAlign: 'center',
marginBottom: spacing.xs,
},
title: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(232, 164, 174, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT_COLOR,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
badge: {
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xl,
},
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: colors.background,
borderColor: colors.primary,
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
badgeText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT_COLOR,
},
description: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
features: {
marginBottom: spacing.xl,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
featureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
teaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
interestButtonContainer: {
marginTop: spacing.xl,
minHeight: 44,
},
});
import { QuizScreen } from "../../src/features/quiz";
export default function QuizRoute() {
return <QuizScreen />;
}
@@ -0,0 +1,126 @@
import { ApiError } from "@/lib/apiClient";
import { quizApi } from "@/features/quiz/services/quizApi";
import { useQuizStore } from "@/store/quizStore";
import type { QuizSession } from "@/features/quiz/types";
jest.mock("@/features/quiz/services/quizApi", () => ({
quizApi: {
startSession: jest.fn(),
getCurrentSession: jest.fn(),
submitAnswers: jest.fn(),
getResult: jest.fn(),
},
getQuizErrorCode: (error: unknown) => {
const { ApiError: RealApiError } = jest.requireActual("@/lib/apiClient");
if (!(error instanceof RealApiError)) return null;
const details = (error as { details?: unknown }).details as { error?: { code?: string } } | undefined;
return details?.error?.code ?? null;
},
}));
function session(overrides: Partial<QuizSession> = {}): QuizSession {
return {
id: "session-1",
status: "active",
created_at: "2026-08-01T00:00:00.000Z",
completed_at: null,
score_percent: null,
questions: [
{ id: "q1", prompt_self: "Self 1", prompt_guess: "Guess 1", options: ["A", "B"] },
{ id: "q2", prompt_self: "Self 2", prompt_guess: "Guess 2", options: ["A", "B"] },
],
my_answers: [],
partner_completed: false,
i_completed: false,
...overrides,
};
}
const resetStore = () =>
useQuizStore.setState({
session: null,
result: null,
localAnswers: {},
isLoading: false,
isStarting: false,
isSubmitting: false,
isLoadingResult: false,
errorCode: null,
error: null,
generation: 0,
});
describe("quizStore", () => {
beforeEach(() => {
jest.clearAllMocks();
resetStore();
});
it("starts a new session and resets local answers/result", async () => {
(quizApi.startSession as jest.Mock).mockResolvedValue({ session: session() });
await useQuizStore.getState().start();
expect(useQuizStore.getState().session?.id).toBe("session-1");
expect(useQuizStore.getState().localAnswers).toEqual({});
expect(useQuizStore.getState().isStarting).toBe(false);
});
it("records local self and guess answers per question without submitting early", () => {
useQuizStore.setState({ session: session() });
useQuizStore.getState().setSelfAnswer("q1", 0);
useQuizStore.getState().setGuessAnswer("q1", 1);
expect(useQuizStore.getState().localAnswers.q1).toEqual({ self_index: 0, guess_index: 1 });
expect(quizApi.submitAnswers).not.toHaveBeenCalled();
});
it("submits the full answer set built from local answers and stores the returned session", async () => {
useQuizStore.setState({
session: session(),
localAnswers: {
q1: { self_index: 0, guess_index: 1 },
q2: { self_index: 1, guess_index: 0 },
},
});
const updated = session({ i_completed: true });
(quizApi.submitAnswers as jest.Mock).mockResolvedValue({ session: updated });
const ok = await useQuizStore.getState().submit();
expect(ok).toBe(true);
expect(quizApi.submitAnswers).toHaveBeenCalledWith("session-1", [
{ question_id: "q1", self_index: 0, guess_index: 1 },
{ question_id: "q2", self_index: 1, guess_index: 0 },
]);
expect(useQuizStore.getState().session?.i_completed).toBe(true);
expect(useQuizStore.getState().localAnswers).toEqual({});
});
it("surfaces a pairing error code instead of a generic message", async () => {
(quizApi.getCurrentSession as jest.Mock).mockRejectedValue(
new ApiError("no_couple", 409, { error: { code: "no_couple" } }),
);
await useQuizStore.getState().load();
expect(useQuizStore.getState().errorCode).toBe("no_couple");
expect(useQuizStore.getState().error).toBeNull();
});
it("clearQuiz resets all state and invalidates any in-flight request (sign-out)", async () => {
let resolve!: (value: unknown) => void;
(quizApi.getCurrentSession as jest.Mock).mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
const staleLoad = useQuizStore.getState().load();
useQuizStore.getState().clearQuiz();
resolve({ session: session({ id: "stale-session" }) });
await staleLoad;
expect(useQuizStore.getState().session).toBeNull();
expect(useQuizStore.getState().generation).toBe(1);
});
});
@@ -0,0 +1,98 @@
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import * as Haptics from 'expo-haptics';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, spacing, radius, typography, shadows, featureColors } from '../../theme';
import { useAuthStore } from '../../store';
const HORIZONTAL_PADDING = spacing.lg;
const TILE_HEIGHT = 100;
const ACCENT = featureColors.quiz.accent;
interface QuizTileProps {
delay?: number;
}
export function QuizTile({ delay = 0 }: QuizTileProps) {
const { user } = useAuthStore();
const paired = Boolean(user?.couple_id);
if (!paired) return null;
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
router.push('/(app)/quiz' as never);
};
return (
<Animated.View
entering={FadeInDown.delay(delay).duration(500).springify()}
style={styles.container}
>
<TouchableOpacity onPress={handlePress} activeOpacity={0.85} style={styles.tileWrapper}>
<View style={[styles.tile, shadows.md]}>
<View style={styles.iconContainer}>
<Ionicons name="help-circle" size={24} color="rgba(255, 255, 255, 0.9)" />
</View>
<View style={styles.content}>
<Text style={styles.title}>Quiz</Text>
<Text style={styles.subtitle}>How well do you know each other?</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="rgba(255, 255, 255, 0.6)" />
<View style={styles.overlay} />
</View>
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: spacing.lg,
},
tileWrapper: {
paddingHorizontal: HORIZONTAL_PADDING,
},
tile: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: ACCENT,
borderRadius: radius.lg,
padding: spacing.md,
height: TILE_HEIGHT,
overflow: 'hidden',
position: 'relative',
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
content: {
flex: 1,
},
title: {
...typography.headline,
color: colors.text,
fontWeight: '700',
},
subtitle: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 2,
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
borderRadius: radius.lg,
pointerEvents: 'none',
},
});
@@ -4,4 +4,5 @@ export { CompactHeader } from './CompactHeader';
export { CircularProgress } from './CircularProgress';
export { LiveDrawTile } from './LiveDrawTile';
export { DaresTile } from './DaresTile';
export { QuizTile } from './QuizTile';
export { PendingQuestionsCard } from './PendingQuestionsCard';
@@ -30,6 +30,7 @@ interface GlassButtonProps {
textStyle?: TextStyle;
haptic?: boolean;
icon?: React.ReactNode;
testID?: string;
}
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
@@ -46,6 +47,7 @@ export function GlassButton({
textStyle,
haptic = true,
icon,
testID,
}: GlassButtonProps) {
const scale = useSharedValue(1);
@@ -104,6 +106,7 @@ export function GlassButton({
onPressIn={handlePressIn}
onPressOut={handlePressOut}
disabled={disabled || loading}
testID={testID}
style={[
animatedStyle,
fullWidth && styles.fullWidth,
@@ -0,0 +1,106 @@
import { ActivityIndicator, View, StyleSheet } from "react-native";
import { GradientBackground } from "../../components/ui";
import { featureColors } from "../../theme";
import { SwipeNoPartnerState } from "../swipe/components/SwipeNoPartnerState";
import { useQuizScreen } from "./hooks/useQuizScreen";
import {
QuizIntro,
QuizQuestionCard,
QuizResults,
QuizShareModal,
QuizWaitingState,
} from "./components";
export function QuizScreen() {
const {
screenState,
session,
result,
currentStep,
totalQuestions,
isStarting,
isSubmitting,
isLoadingResult,
shareModalVisible,
handleSelectOption,
handleStart,
handleNewQuiz,
handleRefresh,
handlePairPress,
handleNudgePartner,
openShareModal,
closeShareModal,
} = useQuizScreen();
if (screenState === "not_paired") {
return <SwipeNoPartnerState hasCouple={false} onPairPress={handlePairPress} />;
}
if (screenState === "loading") {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
</View>
</GradientBackground>
);
}
if (screenState === "intro") {
return <QuizIntro isStarting={isStarting} onStart={handleStart} />;
}
if (screenState === "waiting") {
return <QuizWaitingState onNudgePartner={handleNudgePartner} onRefresh={handleRefresh} />;
}
if (screenState === "results") {
return (
<>
<QuizResults
result={result}
isLoadingResult={isLoadingResult}
isStarting={isStarting}
onNewQuiz={handleNewQuiz}
onShare={openShareModal}
/>
<QuizShareModal
visible={shareModalVisible}
onClose={closeShareModal}
scorePercent={result?.score_percent ?? 0}
/>
</>
);
}
// screenState === "answering"
if (!session || !currentStep || isSubmitting) {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
</View>
</GradientBackground>
);
}
return (
<QuizQuestionCard
question={currentStep.question}
step={currentStep.step}
questionNumber={currentStep.questionNumber}
totalQuestions={totalQuestions}
onSelectOption={handleSelectOption}
/>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
});
export default QuizScreen;
@@ -0,0 +1,118 @@
import { StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated";
import { GlassButton, GradientBackground } from "../../../components/ui";
import { colors, featureColors, radius, shadows, spacing, typography } from "../../../theme";
const ACCENT = featureColors.quiz.accent;
interface QuizIntroProps {
isStarting: boolean;
onStart: () => void;
}
export function QuizIntro({ isStarting, onStart }: QuizIntroProps) {
return (
<GradientBackground>
<View style={styles.container}>
<Animated.View entering={FadeInDown.duration(600).springify()} style={styles.content}>
<LinearGradient
colors={featureColors.quiz.gradient as [string, string]}
style={styles.iconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="help-circle" size={40} color={colors.text} />
</LinearGradient>
<Text style={styles.label}>CONNECTION</Text>
<Text style={styles.title}>How well do you know each other?</Text>
<Text style={styles.description}>
Answer a set of questions about yourself, then guess how your partner
answered. See how well you really know each other, and share your score.
</Text>
<View style={styles.badge}>
<Ionicons name="list-outline" size={14} color={ACCENT} />
<Text style={styles.badgeText}>10 questions</Text>
</View>
<GlassButton
onPress={onStart}
loading={isStarting}
disabled={isStarting}
fullWidth
size="lg"
style={styles.startButton}
testID="quiz-start"
>
Start the quiz
</GlassButton>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.lg,
},
content: {
width: "100%",
alignItems: "center",
},
iconGradient: {
width: 88,
height: 88,
borderRadius: 44,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
...shadows.lg,
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: ACCENT,
textAlign: "center",
marginBottom: spacing.xs,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
marginBottom: spacing.md,
},
description: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
marginBottom: spacing.lg,
},
badge: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xl,
},
badgeText: {
...typography.caption1,
fontWeight: "600",
color: ACCENT,
},
startButton: {
marginTop: spacing.md,
},
});
@@ -0,0 +1,118 @@
import { Pressable, StyleSheet, Text, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { GlassCard, GradientBackground } from "../../../components/ui";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { QuizQuestion } from "../types";
import type { QuizAnswerStep } from "../hooks/useQuizScreen";
const ACCENT = featureColors.quiz.accent;
interface QuizQuestionCardProps {
question: QuizQuestion;
step: QuizAnswerStep;
questionNumber: number;
totalQuestions: number;
onSelectOption: (index: number) => void;
}
export function QuizQuestionCard({
question,
step,
questionNumber,
totalQuestions,
onSelectOption,
}: QuizQuestionCardProps) {
const prompt = step === "self" ? question.prompt_self : question.prompt_guess;
return (
<GradientBackground>
<View style={styles.container}>
<Text style={styles.progress} testID="quiz-progress">
{questionNumber} of {totalQuestions}
</Text>
<View style={styles.stepBadge}>
<Text style={styles.stepBadgeText}>{step === "self" ? "YOU" : "YOUR PARTNER"}</Text>
</View>
<Animated.View entering={FadeInDown.duration(400).springify()} key={`${question.id}-${step}`}>
<GlassCard variant="elevated" style={styles.card}>
<Text style={styles.prompt}>{prompt}</Text>
</GlassCard>
<View style={styles.options}>
{question.options.map((option, index) => (
<Pressable
key={`${question.id}-${index}`}
onPress={() => onSelectOption(index)}
style={({ pressed }) => [styles.option, pressed && styles.optionPressed]}
testID={`quiz-option-${index}`}
>
<Text style={styles.optionText}>{option}</Text>
</Pressable>
))}
</View>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: 80,
},
progress: {
...typography.caption1,
color: colors.textTertiary,
textAlign: "center",
marginBottom: spacing.md,
},
stepBadge: {
alignSelf: "center",
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.lg,
},
stepBadgeText: {
...typography.caption2,
fontWeight: "600",
letterSpacing: 2,
color: ACCENT,
},
card: {
marginBottom: spacing.xl,
},
prompt: {
...typography.title2,
color: colors.text,
textAlign: "center",
},
options: {
gap: spacing.md,
},
option: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.border,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
},
optionPressed: {
borderColor: ACCENT,
backgroundColor: colors.background,
},
optionText: {
...typography.body,
color: colors.text,
textAlign: "center",
fontWeight: "600",
},
});
@@ -0,0 +1,173 @@
import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated";
import { GlassButton, GlassCard, GradientBackground } from "../../../components/ui";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { QuizResult } from "../types";
interface QuizResultsProps {
result: QuizResult | null;
isLoadingResult: boolean;
isStarting: boolean;
onNewQuiz: () => void;
onShare: () => void;
}
export function QuizResults({ result, isLoadingResult, isStarting, onNewQuiz, onShare }: QuizResultsProps) {
if (!result) {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={featureColors.quiz.accent} />
<Text style={styles.loadingText}>
{isLoadingResult ? "Tallying your answers..." : "Loading your results..."}
</Text>
</View>
</GradientBackground>
);
}
return (
<GradientBackground>
<ScrollView contentContainerStyle={styles.container} showsVerticalScrollIndicator={false}>
<Animated.View entering={FadeInDown.duration(500).springify()}>
<LinearGradient
colors={featureColors.quiz.gradient as [string, string]}
style={styles.hero}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.heroLabel}>YOU MATCHED ON</Text>
<Text style={styles.heroScore} testID="quiz-score">
{result.score_percent}%
</Text>
<Text style={styles.heroTagline}>How well do you know each other?</Text>
</LinearGradient>
</Animated.View>
<View style={styles.breakdown}>
{result.questions.map((question) => (
<GlassCard key={question.question_id} style={styles.questionCard}>
<Text style={styles.questionPrompt}>{question.prompt_self}</Text>
<View style={styles.resultRow}>
<Ionicons
name={question.i_guessed_right ? "checkmark-circle" : "close-circle"}
size={18}
color={question.i_guessed_right ? colors.success : colors.error}
/>
<Text style={styles.resultText}>
{question.i_guessed_right ? "You guessed right" : "You guessed wrong"}
</Text>
</View>
<View style={styles.resultRow}>
<Ionicons
name={question.partner_guessed_right ? "checkmark-circle" : "close-circle"}
size={18}
color={question.partner_guessed_right ? colors.success : colors.error}
/>
<Text style={styles.resultText}>
{question.partner_guessed_right
? "Your partner guessed right"
: "Your partner guessed wrong"}
</Text>
</View>
</GlassCard>
))}
</View>
<GlassButton
onPress={onShare}
fullWidth
size="lg"
style={styles.shareButton}
icon={<Ionicons name="share-outline" size={20} color={colors.text} />}
testID="quiz-share"
>
Share your score
</GlassButton>
<GlassButton
variant="secondary"
onPress={onNewQuiz}
loading={isStarting}
disabled={isStarting}
fullWidth
style={styles.newQuizButton}
>
Take another quiz
</GlassButton>
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: spacing.md,
},
loadingText: {
...typography.body,
color: colors.textSecondary,
},
container: {
paddingHorizontal: spacing.lg,
paddingTop: 60,
paddingBottom: spacing.xxl,
},
hero: {
borderRadius: radius.xl,
paddingVertical: spacing.xl,
paddingHorizontal: spacing.lg,
alignItems: "center",
marginBottom: spacing.lg,
},
heroLabel: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: "rgba(255, 255, 255, 0.85)",
marginBottom: spacing.xs,
},
heroScore: {
...typography.largeTitle,
fontSize: 56,
lineHeight: 64,
color: colors.text,
fontWeight: "800",
},
heroTagline: {
...typography.subhead,
color: "rgba(255, 255, 255, 0.85)",
marginTop: spacing.xs,
},
breakdown: {
gap: spacing.sm,
marginBottom: spacing.xl,
},
questionCard: {
gap: spacing.xs,
},
questionPrompt: {
...typography.subhead,
color: colors.text,
fontWeight: "600",
marginBottom: spacing.xs,
},
resultRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
resultText: {
...typography.caption1,
color: colors.textSecondary,
},
shareButton: {
marginBottom: spacing.md,
},
newQuizButton: {},
});
@@ -0,0 +1,15 @@
import { render } from "@/test/test-utils";
import { QuizResultsShareCard } from "./QuizResultsShareCard";
describe("QuizResultsShareCard", () => {
it("renders the score and Sauci watermark without any question content", () => {
const { getByText, queryByText } = render(
<QuizResultsShareCard scorePercent={84} cardWidth={300} />,
);
expect(getByText("84%")).toBeTruthy();
expect(getByText("How well do you know each other?")).toBeTruthy();
expect(getByText("sauci.app")).toBeTruthy();
expect(queryByText(/prompt/i)).toBeNull();
});
});
@@ -0,0 +1,98 @@
import { Image, StyleSheet, Text, View } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { featureColors, radius, spacing, typography } from "../../../theme";
const logo = require("../../../../assets/logo.png");
interface QuizResultsShareCardProps {
scorePercent: number;
cardWidth: number;
}
/**
* The card captured for sharing. Deliberately carries only the score, never the
* question text or answers, so a screenshot cannot leak either partner's content.
*/
export function QuizResultsShareCard({ scorePercent, cardWidth }: QuizResultsShareCardProps) {
return (
<View style={[styles.card, { width: cardWidth }]}>
<LinearGradient
colors={featureColors.quiz.gradient as [string, string]}
style={styles.gradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.overlay} />
<View style={styles.content}>
<Text style={styles.tagline}>How well do you know each other?</Text>
<Text style={styles.label}>WE MATCHED ON</Text>
<Text style={styles.score}>{scorePercent}%</Text>
</View>
<View style={styles.branding}>
<Image source={logo} style={styles.brandingLogo} />
<Text style={styles.brandingText}>sauci.app</Text>
</View>
</LinearGradient>
</View>
);
}
const styles = StyleSheet.create({
card: {
aspectRatio: 0.7,
borderRadius: radius.xl,
overflow: "hidden",
},
gradient: {
flex: 1,
padding: spacing.lg,
justifyContent: "space-between",
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.08)",
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
tagline: {
...typography.subhead,
color: "rgba(255, 255, 255, 0.85)",
textAlign: "center",
marginBottom: spacing.xl,
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: "rgba(255, 255, 255, 0.85)",
marginBottom: spacing.xs,
},
score: {
...typography.largeTitle,
fontSize: 64,
lineHeight: 72,
fontWeight: "800",
color: "#ffffff",
},
branding: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.sm,
},
brandingLogo: {
width: 24,
height: 24,
resizeMode: "contain",
},
brandingText: {
...typography.subhead,
color: "#ffffff",
fontWeight: "600",
},
});
@@ -0,0 +1,286 @@
import { useEffect, useRef, useState } from "react";
import {
Linking,
Modal,
Platform,
Share,
StyleSheet,
Text,
TouchableOpacity,
View,
useWindowDimensions,
} from "react-native";
import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import * as FileSystem from "expo-file-system/legacy";
import { colors, spacing, typography, blur } from "../../../theme";
import { Events } from "../../../lib/analytics";
import { QuizResultsShareCard } from "./QuizResultsShareCard";
const MAX_CARD_WIDTH = 340;
interface QuizShareModalProps {
visible: boolean;
onClose: () => void;
scorePercent: number;
}
export function QuizShareModal({ visible, onClose, scorePercent }: QuizShareModalProps) {
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 64, MAX_CARD_WIDTH);
const shareViewRef = useRef<ViewShot>(null);
const [capturedUri, setCapturedUri] = useState<string | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
const shareMessage = `We matched on ${scorePercent}% on our couple quiz! Discover more on sauci.app`;
useEffect(() => {
if (visible && shareViewRef.current) {
setIsCapturing(true);
const timer = setTimeout(async () => {
try {
const uri = await shareViewRef.current?.capture?.();
if (uri) setCapturedUri(uri);
} catch (error) {
console.error("Error capturing quiz share image:", error);
} finally {
setIsCapturing(false);
}
}, 100);
return () => clearTimeout(timer);
}
}, [visible]);
useEffect(() => {
if (!visible) setCapturedUri(null);
}, [visible]);
const handleInstagramShare = async () => {
if (!capturedUri) return;
try {
const instagramUrl = "instagram://app";
const canOpenInstagram = await Linking.canOpenURL(instagramUrl);
if (canOpenInstagram && Platform.OS === "ios") {
await FileSystem.readAsStringAsync(capturedUri, {
encoding: FileSystem.EncodingType.Base64,
});
const instagramStoriesUrl = "instagram-stories://share?source_application=com.sauci.app";
await Linking.openURL(instagramStoriesUrl);
Events.quizShared();
} else {
await handleMoreShare();
}
} catch (error) {
console.error("Instagram share error:", error);
await handleMoreShare();
}
};
const handleMessagesShare = async () => {
if (!capturedUri) {
await Share.share({ message: shareMessage });
Events.quizShared();
return;
}
try {
if (Platform.OS === "ios") {
await Sharing.shareAsync(capturedUri, { mimeType: "image/png", UTI: "public.png" });
Events.quizShared();
} else {
await handleMoreShare();
}
} catch (error) {
console.error("Messages share error:", error);
await Share.share({ message: shareMessage });
Events.quizShared();
}
};
const handleMoreShare = async () => {
if (!capturedUri) {
await Share.share({ message: shareMessage });
Events.quizShared();
return;
}
try {
const isAvailable = await Sharing.isAvailableAsync();
if (isAvailable) {
await Sharing.shareAsync(capturedUri, { mimeType: "image/png", dialogTitle: "Share your quiz score" });
} else {
await Share.share({ message: shareMessage });
}
Events.quizShared();
} catch (error) {
console.error("Share error:", error);
}
};
const useBlur = Platform.OS === "ios";
return (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<View style={styles.overlay}>
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={StyleSheet.absoluteFill}
>
{useBlur ? (
<BlurView intensity={blur.heavy} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={styles.backdropFallback} />
)}
</Animated.View>
<Animated.View
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
style={[styles.content, { paddingTop: insets.top }]}
>
<View style={styles.header}>
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
testID="quiz-share-close"
>
<Ionicons name="close" size={28} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Share your score</Text>
<View style={styles.headerSpacer} />
</View>
<View style={styles.previewContainer}>
<ViewShot ref={shareViewRef} options={{ format: "png", quality: 1, result: "tmpfile" }}>
<QuizResultsShareCard scorePercent={scorePercent} cardWidth={cardWidth} />
</ViewShot>
</View>
<View style={[styles.actionsContainer, { paddingBottom: insets.bottom + spacing.lg }]}>
<ShareActionButton
icon="logo-instagram"
label="Instagram"
onPress={handleInstagramShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="chatbubble-ellipses"
label="Messages"
onPress={handleMessagesShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="share-outline"
label="More"
onPress={handleMoreShare}
disabled={isCapturing}
/>
</View>
</Animated.View>
</View>
</Modal>
);
}
function ShareActionButton({
icon,
label,
onPress,
disabled,
}: {
icon: string;
label: string;
onPress: () => void;
disabled?: boolean;
}) {
return (
<TouchableOpacity
style={styles.actionButton}
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
testID={`quiz-share-${icon}`}
>
<View style={styles.actionIconContainer}>
<Ionicons name={icon as any} size={28} color={colors.text} />
</View>
<Text style={styles.actionLabel}>{label}</Text>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
backdropFallback: {
flex: 1,
backgroundColor: "rgba(14, 14, 17, 0.95)",
},
content: {
flex: 1,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
closeButton: {
width: 44,
height: 44,
justifyContent: "center",
alignItems: "center",
},
title: {
...typography.headline,
color: colors.text,
fontWeight: "600",
},
headerSpacer: {
width: 44,
},
previewContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.lg,
},
actionsContainer: {
flexDirection: "row",
justifyContent: "center",
gap: spacing.xl,
paddingTop: spacing.xl,
paddingHorizontal: spacing.lg,
},
actionButton: {
alignItems: "center",
gap: spacing.sm,
},
actionIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
},
actionLabel: {
...typography.caption1,
color: colors.text,
fontWeight: "500",
},
});
@@ -0,0 +1,27 @@
import { SwipeInfoStateLayout } from "../../swipe/components/SwipeInfoStateLayout";
import { colors } from "../../../theme";
interface QuizWaitingStateProps {
onNudgePartner: () => void;
onRefresh: () => void;
}
export function QuizWaitingState({ onNudgePartner, onRefresh }: QuizWaitingStateProps) {
return (
<SwipeInfoStateLayout
accentColor={colors.premium.rose}
icon="checkmark-circle"
label="NICE WORK"
title="Your answers are in"
badgeText="WAITING ON YOUR PARTNER"
description="Nudge your partner to finish the quiz so you can both see how well you know each other."
features={[
{ icon: "hourglass-outline", text: "Results unlock once you both finish" },
{ icon: "heart-outline", text: "Share your match score together" },
]}
teaser="Almost there"
action={{ label: "Nudge your partner", onPress: onNudgePartner }}
secondaryAction={{ label: "Refresh", onPress: onRefresh, icon: "refresh-outline" }}
/>
);
}
@@ -0,0 +1,6 @@
export { QuizIntro } from "./QuizIntro";
export { QuizQuestionCard } from "./QuizQuestionCard";
export { QuizWaitingState } from "./QuizWaitingState";
export { QuizResults } from "./QuizResults";
export { QuizResultsShareCard } from "./QuizResultsShareCard";
export { QuizShareModal } from "./QuizShareModal";
@@ -0,0 +1,116 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useQuizScreen } from "./useQuizScreen";
import { useQuizStore } from "../../../store/quizStore";
import { quizApi } from "../services/quizApi";
import type { QuizSession } from "../types";
const mockRouterPush = jest.fn();
jest.mock("expo-router", () => {
const React = jest.requireActual("react");
return {
useRouter: () => ({ push: mockRouterPush }),
// Mirrors react-navigation's real semantics closely enough for this hook:
// runs once per stable callback identity, not on every render.
useFocusEffect: (callback: () => void) => React.useEffect(callback, [callback]),
};
});
let mockAuth = { user: { couple_id: null as string | null }, partner: null as { id: string } | null };
jest.mock("../../../store", () => ({
useAuthStore: () => mockAuth,
}));
jest.mock("../services/quizApi", () => ({
quizApi: {
startSession: jest.fn(),
getCurrentSession: jest.fn(),
submitAnswers: jest.fn(),
getResult: jest.fn(),
},
getQuizErrorCode: () => null,
}));
function session(overrides: Partial<QuizSession> = {}): QuizSession {
return {
id: "session-1",
status: "active",
created_at: "2026-08-01T00:00:00.000Z",
completed_at: null,
score_percent: null,
questions: [
{ id: "q1", prompt_self: "Self 1", prompt_guess: "Guess 1", options: ["A", "B"] },
],
my_answers: [],
partner_completed: false,
i_completed: false,
...overrides,
};
}
describe("useQuizScreen", () => {
beforeEach(() => {
jest.clearAllMocks();
useQuizStore.setState({
session: null, result: null, localAnswers: {},
isLoading: false, isStarting: false, isSubmitting: false, isLoadingResult: false,
errorCode: null, error: null, generation: 0,
});
mockAuth = { user: { couple_id: null }, partner: null };
});
it("shows the not_paired state when the user has no couple or partner", async () => {
const { result } = renderHook(() => useQuizScreen());
expect(result.current.screenState).toBe("not_paired");
act(() => result.current.handlePairPress());
expect(mockRouterPush).toHaveBeenCalledWith("/pairing");
});
it("moves to intro once paired and no active session exists", async () => {
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({ session: null });
const { result } = renderHook(() => useQuizScreen());
await waitFor(() => expect(result.current.screenState).toBe("intro"));
});
it("walks self then guess for each question and submits once all are answered", async () => {
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({ session: session() });
(quizApi.submitAnswers as jest.Mock).mockResolvedValue({
session: session({ i_completed: true }),
});
const { result } = renderHook(() => useQuizScreen());
await waitFor(() => expect(result.current.screenState).toBe("answering"));
expect(result.current.currentStep?.step).toBe("self");
await act(async () => {
await result.current.handleSelectOption(0);
});
expect(result.current.currentStep?.step).toBe("guess");
await act(async () => {
await result.current.handleSelectOption(1);
});
expect(quizApi.submitAnswers).toHaveBeenCalledWith("session-1", [
{ question_id: "q1", self_index: 0, guess_index: 1 },
]);
await waitFor(() => expect(result.current.screenState).toBe("waiting"));
});
it("shows results once the session and both partners have completed", async () => {
mockAuth = { user: { couple_id: "couple-1" }, partner: { id: "partner-1" } };
(quizApi.getCurrentSession as jest.Mock).mockResolvedValue({
session: session({ status: "completed", i_completed: true, partner_completed: true, score_percent: 80 }),
});
(quizApi.getResult as jest.Mock).mockResolvedValue({
score_percent: 80, completed_at: "now", questions: [],
});
const { result } = renderHook(() => useQuizScreen());
await waitFor(() => expect(result.current.screenState).toBe("results"));
await waitFor(() => expect(result.current.result?.score_percent).toBe(80));
});
});
@@ -0,0 +1,164 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Share } from "react-native";
import { useFocusEffect, useRouter } from "expo-router";
import { useAuthStore } from "../../../store";
import { useQuizStore } from "../../../store/quizStore";
import { Events } from "../../../lib/analytics";
import type { QuizQuestion } from "../types";
export type QuizScreenState = "not_paired" | "loading" | "intro" | "answering" | "waiting" | "results";
export type QuizAnswerStep = "self" | "guess";
export interface CurrentQuizStep {
question: QuizQuestion;
step: QuizAnswerStep;
questionNumber: number;
}
export function useQuizScreen() {
const router = useRouter();
const { user, partner } = useAuthStore();
const {
session,
result,
localAnswers,
isLoading,
isStarting,
isSubmitting,
isLoadingResult,
errorCode,
load,
start,
setSelfAnswer,
setGuessAnswer,
submit,
loadResult,
} = useQuizStore();
const [shareModalVisible, setShareModalVisible] = useState(false);
const hasCouple = Boolean(user?.couple_id);
const hasPartner = Boolean(partner);
const paired = hasCouple && hasPartner;
useFocusEffect(
useCallback(() => {
if (paired) void load();
}, [paired, load]),
);
const isCompleted = session?.status === "completed";
const shouldShowResults = isCompleted || (session?.i_completed && session?.partner_completed);
useEffect(() => {
if (shouldShowResults && session && !result && !isLoadingResult) {
void loadResult();
}
}, [shouldShowResults, session, result, isLoadingResult, loadResult]);
const screenState: QuizScreenState = useMemo(() => {
if (!paired) return "not_paired";
if (isLoading && !session) return "loading";
if (!session) return "intro";
if (shouldShowResults) return "results";
if (session.i_completed && !session.partner_completed) return "waiting";
return "answering";
}, [paired, isLoading, session, shouldShowResults]);
// The first unanswered step across the question set, in order.
const currentStep: CurrentQuizStep | null = useMemo(() => {
if (!session) return null;
for (let i = 0; i < session.questions.length; i += 1) {
const question = session.questions[i];
const answer = localAnswers[question.id];
if (answer?.self_index === undefined) {
return { question, step: "self", questionNumber: i + 1 };
}
if (answer?.guess_index === undefined) {
return { question, step: "guess", questionNumber: i + 1 };
}
}
return null;
}, [session, localAnswers]);
const totalQuestions = session?.questions.length ?? 0;
const handleSelectOption = useCallback(
async (index: number) => {
if (!currentStep) return;
const { question, step } = currentStep;
if (step === "self") {
setSelfAnswer(question.id, index);
return;
}
setGuessAnswer(question.id, index);
// The guess just recorded may be the last one needed; submit as soon as
// every question has both a self and a guess answer.
const answers = useQuizStore.getState().localAnswers;
const isLastQuestion = totalQuestions > 0 && currentStep.questionNumber === totalQuestions;
if (isLastQuestion) {
const allAnswered = session!.questions.every((q) => {
const a = q.id === question.id ? { ...answers[q.id], guess_index: index } : answers[q.id];
return a?.self_index !== undefined && a?.guess_index !== undefined;
});
if (allAnswered) {
await submit();
}
}
},
[currentStep, setSelfAnswer, setGuessAnswer, submit, totalQuestions, session],
);
const handleStart = useCallback(() => {
void start();
}, [start]);
const handleNewQuiz = useCallback(() => {
void start();
}, [start]);
const handleRefresh = useCallback(() => {
void load();
}, [load]);
const handlePairPress = useCallback(() => {
router.push("/pairing");
}, [router]);
const handleNudgePartner = useCallback(async () => {
try {
await Share.share({
message: "I just finished our couple quiz on Sauci, your turn!",
});
Events.quizPartnerNudged();
} catch (err) {
console.error("Error nudging partner:", err);
}
}, []);
const openShareModal = useCallback(() => setShareModalVisible(true), []);
const closeShareModal = useCallback(() => setShareModalVisible(false), []);
return {
screenState,
session,
result,
currentStep,
totalQuestions,
isStarting,
isSubmitting,
isLoadingResult,
isLoading,
errorCode,
hasCouple,
shareModalVisible,
handleSelectOption,
handleStart,
handleNewQuiz,
handleRefresh,
handlePairPress,
handleNudgePartner,
openShareModal,
closeShareModal,
};
}
+4
View File
@@ -0,0 +1,4 @@
export { QuizScreen, default } from "./QuizScreen";
export { useQuizScreen } from "./hooks/useQuizScreen";
export { quizApi, getQuizErrorCode } from "./services/quizApi";
export * from "./types";
@@ -0,0 +1,57 @@
import { apiClient, ApiError } from "../../../lib/apiClient";
import { getQuizErrorCode, quizApi } from "./quizApi";
jest.mock("../../../lib/apiClient", () => {
const actual = jest.requireActual("../../../lib/apiClient");
return {
...actual,
apiClient: { get: jest.fn(), post: jest.fn() },
};
});
const get = apiClient.get as jest.Mock;
const post = apiClient.post as jest.Mock;
describe("quizApi", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("starts a session against the couple quiz session endpoint", async () => {
post.mockResolvedValue({ session: { id: "s1" } });
await quizApi.startSession();
expect(post).toHaveBeenCalledWith("/v1/quiz/sessions");
});
it("reads the current session", async () => {
get.mockResolvedValue({ session: null });
await quizApi.getCurrentSession();
expect(get).toHaveBeenCalledWith("/v1/quiz/sessions/current");
});
it("submits the full answer set for a session", async () => {
post.mockResolvedValue({ session: { id: "s1" } });
const answers = [{ question_id: "q1", self_index: 0, guess_index: 1 }];
await quizApi.submitAnswers("s1", answers);
expect(post).toHaveBeenCalledWith("/v1/quiz/sessions/s1/answers", { answers });
});
it("encodes the session id when reading a result", async () => {
get.mockResolvedValue({ score_percent: 80, completed_at: "now", questions: [] });
await quizApi.getResult("s1/weird");
expect(get).toHaveBeenCalledWith("/v1/quiz/sessions/s1%2Fweird/result");
});
});
describe("getQuizErrorCode", () => {
it("reads the contract's { error: { code } } body off an ApiError", () => {
const error = new ApiError("no_couple", 409, { error: { code: "no_couple" } });
expect(getQuizErrorCode(error)).toBe("no_couple");
});
it("returns null for a non-ApiError or a body without a code", () => {
expect(getQuizErrorCode(new Error("boom"))).toBeNull();
expect(getQuizErrorCode(new ApiError("oops", 500, {}))).toBeNull();
expect(getQuizErrorCode(new ApiError("oops", 500, undefined))).toBeNull();
});
});
@@ -0,0 +1,23 @@
import { apiClient, ApiError } from "../../../lib/apiClient";
import type { QuizAnswer, QuizErrorCode, QuizResult, QuizSession } from "../types";
const sessionPath = (sessionId: string) => `/v1/quiz/sessions/${encodeURIComponent(sessionId)}`;
export const quizApi = {
startSession: () => apiClient.post<{ session: QuizSession }>("/v1/quiz/sessions"),
getCurrentSession: () => apiClient.get<{ session: QuizSession | null }>("/v1/quiz/sessions/current"),
submitAnswers: (sessionId: string, answers: QuizAnswer[]) =>
apiClient.post<{ session: QuizSession }>(`${sessionPath(sessionId)}/answers`, { answers }),
getResult: (sessionId: string) => apiClient.get<QuizResult>(`${sessionPath(sessionId)}/result`),
};
/**
* Reads the API's `{ error: { code } }` body out of an `ApiError`, so callers can
* branch on the contract's error codes instead of matching on message text.
*/
export function getQuizErrorCode(error: unknown): QuizErrorCode | null {
if (!(error instanceof ApiError)) return null;
const body = error.details as { error?: { code?: string } } | undefined;
const code = body?.error?.code;
return typeof code === "string" ? (code as QuizErrorCode) : null;
}
+58
View File
@@ -0,0 +1,58 @@
export interface QuizQuestion {
id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
}
export interface QuizAnswer {
question_id: string;
self_index: number;
guess_index: number;
}
export interface QuizSession {
id: string;
status: "active" | "completed";
created_at: string;
completed_at: string | null;
score_percent: number | null;
questions: QuizQuestion[];
my_answers: QuizAnswer[];
partner_completed: boolean;
i_completed: boolean;
}
export interface QuizResultQuestion {
question_id: string;
prompt_self: string;
prompt_guess: string;
options: string[];
my_self_index: number;
my_guess_index: number;
partner_self_index: number;
partner_guess_index: number;
i_guessed_right: boolean;
partner_guessed_right: boolean;
}
export interface QuizResult {
score_percent: number;
completed_at: string;
questions: QuizResultQuestion[];
}
/** Known error codes the quiz API can surface as `{ error: { code } }`. */
export type QuizErrorCode =
| "no_couple"
| "partner_required"
| "session_not_found"
| "session_completed"
| "invalid_answers"
| "session_not_completed";
/** In-progress local answer for a question, before the full set is submitted. */
export interface QuizLocalAnswer {
self_index?: number;
guess_index?: number;
}
+6
View File
@@ -273,6 +273,12 @@ export const Events = {
// Invite events
inviteCodeCopied: () => logEvent("invite_code_copied"),
// Quiz events
quizStarted: () => logEvent("quiz_started"),
quizCompleted: (scorePercent: number) => logEvent("quiz_completed", { score_percent: scorePercent }),
quizShared: () => logEvent("quiz_shared"),
quizPartnerNudged: () => logEvent("quiz_partner_nudged"),
// Tutorial events
tutorialStarted: (tutorial: "swipe" | "matches") =>
logEvent("tutorial_started", { tutorial }),
+8 -4
View File
@@ -35,7 +35,8 @@ const getOtherStores = () => {
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
const { useStreakStore } = require("./streakStore");
const { useResponsesStore } = require("./responsesStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore };
const { useQuizStore } = require("./quizStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore };
};
@@ -88,7 +89,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (error instanceof ApiError && error.status === 401) {
console.log("[Auth] Session rejected by API, signing out");
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
@@ -96,6 +97,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
useQuizStore.getState().clearQuiz();
await authClient.auth.signOut();
return;
}
@@ -144,7 +146,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isLoading: false,
});
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
@@ -152,6 +154,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
useQuizStore.getState().clearQuiz();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
@@ -176,7 +179,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
});
// Clear other stores when user signs out
if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
@@ -184,6 +187,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
useQuizStore.getState().clearQuiz();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
clearBadge();
+1
View File
@@ -11,3 +11,4 @@ export { useNotificationPreferencesStore } from "./notificationPreferencesStore"
export type { NotificationPreferences } from "./notificationPreferencesStore";
export { useStreakStore } from "./streakStore";
export type { CoupleStreak } from "./streakStore";
export { useQuizStore } from "./quizStore";
+164
View File
@@ -0,0 +1,164 @@
import { create } from "zustand";
import { Events } from "../lib/analytics";
import { getQuizErrorCode, quizApi } from "../features/quiz/services/quizApi";
import type { QuizAnswer, QuizErrorCode, QuizLocalAnswer, QuizResult, QuizSession } from "../features/quiz/types";
interface QuizState {
session: QuizSession | null;
result: QuizResult | null;
/** In-progress answers for the active session, keyed by question id, not yet submitted. */
localAnswers: Record<string, QuizLocalAnswer>;
isLoading: boolean;
isStarting: boolean;
isSubmitting: boolean;
isLoadingResult: boolean;
/** Set when the couple/partner state blocks the quiz (`no_couple`, `partner_required`). */
errorCode: QuizErrorCode | null;
error: string | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
load: () => Promise<void>;
start: () => Promise<void>;
setSelfAnswer: (questionId: string, index: number) => void;
setGuessAnswer: (questionId: string, index: number) => void;
submit: () => Promise<boolean>;
loadResult: () => Promise<void>;
reset: () => void;
clearQuiz: () => void;
}
export const useQuizStore = create<QuizState>((set, get) => ({
session: null,
result: null,
localAnswers: {},
isLoading: false,
isStarting: false,
isSubmitting: false,
isLoadingResult: false,
errorCode: null,
error: null,
generation: 0,
load: async () => {
const myGeneration = get().generation;
set({ isLoading: true, error: null, errorCode: null });
try {
const { session } = await quizApi.getCurrentSession();
if (get().generation !== myGeneration) return;
set({ session, isLoading: false, localAnswers: {} });
} catch (cause) {
if (get().generation !== myGeneration) return;
const code = getQuizErrorCode(cause);
console.error("Error loading quiz session:", cause);
set({ isLoading: false, errorCode: code, error: code ? null : "Could not load your quiz" });
}
},
start: async () => {
const myGeneration = get().generation;
set({ isStarting: true, error: null, errorCode: null });
try {
const { session } = await quizApi.startSession();
if (get().generation !== myGeneration) return;
set({ session, result: null, localAnswers: {}, isStarting: false });
Events.quizStarted();
} catch (cause) {
if (get().generation !== myGeneration) return;
const code = getQuizErrorCode(cause);
console.error("Error starting quiz:", cause);
set({ isStarting: false, errorCode: code, error: code ? null : "Could not start the quiz" });
}
},
setSelfAnswer: (questionId, index) => {
set((state) => ({
localAnswers: {
...state.localAnswers,
[questionId]: { ...state.localAnswers[questionId], self_index: index },
},
}));
},
setGuessAnswer: (questionId, index) => {
set((state) => ({
localAnswers: {
...state.localAnswers,
[questionId]: { ...state.localAnswers[questionId], guess_index: index },
},
}));
},
submit: async () => {
const state = get();
const session = state.session;
if (!session) return false;
const answers: QuizAnswer[] = session.questions.map((question) => {
const local = state.localAnswers[question.id] ?? {};
return {
question_id: question.id,
self_index: local.self_index ?? -1,
guess_index: local.guess_index ?? -1,
};
});
const myGeneration = state.generation;
set({ isSubmitting: true, error: null, errorCode: null });
try {
const { session: updated } = await quizApi.submitAnswers(session.id, answers);
if (get().generation !== myGeneration) return false;
set({ session: updated, isSubmitting: false, localAnswers: {} });
if (updated.score_percent !== null) {
Events.quizCompleted(updated.score_percent);
}
return true;
} catch (cause) {
if (get().generation !== myGeneration) return false;
const code = getQuizErrorCode(cause);
console.error("Error submitting quiz answers:", cause);
set({ isSubmitting: false, errorCode: code, error: code ? null : "Could not submit your answers" });
return false;
}
},
loadResult: async () => {
const session = get().session;
if (!session) return;
const myGeneration = get().generation;
set({ isLoadingResult: true, error: null });
try {
const result = await quizApi.getResult(session.id);
if (get().generation !== myGeneration) return;
set({ result, isLoadingResult: false });
} catch (cause) {
if (get().generation !== myGeneration) return;
const code = getQuizErrorCode(cause);
console.error("Error loading quiz result:", cause);
set({ isLoadingResult: false, errorCode: code, error: code ? null : "Could not load your results" });
}
},
/** Clears the in-memory quiz state without bumping the generation, e.g. after starting a new quiz. */
reset: () => {
set({ session: null, result: null, localAnswers: {}, error: null, errorCode: null });
},
clearQuiz: () => {
set((state) => ({
session: null,
result: null,
localAnswers: {},
isLoading: false,
isStarting: false,
isSubmitting: false,
isLoadingResult: false,
errorCode: null,
error: null,
// Invalidates any request already in flight: its generation check will
// fail and it will not be able to write into the next account's store.
generation: state.generation + 1,
}));
},
}));