diff --git a/apps/api/drizzle/0019_quiz.sql b/apps/api/drizzle/0019_quiz.sql new file mode 100644 index 0000000..7b96f9c --- /dev/null +++ b/apps/api/drizzle/0019_quiz.sql @@ -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; diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 57d97a9..6ea4c49 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1788081480000, "tag": "0018_cutover_source_parity", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1788168000000, + "tag": "0019_quiz", + "breakpoints": true } ] } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b3b5425..86d92dd 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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); diff --git a/apps/api/src/db/all-schema.ts b/apps/api/src/db/all-schema.ts index 57c2948..e800719 100644 --- a/apps/api/src/db/all-schema.ts +++ b/apps/api/src/db/all-schema.ts @@ -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'; diff --git a/apps/api/src/domains/quiz/repository.ts b/apps/api/src/domains/quiz/repository.ts new file mode 100644 index 0000000..f9c1ade --- /dev/null +++ b/apps/api/src/domains/quiz/repository.ts @@ -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; + submitAnswers(userId: string, sessionId: string, answers: QuizAnswerInput[]): Promise; + result(userId: string, sessionId: string): Promise; + close(): Promise; +} + +const QUESTIONS_PER_SESSION = 10; + +async function transaction(pool: Pool, work: (client: PoolClient) => Promise): Promise { + 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(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 { + 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 { + 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 { + const result = await client.query( + '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 { + const questions = await this.questionsFor(client, session.question_ids); + const [myAnswers, counts] = await Promise.all([ + client.query( + '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( + `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( + `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( + `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 { + const coupleId = await this.coupleOf(this.pool, userId); + if (!coupleId) return null; + const result = await this.pool.query( + '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 { + return transaction(this.pool, async (client) => { + const sessionResult = await client.query( + '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( + 'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1', + [sessionId], + ); + const byUser = new Map>(); + 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( + `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 { + const sessionResult = await this.pool.query( + '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( + 'select user_id, question_id, self_index, guess_index from quiz_answers where session_id = $1', + [sessionId], + ), + ]); + + const byUser = new Map>(); + 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(); + const theirs = byUser.get(partnerId) ?? new Map(); + + 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 { + await closeResolvedPool(this.pool, this.ownsPool); + } +} diff --git a/apps/api/src/domains/quiz/routes.ts b/apps/api/src/domains/quiz/routes.ts new file mode 100644 index 0000000..a528781 --- /dev/null +++ b/apps/api/src/domains/quiz/routes.ts @@ -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(operation: () => Promise) { + 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); + }); +} diff --git a/apps/api/src/domains/quiz/schema.ts b/apps/api/src/domains/quiz/schema.ts new file mode 100644 index 0000000..3e8bd77 --- /dev/null +++ b/apps/api/src/domains/quiz/schema.ts @@ -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; diff --git a/apps/api/src/domains/quiz/service.ts b/apps/api/src/domains/quiz/service.ts new file mode 100644 index 0000000..4321eae --- /dev/null +++ b/apps/api/src/domains/quiz/service.ts @@ -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 }; +} diff --git a/apps/api/src/domains/quiz/types.ts b/apps/api/src/domains/quiz/types.ts new file mode 100644 index 0000000..e85eac2 --- /dev/null +++ b/apps/api/src/domains/quiz/types.ts @@ -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[]; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5bd56d8..66029d9 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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, diff --git a/apps/api/test/quiz.integration.test.ts b/apps/api/test/quiz.integration.test.ts new file mode 100644 index 0000000..a04ef44 --- /dev/null +++ b/apps/api/test/quiz.integration.test.ts @@ -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); + }); +}); diff --git a/apps/api/test/quiz.routes.test.ts b/apps/api/test/quiz.routes.test.ts new file mode 100644 index 0000000..be078a3 --- /dev/null +++ b/apps/api/test/quiz.routes.test.ts @@ -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 = {}) { + 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(); + }); +}); diff --git a/apps/api/test/quiz.service.test.ts b/apps/api/test/quiz.service.test.ts new file mode 100644 index 0000000..2077b8a --- /dev/null +++ b/apps/api/test/quiz.service.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index cc7d527..fef1341 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -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 */} + {/* Quiz */} + + {/* Content Rows by Category */} {packsByCategory.map((group, index) => ( ({ - transform: [{ scale: scale.value }], - })); - - const handlePressIn = () => { - scale.value = withSpring(0.96, animations.spring); - }; - - const handlePressOut = () => { - scale.value = withSpring(1, animations.spring); - }; - - return ( - - - - {/* Title section */} - CONNECTION - Quiz - - {/* Decorative separator */} - - - - - - - {/* Coming soon badge */} - - ARRIVING SOON - - - {/* Description */} - - Discover the depths of your connection through carefully crafted questions that reveal how well you truly know each other. - - - {/* Feature hints */} - - - - Learn what makes each other tick - - - - Deepen your bond - - - - Track your journey - - - - {/* Bottom teaser */} - Something special is being crafted for you - - {/* Interest button - always reserve space to prevent layout shift */} - - {isAuthenticated && !isLoading && ( - - - {isToggling ? ( - - ) : ( - <> - - - {isInterested ? "You're on the list!" : "I'm interested"} - - - )} - - - )} - - - - - ); -} - -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 ; +} diff --git a/apps/mobile/src/__tests__/stores/quizStore.test.ts b/apps/mobile/src/__tests__/stores/quizStore.test.ts new file mode 100644 index 0000000..dff57aa --- /dev/null +++ b/apps/mobile/src/__tests__/stores/quizStore.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/mobile/src/components/discovery/QuizTile.tsx b/apps/mobile/src/components/discovery/QuizTile.tsx new file mode 100644 index 0000000..6a6c7ec --- /dev/null +++ b/apps/mobile/src/components/discovery/QuizTile.tsx @@ -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 ( + + + + + + + + + Quiz + How well do you know each other? + + + + + + + + + ); +} + +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', + }, +}); diff --git a/apps/mobile/src/components/discovery/index.ts b/apps/mobile/src/components/discovery/index.ts index f118af2..97699b5 100644 --- a/apps/mobile/src/components/discovery/index.ts +++ b/apps/mobile/src/components/discovery/index.ts @@ -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'; diff --git a/apps/mobile/src/components/ui/GlassButton.tsx b/apps/mobile/src/components/ui/GlassButton.tsx index 9b70db0..c647a97 100644 --- a/apps/mobile/src/components/ui/GlassButton.tsx +++ b/apps/mobile/src/components/ui/GlassButton.tsx @@ -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, diff --git a/apps/mobile/src/features/quiz/QuizScreen.tsx b/apps/mobile/src/features/quiz/QuizScreen.tsx new file mode 100644 index 0000000..7b89dfe --- /dev/null +++ b/apps/mobile/src/features/quiz/QuizScreen.tsx @@ -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 ; + } + + if (screenState === "loading") { + return ( + + + + + + ); + } + + if (screenState === "intro") { + return ; + } + + if (screenState === "waiting") { + return ; + } + + if (screenState === "results") { + return ( + <> + + + + ); + } + + // screenState === "answering" + if (!session || !currentStep || isSubmitting) { + return ( + + + + + + ); + } + + return ( + + ); +} + +const styles = StyleSheet.create({ + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, +}); + +export default QuizScreen; diff --git a/apps/mobile/src/features/quiz/components/QuizIntro.tsx b/apps/mobile/src/features/quiz/components/QuizIntro.tsx new file mode 100644 index 0000000..583c6c2 --- /dev/null +++ b/apps/mobile/src/features/quiz/components/QuizIntro.tsx @@ -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 ( + + + + + + + + CONNECTION + How well do you know each other? + + 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. + + + + + 10 questions + + + + Start the quiz + + + + + ); +} + +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, + }, +}); diff --git a/apps/mobile/src/features/quiz/components/QuizQuestionCard.tsx b/apps/mobile/src/features/quiz/components/QuizQuestionCard.tsx new file mode 100644 index 0000000..2e8926b --- /dev/null +++ b/apps/mobile/src/features/quiz/components/QuizQuestionCard.tsx @@ -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 ( + + + + {questionNumber} of {totalQuestions} + + + + {step === "self" ? "YOU" : "YOUR PARTNER"} + + + + + {prompt} + + + + {question.options.map((option, index) => ( + onSelectOption(index)} + style={({ pressed }) => [styles.option, pressed && styles.optionPressed]} + testID={`quiz-option-${index}`} + > + {option} + + ))} + + + + + ); +} + +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", + }, +}); diff --git a/apps/mobile/src/features/quiz/components/QuizResults.tsx b/apps/mobile/src/features/quiz/components/QuizResults.tsx new file mode 100644 index 0000000..cb76ba7 --- /dev/null +++ b/apps/mobile/src/features/quiz/components/QuizResults.tsx @@ -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 ( + + + + + {isLoadingResult ? "Tallying your answers..." : "Loading your results..."} + + + + ); + } + + return ( + + + + + YOU MATCHED ON + + {result.score_percent}% + + How well do you know each other? + + + + + {result.questions.map((question) => ( + + {question.prompt_self} + + + + {question.i_guessed_right ? "You guessed right" : "You guessed wrong"} + + + + + + {question.partner_guessed_right + ? "Your partner guessed right" + : "Your partner guessed wrong"} + + + + ))} + + + } + testID="quiz-share" + > + Share your score + + + + Take another quiz + + + + ); +} + +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: {}, +}); diff --git a/apps/mobile/src/features/quiz/components/QuizResultsShareCard.test.tsx b/apps/mobile/src/features/quiz/components/QuizResultsShareCard.test.tsx new file mode 100644 index 0000000..ae6776e --- /dev/null +++ b/apps/mobile/src/features/quiz/components/QuizResultsShareCard.test.tsx @@ -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( + , + ); + + expect(getByText("84%")).toBeTruthy(); + expect(getByText("How well do you know each other?")).toBeTruthy(); + expect(getByText("sauci.app")).toBeTruthy(); + expect(queryByText(/prompt/i)).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/quiz/components/QuizResultsShareCard.tsx b/apps/mobile/src/features/quiz/components/QuizResultsShareCard.tsx new file mode 100644 index 0000000..4bce0d9 --- /dev/null +++ b/apps/mobile/src/features/quiz/components/QuizResultsShareCard.tsx @@ -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 ( + + + + + + How well do you know each other? + WE MATCHED ON + {scorePercent}% + + + + + sauci.app + + + + ); +} + +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", + }, +}); diff --git a/apps/mobile/src/features/quiz/components/QuizShareModal.tsx b/apps/mobile/src/features/quiz/components/QuizShareModal.tsx new file mode 100644 index 0000000..b01ff51 --- /dev/null +++ b/apps/mobile/src/features/quiz/components/QuizShareModal.tsx @@ -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(null); + const [capturedUri, setCapturedUri] = useState(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 ( + + + + {useBlur ? ( + + ) : ( + + )} + + + + + + + + Share your score + + + + + + + + + + + + + + + + + + ); +} + +function ShareActionButton({ + icon, + label, + onPress, + disabled, +}: { + icon: string; + label: string; + onPress: () => void; + disabled?: boolean; +}) { + return ( + + + + + {label} + + ); +} + +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", + }, +}); diff --git a/apps/mobile/src/features/quiz/components/QuizWaitingState.tsx b/apps/mobile/src/features/quiz/components/QuizWaitingState.tsx new file mode 100644 index 0000000..88a5c1a --- /dev/null +++ b/apps/mobile/src/features/quiz/components/QuizWaitingState.tsx @@ -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 ( + + ); +} diff --git a/apps/mobile/src/features/quiz/components/index.ts b/apps/mobile/src/features/quiz/components/index.ts new file mode 100644 index 0000000..9c85c59 --- /dev/null +++ b/apps/mobile/src/features/quiz/components/index.ts @@ -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"; diff --git a/apps/mobile/src/features/quiz/hooks/useQuizScreen.test.ts b/apps/mobile/src/features/quiz/hooks/useQuizScreen.test.ts new file mode 100644 index 0000000..ddc208c --- /dev/null +++ b/apps/mobile/src/features/quiz/hooks/useQuizScreen.test.ts @@ -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 { + 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)); + }); +}); diff --git a/apps/mobile/src/features/quiz/hooks/useQuizScreen.ts b/apps/mobile/src/features/quiz/hooks/useQuizScreen.ts new file mode 100644 index 0000000..3688fec --- /dev/null +++ b/apps/mobile/src/features/quiz/hooks/useQuizScreen.ts @@ -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, + }; +} diff --git a/apps/mobile/src/features/quiz/index.ts b/apps/mobile/src/features/quiz/index.ts new file mode 100644 index 0000000..be2d901 --- /dev/null +++ b/apps/mobile/src/features/quiz/index.ts @@ -0,0 +1,4 @@ +export { QuizScreen, default } from "./QuizScreen"; +export { useQuizScreen } from "./hooks/useQuizScreen"; +export { quizApi, getQuizErrorCode } from "./services/quizApi"; +export * from "./types"; diff --git a/apps/mobile/src/features/quiz/services/quizApi.test.ts b/apps/mobile/src/features/quiz/services/quizApi.test.ts new file mode 100644 index 0000000..0109abf --- /dev/null +++ b/apps/mobile/src/features/quiz/services/quizApi.test.ts @@ -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(); + }); +}); diff --git a/apps/mobile/src/features/quiz/services/quizApi.ts b/apps/mobile/src/features/quiz/services/quizApi.ts new file mode 100644 index 0000000..9710501 --- /dev/null +++ b/apps/mobile/src/features/quiz/services/quizApi.ts @@ -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(`${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; +} diff --git a/apps/mobile/src/features/quiz/types.ts b/apps/mobile/src/features/quiz/types.ts new file mode 100644 index 0000000..eea312a --- /dev/null +++ b/apps/mobile/src/features/quiz/types.ts @@ -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; +} diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index 90f607b..be45d04 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -276,6 +276,12 @@ export const Events = { logEvent("invite_link_opened", { source }), pairingCodePrefilled: () => logEvent("pairing_code_prefilled"), + // 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 }), diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index 8e478c5..12e5648 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -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((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((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((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((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((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((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(); diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts index 94d9706..3fa0428 100755 --- a/apps/mobile/src/store/index.ts +++ b/apps/mobile/src/store/index.ts @@ -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"; diff --git a/apps/mobile/src/store/quizStore.ts b/apps/mobile/src/store/quizStore.ts new file mode 100644 index 0000000..80e1791 --- /dev/null +++ b/apps/mobile/src/store/quizStore.ts @@ -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; + 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; + start: () => Promise; + setSelfAnswer: (questionId: string, index: number) => void; + setGuessAnswer: (questionId: string, index: number) => void; + submit: () => Promise; + loadResult: () => Promise; + reset: () => void; + clearQuiz: () => void; +} + +export const useQuizStore = create((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, + })); + }, +})); diff --git a/docs/index.md b/docs/index.md index 6c5b1eb..e8442bd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,7 @@ historical notes. Agents should load only the section relevant to their task. - [Redemption codes](product/redemption.md) - [Mobile release process](releasing.md) - [Dares loop](dares-loop.md) +- [Couples quiz](quiz.md) ## Feature and implementation references diff --git a/docs/quiz.md b/docs/quiz.md new file mode 100644 index 0000000..b5aba3a --- /dev/null +++ b/docs/quiz.md @@ -0,0 +1,100 @@ +# Couples quiz + +Maintained reference for the shareable "how well do you know each other?" +quiz: a couple works through the same set of questions, each partner answers +about themselves and guesses about the other, and the pair only sees a score +once both have finished. + +## Ownership + +| Concern | Location | +|---|---| +| Schema and seed content | `apps/api/drizzle/0019_quiz.sql` | +| HTTP contract | `apps/api/src/domains/quiz/routes.ts` | +| Session and scoring rules | `apps/api/src/domains/quiz/repository.ts` | +| Pure scoring function | `apps/api/src/domains/quiz/service.ts` (`computeQuizScore`) | + +## Session lifecycle + +``` +POST /v1/quiz/sessions ──► active session (10 questions, question order fixed at creation) + │ + ├── each partner submits a full answer set (self + guess per question) + │ + └── once BOTH partners have a complete set ──► session completes, score is computed +``` + +Only one active session exists per couple at a time (`quiz_sessions_one_active_per_couple_idx`, +a partial unique index on `couple_id` where `status = 'active'`). Starting a +quiz while one is already active returns that same session (`200`) instead of +creating a second one; two simultaneous starts race safely because the +partial unique index, not application locking, is the source of truth. + +Answers are submitted as one complete batch per call (`quiz_answers`, upsert +on `(session_id, user_id, question_id)`). A submission must cover every +question in the session exactly once, with every index inside that +question's option list, or it is rejected with `400 invalid_answers`. A +session that has already completed rejects further submissions with +`409 session_completed`. + +## Scoring + +Each question contributes up to two hits: partner A's guess about B counts if +it matches B's self-answer, and separately B's guess about A counts if it +matches A's self-answer. `score_percent = round(100 * hits / (2 * +question_count))`. `computeQuizScore` in `service.ts` is the pure, +unit-tested implementation; the repository only assembles the per-question +pairs and persists the result. + +## Couple isolation + +Every session and result lookup is scoped to the caller's own couple. A +session ID that belongs to another couple returns `404 session_not_found`, +never a hint that it exists elsewhere. Starting a quiz requires both a couple +(`409 no_couple`) and a paired partner (`409 partner_required`). + +## HTTP contract + +| Method | Path | Notes | +|---|---|---| +| POST | `/v1/quiz/sessions` | `201` on a new session, `200` when an active one already exists | +| GET | `/v1/quiz/sessions/current` | `{ session: SessionPayload \| null }`, most recent session for the couple | +| POST | `/v1/quiz/sessions/:sessionId/answers` | `{ answers: [{ question_id, self_index, guess_index }] }` | +| GET | `/v1/quiz/sessions/:sessionId/result` | `409 session_not_completed` before both partners finish | + +`SessionPayload` never includes the partner's raw answers before completion; +per-question partner detail (`partner_self_index`, `partner_guess_index`, +`partner_guessed_right`) is only exposed by the `/result` endpoint of a +completed session. + +## Tests + +- `apps/api/test/quiz.service.test.ts`: unit tests for `computeQuizScore` +- `apps/api/test/quiz.routes.test.ts`: HTTP contract and error mapping +- `apps/api/test/quiz.integration.test.ts`: real PostgreSQL, covering the full + happy path with a hand-computed score, idempotent session start, couple + isolation, incomplete answer set rejection, and result blocked before + completion + +## Mobile client + +| Concern | Location | +|---|---| +| Feature screens, hooks, API client | `apps/mobile/src/features/quiz/` | +| Zustand store (generation-counter pattern, cleared on sign-out) | `apps/mobile/src/store/quizStore.ts` | +| Discovery entry point | `apps/mobile/src/components/discovery/QuizTile.tsx` | +| Route wrapper | `apps/mobile/app/(app)/quiz.tsx` | + +The screen walks not-paired, intro, answering (one question at a time, self +then guess), waiting-on-partner, and results states off `useQuizScreen`. +Answers are held client-side per question until every question in the +session has both a self and a guess index, then submitted as the one batch +the API expects; the store never calls the answers endpoint more than once +per session. Results render a shareable card (`QuizResultsShareCard` / +`QuizShareModal`, following the `SharePreviewModal` capture pattern) carrying +only the match score and Sauci branding, never question text or answers, so a +screenshot cannot leak either partner's content. + +## Not in this version + +Question authoring tooling in the admin app is out of scope for this change.