feat(engagement): localize limits and streaks

Use reported user timezones for daily response limits, expose couple streak state and reminders, and improve swipe and live-drawing synchronization with focused tests and documentation.
This commit is contained in:
jjbrunton committed 2026-08-28 20:28:31 +01:00
1 parent 61ec3692b7
commit b60c788362
42 files changed
+1308 -217

No files matched your search

+98
View File
@@ -0,0 +1,98 @@
# Couple streaks
A streak counts consecutive days on which **both** partners answered at least one
question. It is deliberately a property of the couple, not of a person: there is no
individual streak anywhere in the product, and there is not meant to be one.
## What counts as a day together
A day counts when both partners record a response to any eligible question on the same
couple-local calendar date. It is **not** tied to one specific question.
This is a product decision, not an implementation shortcut. Tying the streak to a single
daily question would break a couple who answered fifteen other questions and missed that
one, which is exactly the kind of arbitrary loss that makes the mechanic feel punitive. If
a daily question ships, it should be the prompt that reliably produces the answer, not the
only answer that counts.
Answering more than once in a day does nothing extra. Catch-up answers count the same as
new exploration; they are already exempt from the daily response cap.
## The day boundary
The streak day is the **couple's shared local day**. Both partners have to land inside the
same calendar date, so the day boundary cannot belong to whoever happens to be answering.
The zone is the first reported `profiles.timezone` in member id order — the same ordering
that decides the `user1`/`user2` columns — falling back to the other partner's zone, then
to UTC when neither has reported one or the reported zone is not a known IANA name.
Counting in UTC instead would break a couple in a western zone at UTC midnight while it is
still the same evening where they live. Partners in different zones share one boundary
rather than each getting their own, because the day either happened for the couple or it
did not.
## Storage and writes
`couple_streaks` holds one row per couple:
| Column | Meaning |
|---|---|
| `current_streak` | Consecutive completed days. |
| `longest_streak` | High-water mark, never reduced. |
| `last_active_date` | Couple-local date either partner last answered. |
| `last_completed_date` | Couple-local date **both** partners last answered. |
| `user1_answered_today` / `user2_answered_today` | Per-partner flags for `last_active_date`, positional by member id. |
| `streak_celebrated_at` | Highest milestone already notified. |
`PostgresAnswersRepository.touchStreak` maintains the row inside the same transaction as
the response that caused it, so the streak can never disagree with the responses it counts.
There is no trigger and no cron reset: the write path treats the flags as stale whenever
`last_active_date` is not the couple-local today, which makes a separate midnight job
unnecessary and removes a whole class of clock-skew bugs.
The streak is alive while `last_completed_date` is the couple-local today or yesterday.
## Reading
`GET /v1/me/streak` resolves the row from the **caller's** point of view and returns
`you_answered_today`, `partner_answered_today`, and `partner_name`. The stored flags are
positional because the writer has to be deterministic, but a client cannot act on
"user2 has not answered" without re-deriving the id ordering, so the API does it instead.
The read applies the same day rules as the write. A lapsed streak reports
`current_streak: 0` immediately rather than showing its stored number until the next
response silently resets it, and stale per-partner flags read as `false`.
## Notifications
Two producers in `PostgresOperationsRepository`, both queued through the outbox with a
dedupe key and both individually opt-out-able:
| Producer | Fires | Preference |
|---|---|---|
| Milestones | On reaching 7, 14, 30, 60, or 100 days, once per milestone per couple. | `streak_milestones_enabled` |
| At-risk nudge | From 20:00 couple-local, when the streak is alive and the day is incomplete. Only the partner who still owes an answer is notified. | `streak_reminders_enabled` |
The at-risk copy names the partner who has already answered as an invitation
("Alex answered today"), never as a debt. Guilt between partners is the failure mode for a
shared streak, not the goal — the original implementation reset broken streaks silently for
the same reason, and that should stay true of anything added here.
## Surfaces
- Discovery screen: full card, hidden entirely for couples with no live streak and no
activity today, so a dormant pair is never shown a zero.
- Swipe header: compact flame and count.
- Profile settings: both notification toggles, shown only once the user has a partner.
## Legacy data plane
The retired Supabase project scheduled `reset-daily-streak-flags` and
`check-streak-milestones` cron jobs and a `check-streak-milestones` Edge Function against
the old `couple_streaks` table. The API owns both jobs now. Those legacy writers are
stopped as part of the cutover procedure in
[backend deployment and branching](operations/backend-deployments.md); if any hosted
project still has them scheduled, an operator unschedules them directly — agents do not
execute remote DDL. Migration `0015_couple_streak_locality.sql` drops the equivalent
trigger and functions from the product database defensively.
+1
View File
@@ -15,6 +15,7 @@ historical notes. Agents should load only the section relevant to their task.
- [Authentication](authentication.md)
- [Database schema](schema.md)
- [Couple pairing](couple-pairing.md)
- [Couple streaks](couple-streaks.md)
- [Question selection](question-selection.md)
- [Match notifications](match-notifications.md)
- [Subscriptions](subscription-system.md)