Compare commits

..

No commits in common. "5d03fd98bec39280298a386f94cbaa8e5fc60b7d" and "5013c2cd09bda333b56c9ea4d8a8e9fa113c7eea" have entirely different histories.

57 changed files with 526 additions and 1362 deletions

4
.gitignore vendored
View File

@ -91,7 +91,3 @@ docs/strategy/positioning-vs-paired.md
ClaudeQAPlan.md
ClaudeReport.md
relationship-app.code-workspace
# QA fixtures with test credentials — local only, never commit
qa/fixtures.local.md
qa/*.local.md

View File

@ -1,125 +0,0 @@
# ClaudeGamesReview — Full Games, Mechanics, Retention & UI Review
> **Session 2026-07-07 (Claude, senior-dev review round).** Scope: all 7 games verified live on a
> fresh 2-emulator couple, mechanics checked against their real-world inspirations (researched),
> retention assessment, full-app UI warmth sweep, code-quality audit + fix batches.
> Bugs are cross-referenced by ID; canonical defect state lives in `ClaudeReport.md`.
> Test couple: **Ava + Ben** (`j39xR7PVZLLCveWnmenU`), created through the real signup → invite-code
> → recovery-phrase pairing flow on `CloserQA2` (5554, light) + `CloserCodexQA` (5556, dark).
> Premium was granted via admin (user-authorized), verified (partner unlock modal + push), then
> revoked (locks confirmed restored). 0 active sessions at session end.
## 1 · Per-game verdicts (mechanics fidelity + live results)
| Game | Real-world analog | Fidelity | Live 2-device result |
|---|---|---|---|
| This or That | Rapid-fire preference quiz | ✅ Faithful | **PASS** — engineered answers → exactly 3/5 "in sync"; simultaneous symmetric reveal; warm framing ("Lots in common 💛", neutral "Differ" chips) |
| How Well Do You Know Me | The Newlywed Game | ✅ Core faithful; no round-2 role swap (see R-2) | **PASS** — subject/guesser roles correct on both intros; engineered 2/5 exact; near-miss scale renders amber "4 → 3" (isClose softening); low score framed "Getting there!" |
| Desire Sync | Mojo Upgrade-style mutual-desire matching | ✅ Canonical privacy contract, verbatim in intro copy | **PASS** — engineered intersection → exactly "2 shared desires / 3 stayed private"; non-mutual yeses never rendered on either device; count doesn't leak whose/which; answers `enc:v1:` at rest |
| Spin the Wheel | Conversation-deck wheel | ✅ Honest randomness (category picked during spin; decorative stop angle) | **PASS** — identical 10-question set both sides; blind until both answered; side-by-side reveal; completion gate blocks finish with unanswered prompts |
| Connection Challenges | 7-day couples habit challenge | ✅ Day-gating real (can't complete 2 days in one day) | **PASS** — premium challenge opened with entitlement; day-1 complete → "waiting for partner"; ⚠ day-2 prompt text visible a day early (spoiler, see U-13) |
| Memory Lane | Time capsule / letter to future selves | ✅ | **PASS** — sealed capsule shows only title + "Opens in 29 days"; title AND body `enc:v1:` at rest (admin-verified) |
| Date Match | Tinder swipe-to-match | ✅ incl. one-sided-like privacy | **PASS** — simultaneous mutual-LOVE race → exactly **1** match doc; celebration modal + "It's a match!" push on BOTH devices |
**Shared engine seams, verified live:** one-active-game gate converges the second starter into the
active session (Ben's How Well attempt → "Ava is playing a Wheel game / Join the game");
first-finisher `partner_completed_part` fires as an in-app banner (foreground) and real push
(killed app, cold-start tap opened the app cleanly, 0 FATAL); mid-game Quit abandons correctly
(admin: 0 active sessions after); "you both finished" results banner on both sides.
## 2 · Bugs found (canonical detail in ClaudeReport.md)
| ID | Sev | Summary | Status |
|---|---|---|---|
| DQ-MISMATCH-001 | **P1** | Partners can resolve **different daily questions** for the same day: server assignment is the unresolvable `q_default_daily` rollout fallback → each device falls back locally, and the fallback **pool depends on the viewer's premium state at that moment** (per-user entitlement, transient failures→false, mid-day flips). Reveal then cross-compares answers to different questions. Reproduced live (Ava: premium pool "tiny win" q; Ben: free pool "tiny debate" q; same clock/tz). | **FIXED** (paired fallback pinned to free pool — premium-independent) + follow-up filed to seed real server assignments |
| DQ-SLUG-001 | **P1** | Daily reveal renders the partner's answer as a raw option-id slug ("fake_awards_should_be_mandatory") when the id isn't in this device's question config (same class as HW-BREAKDOWN-001). | **FIXED** (humanize fallback in both reveal summaries) |
| GAME-HANG-001 (B1) | **P2** | ToT/HowWell/DesireSync answer listeners swallowed snapshot errors → game stuck on WAITING forever, no feedback (Wheel/Capsule already had the correct `close(err)` pattern). | **FIXED** (sources `close(err)` + VM `.catch` → retryable ERROR with "Try again" re-attach) |
| DST-001 (B2) | **P2** | `assignDailyQuestion.ts` hardcoded `CST_OFFSET_HOURS=-6`: wrong date labels + reveal times shifted 1h for the ~8 CDT months. | **FIXED** (Intl/America-Chicago helpers, matches streakReminder.ts pattern; 5 DST regression tests incl. spring-forward round-trip) |
| A-003b | P3 | Challenges catalog still shows 🔒 Premium badge after couple premium unlock (gate itself honors premium — opens fine). | Filed |
| BANNER-LIFE-001 | P3 | "You both finished · View" game banner persists across screens/games long after results were viewed (still up entering a NEW game's setup, and shown on top of the results screen it links to). | Filed |
| AUTH-TRUNC-001 | P3 | Welcome/splash privacy tagline clips mid-sentence without ellipsis ("Only you and your paired") — first-run first impression. | Filed |
| Copy/theming P3s | P3 | "Daily Fun Mc" internal category name as user-facing pill; unthemed teal date picker; DOB asked twice in signup→profile funnel; "It is a match!" vs push "It's a match!"; HW matched-row question text low contrast in dark; Date Match card-underlay text bleed; joiner screen titled "Waiting for X" when it's your turn. | Filed |
## 3 · Retention assessment (Hooked / Fogg / SDT lens)
**Strong foundations:** daily-question blind-reveal is a genuine daily anchor with a variable
reward (partner's answer) · streaks with repair + grace (aligned with Duolingo's leniency findings)
· Memory Lane = stored-value investment · Weekly Recap + Your Progress = progress evidence ·
first-finisher nudges close the async loop (verified: banner + push both fire) · notification suite
respects quiet hours.
**Prioritized recommendations:**
- **R-1 (P1): Fix the daily anchor supply chain.** `q_default_daily` fallback means the server
never really assigns; seed the Firestore `questions` pool (or make the callable assign a real id
from a synced catalog) so both devices always agree. (Client-side consistency fix landed this
session; assignment seeding is the durable fix.)
- **R-2 (P2): Role-swap rematch in How Well.** Newlywed Game's round structure swaps roles; the
results screen should offer "Now you guess — swap roles" (starter becomes guesser). Cheap, doubles
sessions per sitting, and answers "what do we do next?" at the emotional peak.
- **R-3 (P2): Return-tomorrow beat on results screens.** After every reveal the only CTAs are
"Play again / Back to Play". Add the next anchor: tomorrow's question teaser, challenge day N+1,
or streak state ("Day 2 tomorrow — keep it alive").
- **R-4 (P2): Repeat-avoidance for game pools.** Desire Sync's effective pool is 102 binary items
drawn `shuffled().take(n)` per session (no cross-session memory) and Date Match's deck is
deterministic (identical first card for every couple, every install). Persist seen-ids per couple.
- **R-5 (P3): Purify the How Well pool.** `getQuestionsForPrediction()` pulls all choice/scale
questions including 500 `daily_fun_mc` novelty MCs ("laugh-track move") — dilutes the
partner-knowledge premise. Exclude `daily_fun_mc` (or curate a prediction-worthy tag).
- **R-6 (P3): Challenges — hide tomorrow's prompt** (spoiler kills the daily ritual) and consider a
visible 7-day chain with both partners' marks (social accountability).
- **R-7 (P3): Analytics funnel gaps.** `RetentionEvent` has GameStarted/GameCompleted but no
first-answer, reveal-viewed, waiting-abandoned, or notification-tap-attribution events — the async
dead-end (biggest churn risk) is unmeasurable today.
- **Content notes:** 48/150 sexual_preferences items unreachable by Desire Sync (non-binary configs
— fine, Wheel uses them) · `daily_fun_mc` rows carry `sex='neutral'` (currently benign only because
of the binary filter; retag to avoid a future landmine) · This or That mood pools healthy (6075
per mood vs 15 max draw); `rebuilding_trust` (45 q) reachable only via "All topics".
## 4 · UI warmth sweep (vs docs/copy-guide.md)
Scores 15 per cluster (tone / emotional safety / welcome / consistency):
- **Games (all 7): 5/5//4.** Best-in-class results framing (no shaming anywhere: "Getting
there!", "Differ" not ✗, "Nothing in common this round" never blames). Consistency dinged for the
banner lifecycle + slug leak (fixed) + per-game accent divergence (DS magenta is intentional and
works).
- **Onboarding/pairing: 4//5/3.** "You're in / Now bring your person in", value-prop checklist,
recovery-phrase ceremony with word-confirmation are excellent. Dinged: tagline truncation, DOB
asked twice, unthemed date picker, dead-end Create-Profile when signed out.
- **Home/Today: 4/5/5/3.** Daily card + mode system (Tiny Win Tuesday live-verified) is a strong
anchor; "Daily Fun Mc" pill leak; Home-card question can disagree with Today tab (DQ-MISMATCH).
- **Empty states: 5.** Memory Lane's "No capsules yet" is the pattern to copy everywhere.
- Both themes render cleanly on every screen driven this session (fresh-couple run covered light
on 5554 + dark on 5556 throughout); HW matched-row contrast is the one dark-mode ding.
## 5 · Code-quality fixes landed this session
- **Batch 1 (correctness):** B1 observer `close(err)` ×3 + VM `.catch`→retryable ERROR ×3 (+
`GameCopy.SYNC_ERROR`); DQ paired-fallback premium-independence; reveal slug humanization ×2;
DST-safe Chicago date helpers + 5 regression tests.
- **Batch 2 (dead code / hygiene):** see ClaudeReport.md fix log.
- **Filed (structural, specced in Future.md):** generic session-game engine (ToT/HW/DS VMs are
~80% clones; ~250-line state machine ×4), generic replay VM/screen (3× ~60-line clones), shared
waiting/length-picker composables, premium-check invocation helper, `GameConstants` for divergent
`ADVANCE_DELAY_MS` (420/380), dispatcher injection, error-surfacing standardization for 6
silent-load VMs, `npm audit` 10 moderate (GCP transitive).
## 5.1 · R30 polish + experience follow-up (2026-07-07)
All Tier-A P3s and Tier-B UX gaps from the review shipped and were live-verified (detail: ClaudeReport R30). Highlights that change the player experience:
- **Daily question is fixed at the root.** The server pool was empty (every couple got the unresolvable `q_default_daily`); now Firestore `questions` is seeded with the free weekday pool and the server picks the same mode-aware, deterministic question the client would — so both partners get the identical themed question, even across time zones. Verified: server assigns `daily_single_choice_weekly_v1_072` (Tiny Win Tuesday), both devices resolve it, no slugs.
- **Games no longer dead-end:** NextBeatCard ("Tonight's question is ready →" / streak) under every results screen; How Well offers a Newlywed-style role swap.
- **Content freshness:** Desire Sync remembers served questions; Date Match shuffles per couple and skips already-swiped ideas (no more identical first card for everyone).
- **Waiting isn't passive:** a "Send a little nudge 💜" button on the waiting screen (reuses the quiet-hours-safe thinking-of-you callable).
- **Onboarding polish:** themed M3 date pickers; the birth date now survives an app restart mid-signup (no more double-ask).
- **A11y + copy:** score visuals carry TalkBack descriptions; banners speak per-game ("guess their answers"); the internal "Daily Fun Mc" label never faces users.
## 6 · Coverage notes
- Verified live: all 7 games happy path 2-device · result extremes (0-match impossible for DS
reveal? engineered 3/5, 2/5, 2-shared, 3/5-sync variants) · one-active gate · abandon · cold-start
push join · simultaneous-LOVE race · premium grant/unlock/revoke lifecycle · daily answer→reveal ·
day-0 check-in · pairing + recovery-phrase ceremony · deep-link scheme audit (only /join/ links
exist; game entries are notification-driven, all tested).
- Deferred (unchanged from plan): full Pass E matrix, Pass K money path, release build, monkey,
TalkBack full pass (spot: n/a this round), TodayWidget interactive smoke (code-read only),
am-kill mid-session resume (cold-start-from-push covered the recovery class).

View File

@ -1,10 +1,6 @@
# Claude QA Coverage Matrix
> **Resume anchor — current status only.** Statuses: `pass | fail→id | todo | n/a | not implemented→Future.md | blocked→id`.
> **R30 (2026-07-07, polish + UX + seed):** all remaining P3s + 8 UX gaps FIXED & live-verified (see ClaudeReport R30 / ClaudeGamesReview §5). **Daily-question pool SEEDED + server made mode-aware/deterministic + deployed** — both devices resolve the same real server-pinned question (DQ-MISMATCH/DQ-SLUG closed at the root, verified live). Gates: unit pass · functions 58 · cold-start smoke run. Still deferred: TodayWidget interactive, TalkBack full pass, release/minify (O), device/OS matrix.
> **R29 (2026-07-07, games/mechanics/retention/UI review):** Pass B (all 7 games) **re-run live 2-device on a fresh couple = pass** (engineered-answer verification; see ClaudeGamesReview.md) · onboarding/signup/pairing/recovery-ceremony/day-0 check-in **pass (live, first full run since R24)** · daily answer→reveal loop **fail→DQ-MISMATCH-001/DQ-SLUG-001 → FIXED e5868bd, re-verified** · premium grant/unlock/revoke lifecycle **pass** · cold-start push open **pass (organic, killed app)** · one-active gate/abandon/simultaneous-match race **pass** · TodayWidget interactive + TalkBack spot + am-kill mid-session resume **todo (deferred; cold-start-from-push covered the recovery class)** · Pass D at-rest spot-check (game answers + capsule on new couple) **pass (`enc:v1:`)**.
> **R28 (2026-07-02) — closed R27's two gaps: FIXED HW-BREAKDOWN-001 (P3) + ran the 3 premium games live 2-device under an explicitly-authorized admin grant, then revoked. 0 P0/P1/P2, 0 FATAL.** **HW-BREAKDOWN-001 (P3) FIXED** — added `humanizeOptionId()` (`_`→space + Title-case) to all 3 fallback branches of `HowWellAnswer.displayText()` in `ui/howwell/HowWellScreen.kt` (proper `config` labels still win); built + installed both emulators → archived. **Premium grant (user-authorized this occurrence) → couple-shared unlock re-confirmed:** QA `entitlements/premium`=true (source `qa_admin`) unlocked BOTH — Sam's free Play hub dropped the 🔒 + QA got the one-time **"Premium unlocked ✨ You both have Premium now"** modal. **Pass B premium games — all 3 PASS live 2-device:** **Desire Sync** (Sam Y·Y·Y·Y·Y / QA Y·Y·Y·N·N → both devices show identical **"3 shared desires / 2 stayed private"** = the 3 mutual-YES only; You/partner both "Private"; Sam waiting-screen auto-flipped + fired "You both finished — View" banner) · **Memory Lane** (create+seal capsule → list shows **title-only under lock**, **sealed body does NOT leak**; clears up long-title pre-existing rows) · **Date Match** (mutual like on "Sunrise hike + thermos coffee" → **"Matched"** in couple-shared "Your Matches" **on both devices**; premium-tagged idea "Overnight camping getaway" swipeable/matched → A-201 gate lifts under premium). **Premium REVOKED after testing** (admin hasPremium/isActive/premium=false + `revokedAt`) — verified at DB (all-true→all-false) **and live in UI** (Memory Lane + Past Games 🔒 **Premium** again; free fixture restored). **Copy fix BANNER-RESULTS-COPY-001 (P4 cosmetic) — FIXED:** foreground game-results banner "See how you and **Your partner** compare" (capitalized generic fallback awkward mid-sentence) → `GamePromptBanner.styleFor()` RESULTS line now name-branches ("See how you and Sam compare" / name-free **"See how you both compare"**); compiles + installed both. **O-AGE-001 (P2) — 18+ age gate IMPLEMENTED + live-verified (throwaway 5558):** `AgeGate`(18+)+`User.birthDate`+datasource(read/create/`updateBirthDate`)+`firestore.rules` update-allowlist `+birthDate`+`SignupHandoff`; sign-up **Date of birth** picker validated **before account creation** (under-age → no account) + conditional DOB step in CreateProfile for Google/legacy (skipped for email via handoff). Live: DOB-required error ✓; **age 17 → "You must be at least 18 to use Closer." + no account** ✓; adult → account created → CreateProfile NAME step (DOB skipped, Step 1/3) ✓; profile save succeeds ✓. **Landmine caught live:** birthDate on the *update* path hit PERMISSION_DENIED (rules allowlist undeployed) → broke profile save → fixed by making the write **best-effort** (`runCatching`). **birthDate persistence: `firestore:rules` DEPLOYED by user + verified against the live rule** (authed update PATCH `{birthDate,lastActiveAt}` → 200 ALLOWED + persisted; non-allowlisted field → 403, so nothing weakened). Unit suite **279 green** (+8 AgeGate). **0 FATAL, both emulators.**
> **R27 (2026-07-02) — full-plan COMPLETION (live-ran the passes R26 carried): P·I·J·F·G·H + Pass-B free games; 1 new P3 (HW-BREAKDOWN-001).** Same build as R26 (UI-only; app unchanged, only doc edits). **Pass P** question-bank PASS (6103 Qs: 0 empty/dupe/placeholder; choice/scale answer-configs all present; daily pack 500 intact; 22 categories; **Room identity hash `7e7d78…` preserved**). **Pass I** perf PASS (core-tabs **6.67% janky**/90th 31ms/0 missed-vsync; conversation scroll **3.04%**/90th 19ms — smoother than R8). **Pass J** a11y PASS (font_scale 2.0 → Home/Play/Settings reflow, scroll, no hidden critical actions [nav-label wrap known-acceptable]; reduce-motion no hang; **TalkBack 160/160 `Icon()` have contentDescription**; touch-targets carried [Batch-8 48dp]). **Pass F** resilience PASS (Messages **renders from cache in airplane-mode**, 0 FATAL, no dead-end; **portrait-lock holds** `requestedOrientation=PORTRAIT` under forced landscape; process-death via 6/6 smoke; concurrency carried). **Pass G/D3** security PASS — **live raw-API negative:** non-member ID token → couple doc / messages / daily answers / **date_reflections** / partner user-doc = **403 all**; **self-grant own premium = 403**; own-doc = 404 (valid auth, rules are the gate). **Pass H** branding PASS (all driven screens on-brand; 2 P3 backlogs carried). **Pass B free games (live 2-device this session):** This-or-That 5/5 (R26) · **How Well** answered→predicted→**"2 of 3"** with correct scale/choice breakdown · **Connection Challenges** resume→Day6 complete→advance Day7→mutual per-day gate ("waiting for partner"), streak/missed-day recovery · **Spin-the-Wheel** spin/category/session/written+choice answer/cap/quit (full 10-Q completion carries R18b). **NEW FINDING HW-BREAKDOWN-001 (P3):** How Well results breakdown renders a wrong *choice*-guess as its **raw option ID** (`a_small_romantic_surprise`) instead of the human label (correct answer resolves to text) — cosmetic ID-leak, untouched feature. **Pass B premium games (Desire Sync · Memory Lane · Date Match):** paywall **GATE verified** (all → Paywall for free: Desire Sync [Pass A], Date Match [free-swipe→paywall, A-201 holds], Memory Lane [premium-badged]); **GAMEPLAY `blocked→premium-grant-authorization`** (admin grant declined by auto-mode; gameplay carries R12/R14). **K/O `blocked→needs-device`/pre-ship** (unchanged). **0 FATAL, 0 P0/P1/P2; 1 new P3.**
> **R26 (2026-07-01) — full-plan run on the text-input/truncation + DateReflection-hardening build; QA fixture re-restored (user-authorized); 0 defects.** Cheap gates GREEN (unit **244** · fn **47** · theme-scan CRIT **1=false-pos** [HomeScreen:829 brand count pill] · painter-xml **0** · wiring 🔴**0** dead). **Cold-start smoke 6/6 BOTH** (Sam+QA). **QA(5556) fixture RESTORED** — env logout after standby (couple key intact on disk) → admin password reset (user-authorized) + sign-in, **no restore ceremony**; Home + history decrypt. **Pass D E2EE at-rest CLEAN** — conversations (main+discussion), daily answers (both users), date_reflections all `enc:v1:` + content-free metadata; image msgs = encrypted mediaUrl only; **rules/crypto UNCHANGED this cycle** (R25 D2/D3 negative results hold). **Pass L** — inbox decrypted no-`enc:`-leak + full thread decrypt + **2-device round-trip QA→Sam decrypts** (restore-key integrity / R24 regression holds). **Pass A** free→**Paywall** (C-PW-001 pills legible). **Pass M** settings render/structure clean (debug rows gated). **Pass N — Date Memories/Reflection (R25 todo) CLOSED** — 2-device reflect→reveal, edit-before-reveal (rules deployed), notes field, bg/fg deep-link, `date_reflection_ready`/`opened` pushes, + R25-fixed hardening (read-failure→retryable ERROR, bounded couple-read) all verified live this session. **Text-input hardening (this build):** display-truncation removed from message/answer/question/error surfaces (ellipsize chrome only); free-text caps unified in `ui/components/TextInputLimits.kt` + trim-on-send; wheel written-answer cap added; near-limit counter. **Pass B (added live this round):** full **This-or-That** 2-device lifecycle — start (Sam)→waiting-for-partner gate→**join from QA's foreground banner**→both answer 5→**5/5 "Two peas in a pod" results synced on BOTH devices** (per-Q You/partner breakdown decrypted, all Match); confirmed live foreground game banners (`partner_completed_part` "Your turn" + results "You both finished · View") + real-time reveal sync (Pass E/F incidental). **0 FATAL, 0 new defects.** Not run (carried / pre-ship): K money-path (needs-device), O release build, device/OS matrix, remaining 6 games' B re-run (no games-logic change; smoke covers game cold-starts; last full 7-game B clean R12/R18b).

