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.
This commit is contained in:
jjbrunton committed 2026-08-28 20:28:31 +01:00
1 parent 61ec3692b7
commit b60c788362
42 files changed
+1308 -217

No files matched your search

+8 -3
View File
@@ -187,7 +187,7 @@ export function AppSettingsPage() {
Daily Response Limit (Free Users)
</CardTitle>
<CardDescription>
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
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -203,14 +203,19 @@ export function AppSettingsPage() {
className="max-w-[200px]"
/>
<p className="text-xs text-muted-foreground">
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.
</p>
<p className="text-xs text-muted-foreground">
Set to <strong>0</strong> to disable daily limits entirely.
</p>
<p className="text-xs text-muted-foreground">
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.
</p>
<p className="text-xs text-muted-foreground">
<strong>Does not count toward the limit:</strong> editing an existing answer, or
answering a question the partner had already answered (catching up is always free).
</p>
</div>
</CardContent>
@@ -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.';
@@ -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);
@@ -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"();
@@ -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;
+28
View File
@@ -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
}
]
}
+1
View File
@@ -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>(),
+106 -9
View File
@@ -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<string, unknown>; 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<string, unknown> | 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<r.created_at";
export interface SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
@@ -20,12 +47,13 @@ export interface AnswersRepository {
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
markSeen(userId: string, ids: string[]): Promise<void>;
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
streak(userId: string): Promise<Record<string, unknown> | null>;
streak(userId: string): Promise<CoupleStreakView | null>;
close(): Promise<void>;
}
export class PostgresAnswersRepository implements AnswersRepository {
private readonly pool: Pool;
private zones: Promise<Set<string>> | null = null;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
@@ -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<QuestionRow>(`
@@ -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<ResponseRow>(`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<string>{
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<CoupleStreakView|null>{
const ctx=await this.context(this.pool,userId);
const r=await this.pool.query<StreakRow>(`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<QuestionRow>(`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<ContextRow&{id:string}>('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];
+58 -2
View File
@@ -36,7 +36,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
async produce(now: Date, limit=100): Promise<ProducerSummary> {
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<number> {
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.id<partner.id then ar.user1_answered_today else ar.user2_answered_today end me_answered,
case when ar.last_active_date is distinct from ar.local_date then false
when me.id<partner.id then ar.user2_answered_today else ar.user1_answered_today end partner_answered
from at_risk ar
join profiles me on me.couple_id=ar.couple_id
join profiles partner on partner.couple_id=ar.couple_id and partner.id<>me.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<number> {
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
+1
View File
@@ -48,4 +48,5 @@ export interface ProducerSummary {
weeklySummaries: number;
unpairedReminders: number;
catchupReminders: number;
streakReminders: number;
}
@@ -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<keyof ProfileUpdate, string> = {
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,
@@ -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({
@@ -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<string, unknown> | null;
push_token?: string | null;
timezone?: string | null;
}
export interface FeedbackSubmission {
+34 -4
View File
@@ -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);});
});
+3 -1
View File
@@ -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);});
});
+2 -1
View File
@@ -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);
}
+39 -1
View File
@@ -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);
+1 -1
View File
@@ -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)};
@@ -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,
+13 -2
View File
@@ -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}
>
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
{/* Shared streak - hidden entirely for couples with nothing running */}
<View style={styles.streakSlot}>
<StreakDisplay delay={50} />
</View>
{/* Live Draw Activity Tile */}
<LiveDrawTile delay={100} />
@@ -209,6 +217,9 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
textAlign: 'center',
},
streakSlot: {
paddingHorizontal: spacing.lg,
},
bottomSpacer: {
height: spacing.lg,
},
@@ -0,0 +1,48 @@
import { streakStatus } from '@/components/StreakDisplay';
import type { CoupleStreak } from '@/store/streakStore';
const streak = (overrides: Partial<CoupleStreak>): 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');
});
});
@@ -0,0 +1,40 @@
import { calculateEffectiveTotal } from "@/features/swipe/utils/useSwipeScreen-helpers";
import type { DailyLimitInfo } from "@/features/swipe/types";
const limit = (overrides: Partial<DailyLimitInfo> = {}): 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);
});
});
@@ -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<string, string> = {};
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<string>());
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 });
});
});
@@ -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']);
});
});
@@ -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<string, string>();
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<string, string> };
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);
});
});
@@ -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',
};
+71 -37
View File
@@ -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 (
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
<View style={styles.compactContainer}>
<View style={[styles.compactContainer, awaitingYou && styles.compactAwaiting]}>
<Animated.View style={flameStyle}>
<Ionicons
name="flame"
@@ -80,44 +111,35 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false
return (
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
<View style={styles.container}>
{/* Main streak display */}
<LinearGradient
colors={hasStreak ? ['rgba(243, 156, 18, 0.2)', 'rgba(233, 69, 96, 0.1)'] : ['rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.02)']}
style={styles.card}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={[styles.card, shadows.sm, awaitingYou && styles.cardAwaiting]}>
<View style={styles.content}>
{/* Flame icon */}
<Animated.View style={[styles.iconContainer, flameStyle]}>
<Ionicons
name="flame"
size={32}
size={28}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
{/* Streak info */}
<View style={styles.info}>
<Text style={[styles.count, !hasStreak && styles.inactiveText]}>
{currentStreak}
<Text style={styles.headline} numberOfLines={2}>
{headline}
</Text>
<Text style={styles.label}>
day{currentStreak !== 1 ? 's' : ''} together
<Text style={[styles.status, awaitingYou && styles.statusAwaiting]} numberOfLines={2}>
{status}
</Text>
</View>
</View>
{/* Longest streak */}
{showLongest && longestStreak > 0 && (
<View style={styles.longest}>
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
<Text style={styles.longestText}>
Best: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
</Text>
</View>
)}
</LinearGradient>
</View>
</View>
</Animated.View>
);
@@ -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',
@@ -44,8 +44,9 @@ export const LiveDrawScreen: React.FC = () => {
const [shareDrawingUri, setShareDrawingUri] = useState<string | null>(null);
const canvasRef = useRef<DrawingCanvasHandle>(null);
// Keep track of the current stroke id for sync broadcasts
const currentStrokeIdRef = useRef<string | null>(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<StrokeSegment | null>(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}
@@ -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<StrokeSegment | null>;
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 (
<Path
path={path}
color={stroke.isEraser ? CANVAS_BACKGROUND : stroke.color}
style="stroke"
strokeWidth={stroke.width}
strokeCap="round"
strokeJoin="round"
/>
);
});
export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>(
function DrawingCanvas(
{
strokes,
canvasWidth,
canvasHeight,
strokeColor,
strokeWidth,
isEraser,
onTouchStart,
onTouchMove,
onTouchEnd,
@@ -128,9 +104,23 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
ref
) {
const canvasRef = useRef<any>(null);
const currentStrokeRef = useRef<StrokeSegment | null>(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<SkPath | null>(null);
const livePrev = useSharedValue<StrokePoint | null>(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<DrawingCanvasHandle, DrawingCanvasProps>
}
}, [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 (
<GestureDetector gesture={pan}>
@@ -199,11 +204,13 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
canvasHeight={canvasHeight}
/>
))}
<CurrentStrokePath
strokeRef={currentStrokeRef}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
_tick={rafTick}
<Path
path={renderedLivePath}
color={liveColor}
style="stroke"
strokeWidth={strokeWidth}
strokeCap="round"
strokeJoin="round"
/>
</Canvas>
</View>
@@ -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<void>;
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<string, StrokeSegment>();
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<StrokeSegment[]>([]);
const writeQueueRef = useRef<Promise<void>>(Promise.resolve());
const pendingWritesRef = useRef(0);
const throttleRef = useRef<NodeJS.Timeout | null>(null);
const throttleRef = useRef<ReturnType<typeof setTimeout> | 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<StrokeSegment | null>(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<void> => {
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]);
}
@@ -37,6 +37,7 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
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<NotificationSettingsProps> = ({
<View style={styles.itemDivider} />
{partner && (
<>
<SwitchItem
icon="alarm-outline"
label="Streak Reminders"
description="An evening nudge while your streak is still open"
value={preferences.streak_reminders_enabled}
onValueChange={handlers.onStreakRemindersToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
</>
)}
<SwitchItem
icon="calendar-outline"
label="Weekly Recap"
@@ -115,6 +115,7 @@ const SwipeScreen = () => {
packContext={packContext}
showBackButton={!!packId || mode === 'pending'}
onBack={() => router.back()}
dailyLimitInfo={dailyLimitInfo}
/>
<SwipeCardStack
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
@@ -6,6 +7,7 @@ import { LinearGradient } from "expo-linear-gradient";
import { DecorativeSeparator, GradientBackground } from "../../../components/ui";
import { Paywall } from "../../../components/paywall";
import { colors, radius, spacing, typography } from "../../../theme";
import { Events } from "../../../lib/analytics";
import type { DailyLimitInfo } from "../types";
interface SwipeDailyLimitStateProps {
@@ -27,6 +29,11 @@ export const SwipeDailyLimitState = ({
}: SwipeDailyLimitStateProps) => {
const accent = colors.premium.gold;
// The meter exists to convert; without these the funnel is unmeasurable.
useEffect(() => {
Events.paywallShown('daily_limit');
}, []);
return (
<GradientBackground>
<View style={styles.centerContainer}>
@@ -59,7 +66,7 @@ export const SwipeDailyLimitState = ({
</Animated.View>
<Text style={styles.dailyLimitDescription}>
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.
</Text>
@@ -100,8 +107,14 @@ export const SwipeDailyLimitState = ({
<Paywall
visible={showPaywall}
onClose={onClosePaywall}
onSuccess={onPaywallSuccess}
onClose={() => {
Events.paywallClosed('daily_limit', 'dismissed');
onClosePaywall();
}}
onSuccess={() => {
Events.paywallClosed('daily_limit', 'purchased');
onPaywallSuccess();
}}
/>
</GradientBackground>
);
@@ -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 (
<Animated.View
entering={FadeIn.duration(400)}
@@ -47,6 +62,16 @@ export const SwipeHeader = ({
<Text style={styles.progressText}>
{currentIndex + 1} of {total}
</Text>
{showMeter && (
<View style={[styles.meter, isRunningOut && styles.meterUrgent]}>
{isRunningOut && (
<Ionicons name="hourglass-outline" size={12} color={colors.premium.gold} />
)}
<Text style={[styles.meterText, isRunningOut && styles.meterTextUrgent]}>
{remaining === 1 ? '1 left today' : `${remaining} left today`}
</Text>
</View>
)}
<View style={[styles.progressBar, styles.progressBarPremium]}>
<Animated.View
style={[
@@ -66,6 +91,11 @@ export const SwipeHeader = ({
</Animated.View>
</View>
</View>
{/* The shared streak sits opposite the back button so the centred
progress column keeps its position. */}
<View style={styles.streakBadge} pointerEvents="none">
<StreakDisplay compact delay={200} />
</View>
</View>
</Animated.View>
);
@@ -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,
@@ -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);
}
@@ -13,6 +13,7 @@ export interface ProfileUpdate {
onboarding_version?: number;
public_key_jwk?: Record<string, unknown> | null;
push_token?: string | null;
timezone?: string | null;
}
type PreferenceKey = keyof Omit<NotificationPreferences, 'user_id' | 'created_at' | 'updated_at'>;
+68
View File
@@ -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<string | null> {
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<void> {
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<void> {
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);
}
}
+5
View File
@@ -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<AuthState>((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);
},
}));
@@ -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;
+11 -2
View File
@@ -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;
+98
View File
@@ -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.
+1
View File
@@ -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)