mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
61ec3692b7
commit
b60c788362
42 files changed
+1296
-205
No files matched your search
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>(),
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);});
|
||||
});
|
||||
@@ -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);});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
const finished = currentStrokeRef.current;
|
||||
currentStrokeRef.current = null;
|
||||
if (!finished) return;
|
||||
addStroke(finished);
|
||||
sync.broadcastStrokeEnd(finished.id);
|
||||
sync.persistStrokes([...strokes, finished]);
|
||||
}
|
||||
}, [addStroke, strokes, sync]);
|
||||
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()
|
||||
const pan = useMemo(
|
||||
() =>
|
||||
Gesture.Pan()
|
||||
.minDistance(0)
|
||||
.onStart((e) => {
|
||||
runOnJS(onTouchStart)({
|
||||
x: e.x / canvasWidth,
|
||||
y: e.y / canvasHeight,
|
||||
});
|
||||
'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) => {
|
||||
runOnJS(onTouchMove)({
|
||||
x: e.x / canvasWidth,
|
||||
y: e.y / canvasHeight,
|
||||
});
|
||||
'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) {
|
||||
// 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(workingRef.current);
|
||||
void enqueueWrite(composeWorking());
|
||||
}, 250);
|
||||
}
|
||||
}, [enqueueWrite]);
|
||||
}, [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) {
|
||||
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'>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user