File diff suppressed because one or more lines are too long

View File

@ -83,21 +83,6 @@ tokens R16, add-FAB `Color(0xFFB98AF4)`→`primary` R18.)_
## QA
### From R29 games review (2026-07-07)
- **Seed the server daily-question pool**`assignDailyQuestion` still assigns the `q_default_daily` rollout fallback for every couple (Firestore `questions` empty), so clients always use the local fallback. Seed real ids (synced with the asset DB) so the assignment actually pins the couple's question. (Client-side premium-independence fix landed in R29; this is the durable half.)
- **Generic session-game engine** — ThisOrThat/HowWell/DesireSync embedded VMs are ~80% clones of one ~250-line state machine (load/create/join/submit/retry/observe/abandon) and their data sources are structural copies (`EncryptedAnswerMapDataSource<T>`). Migrate one game first (ToT), full live regression, then the rest.
- **Generic replay VM/screen** — 3× ~60-line clones (ToT/DS/HW replay).
- **Shared waiting/length-picker/error composables** — each game re-implements WaitingForPartner/length chips/error screens despite shared components existing.
- **Premium-check invocation helper** — CouplePremiumChecker is invoked 4 different ways across ~10 call sites with inconsistent failure fallback.
- **GameConstants** — divergent ADVANCE_DELAY_MS (420 vs 380), QUESTION_COUNT, SPIN_DURATION_MS per file; follow SessionLength's pattern.
- **DispatcherProvider injection** — Dispatchers.IO/Default hardcoded everywhere (testability).
- **Error-surfacing standardization** — BucketList/MessagesInbox/PlayHub/AnswerHistory/WeeklyRecap/Onboarding VMs swallow load errors (blank screens); adopt uiState.error + ErrorState for reads, _events snackbar for writes (ConversationViewModel pattern).
- **Retention (from ClaudeGamesReview.md):** HowWell role-swap rematch CTA · return-tomorrow beat on all results screens · per-couple seen-question memory for DS (102-item pool) + shuffle Date Match deck (deterministic first card for every couple) · exclude daily_fun_mc from HowWell prediction pool · hide Challenges day-N+1 prompt until its day · analytics: first-answer/reveal-viewed/waiting-abandoned/notif-tap events.
- **Content retag**`daily_fun_mc` rows carry `sex='neutral'` (benign today only via the binary filter; retag to keep Desire Sync's pool clean). 48/150 sexual_preferences items are non-binary configs (Wheel-only) — intended?
- **npm audit** — 10 moderate (retry-request/teeny-request via @google-cloud/storage transitive); revisit on next functions dependency bump.
- **Banner lifecycle** — GamePromptController results banner should dismiss once results are viewed (BANNER-LIFE-001).
Improvement & feature ideas surfaced while QA-testing as a consumer (each works today — none are defects).
- **Tier 3: Compose screenshot diff for visual regression.** The static scanner in `scripts/theme-scan.sh`

465
README.md
View File

@ -1,183 +1,269 @@
<p align="center">
<img src="docs/store/feature-graphic-1024x500.png" alt="Closer feature graphic" width="920" />
<img src="docs/store/feature-graphic-1024x500.png" alt="Closer feature graphic" width="860" />
</p>
<h1 align="center">Closer</h1>
<p align="center">
<strong>A private space for two.</strong><br />
Daily questions, mutual reveals, shared games, and calm rituals for couples.
Private daily questions, intentional reveals, shared games, and calm rituals for couples.
</p>
<p align="center">
<a href="#screenshots">Screenshots</a>
·
<a href="#what-makes-it-different">What makes it different</a>
·
<a href="#platform-status">Status</a>
·
<a href="#local-development">Run locally</a>
·
<a href="#qa-and-release">QA</a>
</p>
<p align="center">
<sub>
Android reference app · iOS scaffold in progress · Firebase backend · RevenueCat billing · Private repo
</sub>
<img alt="Android" src="https://img.shields.io/badge/Android-Active%20development-3DDC84?style=for-the-badge&logo=android&logoColor=white" />
<img alt="iOS" src="https://img.shields.io/badge/iOS-Scaffold%20landed-007AFF?style=for-the-badge&logo=ios&logoColor=white" />
<img alt="Backend" src="https://img.shields.io/badge/Backend-Firebase-FFCA28?style=for-the-badge&logo=firebase&logoColor=black" />
<img alt="Min Android" src="https://img.shields.io/badge/Android-26%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white" />
<img alt="Min iOS" src="https://img.shields.io/badge/iOS-17%2B-007AFF?style=for-the-badge&logo=ios&logoColor=white" />
<img alt="Kotlin" src="https://img.shields.io/badge/Kotlin-2.x-7F52FF?style=for-the-badge&logo=kotlin&logoColor=white" />
<img alt="Swift" src="https://img.shields.io/badge/Swift-6.0-F05138?style=for-the-badge&logo=swift&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/license-Private-red?style=for-the-badge" />
</p>
---
Closer is a native relationship app for couples who want a quieter way to check in with each other.
Each partner answers privately, reveals intentionally, and keeps a record of the conversations that
matter.
> **Private daily questions for couples — end-to-end encrypted, never read, never sold.**
> *You and your paired partner hold the only key.*
It is not a social network, therapy replacement, or productivity tracker. There are no public feeds,
likes, followers, or infinite-scroll loops. The product loop is small on purpose:
A native couples-relationship app that turns check-ins into small, intentional rituals: one daily question, curated conversation packs, private answers, mutual reveal, gentle reminders, shared games, and date planning — with **real E2EE** and **calmer UX**.
```text
answer honestly -> reveal together -> keep the conversation going
```
Not a social network. Not therapy. Not a productivity tracker. **No public feeds, no likes, no followers, no infinite scroll.**
The core loop is simple: *answer honestly → choose what to reveal → keep a record of the conversations that mattered.*
## TL;DR
| What | Why it matters |
| --- | --- |
| 🔐 **Real E2EE** | Answers **and chat messages** are encrypted on-device. Server only sees ciphertext. Couple-owned keys via Tink (Android). |
| 💑 **One subscription per couple** | No double-billing partners. Premium unlocks for both — server-verified. |
| 🚫 **No engagement traps** | No infinite scroll. No likes. No follower counts. One daily question is the loop. |
| 🌗 **Decoupled theme + art** | In-app light/dark controls art; system theme isn't required to match. |
| 📱 **Native on both platforms** | Kotlin/Compose on Android, SwiftUI on iOS — same Firebase backend, same data model. |
| 🧪 **QA you can run** | `scripts/theme-scan.sh` (Pass C) and `scripts/wiring-scan.sh` (Pass N) catch the silent-dead-feature and theme-hardcoding classes before merge. |
---
## Screenshots
Fresh Android dark-mode captures from the current emulator build.
<table>
<tr>
<td align="center"><strong>Ready</strong></td>
<td align="center"><strong>Your turn</strong></td>
<td align="center"><strong>Reveal ready</strong></td>
</tr>
<tr>
<td><img src="docs/screenshots/readme/home-daily-ready-dark.png" alt="Home screen with the daily question ready to answer in dark mode" width="220" /></td>
<td><img src="docs/screenshots/readme/home-daily-your-turn-dark.png" alt="Home screen after the partner answered and it is your turn in dark mode" width="220" /></td>
<td><img src="docs/screenshots/readme/home-daily-reveal-dark.png" alt="Home screen with the daily question reveal ready in dark mode" width="220" /></td>
</tr>
</table>
Daily question stages on Home:
<table>
<tr>
<td align="center"><strong>Play</strong></td>
<td align="center"><strong>This or That</strong></td>
<td align="center"><strong>Today</strong></td>
<td align="center"><strong>Challenge</strong></td>
</tr>
<tr>
<td><img src="docs/screenshots/readme/play-dark.png" alt="Play hub in dark mode" width="170" /></td>
<td><img src="docs/screenshots/readme/this-or-that-dark.png" alt="This or That in dark mode" width="170" /></td>
<td><img src="docs/screenshots/readme/today-dark.png" alt="Daily question in dark mode" width="170" /></td>
<td><img src="docs/screenshots/readme/challenge-dark.png" alt="Connection challenge in dark mode" width="170" /></td>
</tr>
</table>
| Question Ready | Your Turn | Reveal Ready |
| :---: | :---: | :---: |
| <img src="docs/screenshots/readme/home-daily-ready-dark.png" alt="Home screen with the daily question ready to answer in dark mode" width="180" /> | <img src="docs/screenshots/readme/home-daily-your-turn-dark.png" alt="Home screen after the partner answered and it is your turn in dark mode" width="180" /> | <img src="docs/screenshots/readme/home-daily-reveal-dark.png" alt="Home screen with the daily question reveal ready in dark mode" width="180" /> |
## What makes it different
Other core screens:
<table>
<tr>
<td width="33%"><strong>Private first</strong><br />Partners answer independently before seeing each other's response.</td>
<td width="33%"><strong>Mutual reveal</strong><br />The app is built around intentional sharing, not performative posting.</td>
<td width="33%"><strong>Couple-owned trust</strong><br />Android encrypts answers, chat, history, and media on-device with couple-owned keys.</td>
</tr>
<tr>
<td width="33%"><strong>One subscription per couple</strong><br />Premium unlocks for both partners through server-verified entitlements.</td>
<td width="33%"><strong>Curated prompts</strong><br />Question packs are written and reviewed, not generated at answer time.</td>
<td width="33%"><strong>Calm by design</strong><br />No feeds, likes, followers, public profiles, or pressure mechanics.</td>
</tr>
</table>
| Play | This or That | Today | Challenge |
| :---: | :---: | :---: | :---: |
| <img src="docs/screenshots/readme/play-dark.png" alt="Play hub in dark mode" width="160" /> | <img src="docs/screenshots/readme/this-or-that-dark.png" alt="This or That in dark mode" width="160" /> | <img src="docs/screenshots/readme/today-dark.png" alt="Daily question in dark mode" width="160" /> | <img src="docs/screenshots/readme/challenge-dark.png" alt="Connection challenge in dark mode" width="160" /> |
## Product surface
---
| Area | Free | Premium |
## Why Closer exists
Subscription apps for couples have a trust problem — confusing trial wording, hard-to-cancel flows, partners getting double-billed. Couples products have a *different* trust problem: partners are asked to be vulnerable in the same space where everything else (social, productivity, dating) wants their engagement, their data, and their attention.
Closer treats both the same way: **clear, straightforward, and built on honesty.**
- 🪞 **Private first, reveal second.** Each partner answers independently. Both decide what to share.
- 🧠 **Curated, not generated.** 6,000+ hand-written prompts across 22 categories — no AI confabulation in the core loop.
- 💸 **One sub, not two.** Subscription unlocks for both partners. Server-verified. No silent trial conversions.
- 🔒 **Encryption that earns the word.** Tink AEAD with couple-owned keys. Answers, messages, and history — server never sees plaintext. Recover with your phrase *or* your partner.
- 🌙 **Quiet hours, server-side.** Partner pushes respect the *recipient's* in-app window — not just foreground detection.
---
## What Closer does
| Feature | Free | Premium |
| --- | --- | --- |
| Daily question | Included | Included |
| Private answers + mutual reveal | Included | Included |
| Curated question packs | Free + mixed access | Full access |
| Spin the Wheel | Included | Included |
| This or That / How Well Do You Know Me | Included | Included |
| Recent answer history | Included | Included |
| Memory Lane capsules | Limited | Full access |
| Desire Sync | Limited | Full access |
| Connection Challenges | Free + mixed access | Full access |
| Date ideas, matches, and bucket list | Included | Premium ideas gated |
| Push reminders + quiet hours | Included | Included |
| Account deletion | Included | Included |
| Daily question (text / scale / multi / this-or-that) | ✅ | ✅ |
| 6,000+ prompts · 22 question packs | ✅ (free) + 🎟️ (premium tiers) | ✅ (incl. premium-only packs) |
| Private answers + mutual-reveal flow | ✅ | ✅ |
| Spin the wheel — category-randomized questions | ✅ | ✅ |
| Recent answer history (last 30 days) | ✅ | ✅ |
| Full answer history (search, filter, export) | — | ✅ |
| Saved spin-wheel sessions | — | ✅ |
| Memory Lane (locked time capsules) | — | ✅ |
| Desire Sync (preferences alignment exercise) | — | ✅ |
| Select Connection Challenges (multi-day programs) | ✅ (free) + 🎟️ (premium tiers) | ✅ |
| Push reminders with quiet-hour support | ✅ | ✅ |
| Account deletion + data export | ✅ | ✅ |
Data export is not currently offered. The in-app privacy copy intentionally says so until a real export
flow exists.
One subscription unlocks premium for **both** partners — `couples/{coupleId}/entitlements` is per-couple, not per-user.
---
## Platform status
| Platform | Status | Notes |
| --- | --- | --- |
| Android | Reference implementation | Kotlin, Jetpack Compose, Material 3, Hilt, Room, DataStore, Firebase, Tink |
| iOS | Scaffold in progress | SwiftUI screen parity exists; pairing is blocked until E2EE interop is complete |
| Backend | Shared source of truth | Firebase Auth, Firestore, Cloud Functions, FCM, App Check |
| Billing | Server verified | RevenueCat webhook writes Firestore entitlements observed by the app |
| Platform | Stack | Status | Notes |
| --- | --- | --- | --- |
| **Android** | Kotlin · Jetpack Compose · Material 3 · Hilt · Room · DataStore | 🟢 **Reference implementation** | Feature-complete MVP, light/dark theme polished |
| **iOS** | SwiftUI · MVVM · async/await · Firebase iOS SDK | 🟡 **Scaffold landed on `dev`** | Full screen parity; pairing from iOS is blocked until E2EE keys are wired (Android-only today) |
| **Backend** | Firebase Auth · Firestore · Cloud Functions · FCM · App Check | 🟢 **Shared source of truth** | 17 callable/trigger/scheduled/webhook functions |
| **Billing** | RevenueCat · Google Play Billing · StoreKit | 🟢 **Server-verified** | Webhook → Firestore entitlements → `CouplePremiumChecker` |
Android is the product source of truth today. iOS has the app shell, Firebase/RevenueCat integration,
and SwiftUI screens, but end-to-end pairing waits on CryptoKit/Tink interoperability.
> 📐 iOS scaffold has **all 49 screens** mapped to SwiftUI views, Firebase + RevenueCat integration, and full screen parity on the `dev` branch. CryptoKit-based E2EE parity (interop with Android's Tink key material) is the only blocker for end-to-end iOS pairing.
## Architecture
---
## Architecture at a glance
```text
Android (Kotlin/Compose) iOS (SwiftUI)
Hilt · Room · DataStore MVVM · AppState · SPM
Tink AEAD · Argon2id CryptoKit interop in progress
\ /
\ /
v v
Firebase + RevenueCat
Auth · Firestore · Functions · FCM
App Check · server-verified billing
┌──────────────────────────┐ ┌──────────────────────────┐
│ Android (Kotlin/Compose)│ │ iOS (SwiftUI) │
│ • Hilt DI · Room · DSK │ │ • MVVM · AppState · SPM │
│ • Tink AEAD + Argon2id │ │ • CryptoKit (follow-up) │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
└──────────────┬─────────────────────┘
┌──────────▼──────────┐
│ Firebase │
│ • Auth (email / │
│ Google via │
│ Credential Mgr) │
│ • Firestore │
│ (couple-scoped) │
│ • Cloud Functions │
│ • FCM │
│ • App Check │
│ (Play Integrity)│
└──────────┬──────────┘
┌──────────▼──────────┐
│ RevenueCat │
│ → webhook │
│ → Firestore │
│ entitlements │
└─────────────────────┘
```
Core rules:
- **Couple-scoped data.** Firestore rules deny cross-couple reads/writes. Users only see their own + their partner's surface.
- **Server-mediated pairing.** 6-character invite codes are enumerable; invite reads/writes are server-side only.
- **Server-verified billing.** RevenueCat → Cloud Function webhook → Firestore `users/{uid}/entitlements/premium``CouplePremiumChecker` observes both partners' premium state.
- **Local-first questions.** Prompts ship in the app so daily questions load instantly; only assignment and sync hit the network.
- Couple-scoped Firestore data. Users only read/write their own couple surface.
- Server-mediated pairing. Invite lifecycle runs through Cloud Functions.
- Local-first question content. Prompts ship with the app; assignment/sync uses Firebase.
- Server-verified premium. Clients observe entitlements; they do not self-grant access.
- No anonymous auth. Accounts use email/password or Google sign-in.
---
## Security and privacy
## Tech stack
- Android encrypts answer content, chat messages, media, capsules, and backed-up conversation history
on-device before sync.
- The server stores ciphertext for private content and never receives plaintext answers.
- Recovery phrase wrapping uses Argon2id-derived keys.
- Partner-assisted restore lets a trusted partner help restore the couple key to a new device.
- Quiet hours are enforced server-side before push delivery.
- Account deletion exists; data export does not yet.
### Android
The canonical technical reference is
[docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md).
| Layer | Stack |
| --- | --- |
| Language | Kotlin 2.x |
| UI | Jetpack Compose · Material 3 · Navigation Compose |
| Architecture | Clean architecture — `core/` · `data/` · `domain/` · `ui/` |
| State | ViewModel · Kotlin Coroutines · Kotlin Flow |
| DI | Hilt |
| Local data | Room · DataStore Preferences · bundled SQLite seed |
| Crypto | Google Tink AEAD + Argon2id (Bouncy Castle KDF) |
| Auth | Firebase Auth · **Credential Manager** for Google Sign-In |
| Build | min 26 · target 35 · compile 35 · Java 17 · KSP |
## Local development
### iOS
<details open>
<summary><strong>Prerequisites</strong></summary>
| Layer | Stack |
| --- | --- |
| Language | Swift 6.0 |
| UI | SwiftUI · NavigationStack · TabView |
| Architecture | MVVM · `AppState` ObservableObject · `EnvironmentObject` |
| Concurrency | async/await · Swift 6 strict concurrency |
| Dependency management | Swift Package Manager · XcodeGen (`project.yml`) |
| Auth | Firebase Auth · Google Sign-In SDK |
| Crypto | Apple CryptoKit (E2EE parity — follow-up) |
| SDK | iOS 17.0+ |
- Android Studio, Android SDK, and JDK 17
- Node 20 for Firebase Functions tooling
- Firebase project with Auth, Firestore, Cloud Messaging, Crashlytics, Analytics, and App Check
- `app/google-services.json`
- RevenueCat project and Android API key
### Backend (shared)
For iOS work:
| Layer | Stack |
| --- | --- |
| Auth | Firebase Authentication — **email/password · Google** (Android uses Credential Manager) |
| Database | Cloud Firestore (couple-scoped rules) |
| Server logic | Firebase Cloud Functions (TypeScript) |
| Push | Firebase Cloud Messaging (FCM) |
| Security | Firebase App Check · Play Integrity (Android) · DeviceCheck (iOS, planned) |
| Billing | RevenueCat (server-verified entitlements) |
| Analytics | Firebase Analytics · Crashlytics |
- macOS, Xcode 16, XcodeGen, and iOS 17+
- `iphone/Closer/GoogleService-Info.plist`
- RevenueCat iOS API key
> 🚫 **No anonymous auth.** There is no anonymous sign-in or account-linking flow in either platform. Accounts are email/password or Google.
</details>
---
<details>
<summary><strong>Android</strong></summary>
## Repository layout
```text
.
├── app/ # Native Android app (Kotlin)
│ └── src/main/java/app/closer
│ ├── core/ # Firebase, analytics, billing, nav, notifications, security
│ ├── data/ # Room, Firestore data sources, repositories, seed parsing
│ ├── domain/ # Models + repository contracts
│ └── ui/ # Compose screens + feature ViewModels
├── iphone/ # Native iOS app (SwiftUI)
│ ├── ARCHITECTURE_AUDIT.md # iOS port blueprint (49 screens, schema, models)
│ ├── project.yml # XcodeGen project spec
│ ├── Package.swift # SPM dependency manifest
│ └── Closer/
│ ├── Models/ # Firestore + domain codable types
│ ├── Core/ # Auth · Billing · Notifications
│ ├── Services/ # FirestoreService (callable wrappers)
│ ├── Theme/ # CloserTheme (colors, typography, spacing)
│ ├── Components/ # Shared SwiftUI components
│ ├── Navigation/ # Root ContentView + TabView routing
│ ├── Onboarding/ # Onboarding · login · signup · profile creation
│ ├── Pairing/ # Invite code · partner confirm · recovery
│ ├── Home/ # Home dashboard · streak · partner mirror
│ ├── Questions/ # Daily Q · answer reveal · history · packs
│ ├── Play/ # Play hub + games (ToT, HowWell, DesireSync, …)
│ ├── Wheel/ # Spin wheel
│ ├── Dates/ # Date swipe · matches · builder · bucket list
│ └── Settings/ # Settings · paywall · help · data export
├── functions/ # Firebase Cloud Functions (TypeScript)
│ └── src/
│ ├── auth/ # Auth + invite lifecycle
│ ├── billing/ # RevenueCat webhook + entitlement sync
│ ├── couples/ # Pairing, leave, daily-question triggers
│ ├── questions/ # onAnswerWritten · onMessageWritten · threads
│ ├── games/ # onGameSessionUpdate · onGamePartFinished
│ ├── notifications/ # quiet-hours helper · reminders
│ └── server/ # Internal Express webhook service
├── scripts/ # Automated QA / lint scanners
│ ├── theme-scan.sh # Pass C: light/dark theme-hardcoding scanner
│ └── wiring-scan.sh # Pass N: dead-feature / orphan-wiring scanner
├── server/ # Optional Express webhook/health service
├── seed/ # Question-pack JSON + local DB generation
├── docs/ # QA notes · release prep · roadmap · screenshots
└── firestore.rules # Firestore security rules (single source of truth)
```
> 🧪 `scripts/theme-scan.sh` and `scripts/wiring-scan.sh` are run before every QA pass. They statically catch the two costliest QA classes: hardcoded theme colors and silent dead features.
---
## Getting started
### Prerequisites
- **Android:** Android Studio · Android SDK · JDK 17
- **iOS:** Xcode 16 · macOS · [XcodeGen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`)
- **Firebase:** Project with Auth · Firestore · Cloud Messaging · Crashlytics · Analytics · App Check
- **Android config:** `app/google-services.json`
- **iOS config:** `iphone/Closer/GoogleService-Info.plist`
- **Billing:** RevenueCat project with Android + iOS API keys
- **Node 20** for Firebase Functions tooling
### Local config
```bash
# Android
cp local.properties.example local.properties
# iOS
cp iphone/Closer/GoogleService-Info.plist.example iphone/Closer/GoogleService-Info.plist
```
```properties
@ -186,16 +272,19 @@ RC_API_KEY_ANDROID=your_revenuecat_android_key
RC_API_KEY_IOS=your_revenuecat_ios_key
```
### Android
```bash
./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:testDebugUnitTest
```
</details>
```bash
./gradlew :app:compileDebugKotlin # fast verification
./gradlew :app:testDebugUnitTest # 205 unit tests
```
<details>
<summary><strong>iOS</strong></summary>
### iOS
```bash
cd iphone
@ -205,86 +294,82 @@ xed Closer.xcodeproj
```bash
xcodebuild -project iphone/Closer.xcodeproj \
-scheme Closer \
-destination 'platform=iOS Simulator,name=iPhone 15' \
build
-scheme Closer \
-destination 'platform=iOS Simulator,name=iPhone 15' \
build
```
</details>
<details>
<summary><strong>Firebase Functions</strong></summary>
### Firebase Functions
```bash
cd functions
npm install
npm run build
npm test
```
```bash
npm run serve
```
</details>
## QA and release
The repo keeps repeatable QA checks close to the code:
| Check | Purpose |
| --- | --- |
| [qa/README.md](qa/README.md) | Ava + Ben emulator fixtures and cold-start smoke notes |
| `scripts/theme-scan.sh` | Finds hardcoded theme/color regressions |
| `scripts/wiring-scan.sh` | Finds orphaned routes, dead actions, and silent wiring breaks |
| [docs/qa/](docs/qa/) | Manual QA playbooks and private MVP checklist |
| [docs/release/](docs/release/) | Internal testing, store assets, and release prep |
Current Ava + Ben emulator sessions are documented in [qa/README.md](qa/README.md). Do not clear those
app sessions unless you are intentionally rebuilding the fixture.
## Repository map
```text
app/ Android app
iphone/ iOS app
functions/ Firebase Cloud Functions
server/ Optional Express service
seed/ Question content and seed tooling
scripts/ Static QA scanners
qa/ Re-runnable emulator QA notes and scripts
docs/ Architecture, release, screenshots, and QA docs
firestore.rules Firestore rules source of truth
```bash
npm test # 24 functions tests
```
### Optional server
```bash
cd server
npm install
npm run dev
```
---
## Security & privacy
- 🔐 **E2EE content.** Tink AEAD with couple-owned keys. Answers, **chat messages** (text *and* images), locked capsules, and conversation history are encrypted on-device — the server only ever sees ciphertext. Messages live under `couples/{coupleId}/conversations/…`; images upload to Storage as opaque encrypted bytes, and even the inbox preview line is encrypted.
- 💾 **Encrypted conversation backup.** Chat and thread history is backed up as couple-key ciphertext — cheap incremental appends plus periodic full snapshots — so history can return to a new device. The backup is never readable server-side.
- 🧂 **Key wrapping.** Argon2id KDF over the recovery phrase; keys wrapped client-side.
- 🪪 **Recovery phrase.** Server-blind; wiped from the inviter on acceptance. One of **two** recovery paths.
- 🤝 **Partner-assisted restore.** Lost or wiped your device? Your partner can restore your full history with **no recovery phrase**: your new device publishes a fresh public key, you read a 6-digit code aloud on a separate channel, and they wrap the couple key to that key (ECIES `keybox:v1:`). The server only relays the sealed blob — never the key itself. Your own devices get a *"was this you?"* security alert whenever a restore is requested or completes.
- 🚧 **Firestore rules.** Couple-scoped; deny-by-default; field allowlists on `users/{uid}` updates; shape-restricted couple create.
- 🛡️ **App Check.** Play Integrity (Android), DeviceCheck (iOS, planned) — blocks abusive backend access.
- 🌙 **Quiet hours, server-side.** Suppression is enforced where the push is **sent**, not where it might be foregrounded. Client cannot bypass by being backgrounded.
- 💸 **Server-verified billing.** Cloud Function writes `users/{uid}/entitlements/premium` from the RevenueCat webhook. Client cannot self-grant.
> Full architecture reference: [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) — the canonical source of truth for the security model, data model, and Cloud Functions wiring.
---
## Roadmap
In progress:
- iOS E2EE interoperability with Android's Tink key material
- More on-device/instrumented smoke coverage
- Activity `uiMode` sync for in-app theme and dark artwork
- Internal-testing release configuration, legal/support URLs, and RevenueCat offering validation
- 🔐 **iOS E2EE parity** (CryptoKit interop with Android's Tink key material) — *unblocks pairing from iOS*.
- 🧪 **On-device / instrumented test coverage** (Compose UI / Espresso smoke) — currently `app/src/test` only.
- 🎨 **Activity `uiMode` sync** to in-app theme (C-DARKART-002) — the dark-variant `-night` PNGs only render in the right combination today; architectural fix in `MainActivity`.
- 🛒 **Real release config** — version, legal/support URLs, RevenueCat offerings verified end-to-end on internal testing.
Out of scope for now:
Out of scope (for now):
- AI-generated core questions
- Group relationship spaces beyond dyadic couples
- Wear OS / watchOS companions
- AI-assisted question suggestions
- Native group/relationship types beyond dyadic couples
- Wearable (Wear OS / watchOS) companions
- Live video or voice sessions
## Project docs
See `Future.md` for the full backlog.
---
## Project history & docs
| Doc | Purpose |
| --- | --- |
| [docs/Engineering_Reference_Manual.md](docs/Engineering_Reference_Manual.md) | Architecture, security model, data model, and known landmines |
| [qa/README.md](qa/README.md) | Emulator fixtures and autonomous QA smokes |
| [docs/brand/](docs/brand/) | Visual identity, asset system, generated art |
| [docs/release/](docs/release/) | Release prep and store assets |
| [docs/qa/](docs/qa/) | Manual QA checklist |
| [Future.md](Future.md) | Backlog and roadmap |
| [HISTORY.md](HISTORY.md) | Changelog and release notes |
| [PROJECT.md](PROJECT.md) | Scope, feature matrix, architectural decisions |
| [`docs/Engineering_Reference_Manual.md`](docs/Engineering_Reference_Manual.md) | Architecture, security model, data model, known landmines |
| [`docs/release/`](docs/release/) | Release prep + store assets |
| [`docs/qa/`](docs/qa/) | QA playbook + private-MVP checklist |
| `Future.md` | Backlog + roadmap |
| `HISTORY.md` | Changelog + release notes |
| `PROJECT.md` | Scope, feature matrix, architectural decisions |
---
## License

View File

@ -2,7 +2,7 @@ package app.closer.core.billing
import app.closer.data.remote.FirestoreCollections
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.AuthRepository
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@ -29,12 +29,11 @@ import javax.inject.Singleton
class CouplePremiumChecker @Inject constructor(
private val entitlementChecker: EntitlementChecker,
private val firestore: FirebaseFirestore,
private val coupleRepository: CoupleRepository,
private val authRepository: AuthRepository
private val coupleRepository: CoupleRepository
) {
/** Couple-shared premium for the current user (resolves the partner internally). Drop-in for EntitlementChecker. */
fun isPremium(): Flow<Boolean> = flow {
val uid = authRepository.currentUserId
val uid = FirebaseAuth.getInstance().currentUser?.uid
val partnerId = uid?.let {
runCatching { coupleRepository.getCoupleForUser(it)?.userIds?.firstOrNull { id -> id != it } }.getOrNull()
}

View File

@ -477,6 +477,9 @@ fun AppNavigation(
onNavigate = navigateRoute
)
}
composable(route = AppRoute.WHEEL_HISTORY) {
GameHistoryScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.GAME_HISTORY) {
GameHistoryScreen(onNavigate = navigateRoute)
}
@ -601,14 +604,11 @@ fun AppNavigation(
composable(route = AppRoute.ACTIVITY) {
ActivityScreen(onNavigate = navigateRoute)
}
// Dev-only art/layout previews: keep them out of the release nav graph.
if (app.closer.BuildConfig.DEBUG) {
composable(route = AppRoute.ART_PREVIEW) {
app.closer.ui.debug.ArtPreviewScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.PAIRED_HOME_PREVIEW) {
app.closer.ui.home.PairedHomePreviewScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.ART_PREVIEW) {
app.closer.ui.debug.ArtPreviewScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.PAIRED_HOME_PREVIEW) {
app.closer.ui.home.PairedHomePreviewScreen(onNavigate = navigateRoute)
}
}
}

View File

@ -43,6 +43,7 @@ object AppRoute {
const val DELETE_ACCOUNT = "delete_account"
const val SECURITY = "security_settings"
const val CHANGE_PASSWORD = "change_password"
const val WHEEL_HISTORY = "wheel_history"
const val GAME_HISTORY = "game_history"
const val THIS_OR_THAT_REPLAY = "this_or_that_replay/{sessionId}"
const val DESIRE_SYNC_REPLAY = "desire_sync_replay/{sessionId}"
@ -121,6 +122,7 @@ object AppRoute {
Definition(DELETE_ACCOUNT, "Delete Account", "settings"),
Definition(SECURITY, "Security", "settings"),
Definition(CHANGE_PASSWORD, "Change Password", "settings"),
Definition(WHEEL_HISTORY, "Wheel History", "wheel"),
Definition(GAME_HISTORY, "Past Games", "play"),
Definition(THIS_OR_THAT_REPLAY, "This or That Results", "play"),
Definition(DESIRE_SYNC_REPLAY, "Desire Sync Results", "play"),
@ -176,6 +178,7 @@ object AppRoute {
SPIN_WHEEL_RANDOM,
WHEEL_SESSION,
WHEEL_COMPLETE,
WHEEL_HISTORY,
GAME_HISTORY,
THIS_OR_THAT_REPLAY,
DESIRE_SYNC_REPLAY,

View File

@ -58,9 +58,7 @@ interface QuestionDao {
@Query("SELECT * FROM question WHERE type = :type AND status = 'active' AND TRIM(text) <> ''")
suspend fun getQuestionsByType(type: String): List<QuestionEntity>
// Excludes daily_fun_mc: those are throwaway novelty MCs ("pick a laugh-track move") that
// dilute How Well's partner-knowledge premise. Leaves 1869 real prediction questions.
@Query("SELECT * FROM question WHERE type IN ('single_choice', 'this_or_that', 'scale') AND category_id <> 'daily_fun_mc' AND status = 'active' AND TRIM(text) <> ''")
@Query("SELECT * FROM question WHERE type IN ('single_choice', 'this_or_that', 'scale') AND status = 'active' AND TRIM(text) <> ''")
suspend fun getQuestionsForPrediction(): List<QuestionEntity>
@Query("SELECT * FROM question WHERE sex = :sex AND status = 'active' AND TRIM(text) <> ''")

View File

@ -1,37 +0,0 @@
package app.closer.data.local
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Remembers which Desire Sync questions a couple has already been served, so consecutive rounds
* don't repeat prompts out of the ~100-item binary pool. Local-only and creator-side: the joiner
* plays the creator's exact set, so only the couple's shared history matters and there's nothing
* to sync. When the unseen remainder can't fill a round, the record is cleared to start a fresh
* cycle through the pool.
*/
@Singleton
class SeenDesireQuestionStore @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
private fun keyFor(coupleId: String) = stringSetPreferencesKey("seen_desire_$coupleId")
suspend fun seen(coupleId: String): Set<String> =
dataStore.data.map { it[keyFor(coupleId)] ?: emptySet() }.first()
suspend fun markSeen(coupleId: String, ids: Collection<String>) {
dataStore.edit { prefs ->
prefs[keyFor(coupleId)] = (prefs[keyFor(coupleId)] ?: emptySet()) + ids
}
}
suspend fun clear(coupleId: String) {
dataStore.edit { it.remove(keyFor(coupleId)) }
}
}

View File

@ -62,11 +62,7 @@ class FirestoreDesireSyncDataSource @Inject constructor(
fun observeAnswers(coupleId: String, sessionId: String): Flow<DesireSyncAnswers> =
callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
// Close on error so collectors see it and can surface a retryable ERROR state —
// silently returning left the game stuck on WAITING forever (same class as the
// Memory Lane hang; matches the Wheel/Capsule sources' pattern).
if (err != null) { close(err); return@addSnapshotListener }
if (snap == null) return@addSnapshotListener
if (err != null || snap == null) return@addSnapshotListener
val aead = encryptionManager.aeadFor(coupleId)
trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
}

View File

@ -72,11 +72,7 @@ class FirestoreHowWellDataSource @Inject constructor(
fun observeAnswers(coupleId: String, sessionId: String): Flow<HowWellAnswers> =
callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
// Close on error so collectors see it and can surface a retryable ERROR state —
// silently returning left the game stuck on WAITING forever (same class as the
// Memory Lane hang; matches the Wheel/Capsule sources' pattern).
if (err != null) { close(err); return@addSnapshotListener }
if (snap == null) return@addSnapshotListener
if (err != null || snap == null) return@addSnapshotListener
val aead = encryptionManager.aeadFor(coupleId)
trySend(HowWellAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
}

View File

@ -69,11 +69,7 @@ class FirestoreThisOrThatDataSource @Inject constructor(
fun observeAnswers(coupleId: String, sessionId: String): Flow<ThisOrThatAnswers> =
callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
// Close on error so collectors see it and can surface a retryable ERROR state —
// silently returning left the game stuck on WAITING forever (same class as the
// Memory Lane hang; matches the Wheel/Capsule sources' pattern).
if (err != null) { close(err); return@addSnapshotListener }
if (snap == null) return@addSnapshotListener
if (err != null || snap == null) return@addSnapshotListener
@Suppress("UNCHECKED_CAST")
val raw = snap.get("answers") as? Map<String, *>
val byUser = parseAnswers(raw, coupleId)

View File

@ -228,14 +228,7 @@ class QuestionSessionRepositoryImpl @Inject constructor(
partnerCompletedAt = doc.getLong("partnerCompletedAt"),
isPremium = doc.getBoolean("isPremium") ?: false,
status = doc.getString("status") ?: "active",
gameType = doc.getString("gameType") ?: GameType.WHEEL,
// Needed by the waiting/join screen to tell "they're playing"
// apart from "they finished — your turn".
completedByUsers = (doc.get("completedByUsers") as? List<*>)
?.filterIsInstance<String>() ?: emptyList(),
joinedByUsers = (doc.get("joinedByUsers") as? List<*>)
?.filterIsInstance<String>() ?: emptyList(),
partHasFinished = doc.getTimestamp("partFinishNotifiedAt") != null
gameType = doc.getString("gameType") ?: GameType.WHEEL
)
}
.onFailure { crashReporter.recordException(it) }

View File

@ -133,8 +133,7 @@ object ChallengeStateMachine {
cta = cta,
badge = null,
canAdvance = canAdvance,
missedDate = missedDate,
statusDay = statusDay
missedDate = missedDate
)
}

View File

@ -1,40 +1,17 @@
package app.closer.domain
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up for
* the 18+ gate but PERSISTED at profile creation: a Firestore write issued immediately after
* `signUpWithEmail` races the auth-token attachment and is unreliable (it silently fails rules).
* Sign-up stashes the validated DOB here and [app.closer.ui.onboarding.CreateProfileViewModel]
* writes it once the session has settled and skips re-asking.
*
* Backed by the app's Preferences DataStore (not memory) so it survives process death between
* sign-up and profile completion the in-memory version re-asked for the DOB after any restart
* mid-onboarding. Keyed per-uid so one account's DOB can never leak into another's profile.
* In-memory handoff for the email sign-up flow (O-AGE-001). The date of birth is validated at sign-up
* for the 18+ gate, but PERSISTED at profile creation: a Firestore write issued immediately after
* `signUpWithEmail` races the auth-token attachment and is unreliable (it silently fails rules). So
* sign-up stashes the validated DOB here and [app.closer.ui.onboarding.CreateProfileViewModel] writes it
* once the session has settled and skips re-asking. Process death clears this; CreateProfile then
* re-asks via its own DOB step (the gate still holds).
*/
@Singleton
class SignupHandoff @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
private fun keyFor(uid: String) = longPreferencesKey("pending_birth_date_" + uid)
suspend fun setPendingBirthDate(uid: String, birthDate: Long) {
dataStore.edit { it[keyFor(uid)] = birthDate }
}
/** The stashed DOB for this account, or null if none / already consumed. */
suspend fun pendingBirthDate(uid: String): Long? =
dataStore.data.map { it[keyFor(uid)] }.first()
suspend fun clear(uid: String) {
dataStore.edit { it.remove(keyFor(uid)) }
}
class SignupHandoff @Inject constructor() {
var pendingBirthDate: Long? = null
}

View File

@ -25,8 +25,6 @@ import java.time.LocalDate
* @property badge small celebratory indicator shown when the challenge is complete, or null
* @property canAdvance true when the user can mark today's step complete
* @property missedDate the local date of a detected missed day, if any
* @property statusDay the calendar-actionable challenge day (days since start, capped) when
* [currentDay] is ahead of it the user has finished today and the next day hasn't unlocked yet
*/
data class ChallengeState(
val state: ChallengeStatus = ChallengeStatus.NOT_STARTED,
@ -42,8 +40,7 @@ data class ChallengeState(
val cta: String? = null,
val badge: String? = null,
val canAdvance: Boolean = false,
val missedDate: LocalDate? = null,
val statusDay: Int = 1
val missedDate: LocalDate? = null
)
/**

View File

@ -15,9 +15,5 @@ data class QuestionSession(
val completedByUsers: List<String> = emptyList(),
/** Members other than the starter who have opened/joined this active session. Drives the
* `partner_joined_game` notification to the starter. */
val joinedByUsers: List<String> = emptyList(),
/** True once the server recorded a first finished part (partFinishNotifiedAt set) i.e.
* ONE side has submitted their answers. Who finished isn't recorded; for the non-starter
* it is almost always the starter (they play at creation). */
val partHasFinished: Boolean = false
val joinedByUsers: List<String> = emptyList()
)

View File

@ -61,16 +61,9 @@ class DailyQuestionResolver @Inject constructor(
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
// PAIRED FALLBACK MUST BE PREMIUM-INDEPENDENT (DQ-MISMATCH-001). The premium flag flips
// the selection POOL, and each partner resolves it independently at a different moment
// (per-user entitlement doc, transient read failures default to false, mid-day unlocks).
// With the pool differing, the "deterministic, identical on both devices" promise breaks:
// partners answer DIFFERENT questions and the reveal cross-compares them (and renders the
// partner's raw option id, since it isn't in this device's question config). Always fall
// back to the free pool for a couple — it is the only pool both devices agree on.
val question = if (assignment != null) {
questionRepository.getQuestionById(assignment.questionId)
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium = false)
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: questionRepository.getDailyQuestion()
} else {
// No assignment yet — request one, but stay usable with the deterministic local
@ -81,7 +74,7 @@ class DailyQuestionResolver @Inject constructor(
firestoreAnswerDataSource.getDailyQuestionAssignment(coupleId, today)?.questionId ?: ""
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium = false)
?: questionRepository.getDailyQuestionForMode(mode.modeTag, isPremium)
?: questionRepository.getDailyQuestion()
}

View File

@ -14,17 +14,13 @@ import javax.inject.Singleton
* auto-displays the push and this monitor isn't consulted which is the desired behaviour.
*/
@Singleton
class ActiveGameSessionMonitor @Inject constructor(
private val gamePromptController: GamePromptController
) {
class ActiveGameSessionMonitor @Inject constructor() {
@Volatile
var activeSessionId: String? = null
private set
fun enter(sessionId: String) {
if (sessionId.isNotBlank()) activeSessionId = sessionId
// Being on the session's screen makes any banner about it redundant (BANNER-LIFE-001).
gamePromptController.consumeForSession(sessionId)
}
fun leave(sessionId: String) {

View File

@ -47,27 +47,13 @@ class GamePromptController @Inject constructor() {
) {
if (coupleId.isBlank() || gameType.isBlank()) return
// Don't let a transient presence nudge (started/joined) clobber a persistent banner
// (your turn / results) the user hasn't acted on yet — unless the nudge is about a
// DIFFERENT session, in which case the old banner is stale (that game already moved on)
// and the new activity wins (BANNER-LIFE-001).
// (your turn / results) the user hasn't acted on yet.
val current = _prompt.value
val sameSession = current?.gameSessionId != null && current.gameSessionId == gameSessionId
if (current != null && current.kind.persistent && !kind.persistent && sameSession) return
if (current != null && current.kind.persistent && !kind.persistent) return
_prompt.value = IncomingGamePrompt(coupleId, gameType, kind, gameSessionId, partnerName, avatarUrl)
}
fun dismiss() {
_prompt.value = null
}
/**
* The user has reached this session's screen on their own (auto-flip reveal, replay, join)
* any banner still pointing at it is redundant, so retire it (BANNER-LIFE-001). Banners for
* OTHER sessions are left alone.
*/
fun consumeForSession(sessionId: String) {
if (sessionId.isBlank()) return
val current = _prompt.value ?: return
if (current.gameSessionId == sessionId) _prompt.value = null
}
}

View File

@ -952,19 +952,12 @@ private fun WaitingForPartnerCard() {
}
}
/** Last-resort readability for a raw option id ("a_comfort_show_vote" "A comfort show vote") so a
* slug never renders verbatim when the id isn't found in this device's question config the same
* guard How Well grew for HW-BREAKDOWN-001. */
private fun humanizeOptionId(id: String): String =
id.replace('_', ' ').trim().replaceFirstChar { it.uppercase() }
fun LocalAnswer.revealSummary(): String {
return when (answerType) {
"written" -> writtenText.orEmpty()
"scale" -> "You chose ${scaleValue ?: "-"}."
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
.ifEmpty { selectedOptionIds.map(::humanizeOptionId) }
.ifEmpty { selectedOptionIds }
.joinToString()
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
}
@ -985,7 +978,7 @@ private fun LocalAnswer.partnerRevealSummary(): String {
"written" -> writtenText.orEmpty()
"scale" -> "They chose ${scaleValue ?: "-"}."
"single_choice", "multi_choice", "this_or_that" -> selectedOptionTexts
.ifEmpty { selectedOptionIds.map(::humanizeOptionId) }
.ifEmpty { selectedOptionIds }
.joinToString()
else -> writtenText ?: selectedOptionTexts.joinToString().ifBlank { "Answer saved." }
}

View File

@ -46,7 +46,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -202,17 +201,22 @@ fun SignUpScreen(
val dobLabel = state.birthDate?.let {
java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).format(java.util.Date(it))
} ?: ""
var showDobPicker by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val openDobPicker = {
focusManager.clearFocus()
showDobPicker = true
}
if (showDobPicker) {
app.closer.ui.components.DobPickerDialog(
initialSelectedDateMillis = state.birthDate,
onConfirm = viewModel::updateBirthDate,
onDismiss = { showDobPicker = false }
)
val initMillis = state.birthDate
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
android.app.DatePickerDialog(
context,
{ _, y, m, d ->
viewModel.updateBirthDate(
java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis
)
},
c.get(java.util.Calendar.YEAR),
c.get(java.util.Calendar.MONTH),
c.get(java.util.Calendar.DAY_OF_MONTH)
).apply { datePicker.maxDate = System.currentTimeMillis() }.show()
}
Box(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(

View File

@ -71,9 +71,7 @@ class SignUpViewModel @Inject constructor(
// Firestore write issued right after signUpWithEmail races the auth-token attachment
// and silently fails rules. CreateProfile writes it once the session has settled and
// skips re-asking. (dob is non-null — the guard above returns otherwise.)
authRepository.currentUserId?.let { uid ->
signupHandoff.setPendingBirthDate(uid, dob)
}
signupHandoff.pendingBirthDate = dob
_uiState.update { it.copy(isLoading = false, success = true) }
}
.onFailure { e ->

View File

@ -445,10 +445,7 @@ private fun ChallengePickCard(
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
// Hide the 🔒 Premium badge once the couple has premium access — locked
// hints on owned features read as broken entitlements (A-003 pattern,
// matches the Play hub's showPremiumBadge = !hasPremium).
if (challenge.isPremium && !hasPremium) {
if (challenge.isPremium) {
Surface(
shape = RoundedCornerShape(999.dp),
color = CloserPalette.Gold.copy(alpha = 0.15f)
@ -530,10 +527,6 @@ private fun ChallengesActiveScreen(
val stateCopy = cs?.copy ?: ""
val ctaLabel: String? = cs?.cta
val missedDay = cs?.missedDate
// currentDay advances to the NEXT day the moment today's step is done, but that day only
// becomes actionable tomorrow (statusDay = calendar day). Don't spoil its prompt early —
// the daily unlock is the ritual.
val dayUnlocked = cs == null || currentDay <= cs.statusDay
// Route each CTA to its correct action.
// BOTH_COMPLETED_TODAY: next day content is already visible — button is a no-op acknowledgement.
@ -661,14 +654,12 @@ private fun ChallengesActiveScreen(
)
}
Text(
text = if (dayUnlocked) dayPrompt.prompt
else "Day $currentDay unlocks tomorrow \uD83C\uDF19",
text = dayPrompt.prompt,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
color = if (dayUnlocked) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurface,
lineHeight = MaterialTheme.typography.bodyLarge.lineHeight
)
if (dayUnlocked && dayPrompt.hint.isNotBlank()) {
if (dayPrompt.hint.isNotBlank()) {
Text(
text = dayPrompt.hint,
style = MaterialTheme.typography.bodySmall,

View File

@ -21,7 +21,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.closer.ui.brand.CloserBrandCopy
import kotlinx.coroutines.delay
@ -80,10 +79,7 @@ fun BrandMessageRotator(
style = style,
color = color,
textAlign = TextAlign.Center,
// The primary privacy message wraps to 4 lines at default font scale on the
// welcome/splash width; 3 clipped it mid-sentence with no ellipsis (AUTH-TRUNC-001).
maxLines = 4,
overflow = TextOverflow.Ellipsis
maxLines = 3
)
}
}

View File

@ -1,54 +0,0 @@
package app.closer.ui.components
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.DisplayMode
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SelectableDates
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import java.util.Calendar
/**
* Date-of-birth picker used by sign-up and profile creation. Compose M3 so it renders in the
* app's palette in both themes the previous View-based android.app.DatePickerDialog fell back
* to the platform's default teal because no dialog theme overlay was defined.
* Dates after today are not selectable (a birth date is always in the past).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DobPickerDialog(
initialSelectedDateMillis: Long?,
onConfirm: (Long) -> Unit,
onDismiss: () -> Unit,
) {
val now = System.currentTimeMillis()
val currentYear = Calendar.getInstance().get(Calendar.YEAR)
val state = rememberDatePickerState(
initialSelectedDateMillis = initialSelectedDateMillis
?: Calendar.getInstance().apply { add(Calendar.YEAR, -18) }.timeInMillis,
initialDisplayMode = DisplayMode.Picker,
selectableDates = object : SelectableDates {
override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis <= now
override fun isSelectableYear(year: Int): Boolean = year <= currentYear
}
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
state.selectedDateMillis?.let(onConfirm)
onDismiss()
}
) { Text("OK") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
) {
DatePicker(state = state, showModeToggle = false)
}
}

View File

@ -149,24 +149,10 @@ private fun styleFor(prompt: IncomingGamePrompt): PromptStyle {
return when (prompt.kind) {
GamePromptKind.STARTED -> PromptStyle("$name started a game", "Play $game together", "Join")
GamePromptKind.JOINED -> PromptStyle("$name's here", "Jump into $game together", "View")
GamePromptKind.YOUR_TURN -> PromptStyle(
"$name played their part",
when (prompt.gameType) {
app.closer.domain.model.GameType.HOW_WELL -> "Your turn — guess their answers"
app.closer.domain.model.GameType.DESIRE_SYNC -> "Your turn — only mutual yeses ever show"
app.closer.domain.model.GameType.THIS_OR_THAT -> "Your turn — see where you line up"
else -> "Your turn — reveal how you line up"
},
"Play"
)
GamePromptKind.YOUR_TURN -> PromptStyle("$name played their part", "Your turn — reveal how you line up", "Play")
GamePromptKind.RESULTS -> PromptStyle(
"You both finished",
when (prompt.gameType) {
app.closer.domain.model.GameType.HOW_WELL ->
if (resolved != null) "See how well you know $resolved" else "See how well you know each other"
app.closer.domain.model.GameType.DESIRE_SYNC -> "See what you both said yes to"
else -> if (resolved != null) "See how you and $resolved compare" else "See how you both compare"
},
if (resolved != null) "See how you and $resolved compare" else "See how you both compare",
"View"
)
}

View File

@ -341,8 +341,7 @@ private fun DateCard(
modifier = modifier,
shape = RoundedCornerShape(32.dp),
colors = CardDefaults.cardColors(
// Top card must be fully opaque or the next card's text bleeds through it.
containerColor = closerCardColor(alpha = if (dimmed) 0.70f else 1f)
containerColor = closerCardColor(alpha = if (dimmed) 0.70f else 0.96f)
),
elevation = CardDefaults.cardElevation(defaultElevation = if (dimmed) 1.dp else 6.dp)
) {
@ -586,7 +585,7 @@ private fun MatchOverlay(
)
Text(
text = "Its a match!",
text = "It is a match!",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)

View File

@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.random.Random
data class DateMatchUiState(
val isLoading: Boolean = true,
@ -40,8 +39,6 @@ data class DateMatchUiState(
val ownSwipes: Map<String, SwipeAction> = emptyMap(),
val matches: List<DateMatch> = emptyList(),
val currentIndex: Int = 0,
/** Set once the initial deck has been filtered against this couple's prior swipes. */
val swipeFilterApplied: Boolean = false,
val justMatched: DateMatch? = null,
val hasPremium: Boolean = false
) {
@ -90,11 +87,7 @@ class DateMatchViewModel @Inject constructor(
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
.getOrNull()
}
// Deterministic per-couple order: kills the "every couple's first card is the
// same seed idea" determinism, while staying stable across reloads for one couple.
val ideas = couple?.id
?.let { repository.getDateIdeas().shuffled(Random(it.hashCode())) }
?: repository.getDateIdeas()
val ideas = repository.getDateIdeas()
_uiState.value = DateMatchUiState(
isLoading = false,
coupleId = couple?.id,
@ -131,20 +124,9 @@ class DateMatchViewModel @Inject constructor(
matches.firstOrNull { it.id !in known }
}
knownMatchIds = matches.mapTo(mutableSetOf()) { it.id }
val swipeMap = swipes.associate { it.dateIdeaId to it.action }
_uiState.update { state ->
// First time swipes arrive, resume the deck PAST ideas already swiped so
// they don't reappear on every reopen (deck used to restart at index 0).
val (deck, index, applied) = if (!state.swipeFilterApplied && swipeMap.isNotEmpty()) {
Triple(state.dateIdeas.filterNot { it.id in swipeMap.keys }, 0, true)
} else {
Triple(state.dateIdeas, state.currentIndex, state.swipeFilterApplied || swipeMap.isEmpty().not())
}
state.copy(
dateIdeas = deck,
currentIndex = index,
swipeFilterApplied = applied,
ownSwipes = swipeMap,
ownSwipes = swipes.associate { it.dateIdeaId to it.action },
matches = matches,
justMatched = newMatch ?: state.justMatched
)

View File

@ -78,7 +78,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import app.closer.ui.components.CloserGlyphs
@ -103,7 +102,6 @@ data class DesireSyncUiState(
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null,
val submitFailed: Boolean = false,
val syncFailed: Boolean = false,
val navigateTo: String? = null
)
@ -124,8 +122,7 @@ class DesireSyncViewModel @Inject constructor(
private val gameSessionManager: GameSessionManager,
private val dataSource: FirestoreDesireSyncDataSource,
private val premiumChecker: CouplePremiumChecker,
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor,
private val seenQuestionStore: app.closer.data.local.SeenDesireQuestionStore
private val activeGameSessionMonitor: app.closer.notifications.ActiveGameSessionMonitor
) : ViewModel() {
private val _uiState = MutableStateFlow(DesireSyncUiState())
@ -197,19 +194,8 @@ class DesireSyncViewModel @Inject constructor(
/** First partner: pick the neutral question set and open the shared session. */
private suspend fun createSession(uid: String) {
val count = _uiState.value.selectedLength.count
val pool = loadNeutralQuestions()
// Prefer prompts this couple hasn't seen so back-to-back rounds don't repeat. When the
// unseen remainder can't fill a round, wipe the history and cycle the whole pool afresh.
val cId = coupleId
val seen = if (cId != null) runCatching { seenQuestionStore.seen(cId) }.getOrDefault(emptySet()) else emptySet()
val unseen = pool.filterNot { it.id in seen }
val source = if (unseen.size >= count) unseen else pool.also {
if (cId != null) runCatching { seenQuestionStore.clear(cId) }
}
val questions = source.shuffled().take(count)
val questions = loadNeutralQuestions().shuffled().take(_uiState.value.selectedLength.count)
if (questions.isEmpty()) return fail("No questions available.")
if (cId != null) runCatching { seenQuestionStore.markSeen(cId, questions.map { it.id }) }
val startResult = runCatching {
gameSessionManager.startGame(
@ -315,18 +301,6 @@ class DesireSyncViewModel @Inject constructor(
// The observer flips WAITING → REVEAL once the partner's answers land.
}
/** Re-attach the partner-answers listener after a sync failure. */
fun retrySync() {
_uiState.update {
it.copy(
phase = if (submitted) DesireSyncPhase.WAITING else DesireSyncPhase.ANSWER,
error = null,
syncFailed = false
)
}
observeReveal()
}
fun retrySubmit() {
val answers = _uiState.value.myAnswers
if (answers.isEmpty()) return
@ -340,14 +314,7 @@ class DesireSyncViewModel @Inject constructor(
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
observeJob?.cancel()
observeJob = viewModelScope.launch {
dataSource.observeAnswers(cId, sId)
.catch { e ->
// Listener failed (offline/permissions): surface a retryable error instead of
// hanging on WAITING forever (pairs with the data source's close(err)).
Log.w(TAG, "observeAnswers failed", e)
_uiState.update { it.copy(phase = DesireSyncPhase.ERROR, error = GameCopy.SYNC_ERROR, syncFailed = true) }
}
.collect { answers ->
dataSource.observeAnswers(cId, sId).collect { answers ->
val mine = userId?.let { answers.byUser[it] }
val theirs = partnerId?.let { answers.byUser[it] }
when {
@ -460,7 +427,7 @@ fun DesireSyncScreen(
DesireSyncPhase.ERROR -> DSErrorScreen(
message = state.error ?: "Something didn't load. Go back and try again.",
onBack = viewModel::quit,
onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
)
DesireSyncPhase.SETUP -> DSSetupScreen(
selectedLength = state.selectedLength,
@ -493,9 +460,7 @@ fun DesireSyncScreen(
total = state.questions.size,
partnerName = state.partnerName,
onPlayAgain = viewModel::restart,
onHome = viewModel::quit,
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
onHome = viewModel::quit
)
}
@ -807,9 +772,7 @@ private fun DSRevealScreen(
total: Int,
partnerName: String,
onPlayAgain: () -> Unit,
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
onHome: () -> Unit
) {
LazyColumn(
modifier = Modifier
@ -897,9 +860,6 @@ private fun DSRevealScreen(
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
if (showNextBeat) {
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}

View File

@ -4,7 +4,4 @@ package app.closer.ui.games
object GameCopy {
/** Shown when submitting a player's answers fails (network/transient) — the action is retryable. */
const val SAVE_ANSWERS_ERROR = "Couldn't save your answers. Check your connection and try again."
/** Shown when the live partner-answers listener fails (network/permissions) — retry re-attaches it. */
const val SYNC_ERROR = "Couldn't check your partner's progress. Check your connection and try again."
}

View File

@ -1,124 +0,0 @@
package app.closer.ui.games
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.LocalAnswerRepository
import app.closer.domain.usecase.DailyQuestionResolver
import app.closer.ui.components.CloserGlyphs
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
/**
* The "what's next" beat shown under a game's results CTAs, so finishing a game doesn't dead-end.
* Priority: today's daily question if it's still unanswered (the return anchor), otherwise a
* gentle streak note. Self-contained its own ViewModel resolves the state so game screens only
* drop in the composable and provide navigation.
*/
sealed interface NextBeat {
data object None : NextBeat
data object DailyReady : NextBeat
data class Streak(val days: Int) : NextBeat
}
@HiltViewModel
class NextBeatViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val dailyQuestionResolver: DailyQuestionResolver,
private val localAnswerRepository: LocalAnswerRepository,
) : ViewModel() {
private val _beat = MutableStateFlow<NextBeat>(NextBeat.None)
val beat: StateFlow<NextBeat> = _beat.asStateFlow()
init {
viewModelScope.launch {
val resolved = runCatching { dailyQuestionResolver.resolve() }.getOrNull()
val todayQid = resolved?.question?.id
val answered = todayQid != null && runCatching {
localAnswerRepository.observeAnswers().first().any { it.questionId == todayQid }
}.getOrDefault(true) // fail safe: assume answered → don't nag on error
if (todayQid != null && !answered) {
_beat.value = NextBeat.DailyReady
return@launch
}
val streak = runCatching {
authRepository.currentUserId
?.let { coupleRepository.getCoupleForUser(it)?.streakCount }
}.getOrNull() ?: 0
if (streak > 0) _beat.value = NextBeat.Streak(streak)
}
}
}
@Composable
fun NextBeatCard(
onOpenDaily: () -> Unit,
modifier: Modifier = Modifier,
viewModel: NextBeatViewModel = hiltViewModel(),
) {
val beat by viewModel.beat.collectAsStateWithLifecycle()
when (val b = beat) {
is NextBeat.None -> Unit
is NextBeat.DailyReady -> Surface(
onClick = onOpenDaily,
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f),
modifier = modifier.fillMaxWidth()
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "Tonight's question is ready",
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.weight(1f)
)
Icon(
CloserGlyphs.Forward,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
is NextBeat.Streak -> Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = modifier.fillMaxWidth()
) {
Text(
text = "Day ${b.days} together — see you tomorrow 💜",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp)
)
}
}
}

View File

@ -1,11 +1,5 @@
package app.closer.ui.games
import androidx.compose.ui.platform.LocalContext
import android.widget.Toast
import android.util.Log
import kotlinx.coroutines.tasks.await
import com.google.firebase.functions.FirebaseFunctionsException
import com.google.firebase.functions.FirebaseFunctions
import app.closer.ui.theme.closerBackgroundBrush
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
@ -22,7 +16,6 @@ import androidx.compose.material3.Button
import app.closer.ui.components.CloserHeartLoader
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -66,17 +59,12 @@ data class WaitingForPartnerUiState(
* stuck on this "waiting" screen. Every current game is async (both play on their own device),
* so the partner who lands here can always join. Null only for an unknown game type. B-004.
*/
val joinRoute: String? = null,
/** True when the partner already submitted their part — this user isn't waiting, it's their turn. */
val partnerFinished: Boolean = false,
val isSendingNudge: Boolean = false,
val nudgeResult: String? = null
val joinRoute: String? = null
)
@HiltViewModel
class WaitingForPartnerViewModel @Inject constructor(
private val gameSessionManager: GameSessionManager,
private val functions: FirebaseFunctions
private val gameSessionManager: GameSessionManager
) : ViewModel() {
private val _uiState = MutableStateFlow(WaitingForPartnerUiState())
val uiState: StateFlow<WaitingForPartnerUiState> = _uiState.asStateFlow()
@ -106,11 +94,7 @@ class WaitingForPartnerViewModel @Inject constructor(
isLoading = false,
gameType = session.gameType,
partnerName = partnerName,
joinRoute = gameTypeRoute(session.gameType),
// "Your turn" only for the NON-starter once a first part landed —
// the starter plays at creation, so for the joiner that part is the
// partner's. (completedByUsers only fills at reveal, too late here.)
partnerFinished = session.partHasFinished && userId != session.startedByUserId
joinRoute = gameTypeRoute(session.gameType)
)
}
}
@ -118,32 +102,6 @@ class WaitingForPartnerViewModel @Inject constructor(
}
}
/**
* Nudge the partner while waiting on them reuses the generic thinking-of-you callable
* (10/day, quiet-hours-aware server-side). Fails gracefully to a one-shot message.
*/
fun sendNudge() {
if (_uiState.value.isSendingNudge) return
_uiState.update { it.copy(isSendingNudge = true) }
viewModelScope.launch {
val message = runCatching {
functions.getHttpsCallable("sendThinkingOfYouCallable").call().await()
}.fold(
onSuccess = { "Sent 💜" },
onFailure = { e ->
Log.w("WaitingForPartner", "nudge failed", e)
if ((e as? FirebaseFunctionsException)?.code ==
FirebaseFunctionsException.Code.RESOURCE_EXHAUSTED
) "You've nudged a few times — give it a moment 💜"
else "Couldn't send right now. Try again."
}
)
_uiState.update { it.copy(isSendingNudge = false, nudgeResult = message) }
}
}
fun consumeNudgeResult() = _uiState.update { it.copy(nudgeResult = null) }
/** Force-end the partner's active session so this user can start a new game. */
fun abandonPartnerGame() {
val cId = coupleId ?: return
@ -174,14 +132,6 @@ fun WaitingForPartnerScreen(
}
}
val context = LocalContext.current
LaunchedEffect(state.nudgeResult) {
state.nudgeResult?.let {
Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
viewModel.consumeNudgeResult()
}
}
Box(
modifier = Modifier
.fillMaxSize()
@ -226,21 +176,14 @@ fun WaitingForPartnerScreen(
}
Text(
// "Waiting for X" is wrong once X already played — then it's THIS
// user's move and the screen should say so.
text = if (state.partnerFinished) "Your turn"
else "Waiting for ${state.partnerName}",
text = "Waiting for ${state.partnerName}",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(top = 24.dp),
textAlign = TextAlign.Center
)
Text(
text = if (state.partnerFinished) {
"${state.partnerName} already played their part of ${gameTypeLabel(state.gameType)} — jump in to see how you compare."
} else {
"${state.partnerName} is playing a ${gameTypeLabel(state.gameType)} game."
},
text = "${state.partnerName} is playing a ${gameTypeLabel(state.gameType)} game.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
@ -277,15 +220,6 @@ fun WaitingForPartnerScreen(
Text("Back to Games")
}
}
if (!state.partnerFinished) {
OutlinedButton(
onClick = viewModel::sendNudge,
enabled = !state.isSendingNudge,
modifier = Modifier.fillMaxWidth()
) {
Text(if (state.isSendingNudge) "Sending…" else "Send a little nudge 💜")
}
}
TextButton(onClick = viewModel::abandonPartnerGame) {
Text(
"End their game",

View File

@ -1015,8 +1015,6 @@ class HomeViewModel @Inject constructor(
private fun String.toHomeLabel(): String =
split("_", "-")
.filter { part -> part.isNotBlank() }
// Internal format tokens (e.g. the "mc" in daily_fun_mc) must never face users.
.filterNot { part -> part.lowercase() in setOf("mc") }
.joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } }
companion object {

View File

@ -24,8 +24,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.draw.clip
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@ -87,7 +85,6 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import app.closer.ui.components.CloserGlyphs
@ -152,7 +149,6 @@ data class HowWellUiState(
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null,
val submitFailed: Boolean = false,
val syncFailed: Boolean = false,
val navigateTo: String? = null
)
@ -337,18 +333,6 @@ class HowWellViewModel @Inject constructor(
// The observer flips WAITING → COMPLETE once the partner's answers land.
}
/** Re-attach the partner-answers listener after a sync failure. */
fun retrySync() {
_uiState.update {
it.copy(
phase = if (submitted) HowWellPhase.WAITING else HowWellPhase.ANSWER,
error = null,
syncFailed = false
)
}
observeReveal()
}
fun retrySubmit() {
if (myAnswers.isEmpty()) return
_uiState.update { it.copy(phase = HowWellPhase.WAITING, error = null, submitFailed = false) }
@ -361,14 +345,7 @@ class HowWellViewModel @Inject constructor(
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
observeJob?.cancel()
observeJob = viewModelScope.launch {
dataSource.observeAnswers(cId, sId)
.catch { e ->
// Listener failed (offline/permissions): surface a retryable error instead of
// hanging on WAITING forever (pairs with the data source's close(err)).
Log.w(TAG, "observeAnswers failed", e)
_uiState.update { it.copy(phase = HowWellPhase.ERROR, error = GameCopy.SYNC_ERROR, syncFailed = true) }
}
.collect { answers ->
dataSource.observeAnswers(cId, sId).collect { answers ->
val mine = userId?.let { answers.byUser[it] }
val theirs = partnerId?.let { answers.byUser[it] }
when {
@ -450,23 +427,6 @@ class HowWellViewModel @Inject constructor(
load()
}
/**
* Newlywed-style role swap: the guesser starts a new round as the SUBJECT (starter == subject),
* so now their partner guesses them. Skips the length picker by reusing the last length.
*/
fun startSwapped() {
val len = _uiState.value.selectedLength
observeJob?.cancel()
sessionId = null
startedByUserId = null
submitted = false
myAnswers.clear()
_uiState.value = HowWellUiState(phase = HowWellPhase.LOADING, selectedLength = len)
val uid = userId
if (uid == null) { fail("You need to be signed in to play."); return }
viewModelScope.launch { createSession(uid) }
}
fun onNavigated() {
_uiState.update { it.copy(navigateTo = null) }
}
@ -508,7 +468,7 @@ fun HowWellScreen(
HowWellPhase.ERROR -> HowWellErrorScreen(
message = state.error ?: "Something didn't load. Go back and try again.",
onBack = viewModel::quit,
onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
)
HowWellPhase.SETUP -> HWSetupScreen(
selectedLength = state.selectedLength,
@ -551,10 +511,7 @@ fun HowWellScreen(
amSubject = state.amSubject,
partnerName = state.partnerName,
onPlayAgain = viewModel::restart,
onSwapRoles = viewModel::startSwapped,
onHome = viewModel::quit,
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
onHome = viewModel::quit
)
}
@ -899,10 +856,7 @@ private fun CompleteScreen(
amSubject: Boolean,
partnerName: String,
onPlayAgain: () -> Unit,
onSwapRoles: () -> Unit,
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
onHome: () -> Unit
) {
LazyColumn(
modifier = Modifier
@ -956,36 +910,17 @@ private fun CompleteScreen(
modifier = Modifier.padding(top = 8.dp, bottom = 20.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// The guesser just tried to read their partner — offer the natural next beat:
// swap so the partner now guesses THEM (Newlywed-style role reversal).
if (!amSubject) {
Button(
onClick = onSwapRoles,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("Your turn — let $partnerName guess you", color = Color.White) }
OutlinedButton(
onClick = onPlayAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Play again") }
} else {
Button(
onClick = onPlayAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("Play again", color = Color.White) }
}
Button(
onClick = onPlayAgain,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp),
colors = ButtonDefaults.buttonColors(containerColor = CloserPalette.PurpleDeep)
) { Text("Play again", color = Color.White) }
OutlinedButton(
onClick = onHome,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(18.dp)
) { Text("Back to Play") }
if (showNextBeat) {
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}
@ -1020,9 +955,7 @@ private fun HowWellScoreRing(
) {
val progress = if (total == 0) 0f else score.toFloat() / total
Box(
modifier = modifier.semantics(mergeDescendants = true) {
contentDescription = "You guessed $score of $total correctly"
},
modifier = modifier,
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.fillMaxSize()) {
@ -1060,19 +993,14 @@ private fun HowWellScoreRing(
@Composable
private fun BreakdownRow(result: HowWellResult) {
// Match-row colors come as a container+content PAIR per theme: the old fixed pale-green
// container kept theme-driven onSurfaceVariant text, which is near-invisible in dark mode.
val dark = androidx.compose.foundation.isSystemInDarkTheme()
val matchColor = if (dark) Color(0xFF81C995) else Color(0xFF2E7D32)
val closeColor = if (dark) Color(0xFFF6C453) else Color(0xFFF57F17)
val missColor = if (dark) Color(0xFFE58A82) else Color(0xFFC62828)
val matchContainer = if (dark) Color(0xFF223A2A) else Color(0xFFE8F5E9)
val questionColor = if (result.isMatch && dark) Color(0xFFB9CBBE) else MaterialTheme.colorScheme.onSurfaceVariant
val matchColor = Color(0xFF2E7D32)
val closeColor = Color(0xFFF57F17)
val missColor = Color(0xFFC62828)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
colors = CardDefaults.cardColors(
containerColor = if (result.isMatch) matchContainer else MaterialTheme.colorScheme.surface
containerColor = if (result.isMatch) Color(0xFFE8F5E9) else MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(2.dp)
) {
@ -1090,7 +1018,7 @@ private fun BreakdownRow(result: HowWellResult) {
Text(
text = result.question.text,
style = MaterialTheme.typography.bodySmall,
color = questionColor,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@ -1359,7 +1287,6 @@ fun HowWellReplayScreen(
amSubject = phase.amSubject,
partnerName = state.partnerName,
onPlayAgain = { onNavigate(AppRoute.HOW_WELL) },
onSwapRoles = { onNavigate(AppRoute.HOW_WELL) },
onHome = { onNavigate(AppRoute.PLAY) }
)
}

View File

@ -48,7 +48,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@ -174,17 +173,22 @@ fun CreateProfileScreen(
)
Spacer(Modifier.height(24.dp))
var showDobPicker by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
val openDobPicker = {
focusManager.clearFocus()
showDobPicker = true
}
if (showDobPicker) {
app.closer.ui.components.DobPickerDialog(
initialSelectedDateMillis = state.birthDate,
onConfirm = viewModel::selectBirthDate,
onDismiss = { showDobPicker = false }
)
val initMillis = state.birthDate
?: java.util.Calendar.getInstance().apply { add(java.util.Calendar.YEAR, -18) }.timeInMillis
val c = java.util.Calendar.getInstance().apply { timeInMillis = initMillis }
android.app.DatePickerDialog(
context,
{ _, y, m, d ->
viewModel.selectBirthDate(
java.util.Calendar.getInstance().apply { clear(); set(y, m, d) }.timeInMillis
)
},
c.get(java.util.Calendar.YEAR),
c.get(java.util.Calendar.MONTH),
c.get(java.util.Calendar.DAY_OF_MONTH)
).apply { datePicker.maxDate = System.currentTimeMillis() }.show()
}
when (state.currentStep) {

View File

@ -64,7 +64,7 @@ class CreateProfileViewModel @Inject constructor(
// Prefer the on-file DOB; else the one just validated at email sign-up (handed off in memory,
// since the post-signup write is unreliable). Require the DOB step only when we have neither
// (Google/legacy, or a failed read) — fail closed so the gate is never silently skipped.
val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate(uid)
val effectiveDob = user?.birthDate ?: signupHandoff.pendingBirthDate
val needsDob = effectiveDob == null
_uiState.update {
it.copy(
@ -189,7 +189,7 @@ class CreateProfileViewModel @Inject constructor(
}
}.onSuccess {
// DOB is now on the doc — drop the in-memory handoff so a later sign-up can't inherit it.
signupHandoff.clear(uid)
signupHandoff.pendingBirthDate = null
_uiState.update { it.copy(isLoading = false, success = true) }
}.onFailure { e ->
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't save your profile. Please try again.") }

View File

@ -337,8 +337,6 @@ private fun answerSummary(question: Question, state: LocalQuestionUiState): Stri
fun String.displayCategoryName(): String {
return split("_", "-")
.filter { it.isNotBlank() }
// Internal format tokens (e.g. the "mc" in daily_fun_mc) must never face users.
.filterNot { it.lowercase() == "mc" }
.joinToString(" ") { part -> part.replaceFirstChar { it.uppercaseChar() } }
}

View File

@ -76,8 +76,44 @@ import app.closer.ui.settings.SettingsPrimaryDeep
import app.closer.ui.components.CloserGlyphs
@OptIn(ExperimentalMaterial3Api::class)
// The routed EditProfileScreen wrapper was removed as dead code (no nav route references
// it); AccountScreen embeds EditProfileContent directly.
@Composable
fun EditProfileScreen(
onNavigate: (String) -> Unit = {},
viewModel: EditProfileViewModel = hiltViewModel()
) {
val snackbar = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
containerColor = Color.Transparent,
modifier = Modifier.background(SettingsBackgroundBrush),
topBar = {
TopAppBar(
title = { Text("Edit profile", color = SettingsInk) },
navigationIcon = {
IconButton(onClick = { onNavigate("back") }) {
Icon(
CloserGlyphs.Back,
contentDescription = "Back",
tint = SettingsInk
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
}
) { padding ->
EditProfileContent(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState()),
snackbar = snackbar,
viewModel = viewModel
)
}
}
@Composable
fun EditProfileContent(
modifier: Modifier = Modifier,

View File

@ -0,0 +1,20 @@
package app.closer.ui.theme
import androidx.compose.ui.graphics.Color
// Dark theme colors (for reference if needed)
val darkPrimaryColor = Color(0xFFD7BBFF)
val darkPrimaryContainerColor = Color(0xFF4D3865)
val darkSecondaryColor = Color(0xFFE8B7D0)
val darkTertiaryColor = Color(0xFFC9C1FF)
val darkBackgroundColor = Color(0xFF111015)
val darkSurfaceColor = Color(0xFF19161F)
val darkErrorColor = Color(0xFFFFB4AB)
val darkOnPrimaryColor = Color(0xFF37204E)
val darkOnPrimaryContainerColor = Color(0xFFF2E8FF)
val darkOnSecondaryColor = Color(0xFF482638)
val darkOnTertiaryColor = Color(0xFF302A55)
val darkOnBackgroundColor = Color(0xFFEDE7EF)
val darkOnSurfaceColor = Color(0xFFEDE7EF)
val darkOnErrorColor = Color(0xFF690005)

View File

@ -51,8 +51,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import app.closer.ui.components.CelebrationOverlay
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -98,7 +96,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -153,7 +150,6 @@ data class ThisOrThatUiState(
val selectedLength: SessionLength = SessionLength.STANDARD,
val error: String? = null,
val submitFailed: Boolean = false,
val syncFailed: Boolean = false,
val navigateTo: String? = null
)
@ -306,14 +302,7 @@ class ThisOrThatViewModel @Inject constructor(
val sId = sessionId?.takeIf { it.isNotBlank() } ?: return
observeJob?.cancel()
observeJob = viewModelScope.launch {
dataSource.observeAnswers(cId, sId)
.catch { e ->
// Listener failed (offline/permissions): surface a retryable error instead of
// hanging on WAITING forever (pairs with the data source's close(err)).
Log.w(TAG, "observeAnswers failed", e)
_uiState.update { it.copy(phase = TotPhase.ERROR, error = GameCopy.SYNC_ERROR, syncFailed = true) }
}
.collect { answers ->
dataSource.observeAnswers(cId, sId).collect { answers ->
val mine = userId?.let { answers.byUser[it] }
val theirs = partnerId?.let { answers.byUser[it] }
when {
@ -432,18 +421,6 @@ class ThisOrThatViewModel @Inject constructor(
}
}
/** Re-attach the partner-answers listener after a sync failure. */
fun retrySync() {
_uiState.update {
it.copy(
phase = if (submitted) TotPhase.WAITING else TotPhase.PLAYING,
error = null,
syncFailed = false
)
}
observeReveal()
}
fun retrySubmit() {
val answers = _uiState.value.myAnswers
if (answers.isEmpty()) return
@ -501,7 +478,7 @@ fun ThisOrThatScreen(
TotPhase.ERROR -> ErrorState(
message = state.error ?: "Something didn't load. Go back and try again.",
onBack = viewModel::quit,
onRetry = if (state.submitFailed) viewModel::retrySubmit else if (state.syncFailed) viewModel::retrySync else null
onRetry = if (state.submitFailed) viewModel::retrySubmit else null
)
TotPhase.WAITING -> WaitingForRevealScreen(
partnerName = state.partnerName,
@ -520,9 +497,7 @@ fun ThisOrThatScreen(
partnerName = state.partnerName,
cards = state.revealCards,
onPlayAgain = viewModel::restart,
onHome = viewModel::quit,
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
onHome = viewModel::quit
)
TotPhase.PLAYING -> {
val question = state.questions.getOrNull(state.currentIndex)
@ -1060,9 +1035,7 @@ private fun ThisOrThatReveal(
partnerName: String,
cards: List<RevealCard>,
onPlayAgain: () -> Unit,
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
onHome: () -> Unit
) {
val ratio = if (total > 0) matched.toFloat() / total else 0f
val headline = when {
@ -1129,10 +1102,6 @@ private fun ThisOrThatReveal(
) {
Text("Back to Play")
}
if (showNextBeat) {
Spacer(Modifier.height(14.dp))
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}
@ -1141,11 +1110,7 @@ private fun ThisOrThatReveal(
private fun MatchScoreBadge(matched: Int, total: Int) {
val cs = MaterialTheme.colorScheme
Surface(
modifier = Modifier
.size(116.dp)
.semantics(mergeDescendants = true) {
contentDescription = "You matched on $matched of $total"
},
modifier = Modifier.size(116.dp),
shape = CircleShape,
color = cs.primaryContainer,
shadowElevation = 8.dp

View File

@ -63,7 +63,7 @@ fun CategoryPickerScreen(
if (item.isLocked) onNavigate(AppRoute.PAYWALL)
else onNavigate(AppRoute.spinWheel(item.category.id))
},
onHistory = { onNavigate(AppRoute.GAME_HISTORY) },
onHistory = { onNavigate(AppRoute.WHEEL_HISTORY) },
onRetry = viewModel::load
)
}

View File

@ -215,7 +215,7 @@ class SpinWheelViewModel @Inject constructor(
}
fun onHistory() {
_uiState.update { it.copy(navigateTo = AppRoute.GAME_HISTORY) }
_uiState.update { it.copy(navigateTo = AppRoute.WHEEL_HISTORY) }
}
fun onNavigated() {

View File

@ -237,9 +237,7 @@ fun WheelCompleteScreen(
partnerName = state.partnerName,
items = state.items,
onSpinAgain = { onNavigate(AppRoute.CATEGORY_PICKER) },
onHome = { onNavigate(AppRoute.PLAY) },
onOpenDaily = { onNavigate(AppRoute.DAILY_QUESTION) },
showNextBeat = true
onHome = { onNavigate(AppRoute.PLAY) }
)
WheelRevealPhase.ERROR -> WheelErrorContent(
message = state.error ?: "Something went wrong.",
@ -326,9 +324,7 @@ private fun WheelRevealContent(
partnerName: String,
items: List<WheelRevealItem>,
onSpinAgain: () -> Unit,
onHome: () -> Unit,
onOpenDaily: () -> Unit = {},
showNextBeat: Boolean = false
onHome: () -> Unit
) {
LazyColumn(
modifier = Modifier
@ -394,10 +390,6 @@ private fun WheelRevealContent(
) {
Text("Back to Play")
}
if (showNextBeat) {
Spacer(Modifier.height(14.dp))
app.closer.ui.games.NextBeatCard(onOpenDaily = onOpenDaily)
}
}
}
}

View File

@ -274,43 +274,6 @@ class ChallengeStateMachineTest {
assertEquals(3, state.currentDay)
}
// -----------------------------------------------------------------
// statusDay exposure (day-unlock gating for the UI)
// -----------------------------------------------------------------
@Test
fun `statusDay stays on the calendar day when user races ahead same-day`() {
// Started today, completed day 1 already: currentDay advances to 2 (next incomplete)
// but statusDay stays 1 — the UI uses currentDay > statusDay to hide day 2's prompt.
val startedAt = today.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(startedAt = startedAt, mine = listOf(1)),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(2, state.currentDay)
assertEquals(1, state.statusDay)
}
@Test
fun `statusDay advances with the calendar`() {
val startedAt = today.minusDays(2).atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli()
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(startedAt = startedAt, mine = listOf(1, 2, 3)),
today = today
)
val state = ChallengeStateMachine.compute(input)
// Day 3 of the calendar; user already did day 3 → next (4) is tomorrow's.
assertEquals(4, state.currentDay)
assertEquals(3, state.statusDay)
}
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------

View File

@ -157,12 +157,7 @@ exports.onGameSessionUpdate = functions.firestore
return;
}
// ── Session completed (reveal ready for both) ────────────────────────
// Only a REAL completion has results: the async flow marks each user complete at reveal, so
// status flips to 'completed' with both uids in completedByUsers. An abandoned/quit session
// also lands on 'completed' (the abandon write) but with an empty list — pushing
// "finished — see your results!" for those was a false notification into an empty reveal.
const completedBy = Array.isArray(currentData.completedByUsers) ? currentData.completedByUsers : [];
if (status === 'completed' && !currentData.finishNotifiedAt && completedBy.length >= 2) {
if (status === 'completed' && !currentData.finishNotifiedAt) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef);
const d = fresh.data();
@ -245,20 +240,8 @@ exports.onGamePartFinished = functions.firestore
const finisher = await db.collection('users').doc(finisherUid).get();
const finisherName = 'Your partner'; // displayName is E2EE; the app shows the real name in-app
const finisherAvatar = (_e = finisher.data()) === null || _e === void 0 ? void 0 : _e.photoUrl;
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', yourTurnBody(gameType), coupleId, finisherAvatar, sessionId);
await notifyPartner(db, messaging, recipient, finisherName, gameType, 'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId, finisherAvatar, sessionId);
});
/**
* Per-game body for the "your turn" (partner_completed_part) push mirrors the client banner's
* YOUR_TURN copy in ui/components/GamePromptBanner.kt so foreground and background read the same.
*/
function yourTurnBody(gameType) {
switch (gameType) {
case 'how_well': return 'Your turn — guess their answers';
case 'desire_sync': return 'Your turn — only mutual yeses ever show';
case 'this_or_that': return 'Your turn — see where you line up';
default: return 'Your turn — reveal how you line up';
}
}
/**
* Send notification to partner via FCM and write to notification_queue.
*/

File diff suppressed because one or more lines are too long

View File

@ -34,11 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.assignDailyQuestionCallable = exports.assignDailyQuestion = void 0;
exports.formatCstDate = formatCstDate;
exports.parseCstDate = parseCstDate;
exports.timestampAt6PmCst = timestampAt6PmCst;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
const CST_OFFSET_HOURS = -6;
/**
* Scheduled function that assigns one daily question per couple every day.
*
@ -59,9 +57,9 @@ exports.assignDailyQuestion = functions.pubsub
const db = admin.firestore();
const today = cstDateString();
const nextDay = nextCstDateString();
const questionId = await pickDailyQuestionId(today);
const questionId = await pickRandomQuestionId();
if (!questionId) {
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment');
console.error('[assignDailyQuestion] no active questions available');
return;
}
const couplesSnap = await db.collection('couples').get();
@ -137,7 +135,7 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
if (date !== today) {
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.');
}
const questionId = await pickDailyQuestionId(today);
const questionId = await pickRandomQuestionId();
if (!questionId) {
throw new functions.https.HttpsError('internal', 'No active questions available.');
}
@ -175,51 +173,20 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
* so the app still functions during rollout. In production, keep the local
* Room database and Firestore pool in sync through the existing seed flow.
*/
// Weekday → daily mode tag. FROZEN to mirror the client's DailyModeResolver.DOW_DEFAULTS
// (app/.../domain/DailyModeResolver.kt); JS getUTCDay(): 0=Sun … 6=Sat.
const WEEKDAY_MODE_TAGS = {
0: 'mode_tiny_date_night', // Sunday — Slow Burn Sunday
1: 'mode_soft_monday', // Monday — Mood Check Monday
2: 'mode_snack_mission', // Tuesday — Tiny Win Tuesday
3: 'mode_no_phone_moment', // Wednesday — Real One Wednesday
4: 'mode_laugh_reset', // Thursday — Laugh It Off Thursday
5: 'mode_flirty_friday', // Friday — Flirty Friday
6: 'mode_weekend_side_quest', // Saturday — Side Quest Saturday
};
/** Days since epoch for a YYYY-MM-DD Chicago date mirrors LocalDate.toEpochDay() the client
* uses for its deterministic daily offset, so server and client land on the same question. */
function epochDayOf(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number);
return Math.floor(Date.UTC(y, m - 1, d) / 86400000);
}
/**
* Deterministically pick the SAME daily question the free client would compute for [dateStr]:
* today's weekday mode, ordered by id, indexed by epochDay % poolSize. Making this SERVER-side and
* authoritative also fixes partners in different time zones getting different questions (the client
* falls back to device-local weekday only when there is no server assignment). Returns null if the
* questions pool is unseeded so the caller can no-op rather than write an unresolvable id.
*/
async function pickDailyQuestionId(dateStr) {
async function pickRandomQuestionId() {
const db = admin.firestore();
// The whole free daily pool is tiny (~75 rows); fetch once and filter in memory to avoid a
// composite index on (active, isPremium, modeTag).
const snapshot = await db
.collection('questions')
.where('active', '==', true)
.where('isPremium', '==', false)
.get();
if (snapshot.empty)
return null;
const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay();
const modeTag = WEEKDAY_MODE_TAGS[weekday];
const inMode = snapshot.docs
.filter((d) => d.get('modeTag') === modeTag)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
const pool = inMode.length > 0
? inMode
: snapshot.docs.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); // wildcard/empty-mode fallback
const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length;
return pool[offset].id;
if (snapshot.empty) {
// Rollout fallback: keep the feature working until the questions pool is seeded.
return 'q_default_daily';
}
const docs = snapshot.docs;
const chosen = docs[Math.floor(Math.random() * docs.length)];
return chosen.id;
}
/**
* Returns today's date as YYYY-MM-DD in America/Chicago time.
@ -236,47 +203,35 @@ function nextCstDateString(from) {
d.setUTCDate(d.getUTCDate() + 1);
return formatCstDate(d);
}
// Tomorrow in Chicago: parse today's Chicago date back to a concrete instant and add 24h.
const todayChicago = parseCstDate(formatCstDate(new Date()));
todayChicago.setUTCDate(todayChicago.getUTCDate() + 1);
return formatCstDate(todayChicago);
const d = new Date();
const cst = applyCstOffset(d);
cst.setUTCDate(cst.getUTCDate() + 1);
return formatCstDate(cst);
}
/**
* UTC-vs-Chicago offset in hours at the given instant (5 during CDT, 6 during CST).
* DST-safe replacement for the old hardcoded -6 (which mislabeled dates and shifted
* reveals by an hour for the ~8 months of the year Chicago observes daylight time).
* Pattern matches streakReminder.ts's Intl-based chicagoDateKey.
*/
function chicagoOffsetHours(atUtcMs) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
}).formatToParts(new Date(atUtcMs));
const get = (t) => { var _a; return Number((_a = parts.find((p) => p.type === t)) === null || _a === void 0 ? void 0 : _a.value); };
const localAsUtcMs = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour') % 24, get('minute'));
return Math.round((atUtcMs - localAsUtcMs) / 3600000);
function applyCstOffset(d) {
const utcMs = d.getTime();
const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000;
return new Date(cstMs);
}
/** YYYY-MM-DD for the given instant in America/Chicago (en-CA gives ISO date order). */
function formatCstDate(d) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
const cst = applyCstOffset(d);
const y = cst.getUTCFullYear();
const m = String(cst.getUTCMonth() + 1).padStart(2, '0');
const day = String(cst.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
/** The UTC instant of midnight America/Chicago on the given calendar date. */
function parseCstDate(dateStr) {
const [y, m, day] = dateStr.split('-').map(Number);
// Guess with the standard offset, then correct with the real offset at that instant.
const guessMs = Date.UTC(y, m - 1, day, 6);
const offset = chicagoOffsetHours(guessMs);
return new Date(Date.UTC(y, m - 1, day, offset));
// Build a UTC timestamp that represents midnight CST, then reverse the offset.
const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0);
return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000);
}
/** The UTC instant of 6:00 PM America/Chicago on the given calendar date. */
function timestampAt6PmCst(dateStr) {
const [y, m, day] = dateStr.split('-').map(Number);
const guessMs = Date.UTC(y, m - 1, day, 18 + 6);
const offset = chicagoOffsetHours(guessMs);
return admin.firestore.Timestamp.fromMillis(Date.UTC(y, m - 1, day, 18 + offset));
const d = parseCstDate(dateStr);
const utcMs = d.getTime();
// 6:00 PM CST == 18:00 CST == 00:00 UTC next day.
// We already have midnight CST in UTC form; add 18 hours.
const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000;
return admin.firestore.Timestamp.fromMillis(at6pmCstMs);
}
//# sourceMappingURL=assignDailyQuestion.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,38 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const assignDailyQuestion_1 = require("./assignDailyQuestion");
// DST regression tests for the Chicago date helpers (the old hardcoded -6 offset
// mislabeled dates and shifted reveal times during the ~8 CDT months).
describe('Chicago date helpers (DST-safe)', () => {
it('labels a summer (CDT, UTC-5) evening with the correct local date', () => {
// 2026-07-08T03:30Z == 2026-07-07 22:30 CDT — the old -6 math said "07-07" too,
// but 2026-07-08T04:30Z == 2026-07-07 23:30 CDT is where they diverge:
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-07-08T03:30:00Z'))).toBe('2026-07-07');
// 23:30 CDT is still Jul 7 locally; hardcoded -6 called it Jul 7 22:30 → same date,
// but 2026-07-08T05:30Z == Jul 8 00:30 CDT; old math: Jul 7 23:30 → WRONG date.
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-07-08T05:30:00Z'))).toBe('2026-07-08');
});
it('labels a winter (CST, UTC-6) instant correctly', () => {
// 2026-01-08T05:30Z == 2026-01-07 23:30 CST
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-01-08T05:30:00Z'))).toBe('2026-01-07');
expect((0, assignDailyQuestion_1.formatCstDate)(new Date('2026-01-08T06:30:00Z'))).toBe('2026-01-08');
});
it('parses midnight Chicago with the seasonal offset', () => {
// Summer: midnight CDT == 05:00Z
expect((0, assignDailyQuestion_1.parseCstDate)('2026-07-07').toISOString()).toBe('2026-07-07T05:00:00.000Z');
// Winter: midnight CST == 06:00Z
expect((0, assignDailyQuestion_1.parseCstDate)('2026-01-07').toISOString()).toBe('2026-01-07T06:00:00.000Z');
});
it('puts the 6 PM reveal at 6 PM local in both seasons', () => {
// Summer: 18:00 CDT == 23:00Z
expect((0, assignDailyQuestion_1.timestampAt6PmCst)('2026-07-07').toDate().toISOString()).toBe('2026-07-07T23:00:00.000Z');
// Winter: 18:00 CST == 00:00Z next day
expect((0, assignDailyQuestion_1.timestampAt6PmCst)('2026-01-07').toDate().toISOString()).toBe('2026-01-08T00:00:00.000Z');
});
it('round-trips format(parse(d)) across the spring-forward boundary', () => {
for (const day of ['2026-03-07', '2026-03-08', '2026-03-09', '2026-11-01', '2026-11-02']) {
expect((0, assignDailyQuestion_1.formatCstDate)((0, assignDailyQuestion_1.parseCstDate)(day))).toBe(day);
}
});
});
//# sourceMappingURL=assignDailyQuestion.test.js.map

View File

@ -1 +0,0 @@
{"version":3,"file":"assignDailyQuestion.test.js","sourceRoot":"","sources":["../../src/questions/assignDailyQuestion.test.ts"],"names":[],"mappings":";;AAAA,+DAAsF;AAEtF,iFAAiF;AACjF,uEAAuE;AACvE,QAAQ,CAAC,iCAAiC,EAAE,GAAG,EAAE;IAC/C,EAAE,CAAC,kEAAkE,EAAE,GAAG,EAAE;QAC1E,gFAAgF;QAChF,uEAAuE;QACvE,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;QAC1E,oFAAoF;QACpF,gFAAgF;QAChF,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;IAC5E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACxD,4CAA4C;QAC5C,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;QAC1E,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAI,IAAI,CAAC,sBAAsB,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAA;IAC5E,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,kDAAkD,EAAE,GAAG,EAAE;QAC1D,iCAAiC;QACjC,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;QACjF,iCAAiC;QACjC,MAAM,CAAC,IAAA,kCAAY,EAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;IACnF,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,oDAAoD,EAAE,GAAG,EAAE;QAC5D,8BAA8B;QAC9B,MAAM,CAAC,IAAA,uCAAiB,EAAC,YAAY,CAAC,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;QAC/F,uCAAuC;QACvC,MAAM,CAAC,IAAA,uCAAiB,EAAC,YAAY,CAAC,CAAC,MAAM,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAA;IACjG,CAAC,CAAC,CAAA;IAEF,EAAE,CAAC,iEAAiE,EAAE,GAAG,EAAE;QACzE,KAAK,MAAM,GAAG,IAAI,CAAC,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,CAAC,EAAE,CAAC;YACzF,MAAM,CAAC,IAAA,mCAAa,EAAC,IAAA,kCAAY,EAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;QACpD,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}

View File

@ -134,12 +134,7 @@ export const onGameSessionUpdate = functions.firestore
}
// ── Session completed (reveal ready for both) ────────────────────────
// Only a REAL completion has results: the async flow marks each user complete at reveal, so
// status flips to 'completed' with both uids in completedByUsers. An abandoned/quit session
// also lands on 'completed' (the abandon write) but with an empty list — pushing
// "finished — see your results!" for those was a false notification into an empty reveal.
const completedBy = Array.isArray(currentData.completedByUsers) ? currentData.completedByUsers : []
if (status === 'completed' && !currentData.finishNotifiedAt && completedBy.length >= 2) {
if (status === 'completed' && !currentData.finishNotifiedAt) {
const claimed = await db.runTransaction(async (tx) => {
const fresh = await tx.get(sessionRef)
const d = fresh.data()
@ -228,24 +223,11 @@ export const onGamePartFinished = functions.firestore
await notifyPartner(
db, messaging, recipient, finisherName, gameType,
'partner_completed_part', yourTurnBody(gameType), coupleId,
'partner_completed_part', `${finisherName} finished their part — your turn to play!`, coupleId,
finisherAvatar, sessionId
)
})
/**
* Per-game body for the "your turn" (partner_completed_part) push mirrors the client banner's
* YOUR_TURN copy in ui/components/GamePromptBanner.kt so foreground and background read the same.
*/
function yourTurnBody(gameType: string): string {
switch (gameType) {
case 'how_well': return 'Your turn — guess their answers'
case 'desire_sync': return 'Your turn — only mutual yeses ever show'
case 'this_or_that': return 'Your turn — see where you line up'
default: return 'Your turn — reveal how you line up'
}
}
/**
* Send notification to partner via FCM and write to notification_queue.
*/

View File

@ -1,40 +0,0 @@
import { formatCstDate, parseCstDate, timestampAt6PmCst } from './assignDailyQuestion'
// DST regression tests for the Chicago date helpers (the old hardcoded -6 offset
// mislabeled dates and shifted reveal times during the ~8 CDT months).
describe('Chicago date helpers (DST-safe)', () => {
it('labels a summer (CDT, UTC-5) evening with the correct local date', () => {
// 2026-07-08T03:30Z == 2026-07-07 22:30 CDT — the old -6 math said "07-07" too,
// but 2026-07-08T04:30Z == 2026-07-07 23:30 CDT is where they diverge:
expect(formatCstDate(new Date('2026-07-08T03:30:00Z'))).toBe('2026-07-07')
// 23:30 CDT is still Jul 7 locally; hardcoded -6 called it Jul 7 22:30 → same date,
// but 2026-07-08T05:30Z == Jul 8 00:30 CDT; old math: Jul 7 23:30 → WRONG date.
expect(formatCstDate(new Date('2026-07-08T05:30:00Z'))).toBe('2026-07-08')
})
it('labels a winter (CST, UTC-6) instant correctly', () => {
// 2026-01-08T05:30Z == 2026-01-07 23:30 CST
expect(formatCstDate(new Date('2026-01-08T05:30:00Z'))).toBe('2026-01-07')
expect(formatCstDate(new Date('2026-01-08T06:30:00Z'))).toBe('2026-01-08')
})
it('parses midnight Chicago with the seasonal offset', () => {
// Summer: midnight CDT == 05:00Z
expect(parseCstDate('2026-07-07').toISOString()).toBe('2026-07-07T05:00:00.000Z')
// Winter: midnight CST == 06:00Z
expect(parseCstDate('2026-01-07').toISOString()).toBe('2026-01-07T06:00:00.000Z')
})
it('puts the 6 PM reveal at 6 PM local in both seasons', () => {
// Summer: 18:00 CDT == 23:00Z
expect(timestampAt6PmCst('2026-07-07').toDate().toISOString()).toBe('2026-07-07T23:00:00.000Z')
// Winter: 18:00 CST == 00:00Z next day
expect(timestampAt6PmCst('2026-01-07').toDate().toISOString()).toBe('2026-01-08T00:00:00.000Z')
})
it('round-trips format(parse(d)) across the spring-forward boundary', () => {
for (const day of ['2026-03-07', '2026-03-08', '2026-03-09', '2026-11-01', '2026-11-02']) {
expect(formatCstDate(parseCstDate(day))).toBe(day)
}
})
})

View File

@ -1,6 +1,7 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
const CST_OFFSET_HOURS = -6
/**
* Scheduled function that assigns one daily question per couple every day.
@ -23,9 +24,9 @@ export const assignDailyQuestion = functions.pubsub
const today = cstDateString()
const nextDay = nextCstDateString()
const questionId = await pickDailyQuestionId(today)
const questionId = await pickRandomQuestionId()
if (!questionId) {
console.error('[assignDailyQuestion] questions pool not seeded — skipping assignment')
console.error('[assignDailyQuestion] no active questions available')
return
}
@ -109,7 +110,7 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: R
if (date !== today) {
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.')
}
const questionId = await pickDailyQuestionId(today)
const questionId = await pickRandomQuestionId()
if (!questionId) {
throw new functions.https.HttpsError('internal', 'No active questions available.')
}
@ -150,53 +151,22 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: R
* so the app still functions during rollout. In production, keep the local
* Room database and Firestore pool in sync through the existing seed flow.
*/
// Weekday → daily mode tag. FROZEN to mirror the client's DailyModeResolver.DOW_DEFAULTS
// (app/.../domain/DailyModeResolver.kt); JS getUTCDay(): 0=Sun … 6=Sat.
const WEEKDAY_MODE_TAGS: Record<number, string> = {
0: 'mode_tiny_date_night', // Sunday — Slow Burn Sunday
1: 'mode_soft_monday', // Monday — Mood Check Monday
2: 'mode_snack_mission', // Tuesday — Tiny Win Tuesday
3: 'mode_no_phone_moment', // Wednesday — Real One Wednesday
4: 'mode_laugh_reset', // Thursday — Laugh It Off Thursday
5: 'mode_flirty_friday', // Friday — Flirty Friday
6: 'mode_weekend_side_quest',// Saturday — Side Quest Saturday
}
/** Days since epoch for a YYYY-MM-DD Chicago date mirrors LocalDate.toEpochDay() the client
* uses for its deterministic daily offset, so server and client land on the same question. */
function epochDayOf(dateStr: string): number {
const [y, m, d] = dateStr.split('-').map(Number)
return Math.floor(Date.UTC(y, m - 1, d) / 86_400_000)
}
/**
* Deterministically pick the SAME daily question the free client would compute for [dateStr]:
* today's weekday mode, ordered by id, indexed by epochDay % poolSize. Making this SERVER-side and
* authoritative also fixes partners in different time zones getting different questions (the client
* falls back to device-local weekday only when there is no server assignment). Returns null if the
* questions pool is unseeded so the caller can no-op rather than write an unresolvable id.
*/
async function pickDailyQuestionId(dateStr: string): Promise<string | null> {
async function pickRandomQuestionId(): Promise<string | null> {
const db = admin.firestore()
// The whole free daily pool is tiny (~75 rows); fetch once and filter in memory to avoid a
// composite index on (active, isPremium, modeTag).
const snapshot = await db
.collection('questions')
.where('active', '==', true)
.where('isPremium', '==', false)
.get()
if (snapshot.empty) return null
const weekday = new Date(`${dateStr}T12:00:00Z`).getUTCDay()
const modeTag = WEEKDAY_MODE_TAGS[weekday]
const inMode = snapshot.docs
.filter((d) => d.get('modeTag') === modeTag)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const pool = inMode.length > 0
? inMode
: snapshot.docs.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) // wildcard/empty-mode fallback
const offset = ((epochDayOf(dateStr) % pool.length) + pool.length) % pool.length
return pool[offset].id
if (snapshot.empty) {
// Rollout fallback: keep the feature working until the questions pool is seeded.
return 'q_default_daily'
}
const docs = snapshot.docs
const chosen = docs[Math.floor(Math.random() * docs.length)]
return chosen.id
}
/**
@ -215,50 +185,38 @@ function nextCstDateString(from?: string): string {
d.setUTCDate(d.getUTCDate() + 1)
return formatCstDate(d)
}
// Tomorrow in Chicago: parse today's Chicago date back to a concrete instant and add 24h.
const todayChicago = parseCstDate(formatCstDate(new Date()))
todayChicago.setUTCDate(todayChicago.getUTCDate() + 1)
return formatCstDate(todayChicago)
const d = new Date()
const cst = applyCstOffset(d)
cst.setUTCDate(cst.getUTCDate() + 1)
return formatCstDate(cst)
}
/**
* UTC-vs-Chicago offset in hours at the given instant (5 during CDT, 6 during CST).
* DST-safe replacement for the old hardcoded -6 (which mislabeled dates and shifted
* reveals by an hour for the ~8 months of the year Chicago observes daylight time).
* Pattern matches streakReminder.ts's Intl-based chicagoDateKey.
*/
function chicagoOffsetHours(atUtcMs: number): number {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
}).formatToParts(new Date(atUtcMs))
const get = (t: string): number => Number(parts.find((p) => p.type === t)?.value)
const localAsUtcMs = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour') % 24, get('minute'))
return Math.round((atUtcMs - localAsUtcMs) / 3_600_000)
function applyCstOffset(d: Date): Date {
const utcMs = d.getTime()
const cstMs = utcMs + CST_OFFSET_HOURS * 60 * 60 * 1000
return new Date(cstMs)
}
/** YYYY-MM-DD for the given instant in America/Chicago (en-CA gives ISO date order). */
export function formatCstDate(d: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d)
function formatCstDate(d: Date): string {
const cst = applyCstOffset(d)
const y = cst.getUTCFullYear()
const m = String(cst.getUTCMonth() + 1).padStart(2, '0')
const day = String(cst.getUTCDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
/** The UTC instant of midnight America/Chicago on the given calendar date. */
export function parseCstDate(dateStr: string): Date {
function parseCstDate(dateStr: string): Date {
const [y, m, day] = dateStr.split('-').map(Number)
// Guess with the standard offset, then correct with the real offset at that instant.
const guessMs = Date.UTC(y, m - 1, day, 6)
const offset = chicagoOffsetHours(guessMs)
return new Date(Date.UTC(y, m - 1, day, offset))
// Build a UTC timestamp that represents midnight CST, then reverse the offset.
const cstMidnightMs = Date.UTC(y, m - 1, day, 0, 0, 0, 0)
return new Date(cstMidnightMs - CST_OFFSET_HOURS * 60 * 60 * 1000)
}
/** The UTC instant of 6:00 PM America/Chicago on the given calendar date. */
export function timestampAt6PmCst(dateStr: string): admin.firestore.Timestamp {
const [y, m, day] = dateStr.split('-').map(Number)
const guessMs = Date.UTC(y, m - 1, day, 18 + 6)
const offset = chicagoOffsetHours(guessMs)
return admin.firestore.Timestamp.fromMillis(Date.UTC(y, m - 1, day, 18 + offset))
function timestampAt6PmCst(dateStr: string): admin.firestore.Timestamp {
const d = parseCstDate(dateStr)
const utcMs = d.getTime()
// 6:00 PM CST == 18:00 CST == 00:00 UTC next day.
// We already have midnight CST in UTC form; add 18 hours.
const at6pmCstMs = utcMs + 18 * 60 * 60 * 1000
return admin.firestore.Timestamp.fromMillis(at6pmCstMs)
}

View File

@ -33,8 +33,3 @@ NODE_PATH=functions/node_modules node qa/qa_push.js <uid> chat_message conversat
- coupleId `Xal3Kw3gjSdn0niERYKJ`
- QA (emulator-5554) `Y05AKO2IlTPMa0JQW1BiNIM0uzK2` · Sam (emulator-5556) `imDjjOTTQvXGGjyUhUc5JSeHWkU2`
- Admin SA JSON: `~/.config/closer/closer-admin-sa.json` (kept OUTSIDE the repo; override path via `SA_JSON`). For tools using Application Default Credentials: `export GOOGLE_APPLICATION_CREDENTIALS=~/.config/closer/closer-admin-sa.json`.
## Fixture accounts & passwords
Live test-account emails, uids, AVD mapping, and passwords are kept OUT of this repo in the
gitignored `qa/fixtures.local.md` (dev-project throwaway creds). Ask the operator or read that
local file. Never commit credentials.