From b60c788362b07681f6c756db868f62335692a840 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Fri, 28 Aug 2026 20:28:31 +0100 Subject: [PATCH] feat(engagement): localize limits and streaks Use reported user timezones for daily response limits, expose couple streak state and reminders, and improve swipe and live-drawing synchronization with focused tests and documentation. --- apps/admin/src/pages/AppSettingsPage.tsx | 11 +- .../drizzle/0013_daily_limit_local_reset.sql | 2 + .../0014_daily_limit_response_index.sql | 2 + .../drizzle/0015_couple_streak_locality.sql | 15 ++ .../0016_streak_reminder_preference.sql | 3 + apps/api/drizzle/meta/_journal.json | 28 +++ apps/api/src/db/schema.ts | 1 + apps/api/src/domains/answers/repository.ts | 115 ++++++++++- apps/api/src/domains/operations/repository.ts | 60 +++++- apps/api/src/domains/operations/types.ts | 1 + .../domains/profile-settings/repository.ts | 3 + .../src/domains/profile-settings/routes.ts | 2 + .../api/src/domains/profile-settings/types.ts | 2 + apps/api/test/answers.integration.test.ts | 38 +++- apps/api/test/answers.routes.test.ts | 4 +- apps/api/test/api.integration.test.ts | 3 +- apps/api/test/operations.integration.test.ts | 40 +++- apps/api/test/operations.runner.test.ts | 2 +- apps/api/test/profile-settings.routes.test.ts | 1 + apps/mobile/app/(app)/index.tsx | 15 +- .../components/StreakDisplay.test.ts | 48 +++++ .../swipe/useSwipeScreen-helpers.test.ts | 40 ++++ .../features/swipe/useSwipeScreen.test.ts | 168 ++++++++++++++++ .../__tests__/hooks/useDrawingSync.test.ts | 36 +++- .../__tests__/lib/reportedTimezone.test.ts | 67 +++++++ .../notificationPreferencesStore.test.ts | 1 + apps/mobile/src/components/StreakDisplay.tsx | 108 +++++++---- .../src/features/live-draw/LiveDrawScreen.tsx | 84 +++++--- .../live-draw/components/DrawingCanvas.tsx | 179 +++++++++--------- .../live-draw/hooks/useDrawingSync.ts | 118 +++++++++--- .../components/NotificationSettings.tsx | 16 ++ .../mobile/src/features/swipe/SwipeScreen.tsx | 1 + .../swipe/components/SwipeDailyLimitState.tsx | 19 +- .../features/swipe/components/SwipeHeader.tsx | 57 ++++++ .../features/swipe/hooks/useSwipeScreen.ts | 48 ++++- apps/mobile/src/lib/profileSettingsApi.ts | 1 + apps/mobile/src/lib/reportedTimezone.ts | 68 +++++++ apps/mobile/src/store/authStore.ts | 5 + .../src/store/notificationPreferencesStore.ts | 1 + apps/mobile/src/store/streakStore.ts | 13 +- docs/couple-streaks.md | 98 ++++++++++ docs/index.md | 1 + 42 files changed, 1308 insertions(+), 217 deletions(-) create mode 100644 apps/api/drizzle/0013_daily_limit_local_reset.sql create mode 100644 apps/api/drizzle/0014_daily_limit_response_index.sql create mode 100644 apps/api/drizzle/0015_couple_streak_locality.sql create mode 100644 apps/api/drizzle/0016_streak_reminder_preference.sql create mode 100644 apps/mobile/src/__tests__/components/StreakDisplay.test.ts create mode 100644 apps/mobile/src/__tests__/features/swipe/useSwipeScreen-helpers.test.ts create mode 100644 apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts create mode 100644 apps/mobile/src/__tests__/lib/reportedTimezone.test.ts create mode 100644 apps/mobile/src/lib/reportedTimezone.ts create mode 100644 docs/couple-streaks.md diff --git a/apps/admin/src/pages/AppSettingsPage.tsx b/apps/admin/src/pages/AppSettingsPage.tsx index fc9db3c..af9e769 100644 --- a/apps/admin/src/pages/AppSettingsPage.tsx +++ b/apps/admin/src/pages/AppSettingsPage.tsx @@ -187,7 +187,7 @@ export function AppSettingsPage() { Daily Response Limit (Free Users) - Limit the number of questions non-premium users can answer per day (UTC 00:00-23:59) + Limit the number of questions non-premium users can answer per day, on their own local calendar day @@ -203,14 +203,19 @@ export function AppSettingsPage() { className="max-w-[200px]" />

- Maximum number of questions a non-premium user can answer per UTC day. + Maximum number of questions a non-premium user can answer per day. Premium users (via personal or partner subscription) bypass this limit.

Set to 0 to disable daily limits entirely.

- Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit. + Resets at midnight in the user's own timezone, as reported by their device. + Users who have not reported a timezone yet fall back to 00:00 UTC. +

+

+ Does not count toward the limit: editing an existing answer, or + answering a question the partner had already answered (catching up is always free).

diff --git a/apps/api/drizzle/0013_daily_limit_local_reset.sql b/apps/api/drizzle/0013_daily_limit_local_reset.sql new file mode 100644 index 0000000..d5488c6 --- /dev/null +++ b/apps/api/drizzle/0013_daily_limit_local_reset.sql @@ -0,0 +1,2 @@ +ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "timezone" text;--> statement-breakpoint +COMMENT ON COLUMN "profiles"."timezone" IS 'IANA zone reported by the client. NULL falls back to UTC when bucketing the daily response limit.'; diff --git a/apps/api/drizzle/0014_daily_limit_response_index.sql b/apps/api/drizzle/0014_daily_limit_response_index.sql new file mode 100644 index 0000000..38ffe7a --- /dev/null +++ b/apps/api/drizzle/0014_daily_limit_response_index.sql @@ -0,0 +1,2 @@ +-- The daily response limit counts a user's responses within their current local day. +CREATE INDEX IF NOT EXISTS "responses_user_created_idx" ON "responses" ("user_id", "created_at" DESC); diff --git a/apps/api/drizzle/0015_couple_streak_locality.sql b/apps/api/drizzle/0015_couple_streak_locality.sql new file mode 100644 index 0000000..bc327fd --- /dev/null +++ b/apps/api/drizzle/0015_couple_streak_locality.sql @@ -0,0 +1,15 @@ +-- Streaks are counted in the couple's shared local day, not the server's calendar day. +-- The zone is the first reported timezone in member id order (the same ordering that +-- decides user1/user2), falling back to UTC when neither partner has reported one. +COMMENT ON COLUMN "couple_streaks"."last_active_date" IS 'Couple-local date on which either partner last answered. The answered-today flags are read as false once this is not the couple-local today.';--> statement-breakpoint +COMMENT ON COLUMN "couple_streaks"."last_completed_date" IS 'Couple-local date on which both partners last answered. The streak is alive while this is the couple-local today or yesterday.';--> statement-breakpoint + +-- Prunes the evening at-risk scan to couples that actually have a streak to lose. +CREATE INDEX IF NOT EXISTS "couple_streaks_live_idx" ON "couple_streaks" ("last_completed_date") WHERE "current_streak" > 0;--> statement-breakpoint + +-- The legacy data plane maintained streaks from a trigger that read auth.uid(). The API +-- owns that write now and runs it in the same transaction as the response, so drop the +-- trigger defensively in case a cutover ever carries these objects across. +DROP TRIGGER IF EXISTS "on_response_update_streak" ON "responses";--> statement-breakpoint +DROP FUNCTION IF EXISTS "update_couple_streak"();--> statement-breakpoint +DROP FUNCTION IF EXISTS "reset_daily_streak_flags"(); diff --git a/apps/api/drizzle/0016_streak_reminder_preference.sql b/apps/api/drizzle/0016_streak_reminder_preference.sql new file mode 100644 index 0000000..3adf996 --- /dev/null +++ b/apps/api/drizzle/0016_streak_reminder_preference.sql @@ -0,0 +1,3 @@ +-- A shared streak only changes behaviour if somebody is told it is still open, so the +-- evening at-risk nudge gets its own opt-out rather than riding on milestone celebrations. +ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "streak_reminders_enabled" boolean NOT NULL DEFAULT true; diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 0430787..a3b7e0c 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -92,6 +92,34 @@ "when": 1787908500000, "tag": "0012_catalogue_field_parity", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1787994900000, + "tag": "0013_daily_limit_local_reset", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1787994960000, + "tag": "0014_daily_limit_response_index", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1788081360000, + "tag": "0015_couple_streak_locality", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1788081420000, + "tag": "0016_streak_reminder_preference", + "breakpoints": true } ] } diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 1db150a..054fc47 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -6,6 +6,7 @@ export const profiles = pgTable('profiles', { email: text('email'), avatarUrl: text('avatar_url'), pushToken: text('push_token'), + timezone: text('timezone'), isPremium: boolean('is_premium').notNull().default(false), coupleId: uuid('couple_id'), gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(), diff --git a/apps/api/src/domains/answers/repository.ts b/apps/api/src/domains/answers/repository.ts index 96bf772..443970c 100644 --- a/apps/api/src/domains/answers/repository.ts +++ b/apps/api/src/domains/answers/repository.ts @@ -5,6 +5,33 @@ interface ContextRow { couple_id: string | null; gender: string | null; max_inte interface QuestionRow { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; question_type: QuestionType; config: Record; created_at: string } interface ResponseRow { id: string; user_id: string; question_id: string; couple_id: string; answer: Answer; response_data: ResponseData; created_at: string } interface MatchRow { id: string; couple_id: string; question_id: string; match_type: MatchType; is_new: boolean; response_summary: Record | null; created_at: string } +interface StreakRow { couple_id: string; current_streak: number; longest_streak: number; last_active_date: string | null; last_completed_date: string | null; user1_answered_today: boolean; user2_answered_today: boolean; streak_celebrated_at: number; created_at: Date; updated_at: Date } + +/** + * A streak resolved from one partner's point of view. The stored row is positional + * (user1/user2 by member id order) because the writer has to be deterministic, but a + * client cannot act on "user2 has not answered" without re-deriving that ordering, so + * the API answers in terms of you and your partner instead. + */ +export interface CoupleStreakView { + 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; + timezone: string; + streak_celebrated_at: number; + created_at: string; + updated_at: string; +} + +// A response to a question the partner answered first is catching up, not new exploration. +// Those neither consume nor are blocked by the daily limit, so the reciprocity loop the +// answer-gap mechanic pushes can never be refused by the meter. +const catchUpExemption = "select 1 from responses pr where pr.question_id=r.question_id and pr.couple_id=r.couple_id and pr.user_id<>r.user_id and pr.created_at; markSeen(userId: string, ids: string[]): Promise; archive(userId: string, matchId: string, archived: boolean): Promise; - streak(userId: string): Promise | null>; + streak(userId: string): Promise; close(): Promise; } export class PostgresAnswersRepository implements AnswersRepository { private readonly pool: Pool; + private zones: Promise> | null = null; constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); } private async context(client: Pool | PoolClient, userId: string): Promise { @@ -41,7 +69,7 @@ export class PostgresAnswersRepository implements AnswersRepository { const p = partner.rows[0]; const config = await this.config(); if (!packId && p && (await this.answerGap(userId)).is_blocked) return []; - if (!packId && !ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return []; + if (!ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return []; const genders = ctx.gender && p?.gender ? [ctx.gender, p.gender].sort().join('+') : null; const maxIntensity = config.couple_intensity_gate_enabled && p ? Math.min(ctx.max_intensity, p.max_intensity) : ctx.max_intensity; const result = await this.pool.query(` @@ -117,7 +145,10 @@ export class PostgresAnswersRepository implements AnswersRepository { await this.assertEligible(client,userId,ctx,q); const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]); if (!existing.rowCount) { - const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, reset_at: limit.reset_at }); + const catchingUp = await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[ctx.couple_id,input.questionId,userId]); + if (!catchingUp.rowCount) { + const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, responses_today: limit.responses_today, remaining: 0, reset_at: limit.reset_at }); + } } const response = await client.query(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5) on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]); @@ -157,12 +188,30 @@ export class PostgresAnswersRepository implements AnswersRepository { return result.rows[0]; } + /** + * The streak day is the couple's shared local day, not the server's. Both partners have + * to land inside the same calendar date for the day to count, so the zone belongs to the + * couple rather than to whoever happens to be answering: the first reported zone in member + * id order, which is the same ordering that decides user1/user2, falling back to UTC when + * neither partner has reported one. Without this a couple in a western zone loses the + * streak at UTC midnight while it is still the same evening for them. + */ + private async coupleZone(client:Pool|PoolClient,reported:(string|null)[]):Promise{ + const zone=reported.find(value=>value); + return zone&&(await this.timezoneNames(client)).has(zone)?zone:'UTC'; + } + + private async streakDay(client:Pool|PoolClient,zone:string){ + const r=await client.query<{today:string;yesterday:string}>("select (now() at time zone $1::text)::date::text today,((now() at time zone $1::text)::date-1)::text yesterday",[zone]); + return r.rows[0]; + } + private async touchStreak(client:PoolClient,userId:string,coupleId:string){ - const users=await client.query<{id:string}>('select id from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return; + const users=await client.query<{id:string;timezone:string|null}>('select id,timezone from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return; const user1=users.rows[0].id===userId; await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]); const locked=await client.query<{current_streak:number;longest_streak:number;last_active_date:string|null;last_completed_date:string|null;user1_answered_today:boolean;user2_answered_today:boolean}>(`select current_streak,longest_streak,last_active_date::text,last_completed_date::text,user1_answered_today,user2_answered_today from couple_streaks where couple_id=$1 for update`,[coupleId]); - const row=locked.rows[0]; const todayResult=await client.query<{today:string;yesterday:string}>('select current_date::text today,(current_date-1)::text yesterday'); const {today,yesterday}=todayResult.rows[0]; + const row=locked.rows[0]; const {today,yesterday}=await this.streakDay(client,await this.coupleZone(client,users.rows.map(member=>member.timezone))); let first=row.last_active_date===today?row.user1_answered_today:false; let second=row.last_active_date===today?row.user2_answered_today:false; if(user1)first=true;else second=true; let current=row.last_completed_date && row.last_completed_date!==yesterday && row.last_completed_date!==today?0:row.current_streak; let completed=row.last_completed_date; @@ -190,13 +239,61 @@ export class PostgresAnswersRepository implements AnswersRepository { } 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]);} - async streak(userId:string){const ctx=await this.context(this.pool,userId);const r=await this.pool.query('select * from couple_streaks where couple_id=$1',[ctx.couple_id]);return r.rows[0]?this.dates(r.rows[0]):null;} + async streak(userId:string):Promise{ + const ctx=await this.context(this.pool,userId); + const r=await this.pool.query(`select couple_id,current_streak,longest_streak,last_active_date::text last_active_date,last_completed_date::text last_completed_date, + user1_answered_today,user2_answered_today,streak_celebrated_at,created_at,updated_at from couple_streaks where couple_id=$1`,[ctx.couple_id]); + const row=r.rows[0]; if(!row)return null; + const members=await this.pool.query<{id:string;name:string|null;timezone:string|null}>('select id,name,timezone from profiles where couple_id=$1 order by id',[ctx.couple_id]); + if(members.rows.length<2)return null; + const zone=await this.coupleZone(this.pool,members.rows.map(member=>member.timezone)); + const {today,yesterday}=await this.streakDay(this.pool,zone); + // The row is only rewritten when somebody answers, so a lapsed streak keeps its stored + // number until the next response arrives. Reading applies the same day rules the writer + // does rather than showing a count that the next answer will silently reset to 1. + const active=row.last_active_date===today; + const first=active&&row.user1_answered_today; const second=active&&row.user2_answered_today; + const alive=row.last_completed_date===today||row.last_completed_date===yesterday; + const isUser1=members.rows[0].id===userId; const partner=members.rows[isUser1?1:0]; + return { + couple_id:row.couple_id, + current_streak:alive?row.current_streak:0, + longest_streak:row.longest_streak, + last_active_date:row.last_active_date, + last_completed_date:row.last_completed_date, + you_answered_today:isUser1?first:second, + partner_answered_today:isUser1?second:first, + partner_name:partner?.name??null, + timezone:zone, + streak_celebrated_at:row.streak_celebrated_at, + created_at:row.created_at.toISOString(), + updated_at:row.updated_at.toISOString(), + }; + } private async question(client:PoolClient,id:string,lock=false){const r=await client.query(`select * from questions where id=$1 and deleted_at is null${lock?' for update':''}`,[id]);if(!r.rows[0])throw new AnswersError('question_not_found',404,'Question was not found');return r.rows[0];} + private timezoneNames(client:Pool|PoolClient){ + // pg_timezone_names is a ~600-row function scan costing ~12ms. The list is static for the + // life of the process, so load it once instead of on every daily-limit check. + this.zones??=client.query<{name:string}>('select name from pg_timezone_names') + .then(r=>new Set(r.rows.map(row=>row.name))) + .catch(cause=>{this.zones=null;throw cause;}); + return this.zones; + } private async lockedContext(client:PoolClient,userId:string){await client.query('select id from profiles where id=$1 for update',[userId]);return this.context(client,userId);} private async dailyLimitWith(client:Pool|PoolClient,userId:string){ - const cfg=await this.config(client);if(cfg.daily_response_limit===0)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false}; - const premium=await client.query<{premium:boolean}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium',[userId]);if(premium.rows[0]?.premium)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false}; - const r=await client.query<{count:number;reset_at:string}>("select count(*)::int count,((date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')+interval '1 day') reset_at from responses where user_id=$1 and created_at>=(date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')",[userId]);const count=r.rows[0]?.count??0;return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:new Date(r.rows[0].reset_at).toISOString(),is_blocked:count>=cfg.daily_response_limit}; + const cfg=await this.config(client);const unlimited={responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false}; + if(cfg.daily_response_limit===0)return unlimited; + // Premium is shared across a couple: either partner's subscription lifts the cap for both. + const profile=await client.query<{premium:boolean;zone:string|null}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium,p.timezone zone from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium,p.timezone',[userId]); + if(profile.rows[0]?.premium)return unlimited; + // The window is the user's local calendar day; an unreported or unrecognised zone falls + // back to UTC. Validating in JS keeps an unknown zone from erroring the query without + // paying for a pg_timezone_names scan inside submit()'s locked transaction. + const reported=profile.rows[0]?.zone; + const zone=reported&&(await this.timezoneNames(client)).has(reported)?reported:'UTC'; + const r=await client.query<{count:number;reset_at:string}>(`select count(*)::int count,((date_trunc('day',now() at time zone $2::text)+interval '1 day') at time zone $2::text) reset_at from responses r where r.user_id=$1 and r.created_at>=(date_trunc('day',now() at time zone $2::text) at time zone $2::text) and not exists(${catchUpExemption})`,[userId,zone]); + const count=r.rows[0]?.count??0; + return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:r.rows[0]?new Date(r.rows[0].reset_at).toISOString():null,is_blocked:count>=cfg.daily_response_limit}; } private async assertEligible(client:PoolClient,userId:string,ctx:ContextRow,q:QuestionRow){ const partner=await client.query('select id,couple_id,gender,max_intensity,is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1',[ctx.couple_id,userId]);const p=partner.rows[0]; diff --git a/apps/api/src/domains/operations/repository.ts b/apps/api/src/domains/operations/repository.ts index bccf0f7..80a77db 100644 --- a/apps/api/src/domains/operations/repository.ts +++ b/apps/api/src/domains/operations/repository.ts @@ -36,7 +36,7 @@ export class PostgresOperationsRepository implements OperationsRepository { async produce(now: Date, limit=100): Promise { return transaction(this.pool, async (client) => { const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked"); - const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0}; + const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0}; if (!lock.rows[0]?.locked) return empty; const released=await client.query<{id:string;name:string}>( @@ -139,11 +139,66 @@ export class PostgresOperationsRepository implements OperationsRepository { ) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]); const catchup=await this.produceCatchups(client,now,limit); + const streakReminders=await this.produceStreakReminders(client,now); return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0, - packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,catchupReminders:catchup}; + packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0, + catchupReminders:catchup,streakReminders}; }); } + /** + * A shared streak only changes behaviour if somebody hears that it is open before the day + * closes, so this fires in the couple's own late evening rather than on a server clock. + * Only the partner who still owes an answer is notified, and the copy names the other + * partner as an invitation rather than as a debt: guilt between partners is the failure + * mode for this mechanic, not the goal. + */ + private async produceStreakReminders(client: PoolClient, now: Date): Promise { + const result=await client.query( + `with zones as (select name from pg_timezone_names), + couple_zone as ( + select r.couple_id,coalesce(z.name,'UTC') zone from ( + select p.couple_id,(array_agg(p.timezone order by p.id) filter (where p.timezone is not null))[1] reported + from profiles p join couple_streaks cs on cs.couple_id=p.couple_id and cs.current_streak>0 + group by p.couple_id + ) r left join zones z on z.name=r.reported + ), + at_risk as ( + select cs.couple_id,cs.current_streak,cs.last_active_date,cs.user1_answered_today,cs.user2_answered_today, + ($1::timestamptz at time zone cz.zone)::date local_date + from couple_streaks cs join couple_zone cz on cz.couple_id=cs.couple_id + where cs.current_streak>0 + and cs.last_completed_date=($1::timestamptz at time zone cz.zone)::date-1 + and extract(hour from ($1::timestamptz at time zone cz.zone))>=20 + ), + sides as ( + select ar.couple_id,ar.current_streak,to_char(ar.local_date,'YYYY-MM-DD') local_date, + me.id me_id,me.push_token,nullif(btrim(partner.name),'') partner_name, + case when ar.last_active_date is distinct from ar.local_date then false + when me.idme.id + ) + insert into operations_outbox(kind,dedupe_key,recipient_id,payload) + select 'expo','streak_risk:'||s.couple_id||':'||s.local_date||':'||s.me_id,s.me_id, + jsonb_build_object( + 'title',case when s.partner_answered then coalesce(s.partner_name,'Your partner')||' answered today' + else 'Your '||s.current_streak||'-day streak' end, + 'body',case when s.partner_answered + then 'You two are '||s.current_streak||' days in together. One question keeps it going.' + else 'You and '||coalesce(s.partner_name,'your partner')||' are '||s.current_streak||' days in. One question each keeps it going.' end, + 'data',jsonb_build_object('type','streak_reminder','streak',s.current_streak)) + from sides s left join notification_preferences np on np.user_id=s.me_id + where not s.me_answered and s.push_token is not null and coalesce(np.streak_reminders_enabled,true) + and not exists(select 1 from operations_outbox o + where o.dedupe_key='streak_risk:'||s.couple_id||':'||s.local_date||':'||s.me_id) + on conflict(dedupe_key) do nothing`,[now]); + return result.rowCount??0; + } + private async produceCatchups(client: PoolClient, now: Date, limit: number): Promise { await client.query( `with behind as ( @@ -202,6 +257,7 @@ export class PostgresOperationsRepository implements OperationsRepository { when 'pack_change' then case when coalesce(np.pack_changes_enabled,true) then p.push_token end when 'new_pack' then case when coalesce(np.new_packs_enabled,true) then p.push_token end when 'streak_milestone' then case when coalesce(np.streak_milestones_enabled,true) then p.push_token end + when 'streak_reminder' then case when coalesce(np.streak_reminders_enabled,true) then p.push_token end when 'weekly_summary' then case when coalesce(np.weekly_summary_enabled,true) then p.push_token end when 'unpaired_reminder' then case when coalesce(np.unpaired_reminders_enabled,true) then p.push_token end when 'catchup_reminder' then case when coalesce(np.catchup_reminders_enabled,true) then p.push_token end diff --git a/apps/api/src/domains/operations/types.ts b/apps/api/src/domains/operations/types.ts index 9e91b8e..2ac6805 100644 --- a/apps/api/src/domains/operations/types.ts +++ b/apps/api/src/domains/operations/types.ts @@ -48,4 +48,5 @@ export interface ProducerSummary { weeklySummaries: number; unpairedReminders: number; catchupReminders: number; + streakReminders: number; } diff --git a/apps/api/src/domains/profile-settings/repository.ts b/apps/api/src/domains/profile-settings/repository.ts index 00c042c..91ccf99 100644 --- a/apps/api/src/domains/profile-settings/repository.ts +++ b/apps/api/src/domains/profile-settings/repository.ts @@ -16,6 +16,7 @@ interface PreferencesRow extends QueryResultRow { pack_changes_enabled: boolean; new_packs_enabled: boolean; streak_milestones_enabled: boolean; + streak_reminders_enabled: boolean; weekly_summary_enabled: boolean; unpaired_reminders_enabled: boolean; catchup_reminders_enabled: boolean; @@ -49,6 +50,7 @@ const profileColumns: Record = { onboarding_version: 'onboarding_version', public_key_jwk: 'public_key_jwk', push_token: 'push_token', + timezone: 'timezone', }; function toPreferences(row: PreferencesRow): NotificationPreferences { @@ -61,6 +63,7 @@ function toPreferences(row: PreferencesRow): NotificationPreferences { pack_changes_enabled: row.pack_changes_enabled, new_packs_enabled: row.new_packs_enabled, streak_milestones_enabled: row.streak_milestones_enabled, + streak_reminders_enabled: row.streak_reminders_enabled, weekly_summary_enabled: row.weekly_summary_enabled, unpaired_reminders_enabled: row.unpaired_reminders_enabled, catchup_reminders_enabled: row.catchup_reminders_enabled, diff --git a/apps/api/src/domains/profile-settings/routes.ts b/apps/api/src/domains/profile-settings/routes.ts index 021b4fb..5c85734 100644 --- a/apps/api/src/domains/profile-settings/routes.ts +++ b/apps/api/src/domains/profile-settings/routes.ts @@ -18,6 +18,8 @@ const profileUpdateSchema = z.object({ onboarding_version: z.number().int().min(0).optional(), public_key_jwk: z.record(z.unknown()).nullable().optional(), push_token: z.string().trim().min(1).max(512).nullable().optional(), + // IANA zone from the client; the daily response limit buckets on the user's local day. + timezone: z.string().trim().min(1).max(64).nullable().optional(), }).strict().refine((body) => Object.keys(body).length > 0, 'At least one profile field is required'); const preferenceSchema = z.object({ diff --git a/apps/api/src/domains/profile-settings/types.ts b/apps/api/src/domains/profile-settings/types.ts index 69d24b8..7fad4ca 100644 --- a/apps/api/src/domains/profile-settings/types.ts +++ b/apps/api/src/domains/profile-settings/types.ts @@ -6,6 +6,7 @@ export const notificationPreferenceKeys = [ 'pack_changes_enabled', 'new_packs_enabled', 'streak_milestones_enabled', + 'streak_reminders_enabled', 'weekly_summary_enabled', 'unpaired_reminders_enabled', 'catchup_reminders_enabled', @@ -30,6 +31,7 @@ export interface ProfileUpdate { onboarding_version?: number; public_key_jwk?: Record | null; push_token?: string | null; + timezone?: string | null; } export interface FeedbackSubmission { diff --git a/apps/api/test/answers.integration.test.ts b/apps/api/test/answers.integration.test.ts index 1d384d5..d5e3cc6 100644 --- a/apps/api/test/answers.integration.test.ts +++ b/apps/api/test/answers.integration.test.ts @@ -5,13 +5,43 @@ import { afterAll,beforeAll,describe,expect,it } from 'vitest'; 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'; -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']){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'])",[q,pack,concurrentQ,femaleOnlyQ]);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();}); +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','0013_daily_limit_local_reset.sql','0014_daily_limit_response_index.sql','0015_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);}); it('is idempotent and creates one match only after both partners answer',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.submit(u1,{questionId:q,answer:'maybe'})).match).toBeNull();const second=await repo.submit(u2,{questionId:q,answer:'yes'});expect(second.match?.match_type).toBe('yes_maybe');await repo.submit(u2,{questionId:q,answer:'yes'});const counts=await pool.query('select (select count(*) from responses where question_id=$1) responses,(select count(*) from matches where question_id=$1) matches',[q]);expect(counts.rows[0]).toMatchObject({responses:'2',matches:'1'});}); it('requires confirmation before destroying a match and isolates outsiders',async()=>{const pending=await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:false});expect(pending).toMatchObject({success:false,requires_confirmation:true,message_count:0});expect(await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:true})).toMatchObject({success:true,match_deleted:true});await expect(repo.archive(outsider,'88888888-8888-4888-8888-888888888888',true)).rejects.toMatchObject({code:'match_not_found'});}); -it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,user1_answered_today:true,user2_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});}); -it('serializes the daily limit across different questions',async()=>{await pool.query('delete from responses where user_id=$1',[u1]);await pool.query('update app_config set daily_response_limit=1');const results=await Promise.allSettled([repo.submit(u1,{questionId:q,answer:'yes'}),repo.submit(u1,{questionId:femaleOnlyQ,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});}); +it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,you_answered_today:true,partner_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});}); +it('answers the streak from the caller side rather than by stored position',async()=>{await pool.query("update profiles set name='Alex' where id=$1",[u2]);await pool.query("update profiles set name='Sam' where id=$1",[u1]);await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);await pool.query("update couple_streaks set current_streak=0,last_active_date=null,last_completed_date=null,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]); + await repo.submit(u1,{questionId:q,answer:'yes'}); + 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});}); + +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'}); + const expected=await pool.query<{day:string}>("select (now() at time zone 'Pacific/Auckland')::date::text as day"); + expect(await repo.streak(u1)).toMatchObject({timezone:'Pacific/Auckland',last_active_date:expected.rows[0].day}); + // The lower member id decides the zone, so an unreported zone there falls through to the partner. + await pool.query('update profiles set timezone=null where id=$1',[u1]); + expect((await repo.streak(u1))?.timezone).toBe('America/Los_Angeles'); + await pool.query("update profiles set timezone='Not/AZone' where id=$1",[u1]); + expect((await repo.streak(u1))?.timezone).toBe('UTC'); + await pool.query('update profiles set timezone=null where couple_id=$1',[couple]);}); + +it('reports a lapsed streak as broken before the next answer rewrites it',async()=>{await pool.query('update profiles set timezone=null where couple_id=$1',[couple]);await pool.query("update couple_streaks set current_streak=5,longest_streak=9,last_active_date=current_date-3,last_completed_date=current_date-3,user1_answered_today=true,user2_answered_today=true where couple_id=$1",[couple]); + expect(await repo.streak(u1)).toMatchObject({current_streak:0,longest_streak:9,you_answered_today:false,partner_answered_today:false}); + // A streak completed yesterday is still alive and still shows its count. + await pool.query('update couple_streaks set last_completed_date=current_date-1,last_active_date=current_date-1 where couple_id=$1',[couple]); + expect(await repo.streak(u1)).toMatchObject({current_streak:5,you_answered_today:false});}); + +const resetLimit=async(limit:number,timezone:string|null=null)=>{await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);await pool.query('update app_config set daily_response_limit=$1',[limit]);await pool.query('update profiles set timezone=$2 where id=$1',[u1,timezone]);}; +it('serializes the daily limit across different questions',async()=>{await resetLimit(1);const results=await Promise.allSettled([repo.submit(u1,{questionId:limitQ1,answer:'yes'}),repo.submit(u1,{questionId:limitQ2,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,remaining:0,is_blocked:true});}); +it('exempts catching up on a question the partner answered first',async()=>{await resetLimit(1);await repo.submit(u2,{questionId:catchUpQ,answer:'yes'});await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});await expect(repo.submit(u1,{questionId:catchUpQ,answer:'yes'})).resolves.toBeTruthy();expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});await expect(repo.submit(u1,{questionId:limitQ2,answer:'yes'})).rejects.toMatchObject({code:'daily_limit'});}); +it('reports the daily limit error with the shape the client renders',async()=>{await resetLimit(1);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});await expect(repo.submit(u1,{questionId:limitQ2,answer:'yes'})).rejects.toMatchObject({code:'daily_limit',status:429,details:{daily_limit:1,responses_today:1,remaining:0}});}); +it('buckets the window on the user local day and falls back to UTC',async()=>{await resetLimit(5,'Pacific/Auckland');const nz=await repo.dailyLimit(u1);const nzBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'Pacific/Auckland')+interval '1 day') at time zone 'Pacific/Auckland') expected");expect(nz.reset_at).toBe(new Date(nzBoundary.rows[0].expected).toISOString()); + const utcBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'UTC')+interval '1 day') at time zone 'UTC') expected"); + for(const zone of [null,'Not/AZone']){await pool.query('update profiles set timezone=$2 where id=$1',[u1,zone]);expect((await repo.dailyLimit(u1)).reset_at).toBe(new Date(utcBoundary.rows[0].expected).toISOString());}}); +it('blocks pack browsing once the limit is reached',async()=>{await resetLimit(1);expect((await repo.recommended(u1,pack)).length).toBeGreaterThan(0);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.recommended(u1,pack)).toEqual([]);expect(await repo.recommended(u1)).toEqual([]);await resetLimit(0);}); }); diff --git a/apps/api/test/answers.routes.test.ts b/apps/api/test/answers.routes.test.ts index 00a26d0..7a98c43 100644 --- a/apps/api/test/answers.routes.test.ts +++ b/apps/api/test/answers.routes.test.ts @@ -15,7 +15,9 @@ function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthId describe('answer routes',()=>{ it('takes ownership only from the bearer identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes',user_id:'attacker'})});expect(r.submit).not.toHaveBeenCalled();const ok=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(ok.status).toBe(200);expect(r.submit).toHaveBeenCalledWith(identity.id,{questionId,answer:'yes',responseData:undefined});}); - 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'));expect((await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})})).status).toBe(429);}); + 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('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 745902f..fff8365 100644 --- a/apps/api/test/api.integration.test.ts +++ b/apps/api/test/api.integration.test.ts @@ -28,7 +28,8 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration', isolatedUrl.searchParams.set('options', `-c search_path=${schema}`); pool = new Pool({ connectionString: isolatedUrl.toString() }); repository = new PostgresRepository(isolatedUrl.toString()); - const migration = await readFile(new URL('../drizzle/0000_identity_and_feature_interests.sql', import.meta.url), 'utf8'); + const migration = (await Promise.all(['0000_identity_and_feature_interests.sql','0013_daily_limit_local_reset.sql'] + .map((name) => readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8')))).join('\n--> statement-breakpoint\n'); for (const statement of migration.split('--> statement-breakpoint')) { if (statement.trim()) await pool.query(statement); } diff --git a/apps/api/test/operations.integration.test.ts b/apps/api/test/operations.integration.test.ts index 0de3ff2..8fcc168 100644 --- a/apps/api/test/operations.integration.test.ts +++ b/apps/api/test/operations.integration.test.ts @@ -12,7 +12,7 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{ const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository; beforeAll(async()=>{ await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.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','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql']){ + 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','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0013_daily_limit_local_reset.sql','0015_couple_streak_locality.sql','0016_streak_reminder_preference.sql']){ const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement); } await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']); @@ -68,6 +68,44 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{ await repo.produce(new Date(Date.now()+10*60*1000),100);expect((await pool.query('select count(*)::int count from operations_outbox')).rows[0].count).toBe(before); }); + it('nudges only the partner who still owes an answer, in the couple evening',async()=>{ + // Alice sorts below Bob, so Alice is user1. She has answered today; Bob has not. + await pool.query("update profiles set timezone='UTC' where couple_id=$1",[couple]); + const evening=new Date('2026-08-27T21:00:00Z'); + await pool.query(`insert into couple_streaks(couple_id,current_streak,longest_streak,last_active_date,last_completed_date,user1_answered_today,user2_answered_today,streak_celebrated_at) + values($1,6,6,'2026-08-27','2026-08-26',true,false,6) on conflict(couple_id) do update set current_streak=6,longest_streak=6, + last_active_date='2026-08-27',last_completed_date='2026-08-26',user1_answered_today=true,user2_answered_today=false,streak_celebrated_at=6`,[couple]); + + expect((await repo.produce(evening,100)).streakReminders).toBe(1); + const queued=await pool.query<{recipient_id:string;title:string;body:string}>( + `select recipient_id,payload->>'title' title,payload->>'body' body from operations_outbox where dedupe_key like 'streak_risk:%'`); + expect(queued.rows).toHaveLength(1); + expect(queued.rows[0]).toMatchObject({recipient_id:bob,title:'Alice answered today'}); + expect(queued.rows[0].body).toContain('6 days in'); + expect((await pool.query('select 1 from operations_outbox where dedupe_key=$1',[`streak_risk:${couple}:2026-08-27:${bob}`])).rowCount).toBe(1); + + // A second tick inside the same couple-local evening must not queue a second nudge. + expect((await repo.produce(evening,100)).streakReminders).toBe(0); + }); + + it('addresses both partners when neither has answered and honours the preference',async()=>{ + const next=new Date('2026-08-28T21:00:00Z'); + await pool.query(`update couple_streaks set last_active_date='2026-08-27',last_completed_date='2026-08-27', + user1_answered_today=true,user2_answered_today=true where couple_id=$1`,[couple]); + expect((await repo.produce(next,100)).streakReminders).toBe(2); + const both=await pool.query<{title:string;body:string}>( + `select payload->>'title' title,payload->>'body' body from operations_outbox where dedupe_key like $1 order by recipient_id`, + [`streak_risk:${couple}:2026-08-28:%`]); + expect(both.rows.map(r=>r.title)).toEqual(['Your 6-day streak','Your 6-day streak']); + expect(both.rows[0].body).toContain('One question each'); + + await pool.query(`insert into notification_preferences(user_id,streak_reminders_enabled) values($1,false),($2,false) + on conflict(user_id) do update set streak_reminders_enabled=false`,[alice,bob]); + await pool.query(`update couple_streaks set last_active_date='2026-08-28',last_completed_date='2026-08-28' where couple_id=$1`,[couple]); + expect((await repo.produce(new Date('2026-08-29T21:00:00Z'),100)).streakReminders).toBe(0); + await pool.query('update notification_preferences set streak_reminders_enabled=true where user_id in ($1,$2)',[alice,bob]); + }); + it('uses skip-locked claims so concurrent workers never receive the same item',async()=>{ await pool.query('insert into notification_preferences(user_id,messages_enabled) values($1,false) on conflict(user_id) do update set messages_enabled=false',[bob]); const [a,b]=await Promise.all([repo.claim(100),repo2.claim(100)]);const ids=[...a,...b].map(item=>item.id);expect(new Set(ids).size).toBe(ids.length);expect(ids.length).toBeGreaterThan(0); diff --git a/apps/api/test/operations.runner.test.ts b/apps/api/test/operations.runner.test.ts index 21c86af..bc31a70 100644 --- a/apps/api/test/operations.runner.test.ts +++ b/apps/api/test/operations.runner.test.ts @@ -5,7 +5,7 @@ import { OperationsRunner } from '../src/domains/operations/runner.js'; import type { OperationItem } from '../src/domains/operations/types.js'; import { loadWorkerConfig } from '../src/workers/config.js'; -const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0}; +const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,streakReminders:0}; function repository(items:OperationItem[]):OperationsRepository { return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined), message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)}; diff --git a/apps/api/test/profile-settings.routes.test.ts b/apps/api/test/profile-settings.routes.test.ts index f62fa74..4f740b3 100644 --- a/apps/api/test/profile-settings.routes.test.ts +++ b/apps/api/test/profile-settings.routes.test.ts @@ -20,6 +20,7 @@ const preferences = { pack_changes_enabled: true, new_packs_enabled: true, streak_milestones_enabled: true, + streak_reminders_enabled: true, weekly_summary_enabled: true, unpaired_reminders_enabled: true, catchup_reminders_enabled: true, diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index 6571a11..85aa3ec 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -4,10 +4,11 @@ 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, useSubscriptionStore } from '../../src/store'; +import { useAuthStore, usePacksStore, useStreakStore, useSubscriptionStore } from '../../src/store'; import { GradientBackground } from '../../src/components/ui'; import { Paywall } from '../../src/components/paywall'; import { CompactHeader, ContentRow, LiveDrawTile } from '../../src/components/discovery'; +import { StreakDisplay } from '../../src/components/StreakDisplay'; import { colors, spacing, typography, radius } from '../../src/theme'; import type { QuestionPack, Category } from '../../src/types'; @@ -68,6 +69,7 @@ function groupPacksByCategory( export default function DiscoveryScreen() { const { user, partner, couple } = useAuthStore(); const { packs, categories, fetchPacks, getPackProgress } = usePacksStore(); + const { fetchStreak } = useStreakStore(); const { subscription } = useSubscriptionStore(); const { width } = useWindowDimensions(); const [showPaywall, setShowPaywall] = useState(false); @@ -81,7 +83,8 @@ export default function DiscoveryScreen() { useFocusEffect( useCallback(() => { fetchPacks(); - }, []) + fetchStreak(); + }, [fetchStreak]) ); // Group packs by category @@ -114,6 +117,11 @@ export default function DiscoveryScreen() { showsVerticalScrollIndicator={false} > + {/* Shared streak - hidden entirely for couples with nothing running */} + + + + {/* Live Draw Activity Tile */} @@ -209,6 +217,9 @@ const styles = StyleSheet.create({ color: colors.textSecondary, textAlign: 'center', }, + streakSlot: { + paddingHorizontal: spacing.lg, + }, bottomSpacer: { height: spacing.lg, }, diff --git a/apps/mobile/src/__tests__/components/StreakDisplay.test.ts b/apps/mobile/src/__tests__/components/StreakDisplay.test.ts new file mode 100644 index 0000000..fd42f87 --- /dev/null +++ b/apps/mobile/src/__tests__/components/StreakDisplay.test.ts @@ -0,0 +1,48 @@ +import { streakStatus } from '@/components/StreakDisplay'; +import type { CoupleStreak } from '@/store/streakStore'; + +const streak = (overrides: Partial): CoupleStreak => ({ + couple_id: 'couple-1', + current_streak: 6, + longest_streak: 9, + last_active_date: '2026-08-28', + last_completed_date: '2026-08-27', + you_answered_today: false, + partner_answered_today: false, + partner_name: 'Alex', + timezone: 'Europe/London', + streak_celebrated_at: 0, + created_at: '2026-08-01T00:00:00.000Z', + updated_at: '2026-08-28T00:00:00.000Z', + ...overrides, +}); + +describe('streakStatus', () => { + it('frames the count as the couple rather than as a personal score', () => { + expect(streakStatus(streak({})).headline).toBe('You and Alex — 6 days in a row'); + expect(streakStatus(streak({ current_streak: 1 })).headline).toBe('You and Alex — 1 day in a row'); + }); + + it('names the partner who moved first so the day has somebody to answer to', () => { + expect(streakStatus(streak({ partner_answered_today: true })).status) + .toBe('Alex has answered today — your turn'); + expect(streakStatus(streak({ you_answered_today: true })).status) + .toBe('Answered. Waiting on Alex'); + }); + + it('settles once both partners have answered', () => { + expect(streakStatus(streak({ you_answered_today: true, partner_answered_today: true })).status) + .toBe("You've both answered today"); + }); + + it('invites rather than warns when the day is still untouched', () => { + expect(streakStatus(streak({})).status).toBe('One question each keeps it going'); + expect(streakStatus(streak({ current_streak: 0 })).status).toBe('Answer together to start a streak'); + }); + + it('falls back to a neutral label when the partner has no name yet', () => { + const anonymous = streak({ partner_name: null, partner_answered_today: true }); + expect(streakStatus(anonymous).headline).toBe('You and your partner — 6 days in a row'); + expect(streakStatus(anonymous).status).toBe('Your partner has answered today — your turn'); + }); +}); diff --git a/apps/mobile/src/__tests__/features/swipe/useSwipeScreen-helpers.test.ts b/apps/mobile/src/__tests__/features/swipe/useSwipeScreen-helpers.test.ts new file mode 100644 index 0000000..794efa1 --- /dev/null +++ b/apps/mobile/src/__tests__/features/swipe/useSwipeScreen-helpers.test.ts @@ -0,0 +1,40 @@ +import { calculateEffectiveTotal } from "@/features/swipe/utils/useSwipeScreen-helpers"; +import type { DailyLimitInfo } from "@/features/swipe/types"; + +const limit = (overrides: Partial = {}): DailyLimitInfo => ({ + responses_today: 0, + limit_value: 10, + remaining: 10, + reset_at: "2026-08-29T00:00:00.000Z", + is_blocked: false, + ...overrides, +}); + +describe("calculateEffectiveTotal", () => { + it("clamps the progress bar to the quota left today", () => { + expect(calculateEffectiveTotal(50, null, limit({ responses_today: 8, remaining: 2 }), 3)).toBe(5); + }); + + it("shows the last card as the last card when one answer remains", () => { + expect(calculateEffectiveTotal(50, null, limit({ responses_today: 9, remaining: 1 }), 7)).toBe(8); + }); + + it("leaves the total alone when the meter is disabled", () => { + expect(calculateEffectiveTotal(50, null, limit({ limit_value: 0, remaining: 0 }), 3)).toBe(50); + expect(calculateEffectiveTotal(50, null, null, 3)).toBe(50); + }); + + it("ignores the quota once blocked, since the limit screen takes over", () => { + expect(calculateEffectiveTotal(50, null, limit({ responses_today: 10, remaining: 0, is_blocked: true }), 3)).toBe(50); + }); + + it("takes whichever of the answer gap and the quota binds first", () => { + const gap = { unanswered: 8, threshold: 10 }; + expect(calculateEffectiveTotal(50, gap, limit({ remaining: 5 }), 2)).toBe(4); + expect(calculateEffectiveTotal(50, gap, limit({ remaining: 1 }), 2)).toBe(3); + }); + + it("never runs fewer questions than the quota allows", () => { + expect(calculateEffectiveTotal(3, null, limit({ remaining: 10 }), 0)).toBe(3); + }); +}); diff --git a/apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts b/apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts new file mode 100644 index 0000000..989dd0e --- /dev/null +++ b/apps/mobile/src/__tests__/features/swipe/useSwipeScreen.test.ts @@ -0,0 +1,168 @@ +import { act, renderHook, waitFor } from "@testing-library/react-native"; + +import { useSwipeScreen } from "@/features/swipe/hooks/useSwipeScreen"; +import { ApiError, apiClient } from "@/lib/apiClient"; +import * as swipeService from "@/features/swipe/services/swipeService"; +import { getSkippedQuestionIds } from "@/lib/skippedQuestions"; + +let mockRouteParams: Record = {}; +const mockFetchStreak = jest.fn(async () => undefined); +jest.mock("expo-router", () => ({ + useLocalSearchParams: () => mockRouteParams, + useFocusEffect: () => undefined, +})); + +// Stable identities: the hook's effects key off these, so fresh objects per render +// would retrigger the fetch effect forever and the screen would never settle. +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 useAuthStore = () => auth; + useAuthStore.getState = () => auth.user; + // 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 }; +}); + +jest.mock("@/lib/skippedQuestions", () => ({ + skipQuestion: jest.fn(), + getSkippedQuestionIds: jest.fn(), +})); + +jest.mock("@/features/swipe/services/swipeService", () => ({ + fetchRecommendedQuestions: jest.fn(), + fetchPendingQuestions: jest.fn(), + fetchAnswerGapStatus: jest.fn(), + fetchDailyLimitStatus: jest.fn(), + fetchPackContext: jest.fn(), + uploadResponseMedia: jest.fn(), +})); + +const questions = [ + { id: "q1", pack_id: "pack-1", question_type: "swipe", text: "One" }, + { id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" }, +]; + +const openLimit = { + responses_today: 4, + limit_value: 10, + remaining: 6, + reset_at: "2026-08-29T00:00:00.000Z", + 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" }, +}); + +// clearMocks wipes implementations between tests, so re-arm the whole surface each time. +beforeEach(() => { + mockRouteParams = {}; + jest.mocked(getSkippedQuestionIds).mockResolvedValue(new Set()); + jest.mocked(swipeService.fetchRecommendedQuestions).mockResolvedValue(questions as never); + jest.mocked(swipeService.fetchAnswerGapStatus).mockResolvedValue({ + unanswered_by_partner: 0, threshold: 10, is_blocked: false, + }); + jest.mocked(swipeService.fetchDailyLimitStatus).mockResolvedValue(openLimit); +}); + +const renderReady = async () => { + const view = renderHook(() => useSwipeScreen()); + await waitFor(() => expect(view.result.current.isLoading).toBe(false)); + await waitFor(() => expect(view.result.current.dailyLimitInfo).not.toBeNull()); + return view; +}; + +describe("useSwipeScreen daily limit", () => { + it("holds the question and shows the wall when the server rejects with 429", async () => { + const { result } = await renderReady(); + expect(result.current.currentIndex).toBe(0); + + jest.spyOn(apiClient, "post").mockRejectedValueOnce(dailyLimit429()); + await act(async () => { + await result.current.handleAnswer("q1", "yes"); + }); + + // Advancing here would silently discard the question the server refused. + expect(result.current.currentIndex).toBe(0); + expect(result.current.dailyLimitInfo).toMatchObject({ + responses_today: 10, + limit_value: 10, + remaining: 0, + reset_at: "2026-08-29T00:00:00.000Z", + is_blocked: true, + }); + }); + + it("still advances past an unrelated submission failure", async () => { + const { result } = await renderReady(); + + jest.spyOn(apiClient, "post").mockRejectedValueOnce(new ApiError("Server error", 500, null)); + await act(async () => { + await result.current.handleAnswer("q1", "yes"); + }); + + expect(result.current.currentIndex).toBe(1); + expect(result.current.dailyLimitInfo?.is_blocked).toBe(false); + }); + + it("still reports the limit while browsing a single pack", async () => { + // Pack browsing used to skip the limit read, which showed "all caught up" + // to a capped user instead of the limit screen. + mockRouteParams = { packId: "pack-1" }; + jest.mocked(swipeService.fetchPackContext).mockResolvedValue({ name: "Pack", icon: "layers" }); + jest.mocked(swipeService.fetchRecommendedQuestions).mockResolvedValue([] as never); + jest.mocked(swipeService.fetchDailyLimitStatus).mockResolvedValue({ + ...openLimit, responses_today: 10, remaining: 0, is_blocked: true, + }); + + const { result } = renderHook(() => useSwipeScreen()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + await waitFor(() => expect(result.current.dailyLimitInfo?.is_blocked).toBe(true)); + }); + + it("counts down the meter optimistically on a successful answer", async () => { + const { result } = await renderReady(); + + jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null }); + jest.mocked(swipeService.fetchDailyLimitStatus) + .mockResolvedValue({ ...openLimit, responses_today: 5, remaining: 5 }); + await act(async () => { + await result.current.handleAnswer("q1", "yes"); + }); + + expect(result.current.currentIndex).toBe(1); + expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 }); + }); + + it("refreshes the shared streak once the answer lands", async () => { + const { result } = await renderReady(); + mockFetchStreak.mockClear(); + + jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null }); + await act(async () => { + await result.current.handleAnswer("q1", "yes"); + }); + + expect(mockFetchStreak).toHaveBeenCalled(); + }); + + it("records the answer even when the streak refresh throws", async () => { + // A refresh that fails outright must not cost the user the answer they just gave. + const { result } = await renderReady(); + mockFetchStreak.mockImplementationOnce(() => { throw new Error("offline"); }); + + jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null }); + jest.mocked(swipeService.fetchDailyLimitStatus) + .mockResolvedValue({ ...openLimit, responses_today: 5, remaining: 5 }); + await act(async () => { + await result.current.handleAnswer("q1", "yes"); + }); + + expect(result.current.currentIndex).toBe(1); + expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 }); + }); +}); diff --git a/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts b/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts index ba78d12..dcc4cbe 100644 --- a/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts +++ b/apps/mobile/src/__tests__/hooks/useDrawingSync.test.ts @@ -1,5 +1,5 @@ import { act, renderHook, waitFor } from '@testing-library/react-native'; -import { useDrawingSync } from '@/features/live-draw/hooks/useDrawingSync'; +import { useDrawingSync, dedupeById, mergeDrawingChanges } from '@/features/live-draw/hooks/useDrawingSync'; import { appDataApi } from '@/lib/appDataApi'; import { ApiError } from '@/lib/apiClient'; @@ -47,3 +47,37 @@ describe('useDrawingSync API polling', () => { expect(onLoadStrokes).toHaveBeenLastCalledWith([stroke, partnerStroke, mine]); }); }); + +describe('useDrawingSync stroke deduplication', () => { + const at = (id: string, points: number, userId = 'u1') => ({ + id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })), + color: '#fff', width: 2, timestamp: 1, isEraser: false, + }); + + it('keeps the most complete copy of a stroke at its original z-position', () => { + // Mid-draw writes persist a truncated copy; touch-end appends the full one. + const out = dedupeById([at('a', 3), at('mine', 5), at('b', 3), at('mine', 40)]); + expect(out.map(s => s.id)).toEqual(['a', 'mine', 'b']); + expect(out[1].points).toHaveLength(40); + }); + + it('leaves a set of unique strokes untouched', () => { + const input = [at('a', 1), at('b', 1)]; + expect(dedupeById(input)).toEqual(input); + }); + + it('never emits duplicate ids after a conflict merge', () => { + const merged = mergeDrawingChanges( + [at('a', 2)], + [at('a', 2), at('mine', 3), at('mine', 30)], + [at('a', 2), at('partner', 4)], + ); + expect(merged.map(s => s.id)).toEqual(['a', 'partner', 'mine']); + expect(merged.find(s => s.id === 'mine')!.points).toHaveLength(30); + }); + + it('still drops strokes removed locally', () => { + const merged = mergeDrawingChanges([at('a', 2), at('gone', 2)], [at('a', 2)], [at('a', 2), at('gone', 2)]); + expect(merged.map(s => s.id)).toEqual(['a']); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/reportedTimezone.test.ts b/apps/mobile/src/__tests__/lib/reportedTimezone.test.ts new file mode 100644 index 0000000..56a41de --- /dev/null +++ b/apps/mobile/src/__tests__/lib/reportedTimezone.test.ts @@ -0,0 +1,67 @@ +import { profileSettingsApi } from "@/lib/profileSettingsApi"; +import { syncTimezone } from "@/lib/reportedTimezone"; + +jest.mock("@/lib/profileSettingsApi", () => ({ + profileSettingsApi: { updateProfile: jest.fn() }, +})); + +jest.mock("expo-secure-store", () => { + const store = new Map(); + return { + __store: store, + getItemAsync: jest.fn(async (key: string) => store.get(key) ?? null), + setItemAsync: jest.fn(async (key: string, value: string) => { store.set(key, value); }), + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const secureStore = require("expo-secure-store") as { __store: Map }; + +const withZone = (zone: string | undefined) => { + jest.spyOn(Intl, "DateTimeFormat").mockReturnValue({ + resolvedOptions: () => ({ timeZone: zone }), + } as never); +}; + +describe("syncTimezone", () => { + beforeEach(() => { + secureStore.__store.clear(); + jest.mocked(profileSettingsApi.updateProfile).mockResolvedValue({ updated: true }); + }); + + it("reports the device zone once and not again until it changes", async () => { + withZone("Europe/London"); + await syncTimezone("user-1"); + await syncTimezone("user-1"); + + expect(profileSettingsApi.updateProfile).toHaveBeenCalledTimes(1); + expect(profileSettingsApi.updateProfile).toHaveBeenCalledWith({ timezone: "Europe/London" }); + + withZone("Pacific/Auckland"); + await syncTimezone("user-1"); + expect(profileSettingsApi.updateProfile).toHaveBeenNthCalledWith(2, { timezone: "Pacific/Auckland" }); + }); + + it("tracks zones per user, so a shared device does not skip the second account", async () => { + withZone("Europe/London"); + await syncTimezone("user-1"); + await syncTimezone("user-2"); + expect(profileSettingsApi.updateProfile).toHaveBeenCalledTimes(2); + }); + + it("does nothing when the platform cannot resolve a zone", async () => { + withZone(undefined); + await syncTimezone("user-1"); + expect(profileSettingsApi.updateProfile).not.toHaveBeenCalled(); + }); + + it("retries on the next launch when the report fails", async () => { + withZone("Europe/London"); + jest.mocked(profileSettingsApi.updateProfile).mockRejectedValueOnce(new Error("offline")); + await syncTimezone("user-1"); + + jest.mocked(profileSettingsApi.updateProfile).mockResolvedValue({ updated: true }); + await syncTimezone("user-1"); + expect(profileSettingsApi.updateProfile).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/mobile/src/__tests__/stores/notificationPreferencesStore.test.ts b/apps/mobile/src/__tests__/stores/notificationPreferencesStore.test.ts index 933f859..ce3f66b 100644 --- a/apps/mobile/src/__tests__/stores/notificationPreferencesStore.test.ts +++ b/apps/mobile/src/__tests__/stores/notificationPreferencesStore.test.ts @@ -21,6 +21,7 @@ const preferences = { weekly_summary_enabled: true, unpaired_reminders_enabled: true, catchup_reminders_enabled: true, + streak_reminders_enabled: true, created_at: '2026-08-27T10:00:00.000Z', updated_at: '2026-08-27T10:00:00.000Z', }; diff --git a/apps/mobile/src/components/StreakDisplay.tsx b/apps/mobile/src/components/StreakDisplay.tsx index 7d0bc44..fd2a63d 100644 --- a/apps/mobile/src/components/StreakDisplay.tsx +++ b/apps/mobile/src/components/StreakDisplay.tsx @@ -2,9 +2,9 @@ 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 { LinearGradient } from 'expo-linear-gradient'; import { useStreakStore } from '../store'; -import { colors, spacing, radius, typography } from '../theme'; +import type { CoupleStreak } from '../store'; +import { colors, spacing, radius, typography, shadows } from '../theme'; interface StreakDisplayProps { /** Whether to show the "longest streak" indicator */ @@ -16,11 +16,34 @@ interface StreakDisplayProps { } /** - * Displays the couple's current streak with a flame icon. - * The flame pulses when there's an active streak. + * 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, isLoading, fetchStreak } = useStreakStore(); + const { streak, fetchStreak } = useStreakStore(); // Fetch streak on mount useEffect(() => { @@ -29,9 +52,10 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false // Flame pulse animation const scale = useSharedValue(1); + const hasStreak = (streak?.current_streak ?? 0) > 0; useEffect(() => { - if (streak && streak.current_streak > 0) { + if (hasStreak) { scale.value = withRepeat( withSequence( withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }), @@ -43,25 +67,32 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false } else { scale.value = 1; } - }, [streak?.current_streak, scale]); + }, [hasStreak, scale]); const flameStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], })); - // Don't show if loading or no couple - if (isLoading || !streak) { + if (!streak) { return null; } - const hasStreak = streak.current_streak > 0; + // 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 ( - + - {/* Main streak display */} - + - {/* Flame icon */} - {/* Streak info */} - - {currentStreak} + + {headline} - - day{currentStreak !== 1 ? 's' : ''} together + + {status} - {/* Longest streak */} {showLongest && longestStreak > 0 && ( - Best: {longestStreak} day{longestStreak !== 1 ? 's' : ''} + Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''} )} - + ); @@ -128,10 +150,14 @@ const styles = StyleSheet.create({ marginVertical: spacing.sm, }, card: { + backgroundColor: colors.surface, borderRadius: radius.lg, padding: spacing.md, borderWidth: 1, - borderColor: colors.glass.border, + borderColor: colors.border, + }, + cardAwaiting: { + borderColor: colors.warning, }, content: { flexDirection: 'row', @@ -139,23 +165,27 @@ const styles = StyleSheet.create({ gap: spacing.md, }, iconContainer: { - width: 48, - height: 48, + width: 44, + height: 44, borderRadius: radius.full, - backgroundColor: 'rgba(243, 156, 18, 0.15)', + backgroundColor: colors.warningLight, justifyContent: 'center', alignItems: 'center', }, info: { flex: 1, }, - count: { - ...typography.title1, + headline: { + ...typography.headline, color: colors.text, }, - label: { + status: { ...typography.subhead, color: colors.textSecondary, + marginTop: 2, + }, + statusAwaiting: { + color: colors.warning, }, inactiveText: { color: colors.textTertiary, @@ -167,7 +197,7 @@ const styles = StyleSheet.create({ marginTop: spacing.sm, paddingTop: spacing.sm, borderTopWidth: 1, - borderTopColor: colors.glass.border, + borderTopColor: colors.border, }, longestText: { ...typography.caption1, @@ -178,11 +208,15 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: spacing.xs, - backgroundColor: 'rgba(243, 156, 18, 0.15)', + backgroundColor: colors.warningLight, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, borderRadius: radius.full, }, + compactAwaiting: { + borderWidth: 1, + borderColor: colors.warning, + }, compactCount: { ...typography.caption1, fontWeight: '600', diff --git a/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx b/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx index dcdbc74..38404f3 100644 --- a/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx +++ b/apps/mobile/src/features/live-draw/LiveDrawScreen.tsx @@ -44,8 +44,9 @@ export const LiveDrawScreen: React.FC = () => { const [shareDrawingUri, setShareDrawingUri] = useState(null); const canvasRef = useRef(null); - // Keep track of the current stroke id for sync broadcasts - const currentStrokeIdRef = useRef(null); + // The stroke under the finger. Points are pushed in place — nothing about the + // in-progress stroke goes through React state until it is finished. + const currentStrokeRef = useRef(null); const canvasWidth = screenWidth - spacing.md * 2; const canvasHeight = canvasWidth * 1.2; @@ -64,6 +65,16 @@ export const LiveDrawScreen: React.FC = () => { const { makeSnapshot, captureToFile } = useCanvasCapture(); + // Mirrors of state read inside the touch handlers. Keeping them in refs means + // the handlers (and therefore the pan gesture) keep a stable identity instead + // of being rebuilt mid-stroke on every render. + const strokesRef = useRef(strokes); + strokesRef.current = strokes; + const strokeStyleRef = useRef({ color: selectedColor, width: brushWidth, isEraser }); + strokeStyleRef.current = { color: selectedColor, width: brushWidth, isEraser }; + const userIdRef = useRef(user?.id); + userIdRef.current = user?.id; + const handleStrokeStartFromPartner = useCallback( (stroke: StrokeSegment) => addStroke(stroke), [addStroke] @@ -95,57 +106,66 @@ export const LiveDrawScreen: React.FC = () => { const handleTouchStart = useCallback( (point: StrokePoint) => { - if (!user?.id) return; - if (strokes.length >= MAX_STROKES) { + const userId = userIdRef.current; + // The gesture starts the live path on the UI thread before we get here, + // so a refused stroke has to be wiped rather than simply not started. + if (!userId) { + canvasRef.current?.clearLiveStroke(); + return; + } + if (strokesRef.current.length >= MAX_STROKES) { + canvasRef.current?.clearLiveStroke(); Alert.alert('Canvas Full', 'Save your drawing and start fresh.'); return; } + const style = strokeStyleRef.current; const stroke: StrokeSegment = { - id: `${user.id}_${Date.now()}`, - userId: user.id, + id: `${userId}_${Date.now()}`, + userId, points: [point], - color: selectedColor, - width: brushWidth, + color: style.color, + width: style.width, timestamp: Date.now(), - isEraser, + isEraser: style.isEraser, }; - currentStrokeIdRef.current = stroke.id; - canvasRef.current?.startStroke(stroke); - sync.broadcastStrokeStart(stroke); + currentStrokeRef.current = stroke; + sync.beginLocalStroke(stroke); }, - [user?.id, selectedColor, brushWidth, isEraser, strokes.length, sync] + [sync] ); const handleTouchMove = useCallback( (point: StrokePoint) => { - if (!currentStrokeIdRef.current) return; - canvasRef.current?.addPoint(point); - sync.broadcastStrokeContinue(currentStrokeIdRef.current, [point]); + const stroke = currentStrokeRef.current; + if (!stroke) return; + stroke.points.push(point); + sync.appendLocalPoint(point); }, [sync] ); const handleTouchEnd = useCallback(() => { - if (!currentStrokeIdRef.current) return; - const finished = canvasRef.current?.endStroke() ?? null; - currentStrokeIdRef.current = null; - if (finished) { - addStroke(finished); - sync.broadcastStrokeEnd(finished.id); - sync.persistStrokes([...strokes, finished]); - } - }, [addStroke, strokes, sync]); + const finished = currentStrokeRef.current; + currentStrokeRef.current = null; + if (!finished) return; + addStroke(finished); + void sync.endLocalStroke(finished); + // Drop the UI-thread copy only after React has painted the committed + // stroke, otherwise the line blinks out for a frame. + requestAnimationFrame(() => canvasRef.current?.clearLiveStroke()); + }, [addStroke, sync]); const handleUndo = useCallback(() => { if (!user?.id) return; const removed = undo(user.id); if (removed) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + canvasRef.current?.clearLiveStroke(); sync.broadcastUndo(removed.id); - sync.persistStrokes(strokes.filter((s) => s.id !== removed.id)); + void sync.persistStrokes(strokesRef.current.filter((s) => s.id !== removed.id)); } - }, [user?.id, undo, strokes, sync]); + }, [user?.id, undo, sync]); const handleRedo = useCallback(() => { if (!user?.id) return; @@ -153,9 +173,9 @@ export const LiveDrawScreen: React.FC = () => { if (restored) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); sync.broadcastRedo(restored); - sync.persistStrokes([...strokes, restored]); + void sync.persistStrokes([...strokesRef.current, restored]); } - }, [user?.id, redo, strokes, sync]); + }, [user?.id, redo, sync]); const handleClear = useCallback(() => { Alert.alert('Clear Canvas', 'This will erase the entire drawing.', [ @@ -165,8 +185,9 @@ export const LiveDrawScreen: React.FC = () => { style: 'destructive', onPress: () => { clearAll(); + canvasRef.current?.clearLiveStroke(); sync.broadcastClearCanvas(); - sync.persistStrokes([]); + void sync.persistStrokes([]); }, }, ]); @@ -203,6 +224,9 @@ export const LiveDrawScreen: React.FC = () => { strokes={strokes} canvasWidth={canvasWidth} canvasHeight={canvasHeight} + strokeColor={selectedColor} + strokeWidth={brushWidth} + isEraser={isEraser} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} diff --git a/apps/mobile/src/features/live-draw/components/DrawingCanvas.tsx b/apps/mobile/src/features/live-draw/components/DrawingCanvas.tsx index 1290caf..1557277 100644 --- a/apps/mobile/src/features/live-draw/components/DrawingCanvas.tsx +++ b/apps/mobile/src/features/live-draw/components/DrawingCanvas.tsx @@ -1,26 +1,28 @@ -import React, { useMemo, useRef, useEffect, useCallback, useState, useImperativeHandle, forwardRef } from 'react'; +import React, { useMemo, useRef, useEffect, useImperativeHandle, forwardRef } from 'react'; import { StyleSheet, View } from 'react-native'; import { Canvas, Path, Skia, } from '@shopify/react-native-skia'; -import type { SkImage } from '@shopify/react-native-skia'; +import type { SkImage, SkPath } from '@shopify/react-native-skia'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import { runOnJS } from 'react-native-reanimated'; +import { runOnJS, useDerivedValue, useSharedValue } from 'react-native-reanimated'; import type { StrokeSegment, StrokePoint } from '../types'; import { CANVAS_BACKGROUND } from '../constants'; export interface DrawingCanvasHandle { - startStroke: (stroke: StrokeSegment) => void; - addPoint: (point: StrokePoint) => void; - endStroke: () => StrokeSegment | null; + /** Drops the in-progress stroke rendered on the UI thread. */ + clearLiveStroke: () => void; } interface DrawingCanvasProps { strokes: StrokeSegment[]; canvasWidth: number; canvasHeight: number; + strokeColor: string; + strokeWidth: number; + isEraser: boolean; onTouchStart: (point: StrokePoint) => void; onTouchMove: (point: StrokePoint) => void; onTouchEnd: () => void; @@ -85,41 +87,15 @@ const StrokePath = React.memo(function StrokePath({ ); }); -// Renders the in-progress stroke from a ref, re-rendered only via rafTick -const CurrentStrokePath = React.memo(function CurrentStrokePath({ - strokeRef, - canvasWidth, - canvasHeight, - _tick, -}: { - strokeRef: React.RefObject; - canvasWidth: number; - canvasHeight: number; - _tick: number; // forces re-render at RAF cadence -}) { - const stroke = strokeRef.current; - if (!stroke) return null; - - const path = buildPath(stroke.points, canvasWidth, canvasHeight); - - return ( - - ); -}); - export const DrawingCanvas = forwardRef( function DrawingCanvas( { strokes, canvasWidth, canvasHeight, + strokeColor, + strokeWidth, + isEraser, onTouchStart, onTouchMove, onTouchEnd, @@ -128,9 +104,23 @@ export const DrawingCanvas = forwardRef ref ) { const canvasRef = useRef(null); - const currentStrokeRef = useRef(null); - const rafScheduled = useRef(false); - const [rafTick, setRafTick] = useState(0); + + // The in-progress stroke lives entirely on the UI thread: the pan gesture + // extends the path in place, so ink never waits on the JS thread. + const livePath = useSharedValue(null); + const livePrev = useSharedValue(null); + // Doubles as the invalidation signal: the path is mutated in place, so this + // counter is what tells the derived value to re-read it. + const livePointCount = useSharedValue(0); + + const liveColor = isEraser ? CANVAS_BACKGROUND : strokeColor; + + // Re-derived once per frame that the path changed — not once per point. + const renderedLivePath = useDerivedValue(() => { + const count = livePointCount.value; + const path = livePath.value; + return count > 0 && path ? path.copy() : Skia.Path.Make(); + }); useEffect(() => { if (onCanvasReady) { @@ -140,52 +130,67 @@ export const DrawingCanvas = forwardRef } }, [onCanvasReady]); - const scheduleRafUpdate = useCallback(() => { - if (rafScheduled.current) return; - rafScheduled.current = true; - requestAnimationFrame(() => { - rafScheduled.current = false; - setRafTick((t) => t + 1); - }); - }, []); - useImperativeHandle(ref, () => ({ - startStroke(stroke: StrokeSegment) { - currentStrokeRef.current = stroke; - scheduleRafUpdate(); + clearLiveStroke() { + livePath.value = null; + livePrev.value = null; + livePointCount.value = 0; }, - addPoint(point: StrokePoint) { - const s = currentStrokeRef.current; - if (!s) return; - // Mutate in place — no React state involved - s.points.push(point); - scheduleRafUpdate(); - }, - endStroke(): StrokeSegment | null { - const s = currentStrokeRef.current; - currentStrokeRef.current = null; - scheduleRafUpdate(); - return s; - }, - }), [scheduleRafUpdate]); + }), [livePath, livePrev, livePointCount]); - const pan = Gesture.Pan() - .minDistance(0) - .onStart((e) => { - runOnJS(onTouchStart)({ - x: e.x / canvasWidth, - y: e.y / canvasHeight, - }); - }) - .onUpdate((e) => { - runOnJS(onTouchMove)({ - x: e.x / canvasWidth, - y: e.y / canvasHeight, - }); - }) - .onEnd(() => { - runOnJS(onTouchEnd)(); - }); + const pan = useMemo( + () => + Gesture.Pan() + .minDistance(0) + .onStart((e) => { + 'worklet'; + const path = Skia.Path.Make(); + path.moveTo(e.x, e.y); + livePath.value = path; + livePrev.value = { x: e.x, y: e.y }; + livePointCount.value = 1; + runOnJS(onTouchStart)({ x: e.x / canvasWidth, y: e.y / canvasHeight }); + }) + .onUpdate((e) => { + 'worklet'; + const path = livePath.value; + const prev = livePrev.value; + if (path && prev) { + // Same quadratic smoothing as buildPath, applied incrementally. + const midX = (prev.x + e.x) / 2; + const midY = (prev.y + e.y) / 2; + if (livePointCount.value === 1) { + path.lineTo(midX, midY); + } else { + path.quadTo(prev.x, prev.y, midX, midY); + } + livePrev.value = { x: e.x, y: e.y }; + livePointCount.value += 1; + } + runOnJS(onTouchMove)({ x: e.x / canvasWidth, y: e.y / canvasHeight }); + }) + .onEnd(() => { + 'worklet'; + const path = livePath.value; + const prev = livePrev.value; + if (path && prev) { + // Close the half-segment the incremental midpoints leave at the tip. + path.lineTo(prev.x, prev.y); + livePointCount.value += 1; + } + runOnJS(onTouchEnd)(); + }), + [ + canvasWidth, + canvasHeight, + onTouchStart, + onTouchMove, + onTouchEnd, + livePath, + livePrev, + livePointCount, + ] + ); return ( @@ -199,11 +204,13 @@ export const DrawingCanvas = forwardRef canvasHeight={canvasHeight} /> ))} - diff --git a/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts b/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts index ae98e7c..762b342 100644 --- a/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts +++ b/apps/mobile/src/features/live-draw/hooks/useDrawingSync.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { ApiError } from '../../../lib/apiClient'; import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi'; import type { StrokeSegment, StrokePoint } from '../types'; @@ -17,9 +17,9 @@ interface UseDrawingSyncConfig { } interface UseDrawingSyncReturn { - broadcastStrokeStart: (stroke: StrokeSegment) => void; - broadcastStrokeContinue: (strokeId: string, points: StrokePoint[]) => void; - broadcastStrokeEnd: (strokeId: string) => void; + beginLocalStroke: (stroke: StrokeSegment) => void; + appendLocalPoint: (point: StrokePoint) => void; + endLocalStroke: (stroke: StrokeSegment) => Promise; broadcastClearCanvas: () => void; broadcastUndo: (strokeId: string) => void; broadcastRedo: (stroke: StrokeSegment) => void; @@ -32,6 +32,14 @@ function conflictState(error: unknown): LiveDrawState | null { return state && Array.isArray(state.strokes) && Number.isInteger(state.revision) ? state : null; } +// Keeps the position of the first occurrence (stable z-order) but the value of +// the last (the most complete copy of a stroke that was persisted mid-draw). +export function dedupeById(strokes: StrokeSegment[]): StrokeSegment[] { + const byId = new Map(); + for (const stroke of strokes) byId.set(stroke.id, stroke); + return [...byId.values()]; +} + export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegment[], current: StrokeSegment[]): StrokeSegment[] { const desiredById = new Map(desired.map(stroke => [stroke.id, stroke])); const desiredIds = new Set(desiredById.keys()); @@ -40,7 +48,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen const merged = current .filter(stroke => !removedIds.has(stroke.id)) .map(stroke => desiredById.get(stroke.id) ?? stroke); - return [...merged, ...desired.filter(stroke => !currentIds.has(stroke.id))]; + return dedupeById([...merged, ...desired.filter(stroke => !currentIds.has(stroke.id))]); } export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn { @@ -50,13 +58,38 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu const workingRef = useRef([]); const writeQueueRef = useRef>(Promise.resolve()); const pendingWritesRef = useRef(0); - const throttleRef = useRef(null); + const throttleRef = useRef | null>(null); + // The stroke currently under the finger. Owned here and mutated in place so a + // touch move costs a push, not a rebuild of every stroke on the canvas. + const localStrokeRef = useRef(null); const onLoadStrokesRef = useRef(onLoadStrokes); onLoadStrokesRef.current = onLoadStrokes; + // workingRef never holds the in-progress stroke; compose it in only when we + // are about to send, so the O(n) copy happens once per write, not per point. + const composeWorking = useCallback((): StrokeSegment[] => { + const local = localStrokeRef.current; + if (!local) return workingRef.current; + return dedupeById([ + ...workingRef.current, + { ...local, points: [...local.points] }, + ]); + }, []); + const acceptState = useCallback((state: LiveDrawState) => { revisionRef.current = state.revision; baselineRef.current = state.strokes; + + const local = localStrokeRef.current; + if (local) { + // Mid-stroke: adopt the remote strokes for the next write, but do NOT + // push them into React state. The server's copy of the stroke under the + // finger is always behind it, and handing it to setStrokes is what makes + // the line snap backwards while drawing. + workingRef.current = state.strokes.filter(stroke => stroke.id !== local.id); + return; + } + workingRef.current = state.strokes; onLoadStrokesRef.current(state.strokes); }, []); @@ -71,26 +104,27 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu const merged = mergeDrawingChanges(base, desired, current.strokes); acceptState(await appDataApi.putLiveDraw(merged, current.revision)); } - updateWidget(workingRef.current); - }, [acceptState]); + updateWidget(composeWorking()); + }, [acceptState, composeWorking]); const enqueueWrite = useCallback((strokes: StrokeSegment[]): Promise => { - workingRef.current = strokes; + const desired = dedupeById(strokes); + workingRef.current = desired.filter(stroke => stroke.id !== localStrokeRef.current?.id); pendingWritesRef.current += 1; - const next = writeQueueRef.current.then(() => writeSnapshot(strokes)); + const next = writeQueueRef.current.then(() => writeSnapshot(desired)); writeQueueRef.current = next.catch(error => { console.error('Failed to persist live drawing:', error); }); return next.finally(() => { pendingWritesRef.current -= 1; }); }, [writeSnapshot]); - const scheduleWrite = useCallback((strokes: StrokeSegment[]) => { - workingRef.current = strokes; - if (!throttleRef.current) { - throttleRef.current = setTimeout(() => { - throttleRef.current = null; - void enqueueWrite(workingRef.current); - }, 250); - } - }, [enqueueWrite]); + // Throttled: reads the latest state at fire time rather than capturing a + // freshly built array on every call. + const scheduleWrite = useCallback(() => { + if (throttleRef.current) return; + throttleRef.current = setTimeout(() => { + throttleRef.current = null; + void enqueueWrite(composeWorking()); + }, 250); + }, [enqueueWrite, composeWorking]); useEffect(() => { if (!coupleId) return; @@ -112,21 +146,45 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); }; }, [coupleId, userId, acceptState]); - const broadcastStrokeStart = useCallback((stroke: StrokeSegment) => { - scheduleWrite([...workingRef.current.filter(item => item.id !== stroke.id), stroke]); - }, [scheduleWrite]); - const broadcastStrokeContinue = useCallback((strokeId: string, points: StrokePoint[]) => { - scheduleWrite(workingRef.current.map(stroke => stroke.id === strokeId ? { ...stroke, points: [...stroke.points, ...points] } : stroke)); - }, [scheduleWrite]); - const broadcastStrokeEnd = useCallback((_strokeId: string) => undefined, []); - const broadcastClearCanvas = useCallback(() => { workingRef.current = []; }, []); - const broadcastUndo = useCallback((strokeId: string) => { workingRef.current = workingRef.current.filter(stroke => stroke.id !== strokeId); }, []); - const broadcastRedo = useCallback((stroke: StrokeSegment) => { workingRef.current = [...workingRef.current.filter(item => item.id !== stroke.id), stroke]; }, []); const persistStrokes = useCallback((strokes: StrokeSegment[]) => { if (throttleRef.current) clearTimeout(throttleRef.current); throttleRef.current = null; return enqueueWrite(strokes); }, [enqueueWrite]); - return { broadcastStrokeStart, broadcastStrokeContinue, broadcastStrokeEnd, broadcastClearCanvas, broadcastUndo, broadcastRedo, persistStrokes }; + const beginLocalStroke = useCallback((stroke: StrokeSegment) => { + localStrokeRef.current = { ...stroke, points: [...stroke.points] }; + workingRef.current = workingRef.current.filter(item => item.id !== stroke.id); + scheduleWrite(); + }, [scheduleWrite]); + + const appendLocalPoint = useCallback((point: StrokePoint) => { + localStrokeRef.current?.points.push(point); + scheduleWrite(); + }, [scheduleWrite]); + + const endLocalStroke = useCallback((stroke: StrokeSegment) => { + localStrokeRef.current = null; + // dedupeById drops the truncated copy the mid-draw writes left behind. + return persistStrokes([...workingRef.current, stroke]); + }, [persistStrokes]); + + const broadcastClearCanvas = useCallback(() => { + localStrokeRef.current = null; + workingRef.current = []; + }, []); + const broadcastUndo = useCallback((strokeId: string) => { workingRef.current = workingRef.current.filter(stroke => stroke.id !== strokeId); }, []); + const broadcastRedo = useCallback((stroke: StrokeSegment) => { workingRef.current = dedupeById([...workingRef.current, stroke]); }, []); + + // Stable identity: the screen's touch handlers depend on this object, and a + // new one every render rebuilt the pan gesture mid-stroke. + return useMemo(() => ({ + beginLocalStroke, + appendLocalPoint, + endLocalStroke, + broadcastClearCanvas, + broadcastUndo, + broadcastRedo, + persistStrokes, + }), [beginLocalStroke, appendLocalPoint, endLocalStroke, broadcastClearCanvas, broadcastUndo, broadcastRedo, persistStrokes]); } diff --git a/apps/mobile/src/features/profile/components/NotificationSettings.tsx b/apps/mobile/src/features/profile/components/NotificationSettings.tsx index d8ead05..19eb392 100644 --- a/apps/mobile/src/features/profile/components/NotificationSettings.tsx +++ b/apps/mobile/src/features/profile/components/NotificationSettings.tsx @@ -37,6 +37,7 @@ export const NotificationSettings: React.FC = ({ onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value), onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value), onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value), + onStreakRemindersToggle: (value: boolean) => updatePreference('streak_reminders_enabled', value), onWeeklySummaryToggle: (value: boolean) => updatePreference('weekly_summary_enabled', value), onUnpairedRemindersToggle: (value: boolean) => updatePreference('unpaired_reminders_enabled', value), onCatchupRemindersToggle: (value: boolean) => updatePreference('catchup_reminders_enabled', value), @@ -138,6 +139,21 @@ export const NotificationSettings: React.FC = ({ + {partner && ( + <> + + + + + )} + { packContext={packContext} showBackButton={!!packId || mode === 'pending'} onBack={() => router.back()} + dailyLimitInfo={dailyLimitInfo} /> { const accent = colors.premium.gold; + // The meter exists to convert; without these the funnel is unmeasurable. + useEffect(() => { + Events.paywallShown('daily_limit'); + }, []); + return ( @@ -59,7 +66,7 @@ export const SwipeDailyLimitState = ({ - You've answered {dailyLimitInfo.limit_value} questions today!{'\n'} + You've answered {dailyLimitInfo.responses_today} questions today!{'\n'} Let the anticipation build while you wait. @@ -100,8 +107,14 @@ export const SwipeDailyLimitState = ({ { + Events.paywallClosed('daily_limit', 'dismissed'); + onClosePaywall(); + }} + onSuccess={() => { + Events.paywallClosed('daily_limit', 'purchased'); + onPaywallSuccess(); + }} /> ); diff --git a/apps/mobile/src/features/swipe/components/SwipeHeader.tsx b/apps/mobile/src/features/swipe/components/SwipeHeader.tsx index fdddf5e..01170c1 100644 --- a/apps/mobile/src/features/swipe/components/SwipeHeader.tsx +++ b/apps/mobile/src/features/swipe/components/SwipeHeader.tsx @@ -4,7 +4,12 @@ import Animated, { FadeIn } from "react-native-reanimated"; import { LinearGradient } from "expo-linear-gradient"; import { colors, spacing, typography } from "../../../theme"; +import { StreakDisplay } from "../../../components/StreakDisplay"; import { useProgressShimmer } from "../hooks/useProgressShimmer"; +import type { DailyLimitInfo } from "../types"; + +/** Remaining answers at or below this switch the meter to its "running out" treatment. */ +const SCARCITY_THRESHOLD = 3; interface SwipeHeaderProps { currentIndex: number; @@ -14,6 +19,7 @@ interface SwipeHeaderProps { packContext: { name: string } | null; showBackButton: boolean; onBack: () => void; + dailyLimitInfo?: DailyLimitInfo | null; } export const SwipeHeader = ({ @@ -24,11 +30,20 @@ export const SwipeHeader = ({ packContext, showBackButton, onBack, + dailyLimitInfo, }: SwipeHeaderProps) => { const shimmerStyle = useProgressShimmer(); const total = effectiveTotal || totalQuestions; const progressPercent = total > 0 ? ((currentIndex + 1) / total) * 100 : 0; + // Catch-up answers are exempt from the cap, so the meter is hidden in pending mode. + const showMeter = mode !== 'pending' + && !!dailyLimitInfo + && dailyLimitInfo.limit_value > 0 + && !dailyLimitInfo.is_blocked; + const remaining = dailyLimitInfo?.remaining ?? 0; + const isRunningOut = showMeter && remaining <= SCARCITY_THRESHOLD; + return ( {currentIndex + 1} of {total} + {showMeter && ( + + {isRunningOut && ( + + )} + + {remaining === 1 ? '1 left today' : `${remaining} left today`} + + + )} + {/* The shared streak sits opposite the back button so the centred + progress column keeps its position. */} + + + ); @@ -87,6 +117,10 @@ const styles = StyleSheet.create({ left: 0, padding: spacing.sm, }, + streakBadge: { + position: 'absolute', + right: 0, + }, progressContainer: { alignItems: "center", }, @@ -100,8 +134,31 @@ const styles = StyleSheet.create({ progressText: { ...typography.subhead, color: colors.textSecondary, + marginBottom: spacing.xs, + }, + meter: { + flexDirection: 'row', + alignItems: 'center', marginBottom: spacing.sm, }, + meterUrgent: { + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: 999, + backgroundColor: 'rgba(212, 175, 55, 0.1)', + borderWidth: 1, + borderColor: 'rgba(212, 175, 55, 0.2)', + }, + meterText: { + ...typography.caption2, + letterSpacing: 0.5, + color: colors.textTertiary, + }, + meterTextUrgent: { + color: colors.premium.gold, + fontWeight: '600', + marginLeft: spacing.xs, + }, progressBar: { width: 160, height: 6, diff --git a/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts b/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts index 0a76f0d..3d7bc8b 100644 --- a/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts +++ b/apps/mobile/src/features/swipe/hooks/useSwipeScreen.ts @@ -1,9 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useFocusEffect, useLocalSearchParams } from "expo-router"; -import { useAuthStore, usePacksStore } from "../../../store"; +import { useAuthStore, usePacksStore, useStreakStore } from "../../../store"; import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions"; -import { apiClient } from "../../../lib/apiClient"; +import { ApiError, apiClient } from "../../../lib/apiClient"; import { Events } from "../../../lib/analytics"; import type { AnswerType } from "../../../types"; import type { DailyLimitInfo, PackInfo, ResponseData } from "../types"; @@ -128,8 +128,16 @@ export const useSwipeScreen = () => { filtered = data; } - if (partner && !packId) { - await Promise.all([checkAnswerGap(), checkDailyLimit()]); + if (partner) { + // The daily limit applies to pack browsing too, so it is read in both + // modes; the answer gap is deliberately only enforced outside packs. + if (packId) { + setIsBlocked(false); + setGapInfo(null); + await checkDailyLimit(); + } else { + await Promise.all([checkAnswerGap(), checkDailyLimit()]); + } } else { setIsBlocked(false); setGapInfo(null); @@ -334,8 +342,40 @@ export const useSwipeScreen = () => { } await Promise.all([checkAnswerGap(), checkDailyLimit()]); + } else { + // Catch-up answers are exempt from the cap, but the partner may have + // answered after this screen loaded, so re-read rather than assume. + await checkDailyLimit(); } + + // The shared streak turns on today's answers, so a stale badge would show the + // 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); } 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. + if (error instanceof ApiError && error.status === 429) { + // ApiError.details carries the whole response body: { error, details }. + const body = (error.details ?? {}) as { + details?: { + daily_limit?: number; + responses_today?: number; + remaining?: number; + reset_at?: string; + }; + }; + const details = body.details ?? {}; + setDailyLimitInfo(prev => ({ + responses_today: details.responses_today ?? prev?.responses_today ?? 0, + limit_value: details.daily_limit ?? prev?.limit_value ?? 0, + remaining: details.remaining ?? 0, + reset_at: details.reset_at ?? prev?.reset_at ?? '', + is_blocked: true, + })); + return; + } + console.error("Failed to submit response", error); setCurrentIndex(prev => prev + 1); } diff --git a/apps/mobile/src/lib/profileSettingsApi.ts b/apps/mobile/src/lib/profileSettingsApi.ts index e58d620..4d7e5b4 100644 --- a/apps/mobile/src/lib/profileSettingsApi.ts +++ b/apps/mobile/src/lib/profileSettingsApi.ts @@ -13,6 +13,7 @@ export interface ProfileUpdate { onboarding_version?: number; public_key_jwk?: Record | null; push_token?: string | null; + timezone?: string | null; } type PreferenceKey = keyof Omit; diff --git a/apps/mobile/src/lib/reportedTimezone.ts b/apps/mobile/src/lib/reportedTimezone.ts new file mode 100644 index 0000000..0737bc7 --- /dev/null +++ b/apps/mobile/src/lib/reportedTimezone.ts @@ -0,0 +1,68 @@ +import * as SecureStore from "expo-secure-store"; +import { Platform } from "react-native"; + +import { profileSettingsApi } from "./profileSettingsApi"; + +const STORAGE_KEY_PREFIX = "reported_timezone"; + +const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`; + +/** The IANA zone this device is in, or null when the platform can't resolve one. */ +export function resolveDeviceTimezone(): string | null { + try { + const zone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return zone && zone.length <= 64 ? zone : null; + } catch { + return null; + } +} + +async function readStored(key: string): Promise { + try { + if (Platform.OS === "web") { + if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { + return window.localStorage.getItem(key); + } + return null; + } + return await SecureStore.getItemAsync(key); + } catch (error) { + console.error("Error reading reported timezone:", error); + return null; + } +} + +async function writeStored(key: string, zone: string): Promise { + try { + if (Platform.OS === "web") { + if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") { + window.localStorage.setItem(key, zone); + } + return; + } + await SecureStore.setItemAsync(key, zone); + } catch (error) { + console.error("Error saving reported timezone:", error); + } +} + +/** + * Report the device timezone to the server so the daily response limit buckets on the + * user's local day. Cached locally, so this costs one write on install and one per + * actual travel event rather than one per foreground. + */ +export async function syncTimezone(userId: string): Promise { + const zone = resolveDeviceTimezone(); + if (!zone) return; + + const key = buildKey(userId); + if (await readStored(key) === zone) return; + + try { + await profileSettingsApi.updateProfile({ timezone: zone }); + await writeStored(key, zone); + } catch (error) { + // Non-critical: the server falls back to UTC until this succeeds. + console.error("Failed to sync timezone:", error); + } +} diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index 39b308b..ea217fe 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -3,6 +3,7 @@ import { ApiError, apiClient } from "../lib/apiClient"; 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"; @@ -200,6 +201,10 @@ export const useAuthStore = create((set, get) => ({ // 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/notificationPreferencesStore.ts b/apps/mobile/src/store/notificationPreferencesStore.ts index c4a3c44..a15c348 100644 --- a/apps/mobile/src/store/notificationPreferencesStore.ts +++ b/apps/mobile/src/store/notificationPreferencesStore.ts @@ -11,6 +11,7 @@ export interface NotificationPreferences { pack_changes_enabled: boolean; new_packs_enabled: boolean; streak_milestones_enabled: boolean; + streak_reminders_enabled: boolean; weekly_summary_enabled: boolean; unpaired_reminders_enabled: boolean; catchup_reminders_enabled: boolean; diff --git a/apps/mobile/src/store/streakStore.ts b/apps/mobile/src/store/streakStore.ts index 9fdf0ef..a505bee 100644 --- a/apps/mobile/src/store/streakStore.ts +++ b/apps/mobile/src/store/streakStore.ts @@ -2,13 +2,22 @@ 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; - user1_answered_today: boolean; - user2_answered_today: boolean; + 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; diff --git a/docs/couple-streaks.md b/docs/couple-streaks.md new file mode 100644 index 0000000..91316ae --- /dev/null +++ b/docs/couple-streaks.md @@ -0,0 +1,98 @@ +# Couple streaks + +A streak counts consecutive days on which **both** partners answered at least one +question. It is deliberately a property of the couple, not of a person: there is no +individual streak anywhere in the product, and there is not meant to be one. + +## What counts as a day together + +A day counts when both partners record a response to any eligible question on the same +couple-local calendar date. It is **not** tied to one specific question. + +This is a product decision, not an implementation shortcut. Tying the streak to a single +daily question would break a couple who answered fifteen other questions and missed that +one, which is exactly the kind of arbitrary loss that makes the mechanic feel punitive. If +a daily question ships, it should be the prompt that reliably produces the answer, not the +only answer that counts. + +Answering more than once in a day does nothing extra. Catch-up answers count the same as +new exploration; they are already exempt from the daily response cap. + +## The day boundary + +The streak day is the **couple's shared local day**. Both partners have to land inside the +same calendar date, so the day boundary cannot belong to whoever happens to be answering. + +The zone is the first reported `profiles.timezone` in member id order — the same ordering +that decides the `user1`/`user2` columns — falling back to the other partner's zone, then +to UTC when neither has reported one or the reported zone is not a known IANA name. + +Counting in UTC instead would break a couple in a western zone at UTC midnight while it is +still the same evening where they live. Partners in different zones share one boundary +rather than each getting their own, because the day either happened for the couple or it +did not. + +## Storage and writes + +`couple_streaks` holds one row per couple: + +| Column | Meaning | +|---|---| +| `current_streak` | Consecutive completed days. | +| `longest_streak` | High-water mark, never reduced. | +| `last_active_date` | Couple-local date either partner last answered. | +| `last_completed_date` | Couple-local date **both** partners last answered. | +| `user1_answered_today` / `user2_answered_today` | Per-partner flags for `last_active_date`, positional by member id. | +| `streak_celebrated_at` | Highest milestone already notified. | + +`PostgresAnswersRepository.touchStreak` maintains the row inside the same transaction as +the response that caused it, so the streak can never disagree with the responses it counts. +There is no trigger and no cron reset: the write path treats the flags as stale whenever +`last_active_date` is not the couple-local today, which makes a separate midnight job +unnecessary and removes a whole class of clock-skew bugs. + +The streak is alive while `last_completed_date` is the couple-local today or yesterday. + +## Reading + +`GET /v1/me/streak` resolves the row from the **caller's** point of view and returns +`you_answered_today`, `partner_answered_today`, and `partner_name`. The stored flags are +positional because the writer has to be deterministic, but a client cannot act on +"user2 has not answered" without re-deriving the id ordering, so the API does it instead. + +The read applies the same day rules as the write. A lapsed streak reports +`current_streak: 0` immediately rather than showing its stored number until the next +response silently resets it, and stale per-partner flags read as `false`. + +## Notifications + +Two producers in `PostgresOperationsRepository`, both queued through the outbox with a +dedupe key and both individually opt-out-able: + +| Producer | Fires | Preference | +|---|---|---| +| Milestones | On reaching 7, 14, 30, 60, or 100 days, once per milestone per couple. | `streak_milestones_enabled` | +| At-risk nudge | From 20:00 couple-local, when the streak is alive and the day is incomplete. Only the partner who still owes an answer is notified. | `streak_reminders_enabled` | + +The at-risk copy names the partner who has already answered as an invitation +("Alex answered today"), never as a debt. Guilt between partners is the failure mode for a +shared streak, not the goal — the original implementation reset broken streaks silently for +the same reason, and that should stay true of anything added here. + +## Surfaces + +- Discovery screen: full card, hidden entirely for couples with no live streak and no + activity today, so a dormant pair is never shown a zero. +- Swipe header: compact flame and count. +- Profile settings: both notification toggles, shown only once the user has a partner. + +## Legacy data plane + +The retired Supabase project scheduled `reset-daily-streak-flags` and +`check-streak-milestones` cron jobs and a `check-streak-milestones` Edge Function against +the old `couple_streaks` table. The API owns both jobs now. Those legacy writers are +stopped as part of the cutover procedure in +[backend deployment and branching](operations/backend-deployments.md); if any hosted +project still has them scheduled, an operator unschedules them directly — agents do not +execute remote DDL. Migration `0015_couple_streak_locality.sql` drops the equivalent +trigger and functions from the product database defensively. diff --git a/docs/index.md b/docs/index.md index 8dedc45..aebd2d4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,6 +15,7 @@ historical notes. Agents should load only the section relevant to their task. - [Authentication](authentication.md) - [Database schema](schema.md) - [Couple pairing](couple-pairing.md) +- [Couple streaks](couple-streaks.md) - [Question selection](question-selection.md) - [Match notifications](match-notifications.md) - [Subscriptions](subscription-system.md)