@@ -203,14 +203,19 @@ export function AppSettingsPage() {
className="max-w-[200px]"
/>
- Maximum number of questions a non-premium user can answer per UTC day.
+ Maximum number of questions a non-premium user can answer per day.
Premium users (via personal or partner subscription) bypass this limit.
Set to 0 to disable daily limits entirely.
- Limit resets daily at 00:00 UTC. Editing existing answers does not count toward the limit.
+ Resets at midnight in the user's own timezone, as reported by their device.
+ Users who have not reported a timezone yet fall back to 00:00 UTC.
+
+
+ Does not count toward the limit: editing an existing answer, or
+ answering a question the partner had already answered (catching up is always free).
diff --git a/apps/api/drizzle/0013_daily_limit_local_reset.sql b/apps/api/drizzle/0013_daily_limit_local_reset.sql
new file mode 100644
index 0000000..d5488c6
--- /dev/null
+++ b/apps/api/drizzle/0013_daily_limit_local_reset.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "profiles" ADD COLUMN IF NOT EXISTS "timezone" text;--> statement-breakpoint
+COMMENT ON COLUMN "profiles"."timezone" IS 'IANA zone reported by the client. NULL falls back to UTC when bucketing the daily response limit.';
diff --git a/apps/api/drizzle/0014_daily_limit_response_index.sql b/apps/api/drizzle/0014_daily_limit_response_index.sql
new file mode 100644
index 0000000..38ffe7a
--- /dev/null
+++ b/apps/api/drizzle/0014_daily_limit_response_index.sql
@@ -0,0 +1,2 @@
+-- The daily response limit counts a user's responses within their current local day.
+CREATE INDEX IF NOT EXISTS "responses_user_created_idx" ON "responses" ("user_id", "created_at" DESC);
diff --git a/apps/api/drizzle/0015_couple_streak_locality.sql b/apps/api/drizzle/0015_couple_streak_locality.sql
new file mode 100644
index 0000000..bc327fd
--- /dev/null
+++ b/apps/api/drizzle/0015_couple_streak_locality.sql
@@ -0,0 +1,15 @@
+-- Streaks are counted in the couple's shared local day, not the server's calendar day.
+-- The zone is the first reported timezone in member id order (the same ordering that
+-- decides user1/user2), falling back to UTC when neither partner has reported one.
+COMMENT ON COLUMN "couple_streaks"."last_active_date" IS 'Couple-local date on which either partner last answered. The answered-today flags are read as false once this is not the couple-local today.';--> statement-breakpoint
+COMMENT ON COLUMN "couple_streaks"."last_completed_date" IS 'Couple-local date on which both partners last answered. The streak is alive while this is the couple-local today or yesterday.';--> statement-breakpoint
+
+-- Prunes the evening at-risk scan to couples that actually have a streak to lose.
+CREATE INDEX IF NOT EXISTS "couple_streaks_live_idx" ON "couple_streaks" ("last_completed_date") WHERE "current_streak" > 0;--> statement-breakpoint
+
+-- The legacy data plane maintained streaks from a trigger that read auth.uid(). The API
+-- owns that write now and runs it in the same transaction as the response, so drop the
+-- trigger defensively in case a cutover ever carries these objects across.
+DROP TRIGGER IF EXISTS "on_response_update_streak" ON "responses";--> statement-breakpoint
+DROP FUNCTION IF EXISTS "update_couple_streak"();--> statement-breakpoint
+DROP FUNCTION IF EXISTS "reset_daily_streak_flags"();
diff --git a/apps/api/drizzle/0016_streak_reminder_preference.sql b/apps/api/drizzle/0016_streak_reminder_preference.sql
new file mode 100644
index 0000000..3adf996
--- /dev/null
+++ b/apps/api/drizzle/0016_streak_reminder_preference.sql
@@ -0,0 +1,3 @@
+-- A shared streak only changes behaviour if somebody is told it is still open, so the
+-- evening at-risk nudge gets its own opt-out rather than riding on milestone celebrations.
+ALTER TABLE "notification_preferences" ADD COLUMN IF NOT EXISTS "streak_reminders_enabled" boolean NOT NULL DEFAULT true;
diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json
index 0430787..a3b7e0c 100644
--- a/apps/api/drizzle/meta/_journal.json
+++ b/apps/api/drizzle/meta/_journal.json
@@ -92,6 +92,34 @@
"when": 1787908500000,
"tag": "0012_catalogue_field_parity",
"breakpoints": true
+ },
+ {
+ "idx": 13,
+ "version": "7",
+ "when": 1787994900000,
+ "tag": "0013_daily_limit_local_reset",
+ "breakpoints": true
+ },
+ {
+ "idx": 14,
+ "version": "7",
+ "when": 1787994960000,
+ "tag": "0014_daily_limit_response_index",
+ "breakpoints": true
+ },
+ {
+ "idx": 15,
+ "version": "7",
+ "when": 1788081360000,
+ "tag": "0015_couple_streak_locality",
+ "breakpoints": true
+ },
+ {
+ "idx": 16,
+ "version": "7",
+ "when": 1788081420000,
+ "tag": "0016_streak_reminder_preference",
+ "breakpoints": true
}
]
}
diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts
index 1db150a..054fc47 100644
--- a/apps/api/src/db/schema.ts
+++ b/apps/api/src/db/schema.ts
@@ -6,6 +6,7 @@ export const profiles = pgTable('profiles', {
email: text('email'),
avatarUrl: text('avatar_url'),
pushToken: text('push_token'),
+ timezone: text('timezone'),
isPremium: boolean('is_premium').notNull().default(false),
coupleId: uuid('couple_id'),
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
diff --git a/apps/api/src/domains/answers/repository.ts b/apps/api/src/domains/answers/repository.ts
index 96bf772..443970c 100644
--- a/apps/api/src/domains/answers/repository.ts
+++ b/apps/api/src/domains/answers/repository.ts
@@ -5,6 +5,33 @@ interface ContextRow { couple_id: string | null; gender: string | null; max_inte
interface QuestionRow { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; question_type: QuestionType; config: Record