diff --git a/.codex/agents/architecture-reviewer.toml b/.codex/agents/architecture-reviewer.toml new file mode 100644 index 0000000..d1f3eb1 --- /dev/null +++ b/.codex/agents/architecture-reviewer.toml @@ -0,0 +1,11 @@ +name = "architecture_reviewer" +description = "Read-only reviewer for cross-cutting or hard-to-reverse Sauci architecture decisions." +model = "gpt-5.6-sol" +model_reasoning_effort = "high" +sandbox_mode = "read-only" +developer_instructions = """ +Evaluate the delegated decision against current system boundaries, data flow, +migration path, operability, reversibility, and verification. Prefer the smallest +coherent change. Return explicit trade-offs, risks, and a recommendation with +file-backed evidence. Do not edit files or review unrelated local refactors. +""" diff --git a/.codex/agents/database-reviewer.toml b/.codex/agents/database-reviewer.toml new file mode 100644 index 0000000..2bf90b4 --- /dev/null +++ b/.codex/agents/database-reviewer.toml @@ -0,0 +1,12 @@ +name = "database_reviewer" +description = "Read-only reviewer for Sauci migrations, RLS, authorization, and query boundaries." +model = "gpt-5.6-terra" +model_reasoning_effort = "high" +sandbox_mode = "read-only" +developer_instructions = """ +Read the applicable AGENTS.md, database migration skill, and migration safety +documentation. Review only the delegated diff for migration tracking, +idempotency, authorization and tenant isolation, portability, rollback impact, +types, and integration coverage. Never mutate a database or edit files. Return +prioritized line-specific findings with concrete evidence and counterevidence. +""" diff --git a/.codex/agents/implementation.toml b/.codex/agents/implementation.toml new file mode 100644 index 0000000..ce5b776 --- /dev/null +++ b/.codex/agents/implementation.toml @@ -0,0 +1,12 @@ +name = "implementation" +description = "Default executor for a bounded Sauci product or harness change after scope and acceptance criteria are known." +model = "gpt-5.6-terra" +developer_instructions = """ +Read the root and nearest scoped AGENTS.md files plus the applicable repository +skill. Implement only the delegated objective and preserve unrelated work. Update +maintained contract documentation and add focused tests when the contract changes. +Run the required focused checks and return changed files, exact results, +assumptions, and blockers. Stop for missing authority or a materially ambiguous +product choice. Do not expand scope or perform external, destructive, production, +release, or provider writes unless the task explicitly authorizes that action. +""" diff --git a/.codex/agents/repo-scout.toml b/.codex/agents/repo-scout.toml new file mode 100644 index 0000000..6c7c945 --- /dev/null +++ b/.codex/agents/repo-scout.toml @@ -0,0 +1,11 @@ +name = "repo_scout" +description = "Read-only Sauci codebase scout for locating ownership, callers, contracts, tests, and existing patterns." +model = "gpt-5.6-luna" +model_reasoning_effort = "low" +sandbox_mode = "read-only" +developer_instructions = """ +Read the root and nearest scoped AGENTS.md files. Map only the requested surface. +Return evidence-backed file paths, symbols, current behavior, dependencies, tests, +and uncertainties. Do not edit files, make design decisions, or propose broad +refactors. Do not claim runtime behavior you did not observe. +""" diff --git a/.codex/agents/security-reviewer.toml b/.codex/agents/security-reviewer.toml new file mode 100644 index 0000000..953f377 --- /dev/null +++ b/.codex/agents/security-reviewer.toml @@ -0,0 +1,12 @@ +name = "security_reviewer" +description = "Read-only reviewer for auth, RLS, cryptography, secrets, payments, privileged MCP, and destructive operations." +model = "gpt-5.6-sol" +model_reasoning_effort = "high" +sandbox_mode = "read-only" +developer_instructions = """ +Perform a scoped threat-boundary review of the delegated change. Trace callers +through authentication and authorization to the data sink. Prioritize exploitable +correctness and security risks, include counterevidence, and cite exact files and +lines. Do not edit files or expand into a repository-wide audit unless explicitly +requested. +""" diff --git a/.codex/agents/test-writer.toml b/.codex/agents/test-writer.toml new file mode 100644 index 0000000..ba96cc8 --- /dev/null +++ b/.codex/agents/test-writer.toml @@ -0,0 +1,11 @@ +name = "test_writer" +description = "Focused Sauci regression and contract-test author for already-defined behavior." +model = "gpt-5.6-terra" +model_reasoning_effort = "medium" +developer_instructions = """ +Read the applicable AGENTS.md and testing documentation. Test the intended +observable contract at the lowest useful layer while preserving real auth, +authorization, tenant, and couple boundaries. Do not weaken assertions to +accommodate a failure. Run the focused test and return files changed, exact +commands, results, and any product defect exposed by the test. +""" diff --git a/.codex/agents/verifier.toml b/.codex/agents/verifier.toml new file mode 100644 index 0000000..f2752c4 --- /dev/null +++ b/.codex/agents/verifier.toml @@ -0,0 +1,11 @@ +name = "verifier" +description = "Independent Sauci behavior verifier that drives completed work and returns evidence, not fixes." +model = "gpt-5.6-terra" +model_reasoning_effort = "medium" +developer_instructions = """ +Read the applicable AGENTS.md and .codex/skills/verify/SKILL.md. Independently +exercise the delegated acceptance criteria against the running non-production +product. You may write generated proof only under evidence/. Do not edit source, +tests, configuration, or acceptance criteria. Return expected behavior, observed +behavior, evidence paths, environment limitations, and a works or broken verdict. +""" diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..7c89319 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,5 @@ +[agents] +enabled = true +max_concurrent_threads_per_session = 3 +default_subagent_model = "gpt-5.6-terra" +default_subagent_reasoning_effort = "medium" diff --git a/AGENTS.md b/AGENTS.md index a1f0b73..baab081 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,10 @@ MCP service in one npm/Turborepo workspace. 5. Read `apps/mobile/DESIGN.md` before mobile UI work. 6. Keep shared package exports type-only and route them through `src/index.ts`. 7. Do not commit secrets, generated evidence, local logs, or runtime state. -8. Scope work to the task. Use high-effort reviewers only for the risk triggers in +8. Scope work to the task. Route bounded implementation through the project + `implementation` agent when delegation is useful; the primary agent retains + acceptance, review, and final verification. Do not delegate trivial edits. +9. Use high-effort reviewers only for the risk triggers in `docs/agents/routing.md`. ## Repository map diff --git a/apps/api/.env.example b/apps/api/.env.example index 08b0374..a923cd8 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -3,6 +3,14 @@ DATABASE_URL=postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci SUPABASE_AUTH_URL=https://your-project.supabase.co SUPABASE_AUTH_AUDIENCE=authenticated +# Optional connection-pool bounds. The API process opens one pool and shares it +# across every repository, so these are the process-wide limits, not per-domain +# ones. Raising DATABASE_POOL_MAX raises the server connections this instance can +# hold; keep the total across instances inside the database's own limit. +# DATABASE_POOL_MAX=10 +# DATABASE_POOL_IDLE_TIMEOUT_MS=30000 +# DATABASE_POOL_CONNECTION_TIMEOUT_MS=10000 + # Server-only hosted Auth Admin credential. Used for account deletion and for # enriching admin user lists; it is never exposed to the browser or used as a # Supabase database client. diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 72ed9b0..b35a5f2 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -7,6 +7,9 @@ const baseSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PORT: z.coerce.number().int().min(1).max(65535).default(3003), DATABASE_URL: z.string().url(), + DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(200).default(10), + DATABASE_POOL_IDLE_TIMEOUT_MS: z.coerce.number().int().min(0).max(3_600_000).default(30_000), + DATABASE_POOL_CONNECTION_TIMEOUT_MS: z.coerce.number().int().min(0).max(60_000).default(10_000), SUPABASE_AUTH_URL: z.string().url().optional(), SUPABASE_AUTH_ISSUER: z.string().url().optional(), SUPABASE_AUTH_JWKS_URL: z.string().url().optional(), @@ -28,6 +31,7 @@ export interface AppConfig { nodeEnv: 'development' | 'test' | 'production'; port: number; databaseUrl: string; + databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number }; authIssuer: string; authJwksUrl?: string; authAudience: string; @@ -81,6 +85,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { nodeEnv: parsed.NODE_ENV, port: parsed.PORT, databaseUrl: parsed.DATABASE_URL, + databasePool: { + max: parsed.DATABASE_POOL_MAX, + idleTimeoutMillis: parsed.DATABASE_POOL_IDLE_TIMEOUT_MS, + connectionTimeoutMillis: parsed.DATABASE_POOL_CONNECTION_TIMEOUT_MS, + }, authIssuer, authJwksUrl, authAudience: parsed.SUPABASE_AUTH_AUDIENCE, diff --git a/apps/api/src/db/pool.ts b/apps/api/src/db/pool.ts new file mode 100644 index 0000000..0db68bd --- /dev/null +++ b/apps/api/src/db/pool.ts @@ -0,0 +1,50 @@ +import { Pool } from 'pg'; + +/** + * A repository is constructed either from a connection string, in which case it + * owns the pool it creates and must end it, or from a pool the caller owns. The + * API process takes the second path so every domain shares one bounded pool + * instead of opening a default-sized pool per repository; tests keep taking the + * first so each suite stays independently closable. + */ +export type DatabaseConnection = string | Pool; + +export interface DatabasePoolOptions { + /** Server-wide ceiling on concurrent PostgreSQL connections. */ + max?: number; + idleTimeoutMillis?: number; + connectionTimeoutMillis?: number; +} + +/** + * `pg` defaults to ten connections per pool, which multiplied by one pool per + * repository. One shared pool of the same size keeps the same headroom for a + * single API process while bounding what a self-hosted PostgreSQL must accept. + */ +export const DEFAULT_POOL_MAX = 10; + +export function createDatabasePool(databaseUrl: string, options: DatabasePoolOptions = {}): Pool { + return new Pool({ + connectionString: databaseUrl, + max: options.max ?? DEFAULT_POOL_MAX, + ...(options.idleTimeoutMillis === undefined ? {} : { idleTimeoutMillis: options.idleTimeoutMillis }), + ...(options.connectionTimeoutMillis === undefined ? {} : { connectionTimeoutMillis: options.connectionTimeoutMillis }), + }); +} + +export interface ResolvedPool { + pool: Pool; + /** True when this repository created the pool and is responsible for ending it. */ + owned: boolean; +} + +export function resolvePool(connection: DatabaseConnection): ResolvedPool { + return typeof connection === 'string' + ? { pool: new Pool({ connectionString: connection }), owned: true } + : { pool: connection, owned: false }; +} + +/** Ends the pool only when this repository created it, so shared pools survive. */ +export async function closeResolvedPool(pool: Pool, owned: boolean): Promise { + if (owned) await pool.end(); +} diff --git a/apps/api/src/db/repository.ts b/apps/api/src/db/repository.ts index e4a8514..477bf2e 100644 --- a/apps/api/src/db/repository.ts +++ b/apps/api/src/db/repository.ts @@ -2,6 +2,7 @@ import type { FeatureInterestResponse, Profile } from '@sauci/shared'; import { and, eq, sql } from 'drizzle-orm'; import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from './pool.js'; import type { AuthIdentity } from '../auth.js'; import { featureInterests, profiles, type ProfileRow } from './schema.js'; @@ -44,10 +45,13 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile { export class PostgresRepository implements ApiRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; private readonly db: NodePgDatabase; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; this.db = drizzle(this.pool); } @@ -55,7 +59,21 @@ export class PostgresRepository implements ApiRepository { await this.db.select({ id: profiles.id }).from(profiles).limit(1); } + /** + * Idempotent for the common case. `GET /v1/me` runs on every app start and on + * every session refresh, and the previous unconditional upsert made each of + * those a write that advanced `auth_synced_at` and produced dead row versions + * for autovacuum. The only fields this sync can actually change are the ones + * the conflict clause sets, so a profile that already carries the identity's + * email is returned as read and nothing is written. First-use creation and + * email enrichment keep their existing behaviour. + */ async upsertProfile(identity: AuthIdentity): Promise { + const [existing] = await this.db.select().from(profiles).where(eq(profiles.id, identity.id)).limit(1); + if (existing && (identity.email === null || identity.email === undefined || existing.email === identity.email)) { + return toProfile(existing); + } + const [row] = await this.db.insert(profiles).values({ id: identity.id, name: identity.name, @@ -93,6 +111,6 @@ export class PostgresRepository implements ApiRepository { } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/account-operations/repository.ts b/apps/api/src/domains/account-operations/repository.ts index aac28d2..f31044e 100644 --- a/apps/api/src/domains/account-operations/repository.ts +++ b/apps/api/src/domains/account-operations/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import { AccountOperationError } from './types.js'; interface ProfileOperationRecord extends QueryResultRow { @@ -63,9 +64,12 @@ async function lockCoupleAndPartner( export class PostgresAccountOperationsRepository implements AccountOperationsRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } async deleteRelationship(userId: string): Promise { @@ -135,7 +139,7 @@ export class PostgresAccountOperationsRepository implements AccountOperationsRep } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/admin/repository.ts b/apps/api/src/domains/admin/repository.ts index 99af77f..563e37a 100644 --- a/apps/api/src/domains/admin/repository.ts +++ b/apps/api/src/domains/admin/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import { readFile } from 'node:fs/promises'; import { resolve, sep } from 'node:path'; import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js'; @@ -139,9 +140,10 @@ export interface AdminRepository { export class PostgresAdminRepository implements AdminRepository { private readonly pool: Pool; - constructor(databaseUrl: string, private readonly options: { + private readonly ownsPool: boolean; + constructor(connection: DatabaseConnection, private readonly options: { adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory; - } = {}) { this.pool = new Pool({ connectionString: databaseUrl }); } + } = {}) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; } async principal(userId: string): Promise { const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>( @@ -356,5 +358,5 @@ export class PostgresAdminRepository implements AdminRepository { await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]); } - async close(): Promise { await this.pool.end(); } + async close(): Promise { await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/answers/repository.ts b/apps/api/src/domains/answers/repository.ts index 443970c..ce39082 100644 --- a/apps/api/src/domains/answers/repository.ts +++ b/apps/api/src/domains/answers/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js'; interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean } @@ -44,7 +45,7 @@ export interface AnswersRepository { submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>; update(userId: string, input: UpdateInput): Promise>; responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>; - matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>; + matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number | null; hasMore: boolean }>; markSeen(userId: string, ids: string[]): Promise; archive(userId: string, matchId: string, archived: boolean): Promise; streak(userId: string): Promise; @@ -53,8 +54,9 @@ export interface AnswersRepository { export class PostgresAnswersRepository implements AnswersRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; private zones: Promise> | null = null; - constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); } + constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; } private async context(client: Pool | PoolClient, userId: string): Promise { const result = await client.query('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]); @@ -230,12 +232,23 @@ export class PostgresAnswersRepository implements AnswersRepository { return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0}; } + /** + * Reading one row past the page decides `hasMore` exactly, and the total — which + * the client only records when it refreshes — is counted on the first page only + * instead of on every scroll. Unread is aggregated once per couple rather than as + * a correlated count per row: the sort key depends on it, so the old subquery + * already ran for every candidate match, not just the twenty that were returned. + */ async matches(userId:string,page:number,limit:number,archived:boolean){ const ctx=await this.context(this.pool,userId); const offset=page*limit; - const params=[userId,ctx.couple_id,archived,limit,offset]; - const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`; - const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]); - return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0}; + const params=[userId,ctx.couple_id,archived,limit+1,offset]; + const from=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1`; + const where=`where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`; + const unread=`unread as (select msg.match_id,count(*)::int c from messages msg join matches um on um.id=msg.match_id and um.couple_id=$2 where msg.user_id<>$1 and msg.read_at is null group by msg.match_id)`; + const [rows,count]=await Promise.all([ + this.pool.query(`with ${unread} select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,coalesce(u.c,0) "unreadCount" ${from} left join unread u on u.match_id=m.id ${where} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params), + page===0?this.pool.query<{count:number}>(`select count(*)::int count ${from} ${where}`,params.slice(0,3)):null]); + return {matches:rows.rows.slice(0,limit).map(this.dates),totalCount:count?count.rows[0]?.count??0:null,hasMore:rows.rows.length>limit}; } async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);} async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);} @@ -304,5 +317,5 @@ export class PostgresAnswersRepository implements AnswersRepository { } private async tx(fn:(client:PoolClient)=>Promise):Promise{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}} private dates>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;} - async close(){await this.pool.end();} + async close(){await closeResolvedPool(this.pool,this.ownsPool);} } diff --git a/apps/api/src/domains/app-data/repository.ts b/apps/api/src/domains/app-data/repository.ts index e4d6a94..746f1f9 100644 --- a/apps/api/src/domains/app-data/repository.ts +++ b/apps/api/src/domains/app-data/repository.ts @@ -1,4 +1,5 @@ import { Pool, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; export interface PackContext { id: string; name: string; icon: string | null } export interface AppQuestion { @@ -19,6 +20,50 @@ export interface StrokeSegment { } export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null } +/** + * A change summary rather than a payload. The mobile app used to re-fetch profile, + * couple, matches, both pending directions and enabled packs every five seconds to + * notice partner activity; it now compares this summary with the last one it saw and + * refreshes only the domains whose marker actually moved. Every field is a marker, + * not display data, so the response stays a few hundred bytes whatever the couple's + * history looks like. + */ +export interface SyncSummary { + server_time: string; + couple_id: string | null; + profile_updated_at: string | null; + partner_id: string | null; + partner_updated_at: string | null; + /** Matches visible to this member: archiving one of them moves the count. */ + match_count: number; + new_match_count: number; + latest_match_at: string | null; + /** Partner answered, this member has not. */ + pending_yours: number; + /** This member answered, partner has not. */ + pending_theirs: number; + unread_total: number; + /** Digest of the enabled pack ids, so an equal-and-opposite toggle is still a change. */ + enabled_packs_fingerprint: string; + /** + * Digest of each visible match's type and response summary. `match_count` / + * `new_match_count` / `latest_match_at` only move when a match is created, + * archived or unarchived — not when a partner edits an existing response and + * changes its `match_type` or `response_summary` — so this fingerprint is + * what actually notices that edit. + */ + match_state_fingerprint: string; + /** + * Digest of each visible match's unread count. `unread_total` can stay equal + * while unread messages redistribute between matches (one read, another + * receives a message), which reorders the unread-first list without moving + * the total; this fingerprint is what notices that. + */ + match_unread_fingerprint: string; + /** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */ + streak_updated_at: string | null; +} + export class AppDataError extends Error { constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record) { super(message); } } @@ -31,6 +76,7 @@ export interface AppDataRepository { markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>; nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>; sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>; + syncSummary(userId: string): Promise; getLiveDraw(userId: string): Promise; putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise; close(): Promise; @@ -38,11 +84,22 @@ export interface AppDataRepository { interface QuestionRow extends QueryResultRow, Omit { created_at: Date } +interface SyncRow extends QueryResultRow { + server_time: Date; couple_id: string | null; profile_updated_at: Date | null; + partner_id: string | null; partner_updated_at: Date | null; + match_count: number | null; new_match_count: number | null; latest_match_at: Date | null; + pending_yours: number | null; pending_theirs: number | null; unread_total: number | null; + enabled_packs_fingerprint: string | null; + match_state_fingerprint: string | null; match_unread_fingerprint: string | null; + streak_updated_at: Date | null; +} + function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; } export class PostgresAppDataRepository implements AppDataRepository { private readonly pool: Pool; - constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); } + private readonly ownsPool: boolean; + constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; } private async visiblePack(userId: string, packId: string): Promise { const result = await this.pool.query(` @@ -189,5 +246,75 @@ export class PostgresAppDataRepository implements AppDataRepository { } return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by }; } - async close(): Promise { await this.pool.end(); } + /** + * One round trip, all scalar subqueries, nothing that grows with the couple's + * history. An unpaired member's couple-scoped markers all collapse to zero or null + * because `me.couple_id` is null, so the endpoint stays valid before pairing. + */ + async syncSummary(userId: string): Promise { + const result = await this.pool.query(` + with me as (select id, couple_id, updated_at from profiles where id = $1), + visible as ( + select m.id, m.is_new, m.created_at, m.match_type, m.response_summary from matches m, me + where m.couple_id = me.couple_id + and not exists (select 1 from match_archives a where a.match_id = m.id and a.user_id = me.id) + ), + visible_unread as ( + select v.id as match_id, count(msg.id)::int as c + from visible v + left join messages msg on msg.match_id = v.id and msg.user_id <> (select id from me) + and msg.read_at is null and msg.deleted_at is null + and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = (select id from me)) + group by v.id + ) + select + now() as server_time, + me.couple_id, + me.updated_at as profile_updated_at, + (select p.id from profiles p where p.couple_id = me.couple_id and p.id <> me.id order by p.id limit 1) as partner_id, + (select max(p.updated_at) from profiles p where p.couple_id = me.couple_id and p.id <> me.id) as partner_updated_at, + (select count(*)::int from visible) as match_count, + (select count(*)::int from visible where is_new) as new_match_count, + (select max(created_at) from visible) as latest_match_at, + (select count(*)::int from responses r join questions q on q.id = r.question_id + where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id <> me.id + and not exists (select 1 from responses o + where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id = me.id)) as pending_yours, + (select count(*)::int from responses r join questions q on q.id = r.question_id + where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id = me.id + and not exists (select 1 from responses o + where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id <> me.id)) as pending_theirs, + (select count(*)::int from messages msg join matches ma on ma.id = msg.match_id + where ma.couple_id = me.couple_id and msg.user_id <> me.id and msg.read_at is null and msg.deleted_at is null + and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = me.id)) as unread_total, + (select md5(coalesce(string_agg(cp.pack_id::text, ',' order by cp.pack_id), '')) + from couple_packs cp where cp.couple_id = me.couple_id and cp.enabled) as enabled_packs_fingerprint, + (select md5(coalesce(string_agg(v.id::text || ':' || v.match_type || ':' || coalesce(v.response_summary::text, ''), ',' order by v.id), '')) + from visible v) as match_state_fingerprint, + (select md5(coalesce(string_agg(vu.match_id::text || ':' || vu.c, ',' order by vu.match_id), '')) + from visible_unread vu) as match_unread_fingerprint, + (select cs.updated_at from couple_streaks cs where cs.couple_id = me.couple_id) as streak_updated_at + from me`, [userId]); + const row = result.rows[0]; + if (!row) throw new AppDataError('not_found', 'Profile not found', 404); + return { + server_time: row.server_time.toISOString(), + couple_id: row.couple_id, + profile_updated_at: row.profile_updated_at?.toISOString() ?? null, + partner_id: row.partner_id, + partner_updated_at: row.partner_updated_at?.toISOString() ?? null, + match_count: row.match_count ?? 0, + new_match_count: row.new_match_count ?? 0, + latest_match_at: row.latest_match_at?.toISOString() ?? null, + pending_yours: row.pending_yours ?? 0, + pending_theirs: row.pending_theirs ?? 0, + unread_total: row.unread_total ?? 0, + enabled_packs_fingerprint: row.enabled_packs_fingerprint ?? '', + match_state_fingerprint: row.match_state_fingerprint ?? '', + match_unread_fingerprint: row.match_unread_fingerprint ?? '', + streak_updated_at: row.streak_updated_at?.toISOString() ?? null, + }; + } + + async close(): Promise { await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/app-data/routes.ts b/apps/api/src/domains/app-data/routes.ts index af2755f..4eaf7eb 100644 --- a/apps/api/src/domains/app-data/routes.ts +++ b/apps/api/src/domains/app-data/routes.ts @@ -44,6 +44,11 @@ export function registerAppDataRoutes(app: App, repository: AppDataRepository): const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at)); return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status); }); + // The client polls this instead of re-fetching every domain; see SyncSummary. + app.get('/v1/me/sync', async c => { + const result = await run(() => repository.syncSummary(c.get('identity').id)); + return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status); + }); app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id))); app.post('/v1/me/nudge', async c => { const result = await run(() => repository.sendNudge(c.get('identity').id)); diff --git a/apps/api/src/domains/billing/repository.ts b/apps/api/src/domains/billing/repository.ts index 2286dd9..aabe999 100644 --- a/apps/api/src/domains/billing/repository.ts +++ b/apps/api/src/domains/billing/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js'; interface ProfileRecord extends QueryResultRow { @@ -43,9 +44,12 @@ async function transaction(pool: Pool, operation: (client: PoolClient) => Pro export class PostgresBillingRepository implements BillingRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } async processRevenueCatEvent( @@ -207,6 +211,6 @@ export class PostgresBillingRepository implements BillingRepository { } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/chat/repository.ts b/apps/api/src/domains/chat/repository.ts index c6916b0..5f6aad5 100644 --- a/apps/api/src/domains/chat/repository.ts +++ b/apps/api/src/domains/chat/repository.ts @@ -1,4 +1,5 @@ import { Pool, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; export type ChatMediaType = 'image' | 'video' | null; export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other'; @@ -45,8 +46,37 @@ export class ChatError extends Error { } } +/** + * The reply to a message poll. A request without a cursor is a full snapshot; a + * request with one carries only what changed, so a quiet chat costs an empty + * array rather than the whole backlog. + */ +export interface MessagesPage { + /** Newest first. Messages created or changed since the cursor, or the snapshot. */ + messages: ChatMessage[]; + /** Messages the caller deleted for themselves since the cursor. */ + removed_ids: string[]; + /** Cursor to send as `since` on the next poll. */ + server_time: string; + /** True when `messages` is a complete snapshot the client may replace its cache with. */ + complete: boolean; + /** Present only when the caller asked for it, folding the typing poll into this one. */ + typing?: PartnerTyping; +} + +export interface PartnerTyping { + typing: boolean; + expires_at: string | null; +} + +export interface ListMessagesOptions { + /** ISO timestamp from a previous `server_time`. Omit for a full snapshot. */ + since?: string; + includeTyping?: boolean; +} + export interface ChatRepository { - listMessages(userId: string, matchId: string): Promise; + listMessages(userId: string, matchId: string, options?: ListMessagesOptions): Promise; sendText(userId: string, matchId: string, content: string): Promise; unreadCounts(userId: string): Promise<{ total: number; by_match: Record }>; markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>; @@ -55,13 +85,21 @@ export interface ChatRepository { deleteForEveryone(userId: string, messageId: string): Promise; report(userId: string, messageId: string, reason: ReportReason): Promise; setTyping(userId: string, matchId: string, ttlMs: number): Promise; - getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>; + getPartnerTyping(userId: string, matchId: string): Promise; close(): Promise; } +// `media_expired` is derived rather than read. Nothing ever writes the stored +// column — it is inserted `false` and never updated — so a video that reached +// `media_expires_at` still arrived flagged as playable, while the media itself +// was already refused (see `accessible` in the media repository, which treats +// `media_expired or media_expires_at <= now()` as gone). Deriving it here is the +// same rule the media side already applies, and it is what makes the client's +// "Video expired" state reachable at all. const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at, m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at, - m.media_expired, m.media_viewed_at, m.version, m.encrypted_content, + (m.media_expired or (m.media_expires_at is not null and m.media_expires_at <= now())) as media_expired, + m.media_viewed_at, m.version, m.encrypted_content, m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`; function iso(value: Date | null): string | null { @@ -82,9 +120,12 @@ function toMessage(row: MessageRecord): ChatMessage { export class PostgresChatRepository implements ChatRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } private async assertMatchAccess(userId: string, matchId: string): Promise { @@ -112,20 +153,68 @@ export class PostgresChatRepository implements ChatRepository { return row; } - async listMessages(userId: string, matchId: string): Promise { + /** + * Without `since` this is the previous behaviour: the newest 500 visible messages. + * With `since` it returns only rows whose visible state moved after that instant — + * new messages, existing ones whose delivery, read, deletion or media-view + * timestamp advanced, and videos whose media expired inside the window — plus the + * ids the caller has since deleted for themselves. + * + * Moderation metadata (`moderation_status`, `flag_reason`, `category`) is not + * covered: the classifier and the admin tool rewrite those columns without + * advancing any timestamp, so a delta cannot see them move. No client renders + * them today, so nothing user-visible goes stale; giving them a cursor would + * mean adding a column to `messages`. + * Rows are returned whole rather than as patches so the client merges by id and + * needs no second shape to interpret. + * + * The returned cursor is deliberately a couple of seconds behind the server clock: + * a transaction that started before this read but committed after it would + * otherwise fall into the gap between two polls. The overlap re-delivers a handful + * of rows the client already has, which its merge-by-id absorbs. + */ + async listMessages(userId: string, matchId: string, options: ListMessagesOptions = {}): Promise { await this.assertMatchAccess(userId, matchId); - const result = await this.pool.query( - `select ${columns} - from messages m - where m.match_id = $1 - and not exists ( - select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2 - ) - order by m.created_at desc, m.id desc - limit 500`, - [matchId, userId], - ); - return result.rows.map(toMessage); + const since = options.since ?? null; + const visible = `not exists (select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2)`; + // Expiry is the one visible change no row write announces: the clock crosses + // `media_expires_at` and nothing is updated, so no timestamp moves past the + // cursor. Comparing the deadline against the window instead emits the row in + // the single poll whose `(since, now()]` contains it, which is exactly when + // the derived `media_expired` above flips. + const changed = `(m.created_at > $3 or m.read_at > $3 or m.delivered_at > $3 + or m.deleted_at > $3 or m.media_viewed_at > $3 + or (m.media_expires_at > $3 and m.media_expires_at <= now()))`; + + const [clock, messages, removed, typing] = await Promise.all([ + this.pool.query<{ cursor: Date }>(`select now() - interval '2 seconds' as cursor`), + this.pool.query( + `select ${columns} + from messages m + where m.match_id = $1 and ${visible}${since === null ? '' : ` and ${changed}`} + order by m.created_at desc, m.id desc + limit 500`, + since === null ? [matchId, userId] : [matchId, userId, since], + ), + since === null + ? null + : this.pool.query<{ message_id: string }>( + `select d.message_id + from message_deletions d + join messages m on m.id = d.message_id + where m.match_id = $1 and d.user_id = $2 and d.deleted_at > $3`, + [matchId, userId, since], + ), + options.includeTyping ? this.partnerTyping(userId, matchId) : null, + ]); + + return { + messages: messages.rows.map(toMessage), + removed_ids: removed?.rows.map((row) => row.message_id) ?? [], + server_time: clock.rows[0]!.cursor.toISOString(), + complete: since === null, + ...(typing ? { typing } : {}), + }; } async sendText(userId: string, matchId: string, content: string): Promise { @@ -226,8 +315,8 @@ export class PostgresChatRepository implements ChatRepository { ); } - async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> { - await this.assertMatchAccess(userId, matchId); + /** Access already asserted by the caller, so the message poll can fold this in for free. */ + private async partnerTyping(userId: string, matchId: string): Promise { const result = await this.pool.query<{ expires_at: Date }>( `select expires_at from chat_typing_states where match_id = $1 and user_id <> $2 and expires_at > now() @@ -238,8 +327,13 @@ export class PostgresChatRepository implements ChatRepository { return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) }; } + async getPartnerTyping(userId: string, matchId: string): Promise { + await this.assertMatchAccess(userId, matchId); + return this.partnerTyping(userId, matchId); + } + async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/chat/routes.ts b/apps/api/src/domains/chat/routes.ts index a09104a..b2f0e19 100644 --- a/apps/api/src/domains/chat/routes.ts +++ b/apps/api/src/domains/chat/routes.ts @@ -8,6 +8,7 @@ type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>; const id = z.string().uuid(); const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict(); const reportBody = z.object({ reason: z.enum(['harassment', 'spam', 'inappropriate_content', 'other']) }).strict(); +const since = z.string().datetime({ offset: true }); function error(code: string, message: string): ApiErrorResponse { return { error: { code, message } }; @@ -25,11 +26,20 @@ async function result(operation: () => Promise) { export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void { app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id))); + // `since` turns this into a delta poll and `typing=true` folds the typing check + // into the same request. Both are optional: a call with neither still answers with + // the full snapshot under the `messages` key it always used. app.get('/v1/matches/:matchId/messages', async (c) => { const parsedId = id.safeParse(c.req.param('matchId')); if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400); - const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data)); - return response.ok ? c.json({ messages: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status); + const rawSince = c.req.query('since'); + const parsedSince = rawSince === undefined ? undefined : since.safeParse(rawSince); + if (parsedSince && !parsedSince.success) return c.json(error('invalid_since', 'since must be an ISO-8601 timestamp'), 400); + const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data, { + ...(parsedSince?.success ? { since: parsedSince.data } : {}), + includeTyping: c.req.query('typing') === 'true', + })); + return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status); }); app.post('/v1/matches/:matchId/messages', async (c) => { diff --git a/apps/api/src/domains/couples/repository.ts b/apps/api/src/domains/couples/repository.ts index ca4852f..97b850c 100644 --- a/apps/api/src/domains/couples/repository.ts +++ b/apps/api/src/domains/couples/repository.ts @@ -1,5 +1,6 @@ import type { Couple, Profile } from '@sauci/shared'; import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import { CoupleError, type CoupleStateResponse } from './types.js'; interface ProfileRecord extends QueryResultRow { @@ -93,9 +94,12 @@ async function transaction(pool: Pool, work: (client: PoolClient) => Promise< export class PostgresCoupleRepository implements CoupleRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } async getState(userId: string): Promise { @@ -199,6 +203,6 @@ export class PostgresCoupleRepository implements CoupleRepository { } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/dares/repository.ts b/apps/api/src/domains/dares/repository.ts index c3b04ab..5902a2d 100644 --- a/apps/api/src/domains/dares/repository.ts +++ b/apps/api/src/domains/dares/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; export type DareStatus = | 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled'; @@ -181,9 +182,12 @@ function toSentDare(row: SentDareRecord, viewerId: string): SentDare { export class PostgresDaresRepository implements DaresRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } /** @@ -475,6 +479,6 @@ export class PostgresDaresRepository implements DaresRepository { } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/media/repository.ts b/apps/api/src/domains/media/repository.ts index 91c6222..52af5cc 100644 --- a/apps/api/src/domains/media/repository.ts +++ b/apps/api/src/domains/media/repository.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import type { MediaKind, MediaObject, MediaUploadContext } from './types.js'; interface MediaRow extends QueryResultRow { @@ -28,7 +29,8 @@ function object(row: MediaRow): MediaObject { export class PostgresMediaRepository implements MediaRepository { private readonly pool: Pool; - constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); } + private readonly ownsPool: boolean; + constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; } async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) { const client = await this.pool.connect(); @@ -102,5 +104,5 @@ export class PostgresMediaRepository implements MediaRepository { async acknowledgeDeletion(storageKey: string): Promise { await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]); } - async close() { await this.pool.end(); } + async close(): Promise { await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/operations/repository.ts b/apps/api/src/domains/operations/repository.ts index 61baa58..8792c5b 100644 --- a/apps/api/src/domains/operations/repository.ts +++ b/apps/api/src/domains/operations/repository.ts @@ -1,4 +1,5 @@ import { Pool, type PoolClient, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js'; interface OutboxRow extends QueryResultRow { @@ -31,7 +32,8 @@ async function transaction(pool: Pool, work: (client: PoolClient) => Promise< export class PostgresOperationsRepository implements OperationsRepository { private readonly pool: Pool; - constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); } + private readonly ownsPool: boolean; + constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; } async produce(now: Date, limit=100): Promise { return transaction(this.pool, async (client) => { @@ -312,7 +314,7 @@ export class PostgresOperationsRepository implements OperationsRepository { async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) { await this.pool.query('update messages set moderation_status=$2,flag_reason=$3,category=$4 where id=$1',[id,status,reason,category]); } - async close() { await this.pool.end(); } + async close(): Promise { await closeResolvedPool(this.pool, this.ownsPool); } } function streakCopy(days:number) { diff --git a/apps/api/src/domains/packs/repository.ts b/apps/api/src/domains/packs/repository.ts index 910a7af..bfd9c17 100644 --- a/apps/api/src/domains/packs/repository.ts +++ b/apps/api/src/domains/packs/repository.ts @@ -1,4 +1,5 @@ import { Pool } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; export interface PackCategory { id: string; @@ -69,9 +70,12 @@ interface ProfilePreferencesRow { export class PostgresPacksRepository implements PacksRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } private async profile(userId: string): Promise { @@ -215,6 +219,6 @@ export class PostgresPacksRepository implements PacksRepository { } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/domains/profile-settings/repository.ts b/apps/api/src/domains/profile-settings/repository.ts index 81dd59e..54d78d3 100644 --- a/apps/api/src/domains/profile-settings/repository.ts +++ b/apps/api/src/domains/profile-settings/repository.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { Pool, type QueryResultRow } from 'pg'; +import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js'; import type { FeedbackSubmission, NotificationPreferenceKey, @@ -80,9 +81,12 @@ function assertChanged(rowCount: number | null): void { export class PostgresProfileSettingsRepository implements ProfileSettingsRepository { private readonly pool: Pool; + private readonly ownsPool: boolean; - constructor(databaseUrl: string) { - this.pool = new Pool({ connectionString: databaseUrl }); + constructor(connection: DatabaseConnection) { + const resolved = resolvePool(connection); + this.pool = resolved.pool; + this.ownsPool = resolved.owned; } async updateProfile(userId: string, update: ProfileUpdate): Promise { @@ -146,6 +150,6 @@ export class PostgresProfileSettingsRepository implements ProfileSettingsReposit } async close(): Promise { - await this.pool.end(); + await closeResolvedPool(this.pool, this.ownsPool); } } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d85eb2f..0121673 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,6 +2,7 @@ import { serve } from '@hono/node-server'; import { createApp } from './app.js'; import { createAuthVerifier } from './auth.js'; import { loadConfig } from './config.js'; +import { createDatabasePool } from './db/pool.js'; import { PostgresRepository } from './db/repository.js'; import { PostgresAppDataRepository } from './domains/app-data/repository.js'; import { AdminRequestAuth } from './domains/admin/auth.js'; @@ -26,30 +27,34 @@ import { FilesystemMediaStorage, PostgresMediaRepository } from './domains/media const config = loadConfig(); const authVerifier = createAuthVerifier(config); -const repository = new PostgresRepository(config.databaseUrl); -const coupleRepository = new PostgresCoupleRepository(config.databaseUrl); -const packsRepository = new PostgresPacksRepository(config.databaseUrl); -const answersRepository = new PostgresAnswersRepository(config.databaseUrl); -const chatRepository = new PostgresChatRepository(config.databaseUrl); -const daresRepository = new PostgresDaresRepository(config.databaseUrl); -const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl); -const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl); +// One deliberately bounded pool for the whole process. Repositories used to open +// a default-sized pool each, so a single API instance could hold twelve times the +// connections a self-hosted PostgreSQL was sized for. +const pool = createDatabasePool(config.databaseUrl, config.databasePool); +const repository = new PostgresRepository(pool); +const coupleRepository = new PostgresCoupleRepository(pool); +const packsRepository = new PostgresPacksRepository(pool); +const answersRepository = new PostgresAnswersRepository(pool); +const chatRepository = new PostgresChatRepository(pool); +const daresRepository = new PostgresDaresRepository(pool); +const profileSettingsRepository = new PostgresProfileSettingsRepository(pool); +const accountOperationsRepository = new PostgresAccountOperationsRepository(pool); const accountOperationsService = new AccountOperationsService( accountOperationsRepository, new SupabaseAuthAdminClient(config.authIssuer, config.supabaseAuthServiceRoleKey), new HttpRevenueCatClient(config.revenueCatApiKey, config.revenueCatEntitlementId), new ExpoPartnerNotifier(), ); -const mediaRepository = new PostgresMediaRepository(config.databaseUrl); +const mediaRepository = new PostgresMediaRepository(pool); const mediaStorage = new FilesystemMediaStorage( config.mediaRoot, config.mediaSigningSecret, config.mediaPublicBaseUrl, ); -const appDataRepository = new PostgresAppDataRepository(config.databaseUrl); -const billingRepository = new PostgresBillingRepository(config.databaseUrl); +const appDataRepository = new PostgresAppDataRepository(pool); +const billingRepository = new PostgresBillingRepository(pool); const billingService = new BillingService(billingRepository, config.revenueCatWebhookSecret); -const adminRepository = new PostgresAdminRepository(config.databaseUrl, { +const adminRepository = new PostgresAdminRepository(pool, { adminPrivateKeyJwk: config.adminPrivateKeyJwk, mediaRoot: config.mediaRoot, authDirectory: new SupabaseAdminAuthDirectory(config.authIssuer, config.supabaseAuthServiceRoleKey), @@ -94,20 +99,9 @@ async function shutdown(signal: string): Promise { server.close(async () => { try { - await Promise.all([ - repository.close(), - coupleRepository.close(), - packsRepository.close(), - answersRepository.close(), - chatRepository.close(), - daresRepository.close(), - profileSettingsRepository.close(), - accountOperationsRepository.close(), - mediaRepository.close(), - appDataRepository.close(), - billingRepository.close(), - adminRepository.close(), - ]); + // Repositories share the process pool and no longer own a connection each, + // so ending the pool once is the whole of the database shutdown. + await pool.end(); process.exit(0); } catch (error) { console.error('Failed to close database connections', error); diff --git a/apps/api/test/answers.integration.test.ts b/apps/api/test/answers.integration.test.ts index 9530f29..f32b728 100644 --- a/apps/api/test/answers.integration.test.ts +++ b/apps/api/test/answers.integration.test.ts @@ -6,7 +6,7 @@ import { PostgresAnswersRepository } from '../src/domains/answers/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('Answer integration tests only permit localhost'); describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository; const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc'; -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 PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();}); +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 PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();}); it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);}); it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});}); it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);}); @@ -18,6 +18,71 @@ it('answers the streak from the caller side rather than by stored position',asyn expect(await repo.streak(u1)).toMatchObject({you_answered_today:true,partner_answered_today:false,partner_name:'Alex',current_streak:0}); expect(await repo.streak(u2)).toMatchObject({you_answered_today:false,partner_answered_today:true,partner_name:'Sam',current_streak:0});}); +describe('match pagination',()=>{ + const paged=Array.from({length:5},(_,index)=>`d0000000-0000-4000-8000-00000000000${index}`); + const pagedQuestions=Array.from({length:5},(_,index)=>`f0000000-0000-4000-8000-00000000000${index}`); + const otherMatch='e0000000-0000-4000-8000-000000000000'; + beforeAll(async()=>{ + await pool.query('delete from match_archives');await pool.query('delete from messages');await pool.query('delete from matches'); + // One question per match: a couple may only match a given question once. + for(const[index,id]of pagedQuestions.entries())await pool.query("insert into questions(id,pack_id,text,intensity) values($1,$2,$3,2)",[id,pack,`Paged ${index}`]); + // Distinct creation times so the ordering assertion is about the query, not tie-breaking. + for(const[index,id]of paged.entries())await pool.query("insert into matches(id,couple_id,question_id,match_type,created_at) values($1,$2,$3,'yes_yes',now()-($4::text||' minutes')::interval)",[id,couple,pagedQuestions[index],String(index)]); + // A match belonging to another couple must never appear, whatever the page. + await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[otherMatch,other,q]); + // Two unread from the partner on the oldest match; one message of our own, which is never unread. + await pool.query("insert into messages(match_id,user_id,content) values($1,$2,'a'),($1,$2,'b'),($1,$3,'mine')",[paged[4],u2,u1]); + }); + + it('counts once on the first page, never again, and still reports hasMore',async()=>{ + const first=await repo.matches(u1,0,2,false); + expect(first.totalCount).toBe(5); + expect(first.hasMore).toBe(true); + expect(first.matches).toHaveLength(2); + + // Later pages skip the count entirely: the client already has it and paging + // is decided by hasMore, which comes from reading limit + 1 rows. + const second=await repo.matches(u1,1,2,false); + expect(second.totalCount).toBeNull(); + expect(second.hasMore).toBe(true); + const last=await repo.matches(u1,2,2,false); + expect(last.totalCount).toBeNull(); + expect(last.hasMore).toBe(false); + expect(last.matches).toHaveLength(1); + + // An exactly-full final page must not claim another page exists. + expect(await repo.matches(u1,0,5,false)).toMatchObject({hasMore:false,totalCount:5}); + }); + + it('keeps unread-first ordering, aggregates unread once, and isolates the other couple',async()=>{ + const page=await repo.matches(u1,0,5,false); + // The unread match sorts ahead of newer ones; the rest stay newest-first. + expect(page.matches.map(item=>item.id)).toEqual([paged[4],paged[0],paged[1],paged[2],paged[3]]); + expect(page.matches[0]).toMatchObject({unreadCount:2}); + // Our own message is not unread to us, and everything else is zero. + expect(page.matches.slice(1).every(item=>item.unreadCount===0)).toBe(true); + // Unread is per viewer: the partner's own two messages are not unread to them, + // but ours is, so the same thread reports a different count on each side. + expect((await repo.matches(u2,0,5,false)).matches.find(item=>item.id===paged[4])?.unreadCount).toBe(1); + // The other couple's match is invisible here, and their member sees only theirs. + expect(page.matches.some(item=>item.id===otherMatch)).toBe(false); + expect((await repo.matches(outsider,0,5,false)).matches.map(item=>item.id)).toEqual([otherMatch]); + }); + + it('pages the archived view separately and per user',async()=>{ + await repo.archive(u1,paged[0],true); + const active=await repo.matches(u1,0,5,false); + expect(active.matches.some(item=>item.id===paged[0])).toBe(false); + expect(active.totalCount).toBe(4); + const archived=await repo.matches(u1,0,5,true); + expect(archived.matches.map(item=>item.id)).toEqual([paged[0]]); + expect(archived).toMatchObject({totalCount:1,hasMore:false}); + // Archiving is per user, so the partner still sees it in their active list. + expect((await repo.matches(u2,0,5,false)).matches.some(item=>item.id===paged[0])).toBe(true); + await repo.archive(u1,paged[0],false); + }); +}); + it('counts the day in the couple shared zone and prefers the first reported member zone',async()=>{await pool.query('update profiles set timezone=$2 where id=$1',[u1,'Pacific/Auckland']);await pool.query('update profiles set timezone=$2 where id=$1',[u2,'America/Los_Angeles']); await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]); await repo.submit(u1,{questionId:q,answer:'yes'}); diff --git a/apps/api/test/answers.routes.test.ts b/apps/api/test/answers.routes.test.ts index 7a98c43..1c8e5e6 100644 --- a/apps/api/test/answers.routes.test.ts +++ b/apps/api/test/answers.routes.test.ts @@ -10,7 +10,7 @@ const questionId='22222222-2222-4222-8222-222222222222'; function repo():AnswersRepository{return { recommended:vi.fn(async()=>[]),pending:vi.fn(async()=>[]),answerGap:vi.fn(async()=>({unanswered_by_partner:0,threshold:10,is_blocked:false})), dailyLimit:vi.fn(async()=>({responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false})), - submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};} + submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0,hasMore:false})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};} function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthIdentity}}>();app.use('/v1/*',async(c,n)=>{c.set('identity',identity);await n();});registerAnswerRoutes(app,r);return app;} describe('answer routes',()=>{ @@ -18,6 +18,16 @@ describe('answer routes',()=>{ it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached',{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}));const blocked=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(blocked.status).toBe(429); // The client renders the limit screen straight from this body, so its shape is a contract. expect(await blocked.json()).toMatchObject({error:{code:'daily_limit'},details:{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}});}); + it('reports pagination from the repository, including a page with no total',async()=>{const r=repo();const a=app(r); + // Later pages skip the count entirely, so `hasMore` — not a client-side + // comparison against totalCount — is what decides whether to keep paging. + vi.mocked(r.matches).mockResolvedValueOnce({matches:[],totalCount:null,hasMore:true}); + const later=await a.request('/v1/matches?page=2&limit=20'); + expect(later.status).toBe(200); + expect(await later.json()).toMatchObject({totalCount:null,hasMore:true}); + expect(r.matches).toHaveBeenCalledWith(identity.id,2,20,false); + await a.request('/v1/matches?page=0&limit=20&archived=true'); + expect(r.matches).toHaveBeenLastCalledWith(identity.id,0,20,true);}); it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);}); }); diff --git a/apps/api/test/api.integration.test.ts b/apps/api/test/api.integration.test.ts index 78e3e7c..1ed83fb 100644 --- a/apps/api/test/api.integration.test.ts +++ b/apps/api/test/api.integration.test.ts @@ -75,6 +75,31 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration', }, }); + // Reading the profile again must not rewrite it. Every authenticated request the + // app makes starts here, so an unconditional upsert turned a read into a write + // on the busiest path in the API. + const stamps = async () => (await pool.query<{ auth_synced_at: Date | null; updated_at: Date }>( + 'select auth_synced_at, updated_at from profiles where id = $1', + ['33333333-3333-4333-8333-333333333333'], + )).rows[0]; + const created = await stamps(); + expect(created.auth_synced_at).not.toBeNull(); + + for (let attempt = 0; attempt < 3; attempt += 1) { + expect((await app.request('/v1/me', { headers })).status).toBe(200); + } + const unchanged = await stamps(); + expect(unchanged.auth_synced_at?.getTime()).toBe(created.auth_synced_at?.getTime()); + expect(unchanged.updated_at.getTime()).toBe(created.updated_at.getTime()); + + // A genuine identity change still writes through, so first use and a changed + // email are both recorded rather than skipped. + await pool.query('update profiles set email = $2 where id = $1', ['33333333-3333-4333-8333-333333333333', 'stale@sauci.test']); + expect((await app.request('/v1/me', { headers })).status).toBe(200); + const resynced = await stamps(); + expect(resynced.auth_synced_at!.getTime()).toBeGreaterThan(created.auth_synced_at!.getTime()); + expect(await (await app.request('/v1/me', { headers })).json()).toMatchObject({ profile: { email: 'integration@sauci.test' } }); + expect((await app.request('/v1/me/feature-interests/better-chat', { method: 'PUT', headers })).status).toBe(200); expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json()) .toEqual({ feature: 'better-chat', interested: true }); diff --git a/apps/api/test/app-data.integration.test.ts b/apps/api/test/app-data.integration.test.ts index 384e982..ece1dd3 100644 --- a/apps/api/test/app-data.integration.test.ts +++ b/apps/api/test/app-data.integration.test.ts @@ -30,6 +30,99 @@ describe.skipIf(!databaseUrl || !local)('app-data repository + PostgreSQL', () = await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'x.jpg','image')",[message,match,alice]); }); afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();}); + it('summarises change markers per couple without leaking another couple state',async()=>{ + // This one small read replaced a five-second refresh of profile, couple, matches, + // pending questions, packs and unread counts, so its markers have to move for + // exactly the changes those refreshes existed to notice. + const before=await repo.syncSummary(alice); + expect(before).toMatchObject({couple_id:couple,partner_id:bob,match_count:1,new_match_count:1,unread_total:0}); + expect(before.profile_updated_at).not.toBeNull(); + expect(Date.parse(before.server_time)).not.toBeNaN(); + + // The partner's own message is unread to them and not to us. + expect((await repo.syncSummary(bob)).unread_total).toBe(1); + + // A partner profile edit moves the partner marker, which is what tells the + // client to re-read the couple rather than everything. + await pool.query("update profiles set name='Bobby', updated_at=now() where id=$1",[bob]); + const afterPartner=await repo.syncSummary(alice); + expect(Date.parse(afterPartner.partner_updated_at!)).toBeGreaterThan(Date.parse(before.partner_updated_at!)); + expect(afterPartner.profile_updated_at).toBe(before.profile_updated_at); + + // couple_packs carries no timestamp, so equal-and-opposite toggles have to be + // caught by a digest rather than a max(updated_at). + const emptyDigest=before.enabled_packs_fingerprint; + expect(emptyDigest).toEqual(expect.any(String)); + await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]); + const enabled=await repo.syncSummary(alice); + expect(enabled.enabled_packs_fingerprint).not.toBe(emptyDigest); + await pool.query('update couple_packs set enabled=false where couple_id=$1 and pack_id=$2',[couple,pack]); + expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(emptyDigest); + // Toggling back must land on the same digest, so the client stops refetching. + await pool.query('update couple_packs set enabled=true where couple_id=$1 and pack_id=$2',[couple,pack]); + expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(enabled.enabled_packs_fingerprint); + + // An outsider's summary is about their own couple and never ours. + const other=await repo.syncSummary(outsider); + expect(other).toMatchObject({couple_id:otherCouple,partner_id:null,match_count:0,unread_total:0}); + }); + + it('moves the match-state fingerprint on an in-place edit that the count and latest-at markers miss',async()=>{ + // Editing an existing match's type/response summary (response editing) creates + // no new match and touches no created_at, so match_count/new_match_count/ + // latest_match_at all hold steady; only the state fingerprint must move. + const before=await repo.syncSummary(alice); + await pool.query("update matches set match_type='yes_maybe', response_summary=$2 where id=$1",[match,JSON.stringify({edited:true})]); + const after=await repo.syncSummary(alice); + expect(after.match_count).toBe(before.match_count); + expect(after.new_match_count).toBe(before.new_match_count); + expect(after.latest_match_at).toBe(before.latest_match_at); + expect(after.match_state_fingerprint).not.toBe(before.match_state_fingerprint); + // Reverting lands back on the same digest, so the client stops refetching. + await pool.query("update matches set match_type='yes_yes', response_summary=null where id=$1",[match]); + expect((await repo.syncSummary(alice)).match_state_fingerprint).toBe(before.match_state_fingerprint); + }); + + it('moves the match-unread fingerprint when unread redistributes without changing the total',async()=>{ + const secondMatch='cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + const secondQuestion='dddddddd-dddd-4ddd-8ddd-dddddddddddd'; + const originalUnread='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + const secondUnread='11111111-2222-4333-8444-555555555555'; + await pool.query("insert into questions(id,pack_id,text) values($1,$2,'Question 2')",[secondQuestion,pack]); + await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[secondMatch,couple,secondQuestion]); + // Alice's own message ('message', from beforeAll) is never unread to her, so + // give the original match a message from her partner to actually redistribute. + await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y.jpg','image')",[originalUnread,match,bob]); + await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y2.jpg','image')",[secondUnread,secondMatch,bob]); + + const before=await repo.syncSummary(alice); + expect(before.unread_total).toBe(2); + + // Read the original match's unread message while a new one lands in the + // second match: unread_total holds steady at 2, but which match owns it moves. + await pool.query('update messages set read_at=now() where id=$1',[originalUnread]); + const thirdMessage='ffffffff-ffff-4fff-8fff-ffffffffffff'; + await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'z.jpg','image')",[thirdMessage,secondMatch,bob]); + + const after=await repo.syncSummary(alice); + expect(after.unread_total).toBe(before.unread_total); + expect(after.match_unread_fingerprint).not.toBe(before.match_unread_fingerprint); + + // Clean up so later assertions in this suite see the original single-match state. + await pool.query('delete from messages where id=any($1::uuid[])',[[originalUnread,secondUnread,thirdMessage]]); + await pool.query('delete from matches where id=$1',[secondMatch]); + await pool.query('delete from questions where id=$1',[secondQuestion]); + }); + + it('moves the streak marker from couple_streaks.updated_at',async()=>{ + await pool.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[couple]); + const before=await repo.syncSummary(alice); + expect(before.streak_updated_at).toEqual(expect.any(String)); + await pool.query('update couple_streaks set current_streak=current_streak+1, updated_at=now() where couple_id=$1',[couple]); + const after=await repo.syncSummary(alice); + expect(Date.parse(after.streak_updated_at!)).toBeGreaterThan(Date.parse(before.streak_updated_at!)); + }); + it('returns only public catalog data and couple-owned match context',async()=>{ expect((await repo.packQuestions(alice,pack))[0]?.text).toBe('Question'); expect((await repo.matchContext(bob,match)).responses).toHaveLength(2); diff --git a/apps/api/test/app-data.routes.test.ts b/apps/api/test/app-data.routes.test.ts index 7235f1f..7f3fec1 100644 --- a/apps/api/test/app-data.routes.test.ts +++ b/apps/api/test/app-data.routes.test.ts @@ -16,6 +16,12 @@ function repository(): AppDataRepository { markMediaViewed: vi.fn(async () => ({ media_viewed_at: 'now', media_expires_at: null })), nudgeStatus: vi.fn(async () => ({ last_nudge_sent_at: null })), sendNudge: vi.fn(async () => ({ success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: 'later' })), + syncSummary: vi.fn(async () => ({ + server_time: 'now', couple_id: null, profile_updated_at: null, partner_id: null, partner_updated_at: null, + match_count: 0, new_match_count: 0, latest_match_at: null, pending_yours: 0, pending_theirs: 0, + unread_total: 0, enabled_packs_fingerprint: '', + match_state_fingerprint: '', match_unread_fingerprint: '', streak_updated_at: null, + })), getLiveDraw: vi.fn(async () => ({ strokes: [], revision: 0, updated_at: null, updated_by: null })), putLiveDraw: vi.fn(async () => ({ strokes: [], revision: 1, updated_at: 'now', updated_by: identity.id })), close: vi.fn(async () => undefined), diff --git a/apps/api/test/chat.integration.test.ts b/apps/api/test/chat.integration.test.ts index ebd5cac..3d5ae10 100644 --- a/apps/api/test/chat.integration.test.ts +++ b/apps/api/test/chat.integration.test.ts @@ -48,7 +48,7 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () => it('sends, lists, counts and reads text messages for couple members only', async () => { const sent = await repository.sendText(alice, match, 'Hello'); - expect((await repository.listMessages(bob, match))[0]?.content).toBe('Hello'); + expect((await repository.listMessages(bob, match)).messages[0]?.content).toBe('Hello'); expect(await repository.unreadCounts(bob)).toEqual({ total: 1, by_match: { [match]: 1 } }); expect((await repository.markDelivered(bob, sent.id)).delivered_at).not.toBeNull(); expect((await repository.markMatchRead(bob, match)).updated).toBe(1); @@ -58,14 +58,110 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () => it('enforces per-user and author-only deletions plus reports', async () => { const sent = await repository.sendText(alice, match, 'Moderate me'); await repository.deleteForSelf(bob, sent.id); - expect((await repository.listMessages(bob, match)).some(item => item.id === sent.id)).toBe(false); - expect((await repository.listMessages(alice, match)).some(item => item.id === sent.id)).toBe(true); + expect((await repository.listMessages(bob, match)).messages.some(item => item.id === sent.id)).toBe(false); + expect((await repository.listMessages(alice, match)).messages.some(item => item.id === sent.id)).toBe(true); await expect(repository.deleteForEveryone(bob, sent.id)).rejects.toMatchObject({ code: 'not_message_author' }); expect((await repository.deleteForEveryone(alice, sent.id)).deleted_at).not.toBeNull(); await repository.report(bob, sent.id, 'harassment'); await expect(repository.report(bob, sent.id, 'spam')).rejects.toBeInstanceOf(ChatError); }); + it('answers a delta with only what changed, and the same rows a full read would give', async () => { + const before = await repository.listMessages(bob, match); + const cursor = before.server_time; + expect(before.complete).toBe(true); + + // Nothing has happened since the cursor, so the delta carries no row the client + // has not already seen. The cursor is deliberately held a couple of seconds + // behind wall clock so a write racing the read is repeated rather than lost; + // that repetition is absorbed by merging on id, never by blanking the thread. + const known = new Set(before.messages.map(item => item.id)); + const quiet = await repository.listMessages(bob, match, { since: cursor }); + expect(quiet.complete).toBe(false); + expect(quiet.messages.every(item => known.has(item.id))).toBe(true); + expect(quiet.removed_ids?.every(id => !known.has(id))).toBe(true); + + const fresh = await repository.sendText(alice, match, 'Delta arrival'); + const delta = await repository.listMessages(bob, match, { since: cursor }); + expect(delta.messages.map(item => item.id)).toContain(fresh.id); + // Media and moderation columns must survive the narrower query. + expect(delta.messages.find(item => item.id === fresh.id)).toMatchObject({ + content: 'Delta arrival', media_path: null, media_type: null, media_expired: false, read_at: null, + }); + + // A status change with no new row still reaches the sender through the delta. + const afterSend = await repository.listMessages(alice, match); + await repository.markMatchRead(bob, match); + const readDelta = await repository.listMessages(alice, match, { since: afterSend.server_time }); + expect(readDelta.messages.find(item => item.id === fresh.id)?.read_at).not.toBeNull(); + + // So does a deletion, which arrives as a removal rather than a silent gap. + const doomed = await repository.sendText(alice, match, 'Delete me'); + const beforeDelete = await repository.listMessages(bob, match); + await repository.deleteForSelf(bob, doomed.id); + const removal = await repository.listMessages(bob, match, { since: beforeDelete.server_time }); + expect(removal.removed_ids).toContain(doomed.id); + + // Ordering is the contract the screen renders against: newest first, both ways. + const full = await repository.listMessages(bob, match); + const timestamps = full.messages.map(item => Date.parse(item.created_at)); + expect([...timestamps].sort((a, b) => b - a)).toEqual(timestamps); + + // Access control is unchanged on the delta path. + await expect(repository.listMessages(outsider, match, { since: cursor })).rejects.toMatchObject({ code: 'match_not_found' }); + }); + + it('surfaces an expiring video to a chat that is already open, and only in the window it expires', async () => { + // Expiry is the one visible change no write announces: the deadline simply + // passes. Backdated so neither `created_at` nor `media_viewed_at` can be what + // puts the row in a delta — only the deadline crossing may. + const video = await repository.sendText(alice, match, 'Sent a video'); + await pool.query( + `update messages + set media_path = 'media:expiring', media_type = 'video', + moderation_status = 'safe', encrypted_content = 'cipher', encryption_iv = 'iv', + created_at = now() - interval '1 hour', + media_viewed_at = now() - interval '1 hour', + media_expires_at = now() + interval '1 hour' + where id = $1`, + [video.id], + ); + + const before = await repository.listMessages(bob, match); + expect(before.messages.find(item => item.id === video.id)).toMatchObject({ media_expired: false }); + + // A deadline still in the future is not a change, so the row is not re-sent on + // every poll until it arrives. + const quiet = await repository.listMessages(bob, match, { since: before.server_time }); + expect(quiet.messages.some(item => item.id === video.id)).toBe(false); + + // The deadline now falls inside this poll's window. + await pool.query(`update messages set media_expires_at = now() - interval '1 second' where id = $1`, [video.id]); + const delta = await repository.listMessages(bob, match, { since: before.server_time }); + const expired = delta.messages.find(item => item.id === video.id); + // Media, encryption and moderation columns still arrive whole on the delta path. + expect(expired).toMatchObject({ + media_expired: true, media_type: 'video', media_path: 'media:expiring', + moderation_status: 'safe', encrypted_content: 'cipher', encryption_iv: 'iv', + }); + expect(expired?.media_expires_at).not.toBeNull(); + expect(expired?.media_viewed_at).not.toBeNull(); + + // A full read agrees, so leaving and re-entering the chat shows the same thing. + expect((await repository.listMessages(bob, match)).messages.find(item => item.id === video.id)) + .toMatchObject({ media_expired: true }); + }); + + it('folds the partner typing check into the message read when asked', async () => { + await repository.setTyping(alice, match, 4_000); + const withTyping = await repository.listMessages(bob, match, { includeTyping: true }); + expect(withTyping.typing?.typing).toBe(true); + // Own typing is never reflected back, so the sender does not see themselves. + expect((await repository.listMessages(alice, match, { includeTyping: true })).typing?.typing).toBe(false); + // And it stays absent unless requested, so the default read costs no extra work. + expect((await repository.listMessages(bob, match)).typing).toBeUndefined(); + }); + it('stores typing state with a short expiry and exposes only the partner state', async () => { await repository.setTyping(alice, match, 4_000); expect((await repository.getPartnerTyping(bob, match)).typing).toBe(true); diff --git a/apps/api/test/chat.routes.test.ts b/apps/api/test/chat.routes.test.ts index 9dc0867..548f019 100644 --- a/apps/api/test/chat.routes.test.ts +++ b/apps/api/test/chat.routes.test.ts @@ -11,7 +11,8 @@ const message = { id: messageId, match_id: matchId, user_id: identity.id, conten function setup() { const repository: ChatRepository = { - listMessages: vi.fn(async () => [message]), sendText: vi.fn(async () => message), + listMessages: vi.fn(async () => ({ messages: [message], removed_ids: [], server_time: new Date().toISOString(), complete: true })), + sendText: vi.fn(async () => message), unreadCounts: vi.fn(async () => ({ total: 1, by_match: { [matchId]: 1 } })), markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })), markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined), @@ -43,6 +44,28 @@ describe('chat routes', () => { expect(repository.sendText).not.toHaveBeenCalled(); }); + it('passes a delta cursor and folded typing check through to the repository', async () => { + const { app, repository } = setup(); + const since = '2026-08-28T10:00:00.000Z'; + const delta = await app.request(`/v1/matches/${matchId}/messages?since=${encodeURIComponent(since)}&typing=true`); + expect(delta.status).toBe(200); + expect(repository.listMessages).toHaveBeenCalledWith(identity.id, matchId, { since, includeTyping: true }); + + // Omitting both keeps the full-snapshot contract the client had before. + const snapshot = await app.request(`/v1/matches/${matchId}/messages`); + expect(snapshot.status).toBe(200); + expect(await snapshot.json()).toMatchObject({ messages: [{ id: messageId }] }); + expect(repository.listMessages).toHaveBeenLastCalledWith(identity.id, matchId, { includeTyping: false }); + }); + + it('refuses a malformed cursor rather than silently reading everything', async () => { + const { app, repository } = setup(); + const response = await app.request(`/v1/matches/${matchId}/messages?since=yesterday`); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: 'invalid_since' } }); + expect(repository.listMessages).not.toHaveBeenCalled(); + }); + it('supports unread, typing, deletion and reporting actions', async () => { const { app, repository } = setup(); expect((await app.request('/v1/chat/unread')).status).toBe(200); diff --git a/apps/api/test/db.pool.test.ts b/apps/api/test/db.pool.test.ts new file mode 100644 index 0000000..9410765 --- /dev/null +++ b/apps/api/test/db.pool.test.ts @@ -0,0 +1,49 @@ +import { Pool } from 'pg'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_POOL_MAX, + closeResolvedPool, + createDatabasePool, + resolvePool, +} from '../src/db/pool.js'; + +const url = 'postgres://user:pass@127.0.0.1:5432/sauci_test'; + +afterEach(() => vi.restoreAllMocks()); + +describe('database pool', () => { + it('bounds the shared pool and only forwards timeouts that were configured', () => { + // A process-wide pool is the whole point of the change: an unbounded default + // multiplied by one pool per repository is what the self-hosted database had + // to absorb before. + const bounded = createDatabasePool(url, { max: 4, idleTimeoutMillis: 1_000, connectionTimeoutMillis: 2_000 }); + expect(bounded.options.max).toBe(4); + expect(bounded.options.idleTimeoutMillis).toBe(1_000); + expect(bounded.options.connectionTimeoutMillis).toBe(2_000); + + const defaults = createDatabasePool(url); + expect(defaults.options.max).toBe(DEFAULT_POOL_MAX); + + return Promise.all([bounded.end(), defaults.end()]); + }); + + it('owns a pool it built from a URL and never ends one it was handed', async () => { + // Repositories in the API process share one pool, so closing any single + // repository must not take the connections out from under the others. Tests + // still construct from a URL and must be able to close what they created. + const owned = resolvePool(url); + expect(owned.owned).toBe(true); + const ownedEnd = vi.spyOn(owned.pool, 'end'); + await closeResolvedPool(owned.pool, owned.owned); + expect(ownedEnd).toHaveBeenCalledTimes(1); + + const shared = new Pool({ connectionString: url }); + const borrowed = resolvePool(shared); + expect(borrowed.owned).toBe(false); + expect(borrowed.pool).toBe(shared); + const sharedEnd = vi.spyOn(shared, 'end'); + await closeResolvedPool(borrowed.pool, borrowed.owned); + expect(sharedEnd).not.toHaveBeenCalled(); + await shared.end(); + }); +}); diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index 980af67..f583d17 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -1,509 +1,495 @@ -import { Tabs, useRouter, useSegments } from "expo-router"; -import { Ionicons } from "@expo/vector-icons"; -import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native"; -import { BlurView } from "expo-blur"; -import { useEffect, useState, useCallback, useRef } from "react"; -import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store"; -import { colors, radius, spacing, typography, shadows } from "../../src/theme"; +import { Tabs, useRouter, useSegments } from "expo-router"; +import { Ionicons } from "@expo/vector-icons"; +import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native"; +import { BlurView } from "expo-blur"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, useStreakStore } from "../../src/store"; +import { colors, radius, spacing, typography, shadows } from "../../src/theme"; import { authClient } from "../../src/lib/authClient"; -import { isBiometricEnabled } from "../../src/lib/biometricAuth"; -import { checkAndRegisterPushToken } from "../../src/lib/notifications"; -import { syncBadgeCount } from "../../src/lib/badge"; -import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; -import { Events } from "../../src/lib/analytics"; -import { needsOnboarding } from "../../src/constants/onboarding"; -import type { Database } from "../../src/types/supabase"; - -type Message = Database["public"]["Tables"]["messages"]["Row"]; - -function MessageToastPreview({ - message, -}: { - message: Message; -}) { - if (message.media_path) { - const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image'; - return ( - - {fallback} - - ); - } - - return ( - - {message.content ?? 'New message'} - - ); -} - -function TabBarBackground() { - // Only use BlurView on iOS - Android experimental blur causes white overlay artifacts - // The solid rgba background in tabBarStyle provides the visual effect on Android - if (Platform.OS === 'ios') { - return ( - <> - - - - ); - } - return null; -} - -export default function AppLayout() { - const router = useRouter(); - const segments = useSegments(); - const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore(); - const { newMatchesCount, pendingQuestions, fetchPendingQuestions, fetchMatches } = useMatchStore(); - const { unreadCount, lastMessage, fetchUnreadCount, clearLastMessage } = useMessageStore(); - const { fetchEnabledPacks } = usePacksStore(); +import { isBiometricEnabled } from "../../src/lib/biometricAuth"; +import { checkAndRegisterPushToken } from "../../src/lib/notifications"; +import { useCoupleSync } from "../../src/hooks/useCoupleSync"; +import { syncBadgeCount } from "../../src/lib/badge"; +import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; +import { Events } from "../../src/lib/analytics"; +import { needsOnboarding } from "../../src/constants/onboarding"; +import type { Database } from "../../src/types/supabase"; + +type Message = Database["public"]["Tables"]["messages"]["Row"]; + +function MessageToastPreview({ + message, +}: { + message: Message; +}) { + if (message.media_path) { + const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image'; + return ( + + {fallback} + + ); + } + + return ( + + {message.content ?? 'New message'} + + ); +} + +function TabBarBackground() { + // Only use BlurView on iOS - Android experimental blur causes white overlay artifacts + // The solid rgba background in tabBarStyle provides the visual effect on Android + if (Platform.OS === 'ios') { + return ( + <> + + + + ); + } + return null; +} + +export default function AppLayout() { + const router = useRouter(); + const segments = useSegments(); + const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore(); + const { newMatchesCount, pendingQuestions, matches, fetchPendingQuestions, fetchMatches } = useMatchStore(); + const { unreadCount, lastMessage, clearLastMessage } = useMessageStore(); const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore(); - const { fetchStreak } = useStreakStore(); - const messageToastAnim = useRef(new Animated.Value(-100)).current; + const { fetchStreak } = useStreakStore(); + const messageToastAnim = useRef(new Animated.Value(-100)).current; const messageTimeoutRef = useRef | null>(null); - const [isLocked, setIsLocked] = useState(false); - const hasCheckedInitialBiometric = useRef(false); - const wentToBackgroundAt = useRef(null); + const [isLocked, setIsLocked] = useState(false); + const hasCheckedInitialBiometric = useRef(false); + const wentToBackgroundAt = useRef(null); const knownMatchIdsRef = useRef>(new Set()); const matchPollInitializedRef = useRef(false); - - // Check if we're on screens that should hide the tab bar - const segmentStrings = segments as string[]; - const isOnOnboarding = segmentStrings.includes("onboarding"); - const isOnPairing = segmentStrings.includes("pairing"); - const isOnChat = segmentStrings.includes("chat"); - const isOnLiveDraw = segmentStrings.includes("live-draw"); - const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2; - const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen; - - // Redirect to login when not authenticated, or to onboarding if not completed/outdated - useEffect(() => { - if (!isLoading && !isAuthenticated) { - router.replace("/(auth)/login"); - } else if (!isLoading && isAuthenticated && user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding) { - router.replace("/(app)/onboarding"); - } - }, [isLoading, isAuthenticated, user, router, isOnOnboarding]); - - // Check session validity on mount and when app comes to foreground - useEffect(() => { - const checkSession = async () => { + + // Check if we're on screens that should hide the tab bar + const segmentStrings = segments as string[]; + const isOnOnboarding = segmentStrings.includes("onboarding"); + const isOnPairing = segmentStrings.includes("pairing"); + const isOnChat = segmentStrings.includes("chat"); + const isOnLiveDraw = segmentStrings.includes("live-draw"); + const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2; + const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen; + + // Redirect to login when not authenticated, or to onboarding if not completed/outdated + useEffect(() => { + if (!isLoading && !isAuthenticated) { + router.replace("/(auth)/login"); + } else if (!isLoading && isAuthenticated && user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding) { + router.replace("/(app)/onboarding"); + } + }, [isLoading, isAuthenticated, user, router, isOnOnboarding]); + + // Check session validity on mount and when app comes to foreground + useEffect(() => { + const checkSession = async () => { const { data: { session } } = await authClient.auth.getSession(); - if (!session) { - // No valid session - sign out to clear local state - signOut(); - } - }; - - // Check immediately - checkSession(); - - // Update last active timestamp on mount - updateLastActive(); - - // Check when app comes to foreground - const subscription = AppState.addEventListener('change', (nextAppState) => { - if (nextAppState === 'active') { - checkSession(); - // Update last active timestamp when app comes to foreground - updateLastActive(); + if (!session) { + // No valid session - sign out to clear local state + signOut(); + } + }; + + // Check immediately + checkSession(); + + // Update last active timestamp on mount + updateLastActive(); + + // Check when app comes to foreground + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (nextAppState === 'active') { + checkSession(); + // Update last active timestamp when app comes to foreground + updateLastActive(); if (user?.id && Platform.OS !== "web") { void refreshSubscriptionStatus(); } - } - }); - - return () => { - subscription.remove(); - }; + } + }); + + return () => { + subscription.remove(); + }; }, [signOut, updateLastActive, user?.id, refreshSubscriptionStatus]); - - // Handle biometric lock when app goes to background/foreground - useEffect(() => { - // Check if biometric should be shown on initial mount - const checkInitialBiometric = async () => { - if (hasCheckedInitialBiometric.current) return; - hasCheckedInitialBiometric.current = true; - - const enabled = await isBiometricEnabled(); - if (enabled && isAuthenticated) { - setIsLocked(true); - } - }; - - if (isAuthenticated && !isLoading) { - checkInitialBiometric(); - } - - const subscription = AppState.addEventListener('change', async (nextAppState) => { - // Track when app goes to background (not just inactive) - if (nextAppState === 'background') { - wentToBackgroundAt.current = Date.now(); - } - - // When app becomes active, check if we should lock - if (nextAppState === 'active' && isAuthenticated && !isLocked) { - // Only lock if we actually went to background (not just inactive from modal/control center) - // and we were in background for at least 1 second - if (wentToBackgroundAt.current) { - const timeInBackground = Date.now() - wentToBackgroundAt.current; - wentToBackgroundAt.current = null; - - if (timeInBackground > 1000) { - const enabled = await isBiometricEnabled(); - if (enabled) { - setIsLocked(true); - } - } - } - } - }); - - return () => { - subscription.remove(); - }; - }, [isAuthenticated, isLoading, isLocked]); - - const handleBiometricUnlock = useCallback(() => { - setIsLocked(false); - }, []); - - // Fetch unread message count on mount - useEffect(() => { - if (user?.id) { - fetchUnreadCount(); - } - }, [user?.id, fetchUnreadCount]); - - // Fetch streak data when user is in a couple - useEffect(() => { - if (user?.couple_id) { - fetchStreak(); - } - }, [user?.couple_id, fetchStreak]); - - // Fetch pending questions (Your Turn) when user is in a couple - useEffect(() => { - if (user?.couple_id) { - fetchPendingQuestions(); - } - }, [user?.couple_id, fetchPendingQuestions]); - - // Sync app icon badge count whenever matches, messages, or pending questions change - useEffect(() => { - syncBadgeCount(newMatchesCount + pendingQuestions.length, unreadCount); - }, [newMatchesCount, unreadCount, pendingQuestions.length]); - - // Initialize RevenueCat for subscription management (iOS and Android only) - useEffect(() => { - if (user?.id && Platform.OS !== "web") { - initializeRevenueCat(user.id); - } - }, [user?.id, initializeRevenueCat]); - - // Check and register push token for this device - // This handles the case where user onboarded on a different device - useEffect(() => { - if (user?.id && user?.onboarding_completed) { - checkAndRegisterPushToken(user.id); - } - }, [user?.id, user?.onboarding_completed]); - - // Poll the self-hosted API for profile, match, pending-question and pack changes. - // This replaces the former Supabase Postgres change subscriptions. - useEffect(() => { - if (!user?.id) return; - let cancelled = false; + + // Handle biometric lock when app goes to background/foreground + useEffect(() => { + // Check if biometric should be shown on initial mount + const checkInitialBiometric = async () => { + if (hasCheckedInitialBiometric.current) return; + hasCheckedInitialBiometric.current = true; + + const enabled = await isBiometricEnabled(); + if (enabled && isAuthenticated) { + setIsLocked(true); + } + }; + + if (isAuthenticated && !isLoading) { + checkInitialBiometric(); + } + + const subscription = AppState.addEventListener('change', async (nextAppState) => { + // Track when app goes to background (not just inactive) + if (nextAppState === 'background') { + wentToBackgroundAt.current = Date.now(); + } + + // When app becomes active, check if we should lock + if (nextAppState === 'active' && isAuthenticated && !isLocked) { + // Only lock if we actually went to background (not just inactive from modal/control center) + // and we were in background for at least 1 second + if (wentToBackgroundAt.current) { + const timeInBackground = Date.now() - wentToBackgroundAt.current; + wentToBackgroundAt.current = null; + + if (timeInBackground > 1000) { + const enabled = await isBiometricEnabled(); + if (enabled) { + setIsLocked(true); + } + } + } + } + }); + + return () => { + subscription.remove(); + }; + }, [isAuthenticated, isLoading, isLocked]); + + const handleBiometricUnlock = useCallback(() => { + setIsLocked(false); + }, []); + + // The unread badge arrives with the first sync summary, so it needs no request + // of its own here. + + // Fetch streak data when user is in a couple + useEffect(() => { + if (user?.couple_id) { + fetchStreak(); + } + }, [user?.couple_id, fetchStreak]); + + // Fetch pending questions (Your Turn) and matches when user is in a couple. + // Both feed tab badges, so they load once here and are kept current by the + // sync summary rather than by re-fetching on a timer. + useEffect(() => { + if (user?.couple_id) { + void fetchPendingQuestions(); + void fetchMatches(true); + } + }, [user?.couple_id, fetchPendingQuestions, fetchMatches]); + + // Sync app icon badge count whenever matches, messages, or pending questions change + useEffect(() => { + syncBadgeCount(newMatchesCount + pendingQuestions.length, unreadCount); + }, [newMatchesCount, unreadCount, pendingQuestions.length]); + + // Initialize RevenueCat for subscription management (iOS and Android only) + useEffect(() => { + if (user?.id && Platform.OS !== "web") { + initializeRevenueCat(user.id); + } + }, [user?.id, initializeRevenueCat]); + + // Check and register push token for this device + // This handles the case where user onboarded on a different device + useEffect(() => { + if (user?.id && user?.onboarding_completed) { + checkAndRegisterPushToken(user.id); + } + }, [user?.id, user?.onboarding_completed]); + + // One small change summary drives every domain refresh. It replaces a + // five-second refetch of profile, matches, pending questions and packs plus a + // three-second unread poll, and only asks a store to reload when the server + // reports that its data actually moved. + useCoupleSync(user?.id, user?.couple_id); + + // Match analytics are derived from the store rather than from a poll response, + // so they fire once per genuinely new match however it arrived. + useEffect(() => { matchPollInitializedRef.current = false; knownMatchIdsRef.current = new Set(); + }, [user?.id, user?.couple_id]); - const poll = async () => { - await Promise.allSettled([ - useAuthStore.getState().fetchUser(), - ...(user.couple_id ? [fetchMatches(true), fetchPendingQuestions(), fetchEnabledPacks()] : []), - ]); - if (cancelled || !user.couple_id) return; - const refreshed = useMatchStore.getState().matches; - if (matchPollInitializedRef.current) { - const added = refreshed.filter(item => !knownMatchIdsRef.current.has(item.id)); - for (const item of added) Events.matchCreated(item.match_type || "unknown"); - const previousCount = knownMatchIdsRef.current.size; - if (previousCount === 0 && added.length > 0) Events.firstMatch(); - else if (added.length > 0 && refreshed.length % 10 === 0) Events.milestoneMatch(refreshed.length); + useEffect(() => { + if (!user?.couple_id) return; + const known = knownMatchIdsRef.current; + const added = matches.filter(item => !known.has(item.id)); + if (matchPollInitializedRef.current && added.length > 0) { + for (const item of added) Events.matchCreated(item.match_type || "unknown"); + if (known.size === 0) Events.firstMatch(); + else if (matches.length % 10 === 0) Events.milestoneMatch(matches.length); + } + knownMatchIdsRef.current = new Set(matches.map(item => item.id)); + matchPollInitializedRef.current = true; + }, [matches, user?.couple_id]); + + // Animate message toast when lastMessage changes + useEffect(() => { + if (lastMessage) { + // Clear any existing timeout + if (messageTimeoutRef.current) { + clearTimeout(messageTimeoutRef.current); } - knownMatchIdsRef.current = new Set(refreshed.map(item => item.id)); - matchPollInitializedRef.current = true; - }; - - void poll(); - const timer = setInterval(() => void poll(), 5_000); - return () => { cancelled = true; clearInterval(timer); }; - }, [user?.id, user?.couple_id, fetchMatches, fetchPendingQuestions, fetchEnabledPacks]); - - // Poll the self-hosted API for unread chat state. Chat screens perform their - // own message polling; this keeps the global badge current without Supabase Realtime. - useEffect(() => { - if (!user?.couple_id) return; - const timer = setInterval(() => void fetchUnreadCount(), 3_000); - return () => clearInterval(timer); - }, [user?.couple_id, fetchUnreadCount]); - - // Animate message toast when lastMessage changes - useEffect(() => { - if (lastMessage) { - // Clear any existing timeout - if (messageTimeoutRef.current) { - clearTimeout(messageTimeoutRef.current); - } - - // Slide in - Animated.spring(messageToastAnim, { - toValue: 0, - useNativeDriver: true, - tension: 80, - friction: 10, - }).start(); - - // Auto-dismiss after 4 seconds - messageTimeoutRef.current = setTimeout(() => { - dismissMessageToast(); - }, 4000); - } - - return () => { - if (messageTimeoutRef.current) { - clearTimeout(messageTimeoutRef.current); - } - }; - }, [lastMessage]); - - const dismissMessageToast = useCallback(() => { - Animated.timing(messageToastAnim, { - toValue: -100, - duration: 200, - useNativeDriver: true, - }).start(() => { - clearLastMessage(); - }); - }, [messageToastAnim, clearLastMessage]); - - const handleMessageToastPress = useCallback(() => { - if (lastMessage?.match_id) { - dismissMessageToast(); - router.push(`/(app)/chat/${lastMessage.match_id}`); - } - }, [lastMessage, dismissMessageToast, router]); - - // Show loading state while checking authentication or redirecting to onboarding - if (isLoading || !isAuthenticated || (user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding)) { - return ( - - - - ); - } - - return ( - <> - , - tabBarStyle: shouldHideTabBar ? { display: 'none' } : { - position: 'absolute', - backgroundColor: 'rgba(14, 14, 17, 0.85)', - borderTopColor: 'rgba(233, 69, 96, 0.2)', // primary brand color - borderTopWidth: 1, - paddingTop: 8, - paddingBottom: Platform.OS === 'ios' ? 28 : 8, - height: Platform.OS === 'ios' ? 88 : 64, - elevation: 0, - }, - tabBarActiveTintColor: colors.primary, - tabBarInactiveTintColor: colors.textSecondary, - tabBarLabelStyle: { - fontSize: 10, - fontWeight: "500", - letterSpacing: 0.5, - }, - }} - > - ( - - ), - }} - /> - - ( - - ), - tabBarBadge: (newMatchesCount + unreadCount + pendingQuestions.length) > 0 ? (newMatchesCount + unreadCount + pendingQuestions.length) : undefined, - tabBarBadgeStyle: { - backgroundColor: colors.premium.rose, - fontSize: 9, - fontWeight: '600', - minWidth: 16, - height: 16, - borderRadius: 8, - }, - }} - /> - ( - - ), - }} - /> - - - - - - - - - - - {/* Message Toast Notification */} - {lastMessage && ( - - - - - - - - New message - - - - - - - - - )} - - {/* Biometric Lock Screen */} - - - ); -} - -const styles = StyleSheet.create({ - loadingContainer: { - flex: 1, - justifyContent: "center", - alignItems: "center", - backgroundColor: colors.background, - }, - messageToast: { - position: 'absolute', - top: Platform.OS === 'ios' ? 50 : 20, - left: spacing.md, - right: spacing.md, - zIndex: 1000, - }, - messageToastContent: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: colors.backgroundLight, - borderRadius: radius.lg, - padding: spacing.md, - borderWidth: 1, - borderColor: colors.glass.border, - ...shadows.lg, - }, - messageToastIcon: { - width: 40, - height: 40, - borderRadius: 20, - backgroundColor: colors.primaryLight, - justifyContent: 'center', - alignItems: 'center', - marginRight: spacing.md, - }, - messageToastText: { - flex: 1, - marginRight: spacing.sm, - }, - messageToastTitle: { - ...typography.subhead, - color: colors.text, - fontWeight: '600', - }, - messageToastBody: { - ...typography.caption1, - color: colors.textSecondary, - marginTop: 2, - }, -}); + + // Slide in + Animated.spring(messageToastAnim, { + toValue: 0, + useNativeDriver: true, + tension: 80, + friction: 10, + }).start(); + + // Auto-dismiss after 4 seconds + messageTimeoutRef.current = setTimeout(() => { + dismissMessageToast(); + }, 4000); + } + + return () => { + if (messageTimeoutRef.current) { + clearTimeout(messageTimeoutRef.current); + } + }; + }, [lastMessage]); + + const dismissMessageToast = useCallback(() => { + Animated.timing(messageToastAnim, { + toValue: -100, + duration: 200, + useNativeDriver: true, + }).start(() => { + clearLastMessage(); + }); + }, [messageToastAnim, clearLastMessage]); + + const handleMessageToastPress = useCallback(() => { + if (lastMessage?.match_id) { + dismissMessageToast(); + router.push(`/(app)/chat/${lastMessage.match_id}`); + } + }, [lastMessage, dismissMessageToast, router]); + + // Show loading state while checking authentication or redirecting to onboarding + if (isLoading || !isAuthenticated || (user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding)) { + return ( + + + + ); + } + + return ( + <> + , + tabBarStyle: shouldHideTabBar ? { display: 'none' } : { + position: 'absolute', + backgroundColor: 'rgba(14, 14, 17, 0.85)', + borderTopColor: 'rgba(233, 69, 96, 0.2)', // primary brand color + borderTopWidth: 1, + paddingTop: 8, + paddingBottom: Platform.OS === 'ios' ? 28 : 8, + height: Platform.OS === 'ios' ? 88 : 64, + elevation: 0, + }, + tabBarActiveTintColor: colors.primary, + tabBarInactiveTintColor: colors.textSecondary, + tabBarLabelStyle: { + fontSize: 10, + fontWeight: "500", + letterSpacing: 0.5, + }, + }} + > + ( + + ), + }} + /> + + ( + + ), + tabBarBadge: (newMatchesCount + unreadCount + pendingQuestions.length) > 0 ? (newMatchesCount + unreadCount + pendingQuestions.length) : undefined, + tabBarBadgeStyle: { + backgroundColor: colors.premium.rose, + fontSize: 9, + fontWeight: '600', + minWidth: 16, + height: 16, + borderRadius: 8, + }, + }} + /> + ( + + ), + }} + /> + + + + + + + + + + + {/* Message Toast Notification */} + {lastMessage && ( + + + + + + + + New message + + + + + + + + + )} + + {/* Biometric Lock Screen */} + + + ); +} + +const styles = StyleSheet.create({ + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: colors.background, + }, + messageToast: { + position: 'absolute', + top: Platform.OS === 'ios' ? 50 : 20, + left: spacing.md, + right: spacing.md, + zIndex: 1000, + }, + messageToastContent: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.backgroundLight, + borderRadius: radius.lg, + padding: spacing.md, + borderWidth: 1, + borderColor: colors.glass.border, + ...shadows.lg, + }, + messageToastIcon: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.primaryLight, + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.md, + }, + messageToastText: { + flex: 1, + marginRight: spacing.sm, + }, + messageToastTitle: { + ...typography.subhead, + color: colors.text, + fontWeight: '600', + }, + messageToastBody: { + ...typography.caption1, + color: colors.textSecondary, + marginTop: 2, + }, +}); diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index ccdc392..e28fcc6 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -4,7 +4,7 @@ import { useFocusEffect } from 'expo-router'; import Animated, { FadeInDown } from 'react-native-reanimated'; import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; -import { useAuthStore, usePacksStore, useStreakStore, useSubscriptionStore } from '../../src/store'; +import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store'; import { GradientBackground } from '../../src/components/ui'; import { Paywall } from '../../src/components/paywall'; import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery'; @@ -68,8 +68,7 @@ function groupPacksByCategory( export default function DiscoveryScreen() { const { user, partner, couple } = useAuthStore(); - const { packs, categories, fetchPacks, getPackProgress } = usePacksStore(); - const { fetchStreak } = useStreakStore(); + const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore(); const { subscription } = useSubscriptionStore(); const { width } = useWindowDimensions(); const [showPaywall, setShowPaywall] = useState(false); @@ -79,12 +78,13 @@ export default function DiscoveryScreen() { const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser; const isWideScreen = width > MAX_CONTENT_WIDTH; - // Refresh data when screen gains focus + // Load the catalog once. Returning to this tab shows what is cached; answering a + // question invalidates it, and StreakDisplay owns the streak, so neither needs a + // request here. useFocusEffect( useCallback(() => { - fetchPacks(); - fetchStreak(); - }, [fetchStreak]) + void ensurePacksLoaded(); + }, [ensurePacksLoaded]) ); // Group packs by category diff --git a/apps/mobile/app/(app)/matches.tsx b/apps/mobile/app/(app)/matches.tsx index bcc9037..527d82e 100755 --- a/apps/mobile/app/(app)/matches.tsx +++ b/apps/mobile/app/(app)/matches.tsx @@ -39,6 +39,7 @@ export default function MatchesScreen() { fetchMatches, markAllAsSeen, isLoading, + isRefreshing, hasMore, isLoadingMore, totalCount, @@ -58,7 +59,9 @@ export default function MatchesScreen() { fetchTheirTurnQuestions, currentView, setCurrentView, + ensureViewLoaded, fetchPendingQuestions, + fetchArchivedMatches, // Nudge state and methods nudgeCooldownUntil, isNudging, @@ -118,14 +121,12 @@ export default function MatchesScreen() { setShowTutorial(false); }; + // Load whichever view is showing, once. Returning to this tab reuses what is + // already cached; the sync poll refreshes it when the server reports a change, + // and pull-to-refresh remains the explicit way to force one. useFocusEffect( useCallback(() => { - fetchMatches(true); - // Also refresh pending questions when focused - fetchPendingQuestions(); - fetchTheirTurnQuestions(); - // Check nudge cooldown - checkNudgeCooldown(); + ensureViewLoaded(); // Cleanup: mark matches as seen when leaving the screen (if on Complete tab) return () => { @@ -134,9 +135,15 @@ export default function MatchesScreen() { state.markAllAsSeen(); } }; - }, []) + }, [ensureViewLoaded]) ); + // The nudge cooldown only moves when this device sends a nudge, and sendNudge + // already records the new cooldown, so one read on mount is enough. + useEffect(() => { + void checkNudgeCooldown(); + }, [checkNudgeCooldown]); + // Format cooldown remaining time const formatCooldownTime = (cooldownUntil: Date): string => { const now = new Date(); @@ -633,13 +640,16 @@ export default function MatchesScreen() { refreshing={ currentView === 'pending' ? isLoadingPending : currentView === 'their_turn' ? isLoadingTheirTurn : - isLoading + currentView === 'archived' ? isLoadingArchived : + isRefreshing } onRefresh={() => { if (currentView === 'pending') { fetchPendingQuestions(); } else if (currentView === 'their_turn') { fetchTheirTurnQuestions(); + } else if (currentView === 'archived') { + fetchArchivedMatches(); } else { fetchMatches(true); } diff --git a/apps/mobile/app/(app)/my-answers.tsx b/apps/mobile/app/(app)/my-answers.tsx index 0b849a0..ad265ad 100644 --- a/apps/mobile/app/(app)/my-answers.tsx +++ b/apps/mobile/app/(app)/my-answers.tsx @@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList); type GroupByOption = "pack" | "date" | "answer"; export default function MyAnswersScreen() { - const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore(); + const { responses, isLoading, isRefreshing, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, ensureResponsesLoaded, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore(); const { user, partner } = useAuthStore(); const router = useRouter(); const params = useLocalSearchParams(); @@ -110,11 +110,12 @@ export default function MyAnswersScreen() { // Ambient orb breathing animations const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(); - // Fetch responses on focus + // Load once, then keep showing what is cached. Answering or editing a response + // invalidates it, and pull-to-refresh is always available. useFocusEffect( useCallback(() => { - fetchResponses(true); - }, []) + void ensureResponsesLoaded(); + }, [ensureResponsesLoaded]) ); const handleLoadMore = useCallback(() => { @@ -271,7 +272,7 @@ export default function MyAnswersScreen() { scrollEventThrottle={16} refreshControl={ fetchResponses(true)} tintColor={colors.primary} colors={[colors.primary]} diff --git a/apps/mobile/app/pack/[id].tsx b/apps/mobile/app/pack/[id].tsx index 0c93815..2389670 100644 --- a/apps/mobile/app/pack/[id].tsx +++ b/apps/mobile/app/pack/[id].tsx @@ -44,7 +44,7 @@ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); export default function PackDetailsScreen() { const { id } = useLocalSearchParams(); const router = useRouter(); - const { packs, fetchPacks } = usePacksStore(); + const { packs, ensurePacksLoaded } = usePacksStore(); const { subscription } = useSubscriptionStore(); const { user, partner } = useAuthStore(); const [questions, setQuestions] = useState([]); @@ -61,9 +61,7 @@ export default function PackDetailsScreen() { const category = categories.find(c => c.id === pack?.category_id); useEffect(() => { - if (packs.length === 0) { - fetchPacks(); - } + void ensurePacksLoaded(); fetchPackDetails(); }, [id]); diff --git a/apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts b/apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts index 989dd0e..d3e612c 100644 --- a/apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts +++ b/apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts @@ -17,13 +17,19 @@ jest.mock("expo-router", () => ({ jest.mock("@/store", () => { const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } }; const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] }; + const packState = { ...packs, invalidatePacks: jest.fn() }; + const responses = { invalidateResponses: jest.fn() }; const useAuthStore = () => auth; useAuthStore.getState = () => auth.user; + const usePacksStore = () => packs; + usePacksStore.getState = () => packState; + const useResponsesStore = () => responses; + useResponsesStore.getState = () => responses; // Read lazily: the factory runs before this module's own consts are initialised. const streak = { fetchStreak: () => mockFetchStreak() }; const useStreakStore = () => streak; useStreakStore.getState = () => streak; - return { useAuthStore, usePacksStore: () => packs, useStreakStore }; + return { useAuthStore, usePacksStore, useResponsesStore, useStreakStore }; }); jest.mock("@/lib/skippedQuestions", () => ({ @@ -45,17 +51,19 @@ const questions = [ { id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" }, ]; +const futureResetAt = "2099-08-29T00:00:00.000Z"; + const openLimit = { responses_today: 4, limit_value: 10, remaining: 6, - reset_at: "2026-08-29T00:00:00.000Z", + reset_at: futureResetAt, is_blocked: false, }; const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, { error: { code: "daily_limit", message: "Daily response limit reached" }, - details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: "2026-08-29T00:00:00.000Z" }, + details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: futureResetAt }, }); // clearMocks wipes implementations between tests, so re-arm the whole surface each time. @@ -92,7 +100,7 @@ describe("useSwipeScreen daily limit", () => { responses_today: 10, limit_value: 10, remaining: 0, - reset_at: "2026-08-29T00:00:00.000Z", + reset_at: futureResetAt, is_blocked: true, }); }); diff --git a/apps/mobile/src/__tests__/hooks/useCoupleSync.test.ts b/apps/mobile/src/__tests__/hooks/useCoupleSync.test.ts new file mode 100644 index 0000000..6eb62ba --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/useCoupleSync.test.ts @@ -0,0 +1,313 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { useCoupleSync, SYNC_INTERVAL_MS } from '@/hooks/useCoupleSync'; +import { appDataApi, type SyncSummary } from '@/lib/appDataApi'; +import { useAuthStore } from '@/store/authStore'; +import { useMatchStore } from '@/store/matchStore'; +import { useMessageStore } from '@/store/messageStore'; +import { usePacksStore } from '@/store/packsStore'; +import { useStreakStore } from '@/store/streakStore'; + +jest.mock('@/lib/appDataApi', () => ({ appDataApi: { syncSummary: jest.fn() } })); + +const mockForeground = { value: true }; +const mockListeners = new Set<(foreground: boolean) => void>(); +jest.mock('@/lib/appForeground', () => ({ + isForeground: () => mockForeground.value, + subscribeToForeground: (listener: (foreground: boolean) => void) => { + mockListeners.add(listener); + return () => { mockListeners.delete(listener); }; + }, +})); + +const baseline: SyncSummary = { + server_time: '2026-08-27T12:00:00.000Z', + couple_id: 'couple', + profile_updated_at: '2026-08-01T00:00:00.000Z', + partner_id: 'partner', + partner_updated_at: '2026-08-01T00:00:00.000Z', + match_count: 3, + new_match_count: 0, + latest_match_at: '2026-08-20T00:00:00.000Z', + pending_yours: 1, + pending_theirs: 2, + unread_total: 0, + enabled_packs_fingerprint: 'digest-a', + match_state_fingerprint: 'state-a', + match_unread_fingerprint: 'unread-a', + streak_updated_at: '2026-08-01T00:00:00.000Z', +}; + +const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); }; +const tick = async () => { + await flush(); + await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS); }); + await flush(); +}; + +/** Replaces every domain refresh the sync can dispatch with a counter. */ +function stubRefreshers() { + const fetchUser = jest.fn(async () => undefined); + const fetchCouple = jest.fn(async () => undefined); + const fetchMatches = jest.fn(async () => undefined); + const fetchPendingQuestions = jest.fn(async () => undefined); + const fetchTheirTurnQuestions = jest.fn(async () => undefined); + const fetchEnabledPacks = jest.fn(async () => undefined); + const refreshStreak = jest.fn(async () => undefined); + useAuthStore.setState({ fetchUser, fetchCouple } as never); + useMatchStore.setState({ fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions } as never); + usePacksStore.setState({ fetchEnabledPacks } as never); + useStreakStore.setState({ refreshStreak } as never); + return { fetchUser, fetchCouple, fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions, fetchEnabledPacks, refreshStreak }; +} + +describe('useCoupleSync', () => { + let refreshers: ReturnType; + const originals = { + auth: { fetchUser: useAuthStore.getState().fetchUser, fetchCouple: useAuthStore.getState().fetchCouple }, + match: { + fetchMatches: useMatchStore.getState().fetchMatches, + fetchPendingQuestions: useMatchStore.getState().fetchPendingQuestions, + fetchTheirTurnQuestions: useMatchStore.getState().fetchTheirTurnQuestions, + }, + packs: { fetchEnabledPacks: usePacksStore.getState().fetchEnabledPacks }, + streak: { refreshStreak: useStreakStore.getState().refreshStreak }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockForeground.value = true; + mockListeners.clear(); + useMessageStore.setState({ unreadCount: 0 }); + // Every view starts loaded: this hook must refresh only what is on screen, + // and the "not loaded" case is asserted separately below. + useMatchStore.setState({ loadedAt: { active: 1, pending: 1, their_turn: 1 } }); + usePacksStore.setState({ enabledPacksLoadedAt: Date.now() }); + refreshers = stubRefreshers(); + }); + afterEach(() => { + jest.useRealTimers(); + useAuthStore.setState(originals.auth as never); + useMatchStore.setState({ ...originals.match, loadedAt: {} } as never); + usePacksStore.setState({ ...originals.packs, enabledPacksLoadedAt: null } as never); + useStreakStore.setState(originals.streak as never); + }); + + const answer = (...summaries: SyncSummary[]) => { + const mock = appDataApi.syncSummary as jest.Mock; + mock.mockReset(); + for (const summary of summaries) mock.mockResolvedValueOnce(summary); + mock.mockResolvedValue(summaries[summaries.length - 1]); + }; + + it('spends one request per interval and refreshes nothing while the markers hold still', async () => { + answer(baseline); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1); + + await tick(); + await tick(); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(3); + // Three polls, three requests in total: the baseline poll must not trigger + // the refresh of five domains that the old five-second sweep performed. + for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled(); + }); + + it('applies the unread total from the summary without a second request', async () => { + answer(baseline, { ...baseline, unread_total: 4 }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(useMessageStore.getState().unreadCount).toBe(4); + }); + + it('refreshes only the domain whose marker moved', async () => { + answer(baseline, { ...baseline, latest_match_at: '2026-08-28T00:00:00.000Z', match_count: 4 }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true }); + expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled(); + expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled(); + expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled(); + expect(refreshers.fetchUser).not.toHaveBeenCalled(); + expect(refreshers.fetchCouple).not.toHaveBeenCalled(); + }); + + it('routes each remaining marker to its own domain', async () => { + answer(baseline, { + ...baseline, + pending_yours: 2, + pending_theirs: 3, + enabled_packs_fingerprint: 'digest-b', + partner_updated_at: '2026-08-29T00:00:00.000Z', + streak_updated_at: '2026-08-29T00:00:00.000Z', + }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchPendingQuestions).toHaveBeenCalledWith({ silent: true }); + expect(refreshers.fetchTheirTurnQuestions).toHaveBeenCalledWith({ silent: true }); + expect(refreshers.fetchEnabledPacks).toHaveBeenCalledTimes(1); + expect(refreshers.refreshStreak).toHaveBeenCalledTimes(1); + // A partner profile change is a couple-level read; the whole user is only + // re-read when the user's own profile or couple membership moved. + expect(refreshers.fetchCouple).toHaveBeenCalledTimes(1); + expect(refreshers.fetchUser).not.toHaveBeenCalled(); + expect(refreshers.fetchMatches).not.toHaveBeenCalled(); + }); + + it('refreshes a loaded Matches view when the match-state fingerprint moves in place', async () => { + // Counts and latest-created-at do not move for an in-place match_type/ + // response_summary edit; only the state fingerprint does. + answer(baseline, { ...baseline, match_state_fingerprint: 'state-b' }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true }); + }); + + it('refreshes a loaded Matches view when unread redistributes without changing the total', async () => { + // One match is read while another receives a message: unread_total holds + // steady but the per-match fingerprint (and unread-first ordering) moves. + answer(baseline, { ...baseline, unread_total: baseline.unread_total, match_unread_fingerprint: 'unread-b' }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true }); + }); + + it('does not refresh enabled packs when that domain has never been opened', async () => { + usePacksStore.setState({ enabledPacksLoadedAt: null }); + answer(baseline, { ...baseline, enabled_packs_fingerprint: 'digest-b' }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled(); + }); + + it('resets the baseline when the account identity changes, instead of diffing against the prior account', async () => { + answer({ ...baseline, match_count: 3 }); + const { rerender } = renderHook( + ({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId), + { initialProps: { userId: 'alice', coupleId: 'couple-a' } }, + ); + await flush(); + await tick(); // establishes alice's baseline (match_count: 3) + + // Switch accounts: bob's couple has a completely different match_count. + // Without a baseline reset this would look like a 12-count jump and + // trigger a refresh purely from comparing across accounts. + answer({ ...baseline, couple_id: 'couple-b', match_count: 15 }); + rerender({ userId: 'bob', coupleId: 'couple-b' }); + await flush(); + expect(refreshers.fetchMatches).not.toHaveBeenCalled(); + + await tick(); + expect(refreshers.fetchMatches).not.toHaveBeenCalled(); + }); + + it('re-reads the user once when the profile moves, instead of the couple as well', async () => { + answer(baseline, { + ...baseline, + profile_updated_at: '2026-08-29T00:00:00.000Z', + partner_updated_at: '2026-08-29T00:00:00.000Z', + }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchUser).toHaveBeenCalledTimes(1); + expect(refreshers.fetchCouple).not.toHaveBeenCalled(); + }); + + it('leaves views the user has never opened unloaded', async () => { + useMatchStore.setState({ loadedAt: {} }); + answer(baseline, { + ...baseline, + match_count: 9, + latest_match_at: '2026-08-29T00:00:00.000Z', + pending_yours: 7, + pending_theirs: 8, + }); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await tick(); + expect(refreshers.fetchMatches).not.toHaveBeenCalled(); + expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled(); + expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled(); + }); + + it('stops entirely for a signed-out user and while the app is backgrounded', async () => { + answer(baseline); + const { unmount } = renderHook(() => useCoupleSync(undefined, null)); + await flush(); + await tick(); + expect(appDataApi.syncSummary).not.toHaveBeenCalled(); + unmount(); + + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1); + + await act(async () => { + mockForeground.value = false; + for (const listener of [...mockListeners]) listener(false); + }); + await tick(); + await tick(); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1); + }); + + it('discards a summary that lands after the account it belongs to was switched away', async () => { + // Alice's poll is still open when Bob signs in. Its answer describes a + // couple Bob is not in, so none of it may land: not his badge, not the + // baseline his next poll is diffed against, not a refresh decision. + let answerAlice: (summary: SyncSummary) => void = () => undefined; + const mock = appDataApi.syncSummary as jest.Mock; + mock.mockReset(); + mock.mockReturnValueOnce(new Promise(resolve => { answerAlice = resolve; })); + + const { rerender } = renderHook( + ({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId), + { initialProps: { userId: 'alice', coupleId: 'couple-a' } }, + ); + await flush(); + expect(mock).toHaveBeenCalledTimes(1); + + const bobBaseline: SyncSummary = { ...baseline, couple_id: 'couple-b', unread_total: 1, match_count: 2 }; + mock.mockResolvedValue(bobBaseline); + rerender({ userId: 'bob', coupleId: 'couple-b' }); + await flush(); + expect(useMessageStore.getState().unreadCount).toBe(1); + + // Alice's request finally answers, long after she stopped being the user. + await act(async () => { + answerAlice({ ...baseline, couple_id: 'couple-a', unread_total: 9, match_count: 42 }); + await Promise.resolve(); + }); + await flush(); + expect(useMessageStore.getState().unreadCount).toBe(1); + + // Bob's baseline is still Bob's: a poll identical to his own first one has + // nothing to refresh. Had Alice's answer been recorded as the baseline, + // this would read as a 40-match jump and refresh the Matches view. + await tick(); + expect(useMessageStore.getState().unreadCount).toBe(1); + for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled(); + }); + + it('never runs two summaries at once', async () => { + let release: (summary: SyncSummary) => void = () => undefined; + (appDataApi.syncSummary as jest.Mock).mockReset(); + (appDataApi.syncSummary as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + (appDataApi.syncSummary as jest.Mock).mockResolvedValue(baseline); + renderHook(() => useCoupleSync('me', 'couple')); + await flush(); + await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS * 4); }); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1); + await act(async () => { release(baseline); await Promise.resolve(); }); + await tick(); + expect(appDataApi.syncSummary).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useDares.test.ts b/apps/mobile/src/__tests__/hooks/useDares.test.ts index fb4b961..a5db683 100644 --- a/apps/mobile/src/__tests__/hooks/useDares.test.ts +++ b/apps/mobile/src/__tests__/hooks/useDares.test.ts @@ -1,6 +1,7 @@ import { act, renderHook, waitFor } from "@testing-library/react-native"; import { useDares } from "@/features/dares/hooks/useDares"; import { ApiError, apiClient } from "@/lib/apiClient"; +import type { UseDaresReturn } from "@/features/dares/hooks/useDares"; import type { SentDare } from "@/features/dares/types"; const incoming: SentDare = { @@ -90,3 +91,80 @@ describe("useDares", () => { expect(result.current.error).toBe("offline"); }); }); + +describe("useDares polling cost", () => { + /** Counts requests per endpoint and lets the active payload be swapped mid-test. */ + function mockCounting(active: () => SentDare[]) { + const calls = { active: 0, history: 0, catalog: 0, stats: 0 }; + jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => { + if (path.startsWith("/v1/dares?filter=active")) { calls.active += 1; return { dares: active() } as never; } + if (path.startsWith("/v1/dares?filter=history")) { calls.history += 1; return { dares: [] } as never; } + if (path === "/v1/dares/packs") { calls.catalog += 1; return catalog as never; } + if (path === "/v1/dares/stats") { calls.stats += 1; return stats as never; } + throw new Error(`unexpected path ${path}`); + }); + return calls; + } + + const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); }; + const tick = async () => { + await flush(); + await act(async () => { jest.advanceTimersByTime(5_000); }); + await flush(); + }; + + beforeEach(() => { jest.restoreAllMocks(); jest.useFakeTimers(); }); + afterEach(() => jest.useRealTimers()); + + it("polls the active list alone and re-reads the static endpoints only when it changes", async () => { + let current: SentDare[] = [incoming]; + const calls = mockCounting(() => current); + const { result } = renderHook(() => useDares({ isFocused: true })); + await flush(); + expect(result.current.loading).toBe(false); + // The first pass is a full load: nothing is cached yet. + expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 }); + + await tick(); + await tick(); + // Two quiet polls cost one request each, not four. The catalogue and the + // stats cannot move while the active set is byte-for-byte identical. + expect(calls).toEqual({ active: 3, history: 1, catalog: 1, stats: 1 }); + + current = [{ ...incoming, status: "active" }]; + await tick(); + expect(calls).toEqual({ active: 4, history: 2, catalog: 2, stats: 2 }); + expect(result.current.active[0]?.status).toBe("active"); + + await tick(); + expect(calls).toEqual({ active: 5, history: 2, catalog: 2, stats: 2 }); + }); + + it("issues nothing at all while the screen is not on top", async () => { + const calls = mockCounting(() => [incoming]); + const { rerender } = renderHook( + ({ isFocused }) => useDares({ isFocused }), + { initialProps: { isFocused: false } }, + ); + await tick(); + await tick(); + expect(calls).toEqual({ active: 0, history: 0, catalog: 0, stats: 0 }); + + rerender({ isFocused: true }); + await flush(); + expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 }); + }); + + it("never overlaps two polls", async () => { + let release: (payload: { dares: SentDare[] }) => void = () => undefined; + jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => { + if (path.startsWith("/v1/dares?filter=active")) return new Promise(resolve => { release = resolve; }) as never; + return { dares: [], ...catalog, ...stats } as never; + }); + renderHook(() => useDares({ isFocused: true })); + await flush(); + await act(async () => { jest.advanceTimersByTime(20_000); }); + expect((apiClient.get as jest.Mock).mock.calls.filter(([path]) => String(path).includes("filter=active"))).toHaveLength(1); + await act(async () => { release({ dares: [] }); await Promise.resolve(); }); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts b/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts index dcc4cbe..01fcfa3 100644 --- a/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts +++ b/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts @@ -48,6 +48,60 @@ describe('useDrawingSync API polling', () => { }); }); +describe('useDrawingSync focus gating', () => { + const handlers = { + onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(), onStrokeEnd: jest.fn(), + onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(), + }; + beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); }); + afterEach(() => jest.useRealTimers()); + + it('reads the canvas once on mount and then polls only while the screen is on top', async () => { + (appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId }); + const onLoadStrokes = jest.fn(); + const { rerender } = renderHook(({ isFocused }) => useDrawingSync({ + coupleId: 'couple', userId, onLoadStrokes, isFocused, ...handlers, + }), { initialProps: { isFocused: false } }); + + await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1)); + // A canvas nobody is looking at costs nothing beyond the state it loaded with. + await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); }); + expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1); + + rerender({ isFocused: true }); + await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); }); + expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2); + + rerender({ isFocused: false }); + await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); }); + expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2); + }); + + it('never polls over its own write in flight', async () => { + (appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId }); + let release: (state: unknown) => void = () => undefined; + (appDataApi.putLiveDraw as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; })); + const onLoadStrokes = jest.fn(); + const { result } = renderHook(() => useDrawingSync({ + coupleId: 'couple', userId, onLoadStrokes, isFocused: true, ...handlers, + })); + await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1)); + + const pending = result.current.persistStrokes([stroke]); + await act(async () => { jest.advanceTimersByTime(750 * 4); await Promise.resolve(); }); + // The write returns the authoritative state; reading over the top of it would + // only race a revision we are about to replace. + expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1); + + await act(async () => { + release({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId }); + await pending; + }); + await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); }); + expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2); + }); +}); + describe('useDrawingSync stroke deduplication', () => { const at = (id: string, points: number, userId = 'u1') => ({ id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })), diff --git a/apps/mobile/src/__tests__/hooks/useMessageSubscription.test.ts b/apps/mobile/src/__tests__/hooks/useMessageSubscription.test.ts index f0f90c7..8a733d6 100644 --- a/apps/mobile/src/__tests__/hooks/useMessageSubscription.test.ts +++ b/apps/mobile/src/__tests__/hooks/useMessageSubscription.test.ts @@ -4,85 +4,183 @@ import { chatApi } from '@/lib/chatApi'; jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } })); -const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any); +const message = (id: string, user_id = 'partner', overrides: Record = {}) => ({ + id, match_id: 'match1', user_id, content: id, + created_at: '2026-08-27T12:00:00Z', read_at: null, ...overrides, +} as any); -describe('useMessageSubscription API polling', () => { +const snapshot = (messages: any[], server_time = 'cursor-1') => ({ messages, removed_ids: [], server_time, complete: true }); +const delta = (messages: any[], options: { removed?: string[]; server_time?: string; typing?: boolean } = {}) => ({ + messages, + removed_ids: options.removed ?? [], + server_time: options.server_time ?? 'cursor-2', + complete: false, + ...(options.typing === undefined ? {} : { typing: { typing: options.typing, expires_at: null } }), +}); + +const listMessages = chatApi.listMessages as jest.Mock; +const markRead = chatApi.markRead as jest.Mock; + +describe('useMessageSubscription incremental polling', () => { beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); - (chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] }); - (chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' }); + listMessages.mockResolvedValue(snapshot([])); + markRead.mockResolvedValue({ updated: 0, read_at: 'now' }); }); afterEach(() => jest.useRealTimers()); - it('fails closed without both a match and authenticated user', async () => { - const { result, rerender } = renderHook, { matchId: string | undefined; userId: string | undefined }>( + const mount = (config: Partial[0]> = {}) => + renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, ...config })); + + it('fails closed without both match and authenticated user', () => { + const { rerender } = renderHook, { matchId: string | undefined; userId: string | undefined }>( ({ matchId, userId }) => useMessageSubscription({ matchId, userId }), { initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } }, ); - expect(result.current.loading).toBe(false); - expect(chatApi.listMessages).not.toHaveBeenCalled(); + expect(listMessages).not.toHaveBeenCalled(); rerender({ matchId: 'match1', userId: undefined }); - expect(chatApi.listMessages).not.toHaveBeenCalled(); + expect(listMessages).not.toHaveBeenCalled(); }); - it('loads initial messages without treating them as newly arrived', async () => { + it('loads a snapshot first, then requests only changes since the server cursor', async () => { + listMessages.mockResolvedValueOnce(snapshot([message('m1')], 'cursor-1')); + const { result } = mount(); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(listMessages).toHaveBeenNthCalledWith(1, 'match1', { typing: true }); + + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2)); + expect(listMessages).toHaveBeenNthCalledWith(2, 'match1', { since: 'cursor-1', typing: true }); + }); + + it('keeps cached messages, and their identity, when a delta carries nothing', async () => { + listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })])); + listMessages.mockResolvedValue(delta([])); + const { result } = mount(); + await waitFor(() => expect(result.current.messages).toHaveLength(1)); + const cached = result.current.messages; + + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2)); + expect(result.current.messages).toBe(cached); + expect(result.current.loading).toBe(false); + }); + + it('merges delta rows by id, applies self-deletions, and keeps newest first', async () => { + listMessages.mockResolvedValueOnce(snapshot([ + message('m2', 'me', { created_at: '2026-08-27T12:00:02Z' }), + message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen' }), + ])); + listMessages.mockResolvedValueOnce(delta([ + message('m3', 'partner', { created_at: '2026-08-27T12:00:03Z', read_at: 'seen' }), + message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen', deleted_at: 'gone' }), + ], { removed: ['m2'] })); + const { result } = mount(); + await waitFor(() => expect(result.current.messages).toHaveLength(2)); + + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(result.current.messages).toHaveLength(2)); + expect(result.current.messages.map(m => m.id)).toEqual(['m3', 'm1']); + expect(result.current.messages[1]?.deleted_at).toBe('gone'); + }); + + it('marks read only when the response holds an unread partner message', async () => { + listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner')])); + listMessages.mockResolvedValueOnce(delta([message('m2', 'me')])); + listMessages.mockResolvedValueOnce(delta([message('m1', 'partner', { read_at: 'seen' })])); + const { result } = mount(); + await waitFor(() => expect(markRead).toHaveBeenCalledTimes(1)); + + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2)); + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(3)); + expect(markRead).toHaveBeenCalledTimes(1); + expect(result.current.messages).toHaveLength(2); + }); + + it('issues no request at all while the chat is not focused', async () => { + const { rerender } = renderHook, { focused: boolean }>( + ({ focused }) => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, isFocused: focused }), + { initialProps: { focused: false } }, + ); + await act(async () => { jest.advanceTimersByTime(2_000); }); + expect(listMessages).not.toHaveBeenCalled(); + expect(markRead).not.toHaveBeenCalled(); + + rerender({ focused: true }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1)); + + rerender({ focused: false }); + const callsAtBlur = listMessages.mock.calls.length; + await act(async () => { jest.advanceTimersByTime(2_000); }); + expect(listMessages).toHaveBeenCalledTimes(callsAtBlur); + }); + + it('reports partner arrivals once, and never for the initial load or own messages', async () => { const onNewMessage = jest.fn(); - (chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] }); - const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage })); + listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })])); + listMessages.mockResolvedValueOnce(delta([message('mine', 'me')])); + listMessages.mockResolvedValueOnce(delta([message('m2', 'partner')])); + listMessages.mockResolvedValueOnce(delta([message('m2', 'partner', { read_at: 'seen' })])); + const { result } = mount({ onNewMessage }); await waitFor(() => expect(result.current.loading).toBe(false)); - expect(result.current.messages).toEqual([message('m1')]); expect(onNewMessage).not.toHaveBeenCalled(); - expect(chatApi.markRead).not.toHaveBeenCalled(); - }); - it('refreshes on the polling interval, marks focused messages read, and reports new partner messages', async () => { - const onNewMessage = jest.fn(); - (chatApi.listMessages as jest.Mock) - .mockResolvedValueOnce({ messages: [message('m1')] }) - .mockResolvedValueOnce({ messages: [message('m2'), message('m1')] }); - const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 })); - await waitFor(() => expect(result.current.loading).toBe(false)); - act(() => { result.current.isFocusedRef.current = true; }); - await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); }); - await waitFor(() => expect(result.current.messages[0]?.id).toBe('m2')); - expect(chatApi.markRead).toHaveBeenCalledWith('match1'); + for (const expected of [2, 3, 4]) { + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(expected)); + } expect(onNewMessage).toHaveBeenCalledTimes(1); }); - it('does not notify for the current user own newly polled message', async () => { - const onNewMessage = jest.fn(); - (chatApi.listMessages as jest.Mock) - .mockResolvedValueOnce({ messages: [] }) - .mockResolvedValueOnce({ messages: [message('mine', 'me')] }); - const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 })); - await waitFor(() => expect(result.current.loading).toBe(false)); - await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); }); - expect(onNewMessage).not.toHaveBeenCalled(); + it('reads partner typing state out of the message response', async () => { + listMessages.mockResolvedValueOnce({ ...snapshot([]), typing: { typing: true, expires_at: 'later' } }); + const { result } = mount(); + await waitFor(() => expect(result.current.partnerTyping).toBe(true)); + + listMessages.mockResolvedValueOnce(delta([], { typing: false })); + await act(async () => { jest.advanceTimersByTime(500); }); + await waitFor(() => expect(result.current.partnerTyping).toBe(false)); }); it('surfaces loading completion and preserves a usable empty state after an API error', async () => { - (chatApi.listMessages as jest.Mock).mockRejectedValueOnce(new Error('forbidden')); - const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' })); + listMessages.mockRejectedValueOnce(new Error('forbidden')); + const { result } = mount(); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.messages).toEqual([]); }); - it('prevents overlapping polls and clears the interval on unmount', async () => { - let resolveFirst!: (value: { messages: any[] }) => void; - (chatApi.listMessages as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; })); - const { unmount } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500 })); - expect(chatApi.listMessages).toHaveBeenCalledTimes(1); - act(() => { jest.advanceTimersByTime(1_500); }); - expect(chatApi.listMessages).toHaveBeenCalledTimes(1); - await act(async () => resolveFirst({ messages: [] })); + it('prevents overlapping polls and stops on unmount', async () => { + let resolveFirst!: (value: unknown) => void; + listMessages.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; })); + const { unmount } = mount(); + expect(listMessages).toHaveBeenCalledTimes(1); + await act(async () => { jest.advanceTimersByTime(1_500); }); + expect(listMessages).toHaveBeenCalledTimes(1); + + await act(async () => { resolveFirst(snapshot([])); }); unmount(); - act(() => { jest.advanceTimersByTime(2_000); }); - expect(chatApi.listMessages).toHaveBeenCalledTimes(1); + await act(async () => { jest.advanceTimersByTime(2_000); }); + expect(listMessages).toHaveBeenCalledTimes(1); + }); + + it('restarts immediately, and without a stale cursor, for a different match', async () => { + listMessages.mockResolvedValue(snapshot([message('m1')], 'cursor-1')); + const { result, rerender } = renderHook, { matchId: string }>( + ({ matchId }) => useMessageSubscription({ matchId, userId: 'me', pollInterval: 500 }), + { initialProps: { matchId: 'match1' } }, + ); + await waitFor(() => expect(result.current.messages).toHaveLength(1)); + + rerender({ matchId: 'match2' }); + await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2)); + expect(listMessages).toHaveBeenNthCalledWith(2, 'match2', { typing: true }); }); it('supports optimistic external updates', async () => { - const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' })); + const { result } = mount(); await waitFor(() => expect(result.current.loading).toBe(false)); act(() => result.current.setMessages([message('optimistic', 'me')])); expect(result.current.messages[0]?.id).toBe('optimistic'); diff --git a/apps/mobile/src/__tests__/hooks/usePolling.test.ts b/apps/mobile/src/__tests__/hooks/usePolling.test.ts new file mode 100644 index 0000000..0f47257 --- /dev/null +++ b/apps/mobile/src/__tests__/hooks/usePolling.test.ts @@ -0,0 +1,141 @@ +import { act, renderHook } from '@testing-library/react-native'; +import { usePolling } from '@/hooks/usePolling'; + +const mockForeground = { value: true }; +const mockListeners = new Set<(foreground: boolean) => void>(); + +jest.mock('@/lib/appForeground', () => ({ + isForeground: () => mockForeground.value, + subscribeToForeground: (listener: (foreground: boolean) => void) => { + mockListeners.add(listener); + return () => { mockListeners.delete(listener); }; + }, +})); + +const setForeground = async (value: boolean) => { + await act(async () => { + mockForeground.value = value; + for (const listener of [...mockListeners]) listener(value); + }); +}; +// The next run is scheduled once the previous one settles, so let pending +// promises land before and after moving the clock. +const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); }; +const tick = async (ms: number) => { + await flush(); + await act(async () => { jest.advanceTimersByTime(ms); }); + await flush(); +}; + +describe('usePolling', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + mockForeground.value = true; + mockListeners.clear(); + }); + afterEach(() => jest.useRealTimers()); + + it('runs once immediately and then on the interval', async () => { + const task = jest.fn(async () => undefined); + renderHook(() => usePolling(task, { intervalMs: 100 })); + expect(task).toHaveBeenCalledTimes(1); + await tick(100); + expect(task).toHaveBeenCalledTimes(2); + await tick(100); + expect(task).toHaveBeenCalledTimes(3); + }); + + it('skips the leading run when asked to', async () => { + const task = jest.fn(async () => undefined); + renderHook(() => usePolling(task, { intervalMs: 100, leading: false })); + expect(task).not.toHaveBeenCalled(); + await tick(100); + expect(task).toHaveBeenCalledTimes(1); + }); + + it('issues nothing while disabled and starts once enabled', async () => { + const task = jest.fn(async () => undefined); + const { rerender } = renderHook(({ enabled }) => usePolling(task, { intervalMs: 100, enabled }), { + initialProps: { enabled: false }, + }); + await tick(1_000); + expect(task).not.toHaveBeenCalled(); + rerender({ enabled: true }); + expect(task).toHaveBeenCalledTimes(1); + }); + + it('stops while the app is backgrounded and catches up on return', async () => { + const task = jest.fn(async () => undefined); + renderHook(() => usePolling(task, { intervalMs: 100 })); + expect(task).toHaveBeenCalledTimes(1); + + await setForeground(false); + await tick(1_000); + expect(task).toHaveBeenCalledTimes(1); + + await setForeground(true); + expect(task).toHaveBeenCalledTimes(2); + await tick(100); + expect(task).toHaveBeenCalledTimes(3); + }); + + it('never runs two tasks concurrently', async () => { + let release!: () => void; + const task = jest.fn(() => new Promise(resolve => { release = resolve; })); + renderHook(() => usePolling(task, { intervalMs: 100 })); + expect(task).toHaveBeenCalledTimes(1); + + await tick(500); + expect(task).toHaveBeenCalledTimes(1); + + await act(async () => { release(); }); + await tick(100); + expect(task).toHaveBeenCalledTimes(2); + }); + + it('backs off after consecutive failures and recovers after a success', async () => { + const task = jest.fn(async (): Promise => { throw new Error('offline'); }); + renderHook(() => usePolling(task, { intervalMs: 100, maxIntervalMs: 400 })); + expect(task).toHaveBeenCalledTimes(1); + + await tick(100); + expect(task).toHaveBeenCalledTimes(1); + await tick(100); + expect(task).toHaveBeenCalledTimes(2); + + await tick(399); + expect(task).toHaveBeenCalledTimes(2); + await tick(1); + expect(task).toHaveBeenCalledTimes(3); + + task.mockImplementation(async () => undefined); + await tick(400); + expect(task).toHaveBeenCalledTimes(4); + await tick(100); + expect(task).toHaveBeenCalledTimes(5); + }); + + it('restarts immediately when the reset key changes', async () => { + const task = jest.fn(async () => undefined); + const { rerender } = renderHook(({ resetKey }) => usePolling(task, { intervalMs: 100, resetKey }), { + initialProps: { resetKey: 'a' }, + }); + expect(task).toHaveBeenCalledTimes(1); + rerender({ resetKey: 'b' }); + expect(task).toHaveBeenCalledTimes(2); + await tick(50); + expect(task).toHaveBeenCalledTimes(2); + }); + + it('stops and unsubscribes on unmount', async () => { + const task = jest.fn(async () => undefined); + const { unmount } = renderHook(() => usePolling(task, { intervalMs: 100 })); + await tick(100); + expect(task).toHaveBeenCalledTimes(2); + unmount(); + expect(mockListeners.size).toBe(0); + await tick(1_000); + expect(task).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useTypingIndicator.test.ts b/apps/mobile/src/__tests__/hooks/useTypingIndicator.test.ts index 6feb5b8..367147d 100644 --- a/apps/mobile/src/__tests__/hooks/useTypingIndicator.test.ts +++ b/apps/mobile/src/__tests__/hooks/useTypingIndicator.test.ts @@ -76,4 +76,22 @@ describe('useTypingIndicator API polling', () => { act(() => { jest.advanceTimersByTime(2_000); }); expect(chatApi.getTyping).toHaveBeenCalledTimes(calls); }); + it('stops polling and mirrors typing state supplied by the message poll', async () => { + const { result, rerender } = renderHook, { externalTyping: boolean | undefined }>( + ({ externalTyping }) => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500, externalTyping }), + { initialProps: { externalTyping: undefined as boolean | undefined } }, + ); + await act(async () => { await Promise.resolve(); }); + const callsBeforeFolding = (chatApi.getTyping as jest.Mock).mock.calls.length; + + rerender({ externalTyping: true }); + expect(result.current.partnerTyping).toBe(true); + await act(async () => { jest.advanceTimersByTime(5_000); }); + expect(chatApi.getTyping).toHaveBeenCalledTimes(callsBeforeFolding); + + act(() => result.current.clearTypingIndicator()); + expect(result.current.partnerTyping).toBe(false); + rerender({ externalTyping: false }); + expect(result.current.partnerTyping).toBe(false); + }); }); diff --git a/apps/mobile/src/__tests__/lib/appForeground.test.ts b/apps/mobile/src/__tests__/lib/appForeground.test.ts new file mode 100644 index 0000000..a56e3b4 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/appForeground.test.ts @@ -0,0 +1,36 @@ +import { AppState } from 'react-native'; +import { isForeground, isForegroundState, subscribeToForeground } from '@/lib/appForeground'; + +describe('appForeground', () => { + afterEach(() => jest.restoreAllMocks()); + + it('treats only background and inactive as away', () => { + expect(isForegroundState('active')).toBe(true); + expect(isForegroundState('background')).toBe(false); + expect(isForegroundState('inactive')).toBe(false); + // Unknown states — including whatever a test harness leaves behind — must + // fail towards running, never towards disabling recurring work forever. + expect(isForegroundState(undefined)).toBe(true); + expect(isForegroundState('extension')).toBe(true); + }); + + it('reads the current state and reports transitions until unsubscribed', () => { + let emit: (state: string) => void = () => undefined; + const remove = jest.fn(); + jest.spyOn(AppState, 'addEventListener').mockImplementation(((_: string, handler: (state: string) => void) => { + emit = handler; + return { remove } as never; + }) as never); + + expect(isForeground()).toBe(isForegroundState(AppState.currentState)); + + const seen: boolean[] = []; + const unsubscribe = subscribeToForeground(value => seen.push(value)); + emit('background'); + emit('active'); + expect(seen).toEqual([false, true]); + + unsubscribe(); + expect(remove).toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/chatApi.test.ts b/apps/mobile/src/__tests__/lib/chatApi.test.ts index 474b2c3..8ebf294 100644 --- a/apps/mobile/src/__tests__/lib/chatApi.test.ts +++ b/apps/mobile/src/__tests__/lib/chatApi.test.ts @@ -24,6 +24,18 @@ describe('chatApi', () => { expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing'); }); + it('encodes the delta cursor and folded typing request into the message query', async () => { + (apiClient.get as jest.Mock).mockResolvedValue({ messages: [], removed_ids: [], server_time: 'now', complete: false }); + + await chatApi.listMessages('match1', { typing: true }); + await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000+01:00' }); + await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000Z', typing: true }); + + expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/matches/match1/messages?typing=true'); + expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000%2B01%3A00'); + expect(apiClient.get).toHaveBeenNthCalledWith(3, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000Z&typing=true'); + }); + it('uses authenticated unread, receipt, deletion and reporting routes', async () => { (apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} }); (apiClient.post as jest.Mock).mockResolvedValue({}); diff --git a/apps/mobile/src/__tests__/stores/matchStore.test.ts b/apps/mobile/src/__tests__/stores/matchStore.test.ts index 0c8fe74..9514648 100644 --- a/apps/mobile/src/__tests__/stores/matchStore.test.ts +++ b/apps/mobile/src/__tests__/stores/matchStore.test.ts @@ -45,9 +45,18 @@ describe('matchStore API behavior', () => { }); it('guards concurrent loads and exhausted pagination', async () => { - useMatchStore.setState({ isLoading: true }); - await useMatchStore.getState().fetchMatches(true); - useMatchStore.setState({ isLoading: false, hasMore: false }); + // Overlap protection cannot key off the loading flags any more: a silent + // refresh raises none of them and still must not issue a second request. + let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const inFlight = useMatchStore.getState().fetchMatches(true); + await useMatchStore.getState().fetchMatches(true, { silent: true }); + expect(apiClient.get).toHaveBeenCalledTimes(1); + release({ matches: [], totalCount: 0 }); + await inFlight; + + (apiClient.get as jest.Mock).mockClear(); + useMatchStore.setState({ hasMore: false }); await useMatchStore.getState().fetchMatches(false); expect(apiClient.get).not.toHaveBeenCalled(); }); @@ -147,7 +156,10 @@ describe('matchStore API behavior', () => { it('toggles archived visibility and triggers an initial fetch only', async () => { (apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 }); useMatchStore.getState().toggleShowArchived(); - await Promise.resolve(); + // Let the floating fetch settle: its in-flight guard is module-scoped and + // is only released in a finally, so a half-run request would leak into + // the next test and silently suppress its archived fetch. + await jest.runOnlyPendingTimersAsync(); expect(useMatchStore.getState().showArchived).toBe(true); expect(apiClient.get).toHaveBeenCalledTimes(1); useMatchStore.getState().toggleShowArchived(); @@ -178,11 +190,19 @@ describe('matchStore API behavior', () => { }); it('updates current view and delegates an empty view fetch', () => { + const real = useMatchStore.getState().fetchArchivedMatches; const fetchArchivedMatches = jest.fn(async () => undefined); useMatchStore.setState({ fetchArchivedMatches }); - useMatchStore.getState().setCurrentView('archived'); - expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true }); - expect(fetchArchivedMatches).toHaveBeenCalled(); + try { + useMatchStore.getState().setCurrentView('archived'); + expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true }); + expect(fetchArchivedMatches).toHaveBeenCalled(); + } finally { + // Actions live in the same store as the data, and clearMatches does not + // reset them: leaving the stub in place would silence every later test's + // archived fetch. + useMatchStore.setState({ fetchArchivedMatches: real }); + } }); }); @@ -225,6 +245,222 @@ describe('matchStore API behavior', () => { }); }); + describe('view switching and cache freshness', () => { + /** Answers each of the four view endpoints with an empty payload. */ + const emptyViews = () => (apiClient.get as jest.Mock).mockImplementation(async (path: string) => { + if (path.startsWith('/v1/matches?archived=true')) return { matches: [], totalCount: 0 }; + if (path.startsWith('/v1/matches')) return { matches: [], totalCount: 0, hasMore: false }; + return { questions: [] }; + }); + + it('switches between loaded views without a single extra request, even when every view is empty', async () => { + emptyViews(); + const views = ['pending', 'their_turn', 'active', 'archived'] as const; + + // First visit to each view loads it once. + for (const view of views) { + useMatchStore.getState().setCurrentView(view); + await jest.runOnlyPendingTimersAsync(); + } + expect(apiClient.get).toHaveBeenCalledTimes(views.length); + expect(useMatchStore.getState()).toMatchObject({ + matches: [], pendingQuestions: [], theirTurnQuestions: [], archivedMatches: [], + }); + + // Every subsequent switch is served from cache. An empty view is loaded, + // not unloaded: this is the bug where `array.length === 0` meant "never + // fetched" and made every visit pay for the same empty answer. + (apiClient.get as jest.Mock).mockClear(); + for (const view of [...views, ...views].reverse()) { + useMatchStore.getState().setCurrentView(view); + await jest.runOnlyPendingTimersAsync(); + } + expect(apiClient.get).not.toHaveBeenCalled(); + }); + + it('keeps cached rows and raises no foreground spinner during a silent refresh', async () => { + (apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false }); + await useMatchStore.getState().fetchMatches(true); + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']); + + let release: (page: unknown) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const refreshing = useMatchStore.getState().fetchMatches(true, { silent: true }); + + // Mid-flight: the user still sees the rows they had, and neither the + // full-screen loader nor the pull-to-refresh spinner is showing. + expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: false }); + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']); + + release({ matches: [match('fresh', false)], totalCount: 1, hasMore: false }); + await refreshing; + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']); + }); + + it('shows the pull-to-refresh spinner for a user-initiated refresh but never the initial loader', async () => { + (apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false }); + await useMatchStore.getState().fetchMatches(true); + + let release: (page: unknown) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const refreshing = useMatchStore.getState().fetchMatches(true); + expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: true }); + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']); + release({ matches: [], totalCount: 0, hasMore: false }); + await refreshing; + expect(useMatchStore.getState()).toMatchObject({ isRefreshing: false }); + }); + + it('keeps a silent list refresh out of the pending and their-turn loaders', async () => { + (apiClient.get as jest.Mock).mockResolvedValue({ questions: [pending('p1')] }); + await useMatchStore.getState().fetchPendingQuestions(); + await useMatchStore.getState().fetchTheirTurnQuestions(); + + let release: (page: unknown) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; })); + const quiet = Promise.all([ + useMatchStore.getState().fetchPendingQuestions({ silent: true }), + useMatchStore.getState().fetchTheirTurnQuestions({ silent: true }), + ]); + expect(useMatchStore.getState()).toMatchObject({ isLoadingPending: false, isLoadingTheirTurn: false }); + expect(useMatchStore.getState().pendingQuestions).toHaveLength(1); + release({ questions: [] }); + await quiet; + }); + }); + + describe('account isolation (generation tokens)', () => { + it('does not let a stale fetchMatches response populate the store after sign-out, and lets the next account start immediately', async () => { + let release: (page: unknown) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleFetch = useMatchStore.getState().fetchMatches(true); + + // Simulates sign-out/account switch while the request is still in flight. + useMatchStore.getState().clearMatches(); + release({ matches: [match('stale')], totalCount: 1, hasMore: false }); + await staleFetch; + + expect(useMatchStore.getState().matches).toEqual([]); + + (apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('fresh')], totalCount: 1, hasMore: false }); + await useMatchStore.getState().fetchMatches(true); + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']); + }); + + it('does not let a stale seen-marker write over the next account\'s list', async () => { + useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 }); + let release: () => void = () => undefined; + (apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = () => resolve(); })); + const stale = useMatchStore.getState().markAsSeen('theirs'); + + useMatchStore.getState().clearMatches(); + useMatchStore.setState({ matches: [match('mine')], newMatchesCount: 1 }); + const untouched = useMatchStore.getState().matches; + release(); + await stale; + + // Not merely "the same ids": the previous account's response must not + // rewrite the array at all, because the recount it derives is only true + // of the rows that account had loaded. + expect(useMatchStore.getState().matches).toBe(untouched); + expect(useMatchStore.getState().newMatchesCount).toBe(1); + }); + + it('does not let a stale mark-all-seen clear the next account\'s new badges', async () => { + useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 }); + let release: () => void = () => undefined; + (apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = () => resolve(); })); + const stale = useMatchStore.getState().markAllAsSeen(); + + useMatchStore.getState().clearMatches(); + useMatchStore.setState({ matches: [match('mine', true), match('mine2', true)], newMatchesCount: 2 }); + release(); + await stale; + + expect(useMatchStore.getState().matches.every(item => item.is_new)).toBe(true); + expect(useMatchStore.getState().newMatchesCount).toBe(2); + }); + + it('does not roll a failed archive or unarchive back into the next account', async () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + let fail: (error: Error) => void = () => undefined; + (apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; })); + useMatchStore.setState({ matches: [match('theirs')], archivedMatches: [], archivedMatchIds: new Set(), totalCount: 1 }); + const staleArchive = useMatchStore.getState().archiveMatch('theirs'); + + useMatchStore.getState().clearMatches(); + useMatchStore.setState({ matches: [match('mine')], totalCount: 1 }); + fail(new Error('forbidden')); + await staleArchive; + + // The revert would otherwise put one couple's match into another's list. + expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['mine']); + expect(useMatchStore.getState().totalCount).toBe(1); + + (apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; })); + useMatchStore.setState({ archivedMatches: [match('theirsArchived')], archivedMatchIds: new Set(['theirsArchived']) }); + const staleUnarchive = useMatchStore.getState().unarchiveMatch('theirsArchived'); + + useMatchStore.getState().clearMatches(); + useMatchStore.setState({ archivedMatches: [match('mineArchived')], archivedMatchIds: new Set(['mineArchived']), totalCount: 0 }); + fail(new Error('forbidden')); + await staleUnarchive; + + expect(useMatchStore.getState().archivedMatches.map(item => item.id)).toEqual(['mineArchived']); + expect(useMatchStore.getState().totalCount).toBe(0); + }); + + it('does not carry a nudge cooldown across an account switch', async () => { + let release: (data: unknown) => void = () => undefined; + (appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleSend = useMatchStore.getState().sendNudge(); + + useMatchStore.getState().clearMatches(); + release({ success: true, notification_sent: true, next_nudge_available_at: '2026-08-28T00:00:00Z' }); + await expect(staleSend).resolves.toEqual({ success: false, notificationSent: false }); + expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull(); + + // The 429 branch writes a cooldown too, and must be gated the same way. + let fail: (error: Error) => void = () => undefined; + (appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; })); + const staleLimit = useMatchStore.getState().sendNudge(); + useMatchStore.getState().clearMatches(); + fail(new ApiError('rate limited', 429, { cooldown_remaining_seconds: 60 })); + await staleLimit; + expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull(); + }); + + it('does not let a stale cooldown read clear the next account\'s cooldown', async () => { + let release: (profile: unknown) => void = () => undefined; + (appDataApi.nudgeStatus as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const stale = useMatchStore.getState().checkNudgeCooldown(); + + useMatchStore.getState().clearMatches(); + const mine = new Date('2026-08-27T18:00:00Z'); + useMatchStore.setState({ nudgeCooldownUntil: mine }); + // The previous account not being in cooldown says nothing about this one. + release({ last_nudge_sent_at: null }); + await stale; + expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(mine); + }); + + it('does not let a stale fetchPendingQuestions response populate the store after sign-out, and lets the next account start immediately', async () => { + let release: (page: unknown) => void = () => undefined; + (apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleFetch = useMatchStore.getState().fetchPendingQuestions(); + + useMatchStore.getState().clearMatches(); + release({ questions: [pending('stale')] }); + await staleFetch; + + expect(useMatchStore.getState().pendingQuestions).toEqual([]); + + (apiClient.get as jest.Mock).mockResolvedValueOnce({ questions: [pending('fresh')] }); + await useMatchStore.getState().fetchPendingQuestions(); + expect(useMatchStore.getState().pendingQuestions.map(item => item.id)).toEqual(['fresh']); + }); + }); + it('clearMatches resets all user-scoped state', () => { useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true }); useMatchStore.getState().clearMatches(); diff --git a/apps/mobile/src/__tests__/stores/messageStore.test.ts b/apps/mobile/src/__tests__/stores/messageStore.test.ts index d9be140..a7a6444 100644 --- a/apps/mobile/src/__tests__/stores/messageStore.test.ts +++ b/apps/mobile/src/__tests__/stores/messageStore.test.ts @@ -47,15 +47,25 @@ describe('messageStore API state', () => { expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } }); }); - it('marks partner messages read then reconciles global and per-match counts', async () => { + it('marks partner messages read and subtracts the cleared count without re-reading the total', async () => { + useMessageStore.setState({ unreadCount: 3 }); (chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' }); - (chatApi.unread as jest.Mock).mockResolvedValue({ total: 1, by_match: { m2: 1 } }); await useMessageStore.getState().markMatchMessagesAsRead('m1'); expect(chatApi.markRead).toHaveBeenCalledWith('m1'); + expect(chatApi.unread).not.toHaveBeenCalled(); expect(useMessageStore.getState().unreadCount).toBe(1); expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1); }); + it('spends no request and touches no count when the read API cleared nothing', async () => { + useMessageStore.setState({ unreadCount: 3 }); + (chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: null }); + await useMessageStore.getState().markMatchMessagesAsRead('m1'); + expect(chatApi.unread).not.toHaveBeenCalled(); + expect(useMessageStore.getState().unreadCount).toBe(3); + expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3); + }); + it('does not mark read without an authenticated user', async () => { useAuthStore.setState({ user: null } as any); await useMessageStore.getState().markMatchMessagesAsRead('m1'); @@ -69,6 +79,42 @@ describe('messageStore API state', () => { expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3); }); + describe('account isolation (generation tokens)', () => { + it('does not let a stale unread total become the next account\'s badge', async () => { + let release: (value: unknown) => void = () => undefined; + (chatApi.unread as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleFetch = useMessageStore.getState().fetchUnreadCount(); + + // Sign-out or leaving a couple, while the request is still open. + useMessageStore.getState().clearMessages(); + useMessageStore.setState({ unreadCount: 2 }); + + release({ total: 9, by_match: { m1: 9 } }); + await staleFetch; + + expect(useMessageStore.getState().unreadCount).toBe(2); + }); + + it('does not subtract a stale read receipt from the next account\'s badge', async () => { + useMessageStore.setState({ unreadCount: 3 }); + let release: (value: unknown) => void = () => undefined; + (chatApi.markRead as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleRead = useMessageStore.getState().markMatchMessagesAsRead('m1'); + + useMessageStore.getState().clearMessages(); + useMessageStore.setState({ unreadCount: 2 }); + useMatchStore.setState({ matches: [{ id: 'm1', unreadCount: 2 } as any] }); + + release({ updated: 3, read_at: '2026-08-27T00:00:00.000Z' }); + await staleRead; + + // The badge is wrong whatever the match id; reusing 'm1' also makes the + // second half of the damage — the per-match count — visible. + expect(useMessageStore.getState().unreadCount).toBe(2); + expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(2); + }); + }); + it('clears transient message state', () => { useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' }); useMessageStore.getState().clearLastMessage(); diff --git a/apps/mobile/src/__tests__/stores/packsStore.test.ts b/apps/mobile/src/__tests__/stores/packsStore.test.ts index d62cfda..efa8ce5 100644 --- a/apps/mobile/src/__tests__/stores/packsStore.test.ts +++ b/apps/mobile/src/__tests__/stores/packsStore.test.ts @@ -64,4 +64,69 @@ describe('packsStore standalone API integration', () => { expect(getSpy).not.toHaveBeenCalled(); expect(usePacksStore.getState().enabledPackIds).toEqual([]); }); + + describe('account isolation (generation tokens)', () => { + it('does not let a stale fetchEnabledPacks response populate the store after clearPacks, and lets the next account start immediately', async () => { + let release: (page: unknown) => void = () => undefined; + const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleFetch = usePacksStore.getState().fetchEnabledPacks(); + + // Simulates sign-out/account switch while the request is still in flight. + usePacksStore.getState().clearPacks(); + release({ enabledPackIds: ['stale-pack'] }); + await staleFetch; + + expect(usePacksStore.getState().enabledPackIds).toEqual([]); + + getSpy.mockResolvedValueOnce({ enabledPackIds: ['fresh-pack'] }); + await usePacksStore.getState().fetchEnabledPacks(); + expect(usePacksStore.getState().enabledPackIds).toEqual(['fresh-pack']); + }); + + it('does not let a stale fetchPackProgress response populate the store after clearPacks, and lets the next account start immediately', async () => { + let release: (page: unknown) => void = () => undefined; + const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleFetch = usePacksStore.getState().fetchPackProgress(); + + usePacksStore.getState().clearPacks(); + release({ progress: [{ packId: 'stale', totalQuestions: 1, answeredQuestions: 1 }] }); + await staleFetch; + + expect(usePacksStore.getState().packProgress.size).toBe(0); + + getSpy.mockResolvedValueOnce({ progress: [{ packId: 'fresh', totalQuestions: 2, answeredQuestions: 1 }] }); + await usePacksStore.getState().fetchPackProgress(); + expect(usePacksStore.getState().packProgress.get('fresh')).toEqual({ totalQuestions: 2, answeredQuestions: 1 }); + }); + + it('does not let a stale toggle write the previous couple\'s pack selection', async () => { + usePacksStore.setState({ enabledPackIds: [] } as any); + let release: (value: unknown) => void = () => undefined; + jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise(resolve => { release = resolve; })); + const staleToggle = usePacksStore.getState().togglePack('pack1'); + + usePacksStore.getState().clearPacks(); + usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any); + + release({ enabledPackIds: ['pack1'] }); + await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' }); + expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']); + }); + + it('does not roll a failed toggle back into the next couple\'s selection', async () => { + usePacksStore.setState({ enabledPackIds: ['pack1'] } as any); + let reject: (reason: unknown) => void = () => undefined; + jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail; })); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const staleToggle = usePacksStore.getState().togglePack('pack1'); + + usePacksStore.getState().clearPacks(); + usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any); + + reject(new Error('offline')); + await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' }); + // The rollback would have re-added pack1, which this couple never enabled. + expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']); + }); + }); }); diff --git a/apps/mobile/src/__tests__/stores/responsesStore.test.ts b/apps/mobile/src/__tests__/stores/responsesStore.test.ts index 707789e..df71fd2 100644 --- a/apps/mobile/src/__tests__/stores/responsesStore.test.ts +++ b/apps/mobile/src/__tests__/stores/responsesStore.test.ts @@ -265,6 +265,53 @@ describe('responsesStore', () => { }); }); + describe('account isolation (generation tokens)', () => { + it('does not let a stale fetchResponses response populate the store after clearResponses, and lets the next account start immediately', async () => { + let resolve!: (value: unknown) => void; + apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + const staleFetch = useResponsesStore.getState().fetchResponses(true); + + // Simulates sign-out/account switch while the request is still in flight. + useResponsesStore.getState().clearResponses(); + resolve({ responses: [response({ id: 'stale' })], totalCount: 1 }); + await staleFetch; + + expect(useResponsesStore.getState().responses).toEqual([]); + + apiGet.mockResolvedValueOnce({ responses: [response({ id: 'fresh' })], totalCount: 1 }); + await useResponsesStore.getState().fetchResponses(true); + expect(useResponsesStore.getState().responses.map((r) => r.id)).toEqual(['fresh']); + }); + + it('does not let a stale edit rewrite the next account\'s row or refresh its matches', async () => { + // Question ids come from the shared catalogue, so the same id names a real + // row in both accounts' lists: a stale edit would land on one of them. + const fetchMatches = jest.fn(); + useMatchStore.setState({ fetchMatches } as any); + useResponsesStore.setState({ + responses: [response({ id: 'alice', answer: 'maybe', has_match: false, match_id: undefined })], + }); + + let resolve!: (value: unknown) => void; + apiPatch.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + const staleEdit = useResponsesStore.getState().updateResponse('q1', 'yes'); + + useResponsesStore.getState().clearResponses(); + const next = [response({ id: 'bob', answer: 'no', has_match: false, match_id: undefined })]; + useResponsesStore.setState({ responses: next }); + + resolve({ success: true, new_match: { id: 'm2' } }); + // The caller still hears what the server did for the account that asked. + await expect(staleEdit).resolves.toMatchObject({ success: true }); + + expect(useResponsesStore.getState().responses).toBe(next); + expect(useResponsesStore.getState().responses[0]).toMatchObject({ + id: 'bob', answer: 'no', has_match: false, + }); + expect(fetchMatches).not.toHaveBeenCalled(); + }); + }); + describe('state controls', () => { it('sets and toggles grouping options', () => { useResponsesStore.getState().setGroupBy('pack'); diff --git a/apps/mobile/src/__tests__/stores/streakStore.test.ts b/apps/mobile/src/__tests__/stores/streakStore.test.ts new file mode 100644 index 0000000..4a95415 --- /dev/null +++ b/apps/mobile/src/__tests__/stores/streakStore.test.ts @@ -0,0 +1,58 @@ +import { apiClient } from '@/lib/apiClient'; +import { useAuthStore } from '@/store/authStore'; +import { type CoupleStreak, useStreakStore } from '@/store/streakStore'; + +jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn() } })); + +const apiGet = apiClient.get as jest.Mock; + +function streak(overrides: Partial = {}): CoupleStreak { + return { + couple_id: 'couple1', current_streak: 1, longest_streak: 1, + last_active_date: '2026-08-27', last_completed_date: '2026-08-27', + you_answered_today: true, partner_answered_today: false, + partner_name: 'Partner', timezone: 'UTC', streak_celebrated_at: 0, + created_at: '2026-08-01T00:00:00.000Z', updated_at: '2026-08-27T00:00:00.000Z', + ...overrides, + }; +} + +describe('streakStore account isolation (generation tokens)', () => { + beforeEach(() => { + jest.clearAllMocks(); + useStreakStore.setState({ streak: null, isLoading: false, error: null, loadedAt: null, generation: 0 }); + useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any); + }); + + it('does not let a stale fetchStreak response populate the store after clearStreak, and lets the next account start immediately', async () => { + let resolve!: (value: unknown) => void; + apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; })); + const staleFetch = useStreakStore.getState().fetchStreak(); + + // Simulates sign-out/account switch while the request is still in flight. + useStreakStore.getState().clearStreak(); + resolve({ streak: streak({ couple_id: 'stale-couple' }) }); + await staleFetch; + + expect(useStreakStore.getState().streak).toBeNull(); + expect(useStreakStore.getState().loadedAt).toBeNull(); + + apiGet.mockResolvedValueOnce({ streak: streak({ couple_id: 'fresh-couple' }) }); + await useStreakStore.getState().fetchStreak(); + expect(useStreakStore.getState().streak?.couple_id).toBe('fresh-couple'); + expect(useStreakStore.getState().loadedAt).not.toBeNull(); + }); + + it('refreshStreak silently reloads only an already-loaded streak', async () => { + await useStreakStore.getState().refreshStreak(); + expect(apiGet).not.toHaveBeenCalled(); + + apiGet.mockResolvedValueOnce({ streak: streak() }); + await useStreakStore.getState().fetchStreak(); + expect(useStreakStore.getState().loadedAt).not.toBeNull(); + + apiGet.mockResolvedValueOnce({ streak: streak({ current_streak: 2 }) }); + await useStreakStore.getState().refreshStreak(); + expect(useStreakStore.getState().streak?.current_streak).toBe(2); + }); +}); diff --git a/apps/mobile/src/components/StreakDisplay.tsx b/apps/mobile/src/components/StreakDisplay.tsx index fd2a63d..88c8b1f 100644 --- a/apps/mobile/src/components/StreakDisplay.tsx +++ b/apps/mobile/src/components/StreakDisplay.tsx @@ -1,227 +1,227 @@ -import React, { useEffect } from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import { Ionicons } from '@expo/vector-icons'; -import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated'; -import { useStreakStore } from '../store'; -import type { CoupleStreak } from '../store'; -import { colors, spacing, radius, typography, shadows } from '../theme'; - -interface StreakDisplayProps { - /** Whether to show the "longest streak" indicator */ - showLongest?: boolean; - /** Animation delay for entry animation */ - delay?: number; - /** Compact mode for smaller displays */ - compact?: boolean; -} - -/** - * The count is deliberately framed as a couple's, not a personal score: a shared streak - * is the thing worth keeping. The status line is the working half of that framing - it - * says who the day is waiting on - and the copy stays an invitation, because guilt - * between partners is the failure mode for this mechanic rather than the goal. - */ -export function streakStatus(streak: CoupleStreak): { headline: string; status: string } { - const named = streak.partner_name?.trim() || null; - // The same fallback reads wrong in both positions, so keep a sentence-start form. - const subject = named ?? 'Your partner'; - const object = named ?? 'your partner'; - const days = streak.current_streak; - const both = streak.you_answered_today && streak.partner_answered_today; - - const headline = days > 0 - ? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row` - : `You and ${object}`; - - if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' }; - if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` }; - if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` }; - return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' }; -} - -/** - * Displays the couple's shared streak. The flame pulses while the streak is alive. - */ -export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) { - const { streak, fetchStreak } = useStreakStore(); - - // Fetch streak on mount - useEffect(() => { - fetchStreak(); - }, [fetchStreak]); - - // Flame pulse animation - const scale = useSharedValue(1); - const hasStreak = (streak?.current_streak ?? 0) > 0; - - useEffect(() => { - if (hasStreak) { - scale.value = withRepeat( - withSequence( - withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }), - withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) }) - ), - -1, - true - ); - } else { - scale.value = 1; - } - }, [hasStreak, scale]); - - const flameStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })); - - if (!streak) { - return null; - } - - // A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply - // have not played yet reads as a scolding for a streak they never started. - if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) { - return null; - } - - const { headline, status } = streakStatus(streak); - const currentStreak = streak.current_streak; - const longestStreak = streak.longest_streak; - // The partner having moved first is the one state the couple can still act on today. - const awaitingYou = streak.partner_answered_today && !streak.you_answered_today; - - if (compact) { - return ( - - - - - - - {currentStreak} - - - - ); - } - - return ( - - - - - - - - - - - {headline} - - - {status} - - - - - {showLongest && longestStreak > 0 && ( - - - - Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''} - - - )} - - - - ); -} - -const styles = StyleSheet.create({ - container: { - marginVertical: spacing.sm, - }, - card: { - backgroundColor: colors.surface, - borderRadius: radius.lg, - padding: spacing.md, - borderWidth: 1, - borderColor: colors.border, - }, - cardAwaiting: { - borderColor: colors.warning, - }, - content: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.md, - }, - iconContainer: { - width: 44, - height: 44, - borderRadius: radius.full, - backgroundColor: colors.warningLight, - justifyContent: 'center', - alignItems: 'center', - }, - info: { - flex: 1, - }, - headline: { - ...typography.headline, - color: colors.text, - }, - status: { - ...typography.subhead, - color: colors.textSecondary, - marginTop: 2, - }, - statusAwaiting: { - color: colors.warning, - }, - inactiveText: { - color: colors.textTertiary, - }, - longest: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - marginTop: spacing.sm, - paddingTop: spacing.sm, - borderTopWidth: 1, - borderTopColor: colors.border, - }, - longestText: { - ...typography.caption1, - color: colors.textTertiary, - }, - // Compact styles - compactContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.xs, - backgroundColor: colors.warningLight, - paddingHorizontal: spacing.sm, - paddingVertical: spacing.xs, - borderRadius: radius.full, - }, - compactAwaiting: { - borderWidth: 1, - borderColor: colors.warning, - }, - compactCount: { - ...typography.caption1, - fontWeight: '600', - color: colors.warning, - }, -}); - -export default StreakDisplay; +import React, { useEffect } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated'; +import { useStreakStore } from '../store'; +import type { CoupleStreak } from '../store'; +import { colors, spacing, radius, typography, shadows } from '../theme'; + +interface StreakDisplayProps { + /** Whether to show the "longest streak" indicator */ + showLongest?: boolean; + /** Animation delay for entry animation */ + delay?: number; + /** Compact mode for smaller displays */ + compact?: boolean; +} + +/** + * The count is deliberately framed as a couple's, not a personal score: a shared streak + * is the thing worth keeping. The status line is the working half of that framing - it + * says who the day is waiting on - and the copy stays an invitation, because guilt + * between partners is the failure mode for this mechanic rather than the goal. + */ +export function streakStatus(streak: CoupleStreak): { headline: string; status: string } { + const named = streak.partner_name?.trim() || null; + // The same fallback reads wrong in both positions, so keep a sentence-start form. + const subject = named ?? 'Your partner'; + const object = named ?? 'your partner'; + const days = streak.current_streak; + const both = streak.you_answered_today && streak.partner_answered_today; + + const headline = days > 0 + ? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row` + : `You and ${object}`; + + if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' }; + if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` }; + if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` }; + return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' }; +} + +/** + * Displays the couple's shared streak. The flame pulses while the streak is alive. + */ +export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) { + const { streak, ensureStreakLoaded } = useStreakStore(); + + // Fetch streak on mount + useEffect(() => { + void ensureStreakLoaded(); + }, [ensureStreakLoaded]); + + // Flame pulse animation + const scale = useSharedValue(1); + const hasStreak = (streak?.current_streak ?? 0) > 0; + + useEffect(() => { + if (hasStreak) { + scale.value = withRepeat( + withSequence( + withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }), + withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) }) + ), + -1, + true + ); + } else { + scale.value = 1; + } + }, [hasStreak, scale]); + + const flameStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + if (!streak) { + return null; + } + + // A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply + // have not played yet reads as a scolding for a streak they never started. + if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) { + return null; + } + + const { headline, status } = streakStatus(streak); + const currentStreak = streak.current_streak; + const longestStreak = streak.longest_streak; + // The partner having moved first is the one state the couple can still act on today. + const awaitingYou = streak.partner_answered_today && !streak.you_answered_today; + + if (compact) { + return ( + + + + + + + {currentStreak} + + + + ); + } + + return ( + + + + + + + + + + + {headline} + + + {status} + + + + + {showLongest && longestStreak > 0 && ( + + + + Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''} + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginVertical: spacing.sm, + }, + card: { + backgroundColor: colors.surface, + borderRadius: radius.lg, + padding: spacing.md, + borderWidth: 1, + borderColor: colors.border, + }, + cardAwaiting: { + borderColor: colors.warning, + }, + content: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + iconContainer: { + width: 44, + height: 44, + borderRadius: radius.full, + backgroundColor: colors.warningLight, + justifyContent: 'center', + alignItems: 'center', + }, + info: { + flex: 1, + }, + headline: { + ...typography.headline, + color: colors.text, + }, + status: { + ...typography.subhead, + color: colors.textSecondary, + marginTop: 2, + }, + statusAwaiting: { + color: colors.warning, + }, + inactiveText: { + color: colors.textTertiary, + }, + longest: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + marginTop: spacing.sm, + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + longestText: { + ...typography.caption1, + color: colors.textTertiary, + }, + // Compact styles + compactContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + backgroundColor: colors.warningLight, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.full, + }, + compactAwaiting: { + borderWidth: 1, + borderColor: colors.warning, + }, + compactCount: { + ...typography.caption1, + fontWeight: '600', + color: colors.warning, + }, +}); + +export default StreakDisplay; diff --git a/apps/mobile/src/features/chat/ChatScreen.tsx b/apps/mobile/src/features/chat/ChatScreen.tsx index c2ec77b..620cf85 100644 --- a/apps/mobile/src/features/chat/ChatScreen.tsx +++ b/apps/mobile/src/features/chat/ChatScreen.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { useFocusEffect, useIsFocused } from "@react-navigation/native"; @@ -60,20 +60,31 @@ export const ChatScreen: React.FC = () => { const isFocused = useIsFocused(); - // Typing indicator hook + // Each hook needs a value the other produces: the message poll reports typing + // state, and an arriving message clears the indicator. The ref breaks the cycle. + const clearTypingRef = useRef<() => void>(() => undefined); + const handleNewMessage = useCallback(() => clearTypingRef.current(), []); + + // Message subscription hook + const { messages, setMessages, partnerTyping: syncedTyping } = useMessageSubscription({ + matchId, + userId: user?.id, + onNewMessage: handleNewMessage, + isFocused, + }); + + // Typing indicator hook, reading the state the message poll already fetched const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({ channelName: `chat:${matchId}`, matchId, userId: user?.id, isFocused, + externalTyping: syncedTyping, }); - // Message subscription hook - const { messages, setMessages, isFocusedRef } = useMessageSubscription({ - matchId, - userId: user?.id, - onNewMessage: clearTypingIndicator, - }); + useEffect(() => { + clearTypingRef.current = clearTypingIndicator; + }, [clearTypingIndicator]); // Message actions hook for deletion and reporting @@ -174,16 +185,12 @@ export const ChatScreen: React.FC = () => { // Track active chat to prevent notifications for current chat useFocusEffect( useCallback(() => { - isFocusedRef.current = true; if (matchId) { setActiveMatchId(matchId); markMatchMessagesAsRead(matchId); Events.matchViewed(); } - return () => { - isFocusedRef.current = false; - setActiveMatchId(null); - }; + return () => setActiveMatchId(null); }, [matchId, setActiveMatchId, markMatchMessagesAsRead]) ); diff --git a/apps/mobile/src/features/dares/DaresScreen.tsx b/apps/mobile/src/features/dares/DaresScreen.tsx index 16f1606..1273ff8 100644 --- a/apps/mobile/src/features/dares/DaresScreen.tsx +++ b/apps/mobile/src/features/dares/DaresScreen.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native"; import { Ionicons } from "@expo/vector-icons"; +import { useIsFocused } from "@react-navigation/native"; import Animated, { FadeIn } from "react-native-reanimated"; import { GradientBackground } from "../../components/ui"; import { Paywall } from "../../components/paywall"; @@ -20,7 +21,8 @@ const TABS: { key: Tab; label: string }[] = [ ]; export function DaresScreen() { - const dares = useDares(); + const isFocused = useIsFocused(); + const dares = useDares({ isFocused }); const [tab, setTab] = useState("active"); const [sheetPack, setSheetPack] = useState(null); const [sheetOpen, setSheetOpen] = useState(false); diff --git a/apps/mobile/src/features/dares/hooks/useDares.ts b/apps/mobile/src/features/dares/hooks/useDares.ts index 9c21627..b0d73dd 100644 --- a/apps/mobile/src/features/dares/hooks/useDares.ts +++ b/apps/mobile/src/features/dares/hooks/useDares.ts @@ -1,11 +1,21 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { daresApi } from "../../../lib/daresApi"; import { ApiError } from "../../../lib/apiClient"; +import { usePolling } from "../../../hooks/usePolling"; import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types"; /** Matches the chat poll cadence; the API has no realtime channel. */ const POLL_INTERVAL_MS = 5_000; +export interface UseDaresConfig { + /** + * Supplied by the screen rather than read from navigation here, so the hook + * stays renderable without a navigator. Polling stops when the screen is not + * on top; `usePolling` additionally stops it when the app backgrounds. + */ + isFocused?: boolean; +} + export interface UseDaresReturn { active: SentDare[]; history: SentDare[]; @@ -25,7 +35,19 @@ export interface UseDaresReturn { cancel: (dareId: string) => Promise; } -export function useDares(): UseDaresReturn { +/** + * Identity of the active set as the user experiences it. History, stats and the + * remaining-send allowance only move when a dare enters or leaves this set or + * changes status, so this is the signal that says the slower endpoints are worth + * re-reading. + */ +const activeSignature = (dares: SentDare[]): string => + dares + .map(dare => `${dare.id}:${dare.status}`) + .sort() + .join("|"); + +export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresReturn { const [active, setActive] = useState([]); const [history, setHistory] = useState([]); const [catalog, setCatalog] = useState(null); @@ -36,42 +58,63 @@ export function useDares(): UseDaresReturn { const [paywallReason, setPaywallReason] = useState(null); const fetchingRef = useRef(false); const mountedRef = useRef(true); + const signatureRef = useRef(null); - const refresh = useCallback(async () => { + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + /** + * `full` reads everything; the polling path reads only the active list and + * follows up on the rest when that list actually changed. A failure updates + * the UI's error state either way, but is only rethrown for the poll path + * (`throwOnError`): `usePolling` needs the rejection to drive its backoff, + * while pull-to-refresh and post-mutation callers already surface the error + * themselves and must not turn it into an unhandled rejection. + */ + const load = useCallback(async (full: boolean, throwOnError = false) => { if (fetchingRef.current) return; fetchingRef.current = true; try { - const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([ - daresApi.list("active"), - daresApi.list("history"), - daresApi.catalog(), - daresApi.stats(), - ]); + const activeResult = await daresApi.list("active"); if (!mountedRef.current) return; setActive(activeResult.dares); - setHistory(historyResult.dares); - setCatalog(catalogResult); - setStats(statsResult); + + const signature = activeSignature(activeResult.dares); + const changed = signature !== signatureRef.current; + signatureRef.current = signature; + + if (full || changed) { + const [historyResult, catalogResult, statsResult] = await Promise.all([ + daresApi.list("history"), + daresApi.catalog(), + daresApi.stats(), + ]); + if (!mountedRef.current) return; + setHistory(historyResult.dares); + setCatalog(catalogResult); + setStats(statsResult); + } setError(null); } catch (cause) { if (mountedRef.current) { setError(cause instanceof ApiError ? cause.message : "Could not load dares"); } + if (throwOnError) throw cause; } finally { fetchingRef.current = false; if (mountedRef.current) setLoading(false); } }, []); - useEffect(() => { - mountedRef.current = true; - void refresh(); - const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS); - return () => { - mountedRef.current = false; - clearInterval(timer); - }; - }, [refresh]); + /** Pull-to-refresh and post-mutation reloads always read the slow endpoints too. */ + const refresh = useCallback(() => load(true), [load]); + const poll = useCallback(() => load(false, true), [load]); + + usePolling(poll, { intervalMs: POLL_INTERVAL_MS, enabled: isFocused }); /** Premium refusals steer to the paywall; everything else surfaces inline. */ const handleFailure = useCallback((cause: unknown) => { diff --git a/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx b/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx index 38404f3..305fcfd 100644 --- a/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx +++ b/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx @@ -8,6 +8,7 @@ import { useWindowDimensions, } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useIsFocused } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; @@ -35,6 +36,7 @@ export const LiveDrawScreen: React.FC = () => { const insets = useSafeAreaInsets(); const { user } = useAuthStore(); const coupleId = coupleIdParam || user?.couple_id; + const isFocused = useIsFocused(); const { width: screenWidth } = useWindowDimensions(); const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]); @@ -95,6 +97,7 @@ export const LiveDrawScreen: React.FC = () => { const sync = useDrawingSync({ coupleId: coupleId!, userId: user?.id, + isFocused, onStrokeStart: handleStrokeStartFromPartner, onStrokeContinue: handleStrokeContinueFromPartner, onStrokeEnd: handleStrokeEndFromPartner, diff --git a/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts b/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts index 762b342..7afb207 100644 --- a/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts +++ b/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts @@ -1,9 +1,13 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { ApiError } from '../../../lib/apiClient'; import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi'; +import { usePolling } from '../../../hooks/usePolling'; import type { StrokeSegment, StrokePoint } from '../types'; import { updateWidget } from '../utils/widgetBridge'; +/** Fast enough for a shared canvas to feel live, and only while one is on screen. */ +const POLL_INTERVAL_MS = 750; + interface UseDrawingSyncConfig { coupleId: string; userId: string | undefined; @@ -14,6 +18,12 @@ interface UseDrawingSyncConfig { onUndo: (strokeId: string) => void; onRedo: (stroke: StrokeSegment) => void; onLoadStrokes: (strokes: StrokeSegment[]) => void; + /** + * Supplied by the screen rather than read from navigation here, so the hook + * stays renderable without a navigator. A canvas nobody is looking at polls + * nothing; `usePolling` also stops it when the app goes to the background. + */ + isFocused?: boolean; } interface UseDrawingSyncReturn { @@ -52,7 +62,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen } export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn { - const { coupleId, userId, onLoadStrokes } = config; + const { coupleId, userId, onLoadStrokes, isFocused = true } = config; const revisionRef = useRef(0); const baselineRef = useRef([]); const workingRef = useRef([]); @@ -132,19 +142,25 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu void appDataApi.getLiveDraw().then(acceptState).catch(error => console.error('Failed to load live drawing:', error)); }, [coupleId, acceptState]); - useEffect(() => { - if (!coupleId || !userId) return; - let cancelled = false; - const poll = async () => { - if (pendingWritesRef.current > 0) return; - try { - const state = await appDataApi.getLiveDraw(); - if (!cancelled && state.revision > revisionRef.current) acceptState(state); - } catch (error) { console.error('Failed to poll live drawing:', error); } - }; - const timer = setInterval(() => void poll(), 750); - return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); }; - }, [coupleId, userId, acceptState]); + // Our own writes already return the authoritative state, so reading over the top + // of one in flight would only race it back to a revision we are about to replace. + const poll = useCallback(async () => { + if (pendingWritesRef.current > 0) return; + const state = await appDataApi.getLiveDraw(); + if (state.revision > revisionRef.current) acceptState(state); + }, [acceptState]); + + // `leading: false` because the mount effect above already read the initial state; + // usePolling adds the overlap guard, backoff and background pause the raw + // interval had none of. + usePolling(poll, { + intervalMs: POLL_INTERVAL_MS, + enabled: Boolean(coupleId) && Boolean(userId) && isFocused, + leading: false, + resetKey: `${coupleId}:${userId ?? ''}`, + }); + + useEffect(() => () => { if (throttleRef.current) clearTimeout(throttleRef.current); }, []); const persistStrokes = useCallback((strokes: StrokeSegment[]) => { if (throttleRef.current) clearTimeout(throttleRef.current); diff --git a/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts b/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts index 3d7bc8b..2561b34 100644 --- a/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts +++ b/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useFocusEffect, useLocalSearchParams } from "expo-router"; -import { useAuthStore, usePacksStore, useStreakStore } from "../../../store"; +import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "../../../store"; import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions"; import { ApiError, apiClient } from "../../../lib/apiClient"; import { Events } from "../../../lib/analytics"; @@ -352,6 +352,12 @@ export const useSwipeScreen = () => { // wrong partner state for the rest of the session. Refreshing it must never // interfere with recording the answer, so it stays off the critical path. void useStreakStore.getState().fetchStreak().catch(() => undefined); + + // This answer changed pack progress and the answer history. Marking their + // caches stale costs no request: whichever screen the user opens next + // reloads once, and the ones they do not open stay quiet. + usePacksStore.getState().invalidatePacks(); + useResponsesStore.getState().invalidateResponses(); } catch (error) { // A 429 means the server rejected this answer outright. Do not advance the // index, or the question is silently skipped and lost for the day. diff --git a/apps/mobile/src/hooks/index.ts b/apps/mobile/src/hooks/index.ts index 6b01130..126e8c9 100644 --- a/apps/mobile/src/hooks/index.ts +++ b/apps/mobile/src/hooks/index.ts @@ -1,4 +1,7 @@ export { useAmbientOrbAnimation } from './useAmbientOrbAnimation'; +export { usePolling } from './usePolling'; +export type { UsePollingOptions } from './usePolling'; +export { useCoupleSync, SYNC_INTERVAL_MS } from './useCoupleSync'; export type { AmbientOrbConfig } from './useAmbientOrbAnimation'; export { useFeatureInterest } from './useFeatureInterest'; export { useMediaPicker } from './useMediaPicker'; diff --git a/apps/mobile/src/hooks/useCoupleSync.ts b/apps/mobile/src/hooks/useCoupleSync.ts new file mode 100644 index 0000000..3293e63 --- /dev/null +++ b/apps/mobile/src/hooks/useCoupleSync.ts @@ -0,0 +1,116 @@ +import { useCallback, useRef } from 'react'; +import { appDataApi, type SyncSummary } from '../lib/appDataApi'; +import { useAuthStore } from '../store/authStore'; +import { useMatchStore } from '../store/matchStore'; +import { useMessageStore } from '../store/messageStore'; +import { usePacksStore } from '../store/packsStore'; +import { useStreakStore } from '../store/streakStore'; +import { usePolling } from './usePolling'; + +/** + * Slower than the five-second full refresh it replaces, because the request it + * makes is a change summary rather than five payloads: the app reacts to a + * partner's action within this window, but pays one small query for it. + */ +export const SYNC_INTERVAL_MS = 15_000; + +/** + * Keeps cached domain data honest without re-fetching it. Each poll reads one + * summary of the couple's state and refreshes only the domains whose markers + * moved — and only the ones already on screen, so a view nobody has opened is + * not loaded in the background. + */ +export function useCoupleSync(userId: string | undefined, coupleId: string | null | undefined): void { + const previousRef = useRef(null); + const identityRef = useRef(''); + // `sync` is memoized on a stable callback, so `taskRef` inside usePolling + // never has to restart the schedule on every render; it reads the current + // identity through this ref instead of closing over the arguments from + // whichever render created it. + const identityValuesRef = useRef({ userId, coupleId }); + identityValuesRef.current = { userId, coupleId }; + + const latestIdentity = useCallback(() => { + const { userId, coupleId } = identityValuesRef.current; + return `${userId ?? ''}:${coupleId ?? ''}`; + }, []); + + const sync = useCallback(async () => { + const identity = latestIdentity(); + if (identity !== identityRef.current) { + // A new account's first summary must not be diffed against the prior + // account's markers, or every domain would silently "refresh" on login. + identityRef.current = identity; + previousRef.current = null; + } + + const summary = await appDataApi.syncSummary(); + // The account can change while this request is in flight. The response + // describes the identity that asked for it, so once that is no longer the + // current one it may not touch anything: not the badge, not the baseline + // the next account will diff against, and not a single refresh decision. + // Both checks matter — `identityRef` catches a newer sync that has already + // started, and `latestIdentity()` catches a switch that has only rendered. + if (identityRef.current !== identity || latestIdentity() !== identity) return; + + const previous = previousRef.current; + previousRef.current = summary; + + // The badge count comes straight out of the summary, so keeping it current + // costs nothing beyond this request. + if (summary.unread_total !== previous?.unread_total) { + useMessageStore.getState().setUnreadCount(summary.unread_total); + } + // The first poll only establishes the baseline: every screen loads its own + // data on mount, and refreshing it again here would undo the saving. + if (!previous) return; + + const auth = useAuthStore.getState(); + const matchStore = useMatchStore.getState(); + const refreshes: Promise[] = []; + + if (summary.profile_updated_at !== previous.profile_updated_at || summary.couple_id !== previous.couple_id) { + // fetchUser also refreshes the couple when one is set. + refreshes.push(auth.fetchUser()); + } else if (summary.partner_id !== previous.partner_id || summary.partner_updated_at !== previous.partner_updated_at) { + refreshes.push(auth.fetchCouple()); + } + + if (matchStore.loadedAt.active !== undefined && ( + summary.match_count !== previous.match_count || + summary.new_match_count !== previous.new_match_count || + summary.latest_match_at !== previous.latest_match_at || + // Neither count moves when a partner edits an existing match's type or + // response summary through response editing. + summary.match_state_fingerprint !== previous.match_state_fingerprint || + // `unread_total` can hold steady while unread redistributes between + // matches (one read, another receives a message), which reorders the + // unread-first list without moving the total. + summary.match_unread_fingerprint !== previous.match_unread_fingerprint)) { + refreshes.push(matchStore.fetchMatches(true, { silent: true })); + } + if (matchStore.loadedAt.pending !== undefined && summary.pending_yours !== previous.pending_yours) { + refreshes.push(matchStore.fetchPendingQuestions({ silent: true })); + } + if (matchStore.loadedAt.their_turn !== undefined && summary.pending_theirs !== previous.pending_theirs) { + refreshes.push(matchStore.fetchTheirTurnQuestions({ silent: true })); + } + // Only refresh a domain the user has actually opened, matching the rule + // that unopened domains stay unloaded. + if (usePacksStore.getState().enabledPacksLoadedAt !== null + && summary.enabled_packs_fingerprint !== previous.enabled_packs_fingerprint) { + refreshes.push(usePacksStore.getState().fetchEnabledPacks()); + } + if (summary.streak_updated_at !== previous.streak_updated_at) { + refreshes.push(useStreakStore.getState().refreshStreak()); + } + + await Promise.allSettled(refreshes); + }, [latestIdentity]); + + usePolling(sync, { + intervalMs: SYNC_INTERVAL_MS, + enabled: Boolean(userId), + resetKey: `${userId ?? ''}:${coupleId ?? ''}`, + }); +} diff --git a/apps/mobile/src/hooks/useMessageSubscription.ts b/apps/mobile/src/hooks/useMessageSubscription.ts index 95651e5..3ebdf3d 100644 --- a/apps/mobile/src/hooks/useMessageSubscription.ts +++ b/apps/mobile/src/hooks/useMessageSubscription.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { chatApi } from '../lib/chatApi'; +import { chatApi, type MessagesPage } from '../lib/chatApi'; +import { usePolling } from './usePolling'; import type { Message } from '../features/chat/types'; export interface UseMessageSubscriptionConfig { @@ -7,51 +8,115 @@ export interface UseMessageSubscriptionConfig { userId: string | undefined; onNewMessage?: () => void; pollInterval?: number; + /** Whether the chat is on screen. Polling and read receipts stop when it is not. */ + isFocused?: boolean; } export interface UseMessageSubscriptionReturn { messages: Message[]; setMessages: React.Dispatch>; loading: boolean; - isFocusedRef: React.MutableRefObject; + /** Partner typing state folded into the message poll; undefined until the server reports it. */ + partnerTyping: boolean | undefined; + /** Fetches the next delta now, e.g. straight after sending. */ + refresh: () => Promise; } -/** Authenticated API polling replaces Supabase Realtime for self-hosted chat. */ -export const useMessageSubscription = ({ matchId, userId, onNewMessage, pollInterval = 1_500 }: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => { +const timeOf = (message: Message): number => { + const parsed = message.created_at ? Date.parse(message.created_at) : Number.NaN; + return Number.isNaN(parsed) ? 0 : parsed; +}; + +/** Newest first, matching the order the API returns a snapshot in. */ +const byNewest = (a: Message, b: Message): number => { + const delta = timeOf(b) - timeOf(a); + if (delta !== 0) return delta; + return a.id < b.id ? 1 : a.id > b.id ? -1 : 0; +}; + +/** + * Returns the previous array unchanged when a delta carries nothing, so an idle + * poll re-renders neither this hook's consumers nor the message list. + */ +export const mergeMessagePage = (previous: Message[], page: MessagesPage): Message[] => { + if (page.complete !== false) return page.messages; + const removed = page.removed_ids ?? []; + if (page.messages.length === 0 && removed.length === 0) return previous; + + const byId = new Map(previous.map(message => [message.id, message])); + for (const message of page.messages) byId.set(message.id, message); + for (const id of removed) byId.delete(id); + return [...byId.values()].sort(byNewest); +}; + +/** + * Authenticated API polling replaces Supabase Realtime for self-hosted chat. + * + * Each poll asks only for what changed since the cursor the server issued last + * time, so an idle conversation costs an all-but-empty response instead of the + * five hundred messages the screen already has. Read receipts are written only + * when the response actually contains an unread message from the partner, which + * on an idle chat is never. + */ +export const useMessageSubscription = ({ + matchId, + userId, + onNewMessage, + pollInterval = 2_000, + isFocused = true, +}: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => { const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(true); - const isFocusedRef = useRef(false); + const [loading, setLoading] = useState(Boolean(matchId && userId)); + const [partnerTyping, setPartnerTyping] = useState(undefined); + const sinceRef = useRef(null); const knownIdsRef = useRef>(new Set()); - const fetchingRef = useRef(false); + const loadingRef = useRef(true); + const notifyRef = useRef(onNewMessage); + notifyRef.current = onNewMessage; + + // A different conversation shares none of this one's state, and the poll below + // restarts on the same key so the new one loads immediately. + useEffect(() => { + sinceRef.current = null; + knownIdsRef.current = new Set(); + loadingRef.current = Boolean(matchId && userId); + setMessages([]); + setPartnerTyping(undefined); + setLoading(loadingRef.current); + }, [matchId, userId]); - const refresh = useCallback(async (initial = false) => { - if (!matchId || !userId || fetchingRef.current) return; - fetchingRef.current = true; + const refresh = useCallback(async () => { + if (!matchId || !userId) return; + const since = sinceRef.current; try { - const response = await chatApi.listMessages(matchId); - const partnerArrived = !initial && response.messages.some(message => message.user_id !== userId && !knownIdsRef.current.has(message.id)); - knownIdsRef.current = new Set(response.messages.map(message => message.id)); - setMessages(response.messages); - if (isFocusedRef.current) await chatApi.markRead(matchId); - if (partnerArrived) onNewMessage?.(); + const page = await chatApi.listMessages(matchId, { ...(since ? { since } : {}), typing: true }); + // Without a cursor the server cannot answer with deltas, so keep asking + // for snapshots rather than silently freezing on a stale page. + sinceRef.current = page.server_time ?? null; + if (page.typing) setPartnerTyping(page.typing.typing); + + const arrivals = page.messages.filter(message => message.user_id !== userId && !knownIdsRef.current.has(message.id)); + for (const message of page.messages) knownIdsRef.current.add(message.id); + for (const id of page.removed_ids ?? []) knownIdsRef.current.delete(id); + setMessages(previous => mergeMessagePage(previous, page)); + + if (isFocused && page.messages.some(message => message.user_id !== userId && !message.read_at)) { + await chatApi.markRead(matchId); + } + if (!loadingRef.current && arrivals.length > 0) notifyRef.current?.(); } finally { - fetchingRef.current = false; - if (initial) setLoading(false); + if (loadingRef.current) { + loadingRef.current = false; + setLoading(false); + } } - }, [matchId, userId, onNewMessage]); + }, [matchId, userId, isFocused]); - useEffect(() => { - if (!matchId || !userId) { - setMessages([]); - setLoading(false); - return; - } - setLoading(true); - knownIdsRef.current = new Set(); - void refresh(true).catch(() => undefined); - const timer = setInterval(() => void refresh(false).catch(() => undefined), pollInterval); - return () => clearInterval(timer); - }, [matchId, userId, pollInterval, refresh]); + usePolling(refresh, { + intervalMs: pollInterval, + enabled: Boolean(matchId && userId && isFocused), + resetKey: `${matchId ?? ''}:${userId ?? ''}`, + }); - return { messages, setMessages, loading, isFocusedRef }; + return { messages, setMessages, loading, partnerTyping, refresh }; }; diff --git a/apps/mobile/src/hooks/usePolling.ts b/apps/mobile/src/hooks/usePolling.ts new file mode 100644 index 0000000..3ca066e --- /dev/null +++ b/apps/mobile/src/hooks/usePolling.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef } from 'react'; +import { isForeground, subscribeToForeground } from '../lib/appForeground'; + +export interface UsePollingOptions { + intervalMs: number; + /** Caller-owned relevance: screen focus, a loaded id, a paired couple. */ + enabled?: boolean; + /** Run once as soon as polling becomes active. Default true. */ + leading?: boolean; + /** Ceiling for the exponential backoff applied after consecutive failures. */ + maxIntervalMs?: number; + /** + * Restarts the schedule when it changes, so a caller that switches subject — + * a different chat, a different couple — gets an immediate run instead of + * waiting out the interval left over from the previous one. + */ + resetKey?: string | number; +} + +/** + * A recurring task with the three properties every poller in this app needs and + * none of them had: it stops while the app is backgrounded, it never runs twice + * concurrently, and it backs off instead of hammering an API that is failing. + * + * The next run is scheduled after the previous one settles rather than on a fixed + * `setInterval`, so a slow response delays the next request instead of queueing + * one behind it. `task` is read through a ref, so a caller may pass a fresh + * closure every render without restarting the schedule. + */ +export function usePolling(task: () => Promise, options: UsePollingOptions): void { + const { intervalMs, enabled = true, leading = true, maxIntervalMs = intervalMs * 8, resetKey } = options; + const taskRef = useRef(task); + taskRef.current = task; + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + let running = false; + let failures = 0; + let foreground = isForeground(); + let timer: ReturnType | null = null; + + const clear = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + const schedule = () => { + if (cancelled || !foreground || timer !== null) return; + const delay = Math.min(intervalMs * 2 ** failures, maxIntervalMs); + timer = setTimeout(() => { + timer = null; + void run(); + }, delay); + }; + const run = async () => { + if (cancelled || running || !foreground) return; + running = true; + try { + await taskRef.current(); + failures = 0; + } catch { + failures += 1; + } finally { + running = false; + schedule(); + } + }; + const unsubscribe = subscribeToForeground((next) => { + if (next === foreground) return; + foreground = next; + if (!foreground) { + clear(); + return; + } + // Back from the background: the cached data is as stale as the absence + // was long, so catch up now rather than after another full interval. + failures = 0; + void run(); + }); + + if (leading) void run(); + else schedule(); + + return () => { + cancelled = true; + clear(); + unsubscribe(); + }; + }, [enabled, intervalMs, leading, maxIntervalMs, resetKey]); +} diff --git a/apps/mobile/src/hooks/useTypingIndicator.ts b/apps/mobile/src/hooks/useTypingIndicator.ts index 6b1edb2..fbc0be3 100644 --- a/apps/mobile/src/hooks/useTypingIndicator.ts +++ b/apps/mobile/src/hooks/useTypingIndicator.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { chatApi } from '../lib/chatApi'; +import { usePolling } from './usePolling'; export interface UseTypingIndicatorConfig { channelName: string; @@ -9,6 +10,12 @@ export interface UseTypingIndicatorConfig { isFocused?: boolean; throttleInterval?: number; pollInterval?: number; + /** + * Typing state already known from another request. When supplied this hook + * stops polling for it, which is how the chat screen gets typing state for + * free out of the message poll instead of a second request every interval. + */ + externalTyping?: boolean | undefined; } export interface UseTypingIndicatorReturn { @@ -17,31 +24,43 @@ export interface UseTypingIndicatorReturn { clearTypingIndicator: () => void; } -export const useTypingIndicator = ({ channelName, userId, matchId = channelName.replace(/^chat:/, ''), isFocused = true, throttleInterval = 2_000, pollInterval = 1_500 }: UseTypingIndicatorConfig): UseTypingIndicatorReturn => { +export const useTypingIndicator = ({ + channelName, + userId, + matchId = channelName.replace(/^chat:/, ''), + isFocused = true, + throttleInterval = 2_000, + pollInterval = 1_500, + externalTyping, +}: UseTypingIndicatorConfig): UseTypingIndicatorReturn => { const [partnerTyping, setPartnerTyping] = useState(false); const lastTypingEventRef = useRef(0); + const external = externalTyping !== undefined; useEffect(() => { - if (!matchId || !userId || !isFocused) { + if (externalTyping !== undefined) setPartnerTyping(externalTyping); + }, [externalTyping]); + + // Losing the subject stops both sources of truth, so drop the stale indicator + // rather than leaving the partner typing forever in a chat nobody is watching. + useEffect(() => { + if (!matchId || !userId || !isFocused) setPartnerTyping(false); + }, [matchId, userId, isFocused]); + + const poll = useCallback(async () => { + try { + const state = await chatApi.getTyping(matchId); + setPartnerTyping(state.typing); + } catch { setPartnerTyping(false); - return; } - let active = true; - const poll = async () => { - try { - const state = await chatApi.getTyping(matchId); - if (active) setPartnerTyping(state.typing); - } catch { - if (active) setPartnerTyping(false); - } - }; - void poll(); - const timer = setInterval(() => void poll(), pollInterval); - return () => { - active = false; - clearInterval(timer); - }; - }, [matchId, userId, isFocused, pollInterval]); + }, [matchId]); + + usePolling(poll, { + intervalMs: pollInterval, + enabled: !external && Boolean(matchId) && Boolean(userId) && isFocused, + resetKey: matchId, + }); const sendTypingEvent = useCallback(() => { if (!matchId || !userId) return; diff --git a/apps/mobile/src/lib/appDataApi.ts b/apps/mobile/src/lib/appDataApi.ts index f29ad54..ca2e07d 100644 --- a/apps/mobile/src/lib/appDataApi.ts +++ b/apps/mobile/src/lib/appDataApi.ts @@ -10,7 +10,36 @@ export interface LiveDrawState { updated_by: string | null; } +/** + * A change summary, not a payload: markers the client compares against the ones + * it saw last time to decide which domain, if any, is worth refetching. It + * replaces a periodic full refresh of profile, couple, matches, pending + * questions, packs and unread counts with one small request. + */ +export interface SyncSummary { + server_time: string; + couple_id: string | null; + profile_updated_at: string | null; + partner_id: string | null; + partner_updated_at: string | null; + match_count: number; + new_match_count: number; + latest_match_at: string | null; + pending_yours: number; + pending_theirs: number; + unread_total: number; + /** couple_packs carries no timestamp, so equal-and-opposite toggles are caught by a digest. */ + enabled_packs_fingerprint: string | null; + /** Digest of each visible match's type and response summary; moves on an in-place response edit that the count/latest-at markers miss. */ + match_state_fingerprint: string; + /** Digest of each visible match's unread count; moves when unread redistributes between matches without changing the total. */ + match_unread_fingerprint: string; + /** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */ + streak_updated_at: string | null; +} + export const appDataApi = { + syncSummary: () => apiClient.get('/v1/me/sync'), packContext: (packId: string) => apiClient.get<{ pack: { id: string; name: string; icon: string | null } }>(`/v1/packs/${encodeURIComponent(packId)}/context`), packQuestions: (packId: string) => apiClient.get<{ questions: Question[] }>(`/v1/packs/${encodeURIComponent(packId)}/questions`), packTeaser: (packId: string) => apiClient.get<{ questions: Array<{ id: string; text: string; intensity: number }> }>(`/v1/packs/${encodeURIComponent(packId)}/teaser`), diff --git a/apps/mobile/src/lib/appForeground.ts b/apps/mobile/src/lib/appForeground.ts new file mode 100644 index 0000000..5ace386 --- /dev/null +++ b/apps/mobile/src/lib/appForeground.ts @@ -0,0 +1,21 @@ +import { AppState, type AppStateStatus } from 'react-native'; + +/** + * `background` is a real suspension and `inactive` is the moment behind the app + * switcher, a call, or a system prompt. Anything else — including states a test + * harness never sets — counts as foreground, so recurring work fails towards + * running rather than towards being silently disabled forever. + */ +export function isForegroundState(state: AppStateStatus | string | undefined): boolean { + return state !== 'background' && state !== 'inactive'; +} + +export function isForeground(): boolean { + return isForegroundState(AppState.currentState as AppStateStatus | undefined); +} + +/** Reports the new foreground flag on every transition. Returns an unsubscribe. */ +export function subscribeToForeground(onChange: (foreground: boolean) => void): () => void { + const subscription = AppState.addEventListener('change', (state) => onChange(isForegroundState(state))); + return () => subscription.remove(); +} diff --git a/apps/mobile/src/lib/chatApi.ts b/apps/mobile/src/lib/chatApi.ts index ebf0184..9e9a3b6 100644 --- a/apps/mobile/src/lib/chatApi.ts +++ b/apps/mobile/src/lib/chatApi.ts @@ -1,8 +1,40 @@ import { apiClient } from './apiClient'; import type { Message, ReportReason } from '../features/chat/types'; +export interface PartnerTyping { + typing: boolean; + expires_at: string | null; +} + +/** + * A snapshot when `complete`, otherwise only what changed since the cursor the + * previous page handed back. Delta rows are whole messages, not patches, so a + * caller merges them by id without a second shape to understand. + */ +export interface MessagesPage { + messages: Message[]; + /** Messages the caller deleted for themselves since the cursor. */ + removed_ids?: string[]; + /** Cursor to send as `since` on the next poll. */ + server_time?: string; + complete?: boolean; + /** Present when requested, saving a separate typing request. */ + typing?: PartnerTyping; +} + +export interface ListMessagesOptions { + since?: string; + typing?: boolean; +} + export const chatApi = { - listMessages: (matchId: string) => apiClient.get<{ messages: Message[] }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`), + listMessages: (matchId: string, options: ListMessagesOptions = {}) => { + const query: string[] = []; + if (options.since) query.push(`since=${encodeURIComponent(options.since)}`); + if (options.typing) query.push('typing=true'); + const suffix = query.length > 0 ? `?${query.join('&')}` : ''; + return apiClient.get(`/v1/matches/${encodeURIComponent(matchId)}/messages${suffix}`); + }, sendText: (matchId: string, content: string) => apiClient.post<{ message: Message }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`, { content }), unread: () => apiClient.get<{ total: number; by_match: Record }>('/v1/chat/unread'), markRead: (matchId: string) => apiClient.post<{ updated: number; read_at: string }>(`/v1/matches/${encodeURIComponent(matchId)}/read`), @@ -11,5 +43,5 @@ export const chatApi = { deleteForEveryone: (messageId: string) => apiClient.delete<{ deleted: true; message: Message }>(`/v1/messages/${encodeURIComponent(messageId)}?scope=everyone`), report: (messageId: string, reason: ReportReason) => apiClient.post<{ reported: true }>(`/v1/messages/${encodeURIComponent(messageId)}/reports`, { reason }), setTyping: (matchId: string) => apiClient.put<{ typing: true }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`), - getTyping: (matchId: string) => apiClient.get<{ typing: boolean; expires_at: string | null }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`), + getTyping: (matchId: string) => apiClient.get(`/v1/matches/${encodeURIComponent(matchId)}/typing`), }; diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index ea217fe..8e478c5 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -4,104 +4,106 @@ import { authClient } from "../lib/authClient"; import { coupleApi } from "../lib/coupleApi"; import { profileSettingsApi } from "../lib/profileSettingsApi"; import { syncTimezone } from "../lib/reportedTimezone"; -import { Events } from "../lib/analytics"; -import type { Profile, Couple } from "@/types"; - - -interface AuthState { - user: Profile | null; - couple: Couple | null; - partner: Profile | null; - isLoading: boolean; - isAuthenticated: boolean; - isAnonymous: boolean; - - - // Actions - fetchUser: () => Promise; - fetchCouple: () => Promise; - refreshPartner: () => Promise; - signOut: () => Promise; - setUser: (user: Profile | null) => void; - updateLastActive: () => Promise; -} - -// Import other stores lazily to avoid circular dependency issues -const getOtherStores = () => { - const { useMatchStore } = require("./matchStore"); - const { usePacksStore } = require("./packsStore"); - const { useMessageStore } = require("./messageStore"); - const { useSubscriptionStore } = require("./subscriptionStore"); - const { useNotificationPreferencesStore } = require("./notificationPreferencesStore"); - const { useStreakStore } = require("./streakStore"); - return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore }; -}; - - -export const useAuthStore = create((set, get) => ({ - user: null, - couple: null, - partner: null, - isLoading: true, - isAuthenticated: false, - isAnonymous: false, - - fetchUser: async () => { - try { - // First check if we have a session in storage +import { Events } from "../lib/analytics"; +import type { Profile, Couple } from "@/types"; + + +interface AuthState { + user: Profile | null; + couple: Couple | null; + partner: Profile | null; + isLoading: boolean; + isAuthenticated: boolean; + isAnonymous: boolean; + + + // Actions + fetchUser: () => Promise; + fetchCouple: () => Promise; + refreshPartner: () => Promise; + signOut: () => Promise; + setUser: (user: Profile | null) => void; + updateLastActive: () => Promise; +} + +// Import other stores lazily to avoid circular dependency issues +const getOtherStores = () => { + const { useMatchStore } = require("./matchStore"); + const { usePacksStore } = require("./packsStore"); + const { useMessageStore } = require("./messageStore"); + const { useSubscriptionStore } = require("./subscriptionStore"); + const { useNotificationPreferencesStore } = require("./notificationPreferencesStore"); + const { useStreakStore } = require("./streakStore"); + const { useResponsesStore } = require("./responsesStore"); + return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore }; +}; + + +export const useAuthStore = create((set, get) => ({ + user: null, + couple: null, + partner: null, + isLoading: true, + isAuthenticated: false, + isAnonymous: false, + + fetchUser: async () => { + try { + // First check if we have a session in storage const { data: { session } } = await authClient.auth.getSession(); - - if (!session?.user) { - set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false }); - return; - } - + + if (!session?.user) { + set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false }); + return; + } + // This endpoint verifies the Supabase access token and creates the // product profile on first use, keeping profile data out of Supabase. const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me"); const isAnonymous = !!(session.user as { is_anonymous?: boolean }).is_anonymous; - - // Only update user if data actually changed to avoid unnecessary re-renders - const currentUser = get().user; - const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile); - if (userChanged) { - set({ - user: profile, - isAuthenticated: true, - isAnonymous, - }); - } else if (!get().isAuthenticated) { - set({ isAuthenticated: true, isAnonymous }); - } - - // If user has a couple, fetch couple data; otherwise clear couple/partner - if (profile?.couple_id) { - await get().fetchCouple(); - } else { - set({ couple: null, partner: null }); - } - - set({ isLoading: false }); + + // Only update user if data actually changed to avoid unnecessary re-renders + const currentUser = get().user; + const userChanged = !currentUser || JSON.stringify(currentUser) !== JSON.stringify(profile); + if (userChanged) { + set({ + user: profile, + isAuthenticated: true, + isAnonymous, + }); + } else if (!get().isAuthenticated) { + set({ isAuthenticated: true, isAnonymous }); + } + + // If user has a couple, fetch couple data; otherwise clear couple/partner + if (profile?.couple_id) { + await get().fetchCouple(); + } else { + set({ couple: null, partner: null }); + } + + set({ isLoading: false }); } catch (error) { 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 } = getOtherStores(); + const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores(); useMatchStore.getState().clearMatches(); usePacksStore.getState().clearPacks(); useMessageStore.getState().clearMessages(); useSubscriptionStore.getState().clearSubscription(); useNotificationPreferencesStore.getState().clearPreferences(); useStreakStore.getState().clearStreak(); + useResponsesStore.getState().clearResponses(); await authClient.auth.signOut(); return; } console.error("Error fetching user:", error); set({ isLoading: false }); - } - }, - + } + }, + fetchCouple: async () => { const user = get().user; if (!user?.couple_id) { @@ -111,100 +113,102 @@ export const useAuthStore = create((set, get) => ({ const { couple, partner } = await coupleApi.getState(); set({ couple, partner }); - }, - - /** - * Refresh partner's profile data (useful to get latest public_key_jwk for E2EE) - * Returns the updated partner profile or null if not found - */ - refreshPartner: async () => { - const user = get().user; - if (!user?.couple_id) return null; - + }, + + /** + * Refresh partner's profile data (useful to get latest public_key_jwk for E2EE) + * Returns the updated partner profile or null if not found + */ + refreshPartner: async () => { + const user = get().user; + if (!user?.couple_id) return null; + const { partner } = await coupleApi.getState(); - - if (partner) { - set({ partner }); - } - return partner; - }, - - signOut: async () => { - Events.signOut(); - - // Clear local state FIRST to ensure UI updates even if Supabase call fails - set({ - user: null, - couple: null, - partner: null, - isAuthenticated: false, - isAnonymous: false, - isLoading: false, - }); - // Clear other stores - const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores(); - useMatchStore.getState().clearMatches(); - usePacksStore.getState().clearPacks(); - useMessageStore.getState().clearMessages(); - useSubscriptionStore.getState().clearSubscription(); - useNotificationPreferencesStore.getState().clearPreferences(); - useStreakStore.getState().clearStreak(); - - // Clear badge on sign out - const { clearBadge } = require("../lib/badge"); - await clearBadge(); - - // Then try to sign out from Supabase (don't block on this) - try { + + if (partner) { + set({ partner }); + } + return partner; + }, + + signOut: async () => { + Events.signOut(); + + // Clear local state FIRST to ensure UI updates even if Supabase call fails + set({ + user: null, + couple: null, + partner: null, + isAuthenticated: false, + isAnonymous: false, + isLoading: false, + }); + // Clear other stores + const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores(); + useMatchStore.getState().clearMatches(); + usePacksStore.getState().clearPacks(); + useMessageStore.getState().clearMessages(); + useSubscriptionStore.getState().clearSubscription(); + useNotificationPreferencesStore.getState().clearPreferences(); + useStreakStore.getState().clearStreak(); + useResponsesStore.getState().clearResponses(); + + // Clear badge on sign out + const { clearBadge } = require("../lib/badge"); + await clearBadge(); + + // Then try to sign out from Supabase (don't block on this) + try { await authClient.auth.signOut(); - } catch (error) { - console.error("Supabase signOut error:", error); - } - }, - - setUser: (user) => { - set({ - user, - isAuthenticated: !!user, - ...(user === null && { isAnonymous: false }), - isLoading: false, - // Clear couple/partner when user is null (signed out) - ...(user === null && { couple: null, partner: null }) - }); - // Clear other stores when user signs out - if (user === null) { - const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores(); - useMatchStore.getState().clearMatches(); - usePacksStore.getState().clearPacks(); - useMessageStore.getState().clearMessages(); - useSubscriptionStore.getState().clearSubscription(); - useNotificationPreferencesStore.getState().clearPreferences(); - useStreakStore.getState().clearStreak(); - // Clear badge on sign out - const { clearBadge } = require("../lib/badge"); - clearBadge(); - } - }, - - /** - * Update the user's last_active_at timestamp. - * Called when the app comes to the foreground to track user activity. - * This helps prevent sending notifications to users who are already in the app. - */ - updateLastActive: async () => { - const userId = get().user?.id; - if (!userId) return; - - try { + } catch (error) { + console.error("Supabase signOut error:", error); + } + }, + + setUser: (user) => { + set({ + user, + isAuthenticated: !!user, + ...(user === null && { isAnonymous: false }), + isLoading: false, + // Clear couple/partner when user is null (signed out) + ...(user === null && { couple: null, partner: null }) + }); + // Clear other stores when user signs out + if (user === null) { + const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores(); + useMatchStore.getState().clearMatches(); + usePacksStore.getState().clearPacks(); + useMessageStore.getState().clearMessages(); + useSubscriptionStore.getState().clearSubscription(); + useNotificationPreferencesStore.getState().clearPreferences(); + useStreakStore.getState().clearStreak(); + useResponsesStore.getState().clearResponses(); + // Clear badge on sign out + const { clearBadge } = require("../lib/badge"); + clearBadge(); + } + }, + + /** + * Update the user's last_active_at timestamp. + * Called when the app comes to the foreground to track user activity. + * This helps prevent sending notifications to users who are already in the app. + */ + updateLastActive: async () => { + const userId = get().user?.id; + if (!userId) return; + + try { await profileSettingsApi.updateLastActive(); - } catch (error) { - // Silently fail - this is not critical - console.error("Failed to update last_active_at:", error); - } - - // Keep the server's idea of the user's timezone current so the daily - // response limit resets at their local midnight, not 00:00 UTC. - void syncTimezone(userId); - }, -})); - + } catch (error) { + // Silently fail - this is not critical + console.error("Failed to update last_active_at:", error); + } + + // Keep the server's idea of the user's timezone current so the daily + // response limit resets at their local midnight, not 00:00 UTC. + void syncTimezone(userId); + }, +})); + diff --git a/apps/mobile/src/store/matchStore.ts b/apps/mobile/src/store/matchStore.ts index 583b7ff..c282d41 100644 --- a/apps/mobile/src/store/matchStore.ts +++ b/apps/mobile/src/store/matchStore.ts @@ -15,6 +15,20 @@ export interface PendingQuestion { type MatchViewType = 'active' | 'archived' | 'pending' | 'their_turn'; +/** + * A refresh that must not disturb what the user is looking at: no spinner, and + * the cached rows stay on screen until the replacement arrives. + */ +export interface FetchOptions { silent?: boolean } + +// Overlap protection lives outside the store because the loading flags are now +// presentational — a silent refresh sets none of them and still must not run twice. +// Keyed to the generation that started the request (see `generation` below) rather +// than a bare presence check: a stale generation's entry must not block the next +// account's request for the same key, and a stale generation's `finally` must not +// clear an entry a newer generation just claimed. +const inFlight = new Map(); + // Rate limit: 12 hours in milliseconds const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000; @@ -32,6 +46,8 @@ interface MatchState { newMatchesCount: number; totalCount: number | null; isLoading: boolean; + /** A user-initiated refresh over data already on screen; drives pull-to-refresh only. */ + isRefreshing: boolean; isLoadingMore: boolean; page: number; hasMore: boolean; @@ -48,11 +64,23 @@ interface MatchState { theirTurnQuestions: PendingQuestion[]; isLoadingTheirTurn: boolean; currentView: MatchViewType; + /** + * When each view last loaded successfully. Tracked separately from the row + * arrays because an empty view is still a loaded view, and revisiting one + * must not refetch. + */ + loadedAt: Partial>; // Nudge state nudgeCooldownUntil: Date | null; isNudging: boolean; + /** + * Bumped on every clear (sign-out/account switch). A fetch captures this before + * it starts and checks it again before writing its result, so a response that + * outlives the clear cannot land in the next account's store. + */ + generation: number; // Methods - fetchMatches: (refresh?: boolean) => Promise; + fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise; markAsSeen: (matchId: string) => Promise; markAllAsSeen: () => Promise; addMatch: (match: Match) => void; @@ -61,13 +89,14 @@ interface MatchState { // Archive methods archiveMatch: (matchId: string) => Promise; unarchiveMatch: (matchId: string) => Promise; - fetchArchivedMatches: () => Promise; + fetchArchivedMatches: (options?: FetchOptions) => Promise; toggleShowArchived: () => void; isMatchArchived: (matchId: string) => boolean; // Pending methods - fetchPendingQuestions: () => Promise; - fetchTheirTurnQuestions: () => Promise; + fetchPendingQuestions: (options?: FetchOptions) => Promise; + fetchTheirTurnQuestions: (options?: FetchOptions) => Promise; setCurrentView: (view: MatchViewType) => void; + ensureViewLoaded: (view?: MatchViewType) => void; // Nudge methods sendNudge: () => Promise<{ success: boolean; notificationSent: boolean }>; checkNudgeCooldown: () => Promise; @@ -78,6 +107,7 @@ export const useMatchStore = create((set, get) => ({ newMatchesCount: 0, totalCount: null, isLoading: false, + isRefreshing: false, isLoadingMore: false, page: 0, hasMore: true, @@ -94,40 +124,53 @@ export const useMatchStore = create((set, get) => ({ theirTurnQuestions: [], isLoadingTheirTurn: false, currentView: 'pending' as MatchViewType, + loadedAt: {}, // Nudge state nudgeCooldownUntil: null, isNudging: false, + generation: 0, - fetchMatches: async (refresh = false) => { - const userId = useAuthStore.getState().user?.id; + fetchMatches: async (refresh = false, options = {}) => { const coupleId = useAuthStore.getState().user?.couple_id; + const myGeneration = get().generation; // Early return if no couple - user isn't paired yet if (!coupleId) { - set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false }); + set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false }); return; } const state = get(); - if (state.isLoading || (state.isLoadingMore && !refresh)) return; + if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return; + // Nothing on screen yet is the only case that warrants blanking the list + // for a spinner; every other refresh leaves the cached rows in place. + const initial = state.loadedAt.active === undefined; if (refresh) { - set({ isLoading: true, error: null, page: 0, hasMore: true }); + set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true }); } else { if (!state.hasMore) return; set({ isLoadingMore: true }); } + inFlight.set('active', myGeneration); try { const currentPage = refresh ? 0 : state.page; - const result = await apiClient.get<{ matches: Match[]; totalCount: number }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`); + const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`); + // A sign-out/account switch that happened while this request was in + // flight must not let its response populate the next account's store. + if (get().generation !== myGeneration) return; const nonArchivedMatches = result.matches; const totalCount = result.totalCount; const archivedMatchIds = state.archivedMatchIds; + // The API reports this by reading one row past the page; the length + // comparison is only a fallback for an older server. + const hasMore = result.hasMore ?? nonArchivedMatches.length === BATCH_SIZE; + const loadedAt = { ...state.loadedAt, active: Date.now() }; if (nonArchivedMatches.length === 0) { if (refresh) { - set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false, archivedMatchIds }); + set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, archivedMatchIds, loadedAt }); } else { set({ isLoadingMore: false, hasMore: false }); } @@ -139,7 +182,7 @@ export const useMatchStore = create((set, get) => ({ // Calculate new matches count - this is tricky with pagination // Ideally we should have a separate query for count, but we'll just count in current batch const newMatchesInBatch = sortedData.filter((m) => m.is_new).length; - + set((state) => { // Deduplicate matches by ID when appending new pages const mergedMatches = refresh @@ -154,22 +197,34 @@ export const useMatchStore = create((set, get) => ({ return { matches: mergedMatches, newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch, - totalCount: refresh ? totalCount : state.totalCount, + // Later pages no longer carry a total, so keep the one the refresh recorded. + totalCount: refresh ? totalCount ?? state.totalCount : state.totalCount, isLoading: false, + isRefreshing: false, isLoadingMore: false, page: currentPage + 1, - hasMore: nonArchivedMatches.length === BATCH_SIZE, + hasMore, archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds, + loadedAt, }; }); } catch (err) { + if (get().generation !== myGeneration) return; console.error("Error fetching matches:", err); - set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false }); + set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false }); + } finally { + // An old generation's cleanup must not delete a newer generation's guard. + if (inFlight.get('active') === myGeneration) inFlight.delete('active'); } }, markAsSeen: async (matchId) => { + const myGeneration = get().generation; await apiClient.patch("/v1/matches/seen", { ids: [matchId] }); + // A clear while this was in flight leaves an empty list behind, and the + // recount below would write that emptiness — and a zero badge — over + // whatever the next account has already loaded. + if (get().generation !== myGeneration) return; const matches = get().matches.map((m) => m.id === matchId ? { ...m, is_new: false } : m @@ -179,11 +234,13 @@ export const useMatchStore = create((set, get) => ({ }, markAllAsSeen: async () => { + const myGeneration = get().generation; const newMatches = get().matches.filter((m) => m.is_new); if (newMatches.length === 0) return; const newMatchIds = newMatches.map((m) => m.id); await apiClient.patch("/v1/matches/seen", { ids: newMatchIds }); + if (get().generation !== myGeneration) return; const matches = get().matches.map((m) => m.is_new ? { ...m, is_new: false } : m @@ -210,11 +267,12 @@ export const useMatchStore = create((set, get) => ({ }, clearMatches: () => { - set({ + set((state) => ({ matches: [], newMatchesCount: 0, totalCount: null, isLoading: false, + isRefreshing: false, error: null, page: 0, hasMore: true, @@ -228,9 +286,16 @@ clearMatches: () => { theirTurnQuestions: [], isLoadingTheirTurn: false, currentView: 'pending', + loadedAt: {}, nudgeCooldownUntil: null, isNudging: false, - }); + // 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, + })); + // A request belonging to the signed-out user must not suppress the next + // one: the guard is a duplicate-request check, not a lock on the data. + inFlight.clear(); }, // Archive methods @@ -240,6 +305,7 @@ clearMatches: () => { // Optimistically update UI const state = get(); + const myGeneration = state.generation; const matchToArchive = state.matches.find(m => m.id === matchId); if (matchToArchive) { @@ -258,7 +324,11 @@ clearMatches: () => { await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: true }); } catch (error) { console.error("Error archiving match:", error); - // Revert on error + // Revert on error — but only into the store the optimistic update was + // applied to. After an account switch the row it would restore belongs + // to the previous couple, and putting it back would show one couple's + // match inside another's list. + if (get().generation !== myGeneration) return; if (matchToArchive) { const revertArchivedIds = new Set(get().archivedMatchIds); revertArchivedIds.delete(matchId); @@ -279,6 +349,7 @@ clearMatches: () => { // Optimistically update UI const state = get(); + const myGeneration = state.generation; const matchToUnarchive = state.archivedMatches.find(m => m.id === matchId); if (matchToUnarchive) { @@ -297,7 +368,8 @@ clearMatches: () => { await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: false }); } catch (error) { console.error("Error unarchiving match:", error); - // Revert on error + // Revert on error, and only for the account that started it. + if (get().generation !== myGeneration) return; if (matchToUnarchive) { const revertArchivedIds = new Set(get().archivedMatchIds); revertArchivedIds.add(matchId); @@ -312,34 +384,35 @@ clearMatches: () => { } }, - fetchArchivedMatches: async () => { + fetchArchivedMatches: async (options = {}) => { const userId = useAuthStore.getState().user?.id; const coupleId = useAuthStore.getState().user?.couple_id; if (!coupleId || !userId) return; + const myGeneration = get().generation; const state = get(); - if (state.isLoadingArchived) return; - - set({ isLoadingArchived: true }); + if (inFlight.get('archived') === myGeneration) return; + if (!options.silent || state.loadedAt.archived === undefined) set({ isLoadingArchived: true }); + inFlight.set('archived', myGeneration); try { - const result = await apiClient.get<{matches:Match[];totalCount:number}>(`/v1/matches?archived=true&limit=100`); + const result = await apiClient.get<{matches:Match[];totalCount:number | null}>(`/v1/matches?archived=true&limit=100`); + if (get().generation !== myGeneration) return; const archivedMatches = result.matches; const archivedIds = archivedMatches.map(match => match.id); - if (archivedMatches.length === 0) { - set({ archivedMatches: [], isLoadingArchived: false }); - return; - } - - set({ + set((current) => ({ archivedMatches, isLoadingArchived: false, archivedMatchIds: new Set(archivedIds), - }); + loadedAt: { ...current.loadedAt, archived: Date.now() }, + })); } catch (err) { + if (get().generation !== myGeneration) return; console.error("Error fetching archived matches:", err); set({ isLoadingArchived: false }); + } finally { + if (inFlight.get('archived') === myGeneration) inFlight.delete('archived'); } }, @@ -350,8 +423,8 @@ clearMatches: () => { set({ showArchived: newShowArchived }); // Fetch archived matches if switching to archived view and not yet loaded - if (newShowArchived && state.archivedMatches.length === 0) { - get().fetchArchivedMatches(); + if (newShowArchived && state.loadedAt.archived === undefined) { + void get().fetchArchivedMatches(); } }, @@ -360,7 +433,7 @@ clearMatches: () => { }, // Pending (Your Turn) methods - fetchPendingQuestions: async () => { + fetchPendingQuestions: async (options = {}) => { const userId = useAuthStore.getState().user?.id; const coupleId = useAuthStore.getState().user?.couple_id; @@ -369,22 +442,27 @@ clearMatches: () => { return; } + const myGeneration = get().generation; const state = get(); - if (state.isLoadingPending) return; - - set({ isLoadingPending: true }); + if (inFlight.get('pending') === myGeneration) return; + if (!options.silent || state.loadedAt.pending === undefined) set({ isLoadingPending: true }); + inFlight.set('pending', myGeneration); try { const { questions: pendingQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=partner"); - set({ pendingQuestions, isLoadingPending: false }); + if (get().generation !== myGeneration) return; + set((current) => ({ pendingQuestions, isLoadingPending: false, loadedAt: { ...current.loadedAt, pending: Date.now() } })); } catch (err) { + if (get().generation !== myGeneration) return; console.error("Error fetching pending questions:", err); set({ isLoadingPending: false }); + } finally { + if (inFlight.get('pending') === myGeneration) inFlight.delete('pending'); } }, // Their Turn methods (user answered, partner hasn't yet) - fetchTheirTurnQuestions: async () => { + fetchTheirTurnQuestions: async (options = {}) => { const userId = useAuthStore.getState().user?.id; const coupleId = useAuthStore.getState().user?.couple_id; @@ -393,35 +471,43 @@ clearMatches: () => { return; } + const myGeneration = get().generation; const state = get(); - if (state.isLoadingTheirTurn) return; - - set({ isLoadingTheirTurn: true }); + if (inFlight.get('their_turn') === myGeneration) return; + if (!options.silent || state.loadedAt.their_turn === undefined) set({ isLoadingTheirTurn: true }); + inFlight.set('their_turn', myGeneration); try { const { questions: theirTurnQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=mine"); - set({ theirTurnQuestions, isLoadingTheirTurn: false }); + if (get().generation !== myGeneration) return; + set((current) => ({ theirTurnQuestions, isLoadingTheirTurn: false, loadedAt: { ...current.loadedAt, their_turn: Date.now() } })); } catch (err) { + if (get().generation !== myGeneration) return; console.error("Error fetching their turn questions:", err); set({ isLoadingTheirTurn: false }); + } finally { + if (inFlight.get('their_turn') === myGeneration) inFlight.delete('their_turn'); } }, setCurrentView: (view: MatchViewType) => { - const state = get(); - // Update showArchived for backwards compatibility const showArchived = view === 'archived'; set({ currentView: view, showArchived }); - // Fetch data for the selected view if not already loaded - if (view === 'archived' && state.archivedMatches.length === 0) { - get().fetchArchivedMatches(); - } else if (view === 'pending' && state.pendingQuestions.length === 0) { - get().fetchPendingQuestions(); - } else if (view === 'their_turn' && state.theirTurnQuestions.length === 0) { - get().fetchTheirTurnQuestions(); - } + get().ensureViewLoaded(view); + }, + + /** + * Loads a view once. Emptiness is not evidence of never having loaded, so + * returning to a legitimately empty view costs no request at all. + */ + ensureViewLoaded: (view = get().currentView) => { + if (get().loadedAt[view] !== undefined) return; + if (view === 'archived') void get().fetchArchivedMatches(); + else if (view === 'pending') void get().fetchPendingQuestions(); + else if (view === 'their_turn') void get().fetchTheirTurnQuestions(); + else void get().fetchMatches(true); }, // Nudge methods @@ -436,10 +522,14 @@ clearMatches: () => { return { success: false, notificationSent: false }; } + const myGeneration = state.generation; set({ isNudging: true }); try { const data = await appDataApi.sendNudge(); + // The cooldown is per-account. Recording the previous account's window + // would either silence the new account's nudge or clear a real cooldown. + if (get().generation !== myGeneration) return { success: false, notificationSent: false }; // Success - update cooldown const cooldownUntil = data.next_nudge_available_at @@ -453,6 +543,7 @@ clearMatches: () => { notificationSent: data.notification_sent ?? false, }; } catch (err) { + if (get().generation !== myGeneration) return { success: false, notificationSent: false }; if (err instanceof ApiError && err.status === 429) { const details = err.details as NudgeResponse | undefined; const cooldownUntil = details?.next_nudge_available_at @@ -471,8 +562,12 @@ clearMatches: () => { const userId = useAuthStore.getState().user?.id; if (!userId) return; + const myGeneration = get().generation; try { const profile = await appDataApi.nudgeStatus(); + // Read for the previous account: neither its cooldown nor its absence + // says anything about whether this one may nudge. + if (get().generation !== myGeneration) return; if (!profile.last_nudge_sent_at) { set({ nudgeCooldownUntil: null }); diff --git a/apps/mobile/src/store/messageStore.ts b/apps/mobile/src/store/messageStore.ts index c3dac86..b0a01a1 100644 --- a/apps/mobile/src/store/messageStore.ts +++ b/apps/mobile/src/store/messageStore.ts @@ -1,85 +1,110 @@ -import { create } from "zustand"; +import { create } from "zustand"; import { chatApi } from "../lib/chatApi"; -import type { Database } from "@/types/supabase"; -import { useAuthStore } from "./authStore"; -import { useMatchStore } from "./matchStore"; - -type Message = Database["public"]["Tables"]["messages"]["Row"]; - -interface MessageWithMatch extends Message { - match?: { - id: string; - question: { - text: string; - }; - }; -} - -interface MessageState { - unreadCount: number; - lastMessage: MessageWithMatch | null; - activeMatchId: string | null; // Track which chat is currently open - fetchUnreadCount: () => Promise; - addMessage: (message: MessageWithMatch) => void; - clearLastMessage: () => void; - setActiveMatchId: (matchId: string | null) => void; - markMatchMessagesAsRead: (matchId: string) => Promise; - clearMessages: () => void; -} - -export const useMessageStore = create((set, get) => ({ - unreadCount: 0, - lastMessage: null, - activeMatchId: null, - - fetchUnreadCount: async () => { - const userId = useAuthStore.getState().user?.id; - if (!userId) { - set({ unreadCount: 0 }); - return; - } - +import type { Database } from "@/types/supabase"; +import { useAuthStore } from "./authStore"; +import { useMatchStore } from "./matchStore"; + +type Message = Database["public"]["Tables"]["messages"]["Row"]; + +interface MessageWithMatch extends Message { + match?: { + id: string; + question: { + text: string; + }; + }; +} + +interface MessageState { + unreadCount: number; + lastMessage: MessageWithMatch | null; + activeMatchId: string | null; // Track which chat is currently open + /** + * Bumped by `clearMessages` on sign-out or a couple change, so a reply that + * outlives the account that asked for it cannot write into the next one's badge. + */ + generation: number; + fetchUnreadCount: () => Promise; + /** Applies a count the sync summary already reported, without a second request. */ + setUnreadCount: (total: number) => void; + addMessage: (message: MessageWithMatch) => void; + clearLastMessage: () => void; + setActiveMatchId: (matchId: string | null) => void; + markMatchMessagesAsRead: (matchId: string) => Promise; + clearMessages: () => void; +} + +export const useMessageStore = create((set, get) => ({ + unreadCount: 0, + lastMessage: null, + activeMatchId: null, + generation: 0, + + fetchUnreadCount: async () => { + const userId = useAuthStore.getState().user?.id; + if (!userId) { + set({ unreadCount: 0 }); + return; + } + + const myGeneration = get().generation; const { total } = await chatApi.unread(); + // This total counts one account's messages; after a clear it would show + // up as the next account's badge. + if (get().generation !== myGeneration) return; set({ unreadCount: total }); - }, - - addMessage: (message) => { - const userId = useAuthStore.getState().user?.id; - const activeMatchId = get().activeMatchId; - - // Only show notification if message is from partner and not in the active chat - if (message.user_id !== userId && message.match_id !== activeMatchId) { - set((state) => ({ - unreadCount: state.unreadCount + 1, - lastMessage: message, - })); - } - }, - - clearLastMessage: () => { - set({ lastMessage: null }); - }, - - setActiveMatchId: (matchId) => { - set({ activeMatchId: matchId }); - }, - - markMatchMessagesAsRead: async (matchId) => { - const userId = useAuthStore.getState().user?.id; - if (!userId) return; - + }, + + setUnreadCount: (total) => { + set({ unreadCount: Math.max(0, total) }); + }, + + addMessage: (message) => { + const userId = useAuthStore.getState().user?.id; + const activeMatchId = get().activeMatchId; + + // Only show notification if message is from partner and not in the active chat + if (message.user_id !== userId && message.match_id !== activeMatchId) { + set((state) => ({ + unreadCount: state.unreadCount + 1, + lastMessage: message, + })); + } + }, + + clearLastMessage: () => { + set({ lastMessage: null }); + }, + + setActiveMatchId: (matchId) => { + set({ activeMatchId: matchId }); + }, + + markMatchMessagesAsRead: async (matchId) => { + const userId = useAuthStore.getState().user?.id; + if (!userId) return; + + const myGeneration = get().generation; const { updated: unreadInMatch } = await chatApi.markRead(matchId); - - // Refetch global unread count - await get().fetchUnreadCount(); - - // Sync per-match unread count in matchStore - if (unreadInMatch > 0) { - useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch); - } - }, - - clearMessages: () => { - set({ unreadCount: 0, lastMessage: null, activeMatchId: null }); - }, -})); + if (unreadInMatch <= 0) return; + + // Those messages belonged to a couple that is no longer loaded, and the + // match they were in is not in the current list, so subtracting them now + // would take the old badge off the new account's. + if (get().generation !== myGeneration) return; + + // The server just told us exactly how many it cleared, so subtract instead + // of spending a request to re-read the total; the sync summary corrects + // any drift on its next pass. + set((state) => ({ unreadCount: Math.max(0, state.unreadCount - unreadInMatch) })); + useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch); + }, + + clearMessages: () => { + set((state) => ({ + unreadCount: 0, lastMessage: null, activeMatchId: null, + // Invalidates any request already in flight for the account being left. + generation: state.generation + 1, + })); + }, +})); diff --git a/apps/mobile/src/store/packsStore.ts b/apps/mobile/src/store/packsStore.ts index c41ea36..6e6934f 100644 --- a/apps/mobile/src/store/packsStore.ts +++ b/apps/mobile/src/store/packsStore.ts @@ -4,6 +4,12 @@ import { Events } from "../lib/analytics"; import type { QuestionPack, Category } from "@/types"; import { useAuthStore } from "./authStore"; +// The catalog load fans out into three requests, so a second caller arriving mid-flight +// must wait for the first rather than triple the cost. Keyed to the generation that +// started the request: a stale generation's entry must not block the next account's +// request for the same key, and its `finally` must not clear a newer generation's guard. +const inFlight = new Map(); + // Progress data for a pack export interface PackProgressData { totalQuestions: number; @@ -16,10 +22,23 @@ interface PacksState { enabledPackIds: string[]; packProgress: Map; isLoading: boolean; + /** + * When the catalog, enabled set and progress were last read together. Null means + * never — which is not the same as "empty", so a couple with no packs enabled + * stops refetching after the first load. + */ + catalogLoadedAt: number | null; + /** Tracked separately because the swipe screen loads the enabled set on its own. */ + enabledPacksLoadedAt: number | null; + /** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */ + generation: number; fetchPacks: () => Promise; fetchEnabledPacks: () => Promise; fetchPackProgress: () => Promise; + ensurePacksLoaded: () => Promise; ensureEnabledPacksLoaded: () => Promise; + /** Marks the cached catalog stale without spending a request; the next ensure reloads it. */ + invalidatePacks: () => void; togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>; clearPacks: () => void; getPackProgress: (packId: string) => PackProgressData | undefined; @@ -31,38 +50,61 @@ export const usePacksStore = create((set, get) => ({ enabledPackIds: [], packProgress: new Map(), isLoading: false, + catalogLoadedAt: null, + enabledPacksLoadedAt: null, + generation: 0, fetchPacks: async () => { + const myGeneration = get().generation; + if (inFlight.get('catalog') === myGeneration) return; + inFlight.set('catalog', myGeneration); set({ isLoading: true }); try { const catalog = await apiClient.get<{ categories: Category[]; packs: QuestionPack[] }>( '/v1/packs?showAllIntensities=false', ); + if (get().generation !== myGeneration) return; set({ packs: catalog.packs, categories: catalog.categories }); await get().fetchEnabledPacks(); await get().fetchPackProgress(); + if (get().generation !== myGeneration) return; + set({ catalogLoadedAt: Date.now() }); } finally { - set({ isLoading: false }); + if (inFlight.get('catalog') === myGeneration) inFlight.delete('catalog'); + if (get().generation === myGeneration) set({ isLoading: false }); } }, + ensurePacksLoaded: async () => { + if (get().catalogLoadedAt !== null) return; + await get().fetchPacks(); + }, + + invalidatePacks: () => { + set({ catalogLoadedAt: null }); + }, + fetchEnabledPacks: async () => { const coupleId = useAuthStore.getState().user?.couple_id; + const myGeneration = get().generation; if (!coupleId) { - set({ enabledPackIds: [] }); + set({ enabledPackIds: [], enabledPacksLoadedAt: null }); return; } const result = await apiClient.get<{ enabledPackIds: string[] }>("/v1/me/enabled-packs"); - set({ enabledPackIds: result.enabledPackIds }); + if (get().generation !== myGeneration) return; + set({ enabledPackIds: result.enabledPackIds, enabledPacksLoadedAt: Date.now() }); }, ensureEnabledPacksLoaded: async () => { - // Only fetch if not already loaded - lightweight check for swipe screen + // Only fetch if not already loaded - lightweight check for swipe screen. + // Keyed off the load marker, not the array: "no packs enabled" is a real + // answer and must not be retried on every visit. const coupleId = useAuthStore.getState().user?.couple_id; if (!coupleId) return; - if (get().enabledPackIds.length === 0) { + if (get().enabledPacksLoadedAt === null) { await get().fetchEnabledPacks(); } }, @@ -73,6 +115,7 @@ export const usePacksStore = create((set, get) => ({ return { success: false, reason: "no_couple" }; } + const myGeneration = get().generation; const isEnabled = get().enabledPackIds.includes(packId); const newValue = !isEnabled; @@ -88,9 +131,16 @@ export const usePacksStore = create((set, get) => ({ `/v1/me/enabled-packs/${packId}`, { enabled: newValue }, ); + // These ids belong to the couple that asked; a clear while the request + // was open means they would land in a different couple's selection. + if (get().generation !== myGeneration) return { success: false, reason: "stale" }; set({ enabledPackIds: result.enabledPackIds }); } catch (error) { console.error("Error toggling pack:", error); + // The optimistic update this reverts was cleared along with everything + // else, so reverting now would only push a stale pack id into the next + // couple's list. + if (get().generation !== myGeneration) return { success: false, reason: "stale" }; set({ enabledPackIds: isEnabled ? [...new Set([...get().enabledPackIds, packId])] : get().enabledPackIds.filter(id => id !== packId) }); @@ -108,6 +158,7 @@ export const usePacksStore = create((set, get) => ({ }, fetchPackProgress: async () => { + const myGeneration = get().generation; if (!useAuthStore.getState().user?.id) { set({ packProgress: new Map() }); return; @@ -115,6 +166,7 @@ export const usePacksStore = create((set, get) => ({ const result = await apiClient.get<{ progress: Array }>( "/v1/me/pack-progress", ); + if (get().generation !== myGeneration) return; const progressMap = new Map(); result.progress.forEach(({ packId, totalQuestions, answeredQuestions }) => { progressMap.set(packId, { totalQuestions, answeredQuestions }); @@ -128,6 +180,16 @@ export const usePacksStore = create((set, get) => ({ }, clearPacks: () => { - set({ enabledPackIds: [], packProgress: new Map() }); + set((state) => ({ + enabledPackIds: [], + packProgress: new Map(), + catalogLoadedAt: null, + enabledPacksLoadedAt: null, + // Invalidates any request already in flight for the signed-out user. + generation: state.generation + 1, + })); + // See matchStore.clearMatches: the previous user's in-flight catalog read + // must not block the next user's. + inFlight.clear(); }, })); diff --git a/apps/mobile/src/store/responsesStore.ts b/apps/mobile/src/store/responsesStore.ts index fd1dae2..d4a7975 100644 --- a/apps/mobile/src/store/responsesStore.ts +++ b/apps/mobile/src/store/responsesStore.ts @@ -36,16 +36,33 @@ export type DateSortOrder = "newest" | "oldest"; const BATCH_SIZE = 20; +// Overlap protection independent of the presentational flags: a refresh over cached +// rows raises `isRefreshing`, not `isLoading`, and still must not run twice. Keyed to +// the generation that started the request: a stale generation's entry must not block +// the next account's request, and its `finally` must not clear a newer generation's guard. +const inFlight = new Map(); + interface ResponsesState { responses: ResponseWithQuestion[]; + /** First load only. A refresh over rows already on screen must not blank the list. */ isLoading: boolean; + /** Refresh over cached rows; drives pull-to-refresh only. */ + isRefreshing: boolean; isLoadingMore: boolean; groupBy: GroupByOption; dateSortOrder: DateSortOrder; hasMore: boolean; page: number; totalCount: number | null; + /** Null until the first successful read; an empty history is a loaded answer. */ + loadedAt: number | null; + /** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */ + generation: number; fetchResponses: (refresh?: boolean) => Promise; + /** Loads the first page once; revisiting the screen reuses what is cached. */ + ensureResponsesLoaded: () => Promise; + /** Marks the cache stale without spending a request; the next ensure reloads it. */ + invalidateResponses: () => void; updateResponse: ( questionId: string, newAnswer: AnswerType, @@ -61,12 +78,15 @@ interface ResponsesState { export const useResponsesStore = create((set, get) => ({ responses: [], isLoading: false, + isRefreshing: false, isLoadingMore: false, groupBy: "date", dateSortOrder: "newest", hasMore: true, page: 0, totalCount: null, + loadedAt: null, + generation: 0, fetchResponses: async (refresh = false) => { const userId = useAuthStore.getState().user?.id; @@ -74,25 +94,31 @@ export const useResponsesStore = create((set, get) => ({ if (!userId || !coupleId) return; + const myGeneration = get().generation; const state = get(); - if (state.isLoading || (state.isLoadingMore && !refresh)) return; + if (inFlight.get('responses') === myGeneration || (state.isLoadingMore && !refresh)) return; + // Nothing on screen yet is the only case that warrants a full-screen spinner; + // every other refresh leaves the cached rows in place until replacements land. + const initial = state.loadedAt === null; if (refresh) { - set({ isLoading: true, page: 0, hasMore: true }); + set({ isLoading: initial, isRefreshing: !initial, page: 0, hasMore: true }); } else { if (!state.hasMore) return; set({ isLoadingMore: true }); } + inFlight.set('responses', myGeneration); try { const currentPage = refresh ? 0 : state.page; const result = await apiClient.get<{responses: ResponseWithQuestion[];totalCount:number}>(`/v1/me/responses?page=${currentPage}&limit=${BATCH_SIZE}`); + if (get().generation !== myGeneration) return; const responses = result.responses; const totalCount = result.totalCount; if (!responses || responses.length === 0) { if (refresh) { - set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false }); + set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, loadedAt: Date.now() }); } else { set({ isLoadingMore: false, hasMore: false }); } @@ -100,29 +126,44 @@ export const useResponsesStore = create((set, get) => ({ } const transformedResponses = responses; - + set((state) => ({ responses: refresh ? transformedResponses : [...state.responses, ...transformedResponses], totalCount: refresh ? totalCount : state.totalCount, isLoading: false, + isRefreshing: false, isLoadingMore: false, page: currentPage + 1, - hasMore: responses.length === BATCH_SIZE + hasMore: responses.length === BATCH_SIZE, + loadedAt: Date.now(), })); } catch (error) { + if (get().generation !== myGeneration) return; console.error("Error in fetchResponses:", error); - set({ isLoading: false, isLoadingMore: false }); + set({ isLoading: false, isRefreshing: false, isLoadingMore: false }); + } finally { + if (inFlight.get('responses') === myGeneration) inFlight.delete('responses'); } }, + ensureResponsesLoaded: async () => { + if (get().loadedAt !== null) return; + await get().fetchResponses(true); + }, + + invalidateResponses: () => { + set({ loadedAt: null }); + }, + updateResponse: async ( questionId: string, newAnswer: AnswerType, confirmDelete = false, responseData?: ResponseData | null ): Promise => { + const myGeneration = get().generation; try { const result = await apiClient.patch(`/v1/responses/${questionId}`, { new_answer: newAnswer, @@ -130,6 +171,13 @@ export const useResponsesStore = create((set, get) => ({ response_data: responseData, }); + // Rows are keyed by a question id from the shared catalogue, so once the + // store has been cleared that same id can name a different account's + // answer. Hand the caller the server's result — it is true of whoever + // asked — but write nothing, and refresh no matches, for the account + // that is loaded now. + if (get().generation !== myGeneration) return result; + // If update was successful (not just requiring confirmation), update local state if (result.success && !result.requires_confirmation) { // Update local response state @@ -191,7 +239,15 @@ export const useResponsesStore = create((set, get) => ({ }, clearResponses: () => { - set({ responses: [], isLoading: false, groupBy: "date", dateSortOrder: "newest", page: 0, hasMore: true, isLoadingMore: false, totalCount: null }); + set((state) => ({ + responses: [], isLoading: false, isRefreshing: false, groupBy: "date", dateSortOrder: "newest", + page: 0, hasMore: true, isLoadingMore: false, totalCount: null, loadedAt: null, + // Invalidates any request already in flight for the signed-out user. + generation: state.generation + 1, + })); + // See matchStore.clearMatches: the previous user's in-flight page must not + // block the next user's. + inFlight.clear(); }, })); diff --git a/apps/mobile/src/store/streakStore.ts b/apps/mobile/src/store/streakStore.ts index a505bee..aab5d0b 100644 --- a/apps/mobile/src/store/streakStore.ts +++ b/apps/mobile/src/store/streakStore.ts @@ -1,62 +1,110 @@ -import { create } from "zustand"; +import { create } from "zustand"; import { apiClient } from "../lib/apiClient"; -import { useAuthStore } from "./authStore"; - -/** - * Resolved from the signed-in partner's point of view by the API. The stored row is - * positional (user1/user2 by member id), which a client cannot act on without - * re-deriving that ordering, so the endpoint answers in you/partner terms instead. - */ -export interface CoupleStreak { - couple_id: string; - current_streak: number; - longest_streak: number; - last_active_date: string | null; - last_completed_date: string | null; - you_answered_today: boolean; - partner_answered_today: boolean; - partner_name: string | null; - /** The couple's shared IANA zone, which is the day boundary the streak is counted in. */ - timezone: string; - streak_celebrated_at: number; - created_at: string; - updated_at: string; -} - -interface StreakState { - streak: CoupleStreak | null; - isLoading: boolean; - error: string | null; - - // Actions - fetchStreak: () => Promise; - clearStreak: () => void; -} - -export const useStreakStore = create((set) => ({ - streak: null, - isLoading: false, - error: null, - - fetchStreak: async () => { - const coupleId = useAuthStore.getState().user?.couple_id; - if (!coupleId) { - set({ streak: null, isLoading: false }); - return; - } - - set({ isLoading: true, error: null }); - - try { - const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak"); - set({ streak, isLoading: false }); - } catch (error) { - console.error("Error fetching streak:", error); - set({ error: "Failed to load streak", isLoading: false }); - } - }, - - clearStreak: () => { - set({ streak: null, isLoading: false, error: null }); - }, -})); +import { useAuthStore } from "./authStore"; + +/** + * Resolved from the signed-in partner's point of view by the API. The stored row is + * positional (user1/user2 by member id), which a client cannot act on without + * re-deriving that ordering, so the endpoint answers in you/partner terms instead. + */ +export interface CoupleStreak { + couple_id: string; + current_streak: number; + longest_streak: number; + last_active_date: string | null; + last_completed_date: string | null; + you_answered_today: boolean; + partner_answered_today: boolean; + partner_name: string | null; + /** The couple's shared IANA zone, which is the day boundary the streak is counted in. */ + timezone: string; + streak_celebrated_at: number; + created_at: string; + updated_at: string; +} + +interface StreakState { + streak: CoupleStreak | null; + isLoading: boolean; + error: string | null; + /** + * Null until the first successful read. A couple with no streak yet resolves to a + * null `streak`, which is a loaded answer rather than a reason to ask again. + */ + loadedAt: number | null; + /** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */ + generation: number; + + // Actions + fetchStreak: () => Promise; + /** Loads the streak once; later callers reuse the cached value. */ + ensureStreakLoaded: () => Promise; + /** Silent re-read of an already-loaded streak; leaves the cached value on screen if it fails. */ + refreshStreak: () => Promise; + clearStreak: () => void; +} + +// The streak is rendered by a badge, the home screen and the swipe screen, so +// concurrent mounts must share one request rather than each issuing their own. +// Tagged with the generation that started it: clearing/signing out bumps the +// generation, so a stale response can no longer write into the next account's +// store, and a stale `finally` cannot clear a newer generation's guard. +let inFlight: { generation: number; promise: Promise } | null = null; + +export const useStreakStore = create((set, get) => ({ + streak: null, + isLoading: false, + error: null, + loadedAt: null, + generation: 0, + + fetchStreak: async () => { + const coupleId = useAuthStore.getState().user?.couple_id; + const myGeneration = get().generation; + if (!coupleId) { + set({ streak: null, isLoading: false, loadedAt: null }); + return; + } + + if (inFlight && inFlight.generation === myGeneration) return inFlight.promise; + + set({ isLoading: true, error: null }); + + const promise = (async () => { + try { + const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak"); + if (get().generation !== myGeneration) return; + set({ streak, isLoading: false, loadedAt: Date.now() }); + } catch (error) { + if (get().generation !== myGeneration) return; + console.error("Error fetching streak:", error); + set({ error: "Failed to load streak", isLoading: false }); + } finally { + if (inFlight?.generation === myGeneration) inFlight = null; + } + })(); + + inFlight = { generation: myGeneration, promise }; + return promise; + }, + + ensureStreakLoaded: async () => { + if (get().loadedAt !== null) return; + await get().fetchStreak(); + }, + + refreshStreak: async () => { + // Only silently refresh a streak the UI has actually loaded; an unopened + // screen has nothing cached to keep honest. + if (get().loadedAt === null) return; + await get().fetchStreak(); + }, + + clearStreak: () => { + set((state) => ({ + streak: null, isLoading: false, error: null, loadedAt: null, + // Invalidates any request already in flight for the signed-out user. + generation: state.generation + 1, + })); + }, +})); diff --git a/docs/agents/routing.md b/docs/agents/routing.md index ca74fc9..dac7d0b 100644 --- a/docs/agents/routing.md +++ b/docs/agents/routing.md @@ -1,21 +1,58 @@ # Agent routing and effort -Use one implementation agent by default. Add independent agents only when work is -actually separable or a risk trigger requires independent judgment. +The primary agent owns scope, acceptance criteria, task decomposition, and the +final answer. Use one implementation agent when a bounded handoff will keep noisy +execution out of the primary context. Do not pay the coordination cost for a +trivial one-file edit. Add other agents only for separable work or a risk trigger. -| Role | Default effort | Trigger | Do not use for | -|---|---|---|---| -| `repo-scout` | low | locate ownership, callers, contracts | editing or design decisions | -| `implementation` | medium | bounded product or harness change | broad architecture review | -| `test-writer` | medium | focused regression or missing contract test | rubber-stamping green output | -| `verifier` | medium | independently drive changed behavior | editing implementation | -| `database-reviewer` | medium | migrations, RLS, function/query boundaries | ordinary UI changes | -| `security-reviewer` | high | auth, RLS, crypto, secrets, payments, destructive operations | every routine change | -| `architecture-reviewer` | high | cross-cutting or hard-to-reverse decisions | local refactors | +## Cost ladder -Escalate effort only when uncertainty, blast radius, or irreversibility justifies -it. “Proactively use” is not a trigger. Verification agents return evidence and a -verdict; they do not make changes. +Project Codex defaults live in `.codex/config.toml`; task profiles live in +`.codex/agents/`. An explicit task handoff may raise or lower reasoning effort. -Procedures belong in skills. Agents read the applicable skill and nearest -`AGENTS.md`, then operate within the task's authorization. +| Role | Codex model | Default effort | Trigger | Do not use for | +|---|---|---:|---|---| +| `repo_scout` | Luna | low | locate ownership, callers, contracts | editing or design decisions | +| `implementation` | Terra | medium, overridable | bounded product or harness change | broad architecture review | +| `test_writer` | Terra | medium | focused regression or missing contract test | rubber-stamping green output | +| `verifier` | Terra | medium | independently drive implemented behavior | editing implementation | +| `database_reviewer` | Terra | high | migrations, RLS, function/query boundaries | ordinary UI changes | +| `security_reviewer` | Sol | high | auth, RLS, crypto, secrets, payments, destructive operations | every routine change | +| `architecture_reviewer` | Sol | high | cross-cutting or hard-to-reverse decisions | local refactors | + +Claude profiles under `.claude/agents/` provide equivalent roles when Claude is +explicitly requested. They are not part of the normal Codex routing path. + +## Implementation effort + +Keep `implementation` on Terra and choose effort from the task shape: + +- `low`: mechanical, fully specified, narrow change with an obvious check. +- `medium`: normal feature or bug fix with a few files and known contracts. +- `high`: ambiguous behavior, several interacting modules, concurrency, or + difficult debugging. +- `xhigh`: exceptional Terra implementation where high effort has already proved + insufficient or the change has a large but non-security blast radius. + +Security and architecture risk are different jobs, not reasons to silently turn +the implementation agent into a premium generalist. Add the matching read-only +Sol reviewer and keep implementation on Terra unless the user explicitly asks for +a different model. + +## Handoff contract + +Every delegated task includes the objective, owned files or surface, acceptance +criteria, applicable `AGENTS.md` and skill, allowed side effects, required checks, +and the evidence to return. The agent must report changed files, commands and +results, assumptions, and blockers. The primary agent independently reviews the +diff and decides whether acceptance has been met. + +Parallel agents are for independent read-heavy work or non-overlapping ownership. +Avoid concurrent edits to the same files, shared database state, fixed local +ports, or one simulator. Subagents consume additional tokens, so parallelism must +buy meaningful time or independent judgment. + +Procedures belong in deterministic skills rather than agent prompts. Agents read +the nearest `AGENTS.md` and the applicable repository skill, then operate only +within the task's authorization. Verification agents return evidence and a +verdict; they do not fix implementation. diff --git a/docs/architecture/client-data-freshness.md b/docs/architecture/client-data-freshness.md new file mode 100644 index 0000000..5745b1d --- /dev/null +++ b/docs/architecture/client-data-freshness.md @@ -0,0 +1,195 @@ +# Client data freshness + +The mobile app talks to a request-per-read API with no realtime channel. This +document is the contract for how it stays current without spending a request +every time the user looks at something they have already loaded. + +## The invariant + +**Cached data is usable data.** Once a screen has loaded, switching away from it +and back must cost zero requests and must never blank what is on screen. A +refresh happens for a reason — the user pulled to refresh, a mutation invalidated +the data, or a change marker moved — never merely because a screen regained +focus. + +Three rules make that hold: + +- **Loaded-ness is recorded, not inferred.** Stores keep a marker + (`loadedAt`, `catalogLoadedAt`, `enabledPacksLoadedAt`) that is set on a + successful load and cleared on sign-out or explicit invalidation. Emptiness is + a legitimate answer, not evidence of never having loaded: keying off + `array.length === 0` makes every visit to an empty list pay for the same empty + response. +- **Initial loading and refreshing are different states.** `isLoading` means + there is nothing to show yet and drives the full-screen loader. `isRefreshing` + drives pull-to-refresh only. A background refresh raises neither, so cached + rows stay on screen and in place until the replacement arrives. +- **Invalidation is free.** A mutation that changes another screen's data marks + that cache stale (`invalidatePacks`, `invalidateResponses`) instead of + refetching it. Whichever screen the user opens next reloads once; screens they + do not open stay quiet. + +Overlap protection lives in a module-scoped `inFlight` set rather than in the +loading flags, because a silent refresh raises no flag and still must not issue a +second request. The set is cleared alongside the store on sign-out, so a +signed-out user's in-flight request cannot suppress the next user's. + +## Recurring work + +`usePolling` (`apps/mobile/src/hooks/usePolling.ts`) owns every recurring read. +It schedules the next run after the previous one settles rather than on a fixed +interval, so a slow response delays the next request instead of queueing one +behind it, and it provides three properties no hand-rolled interval in this app +had: + +- it stops while the app is backgrounded or `inactive`, and catches up + immediately on return; +- it never runs two passes concurrently; +- it backs off exponentially — to `maxIntervalMs`, eight intervals by default — + while the API is failing. + +Callers gate it further with `enabled`, which carries screen focus, a loaded id, +or a paired couple. Screens pass focus in as a prop rather than having the hook +read navigation state, so hooks stay renderable without a navigator. + +| Poller | Interval | Runs while | +|---|---|---| +| Couple sync (`useCoupleSync`) | 15s | signed in, app foregrounded | +| Chat messages | 2s | chat screen focused | +| Dares | 5s | dares screen focused | +| Live draw | 750ms | canvas focused | + +Live draw keeps the sub-second cadence a shared canvas needs, but only for the +seconds someone is actually looking at it. + +## `GET /v1/me/sync` + +One small query summarising everything the app used to discover by re-reading +five endpoints. The client compares it against the previous summary and refreshes +only the domains whose markers moved — and only those already loaded, so a view +nobody has opened is never fetched in the background. + +| Field | Moves when | +|---|---| +| `server_time` | every call | +| `couple_id`, `profile_updated_at` | the user's own profile or couple membership changed | +| `partner_id`, `partner_updated_at` | the partner's profile changed | +| `match_count`, `new_match_count`, `latest_match_at` | matches were created, seen, or archived | +| `pending_yours`, `pending_theirs` | either side answered a question the other is waiting on | +| `unread_total` | chat unread count for this user changed | +| `enabled_packs_fingerprint` | the couple's enabled pack set changed | +| `match_state_fingerprint` | a visible match's type or response summary changed in place | +| `match_unread_fingerprint` | unread moved between matches, with or without a change to the total | +| `streak_updated_at` | the couple's streak row changed | + +`unread_total` is applied directly to the badge, so keeping it current costs +nothing beyond this request. The first poll after sign-in only establishes the +baseline: every screen loads its own data on mount, and refreshing it again would +undo the saving. + +`enabled_packs_fingerprint` is an `md5` digest of the enabled pack ids rather +than a timestamp, because `couple_packs` carries no `updated_at` and equal and +opposite toggles would otherwise be invisible. Zero enabled packs is the stable +digest of the empty string, not `null`. + +The two match digests exist for the same reason in a different shape: the counts +and `latest_match_at` all hold steady when a partner edits an existing match in +place, and `unread_total` holds steady when unread merely moves between matches — +which still reorders an unread-first list. + +Every field is derived for the calling identity's own couple. The summary reveals +nothing about any other couple, and reveals no partner content — only that +something moved. + +## Chat deltas + +`GET /v1/matches/:id/messages` takes two optional parameters: + +- `since` — an ISO 8601 timestamp with offset. Returns only rows whose visible + state moved since then: created, delivered, read, deleted, media viewed, or a + video whose `media_expires_at` fell inside the window. Messages are not + editable, so there is no edit to report. A malformed value is rejected with + `400 invalid_since` rather than silently treated as a full read. +- `typing=true` — folds the partner's typing state into the same response, so a + focused chat makes one request per pass instead of two. + +The response is a `MessagesPage`: + +| Field | Meaning | +|---|---| +| `messages` | rows to merge by id, newest first | +| `removed_ids` | rows the client must drop (deleted, or deleted-for-self) | +| `server_time` | cursor for the next `since` | +| `complete` | true when this is a full snapshot rather than a delta | +| `typing` | present only when `typing=true` was requested | + +`server_time` is deliberately set two seconds behind `now()` so a row committed +during the request cannot fall between two cursors. The overlap re-returns a +handful of already-known rows, which is harmless because the client merges by id. + +`media_expired` is derived in the query — `media_expired or media_expires_at <= +now()` — rather than read from the stored column, which nothing ever writes. That +is the rule the media endpoint already applies when it refuses an expired object, +so a client is no longer handed a video it will not be allowed to fetch. Expiry is +also the one visible change no row write announces, which is why the delta +compares the deadline against the poll window rather than looking for a timestamp +that moved; the row is emitted in the single window its deadline falls into. + +`moderation_status`, `flag_reason` and `category` are returned in full but are +**not** covered by `since`: the classifier and the admin tool rewrite them without +advancing any timestamp, so a delta cannot see them move. No client renders them +today, so nothing user-visible goes stale — but a client that started rendering +them would need a cursor for them, which means a new column on `messages`. + +Deltas do not change access control, ordering, media fields, or deletion +semantics: a delta returns exactly the rows a full read would have returned for +that window, and a caller outside the couple still gets `match_not_found`. + +The client marks the conversation read only when a response actually contains an +unread partner message, and `POST .../read` returns the number of rows it +cleared, so the badge is adjusted by subtraction rather than by re-reading the +total. + +## Match pagination + +`GET /v1/matches` returns `{ matches, totalCount, hasMore }`. + +`totalCount` is a `number` on page 0 and **`null` on every later page**: the +count is a full scan of the couple's matches and nothing in the UI needs it more +than once per refresh. Clients keep the value from the refresh that started the +sequence. + +`hasMore` comes from reading `limit + 1` rows and reporting whether the extra one +existed, so an exactly-full final page reports `false` correctly. Unread counts +are aggregated once in a CTE rather than by a correlated subquery per row. +Ordering (unread first, then newest) and couple isolation are unchanged. + +Pagination itself is still page-number/`OFFSET`-based, not cursor-based. This +pass removed the repeated `count(*)` and the per-row unread subquery — the two +costs that scaled with the couple's history — but paging deep into a large +history still pays an `OFFSET` scan. Cursor pagination would remove that too; +it has not been implemented. + +## `GET /v1/me` is idempotent + +Bootstrapping a profile reads first and writes only what actually differs. An +unchanged read leaves both `updated_at` and `auth_synced_at` alone, so a client +polling `/v1/me` no longer produces a write per call. First use still creates +the profile from the auth identity's name, avatar and email. On every later +call only a changed **email** writes through, syncing the one field the auth +provider is authoritative for; the product's display name and avatar are not +touched, so a locally-edited name or avatar is never overwritten by +potentially stale auth metadata. + +## One database pool + +The API process creates a single `pg` pool and passes it to every repository, so +the process holds one bounded set of server connections rather than a +default-sized pool per domain. Shutdown ends that pool once. Repositories still +accept a connection string in tests, in which case they own and end the pool they +created — see `resolvePool` / `closeResolvedPool` in `apps/api/src/db/pool.ts`. + +Bounds come from `DATABASE_POOL_MAX` (default 10), +`DATABASE_POOL_IDLE_TIMEOUT_MS` (30000) and +`DATABASE_POOL_CONNECTION_TIMEOUT_MS` (10000). They are process-wide; keep the +total across running instances inside the database's own connection limit. diff --git a/docs/dares-loop.md b/docs/dares-loop.md index a4f6b59..e9f04ae 100644 --- a/docs/dares-loop.md +++ b/docs/dares-loop.md @@ -121,9 +121,15 @@ rejected. ## Mobile Reached from the home screen's `DaresTile`, which badges pending incoming dares. The -screen has Active / Send / History tabs, polls on a 5s interval (there is no realtime -channel), and opens the paywall both proactively and in reaction to a `402` — a client -whose entitlement went stale still lands on the paywall rather than an error. +screen has Active / Send / History tabs and opens the paywall both proactively and in +reaction to a `402` — a client whose entitlement went stale still lands on the paywall +rather than an error. + +There is no realtime channel, so the screen polls on a 5s interval — but only while it is +focused and the app is foregrounded, and each pass reads the active list alone. History, +the catalogue and the stats are re-read only when the active set's ids or statuses +actually changed, or on an explicit refresh or a mutation. See +[Client data freshness](architecture/client-data-freshness.md). ## Tests diff --git a/docs/index.md b/docs/index.md index 5b74a3f..cc50425 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,7 @@ historical notes. Agents should load only the section relevant to their task. ## Maintained system documentation - [System architecture](architecture/system.md) +- [Client data freshness](architecture/client-data-freshness.md) - [Local full-stack development](development/local-stack.md) - [Testing strategy](testing/strategy.md) - [Database migration safety](operations/database-migrations.md) diff --git a/scripts/lint-harness.mjs b/scripts/lint-harness.mjs index 9db150f..3611d07 100644 --- a/scripts/lint-harness.mjs +++ b/scripts/lint-harness.mjs @@ -23,6 +23,10 @@ function fail(file, message, fix) { errors.push(`${path.relative(root, file)}: ${message}\n Fix: ${fix}`); } +function tomlString(source, key) { + return source.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"\\s*$`, 'm'))?.[1]; +} + const allFiles = await walk(root); for (const file of allFiles) { if (path.basename(file) === 'agents.md') { @@ -37,6 +41,57 @@ for (const file of allFiles) { if (!scoped) { const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); + const codexConfigFile = path.join(root, '.codex', 'config.toml'); + const codexConfig = await readFile(codexConfigFile, 'utf8'); + if (!/^\[agents\]$/m.test(codexConfig) || !/^enabled\s*=\s*true$/m.test(codexConfig)) { + fail(codexConfigFile, 'project subagent routing is not enabled', 'restore the [agents] table with enabled = true'); + } + if (tomlString(codexConfig, 'default_subagent_model') !== 'gpt-5.6-terra') { + fail(codexConfigFile, 'ordinary subagents no longer default to the cost-balanced model', 'set default_subagent_model = "gpt-5.6-terra"'); + } + if (tomlString(codexConfig, 'default_subagent_reasoning_effort') !== 'medium') { + fail(codexConfigFile, 'ordinary subagent effort is no longer the balanced default', 'set default_subagent_reasoning_effort = "medium"'); + } + const maxThreads = Number(codexConfig.match(/^max_concurrent_threads_per_session\s*=\s*(\d+)$/m)?.[1]); + if (!Number.isInteger(maxThreads) || maxThreads < 1 || maxThreads > 3) { + fail(codexConfigFile, 'subagent concurrency is missing or exceeds the repository cost cap', 'set max_concurrent_threads_per_session between 1 and 3'); + } + + const agentContracts = { + 'repo-scout.toml': { name: 'repo_scout', model: 'gpt-5.6-luna', effort: 'low', readOnly: true }, + 'implementation.toml': { name: 'implementation', model: 'gpt-5.6-terra', effort: null, flexibleEffort: true }, + 'test-writer.toml': { name: 'test_writer', model: 'gpt-5.6-terra', effort: 'medium' }, + 'verifier.toml': { name: 'verifier', model: 'gpt-5.6-terra', effort: 'medium' }, + 'database-reviewer.toml': { name: 'database_reviewer', model: 'gpt-5.6-terra', effort: 'high', readOnly: true }, + 'security-reviewer.toml': { name: 'security_reviewer', model: 'gpt-5.6-sol', effort: 'high', readOnly: true }, + 'architecture-reviewer.toml': { name: 'architecture_reviewer', model: 'gpt-5.6-sol', effort: 'high', readOnly: true }, + }; + for (const [filename, contract] of Object.entries(agentContracts)) { + const agentFile = path.join(root, '.codex', 'agents', filename); + if (!(await exists(agentFile))) { + fail(agentFile, 'required task-agent profile is missing', `restore .codex/agents/${filename}`); + continue; + } + const source = await readFile(agentFile, 'utf8'); + if (tomlString(source, 'name') !== contract.name || tomlString(source, 'model') !== contract.model) { + fail(agentFile, 'task-agent identity or cost tier drifted', `set name = "${contract.name}" and model = "${contract.model}"`); + } + const effort = tomlString(source, 'model_reasoning_effort'); + if (contract.flexibleEffort ? effort !== undefined : effort !== contract.effort) { + fail( + agentFile, + contract.flexibleEffort ? 'implementation reasoning is pinned and cannot flex by task complexity' : 'task-agent reasoning effort drifted', + contract.flexibleEffort ? 'omit model_reasoning_effort so the handoff or project default controls it' : `set model_reasoning_effort = "${contract.effort}"`, + ); + } + if (contract.readOnly && tomlString(source, 'sandbox_mode') !== 'read-only') { + fail(agentFile, 'review-only agent can mutate the workspace', 'set sandbox_mode = "read-only"'); + } + if (!/^description\s*=\s*".+"$/m.test(source) || !/developer_instructions\s*=\s*"""[\s\S]*\S[\s\S]*"""/m.test(source)) { + fail(agentFile, 'custom agent is missing its required description or instructions', 'restore non-empty description and developer_instructions fields'); + } + } + const mobilePackageFile = path.join(root, 'apps', 'mobile', 'package.json'); const mobilePackageJson = JSON.parse(await readFile(mobilePackageFile, 'utf8')); const releaseBuildScripts = Object.entries(mobilePackageJson.scripts ?? {})