Compare commits

...

19 Commits

Author SHA1 Message Date
null 3422843a68 review 2026-07-12 19:55:14 -05:00
null fb9a876b98 docs(seed): add anti-AI-voice + humor-craft standard to question guides
The content agent (ChatGPT) authors packs from these guides, and the repo has
documented LLM over-compliance failures: snack overload, 416/499 same-mechanic
six-question blocks, exactly-6-of-each mechanic counts. The guides banned
therapy voice but never the machine-voice failure modes.

- QUESTION_CONTENT_GUIDE.md: new section 8A "Write Like a Human" — AI-tell
  phrase ban, no-exclamation/no-emoji/sentence-case rules, humor craft (joke
  lives in the detail not the adjective; one absurd detail max; adult-deadpan
  mock-formality register; punch at the situation never the partner; snort
  test at ~1-in-4), anti-uniformity guards (guide examples are categories not
  vocabulary; no template blocks in any 10 consecutive; uneven distribution
  is correct), texting test. Also retitled section 18 agent-neutral.
- DAILY_SINGLE_CHOICE_WEEKDAY_SYSTEM.md (v8→v9): "Daily Voice: Human and
  Funny" subsection — weekday theme is the angle not a template stem;
  wildcard absurdity rules; points at 8A as authority.
- QUESTION_QUALITY_CHECKLIST.md (v10→v11): machine-voice Automatic Rejects,
  implausibly-even mechanic-count repetition check, and 5 new marking reasons
  (ai_voice, labeled_joke, uniform_option_grammar, guide_example_parrot,
  brand_caption_voice) so patch discipline can target these failures.

Additive only: no schema/importer-contract, count, or patch-policy changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 16:14:34 -05:00
null d7d97cc8a8 review 2026-07-12 16:07:09 -05:00
null 75a644e1ba review 2026-07-12 16:02:05 -05:00
null 157f080ee6 review 2026-07-12 12:55:14 -05:00
null 89138874e7 review 2026-07-12 12:08:42 -05:00
null 25d3441ea9 review 2026-07-12 04:47:48 -05:00
null 5643a0945e docs(future): file the codebase-wide ImmutableList Compose-perf convention (Home refactor follow-up)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:55:26 -05:00
null 757d4efdda test(home): robust assertion in render smoke's unanswered-state case
The unanswered case asserted a specific CTA ('Answer privately') that the preview's
demo state doesn't surface in the test viewport. Assert the stable header instead —
still exercises the UNANSWERED render path (setContent), just without a brittle text
match. Verified 3/3 green on a throwaway emulator (fixtures untouched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:54:17 -05:00
null ed0aa4d3b3 fix(test): HomeContentRenderSmokeTest nested-comment (components/* opened a block comment)
'ui/home/components/*' in the KDoc contains a /* sequence, which Kotlin treats as
a NESTED block-comment opener (Kotlin nests block comments) -> unclosed comment.
Reworded. androidTest now compiles. (App was never affected.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:49:06 -05:00
null 1eacf36d81 fix(test): HomeContentRenderSmokeTest unclosed-comment (emoji in KDoc broke Kotlin lexer)
The prior commit's androidTest didn't compile — a stop-sign emoji in the KDoc
tripped the Kotlin lexer ('Unclosed comment'). Replaced with plain text. App was
never affected (androidTest isn't in the APK); this restores connectedAndroidTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:47:59 -05:00
null f7eb03417b test(home): HomeActionMapperTest (JVM) + HomeContentRenderSmokeTest (instrumented)
Part 3 of the Home refactor. Adds the durable coverage the plan called for:
- HomeActionMapperTest: 12 JVM cases over the lifted pure mapper (refresh states,
  withHomeActions pairing/daily/loading/error paths, toHomeLabel mc-drop, secondary
  cap of 3, C-HOME-001 primary/pending dedup) — locks the extraction as faithful.
- HomeContentRenderSmokeTest: instrumented render net for Home (via the VM-free
  PairedHomePreviewScreen), light + dark — catches 'composes fine, crashes on
  first paint'. Runs on a THROWAWAY only (uninstalls app-under-test).

JVM test green; androidTest compiles. Fills the 'Home has no UI test' gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:46:16 -05:00
null 9e81c15350 perf(home): @Immutable on HomeAction/PendingActionCard/HomeCategorySummary
Part 2b (Compose stack advantage). Marks the three verified-deeply-immutable
card models @Immutable so their single-instance params get structural-equality
skipping (upgrade over the K2 reference-equality strong-skipping default).
HomeAnswerStats/HomeUiState left unannotated (LocalAnswer/Question/Set transitive
types not fully audited — a false @Immutable promise would cause stale UI).
Rendered output identical; only recomposition frequency drops. compile green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:42:04 -05:00
null 7be9a1bf15 refactor(home): lift pure action mapper -> HomeActionMapper.kt (VM 867->562)
Part 2b. Moves the ~305 lines of pure HomeUiState action-derivation extensions
(refreshDailyQuestionState/withHomeActions internal; toHomeAction/
buildDailyQuestionAction/buildPendingActions/has*/toHomeLabel private) out of
HomeViewModel verbatim (de-indented, byte-identical bodies). Verified pure — no
this@HomeViewModel/repo refs. VM call-sites unchanged (same-package extensions).
compile + full unit suite green. HomeViewModel now 562 lines (from 1030).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:40:31 -05:00
null 227d141221 refactor(home): extract data models -> HomeModels.kt (VM 1030->867)
Part 2a. Moves the Home data classes/enums + computeDailyQuestionState (kept
@VisibleForTesting internal) + gameRouteFor (widened private->internal, its only
caller loadHome is same-package) out of HomeViewModel into HomeModels.kt.
Same package -> no consumer import churn (AppNavigation, DailyQuestionStateTest
unchanged). Behavior-identical. compile + ui.home unit tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:38:29 -05:00
null 415f8d6866 refactor(home): extract StreakMilestoneDialog + drop dead MomentCueCard
Step 9/9 of the UI split. StreakMilestoneDialog -> components/ (kept internal;
ArtPreviewScreen call updated to the new package). MomentCueCard was dead (no
callers) — removed. Orphaned imports cleaned. compileDebugKotlin green.
HomeScreen.kt now ~620 lines (from 1921).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 20:34:37 -05:00
null 5e266a95fa merge(home): HomeScreen UI decomposition (8 cluster files) into dev
Behavior-preserving split of HomeScreen.kt (1921 -> 712 lines) into
ui/home/components/: HomeActionStyle, HomeStateCards, HomeCategoryPreview,
HomePendingCards, HomeActionFeed, HomeStatusStrip, HomePrimaryCards,
HomeHeaderSection. Each step compile-gated. ViewModel extraction + tests still
pending (tracked separately).
2026-07-11 19:59:45 -05:00
null 94360a5438 refactor(home): extract header + partner sheet -> components/HomeHeaderSection.kt
Step 8/9. Verbatim move (HomeHeader + PartnerQuickActionsSheet public, rest private;
@OptIn(ExperimentalMaterial3Api) + .semantics a11y + full state param preserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:43:22 -05:00
null c13dbc5bfd refactor(home): extract primary/activation cards -> components/HomePrimaryCards.kt
Step 7/9. Verbatim move (activation + primary hero public, benefit-pill private).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:40:08 -05:00
38 changed files with 89720 additions and 80681 deletions

2
.gitignore vendored
View File

@ -101,3 +101,5 @@ revenucat_secret_api.md
# Local ship-readiness working plan — operator doc, not repo history
release_guide.md
seed/questions/closer_question_guides_150_max.zip
seed/questions/rebuilding_trust_BATCH1_NOTE.md

View File

@ -111,6 +111,19 @@ Still open:
- **Content retag**`daily_fun_mc` rows still carry `sex='neutral'` in the asset DB; after C3's HowWell-side exclusion this is purely Desire Sync pool hygiene (benign today via the binary filter). 48/150 sexual_preferences items are non-binary configs (Wheel-only) — intended?
- **npm audit** — 9 moderate remain after the 2026-07-08 firebase-functions v7 bump (retry-request/teeny-request via @google-cloud/storage, i.e. **firebase-admin** transitive — untouched by the functions bump); revisit on the next firebase-admin major. _(Re-verified 2026-07-11: still 9 moderate, unchanged set; deliberately not running `npm audit fix` — no lockfile churn on the production backend for documented moderates.)_
- **`scheduledOutcomesReminder` scaling cap** — the daily cron scans `couples` with a flat `.limit(200)` and no pagination (`functions/src/couples/scheduledOutcomesReminder.ts:35`), so couples beyond the first 200 silently never get 30/60/90-day outcome nudges. Fine pre-launch; **before the userbase approaches ~200 couples**, paginate with the same `orderBy(__name__)+startAfter` page loop `assignDailyQuestion` already uses (see `assignDailyQuestion.ts` PAGE_SIZE pattern).
- **Compose stability: adopt `ImmutableList` for `*UiState` list/set fields (codebase-wide).** Strong-skipping (K2 default) is on, and the Home refactor added `@Immutable` to the Home card models — but `List<>`/`Set<>` params still only reference-skip. Add the `kotlinx.collections.immutable` dependency (JetBrains, first-party) and switch `*UiState` list/set fields to `ImmutableList`/`ImmutableSet` (with `.toImmutableList()` at each VM construction site) so repeated card/list params become structurally skippable. **Do it consistently across all ~20 screens in one pass — not per-screen — to avoid a snowflake.** `ui/home/HomeModels.kt` is already partly there (`@Immutable` on `HomeAction`/`PendingActionCard`/`HomeCategorySummary`), so Home is the natural reference implementation.
- **Test `HomeViewModel.loadHome` (the honest follow-up to the Home decomposition).** The R31 Home split made
Home navigable, extracted its pure action logic into `HomeActionMapper` (12 JVM tests) and added a render
smoke — but deliberately did **not** touch `loadHome`, the ~180-line Firestore orchestration (5 parallel
retention fetches + streak/outcome calc + error handling) that remains the **highest-risk, still-untested**
code on the screen. The render-smoke exercises the render path, not `loadHome`'s data assembly. Real next
priority: a `HomeViewModel` test with fake repos covering the parallel-fetch success/partial-failure paths.
Needs the repo interfaces faked (no Hilt/Firebase) — its own follow-up, not a Home-UI change.
- **De-duplicate `dueFollowUpDay` across Home / Settings / YourProgress (3 copies).** The 30/60/90-day
outcome-reminder day math is implemented three times (now `HomeActionMapper.dueFollowUpDay`,
`SettingsViewModel`, `YourProgressViewModel`), all on the API-safe `Instant.atZone().toLocalDate()` path
since R31. Lift to one shared helper so the three can't drift; low-risk once `loadHome` has a test around it.
Improvement & feature ideas surfaced while QA-testing as a consumer (each works today — none are defects).

View File

@ -0,0 +1,62 @@
package app.closer.ui.home
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.closer.ui.theme.CloserTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented render smoke for the Home screen the on-device net for the "composes fine, crashes
* on first paint" class that JVM unit tests and static scanners can't catch. Created alongside the
* HomeScreen decomposition (1921 -> 614 lines across the ui/home/components package) so any extraction
* or @Immutable change that breaks Home rendering is caught.
*
* Renders the self-contained, VM-free `PairedHomePreviewScreen` (which builds demo HomeUiState and
* calls the private HomeContent) a successful render exercises HomeContent + every extracted
* cluster (header, status strip, primary card, action feed, pending, category grid). Both light and
* dark are covered (Home has decoupled theme art + per-theme colors).
*
* WARNING: Run on a THROWAWAY emulator only - connectedDebugAndroidTest uninstalls the
* app-under-test, which WIPES fixture data. Never run against the 5554/5556 fixture couple.
* ANDROID_SERIAL=emulator-5558 ./gradlew :app:connectedDebugAndroidTest
*/
@RunWith(AndroidJUnit4::class)
class HomeContentRenderSmokeTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun pairedHome_rendersHeaderAndStatusStrip_light() {
composeRule.setContent {
CloserTheme(darkTheme = false) { PairedHomePreviewScreen() }
}
composeRule.onNodeWithText("For tonight").assertIsDisplayed() // HomeHeader
composeRule.onNodeWithText("Just for two").assertIsDisplayed() // HomeStatusStrip
}
@Test
fun pairedHome_rendersHeader_dark() {
composeRule.setContent {
CloserTheme(darkTheme = true) { PairedHomePreviewScreen() }
}
composeRule.onNodeWithText("For tonight").assertIsDisplayed()
}
@Test
fun pairedHome_rendersInUnansweredState() {
// Exercises the UNANSWERED daily-question render path (a different primary-card branch); a
// regression that broke composition in this state would throw during setContent. Asserts the
// stable header rather than a specific CTA (demo state differs from the live app).
composeRule.setContent {
CloserTheme(darkTheme = false) {
PairedHomePreviewScreen(dailyQuestionState = DailyQuestionState.UNANSWERED)
}
}
composeRule.onNodeWithText("For tonight").assertIsDisplayed()
}
}

View File

@ -45,7 +45,7 @@ fun ArtPreviewScreen(onNavigate: (String) -> Unit = {}) {
var showStreak by remember { mutableStateOf(false) }
if (showStreak) {
app.closer.ui.home.StreakMilestoneDialog(
app.closer.ui.home.components.StreakMilestoneDialog(
milestone = 7,
partnerName = "Sofia",
onDismiss = { showStreak = false }

View File

@ -0,0 +1,318 @@
package app.closer.ui.home
import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput
import app.closer.ui.home.HomePriorityEngine.Priority
/**
* Pure Home action-derivation logic, lifted verbatim from HomeViewModel as top-level HomeUiState
* extensions so it can be unit-tested directly (like HomePriorityEngine). No Firestore/ViewModel
* state every function derives new HomeUiState/HomeAction values from the receiver. The two entry
* points the ViewModel calls (refreshDailyQuestionState, withHomeActions) are `internal`; the rest
* are file-private helpers.
*/
internal fun HomeUiState.refreshDailyQuestionState(): HomeUiState {
val state = computeDailyQuestionState(
questionId = dailyQuestion?.id,
answeredQuestionIds = answerStats.answeredQuestionIds,
latestAnswer = answerStats.latest,
hasPartnerAnsweredToday = hasPartnerAnsweredToday,
partnerAnsweredQuestionId = partnerAnsweredQuestionId
)
return copy(
dailyQuestionState = state,
hasRevealedToday = state == DailyQuestionState.REVEALED
)
}
internal fun HomeUiState.withHomeActions(): HomeUiState {
if (isLoading || error != null) {
return copy(primaryAction = null, secondaryActions = emptyList(), pendingActions = emptyList())
}
val engineInput = PriorityInput(
needsCriticalAction = needsRecovery,
isPaired = isPaired,
needsEncryptionUnlock = needsRecovery,
revealReady = dailyQuestionState == DailyQuestionState.BOTH_ANSWERED,
partnerAnsweredUserPending = dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING,
gameWaiting = hasWaitingGame(),
challengeWaiting = hasIncompleteChallenge(),
dailyQuestionUnanswered = dailyQuestionState == DailyQuestionState.UNANSWERED && dailyQuestion != null,
dailyQuestionAwaitingPartner = dailyQuestionState == DailyQuestionState.USER_ANSWERED_PARTNER_PENDING,
dailyQuestionRevealed = dailyQuestionState == DailyQuestionState.REVEALED && dailyQuestion != null,
weeklyRecapReady = weeklyRecapReady,
capsuleUnlocked = hasUnlockedCapsule(),
dateReminder = hasUpcomingDate(),
dateReflectionPending = hasPendingDateReflection,
suggestedPackAvailable = categories.isNotEmpty(),
exploreGamesAvailable = categories.isNotEmpty()
)
val priorityOutput = HomePriorityEngine.compute(engineInput)
val primary = priorityOutput.primary?.let { toHomeAction(it.priority) }
val secondary = priorityOutput.secondary.mapNotNull { toHomeAction(it.priority) }
// The primary action already gets the prominent hero card; drop it from the "Waiting for
// you" list so the same item isn't surfaced twice (C-HOME-001).
val pending = buildPendingActions().filterNot { pending ->
pending.target == primary?.target ||
(primary?.target == HomeActionTarget.DailyQuestion &&
(pending.target == HomeActionTarget.AnswerReveal ||
pending.target == HomeActionTarget.DailyQuestion))
}
return copy(
primaryAction = primary,
secondaryActions = secondary.take(3),
pendingActions = pending.take(3)
)
}
private fun HomeUiState.toHomeAction(priority: Priority): HomeAction? = when (priority) {
Priority.CRITICAL_ACTION ->
if (needsRecovery) HomeAction(
eyebrow = "Account recovery",
title = "Secure your answers before continuing.",
body = "A privacy action needs your attention. Complete recovery to keep your shared space safe.",
cta = "Start recovery",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
) else null
Priority.PAIRING_NEEDED -> HomeAction(
eyebrow = "1 of 2 connected",
title = "A private space for two",
body = "Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.",
cta = "Invite partner",
target = HomeActionTarget.InvitePartner,
tone = HomeActionTone.Invite
)
Priority.ENCRYPTION_UNLOCK_NEEDED -> HomeAction(
eyebrow = "Encryption unlock",
title = "Unlock your shared answers.",
body = "Your couple's encryption needs to be restored. Complete recovery to keep accessing your answers.",
cta = "Recover keys",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
)
Priority.REVEAL_READY -> buildDailyQuestionAction(
title = "Your reveal is waiting",
body = "You both answered — open it together when you're ready.",
cta = "Reveal together"
)
Priority.PARTNER_ANSWERED_USER_PENDING -> buildDailyQuestionAction(
title = "Your partner answered. Your turn.",
body = "Answer to unlock the reveal. Your response stays private until you are ready.",
cta = "Answer to unlock reveal"
)
// NOTE: GAME_WAITING fires whenever there's an active session this user hasn't finished
// (for async games like This or That, completedByUsers stays empty until BOTH finish, so we
// cannot assume the partner has played their part here). Keep this copy accurate for every
// state — the real-time "X played their part, your turn" nudge is delivered separately by the
// push-driven YOUR_TURN GamePromptBanner, which knows the partner actually finished first.
Priority.GAME_WAITING -> HomeAction(
eyebrow = "Game in progress",
title = "Pick up your game.",
body = partnerName?.let {
"Jump back in to finish your picks and see how you and $it line up."
} ?: "Jump back in to finish your picks and see how you two line up.",
cta = "Play now",
target = HomeActionTarget.Game,
tone = HomeActionTone.Ritual,
gameRoute = waitingGameRoute
)
Priority.CHALLENGE_WAITING -> HomeAction(
eyebrow = "Connection challenge",
title = "Todays challenge step is ready.",
body = "Open one small shared action for tonight. It is meant to feel doable, not like homework.",
cta = "Open challenge",
target = HomeActionTarget.Challenge,
tone = HomeActionTone.Ritual
)
Priority.DAILY_QUESTION_UNANSWERED -> buildDailyQuestionAction(
title = dailyQuestion?.text ?: "Tonight's question is ready.",
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
cta = "Answer privately"
)
// You answered; the reveal waits on your partner. The hero card (PrimaryHomeActionCard) overrides
// this title/body and routes the CTA to the gentle-reminder send; this copy/CTA label is what shows
// when it renders as a smaller secondary card (a game/challenge is the hero).
Priority.DAILY_QUESTION_AWAITING_PARTNER -> buildDailyQuestionAction(
title = "You showed up tonight.",
body = partnerName?.let { "Your answer stays private until $it answers too — no pressure." }
?: "Your answer stays private until your partner answers too — no pressure.",
cta = "Send a gentle nudge"
)
// You already revealed today — a low-priority closure card that links to the discussion thread.
Priority.DAILY_QUESTION_REVEALED -> buildDailyQuestionAction(
title = "You opened a conversation tonight.",
body = "Keep it going whenever you're both ready.",
cta = "Keep the conversation going"
)
Priority.WEEKLY_RECAP_READY -> HomeAction(
eyebrow = "Your week together",
title = "Look back at what you built this week.",
body = "Reveals, answers, and small rituals are summarized for just the two of you.",
cta = "See recap",
target = HomeActionTarget.WeeklyRecap,
tone = HomeActionTone.Reflection
)
Priority.CAPSULE_UNLOCKED -> HomeAction(
eyebrow = "Memory capsule",
title = "A saved memory is ready to open.",
body = "One of your time capsules unlocked. Open it together and remember why you saved it.",
cta = "Open capsule",
target = HomeActionTarget.MemoryCapsule,
tone = HomeActionTone.Reflection
)
Priority.DATE_REMINDER -> HomeAction(
eyebrow = "Date coming up",
title = "A planned moment is almost here.",
body = "You saved a date idea together. Check the details before the night arrives.",
cta = "View date",
target = HomeActionTarget.DatePlan,
tone = HomeActionTone.Ritual
)
Priority.DATE_REFLECTION_PENDING -> HomeAction(
eyebrow = "Date replay",
title = partnerName?.let { "Reflect on your date with $it 💭" }
?: "Reflect on your date 💭",
body = "Capture what the night meant to you. You'll reveal your reflections together when you're both ready.",
cta = "Add your reflection",
target = HomeActionTarget.DateMemories,
tone = HomeActionTone.Reflection
)
Priority.SUGGESTED_PACK -> categories.firstOrNull()?.let { category ->
HomeAction(
eyebrow = "Suggested pack",
title = category.category.displayName.ifBlank { "Question pack" },
body = "${category.questionCount} questions for when you want a different doorway into the conversation.",
cta = "Open pack",
target = HomeActionTarget.QuestionPacks,
tone = HomeActionTone.Pack,
categoryId = category.category.id
)
}
Priority.EXPLORE_GAMES -> HomeAction(
eyebrow = "Explore",
title = "Try a game together.",
body = "Playful ways to connect when you both want something light.",
cta = "Browse games",
target = HomeActionTarget.QuestionPacks,
tone = HomeActionTone.Starter
)
}
private fun HomeUiState.buildDailyQuestionAction(
title: String,
body: String,
cta: String
): HomeAction = HomeAction(
eyebrow = "Your daily question",
title = title,
body = body,
cta = cta,
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { it.isNotBlank() }?.toHomeLabel()
)
private fun HomeUiState.buildPendingActions(): List<PendingActionCard> {
if (!isPaired) return emptyList()
val actions = mutableListOf<PendingActionCard>()
if (dailyQuestionState == DailyQuestionState.BOTH_ANSWERED) {
actions += PendingActionCard(
title = "Your reveal is waiting",
subtitle = "Both of you answered tonight. Open it together.",
priority = 1,
target = HomeActionTarget.AnswerReveal
)
}
if (dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING) {
actions += PendingActionCard(
title = "Your partner answered",
subtitle = "Answer tonights question to unlock the reveal.",
priority = 2,
target = HomeActionTarget.DailyQuestion
)
}
if (hasWaitingGame()) {
actions += PendingActionCard(
title = "Game waiting",
subtitle = "Your turn to play a game together.",
priority = 3,
target = HomeActionTarget.Game
)
}
if (hasIncompleteChallenge()) {
actions += PendingActionCard(
title = "Challenge waiting",
subtitle = "Todays small step is ready for both of you.",
priority = 4,
target = HomeActionTarget.Challenge
)
}
if (hasUpcomingDate()) {
actions += PendingActionCard(
title = "Date coming up",
subtitle = "A planned moment is almost here.",
priority = 5,
target = HomeActionTarget.DatePlan
)
}
if (hasPendingDateReflection) {
actions += PendingActionCard(
title = "Reflect on your date",
subtitle = "Capture the night, then reveal together.",
priority = 6,
target = HomeActionTarget.DateMemories
)
}
if (hasUnlockedCapsule()) {
actions += PendingActionCard(
title = "Capsule unlocked",
subtitle = "A saved memory is ready to open together.",
priority = 7,
target = HomeActionTarget.MemoryCapsule
)
}
return actions.sortedBy { it.priority }
}
private fun HomeUiState.hasWaitingGame(): Boolean = hasWaitingGame
private fun HomeUiState.hasIncompleteChallenge(): Boolean = hasActiveChallenge
private fun HomeUiState.hasUpcomingDate(): Boolean = hasUpcomingDatePlan
private fun HomeUiState.hasUnlockedCapsule(): Boolean = hasUnlockedCapsule
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() } }

View File

@ -0,0 +1,187 @@
package app.closer.ui.home
import androidx.compose.runtime.Immutable
import app.closer.core.navigation.AppRoute
import app.closer.domain.model.GameType
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.OutcomeDay
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
/**
* Home screen data models + pure state helpers, extracted from HomeViewModel.kt so the UI, the
* action mapper ([HomeActionMapper]), and tests can share them without pulling in the Firestore-bound
* ViewModel. Behavior-identical to the originals.
*/
// @Immutable: deeply immutable (QuestionCategory is all String vals) → structural-equality skipping
// for the category cards, an upgrade over the reference-equality strong-skipping default.
@Immutable
data class HomeCategorySummary(
val category: QuestionCategory,
val questionCount: Int
)
data class HomeAnswerStats(
val total: Int = 0,
val revealed: Int = 0,
val private: Int = 0,
val latest: LocalAnswer? = null,
val answeredQuestionIds: Set<String> = emptySet()
)
enum class HomeActionTarget {
InvitePartner,
DailyQuestion,
AnswerHistory,
QuestionPacks,
Settings,
AnswerReveal,
Game,
Challenge,
DatePlan,
MemoryCapsule,
DateMemories,
WeeklyRecap
}
enum class HomeActionTone {
Invite,
Daily,
Reflection,
Ritual,
Starter,
Pack,
Utility,
Pending
}
// @Immutable: only String/enum/primitive fields → the single-instance card params
// (primaryAction, secondary/feed items) become value-skippable.
@Immutable
data class HomeAction(
val eyebrow: String,
val title: String,
val body: String,
val cta: String,
val target: HomeActionTarget,
val tone: HomeActionTone,
val metric: String? = null,
val categoryId: String? = null,
// For the "your partner is waiting to play" CTA: the specific game route to resume
// (so "Play now" jumps into the actual waiting game, not the generic Play hub). B-002.
val gameRoute: String? = null
)
// @Immutable: only String/Int/enum fields → the pending-card params become value-skippable.
@Immutable
data class PendingActionCard(
val title: String,
val subtitle: String?,
val priority: Int,
val target: HomeActionTarget
)
/**
* The entry route that resumes an in-progress game of [gameType]. Each game screen
* detects the couple's active session on open and joins it, so navigating here lets the
* Home "Play now" CTA drop the user straight back into the waiting game (B-002).
*/
internal fun gameRouteFor(gameType: String?): String? = when (gameType) {
GameType.WHEEL -> AppRoute.SPIN_WHEEL_RANDOM
GameType.THIS_OR_THAT -> AppRoute.THIS_OR_THAT
GameType.HOW_WELL -> AppRoute.HOW_WELL
GameType.DESIRE_SYNC -> AppRoute.DESIRE_SYNC
else -> null
}
enum class DailyQuestionState {
UNANSWERED,
USER_ANSWERED_PARTNER_PENDING,
PARTNER_ANSWERED_USER_PENDING,
BOTH_ANSWERED,
REVEALED
}
/**
* Pure derivation of the daily-question home state extracted so it can be unit-tested directly,
* mirroring the pure-logic style of [HomePriorityEngine].
*
* The partner is only counted as "answered" when their answer is for THIS question. The partner-answer
* listener keys off *today's date*, not the question id, so a rotated daily question (e.g. across
* midnight while Home is open) could otherwise mark the pair as both-answered for a question the partner
* never answered. A blank [partnerAnsweredQuestionId] (legacy answer docs written before the field
* existed) falls back to plain existence so the common case never regresses.
*/
@androidx.annotation.VisibleForTesting
internal fun computeDailyQuestionState(
questionId: String?,
answeredQuestionIds: Set<String>,
latestAnswer: LocalAnswer?,
hasPartnerAnsweredToday: Boolean,
partnerAnsweredQuestionId: String?
): DailyQuestionState {
val userAnswered = questionId != null && questionId in answeredQuestionIds
val userRevealed = questionId != null &&
latestAnswer?.let { it.questionId == questionId && it.isRevealed } == true
val partnerAnswered = hasPartnerAnsweredToday &&
(partnerAnsweredQuestionId == questionId || partnerAnsweredQuestionId.isNullOrBlank())
return when {
questionId == null -> DailyQuestionState.UNANSWERED
userRevealed -> DailyQuestionState.REVEALED
userAnswered && partnerAnswered -> DailyQuestionState.BOTH_ANSWERED
userAnswered -> DailyQuestionState.USER_ANSWERED_PARTNER_PENDING
partnerAnswered -> DailyQuestionState.PARTNER_ANSWERED_USER_PENDING
else -> DailyQuestionState.UNANSWERED
}
}
data class HomeUiState(
val isLoading: Boolean = true,
val error: String? = null,
val dailyQuestion: Question? = null,
val categories: List<HomeCategorySummary> = emptyList(),
val answerStats: HomeAnswerStats = HomeAnswerStats(),
val partnerName: String? = null,
val partnerPhotoUrl: String? = null,
val streakCount: Int = 0,
/** When the couple was created (millis), for the partner-sheet "together since" glance. 0 = unknown. */
val togetherSince: Long = 0L,
val isPaired: Boolean = false,
val primaryAction: HomeAction? = null,
val secondaryActions: List<HomeAction> = emptyList(),
val partnerLeftEvent: Boolean = false,
val needsRecovery: Boolean = false,
val coupleId: String? = null,
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
val hasPartnerAnsweredToday: Boolean = false,
val partnerAnsweredQuestionId: String? = null,
val hasRevealedToday: Boolean = false,
val pendingActions: List<PendingActionCard> = emptyList(),
// Retention signals — populated in loadHome() and observeAnswers()
val hasWaitingGame: Boolean = false,
// The route of the active game waiting for this user, so the Home "Play now" CTA
// resumes that specific game instead of dumping on the generic Play hub (B-002).
val waitingGameRoute: String? = null,
// The waiting game's type (e.g. "wheel"), so the Home card can name the game ("… in Spin the Wheel").
val waitingGameType: String? = null,
val hasActiveChallenge: Boolean = false,
val hasUpcomingDatePlan: Boolean = false,
val hasUnlockedCapsule: Boolean = false,
// A completed date this user hasn't reflected on yet (drives the Home "reflect on your date" nudge).
val hasPendingDateReflection: Boolean = false,
val weeklyRecapReady: Boolean = false,
val reminderSentEvent: Boolean = false,
/** "Thinking of you" nudge: in-flight guard + one-shot snackbar message (success or friendly error). */
val isSendingNudge: Boolean = false,
val nudgeResult: String? = null,
// Outcome check-in state
val outcomeSubmitSuccess: Boolean = false,
val outcomeError: String? = null,
val showOutcomeBaselineDialog: Boolean = false,
val showOutcomeFollowUpDialog: Boolean = false,
val outcomeFollowUpDay: OutcomeDay? = null,
/** Non-null when a streak tier was just reached and should be celebrated. */
val streakMilestone: Int? = null,
val unreadActivityCount: Int = 0
)

View File

@ -5,14 +5,11 @@ import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.Spacer
import androidx.compose.ui.window.Dialog
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import app.closer.ui.components.CelebrationOverlay
import app.closer.ui.components.CategoryGlyph
import app.closer.ui.components.CloserActionButton
import app.closer.ui.components.CloserButtonStyle
@ -24,14 +21,10 @@ import app.closer.ui.components.CloserRadii
import app.closer.ui.components.ErrorState
import app.closer.ui.components.LoadingState
import app.closer.ui.questions.displayCategoryName
import app.closer.ui.settings.SettingsInk
import app.closer.ui.settings.SettingsMuted
import app.closer.ui.settings.SettingsSoft
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerBackgroundBrush
import app.closer.ui.theme.closerCardColor
import app.closer.ui.theme.isCloserDarkTheme
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.Arrangement
@ -75,7 +68,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.vectorResource
import app.closer.ui.components.BrandIllustration
import androidx.compose.ui.text.font.FontWeight
@ -92,10 +84,15 @@ import app.closer.ui.home.components.CategoryPreviewGrid
import app.closer.ui.home.components.EmptyHomeContent
import app.closer.ui.home.components.ErrorHomeCard
import app.closer.ui.home.components.HomeActionColors
import app.closer.ui.home.components.HomeHeader
import app.closer.ui.home.components.HomeGlyphIcon
import app.closer.ui.home.components.HomePill
import app.closer.ui.home.components.HomeStatusStrip
import app.closer.ui.home.components.LoadingHomeCard
import app.closer.ui.home.components.PartnerQuickActionsSheet
import app.closer.ui.home.components.PartnerActivationCard
import app.closer.ui.home.components.PrimaryHomeActionCard
import app.closer.ui.home.components.StreakMilestoneDialog
import app.closer.ui.home.components.WaitingForYouSection
import app.closer.ui.home.components.actionColors
import app.closer.ui.home.components.homeActionGlyph
@ -424,703 +421,8 @@ private fun HomeContent(
}
}
@Composable
private fun HomeHeader(
partnerName: String?,
partnerPhotoUrl: String?,
streakCount: Int,
isPaired: Boolean,
unreadActivityCount: Int = 0,
onTogether: () -> Unit = {}
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "For tonight",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (streakCount > 0) {
HomePill("$streakCount nights")
}
// Partner/together entry point with an unread badge.
Box {
PartnerHeaderBubble(
partnerName = partnerName,
partnerPhotoUrl = partnerPhotoUrl,
isPaired = isPaired,
onClick = onTogether
)
if (unreadActivityCount > 0) {
// Surface-ringed dot so it stays legible over the avatar photo or the gradient ring.
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(14.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(CloserPalette.PinkBright)
)
}
}
}
}
Text(
text = if (!isPaired)
"Set up your shared space, then keep exploring at your own pace."
else if (partnerName != null)
"Connected with $partnerName. Here's what matters tonight."
else
"Open the app, see what matters, and take one small step toward closeness.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun PartnerHeaderBubble(
partnerName: String?,
partnerPhotoUrl: String?,
isPaired: Boolean,
onClick: () -> Unit
) {
// Brand gradient ring around the avatar — signals "your partner" + that it's tappable.
val ringBrush = Brush.linearGradient(
listOf(CloserPalette.PurpleRich, CloserPalette.PinkBright)
)
val label = when {
!isPaired -> "Invite your partner"
!partnerName.isNullOrBlank() -> "Open $partnerName's space"
else -> "Open your shared space"
}
Box(
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(ringBrush)
.clickable(onClick = onClick)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(46.dp)
.clip(CircleShape)
.background(CloserPalette.PurpleSoft),
contentAlignment = Alignment.Center
) {
when {
!isPaired -> HomeGlyphIcon(
resId = R.drawable.glyph_couple,
contentDescription = null,
tint = CloserPalette.PurpleRich,
modifier = Modifier.size(24.dp)
)
!partnerPhotoUrl.isNullOrBlank() -> SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(partnerPhotoUrl)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(CircleShape),
// While loading or on failure, keep the partner's initials centered in view
// (never a blank/grey circle).
loading = {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
PartnerInitials(partnerName = partnerName)
}
},
error = {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
PartnerInitials(partnerName = partnerName)
}
}
)
else -> PartnerInitials(partnerName = partnerName)
}
}
}
}
@Composable
private fun PartnerInitials(partnerName: String?) {
Text(
text = partnerInitials(partnerName),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = CloserPalette.PurpleRich,
maxLines = 1
)
}
private fun partnerInitials(name: String?): String {
val clean = name?.trim().orEmpty()
if (clean.isBlank()) return "2"
val words = clean.split(Regex("\\s+")).filter { it.isNotBlank() }
val initials = if (words.size >= 2) {
"${words[0].first()}${words[1].first()}"
} else {
clean.take(2)
}
return initials.uppercase()
}
/**
* Warm quick-actions sheet shown when the partner avatar is tapped: a relationship glance + one-tap
* actions. Replaces the old dead-end into the read-only "Together" feed. Only shown when paired.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PartnerQuickActionsSheet(
state: HomeUiState,
onDismiss: () -> Unit,
onNavigate: (String) -> Unit,
onThinkingOfYou: () -> Unit
) {
val sheetState = rememberModalBottomSheetState()
// A locked/blank name (E2EE key missing on this device) must never surface ciphertext/placeholder.
val name = state.partnerName
?.takeIf { it.isNotBlank() && it != FieldEncryptor.LOCKED_PLACEHOLDER }
?: "Your partner"
val glance = remember(state.streakCount, state.togetherSince) {
buildList {
if (state.streakCount > 0) {
add("💜 ${state.streakCount} ${if (state.streakCount == 1) "night" else "nights"}")
}
if (state.togetherSince > 0L) add("together since ${formatMonthYear(state.togetherSince)}")
}.joinToString(" · ")
}
// Living "today" status — reflects where the couple is in the daily ritual. Hidden when there's no
// question assigned (no misleading "still open").
val todayStatus = state.dailyQuestion?.let {
when (state.dailyQuestionState) {
DailyQuestionState.BOTH_ANSWERED, DailyQuestionState.REVEALED -> "You've both answered today 💜"
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "They answered — your turn"
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "Waiting on their answer"
DailyQuestionState.UNANSWERED -> "Tonight's question is still open"
}
}
val openPartnerPage = { onNavigate(AppRoute.PARTNER_HOME); onDismiss() }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = openPartnerPage)
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
PartnerHeaderBubble(
partnerName = name,
partnerPhotoUrl = state.partnerPhotoUrl,
isPaired = true,
onClick = openPartnerPage
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = name,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (glance.isNotBlank()) {
Text(
text = glance,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
todayStatus?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 0.5.dp)
PartnerSheetAction(R.drawable.glyph_heart, "Thinking of you", enabled = !state.isSendingNudge, onClick = onThinkingOfYou)
PartnerSheetAction(R.drawable.glyph_chat, "Message") { onNavigate(AppRoute.MESSAGES); onDismiss() }
PartnerSheetAction(
R.drawable.glyph_paired_cards,
"Together",
trailing = state.unreadActivityCount.takeIf { it > 0 }?.let { if (it > 9) "9+" else "$it" }
) { onNavigate(AppRoute.ACTIVITY); onDismiss() }
PartnerSheetAction(R.drawable.glyph_memory_capsule, "Our memories") { onNavigate(AppRoute.MEMORY_LANE); onDismiss() }
PartnerSheetAction(R.drawable.glyph_settings, "Your relationship") { onNavigate(AppRoute.RELATIONSHIP_SETTINGS); onDismiss() }
}
}
}
@Composable
private fun PartnerSheetAction(
@DrawableRes iconRes: Int,
label: String,
enabled: Boolean = true,
trailing: String? = null,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 8.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
HomeGlyphIcon(
resId = iconRes,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Text(
text = label,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f)
)
if (trailing != null) {
Surface(shape = CircleShape, color = CloserPalette.PinkBright) {
Text(
text = trailing,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = Color.White
)
}
}
}
}
private fun formatMonthYear(millis: Long): String =
java.text.SimpleDateFormat("MMM yyyy", java.util.Locale.getDefault()).format(java.util.Date(millis))
@Composable
private fun PartnerActivationCard(
onInvite: () -> Unit,
onAcceptInvite: () -> Unit
) {
val isDark = isCloserDarkTheme()
val inviteTone = HomeActionTone.Invite.actionColors()
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.FeatureCard),
containerColor = closerCardColor(alpha = 0.94f),
elevation = CloserElevations.Feature
) {
Column(
modifier = Modifier
.background(
Brush.linearGradient(
if (isDark) {
listOf(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
)
} else {
listOf(
MaterialTheme.colorScheme.surface,
CloserPalette.PurpleSoft,
CloserPalette.PinkMist.copy(alpha = 0.84f)
)
},
start = Offset.Zero,
end = Offset.Infinite
)
)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
HomePill("1 of 2 connected")
Surface(
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.76f)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically
) {
HomeGlyphIcon(
resId = R.drawable.glyph_lock,
contentDescription = null,
tint = inviteTone.deep,
modifier = Modifier.size(14.dp)
)
Text(
text = "Private invite",
style = MaterialTheme.typography.labelSmall,
color = inviteTone.deep,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
BrandIllustration(
res = R.drawable.illustration_partner_activation,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(142.dp)
.clip(RoundedCornerShape(22.dp))
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "A private space for two",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ActivationBenefitPill("Private reveals", Modifier.weight(1f))
ActivationBenefitPill("Shared streak", Modifier.weight(1f))
ActivationBenefitPill("Games for two", Modifier.weight(1f))
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
CloserActionButton(
label = "Invite partner",
onClick = onInvite,
modifier = Modifier.fillMaxWidth(),
containerColor = inviteTone.accent,
contentColor = inviteTone.onAccent
)
CloserActionButton(
label = "Enter code",
onClick = onAcceptInvite,
modifier = Modifier.fillMaxWidth(),
style = CloserButtonStyle.Secondary,
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
contentColor = inviteTone.deep
)
}
}
}
}
@Composable
private fun ActivationBenefitPill(
label: String,
modifier: Modifier = Modifier
) {
val colors = HomeActionTone.Invite.actionColors()
Surface(
modifier = modifier,
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.66f)
) {
Text(
text = label,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 9.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelSmall,
color = colors.deep,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun PrimaryHomeActionCard(
action: HomeAction,
onAction: (HomeAction) -> Unit,
onReminder: () -> Unit,
onReveal: () -> Unit,
onFollowUp: () -> Unit,
dailyQuestionState: DailyQuestionState,
dailyQuestion: Question?
) {
val colors = action.tone.actionColors()
val isDark = isCloserDarkTheme()
// Daily-question art stays people-forward while keeping the two states distinct.
val artRes = if (action.target == HomeActionTarget.DailyQuestion) {
when (dailyQuestionState) {
DailyQuestionState.UNANSWERED -> R.drawable.illustration_tonight_partner_prompt
DailyQuestionState.BOTH_ANSWERED -> R.drawable.illustration_daily_reveal_ready
else -> homePrimaryArt(action.target)
}
} else {
homePrimaryArt(action.target)
}
// For daily-question actions, route the CTA through the explicit state handlers
// so the same button label maps to the correct next step (answer, remind,
// reveal, or follow-up) even if the action target is still DailyQuestion.
val ctaClick = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> onReminder
DailyQuestionState.BOTH_ANSWERED -> onReveal
DailyQuestionState.REVEALED -> onFollowUp
else -> { { onAction(action) } }
}
else -> { { onAction(action) } }
}
val titleOverride = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.UNANSWERED -> "Tonight's question is ready."
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "You showed up tonight. Waiting for your partner."
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "Your partner answered. Your turn."
DailyQuestionState.BOTH_ANSWERED -> "Your reveal is waiting"
DailyQuestionState.REVEALED -> "You opened a conversation tonight."
}
else -> action.title
}
val bodyOverride = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.UNANSWERED ->
dailyQuestion?.text ?: "Answer tonight's question privately, then choose when to share."
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING ->
"Your answer is private until they answer too. No pressure — the reveal waits for both of you."
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING ->
"Answer to unlock the reveal. Your response stays private until you are ready."
DailyQuestionState.BOTH_ANSWERED ->
"You both answered — open it together when you're ready."
DailyQuestionState.REVEALED ->
"You revealed an answer together. What comes next is up to both of you."
}
else -> action.body
}
val timeBudgetLabel = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestion?.type) {
"scale" -> "~1 min"
"single_choice" -> "~2 min"
"this_or_that" -> "~2 min"
else -> "~3 min"
}
else -> null
}
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.FeatureCard),
containerColor = closerCardColor(alpha = 0.92f),
elevation = CloserElevations.Feature
) {
Column(
modifier = Modifier
.background(
Brush.linearGradient(
if (isDark) {
listOf(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.surfaceVariant,
colors.soft.copy(alpha = 0.72f)
)
} else {
listOf(
MaterialTheme.colorScheme.surface,
colors.soft,
CloserPalette.PinkMist.copy(alpha = 0.72f)
)
},
start = Offset.Zero,
end = Offset.Infinite
)
)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
HomePill(action.eyebrow)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
timeBudgetLabel?.let { label ->
HomePill(label)
}
action.metric?.let { HomePill(it) }
}
}
artRes?.let { res ->
BrandIllustration(
res = res,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.clip(RoundedCornerShape(22.dp))
)
}
if (artRes != null) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = titleOverride,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = bodyOverride,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top
) {
Surface(
shape = RoundedCornerShape(CloserRadii.Tile),
color = colors.accent.copy(alpha = 0.16f),
modifier = Modifier.size(52.dp)
) {
Box(contentAlignment = Alignment.Center) {
HomeGlyphIcon(
resId = homeActionGlyph(action.target),
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(26.dp)
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = titleOverride,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = bodyOverride,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}
}
CloserActionButton(
label = action.cta,
onClick = ctaClick,
modifier = Modifier.fillMaxWidth(),
containerColor = colors.accent,
contentColor = colors.onAccent
)
}
}
}
@Composable
private fun MomentCueCard() {
val isDark = isCloserDarkTheme()
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.Card),
containerColor = if (isDark) {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.58f)
} else {
CloserPalette.PinkMist.copy(alpha = 0.7f)
},
elevation = CloserElevations.Flat
) {
Column(
modifier = Modifier.padding(17.dp),
verticalArrangement = Arrangement.spacedBy(7.dp)
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
HomePill("Special dates")
HomePill("Quiet reminder")
}
Text(
text = "Keep important days close without turning them into chores.",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Birthdays, anniversaries, and planned moments will sit here as gentle cues once they are saved.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Preview
@Composable
@ -1310,57 +612,3 @@ private fun previewDailyQuestionAction(state: DailyQuestionState): HomeAction =
)
}
@Composable
internal fun StreakMilestoneDialog(
milestone: Int,
partnerName: String?,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Box(contentAlignment = Alignment.Center) {
Surface(
shape = RoundedCornerShape(28.dp),
color = SettingsSoft,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 28.dp, vertical = 30.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(R.drawable.illustration_streak_milestone),
contentDescription = null,
modifier = Modifier.size(156.dp)
)
Spacer(Modifier.height(18.dp))
Text(
text = if (milestone == 1) "Your first night together" else "$milestone nights together",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = SettingsInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
val nights = if (milestone == 1) "your first night" else "$milestone nights running"
Text(
text = partnerName?.takeIf { it.isNotBlank() }
?.let { "You and $it keep showing up for each other — $nights. The small moments are adding up to something." }
?: "You keep showing up — $nights. The small moments are adding up to something.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
CloserActionButton(
label = "Keep going together",
onClick = onDismiss,
modifier = Modifier.fillMaxWidth()
)
}
}
CelebrationOverlay(visible = true, intensity = 1.4f)
}
}
}

View File

@ -31,8 +31,6 @@ import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.functions.FirebaseFunctions
import com.google.firebase.functions.FirebaseFunctionsException
import dagger.hilt.android.lifecycle.HiltViewModel
import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput
import app.closer.ui.home.HomePriorityEngine.Priority
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.ZoneId
@ -49,169 +47,6 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.first
data class HomeCategorySummary(
val category: QuestionCategory,
val questionCount: Int
)
data class HomeAnswerStats(
val total: Int = 0,
val revealed: Int = 0,
val private: Int = 0,
val latest: LocalAnswer? = null,
val answeredQuestionIds: Set<String> = emptySet()
)
enum class HomeActionTarget {
InvitePartner,
DailyQuestion,
AnswerHistory,
QuestionPacks,
Settings,
AnswerReveal,
Game,
Challenge,
DatePlan,
MemoryCapsule,
DateMemories,
WeeklyRecap
}
enum class HomeActionTone {
Invite,
Daily,
Reflection,
Ritual,
Starter,
Pack,
Utility,
Pending
}
data class HomeAction(
val eyebrow: String,
val title: String,
val body: String,
val cta: String,
val target: HomeActionTarget,
val tone: HomeActionTone,
val metric: String? = null,
val categoryId: String? = null,
// For the "your partner is waiting to play" CTA: the specific game route to resume
// (so "Play now" jumps into the actual waiting game, not the generic Play hub). B-002.
val gameRoute: String? = null
)
data class PendingActionCard(
val title: String,
val subtitle: String?,
val priority: Int,
val target: HomeActionTarget
)
/**
* The entry route that resumes an in-progress game of [gameType]. Each game screen
* detects the couple's active session on open and joins it, so navigating here lets the
* Home "Play now" CTA drop the user straight back into the waiting game (B-002).
*/
private fun gameRouteFor(gameType: String?): String? = when (gameType) {
GameType.WHEEL -> AppRoute.SPIN_WHEEL_RANDOM
GameType.THIS_OR_THAT -> AppRoute.THIS_OR_THAT
GameType.HOW_WELL -> AppRoute.HOW_WELL
GameType.DESIRE_SYNC -> AppRoute.DESIRE_SYNC
else -> null
}
enum class DailyQuestionState {
UNANSWERED,
USER_ANSWERED_PARTNER_PENDING,
PARTNER_ANSWERED_USER_PENDING,
BOTH_ANSWERED,
REVEALED
}
/**
* Pure derivation of the daily-question home state extracted so it can be unit-tested directly,
* mirroring the pure-logic style of [HomePriorityEngine].
*
* The partner is only counted as "answered" when their answer is for THIS question. The partner-answer
* listener keys off *today's date*, not the question id, so a rotated daily question (e.g. across
* midnight while Home is open) could otherwise mark the pair as both-answered for a question the partner
* never answered. A blank [partnerAnsweredQuestionId] (legacy answer docs written before the field
* existed) falls back to plain existence so the common case never regresses.
*/
@androidx.annotation.VisibleForTesting
internal fun computeDailyQuestionState(
questionId: String?,
answeredQuestionIds: Set<String>,
latestAnswer: LocalAnswer?,
hasPartnerAnsweredToday: Boolean,
partnerAnsweredQuestionId: String?
): DailyQuestionState {
val userAnswered = questionId != null && questionId in answeredQuestionIds
val userRevealed = questionId != null &&
latestAnswer?.let { it.questionId == questionId && it.isRevealed } == true
val partnerAnswered = hasPartnerAnsweredToday &&
(partnerAnsweredQuestionId == questionId || partnerAnsweredQuestionId.isNullOrBlank())
return when {
questionId == null -> DailyQuestionState.UNANSWERED
userRevealed -> DailyQuestionState.REVEALED
userAnswered && partnerAnswered -> DailyQuestionState.BOTH_ANSWERED
userAnswered -> DailyQuestionState.USER_ANSWERED_PARTNER_PENDING
partnerAnswered -> DailyQuestionState.PARTNER_ANSWERED_USER_PENDING
else -> DailyQuestionState.UNANSWERED
}
}
data class HomeUiState(
val isLoading: Boolean = true,
val error: String? = null,
val dailyQuestion: Question? = null,
val categories: List<HomeCategorySummary> = emptyList(),
val answerStats: HomeAnswerStats = HomeAnswerStats(),
val partnerName: String? = null,
val partnerPhotoUrl: String? = null,
val streakCount: Int = 0,
/** When the couple was created (millis), for the partner-sheet "together since" glance. 0 = unknown. */
val togetherSince: Long = 0L,
val isPaired: Boolean = false,
val primaryAction: HomeAction? = null,
val secondaryActions: List<HomeAction> = emptyList(),
val partnerLeftEvent: Boolean = false,
val needsRecovery: Boolean = false,
val coupleId: String? = null,
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
val hasPartnerAnsweredToday: Boolean = false,
val partnerAnsweredQuestionId: String? = null,
val hasRevealedToday: Boolean = false,
val pendingActions: List<PendingActionCard> = emptyList(),
// Retention signals — populated in loadHome() and observeAnswers()
val hasWaitingGame: Boolean = false,
// The route of the active game waiting for this user, so the Home "Play now" CTA
// resumes that specific game instead of dumping on the generic Play hub (B-002).
val waitingGameRoute: String? = null,
// The waiting game's type (e.g. "wheel"), so the Home card can name the game ("… in Spin the Wheel").
val waitingGameType: String? = null,
val hasActiveChallenge: Boolean = false,
val hasUpcomingDatePlan: Boolean = false,
val hasUnlockedCapsule: Boolean = false,
// A completed date this user hasn't reflected on yet (drives the Home "reflect on your date" nudge).
val hasPendingDateReflection: Boolean = false,
val weeklyRecapReady: Boolean = false,
val reminderSentEvent: Boolean = false,
/** "Thinking of you" nudge: in-flight guard + one-shot snackbar message (success or friendly error). */
val isSendingNudge: Boolean = false,
val nudgeResult: String? = null,
// Outcome check-in state
val outcomeSubmitSuccess: Boolean = false,
val outcomeError: String? = null,
val showOutcomeBaselineDialog: Boolean = false,
val showOutcomeFollowUpDialog: Boolean = false,
val outcomeFollowUpDay: OutcomeDay? = null,
/** Non-null when a streak tier was just reached and should be celebrated. */
val streakMilestone: Int? = null,
val unreadActivityCount: Int = 0
)
@HiltViewModel
class HomeViewModel @Inject constructor(
@ -713,311 +548,6 @@ class HomeViewModel @Inject constructor(
}
}
private fun HomeUiState.refreshDailyQuestionState(): HomeUiState {
val state = computeDailyQuestionState(
questionId = dailyQuestion?.id,
answeredQuestionIds = answerStats.answeredQuestionIds,
latestAnswer = answerStats.latest,
hasPartnerAnsweredToday = hasPartnerAnsweredToday,
partnerAnsweredQuestionId = partnerAnsweredQuestionId
)
return copy(
dailyQuestionState = state,
hasRevealedToday = state == DailyQuestionState.REVEALED
)
}
private fun HomeUiState.withHomeActions(): HomeUiState {
if (isLoading || error != null) {
return copy(primaryAction = null, secondaryActions = emptyList(), pendingActions = emptyList())
}
val engineInput = PriorityInput(
needsCriticalAction = needsRecovery,
isPaired = isPaired,
needsEncryptionUnlock = needsRecovery,
revealReady = dailyQuestionState == DailyQuestionState.BOTH_ANSWERED,
partnerAnsweredUserPending = dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING,
gameWaiting = hasWaitingGame(),
challengeWaiting = hasIncompleteChallenge(),
dailyQuestionUnanswered = dailyQuestionState == DailyQuestionState.UNANSWERED && dailyQuestion != null,
dailyQuestionAwaitingPartner = dailyQuestionState == DailyQuestionState.USER_ANSWERED_PARTNER_PENDING,
dailyQuestionRevealed = dailyQuestionState == DailyQuestionState.REVEALED && dailyQuestion != null,
weeklyRecapReady = weeklyRecapReady,
capsuleUnlocked = hasUnlockedCapsule(),
dateReminder = hasUpcomingDate(),
dateReflectionPending = hasPendingDateReflection,
suggestedPackAvailable = categories.isNotEmpty(),
exploreGamesAvailable = categories.isNotEmpty()
)
val priorityOutput = HomePriorityEngine.compute(engineInput)
val primary = priorityOutput.primary?.let { toHomeAction(it.priority) }
val secondary = priorityOutput.secondary.mapNotNull { toHomeAction(it.priority) }
// The primary action already gets the prominent hero card; drop it from the "Waiting for
// you" list so the same item isn't surfaced twice (C-HOME-001).
val pending = buildPendingActions().filterNot { pending ->
pending.target == primary?.target ||
(primary?.target == HomeActionTarget.DailyQuestion &&
(pending.target == HomeActionTarget.AnswerReveal ||
pending.target == HomeActionTarget.DailyQuestion))
}
return copy(
primaryAction = primary,
secondaryActions = secondary.take(3),
pendingActions = pending.take(3)
)
}
private fun HomeUiState.toHomeAction(priority: Priority): HomeAction? = when (priority) {
Priority.CRITICAL_ACTION ->
if (needsRecovery) HomeAction(
eyebrow = "Account recovery",
title = "Secure your answers before continuing.",
body = "A privacy action needs your attention. Complete recovery to keep your shared space safe.",
cta = "Start recovery",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
) else null
Priority.PAIRING_NEEDED -> HomeAction(
eyebrow = "1 of 2 connected",
title = "A private space for two",
body = "Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.",
cta = "Invite partner",
target = HomeActionTarget.InvitePartner,
tone = HomeActionTone.Invite
)
Priority.ENCRYPTION_UNLOCK_NEEDED -> HomeAction(
eyebrow = "Encryption unlock",
title = "Unlock your shared answers.",
body = "Your couple's encryption needs to be restored. Complete recovery to keep accessing your answers.",
cta = "Recover keys",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
)
Priority.REVEAL_READY -> buildDailyQuestionAction(
title = "Your reveal is waiting",
body = "You both answered — open it together when you're ready.",
cta = "Reveal together"
)
Priority.PARTNER_ANSWERED_USER_PENDING -> buildDailyQuestionAction(
title = "Your partner answered. Your turn.",
body = "Answer to unlock the reveal. Your response stays private until you are ready.",
cta = "Answer to unlock reveal"
)
// NOTE: GAME_WAITING fires whenever there's an active session this user hasn't finished
// (for async games like This or That, completedByUsers stays empty until BOTH finish, so we
// cannot assume the partner has played their part here). Keep this copy accurate for every
// state — the real-time "X played their part, your turn" nudge is delivered separately by the
// push-driven YOUR_TURN GamePromptBanner, which knows the partner actually finished first.
Priority.GAME_WAITING -> HomeAction(
eyebrow = "Game in progress",
title = "Pick up your game.",
body = partnerName?.let {
"Jump back in to finish your picks and see how you and $it line up."
} ?: "Jump back in to finish your picks and see how you two line up.",
cta = "Play now",
target = HomeActionTarget.Game,
tone = HomeActionTone.Ritual,
gameRoute = waitingGameRoute
)
Priority.CHALLENGE_WAITING -> HomeAction(
eyebrow = "Connection challenge",
title = "Todays challenge step is ready.",
body = "Open one small shared action for tonight. It is meant to feel doable, not like homework.",
cta = "Open challenge",
target = HomeActionTarget.Challenge,
tone = HomeActionTone.Ritual
)
Priority.DAILY_QUESTION_UNANSWERED -> buildDailyQuestionAction(
title = dailyQuestion?.text ?: "Tonight's question is ready.",
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
cta = "Answer privately"
)
// You answered; the reveal waits on your partner. The hero card (PrimaryHomeActionCard) overrides
// this title/body and routes the CTA to the gentle-reminder send; this copy/CTA label is what shows
// when it renders as a smaller secondary card (a game/challenge is the hero).
Priority.DAILY_QUESTION_AWAITING_PARTNER -> buildDailyQuestionAction(
title = "You showed up tonight.",
body = partnerName?.let { "Your answer stays private until $it answers too — no pressure." }
?: "Your answer stays private until your partner answers too — no pressure.",
cta = "Send a gentle nudge"
)
// You already revealed today — a low-priority closure card that links to the discussion thread.
Priority.DAILY_QUESTION_REVEALED -> buildDailyQuestionAction(
title = "You opened a conversation tonight.",
body = "Keep it going whenever you're both ready.",
cta = "Keep the conversation going"
)
Priority.WEEKLY_RECAP_READY -> HomeAction(
eyebrow = "Your week together",
title = "Look back at what you built this week.",
body = "Reveals, answers, and small rituals are summarized for just the two of you.",
cta = "See recap",
target = HomeActionTarget.WeeklyRecap,
tone = HomeActionTone.Reflection
)
Priority.CAPSULE_UNLOCKED -> HomeAction(
eyebrow = "Memory capsule",
title = "A saved memory is ready to open.",
body = "One of your time capsules unlocked. Open it together and remember why you saved it.",
cta = "Open capsule",
target = HomeActionTarget.MemoryCapsule,
tone = HomeActionTone.Reflection
)
Priority.DATE_REMINDER -> HomeAction(
eyebrow = "Date coming up",
title = "A planned moment is almost here.",
body = "You saved a date idea together. Check the details before the night arrives.",
cta = "View date",
target = HomeActionTarget.DatePlan,
tone = HomeActionTone.Ritual
)
Priority.DATE_REFLECTION_PENDING -> HomeAction(
eyebrow = "Date replay",
title = partnerName?.let { "Reflect on your date with $it 💭" }
?: "Reflect on your date 💭",
body = "Capture what the night meant to you. You'll reveal your reflections together when you're both ready.",
cta = "Add your reflection",
target = HomeActionTarget.DateMemories,
tone = HomeActionTone.Reflection
)
Priority.SUGGESTED_PACK -> categories.firstOrNull()?.let { category ->
HomeAction(
eyebrow = "Suggested pack",
title = category.category.displayName.ifBlank { "Question pack" },
body = "${category.questionCount} questions for when you want a different doorway into the conversation.",
cta = "Open pack",
target = HomeActionTarget.QuestionPacks,
tone = HomeActionTone.Pack,
categoryId = category.category.id
)
}
Priority.EXPLORE_GAMES -> HomeAction(
eyebrow = "Explore",
title = "Try a game together.",
body = "Playful ways to connect when you both want something light.",
cta = "Browse games",
target = HomeActionTarget.QuestionPacks,
tone = HomeActionTone.Starter
)
}
private fun HomeUiState.buildDailyQuestionAction(
title: String,
body: String,
cta: String
): HomeAction = HomeAction(
eyebrow = "Your daily question",
title = title,
body = body,
cta = cta,
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { it.isNotBlank() }?.toHomeLabel()
)
private fun HomeUiState.buildPendingActions(): List<PendingActionCard> {
if (!isPaired) return emptyList()
val actions = mutableListOf<PendingActionCard>()
if (dailyQuestionState == DailyQuestionState.BOTH_ANSWERED) {
actions += PendingActionCard(
title = "Your reveal is waiting",
subtitle = "Both of you answered tonight. Open it together.",
priority = 1,
target = HomeActionTarget.AnswerReveal
)
}
if (dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING) {
actions += PendingActionCard(
title = "Your partner answered",
subtitle = "Answer tonights question to unlock the reveal.",
priority = 2,
target = HomeActionTarget.DailyQuestion
)
}
if (hasWaitingGame()) {
actions += PendingActionCard(
title = "Game waiting",
subtitle = "Your turn to play a game together.",
priority = 3,
target = HomeActionTarget.Game
)
}
if (hasIncompleteChallenge()) {
actions += PendingActionCard(
title = "Challenge waiting",
subtitle = "Todays small step is ready for both of you.",
priority = 4,
target = HomeActionTarget.Challenge
)
}
if (hasUpcomingDate()) {
actions += PendingActionCard(
title = "Date coming up",
subtitle = "A planned moment is almost here.",
priority = 5,
target = HomeActionTarget.DatePlan
)
}
if (hasPendingDateReflection) {
actions += PendingActionCard(
title = "Reflect on your date",
subtitle = "Capture the night, then reveal together.",
priority = 6,
target = HomeActionTarget.DateMemories
)
}
if (hasUnlockedCapsule()) {
actions += PendingActionCard(
title = "Capsule unlocked",
subtitle = "A saved memory is ready to open together.",
priority = 7,
target = HomeActionTarget.MemoryCapsule
)
}
return actions.sortedBy { it.priority }
}
private fun HomeUiState.hasWaitingGame(): Boolean = hasWaitingGame
private fun HomeUiState.hasIncompleteChallenge(): Boolean = hasActiveChallenge
private fun HomeUiState.hasUpcomingDate(): Boolean = hasUpcomingDatePlan
private fun HomeUiState.hasUnlockedCapsule(): Boolean = hasUnlockedCapsule
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 {
private const val TAG = "HomeViewModel"

View File

@ -0,0 +1,354 @@
package app.closer.ui.home.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.closer.R
import app.closer.core.navigation.AppRoute
import app.closer.crypto.FieldEncryptor
import app.closer.ui.home.DailyQuestionState
import app.closer.ui.home.HomeUiState
import app.closer.ui.theme.CloserPalette
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
/** Home's header row (title + streak + partner presence bubble) and the partner quick-actions bottom
* sheet. Extracted from HomeScreen.kt. */
@Composable
fun HomeHeader(
partnerName: String?,
partnerPhotoUrl: String?,
streakCount: Int,
isPaired: Boolean,
unreadActivityCount: Int = 0,
onTogether: () -> Unit = {}
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "For tonight",
style = MaterialTheme.typography.headlineLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
if (streakCount > 0) {
HomePill("$streakCount nights")
}
// Partner/together entry point with an unread badge.
Box {
PartnerHeaderBubble(
partnerName = partnerName,
partnerPhotoUrl = partnerPhotoUrl,
isPaired = isPaired,
onClick = onTogether
)
if (unreadActivityCount > 0) {
// Surface-ringed dot so it stays legible over the avatar photo or the gradient ring.
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.size(14.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(10.dp)
.clip(CircleShape)
.background(CloserPalette.PinkBright)
)
}
}
}
}
Text(
text = if (!isPaired)
"Set up your shared space, then keep exploring at your own pace."
else if (partnerName != null)
"Connected with $partnerName. Here's what matters tonight."
else
"Open the app, see what matters, and take one small step toward closeness.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun PartnerHeaderBubble(
partnerName: String?,
partnerPhotoUrl: String?,
isPaired: Boolean,
onClick: () -> Unit
) {
// Brand gradient ring around the avatar — signals "your partner" + that it's tappable.
val ringBrush = Brush.linearGradient(
listOf(CloserPalette.PurpleRich, CloserPalette.PinkBright)
)
val label = when {
!isPaired -> "Invite your partner"
!partnerName.isNullOrBlank() -> "Open $partnerName's space"
else -> "Open your shared space"
}
Box(
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(ringBrush)
.clickable(onClick = onClick)
.semantics { contentDescription = label },
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(46.dp)
.clip(CircleShape)
.background(CloserPalette.PurpleSoft),
contentAlignment = Alignment.Center
) {
when {
!isPaired -> HomeGlyphIcon(
resId = R.drawable.glyph_couple,
contentDescription = null,
tint = CloserPalette.PurpleRich,
modifier = Modifier.size(24.dp)
)
!partnerPhotoUrl.isNullOrBlank() -> SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(partnerPhotoUrl)
.crossfade(true)
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().clip(CircleShape),
// While loading or on failure, keep the partner's initials centered in view
// (never a blank/grey circle).
loading = {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
PartnerInitials(partnerName = partnerName)
}
},
error = {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
PartnerInitials(partnerName = partnerName)
}
}
)
else -> PartnerInitials(partnerName = partnerName)
}
}
}
}
@Composable
private fun PartnerInitials(partnerName: String?) {
Text(
text = partnerInitials(partnerName),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = CloserPalette.PurpleRich,
maxLines = 1
)
}
private fun partnerInitials(name: String?): String {
val clean = name?.trim().orEmpty()
if (clean.isBlank()) return "2"
val words = clean.split(Regex("\\s+")).filter { it.isNotBlank() }
val initials = if (words.size >= 2) {
"${words[0].first()}${words[1].first()}"
} else {
clean.take(2)
}
return initials.uppercase()
}
/**
* Warm quick-actions sheet shown when the partner avatar is tapped: a relationship glance + one-tap
* actions. Replaces the old dead-end into the read-only "Together" feed. Only shown when paired.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PartnerQuickActionsSheet(
state: HomeUiState,
onDismiss: () -> Unit,
onNavigate: (String) -> Unit,
onThinkingOfYou: () -> Unit
) {
val sheetState = rememberModalBottomSheetState()
// A locked/blank name (E2EE key missing on this device) must never surface ciphertext/placeholder.
val name = state.partnerName
?.takeIf { it.isNotBlank() && it != FieldEncryptor.LOCKED_PLACEHOLDER }
?: "Your partner"
val glance = remember(state.streakCount, state.togetherSince) {
buildList {
if (state.streakCount > 0) {
add("💜 ${state.streakCount} ${if (state.streakCount == 1) "night" else "nights"}")
}
if (state.togetherSince > 0L) add("together since ${formatMonthYear(state.togetherSince)}")
}.joinToString(" · ")
}
// Living "today" status — reflects where the couple is in the daily ritual. Hidden when there's no
// question assigned (no misleading "still open").
val todayStatus = state.dailyQuestion?.let {
when (state.dailyQuestionState) {
DailyQuestionState.BOTH_ANSWERED, DailyQuestionState.REVEALED -> "You've both answered today 💜"
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "They answered — your turn"
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "Waiting on their answer"
DailyQuestionState.UNANSWERED -> "Tonight's question is still open"
}
}
val openPartnerPage = { onNavigate(AppRoute.PARTNER_HOME); onDismiss() }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 28.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.clickable(onClick = openPartnerPage)
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.CenterVertically
) {
PartnerHeaderBubble(
partnerName = name,
partnerPhotoUrl = state.partnerPhotoUrl,
isPaired = true,
onClick = openPartnerPage
)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
text = name,
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (glance.isNotBlank()) {
Text(
text = glance,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
todayStatus?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Medium),
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Divider(modifier = Modifier.padding(vertical = 4.dp), thickness = 0.5.dp)
PartnerSheetAction(R.drawable.glyph_heart, "Thinking of you", enabled = !state.isSendingNudge, onClick = onThinkingOfYou)
PartnerSheetAction(R.drawable.glyph_chat, "Message") { onNavigate(AppRoute.MESSAGES); onDismiss() }
PartnerSheetAction(
R.drawable.glyph_paired_cards,
"Together",
trailing = state.unreadActivityCount.takeIf { it > 0 }?.let { if (it > 9) "9+" else "$it" }
) { onNavigate(AppRoute.ACTIVITY); onDismiss() }
PartnerSheetAction(R.drawable.glyph_memory_capsule, "Our memories") { onNavigate(AppRoute.MEMORY_LANE); onDismiss() }
PartnerSheetAction(R.drawable.glyph_settings, "Your relationship") { onNavigate(AppRoute.RELATIONSHIP_SETTINGS); onDismiss() }
}
}
}
@Composable
private fun PartnerSheetAction(
@DrawableRes iconRes: Int,
label: String,
enabled: Boolean = true,
trailing: String? = null,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 8.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
HomeGlyphIcon(
resId = iconRes,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(22.dp)
)
Text(
text = label,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = if (enabled) 1f else 0.5f)
)
if (trailing != null) {
Surface(shape = CircleShape, color = CloserPalette.PinkBright) {
Text(
text = trailing,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
style = MaterialTheme.typography.labelSmall,
color = Color.White
)
}
}
}
}
private fun formatMonthYear(millis: Long): String =
java.text.SimpleDateFormat("MMM yyyy", java.util.Locale.getDefault()).format(java.util.Date(millis))

View File

@ -0,0 +1,397 @@
package app.closer.ui.home.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import app.closer.R
import app.closer.domain.model.Question
import app.closer.ui.components.BrandIllustration
import app.closer.ui.components.CloserActionButton
import app.closer.ui.components.CloserButtonStyle
import app.closer.ui.components.CloserCard
import app.closer.ui.components.CloserElevations
import app.closer.ui.components.CloserRadii
import app.closer.ui.home.DailyQuestionState
import app.closer.ui.home.HomeAction
import app.closer.ui.home.HomeActionTarget
import app.closer.ui.home.HomeActionTone
import app.closer.ui.theme.CloserPalette
import app.closer.ui.theme.closerCardColor
import app.closer.ui.theme.isCloserDarkTheme
/** Home's hero cards the unpaired partner-activation card and the primary daily-question/action
* card (with its per-state title/body/CTA overrides). Extracted from HomeScreen.kt. */
@Composable
fun PartnerActivationCard(
onInvite: () -> Unit,
onAcceptInvite: () -> Unit
) {
val isDark = isCloserDarkTheme()
val inviteTone = HomeActionTone.Invite.actionColors()
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.FeatureCard),
containerColor = closerCardColor(alpha = 0.94f),
elevation = CloserElevations.Feature
) {
Column(
modifier = Modifier
.background(
Brush.linearGradient(
if (isDark) {
listOf(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
)
} else {
listOf(
MaterialTheme.colorScheme.surface,
CloserPalette.PurpleSoft,
CloserPalette.PinkMist.copy(alpha = 0.84f)
)
},
start = Offset.Zero,
end = Offset.Infinite
)
)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(18.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
HomePill("1 of 2 connected")
Surface(
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.76f)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically
) {
HomeGlyphIcon(
resId = R.drawable.glyph_lock,
contentDescription = null,
tint = inviteTone.deep,
modifier = Modifier.size(14.dp)
)
Text(
text = "Private invite",
style = MaterialTheme.typography.labelSmall,
color = inviteTone.deep,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
BrandIllustration(
res = R.drawable.illustration_partner_activation,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(142.dp)
.clip(RoundedCornerShape(22.dp))
)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "A private space for two",
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Invite your partner to unlock shared reveals, games, streaks, and answers you can both respond to.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
ActivationBenefitPill("Private reveals", Modifier.weight(1f))
ActivationBenefitPill("Shared streak", Modifier.weight(1f))
ActivationBenefitPill("Games for two", Modifier.weight(1f))
}
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
CloserActionButton(
label = "Invite partner",
onClick = onInvite,
modifier = Modifier.fillMaxWidth(),
containerColor = inviteTone.accent,
contentColor = inviteTone.onAccent
)
CloserActionButton(
label = "Enter code",
onClick = onAcceptInvite,
modifier = Modifier.fillMaxWidth(),
style = CloserButtonStyle.Secondary,
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
contentColor = inviteTone.deep
)
}
}
}
}
@Composable
private fun ActivationBenefitPill(
label: String,
modifier: Modifier = Modifier
) {
val colors = HomeActionTone.Invite.actionColors()
Surface(
modifier = modifier,
shape = RoundedCornerShape(CloserRadii.Pill),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.66f)
) {
Text(
text = label,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 9.dp, vertical = 7.dp),
style = MaterialTheme.typography.labelSmall,
color = colors.deep,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
fun PrimaryHomeActionCard(
action: HomeAction,
onAction: (HomeAction) -> Unit,
onReminder: () -> Unit,
onReveal: () -> Unit,
onFollowUp: () -> Unit,
dailyQuestionState: DailyQuestionState,
dailyQuestion: Question?
) {
val colors = action.tone.actionColors()
val isDark = isCloserDarkTheme()
// Daily-question art stays people-forward while keeping the two states distinct.
val artRes = if (action.target == HomeActionTarget.DailyQuestion) {
when (dailyQuestionState) {
DailyQuestionState.UNANSWERED -> R.drawable.illustration_tonight_partner_prompt
DailyQuestionState.BOTH_ANSWERED -> R.drawable.illustration_daily_reveal_ready
else -> homePrimaryArt(action.target)
}
} else {
homePrimaryArt(action.target)
}
// For daily-question actions, route the CTA through the explicit state handlers
// so the same button label maps to the correct next step (answer, remind,
// reveal, or follow-up) even if the action target is still DailyQuestion.
val ctaClick = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> onReminder
DailyQuestionState.BOTH_ANSWERED -> onReveal
DailyQuestionState.REVEALED -> onFollowUp
else -> { { onAction(action) } }
}
else -> { { onAction(action) } }
}
val titleOverride = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.UNANSWERED -> "Tonight's question is ready."
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> "You showed up tonight. Waiting for your partner."
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> "Your partner answered. Your turn."
DailyQuestionState.BOTH_ANSWERED -> "Your reveal is waiting"
DailyQuestionState.REVEALED -> "You opened a conversation tonight."
}
else -> action.title
}
val bodyOverride = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestionState) {
DailyQuestionState.UNANSWERED ->
dailyQuestion?.text ?: "Answer tonight's question privately, then choose when to share."
DailyQuestionState.USER_ANSWERED_PARTNER_PENDING ->
"Your answer is private until they answer too. No pressure — the reveal waits for both of you."
DailyQuestionState.PARTNER_ANSWERED_USER_PENDING ->
"Answer to unlock the reveal. Your response stays private until you are ready."
DailyQuestionState.BOTH_ANSWERED ->
"You both answered — open it together when you're ready."
DailyQuestionState.REVEALED ->
"You revealed an answer together. What comes next is up to both of you."
}
else -> action.body
}
val timeBudgetLabel = when (action.target) {
HomeActionTarget.DailyQuestion -> when (dailyQuestion?.type) {
"scale" -> "~1 min"
"single_choice" -> "~2 min"
"this_or_that" -> "~2 min"
else -> "~3 min"
}
else -> null
}
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.FeatureCard),
containerColor = closerCardColor(alpha = 0.92f),
elevation = CloserElevations.Feature
) {
Column(
modifier = Modifier
.background(
Brush.linearGradient(
if (isDark) {
listOf(
MaterialTheme.colorScheme.surface,
MaterialTheme.colorScheme.surfaceVariant,
colors.soft.copy(alpha = 0.72f)
)
} else {
listOf(
MaterialTheme.colorScheme.surface,
colors.soft,
CloserPalette.PinkMist.copy(alpha = 0.72f)
)
},
start = Offset.Zero,
end = Offset.Infinite
)
)
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
HomePill(action.eyebrow)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
timeBudgetLabel?.let { label ->
HomePill(label)
}
action.metric?.let { HomePill(it) }
}
}
artRes?.let { res ->
BrandIllustration(
res = res,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(150.dp)
.clip(RoundedCornerShape(22.dp))
)
}
if (artRes != null) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = titleOverride,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = bodyOverride,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
} else {
Row(
horizontalArrangement = Arrangement.spacedBy(14.dp),
verticalAlignment = Alignment.Top
) {
Surface(
shape = RoundedCornerShape(CloserRadii.Tile),
color = colors.accent.copy(alpha = 0.16f),
modifier = Modifier.size(52.dp)
) {
Box(contentAlignment = Alignment.Center) {
HomeGlyphIcon(
resId = homeActionGlyph(action.target),
contentDescription = null,
tint = colors.deep,
modifier = Modifier.size(26.dp)
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = titleOverride,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = bodyOverride,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}
}
CloserActionButton(
label = action.cta,
onClick = ctaClick,
modifier = Modifier.fillMaxWidth(),
containerColor = colors.accent,
contentColor = colors.onAccent
)
}
}
}

View File

@ -0,0 +1,85 @@
package app.closer.ui.home.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import app.closer.R
import app.closer.ui.components.CelebrationOverlay
import app.closer.ui.components.CloserActionButton
import app.closer.ui.settings.SettingsInk
import app.closer.ui.settings.SettingsMuted
import app.closer.ui.settings.SettingsSoft
/** One-time streak-milestone celebration dialog. Extracted from HomeScreen.kt; kept `internal`
* because ui/debug/ArtPreviewScreen also renders it. */
@Composable
internal fun StreakMilestoneDialog(
milestone: Int,
partnerName: String?,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Box(contentAlignment = Alignment.Center) {
Surface(
shape = RoundedCornerShape(28.dp),
color = SettingsSoft,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 28.dp, vertical = 30.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(R.drawable.illustration_streak_milestone),
contentDescription = null,
modifier = Modifier.size(156.dp)
)
Spacer(Modifier.height(18.dp))
Text(
text = if (milestone == 1) "Your first night together" else "$milestone nights together",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
color = SettingsInk,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
val nights = if (milestone == 1) "your first night" else "$milestone nights running"
Text(
text = partnerName?.takeIf { it.isNotBlank() }
?.let { "You and $it keep showing up for each other — $nights. The small moments are adding up to something." }
?: "You keep showing up — $nights. The small moments are adding up to something.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(24.dp))
CloserActionButton(
label = "Keep going together",
onClick = onDismiss,
modifier = Modifier.fillMaxWidth()
)
}
}
CelebrationOverlay(visible = true, intensity = 1.4f)
}
}
}

View File

@ -0,0 +1,154 @@
package app.closer.ui.home
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Characterization tests for the pure Home action mapper (HomeActionMapper.kt), locking in the
* behavior lifted out of HomeViewModel so the extraction is provably faithful and future edits are
* guarded. Only the `internal` entry points (refreshDailyQuestionState, withHomeActions) are called
* directly; they exercise every private helper (toHomeAction, buildDailyQuestionAction,
* buildPendingActions, has*, toHomeLabel) transitively.
*/
class HomeActionMapperTest {
private fun question(id: String = "q1", category: String = "") =
Question(id = id, text = "Test question?", category = category, type = "single_choice")
// ── refreshDailyQuestionState ──────────────────────────────────────────────
@Test
fun `refresh yields UNANSWERED when there is no daily question`() {
val out = HomeUiState().refreshDailyQuestionState()
assertEquals(DailyQuestionState.UNANSWERED, out.dailyQuestionState)
assertFalse(out.hasRevealedToday)
}
@Test
fun `refresh yields USER_ANSWERED_PARTNER_PENDING when only the user has answered`() {
val out = HomeUiState(
dailyQuestion = question("q1"),
answerStats = HomeAnswerStats(answeredQuestionIds = setOf("q1"))
).refreshDailyQuestionState()
assertEquals(DailyQuestionState.USER_ANSWERED_PARTNER_PENDING, out.dailyQuestionState)
assertFalse(out.hasRevealedToday)
}
@Test
fun `refresh yields BOTH_ANSWERED when both answered this question`() {
val out = HomeUiState(
dailyQuestion = question("q1"),
answerStats = HomeAnswerStats(answeredQuestionIds = setOf("q1")),
hasPartnerAnsweredToday = true,
partnerAnsweredQuestionId = "q1"
).refreshDailyQuestionState()
assertEquals(DailyQuestionState.BOTH_ANSWERED, out.dailyQuestionState)
}
@Test
fun `refresh marks REVEALED and hasRevealedToday when the user's latest answer is revealed`() {
val out = HomeUiState(
dailyQuestion = question("q1"),
answerStats = HomeAnswerStats(
answeredQuestionIds = setOf("q1"),
latest = LocalAnswer(
questionId = "q1", questionText = "Test question?",
category = "", answerType = "single_choice", isRevealed = true
)
)
).refreshDailyQuestionState()
assertEquals(DailyQuestionState.REVEALED, out.dailyQuestionState)
assertTrue(out.hasRevealedToday)
}
// ── withHomeActions ────────────────────────────────────────────────────────
@Test
fun `withHomeActions clears all actions while loading`() {
val out = HomeUiState(isLoading = true).withHomeActions()
assertNull(out.primaryAction)
assertTrue(out.secondaryActions.isEmpty())
assertTrue(out.pendingActions.isEmpty())
}
@Test
fun `withHomeActions clears the primary action on error`() {
val out = HomeUiState(isLoading = false, error = "boom").withHomeActions()
assertNull(out.primaryAction)
}
@Test
fun `withHomeActions surfaces pairing as the primary when unpaired`() {
val out = HomeUiState(isLoading = false, isPaired = false).withHomeActions()
assertEquals(HomeActionTarget.InvitePartner, out.primaryAction?.target)
}
@Test
fun `withHomeActions surfaces the daily question when paired and unanswered`() {
val out = HomeUiState(
isLoading = false,
isPaired = true,
dailyQuestion = question("q1"),
dailyQuestionState = DailyQuestionState.UNANSWERED
).withHomeActions()
assertEquals(HomeActionTarget.DailyQuestion, out.primaryAction?.target)
assertEquals("Your daily question", out.primaryAction?.eyebrow)
}
@Test
fun `withHomeActions humanizes the daily category metric and drops the mc token (toHomeLabel)`() {
val out = HomeUiState(
isLoading = false,
isPaired = true,
dailyQuestion = question("q1", category = "daily_fun_mc"),
dailyQuestionState = DailyQuestionState.UNANSWERED
).withHomeActions()
assertEquals("Daily Fun", out.primaryAction?.metric)
}
@Test
fun `withHomeActions caps secondary actions at three`() {
val out = HomeUiState(
isLoading = false,
isPaired = true,
dailyQuestion = question("q1"),
dailyQuestionState = DailyQuestionState.UNANSWERED,
hasWaitingGame = true,
hasActiveChallenge = true,
hasUpcomingDatePlan = true,
hasPendingDateReflection = true,
hasUnlockedCapsule = true,
weeklyRecapReady = true,
categories = listOf(HomeCategorySummary(app.closer.domain.model.QuestionCategory(id = "c1"), 5))
).withHomeActions()
assertTrue("secondary capped at 3", out.secondaryActions.size <= 3)
}
@Test
fun `withHomeActions never duplicates the primary target in the pending list (C-HOME-001)`() {
val out = HomeUiState(
isLoading = false,
isPaired = true,
hasWaitingGame = true,
hasActiveChallenge = true,
hasUnlockedCapsule = true
).withHomeActions()
val primaryTarget = out.primaryAction?.target
assertTrue(
"primary target must not also appear in pending",
out.pendingActions.none { it.target == primaryTarget }
)
assertTrue("pending capped at 3", out.pendingActions.size <= 3)
}
@Test
fun `withHomeActions produces no pending actions when unpaired`() {
val out = HomeUiState(isLoading = false, isPaired = false).withHomeActions()
assertTrue(out.pendingActions.isEmpty())
}
}

View File

@ -1,9 +1,11 @@
# Daily Patch Review Loop Policy v1
# Daily Patch Review Loop Policy v2
This policy exists because a pack can pass schema checks and still be boring, weird, or too generated.
The daily pack must be fixed in patch mode unless a mass rewrite is explicitly justified.
This policy applies to the documented 511-question daily special pack. Normal category packs remain capped at 150 questions under `QUESTION_CONTENT_GUIDE.md` and `QUESTION_SCHEMA.md`. The special daily count does not create a general exception for other packs.
## Core Rule
Review the full pack. Mark only questions that fail. Fix only the marked question IDs.

View File

@ -1,4 +1,4 @@
# Daily Single Choice Weekday System v6
# Daily Single Choice Weekday System v9 — Importer-Aligned, Human-Voice
This document defines the Closer daily weekday question pack.
@ -6,26 +6,67 @@ This is a special pack. It is not a normal mixed category pack.
## Pack Identity
Recommended pack id:
Logical pack id:
```text
daily_single_choice_weekly_v1
```
Recommended future file name:
Production category id:
```text
daily_single_choice_weekly_v1.json
daily_fun_mc
```
Current compatibility file name:
Current compatibility filename:
```text
daily_fun_multiple_choice_v3.json
```
Use the old file name only while the app code still expects it. The content inside the file must still be single choice.
Possible future filename:
```text
daily_single_choice_weekly_v1.json
```
The logical pack id and the production category id are not interchangeable.
- `category.id` must remain `daily_fun_mc` while the current app/database contract uses that category.
- every Daily question must use `"category_id": "daily_fun_mc"`
- store `"pack_id": "daily_single_choice_weekly_v1"` inside category metadata
- the compatibility filename does not supply category identity
- the production file must include the full `category` object and full `questions` array
When editing the existing production pack, preserve its current display name, description, access, and icon unless the product owner explicitly changes them.
## Required production wrapper
```json
{
"category": {
"id": "daily_fun_mc",
"display_name": "Daily Fun",
"description": "One quick couples-game question for each day, including wildcard surprise days.",
"access": "mixed",
"icon_name": "calendar_today",
"metadata": {
"pack_id": "daily_single_choice_weekly_v1",
"question_type_policy": "single_choice_only",
"total_questions": 511,
"free_questions": 86,
"premium_questions": 425
}
},
"questions": []
}
```
The example documents the required shape. In the live file, preserve the existing category display fields and icon unless they are intentionally changed.
A flat document with `id`, `title`, `count`, and `questions` but no `category` object is not importable as the Daily category.
## Required Counts
## Required Counts
This special daily pack uses:
@ -47,7 +88,7 @@ This special daily pack uses:
* 511 total · 86 free · 425 premium · 511 single_choice
Do not apply the standard 250 question category mix to this pack.
Normal category packs are capped at 150 questions. This 511-question daily system is a documented special-pack exception, so the normal maximum and mixed-type planning target do not apply here.
## Required Type
@ -263,6 +304,29 @@ Avoid these in daily questions:
These words push the pack toward therapy voice.
## Daily Voice: Human and Funny
The daily pack is the funniest surface in the app, so the "Write Like a Human" standard in
`QUESTION_CONTENT_GUIDE.md` (section 8A) binds hardest here. Read it before writing any daily batch.
Daily-specific applications:
* The joke must live in the detail, never in a label. If "silly" or "ridiculous" is doing the humor
work, the line has no humor. Cut the adjective and make the option specific enough to be funny alone.
* The house register is adult deadpan: mock-formality and fake stakes for tiny real things. Not
chaotic-quirky, not children's-entertainer energy.
* The weekday theme is the *angle*, not a template stem. Every Thursday prompt opening
"What's the funniest..." is a machine fingerprint. Approach each weekday's mood from a different
direction every time.
* Wildcards get the single most absurd detail in the pack — still adult deadpan, still one absurd
detail maximum.
* The guides' example nouns ("two-song kitchen dance", "dessert walk", "snack board") are categories,
not vocabulary. Shipping them verbatim is a reject.
* No exclamation marks in prompts. No emoji in prompts or options. Options in sentence case.
* Aim for roughly one question in four to pass the snort test (partner would snort and show the
other the phone); the rest land warm and concrete. A pack that strains for a laugh on every line
is as exhausting as one with none.
## Good Daily Examples
Good:
@ -700,6 +764,29 @@ If the app code still uses older mode tags, include the compatibility tag too, b
## Patch Discipline: Fix Only What Fails
### Production-file protection
Patch discipline does not change the production file shape.
A document containing only:
```text
id
expected_text
reasons
replacement text
replacement options
```
is a patch manifest, not the Daily pack.
Never overwrite `daily_fun_multiple_choice_v3.json` with that manifest.
Apply the patch to the complete 511-question source, retain the full category object, validate the complete result, and ship the complete production JSON.
Daily pack updates must use patch discipline.
The writer must review the full pack, mark the failing question IDs, and then fix only those marked IDs.
@ -770,7 +857,7 @@ If one option fails the answer test, fix that option. If two or more fail, rewri
## Production Review Loop
Do not write or rewrite all 500 questions in one blind pass.
Do not write or rewrite all 511 questions in one blind pass.
For each weekday:
@ -824,7 +911,11 @@ These sources informed the daily fun rules. Do not copy their question lists. Us
Before shipping:
1. Run schema and count validation against the totals in Required Counts (511 · 86 free · 425 premium once the wildcard add-on is in; 511 single_choice).
1. Confirm the file contains the full category object and all 511 questions.
2. Confirm `category.id` and every `category_id` are `daily_fun_mc`.
3. Confirm every depth is integer `1`, `2`, or `3`.
4. Confirm every option set is mirrored into `answer_config.options`.
5. Run schema and count validation against the totals in Required Counts (511 · 86 free · 425 premium once the wildcard add-on is in; 511 single_choice).
2. Run duplicate question checks.
3. Run duplicate option-list checks.
4. Check repeated openers and repeated option text.

View File

@ -1,4 +1,4 @@
# Fun Relationship Questions Research Notes v2
# Fun Relationship Questions Research Notes v3
This research note updates the Closer daily question guides after reviewing modern couple apps, conversation-card games, date-night question lists, and relationship research summaries.
@ -8,6 +8,14 @@ Fun relationship questions work when they feel like a small playable moment.
They are not just "warm prompts". They use choice, humor, tiny missions, preferences, memories, flirtation, and low-pressure honesty.
## Pack Size Decision
Normal Closer category packs are capped at **150 questions**. The cap is a maximum, not a quota; smaller packs are preferred when they avoid filler or repeated mechanics.
A full 150-question mixed pack targets 45 free and 105 premium questions, with no more than 5 written questions by default. Written prompts remain optional and should be used only when an open response adds real value.
The 511-question daily single-choice weekday and wildcard system is a documented product-specific exception. Its size must not be copied into normal category packs.
## What Good Examples Have in Common
* They are quick to answer.
@ -82,3 +90,11 @@ Second, it did not clearly separate fun from random. A pack can avoid therapy vo
* Fun but grounded is now a gate.
* The option answer test is now required.
* Remaining hard flags must be 0 before production-ready.
## Added In v8
* Normal category packs are capped at 150 questions.
* The 150-question limit is a ceiling, not a quota.
* A full mixed pack targets 45 free and 105 premium questions.
* Written questions default to 0 to 5 per normal pack.
* The 511-question daily system remains a documented special exception.

View File

@ -0,0 +1,44 @@
# Importer and Question-Guide Reconciliation
## Final authority
Production content must match the importer and Room model that ship today.
The guides now separate:
1. **Current production schema** — required for content JSON now.
2. **Future schema direction** — an engineering migration, not an authoring choice.
## Correct current production rules
| Aspect | Production rule |
|---|---|
| depth | integer `1`, `2`, or `3` |
| pack wrapper | `{ "category": {...}, "questions": [...] }` |
| scale settings | `answer_config` |
| written settings | `answer_config` |
| this-or-that | exactly two explicit options, mirrored into `answer_config.options` |
| Daily category id | `daily_fun_mc` |
| Daily logical pack id | `daily_single_choice_weekly_v1` in metadata |
| Daily production file | complete 511-question pack, never a patch manifest |
## Future string-depth migration
String depth is still documented as a possible future direction, but it is clearly marked **not production-ready**.
It becomes valid only after coordinated updates to the importer, Room schema/entity, database migration path, readers, routing, validation, tests, and existing content.
## Catalog-wide build gate
A pack can be perfect by itself and still fail the catalog.
Before rebuilding the database, validate all production files together for:
- duplicate IDs
- duplicate exact or normalized question text
- blocked near-duplicates
- unknown categories
- partial packs or patch manifests under production filenames
- values that will be coerced or silently ignored
The known `Comfort first or solutions first?` collision must be resolved in one category before the catalog can pass.

View File

@ -8,6 +8,35 @@ Closer is a private couples app. The questions should help two people feel close
Use this guide before writing or rewriting any question JSON file.
## Pack Size Authority
Normal category packs may contain **up to 150 questions**. The 150-question limit is a ceiling, not a quota. Use fewer questions when the topic cannot support 150 distinct, strong prompts without filler or repetition.
Only a documented product-specific special pack may exceed 150 questions. The current daily single-choice weekday system is one such exception and keeps its separately documented 511-question total.
Written questions must remain rare. A normal pack may use **0 to 5 written questions by default**. More than 5 requires a documented pack-specific reason and explicit product-owner approval. Never use written questions to fill a count.
## Importer and Production Contract Authority
The active app importer and Room database contract are authoritative for production JSON.
Human-friendly authoring labels, batch notes, patch manifests, and planning files may help writers work, but they must never replace or override the production schema.
Current production rules:
- a production pack must use the top-level shape `{ "category": {...}, "questions": [...] }`
- the `category` object is required and must include `id`, `display_name`, `description`, `access`, and `icon_name`
- every question must include `id`, `category_id`, `type`, `text`, integer `depth`, `access`, and `tags`
- `category_id` must exactly match `category.id`
- `depth` must be an integer: `1` = light, `2` = medium, `3` = deep
- `tags` must be present as an array; use at least one meaningful tag unless a documented exception exists
- `sex` is optional and nullable for ordinary packs; require it only when a product feature actually filters or routes by sex
- question-type-specific fields must match `QUESTION_SCHEMA.md`
- never rely on the filename to create or repair a missing category
- a batch file, continuation note, validation report, or patch manifest is not a production question pack
When a writing document conflicts with the importer, stop and correct the writing document. Do not ship compatibility-breaking JSON and do not defer the mismatch to a future importer change.
---
# 1. The Closer Voice
@ -137,6 +166,38 @@ SET PREMIUM_VALUE=Deeper romance, planning preferences, rut-breaking, and memory
Use depth intentionally.
The writing concepts are Light, Medium, and Deep, but the production JSON field is numeric:
| Writing concept | Production `depth` |
|---|---:|
| Light | `1` |
| Medium | `2` |
| Deep | `3` |
Never write `"light"`, `"medium"`, or `"deep"` into a production question object. Those labels may appear only in planning notes, coverage maps, or human-readable review documents.
## Future depth migration — not production-ready
String depth values such as `"light"`, `"medium"`, and `"deep"` are a possible future schema direction.
They are **not valid production values today**.
Do not use string depth until one coordinated engineering change updates and verifies all of the following:
```text
build_db.py
Room entity and column type
database migration or asset rebuild path
all getInt()/integer readers
depth routing and help text
seed validation
automated tests
existing production JSON
```
Until that migration is merged and deployed, production JSON must use integers `1`, `2`, or `3`.
## Light
Light questions should be easy, fun, and low-pressure.
@ -186,21 +247,42 @@ ECHO Sensitive questions should never pressure a partner to disclose, forgive, e
# 6. Question Types
Each question type should have a purpose.
Every production example in this section matches the importer contract used today.
`sex` is omitted because it is optional for ordinary packs. Add it only when a documented feature needs it.
## written
Use for stories, memories, reflection, emotional honesty, and open-ended answers.
Use written questions only when a choice-based format would lose the story, nuance, or personal explanation.
Good written examples:
A normal pack should contain 0 to 5 written questions unless a documented exception is approved.
```text
What is one small thing I do that makes you feel cared for?
What is a memory of us that still makes you smile?
What is something you wish we made more time for?
```json
{
"id": "emotional_intimacy_001",
"category_id": "emotional_intimacy",
"type": "written",
"text": "What is one recent moment when you felt especially close to me?",
"depth": 2,
"access": "free",
"tags": ["recent_memory", "closeness", "emotional_intimacy"],
"answer_config": {
"max_length": 500
}
}
```
Avoid written questions that are too vague:
Rules:
```bat
ECHO Use answer_config for written settings.
ECHO max_length is required when a custom limit matters.
ECHO Do not use a top-level answer object.
ECHO Do not use written questions for basic preferences.
ECHO Do not use written questions to fill the pack count.
```
Avoid:
```text
How can we deepen our connection?
@ -216,31 +298,42 @@ What is one thing we could do this week that would make us feel more like a team
## single_choice
Use for preferences and simple decisions.
Good single-choice example:
Use when the player should choose one best answer.
```json
{
"id": "date_night_001",
"category_id": "date_night",
"type": "single_choice",
"text": "Which kind of date sounds best this week?",
"depth": 1,
"access": "free",
"tags": ["date_preference", "planning", "date_night"],
"options": [
{ "id": "cozy_at_home", "text": "Cozy at home" },
{ "id": "dinner_out", "text": "Dinner out" },
{ "id": "something_playful", "text": "Something playful" },
{ "id": "something_outside", "text": "Something outside" },
{ "id": "surprise_me", "text": "Surprise me" }
]
{ "id": "something_outside", "text": "Something outside" }
],
"answer_config": {
"options": [
{ "id": "cozy_at_home", "text": "Cozy at home" },
{ "id": "dinner_out", "text": "Dinner out" },
{ "id": "something_playful", "text": "Something playful" },
{ "id": "something_outside", "text": "Something outside" }
]
}
}
```
Rules:
```bat
ECHO Options should be short.
ECHO Options should not overlap too much.
ECHO Options should sound like real choices.
ECHO Avoid more than 6 options unless truly needed.
ECHO Use 4 to 6 options by default.
ECHO Options should be short and natural.
ECHO Every option must directly answer the prompt.
ECHO Options should be similar in effort and emotional weight.
ECHO Mirror top-level options into answer_config.options exactly.
```
---
@ -249,48 +342,32 @@ ECHO Avoid more than 6 options unless truly needed.
Use when more than one answer can be true.
Good multi-choice example:
```json
{
"id": "date_night_002",
"category_id": "date_night",
"type": "multi_choice",
"text": "What helps you feel relaxed on a date with me?",
"depth": 1,
"access": "free",
"tags": ["comfort", "date_preferences", "date_night"],
"options": [
{ "id": "no_rushing", "text": "Not feeling rushed" },
{ "id": "phones_away", "text": "Putting phones away" },
{ "id": "good_food", "text": "Good food" },
{ "id": "easy_conversation", "text": "Easy conversation" },
{ "id": "physical_affection", "text": "Physical affection" },
{ "id": "clear_plan", "text": "Having a plan" }
]
}
```
Rules:
```bat
ECHO Multi-choice prompts should say or imply that multiple answers are okay.
ECHO Options should not shame either partner.
ECHO Include practical and emotional options when possible.
```
---
## scale
Use for comfort level, closeness, energy, readiness, satisfaction, or frequency.
Good scale example:
```json
{
"type": "scale",
"text": "How much do you feel like we need a real date soon?",
"scale": {
"min": 1,
"max": 5,
"min_label": "Not much",
"max_label": "Very much"
{ "id": "physical_affection", "text": "Physical affection" }
],
"answer_config": {
"options": [
{ "id": "no_rushing", "text": "Not feeling rushed" },
{ "id": "phones_away", "text": "Putting phones away" },
{ "id": "good_food", "text": "Good food" },
{ "id": "easy_conversation", "text": "Easy conversation" },
{ "id": "physical_affection", "text": "Physical affection" }
],
"min_selections": 1,
"max_selections": 3
}
}
```
@ -298,8 +375,43 @@ Good scale example:
Rules:
```bat
ECHO The prompt should clearly allow multiple answers.
ECHO Options should not shame either partner.
ECHO Options should not overlap excessively.
ECHO Mirror top-level options into answer_config.options exactly.
```
---
## scale
Use for one measurable dimension such as comfort, closeness, energy, readiness, satisfaction, confidence, importance, or frequency.
```json
{
"id": "date_night_003",
"category_id": "date_night",
"type": "scale",
"text": "How much would a real date help us feel connected right now?",
"depth": 2,
"access": "free",
"tags": ["connection", "readiness", "date_night"],
"answer_config": {
"min": 1,
"max": 5,
"min_label": "Not much",
"max_label": "A lot"
}
}
```
Rules:
```bat
ECHO Put scale settings inside answer_config.
ECHO Do not use a top-level scale object.
ECHO min, max, min_label, and max_label are required for custom labels.
ECHO Scale labels should be gentle and neutral.
ECHO Do not make the low end sound bad or shameful.
ECHO Scale questions should measure one thing only.
```
@ -319,27 +431,42 @@ How much would some intentional time together help us right now?
## this_or_that
Use for fast, playful, low-pressure choices.
Use for a fast two-option choice.
Good this-or-that examples:
```text
Planned date or spontaneous date?
Dress up or stay cozy?
Cook together or order in?
Sunset walk or late-night drive?
```json
{
"id": "date_night_004",
"category_id": "date_night",
"type": "this_or_that",
"text": "Planned date or spontaneous date?",
"depth": 1,
"access": "free",
"tags": ["quick_choice", "date_style", "date_night"],
"options": [
{ "id": "planned_date", "text": "Planned date" },
{ "id": "spontaneous_date", "text": "Spontaneous date" }
],
"answer_config": {
"options": [
{ "id": "planned_date", "text": "Planned date" },
{ "id": "spontaneous_date", "text": "Spontaneous date" }
]
}
}
```
Rules:
```bat
ECHO This-or-that prompts should be quick.
ECHO Avoid choices that imply one partner is wrong.
ECHO Best used in fun, date, intimacy, home, and lifestyle packs.
ECHO Supply exactly two options.
ECHO Do not ship a bare this_or_that prompt with no options.
ECHO Mirror top-level options into answer_config.options exactly.
ECHO Keep both choices balanced and easy to understand.
```
---
# 7. Free vs Premium Strategy
Free questions should prove the app is worth using.
@ -437,6 +564,131 @@ What is one small thing that makes you want to be closer to me?
---
# 8A. Write Like a Human
Section 8 bans therapy voice. This section bans the other failure mode: content that is technically
warm and playful but reads machine-written. These are the patterns that make a pack feel like an app
talking instead of a person.
## AI-tell phrases are banned
Never use these in prompts or options:
```text
spice things up
take it to the next level
level up
unleash
embark
elevate
epic
ultimate
game-changer
adventure awaits
cozy up (as a command)
delight / delightful
whimsical
a dash of
sprinkle
```
These are marketing-bot words. A real person deciding what to do tonight does not say "let's elevate
our evening."
## Punctuation and case rules
- No exclamation marks in prompts.
- No emoji in prompts or options.
- Options use sentence case, never Title Case.
Enthusiasm comes from the idea, not the punctuation.
## The joke is the detail, not the adjective
Never label a joke. "Silly", "hilarious", "ridiculous", and "funny" doing the humor work means there
is no humor in the line. Make the option itself funny through unexpected specificity.
Dead:
```text
A silly dance together
```
Alive:
```text
A two-song kitchen dance
```
The second one is funny because "two-song" is a weirdly specific commitment. Nobody labeled it.
If removing the word "silly" or "ridiculous" from a line kills it, the line was already dead.
## One absurd detail, maximum
One unexpected detail makes a line funny. Two makes it a kids' menu.
Good: a formal award ceremony for whoever picked the better snack.
Too much: a formal award ceremony with a trophy made of cheese judged by the cat in a tiny hat.
## The house humor register
Closer's humor is adult deadpan: mock-formality and fake stakes applied to tiny real things.
```text
official pick
formal ruling
lifetime achievement award for couch positioning
a binding decision
this week's championship
```
Not random wackiness. Not chaotic-quirky. Not children's-entertainer energy. Two adults keeping a
straight face about something small — that is the register.
## Punch at the situation, never at the partner
If the laugh needs a target, the target is the couple's shared circumstance — the fridge, the
algorithm, the weather, the errand that ate the afternoon. Never either person's body, habits,
competence, or effort.
## The snort test
The best daily lines make one partner snort and show the other the phone. Aim for roughly one in four
questions to genuinely hit that bar. The rest should land warm and concrete. A pack where every line
strains to be funny is as exhausting as a pack with no jokes at all.
## Example lists are categories, not vocabulary
The example lists in these guides (snacks, tiny dates, silly bets...) name *kinds* of content. They
are not words to reuse. Do not put the guides' own example nouns — "two-song kitchen dance",
"dessert walk", "snack board", "fake award" as literal text — into shipped questions. Invent your
own specifics of the same kind.
If a guide example appears verbatim in a pack, that question is a reject.
## No template blocks
Within any 10 consecutive questions:
- no repeated game mechanic
- no two prompts opening with the same first three words
- no option set where all options share one grammatical template
(four options all shaped "A + adjective + noun" is a machine fingerprint)
## Distribution should look human
A human writer favors some mechanics and neglects others. Exactly N of each mechanic, evenly spaced,
is a machine fingerprint and a reject signal at review. Uneven is correct.
## The texting test
Read the question as a 6pm text to your partner. If it reads like a brand's Instagram caption or an
app talking, rewrite it. A person wrote this for one specific other person — it should sound that way.
---
# 9. Tone Rules by Pack Type
## Fun packs
@ -688,39 +940,180 @@ What tends to make date night harder for you to enjoy?
---
# 12. JSON Quality Rules
# 12. Production JSON Contract and Quality Rules
Every JSON file should pass these checks.
A JSON file is production-ready only when it is both valid content and a complete importable pack.
## 12.1 Required top-level shape
Every production pack must use:
```json
{
"category": {
"id": "example_category",
"display_name": "Example Category",
"description": "User-facing description.",
"access": "mixed",
"icon_name": "favorite"
},
"questions": []
}
```
Required category fields:
```text
id
display_name
description
access
icon_name
```
Optional category fields such as `schema_version` and `metadata` may be included, but they do not replace the required fields.
Do not use the old simple-pack shape:
```json
{
"id": "example",
"title": "Example",
"count": 25,
"questions": []
}
```
Do not rely on filename parsing to create a category. A missing `category` object is a hard production failure.
## 12.2 Required question fields
Every production question must include:
```text
id
category_id
type
text
depth
access
tags
```
Rules:
- `category_id` must exactly equal the enclosing `category.id`
- `depth` must be the integer `1`, `2`, or `3`
- `tags` must be an array and should contain at least one meaningful tag
- `access` must be `free` or `premium`
- `sex` is optional and nullable for ordinary category packs
- `sex` becomes required only for a documented feature that routes or filters by it
- choice options and `answer_config` must follow `QUESTION_SCHEMA.md`
- top-level `options` and `answer_config.options` must match when both are required by the active schema
## 12.2A Type-specific importer contract
The active importer reads:
| Type | Production source |
|---|---|
| `single_choice` | top-level `options`; mirror into `answer_config.options` |
| `multi_choice` | top-level `options`; mirror into `answer_config.options` |
| `this_or_that` | exactly two top-level `options`; mirror into `answer_config.options` |
| `scale` | `answer_config.min`, `max`, `min_label`, `max_label` |
| `written` | `answer_config`, including `max_length` when customized |
Do not place scale settings in a top-level `scale` object.
Do not place written settings in a top-level `answer` object.
Do not ship `this_or_that` without two explicit choices.
## 12.3 Production pack versus work artifact
These are not production packs:
```text
25-question batch files
continuation notes
coverage maps
validation reports
marked-fix lists
patch manifests
apply scripts
review summaries
```
A work artifact must never overwrite a production filename.
A partial batch may be saved only outside importer-scanned production directories and must be clearly labeled as work in progress. The last complete validated production pack stays in place until the full replacement is ready.
A patch manifest must:
- use a clearly non-production name such as `*.patch.json`
- live outside any importer-scanned production directory
- identify the complete source pack it applies to
- be applied to the full source pack
- produce a complete validated production JSON before shipping
A patch manifest is never itself renamed to the production filename.
## 12.4 Daily pack protection
`daily_fun_multiple_choice_v3.json` is the compatibility filename for the Daily Single-Choice Weekday System.
That production file must always contain the complete documented pack:
```text
500 weekday questions
11 wildcard questions
511 total questions
86 free
425 premium
511 single_choice
```
A daily patch document containing only changed IDs is not the daily pack. Restore or retain the complete 511-question source, apply the patch to that source, then validate and ship the complete result.
## 12.5 Per-file hard checks
```bat
ECHO [ ] Valid JSON.
ECHO [ ] Category id matches file purpose.
ECHO [ ] Category title sounds user-facing.
ECHO [ ] Category access matches actual free/premium strategy.
ECHO [ ] Top-level category object exists.
ECHO [ ] Top-level questions array exists.
ECHO [ ] Category contains id, display_name, description, access, and icon_name.
ECHO [ ] Category id matches the file purpose.
ECHO [ ] Category title and description are user-facing.
ECHO [ ] Metadata counts match actual question counts.
ECHO [ ] All question IDs are unique.
ECHO [ ] All question texts are unique.
ECHO [ ] No near-duplicate blocks.
ECHO [ ] Every question has category_id matching category.id.
ECHO [ ] Every question has integer depth 1, 2, or 3.
ECHO [ ] Every question has an access value of free or premium.
ECHO [ ] Every question has a tags array.
ECHO [ ] All question IDs are unique inside the file.
ECHO [ ] All question texts are unique inside the file.
ECHO [ ] No near-duplicate blocks remain.
ECHO [ ] Type counts match metadata.
ECHO [ ] Free/premium counts match metadata.
ECHO [ ] Depth values are valid.
ECHO [ ] Access values are valid.
ECHO [ ] Options have unique IDs inside each question.
ECHO [ ] Option IDs are neutral and clean.
ECHO [ ] Choice options mirror answer_config where required.
ECHO [ ] Scale labels are present when needed.
ECHO [ ] No malformed keys like m a x _ l e n g t h.
ECHO [ ] Written answer limits are present when needed.
ECHO [ ] No malformed keys.
ECHO [ ] No placeholder text.
ECHO [ ] No accidental old gendered IDs unless intentionally used.
ECHO [ ] The file is a complete pack, not a batch or patch artifact.
```
Recommended access values:
Valid access values:
```text
free
premium
```
Recommended category access values:
Valid category access values:
```text
free
@ -728,15 +1121,15 @@ premium
mixed
```
Recommended depth values:
Valid production depth values:
```text
light
medium
deep
1
2
3
```
Recommended question types:
Valid question types:
```text
written
@ -746,26 +1139,67 @@ scale
this_or_that
```
---
## 12.6 Catalog-wide hard gate
Per-file validation is not enough.
Before rebuilding `app.db` or shipping question content, validate every production pack together.
The full catalog must have:
- globally unique question IDs
- globally unique exact question text
- no normalized duplicate question text after case and whitespace normalization
- no blocked near-duplicates across categories
- no production filename containing a patch or partial batch
- no category resolving to `unknown`
- no schema mismatch that would coerce or zero a field
A duplicate in two different category files is still a hard failure. The importer may abort the entire build, not merely skip the duplicate question.
When the same idea belongs in two packs, rewrite each prompt around its category-specific purpose rather than copying the same text.
## 12.7 Catalog impact reporting
When replacing or trimming an existing pack, report:
```text
old question count
new question count
net catalog change
old free/premium counts
new free/premium counts
whether the change is intentional and approved
```
A planned reduction from a legacy 250-question pack to a stronger 150-question pack is not a schema defect, but the live catalog shrink must be visible in the completion report.
# 13. Pack Structure Recommendation
For a 250-question pack:
Normal category packs may contain **no more than 150 questions**. A complete 150-question mixed pack should normally target:
```bat
SET TOTAL_QUESTIONS=250
SET FREE_QUESTIONS=75
SET PREMIUM_QUESTIONS=175
SET WRITTEN=150
SET SINGLE_CHOICE=40
SET MULTI_CHOICE=20
SET SCALE=25
SET THIS_OR_THAT=15
SET TOTAL_QUESTIONS=150
SET FREE_QUESTIONS=45
SET PREMIUM_QUESTIONS=105
SET MULTI_CHOICE=90
SET SINGLE_CHOICE=30
SET SCALE=15
SET THIS_OR_THAT=10
SET WRITTEN=5
```
This structure is acceptable if the current app expects those counts.
This mix is a planning target, not permission to add filler. A smaller pack is valid when the topic cannot support 150 distinct, strong questions.
But quality matters more than exact counts. If a pack cannot support 250 good questions without filler, reduce the pack or split it.
Rules:
- 150 is the maximum for a normal category pack.
- written questions should normally total 0 to 5
- do not force every pack to use every question type
- preserve the intended free/premium value split when using a smaller pack
- only a documented special product pack may exceed 150
- the 511-question daily single-choice system is a documented special exception
---
@ -773,7 +1207,24 @@ But quality matters more than exact counts. If a pack cannot support 250 good qu
Question packs must not be written in one uninterrupted bulk-generation pass.
The full pack may contain 250 or more questions when the product requires it, but question creation must be divided into controlled batches so quality, continuity, counts, and repetition can be reviewed throughout the work.
A normal category pack may contain up to 150 questions. Only a documented special product pack may exceed that limit. Regardless of final size, question creation must be divided into controlled batches so quality, continuity, counts, and repetition can be reviewed throughout the work.
## 14.0 Production Replacement Rule
Batching controls the writing process; it does not redefine the production file.
During a multi-batch rewrite:
1. Keep the last complete production pack intact.
2. Draft and review each batch in a clearly labeled work artifact or controlled workspace.
3. Never overwrite the production filename with only the completed batches so far.
4. Never change the production file to a simple batch schema.
5. Merge reviewed batches into the complete `{ "category": ..., "questions": [...] }` pack.
6. Run per-file validation.
7. Run the catalog-wide duplicate and import validation.
8. Replace the production file only with the complete validated result.
A continuation note may say `Next ID: ...`; that does not make the partial JSON shippable.
## 14.1 Batch Size
@ -1087,47 +1538,58 @@ Even an approved mass rewrite must still use 25-question batches.
After all planned questions have been written or corrected:
1. Validate the complete JSON file.
2. Confirm category metadata.
3. Confirm all IDs.
4. Confirm all IDs are unique.
5. Confirm all question texts are unique.
6. Confirm total question count.
7. Confirm question-type distribution.
8. Confirm free and premium distribution.
9. Confirm depth distribution.
10. Confirm targeting fields and tags.
11. Run exact duplicate checks.
12. Run near-duplicate checks.
13. Run repeated-stem and repeated-option checks.
14. Review subtopic coverage.
15. Review emotional-depth spacing.
16. Review mechanic distribution.
17. Review free-to-premium progression.
18. Read a representative sample from every section aloud.
19. Mark every failure by ID and reason.
20. Patch only failed IDs.
21. Re-run every affected validation and review check.
2. Confirm the top-level shape is `{ "category": ..., "questions": [...] }`.
3. Confirm the category object contains every required field.
4. Confirm every question has `category_id` matching `category.id`.
5. Confirm every question uses integer depth `1`, `2`, or `3`.
6. Confirm every question has a tags array.
7. Confirm all IDs are unique inside the file.
8. Confirm all question texts are unique inside the file.
9. Confirm total question count.
10. Confirm question-type distribution.
11. Confirm free and premium distribution.
12. Confirm targeting fields.
13. Run exact duplicate checks inside the file.
14. Run near-duplicate checks inside the file.
15. Run repeated-stem and repeated-option checks.
16. Review subtopic coverage.
17. Review emotional-depth spacing.
18. Review mechanic distribution.
19. Review free-to-premium progression.
20. Read a representative sample from every section aloud.
21. Mark every failure by ID and reason.
22. Patch only failed IDs.
23. Re-run every affected validation and review check.
24. Run the full-catalog global ID and question-text duplicate gate.
25. Confirm no production filename contains a patch manifest or partial batch.
26. Report the old count, new count, and net catalog change.
The pack may ship only when:
```bat
ECHO The JSON parses.
ECHO The production top-level shape is category plus questions.
ECHO Required category fields exist.
ECHO Every category_id matches category.id.
ECHO Metadata counts match actual counts.
ECHO IDs are unique.
ECHO Question texts are unique.
ECHO IDs are unique inside the pack and across the catalog.
ECHO Question texts are unique inside the pack and across the catalog.
ECHO Free/premium counts match.
ECHO Type counts match.
ECHO Depth values are valid.
ECHO Depth values are integers 1, 2, or 3.
ECHO Tags exist on every question.
ECHO Required targeting fields are valid.
ECHO The file is a complete pack, not a patch or partial batch.
ECHO No unresolved hard failures remain.
ECHO No unacceptable repetition remains.
ECHO The full catalog import gate passes.
ECHO The final sample sounds natural and enjoyable aloud.
ECHO The tone guide was followed.
```
## 14.15 Quality Takes Priority Over Pack Size
A product target of 250 questions does not justify filler.
A product target of 150 questions does not justify filler. The maximum is not a quota.
If the topic cannot support the planned count without repetition:
@ -1245,33 +1707,47 @@ ECHO Would make a normal couple roll their eyes.
---
# 18. Claude Instructions for Rewriting JSON Packs
# 18. Agent Instructions for Rewriting JSON Packs
When Claude rewrites a pack:
These rules bind every agent that rewrites a pack (ChatGPT, Claude, or any other writer).
When an agent rewrites a pack:
```bat
ECHO Read the existing JSON first.
ECHO Preserve required schema unless told otherwise.
ECHO Preserve required counts unless told otherwise.
ECHO Treat the active importer and Room contract as schema authority.
ECHO Preserve the production shape: category object plus questions array.
ECHO Use integer depth 1, 2, or 3.
ECHO Add category_id and tags to every production question.
ECHO Keep sex optional unless the pack-specific feature requires it.
ECHO Preserve required counts unless an approved rewrite changes them.
ECHO Never overwrite a production filename with a partial batch.
ECHO Never overwrite a production filename with a patch manifest.
ECHO Apply patches to the complete source pack and return the complete JSON.
ECHO Improve wording, variety, and product feel.
ECHO Do not create repetitive template blocks.
ECHO Do not use weird phrasing.
ECHO Do not overuse therapy language.
ECHO Do not make sensitive prompts coercive.
ECHO Validate the final JSON.
ECHO Report count totals.
ECHO Validate the final JSON per file and across the full catalog.
ECHO Report old count, new count, and net catalog change.
ECHO Report any schema changes.
```
Claude should not say a file is done unless:
The agent should not say a file is done unless:
```bat
ECHO The JSON parses.
ECHO The file is a complete production pack.
ECHO The category object and questions array exist.
ECHO Required category and question fields exist.
ECHO The counts match.
ECHO IDs are unique.
ECHO Question texts are unique.
ECHO Depth values are integers 1, 2, or 3.
ECHO IDs are unique inside the pack and across the catalog.
ECHO Question texts are unique inside the pack and across the catalog.
ECHO Free/premium counts match.
ECHO Type counts match.
ECHO The catalog import gate passes.
ECHO The tone guide was followed.
```

View File

@ -0,0 +1,116 @@
# Closer Question Guide Correction Summary
## Why this update was required
The writing documents and the active importer disagreed about production depth and file shape.
The old guide allowed human-readable depth strings such as `light`, `medium`, and `deep`. The active Room-backed importer expects an integer field, so string depth can be read as `0`.
The old workflow also did not clearly forbid a partial batch or patch manifest from replacing a production JSON filename.
## Correct production authority
Production packs now require:
```text
top-level category object
top-level questions array
category.id
category.display_name
category.description
category.access
category.icon_name
question.category_id
question.type
question.text
question.depth as integer 1/2/3
question.access
question.tags array
```
`sex` remains optional and nullable unless a documented feature specifically reads it.
## Work artifacts
The corrected guides explicitly classify these as non-production:
```text
partial batches
simple-pack batch JSON
patch manifests
validation reports
coverage maps
continuation notes
apply scripts
review summaries
```
They must not overwrite production filenames or live in importer-scanned production locations.
## Daily pack rule
`daily_fun_multiple_choice_v3.json` must always remain the complete 511-question Daily Single-Choice Weekday System.
A JSON document containing only changed IDs is a patch manifest. It must be applied to the complete 511-question source; the resulting complete pack is the file that ships.
## Catalog-wide rule
Every production file must pass individually, then the complete catalog must pass together.
The catalog gate rejects:
- duplicate IDs across files
- duplicate exact or normalized question text across files
- blocked cross-category near-duplicates
- unknown categories
- partial or patch artifacts under production filenames
- depth or other field coercion caused by schema mismatches
## Immediate file remediation implied by the corrected guide
### `rebuilding_trust.json`
Do not ship the 25-question Batch 1 artifact.
Rebuild or complete the production pack using:
- full `{ "category": ..., "questions": [...] }` shape
- required category object
- `category_id` on every question
- integer depth `1`, `2`, or `3`
- tags arrays
- the approved final count and access/type distribution
Keep the last complete production version in place until the full replacement passes validation.
### `daily_fun_multiple_choice_v3.json`
Restore the complete 511-question source.
Apply the 17-ID patch to that complete source.
Ship the complete validated 511-question result, not the patch manifest.
### Cross-file duplicate
Reword one instance of:
```text
Comfort first or solutions first?
```
Do not allow identical question text in both Emotional Intimacy and Stress. Give each version a category-specific angle, then rerun the complete catalog gate.
## Catalog count changes
Intentional 250-to-150 pack reductions are allowed under the 150-question cap.
Every completion report must still show:
```text
old pack count
new pack count
net catalog change
free/premium change
approval or product rationale
```

View File

@ -1,4 +1,4 @@
# Closer Question Quality Checklist v7
# Closer Question Quality Checklist v11 — Importer-Aligned, Human-Voice
**See also:** [QUESTION_CONTENT_GUIDE.md](QUESTION_CONTENT_GUIDE.md) | [QUESTION_SCHEMA.md](QUESTION_SCHEMA.md) | [QUESTION_REWRITE_PLAN.md](QUESTION_REWRITE_PLAN.md)
@ -31,20 +31,92 @@ Reject any question that contains or strongly resembles:
These are therapy worksheet patterns.
Also reject any question where (see `QUESTION_CONTENT_GUIDE.md` section 8A):
* an AI-tell phrase appears (`spice things up`, `level up`, `unleash`, `elevate`, `epic`, `ultimate`,
`game-changer`, `adventure awaits`, `whimsical`, `a dash of`, `sprinkle`, and the rest of the 8A list)
* "silly", "hilarious", "ridiculous", or "funny" is doing the humor work instead of a specific detail
* a prompt contains an exclamation mark, or a prompt/option contains emoji
* options are in Title Case
* all options in one set share a single grammatical template
* a guide example noun ("two-song kitchen dance", "dessert walk", "snack board") ships verbatim
These are machine-voice patterns.
## Production File Hard Checks
Run these before tone or content review.
Reject the file immediately if any item fails:
- top-level `category` object is missing
- top-level `questions` array is missing
- category is missing `id`, `display_name`, `description`, `access`, or `icon_name`
- any question is missing `id`, `category_id`, `type`, `text`, `depth`, `access`, or `tags`
- any `category_id` does not exactly match `category.id`
- any production `depth` is not the integer `1`, `2`, or `3`
- any `tags` value is missing or is not an array
- the file uses the old simple-pack shape
- the file is a partial batch
- the file is a patch manifest
- the production filename was overwritten by a work artifact
- the filename is being used as a substitute for a missing category
`sex` is optional and nullable for ordinary packs. Do not reject a normal pack merely because `sex` is absent. Require it only when a documented feature actually uses it for targeting.
## Type-specific importer checks
Reject the question if:
- a `scale` stores its settings outside `answer_config`
- a `written` question stores its settings outside `answer_config`
- a `this_or_that` question has no options
- a `this_or_that` question has anything other than two options
- a choice questions top-level options do not match `answer_config.options`
- a production question uses string depth
- a content note says “fix the importer later” while the production JSON remains incompatible
String depth is a future migration only. Until the code migration lands, valid production depth is integer `1`, `2`, or `3`.
## Normal Pack Hard Checks
For every normal category pack, confirm before content review:
* total question count is 150 or fewer
* the planned count is treated as a ceiling, not a quota
* metadata counts match the actual questions
* written questions total 0 to 5 unless a documented exception exists
* no questions were added only to reach 150
* free and premium counts match the documented pack plan
* all IDs and question texts are unique
* no exact or near-duplicate blocks remain
* old count, new count, and net catalog change are reported when replacing a legacy pack
## Daily Pack Hard Checks
For the daily single choice weekday pack, confirm before content review:
* 500 total questions
* 75 free questions
* 500 frozen weekday questions
* 11 free wildcard questions
* 511 total questions
* 86 free questions
* 425 premium questions
* every question is single_choice
* every question has a weekday tag
* every weekday question has exactly one weekday tag
* every wildcard question has `mode_wildcard` and `daily_wildcard` and no weekday tag
* every question has 4 to 6 options
* 4 options preferred
* no duplicate IDs
* no duplicate question text
* no duplicate exact option lists
* the production file contains the full `category` object and all 511 questions
* the production file is not a patch manifest containing only changed IDs
* the category object exists and its id matches every `category_id`
* the production category id remains `daily_fun_mc` unless app code and data are migrated
* logical pack id `daily_single_choice_weekly_v1` belongs in metadata, not in place of the category object
* every daily question uses integer depth and includes category_id, access, and tags
## Daily Fun Gate
@ -225,6 +297,28 @@ Better:
Save me the best couch spot
```
## Catalog-Wide Hard Gate
Per-file checks are not enough.
Before rebuilding `app.db` or shipping any question change, scan every production pack together.
Reject the entire content build if any of these remain:
- duplicate question ID across files
- duplicate exact question text across files
- duplicate question text after case and whitespace normalization
- blocked near-duplicate prompts across related categories
- a category resolving to `unknown`
- a production filename containing a patch, partial batch, validation report, or other work artifact
- a depth value that would be coerced or read as `0`
- a question missing `category_id`
- a question missing its tags array
A duplicate across two otherwise valid packs still fails the whole catalog.
When two categories need similar ideas, rewrite each prompt around its own category purpose instead of copying identical text.
## Patch Discipline Checks
Before updating a daily pack, confirm the workflow is patch mode.
@ -237,10 +331,16 @@ Required:
* only marked IDs are edited
* passing IDs are left unchanged
* metadata is preserved unless metadata failed
* the patch manifest is stored outside importer-scanned production files
* the complete source pack remains available
* the patch is applied to the complete source pack before shipping
* the result is a complete production pack, not a patch-only deliverable
* the report lists marked count, patched count, and remaining flag count
Reject the update if it rewrites passing questions without a mass rewrite exception.
Also reject the update if a patch manifest or partial batch replaces the production JSON, even when the patch itself is valid JSON.
Mass rewrite exception requires:
* more than 60 percent of the weekday or pack fails
@ -294,6 +394,7 @@ Reject or rewrite if:
* the same situation repeats with different nouns
* the same answer pattern repeats
* the weekday starts to feel like wallpaper
* mechanic counts are implausibly even (exactly N of each mechanic is a machine fingerprint, not balance)
The pack can pass duplicate checks and still fail repetition review.
@ -301,7 +402,7 @@ The pack can pass duplicate checks and still fail repetition review.
Before approving the full daily pack:
1. Read 10 random questions from each weekday.
1. Read 10 random questions from each weekday and all 11 wildcard questions.
2. Mark anything therapy-coded, boring, weird, logistical, or not fun.
3. Fix the marked items.
4. Run a second random sample from each weekday.
@ -345,6 +446,11 @@ Use these reasons when marking weak questions:
* duplicate_text
* duplicate_options
* schema_issue
* ai_voice
* labeled_joke
* uniform_option_grammar
* guide_example_parrot
* brand_caption_voice
## Final Verdict Labels

View File

@ -1,4 +1,4 @@
# QUESTION_REWRITE_PLAN.md
# Closer Question Rewrite Plan v10 — Importer-Aligned
## Purpose
@ -8,13 +8,55 @@ The goal is not just clean JSON.
The goal is questions real couples want to answer.
## Importer-aligned schema snapshot
Every production replacement must preserve:
```text
top-level category object
top-level questions array
category_id on every question
integer depth 1/2/3
tags array on every question
answer_config for scale settings
answer_config for written settings
two explicit options for this_or_that
```
The future string-depth plan is not part of current production work.
If the intended future schema differs from the importer, create a separate engineering migration task. Do not make content agents bridge the mismatch by shipping incompatible JSON.
## Production Replacement Rule
Writing in batches does not permit shipping a partial pack.
During a rewrite:
1. Keep the last complete production JSON in place.
2. Draft each batch in a controlled workspace or clearly labeled WIP artifact.
3. Do not save a 25-question batch under the production filename.
4. Do not switch the production file to the old simple-pack shape.
5. Merge reviewed batches into the full `{ "category": ..., "questions": [...] }` structure.
6. Use integer depth `1`, `2`, or `3`.
7. Include `category_id` and `tags` on every production question.
8. Validate the complete pack.
9. Validate the full catalog across every production file.
10. Replace the production file only after both gates pass.
A continuation note such as `Next ID: rt_026` proves that work is incomplete. It is not permission to replace the live pack with the first 25 questions.
A patch manifest is also a work artifact. It must never replace the source pack.
## Standard Pack Workflow
For normal 250 question category packs:
For normal category packs of up to 150 questions:
1. Define the category purpose.
2. Define the main subtopics.
3. Write in batches of 40 to 50.
3. Write in batches of 25, or 15 to 20 for sensitive packs.
4. Review tone and repetition after each batch.
5. Assign types, access, and depth.
6. Validate schema.
@ -23,24 +65,30 @@ For normal 250 question category packs:
9. Read random questions out loud.
10. Fix weak items before shipping.
## Standard 250 Question Mix
## Standard Pack Size and Full 150-Question Mix
Normal category packs may contain **up to 150 questions**. The limit is a ceiling, not a quota. Use fewer when the topic cannot support 150 strong, distinct prompts.
For a full 150-question mixed pack, use this planning target:
| Type | Count |
|---|---:|
| multi_choice | 140 |
| single_choice | 50 |
| scale | 35 |
| this_or_that | 15 |
| written | 10 |
| multi_choice | 90 |
| single_choice | 30 |
| scale | 15 |
| this_or_that | 10 |
| written | 5 |
Free and premium split:
* 75 free
* 175 premium
* 45 free
* 105 premium
Written questions should normally total 0 to 5. They are not required when another question type works better. More than 5 requires a documented pack-specific reason and explicit approval.
## Special Pack Exception
Some packs are product-specific and may override the standard 250 question mix.
Some product-specific packs may exceed the normal 150-question maximum or use a different type mix, but only when the exception is documented.
A special pack must document:
@ -106,17 +154,22 @@ daily_single_choice_weekly_v1.json
Expected counts:
* 500 total questions
* 75 free questions
* 500 frozen weekday questions
* 11 free wildcard questions
* 511 total questions
* 86 free questions
* 425 premium questions
* 500 single_choice questions
* 511 single_choice questions
This is a documented special-pack exception to the normal 150-question maximum.
Rules:
* every question must be single_choice
* every question must have 4 to 6 options
* 4 options preferred
* every question must have a weekday tag
* every weekday question must have exactly one weekday tag
* every wildcard question must use `mode_wildcard` and `daily_wildcard` and must not use a weekday tag
* every option must answer the prompt
* no therapy worksheet tone
* no wellness survey tone
@ -158,7 +211,7 @@ A daily batch should feel like something users would want to tap tonight.
## Daily Production Loop
Do not write or rewrite 500 daily questions in one pass.
Do not write or rewrite all 511 daily questions in one pass.
Use a bounded loop. The goal is quality control, not infinite polishing until everyone forgets why the app exists.
@ -176,7 +229,7 @@ Patch mode means:
6. Review the patched questions again.
7. Repeat only for IDs that still fail.
Do not rewrite all 500 because some questions failed.
Do not rewrite all 511 because some questions failed.
Do not rewrite a whole weekday unless the mass rewrite exception applies.
@ -261,13 +314,13 @@ When marking a daily question, use one or more of these reasons:
### Final Pack Gate
After all weekdays are drafted:
After all weekdays and wildcard questions are drafted:
1. Run schema and count validation.
2. Run duplicate question and duplicate option-list checks.
3. Check repeated openers.
4. Check repeated option text.
5. Read 10 random questions from each weekday.
5. Read 10 random questions from each weekday and all 11 wildcard questions.
6. Mark anything weak.
7. Fix marked questions.
8. Run a second random sample from each weekday.

View File

@ -1,338 +1,464 @@
# Closer Question Schema v6
# Closer Question Schema v8 — Importer-Aligned
**See also:** [QUESTION_CONTENT_GUIDE.md](QUESTION_CONTENT_GUIDE.md) | [QUESTION_REWRITE_PLAN.md](QUESTION_REWRITE_PLAN.md) | [QUESTION_QUALITY_CHECKLIST.md](QUESTION_QUALITY_CHECKLIST.md)
**See also:** [QUESTION_CONTENT_GUIDE.md](QUESTION_CONTENT_GUIDE.md) | [DAILY_SINGLE_CHOICE_WEEKDAY_SYSTEM.md](DAILY_SINGLE_CHOICE_WEEKDAY_SYSTEM.md) | [QUESTION_REWRITE_PLAN.md](QUESTION_REWRITE_PLAN.md) | [QUESTION_QUALITY_CHECKLIST.md](QUESTION_QUALITY_CHECKLIST.md)
## Purpose
## Authority
This document defines the JSON schema, question types, validation rules, and required counts for Closer question packs.
This document defines the JSON that imports correctly **today**.
For writing philosophy and tone, see `QUESTION_CONTENT_GUIDE.md`.
The active `build_db.py` behavior and the Room storage contract are authoritative for production files.
For rewrite workflow and special pack exceptions, see `QUESTION_REWRITE_PLAN.md`.
A future schema direction may be documented separately, but it must not appear in production examples until the importer, Room model, migrations, readers, tests, and existing content are updated together.
## Question Types
## Production pack shape
Use these type names exactly:
* written
* single_choice
* multi_choice
* scale
* this_or_that
Do not rename them unless the app code is updated first.
## multi_choice
Select every option that applies.
Use 4 to 6 options.
Example:
Every production pack must use:
```json
{
"type": "multi_choice",
"text": "What helps you feel relaxed on a date with me?",
"options": [
{ "id": "no_rushing", "text": "Not feeling rushed" },
{ "id": "phones_away", "text": "Putting phones away" },
{ "id": "good_food", "text": "Good food" },
{ "id": "easy_conversation", "text": "Easy conversation" }
]
"category": {
"id": "quality_time",
"display_name": "Quality Time",
"description": "Questions about being present and enjoying meaningful time together.",
"access": "mixed",
"icon_name": "schedule",
"metadata": {}
},
"questions": []
}
```
Required top-level fields:
```text
category
questions
```
Required category fields:
```text
id
display_name
description
access
icon_name
```
`metadata` is optional but recommended.
Do not use a flat or simple-pack wrapper such as:
```json
{
"id": "quality_time",
"title": "Quality Time",
"count": 25,
"questions": []
}
```
The importer reads `data["category"]`. A missing category object can cause the file to resolve to `unknown`.
Do not rely on the filename to recover category identity.
## Required question fields
Every production question requires:
```text
id
category_id
type
text
depth
access
tags
```
Rules:
* Prompt should clearly allow multiple answers.
* Options should not shame either partner.
* Options should be practical, emotional, or playful when possible.
* Options must not overlap too much.
- `category_id` must exactly match `category.id`
- `type` must be a supported type
- `text` must be non-empty and catalog-unique
- `depth` must be integer `1`, `2`, or `3`
- `access` must be `free` or `premium`
- `tags` must be an array
- use at least one meaningful tag unless a documented product exception exists
- `sex` is optional and nullable for ordinary packs
- require `sex` only when a documented feature actually reads it
## single_choice
## Depth: production values
Select one best answer.
| Meaning | Production value |
|---|---:|
| Light | `1` |
| Medium | `2` |
| Deep | `3` |
Use 4 to 6 options.
Production example:
Example:
```json
"depth": 2
```
### Future depth migration — not production-ready
String values are a planned possibility:
```json
"depth": "medium"
```
Do **not** ship them today.
The current importer writes depth into an integer column, and Room reads the field as an integer. A string can be stored or coerced incorrectly and then read as `0`, breaking depth routing and help text.
String depth becomes valid only after a coordinated code and data migration updates:
```text
build_db.py
Room entity and schema
database migration or asset rebuild path
all integer depth readers
depth routing and help text
validation
tests
all production content
```
Until that migration lands, integers are mandatory.
## Supported question types
Use these names exactly:
```text
written
single_choice
multi_choice
scale
this_or_that
```
## `single_choice`
Use when the player chooses one best answer.
```json
{
"id": "quality_time_001",
"category_id": "quality_time",
"type": "single_choice",
"text": "Which kind of date sounds best this week?",
"text": "Which low-key plan sounds best tonight?",
"depth": 1,
"access": "free",
"tags": ["low_energy", "easy_plan", "quality_time"],
"options": [
{ "id": "cozy_at_home", "text": "Cozy at home" },
{ "id": "dinner_out", "text": "Dinner out" },
{ "id": "something_playful", "text": "Something playful" },
{ "id": "something_outside", "text": "Something outside" }
]
}
```
Rules:
* Options should be short.
* Options should not overlap too much.
* Options should sound like real choices.
* Every option must directly answer the prompt.
* Every option should be similar in weight.
## scale
Rate agreement, comfort, importance, confidence, or frequency.
Example:
```json
{
"type": "scale",
"text": "How much do you feel like we need a real date soon?",
"scale": {
"min": 1,
"max": 5,
"min_label": "Not much",
"max_label": "Very much"
{ "id": "short_walk", "text": "A short walk" },
{ "id": "one_episode", "text": "One episode together" },
{ "id": "snack_and_talk", "text": "A snack and a talk" },
{ "id": "music_on_the_couch", "text": "Music on the couch" }
],
"answer_config": {
"options": [
{ "id": "short_walk", "text": "A short walk" },
{ "id": "one_episode", "text": "One episode together" },
{ "id": "snack_and_talk", "text": "A snack and a talk" },
{ "id": "music_on_the_couch", "text": "Music on the couch" }
]
}
}
```
Rules:
Requirements:
* Scale labels should be gentle and neutral.
* Do not make the low end sound bad or shameful.
* Scale questions should measure one thing only.
- use 4 to 6 options by default
- top-level `options` are required
- `answer_config.options` must mirror top-level options exactly
- every option must directly answer the prompt
- options should be similar in effort, emotional weight, and intimacy
## this_or_that
## `multi_choice`
Very fast playful questions.
Example:
Use when more than one answer can be true.
```json
{
"type": "this_or_that",
"text": "Planned date or spontaneous date?"
"id": "quality_time_002",
"category_id": "quality_time",
"type": "multi_choice",
"text": "What helps time together feel easy to enjoy?",
"depth": 1,
"access": "free",
"tags": ["presence", "comfort", "quality_time"],
"options": [
{ "id": "no_rushing", "text": "Not feeling rushed" },
{ "id": "phones_away", "text": "Putting phones away" },
{ "id": "easy_conversation", "text": "Easy conversation" },
{ "id": "clear_plan", "text": "Knowing the general plan" }
],
"answer_config": {
"options": [
{ "id": "no_rushing", "text": "Not feeling rushed" },
{ "id": "phones_away", "text": "Putting phones away" },
{ "id": "easy_conversation", "text": "Easy conversation" },
{ "id": "clear_plan", "text": "Knowing the general plan" }
],
"min_selections": 1,
"max_selections": 3
}
}
```
Rules:
Requirements:
* Prompts should be quick.
* Avoid choices that imply one partner is wrong.
* Best used in fun, date, intimacy, home, and lifestyle packs.
- use 4 to 6 options by default
- top-level `options` are required
- `answer_config.options` must mirror them exactly
- selection bounds must be valid when included
- options must not overlap excessively
## written
## `scale`
Reserved for questions where a short written response adds meaningful value.
Example:
The importer reads scale settings from `answer_config`.
```json
{
"id": "quality_time_003",
"category_id": "quality_time",
"type": "scale",
"text": "How easy is it to be present when we finally get time together?",
"depth": 2,
"access": "free",
"tags": ["presence", "attention", "quality_time"],
"answer_config": {
"min": 1,
"max": 5,
"min_label": "Not easy yet",
"max_label": "Very easy"
}
}
```
Requirements:
- use `answer_config`
- include `min`, `max`, `min_label`, and `max_label`
- measure one thing only
- keep both labels neutral and non-shaming
Do not put scale settings in a separate top-level object. The importer does not read custom scale labels from there.
## `written`
The importer reads written settings from `answer_config`.
```json
{
"id": "quality_time_004",
"category_id": "quality_time",
"type": "written",
"text": "What is one small thing I do that makes you feel cared for?",
"answer": {
"text": "What is one recent moment together that felt worth slowing down for?",
"depth": 2,
"access": "free",
"tags": ["recent_memory", "presence", "quality_time"],
"answer_config": {
"max_length": 500
}
}
```
Written questions require an `answer` configuration with `max_length`.
Requirements:
Do not use written questions for basic preferences.
- use `answer_config`
- include `max_length` when a custom limit matters
- do not use a separate top-level `answer` object
- use written questions only when typing adds real value
- normal packs should contain 0 to 5 written questions
## Standard 250 Question Packs
## `this_or_that`
Every standard 250 question pack must contain:
A `this_or_that` question must contain its two choices.
```json
{
"id": "quality_time_005",
"category_id": "quality_time",
"type": "this_or_that",
"text": "Quiet time or playful time?",
"depth": 1,
"access": "free",
"tags": ["quick_choice", "mood", "quality_time"],
"options": [
{ "id": "quiet_time", "text": "Quiet time" },
{ "id": "playful_time", "text": "Playful time" }
],
"answer_config": {
"options": [
{ "id": "quiet_time", "text": "Quiet time" },
{ "id": "playful_time", "text": "Playful time" }
]
}
}
```
Requirements:
- exactly two top-level options
- `answer_config.options` must mirror them exactly
- never ship a bare prompt with no A/B choices
- both choices should be balanced
## Standard category packs
A normal pack may contain up to 150 questions.
A full 150-question target is:
| Type | Count |
|---|---:|
| multi_choice | 140 |
| single_choice | 50 |
| scale | 35 |
| this_or_that | 15 |
| written | 10 |
| `multi_choice` | 90 |
| `single_choice` | 30 |
| `scale` | 15 |
| `this_or_that` | 10 |
| `written` | 5 |
Free and premium split:
* 75 free
* 175 premium
## Special Packs
Special product packs may override standard counts only when documented.
The daily single choice weekday pack is a special pack.
## Daily Single Choice Weekday Pack
Pack id:
Normal full-pack access target:
```text
daily_single_choice_weekly_v1
45 free
105 premium
```
Compatibility file name:
The count is a ceiling, not a quota.
## Daily Single-Choice Weekday System
Compatibility filename:
```text
daily_fun_multiple_choice_v3.json
```
Target counts — weekday pack (frozen; do not change these 500):
Production category id:
| Field | Count |
|---|---:|
| weekday total | 500 |
| weekday free | 75 |
| weekday premium | 425 |
| single_choice | 500 |
```text
daily_fun_mc
```
Plus the wildcard add-on (surprise days; see `DAILY_SINGLE_CHOICE_WEEKDAY_SYSTEM.md` → Wildcard Mode).
The wildcard pool size must stay **coprime with 10** (the wildcard-day spacing), so 11, not 10/12:
Logical pack id stored in metadata:
| Field | Count |
|---|---:|
| wildcard free | 11 |
```text
daily_single_choice_weekly_v1
```
**New pack totals (weekday + wildcard):** 511 total · 86 free · 425 premium · 511 single_choice.
These counts do not apply to standard category packs.
## Daily Pack Metadata Example
The production file must use the normal top-level wrapper:
```json
{
"id": "daily_single_choice_weekly_v1",
"title": "Daily Single Choice",
"access": "mixed",
"description": "One weekday themed single choice question per day, plus wildcard surprise days.",
"count": 511,
"free_count": 86,
"premium_count": 425,
"question_type_policy": "single_choice_only",
"review_policy": "weekday_batch_loop_required",
"patch_policy": "fix_marked_ids_only",
"mass_rewrite_policy": "requires_over_60_percent_shared_failure",
"content_policy": "fun_first_daily_game_questions",
"research_note": "Use relationship question research patterns, not copied prompts.",
"category": {
"id": "daily_fun_mc",
"display_name": "Daily Fun",
"description": "One quick couples-game question for each day, including wildcard surprise days.",
"access": "mixed",
"icon_name": "calendar_today",
"metadata": {
"pack_id": "daily_single_choice_weekly_v1",
"question_type_policy": "single_choice_only",
"total_questions": 511,
"free_questions": 86,
"premium_questions": 425
}
},
"questions": []
}
```
If the compatibility filename `daily_fun_multiple_choice_v3.json` is used, the metadata should still make clear that the content is `single_choice` only.
When editing the existing production file, preserve its current category display fields and icon unless the product owner explicitly changes them. The important contract is that the category object exists and its `id` matches every questions `category_id`.
Required totals:
## Daily Content Metadata
Daily packs should include metadata that makes the content standard impossible to miss.
Recommended fields:
```json
{
"question_type_policy": "single_choice_only",
"review_policy": "weekday_batch_loop_required",
"patch_policy": "fix_marked_ids_only",
"mass_rewrite_policy": "requires_over_60_percent_shared_failure",
"content_policy": "fun_first_daily_game_questions",
"research_note": "Use relationship question research patterns, not copied prompts."
}
```text
500 weekday questions
11 wildcard questions
511 total questions
86 free
425 premium
511 single_choice
```
These fields are not a replacement for validation. They document the intended behavior so future rewrites do not quietly turn the pack into chores, therapy, or generic wellness sludge.
Every daily question requires:
## Daily Question Object Example
```json
{
"id": "daily_monday_001",
"type": "single_choice",
"text": "What tiny date move sounds best tonight?",
"depth": "light",
"access": "free",
"sex": "neutral",
"tags": ["daily_monday_mood_check"],
"options": [
{ "id": "dessert_couch", "text": "Dessert on the couch" },
{ "id": "kitchen_dance", "text": "A two-song kitchen dance" },
{ "id": "snack_walk", "text": "A short walk with snacks" },
{ "id": "movie_pick", "text": "A ridiculous movie pick" }
]
}
```text
category_id: daily_fun_mc
integer depth
access
tags
top-level options
mirrored answer_config.options
```
## Daily Single Choice Content Rules
Every weekday question must carry exactly one documented weekday tag.
Daily options must be:
Every wildcard must carry `mode_wildcard` and `daily_wildcard`, and must not carry a weekday tag.
* complete answers to the prompt
* short and natural
* similar in weight
* fun, sweet, flirty, silly, or date-like when possible
* easy to pick in under 10 seconds
A patch manifest containing only changed IDs is not the Daily production pack.
Daily options must not be mostly chores, household admin, or therapy phrasing.
## Work artifacts are not production packs
Reject options centered on clean counters, bills, laundry, dishes, appointment scheduling, bedtime planning, saved blankets, or oddly specific domestic logistics unless the question clearly frames the option as playful and worth choosing.
Do not place any of these under a production filename:
Schema validation only proves the file can be parsed. It does not prove the content is good.
```text
partial batch JSON
simple-pack batch JSON
patch manifest
marked-fix list
validation report
coverage map
continuation note
apply script
review summary
```
Daily packs must also pass the fun gate in `QUESTION_QUALITY_CHECKLIST.md` and the loop in `QUESTION_REWRITE_PLAN.md`.
A patch must be clearly named as a patch, applied to the complete source, and followed by validation of the complete resulting pack.
## Weekday Tags
## Per-file validation
Daily questions must include the correct weekday tag:
Before shipping:
* daily_monday_mood_check
* daily_tuesday_tiny_win
* daily_wednesday_real_one
* daily_thursday_laugh
* daily_friday_flirty
* daily_saturday_side_quest
* daily_sunday_slow_burn
- JSON parses
- top-level `category` exists
- top-level `questions` exists
- all required category fields exist
- every required question field exists
- every `category_id` matches `category.id`
- every `depth` is integer `1`, `2`, or `3`
- all IDs are unique inside the file
- all question text is unique inside the file
- all access values are valid
- every tags value is an array
- choice option IDs are unique per question
- mirrored option arrays match exactly
- scale and written settings are in `answer_config`
- every `this_or_that` has exactly two choices
- metadata counts match actual counts
- the file is complete, not a batch or patch
If app code still uses older mode tags, include compatibility tags as needed without removing the new weekday tag.
## Catalog-wide validation
## Depth
Before rebuilding `app.db` or shipping content, validate every production pack together.
Documented depth values:
Reject the build for:
* light
* medium
* deep
- duplicate question IDs across files
- duplicate exact question text across files
- duplicate normalized question text after case and whitespace normalization
- blocked near-duplicates across categories
- any category resolving to `unknown`
- any production filename containing a work artifact
- any schema mismatch that can coerce a value or silently discard configuration
Do not use numeric depth values unless the active app import code still requires numeric depth for compatibility.
A duplicate in two otherwise valid files can abort the entire import.
If compatibility requires numeric depth, document the exception in the pack README and fix the import code later.
## Access
Valid access values:
* free
* premium
## Validation Requirements
Before shipping any pack:
* JSON must parse
* all IDs must be unique
* all question text must be unique
* every type must be valid
* every required field must exist
* option IDs must be unique within a question
* options must directly answer the prompt
* access values must be valid
* depth values must match current app compatibility rules
* special packs must match their documented counts
## Content Validation Reminder
Do not confuse schema validity with quality.
A file can be valid JSON and still be bad content.
Daily packs require schema validation plus human content review.
Per-file success does not override catalog failure.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -185,7 +185,7 @@
"id": "daily_single_choice_weekly_v1_002",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "Our first ten minutes together tonight: choose our official pick.",
"text": "How should we spend our first ten minutes together tonight?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -409,7 +409,7 @@
"id": "daily_single_choice_weekly_v1_006",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "Which choice should lead a one-song moment?",
"text": "What should we do for one song?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -436,7 +436,7 @@
},
{
"id": "pick_a_song_for_future_us",
"text": "Pick a song for future-us"
"text": "Pick a song that feels like us"
}
],
"answer_config": {
@ -455,7 +455,7 @@
},
{
"id": "pick_a_song_for_future_us",
"text": "Pick a song for future-us"
"text": "Pick a song that feels like us"
}
],
"selection_style": "single"
@ -521,7 +521,7 @@
"id": "daily_single_choice_weekly_v1_008",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "For a phone-free ten minutes, what gets our official vote?",
"text": "What should we do for ten phone-free minutes?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -689,7 +689,7 @@
"id": "daily_single_choice_weekly_v1_011",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "A little future plan: choose our official pick.",
"text": "Which little plan should we make for later this week?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -745,7 +745,7 @@
"id": "daily_single_choice_weekly_v1_012",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "Which tiny gesture would make you feel specially chosen?",
"text": "Which small gesture makes you feel chosen?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -857,7 +857,7 @@
"id": "daily_single_choice_weekly_v1_014",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "The ride home: what gets top billing?",
"text": "What would make the ride home better?",
"depth": 1,
"access": "free",
"sex": "neutral",
@ -1193,7 +1193,7 @@
"id": "daily_single_choice_weekly_v1_020",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "For a no-scroll break, what deserves first place?",
"text": "What sounds best for a no-scroll break?",
"depth": 1,
"access": "free",
"sex": "neutral",
@ -1249,7 +1249,7 @@
"id": "daily_single_choice_weekly_v1_021",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "Which sweet little ambush would be your favorite?",
"text": "Which sweet little surprise would be your favorite?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -1361,7 +1361,7 @@
"id": "daily_single_choice_weekly_v1_023",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "One thing to anticipate: what gets top billing?",
"text": "Which little plan would you look forward to most?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -9873,7 +9873,7 @@
"id": "daily_single_choice_weekly_v1_175",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "For a tiny escape, which option gets the nod?",
"text": "Which tiny escape sounds best?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -13401,7 +13401,7 @@
"id": "daily_single_choice_weekly_v1_238",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "A sixty-second challenge—which option wins?",
"text": "Which one-minute challenge sounds worth trying?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -13415,39 +13415,39 @@
],
"options": [
{
"id": "build_a_snack_tower",
"text": "Build a snack tower"
"id": "draw_each_other_without_looking_down",
"text": "Draw each other without looking down"
},
{
"id": "draw_the_other_person",
"text": "Draw the other person"
"id": "find_the_oldest_photo_on_our_phones",
"text": "Find the oldest photo on our phones"
},
{
"id": "find_five_blue_things",
"text": "Find five blue things"
"id": "name_five_things_we_both_love",
"text": "Name five things we both love"
},
{
"id": "create_a_couple_slogan",
"text": "Create a couple slogan"
"id": "pitch_the_worst_possible_date_idea",
"text": "Pitch the worst possible date idea"
}
],
"answer_config": {
"options": [
{
"id": "build_a_snack_tower",
"text": "Build a snack tower"
"id": "draw_each_other_without_looking_down",
"text": "Draw each other without looking down"
},
{
"id": "draw_the_other_person",
"text": "Draw the other person"
"id": "find_the_oldest_photo_on_our_phones",
"text": "Find the oldest photo on our phones"
},
{
"id": "find_five_blue_things",
"text": "Find five blue things"
"id": "name_five_things_we_both_love",
"text": "Name five things we both love"
},
{
"id": "create_a_couple_slogan",
"text": "Create a couple slogan"
"id": "pitch_the_worst_possible_date_idea",
"text": "Pitch the worst possible date idea"
}
],
"selection_style": "single"
@ -13457,7 +13457,7 @@
"id": "daily_single_choice_weekly_v1_239",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "Which weird little rule should we use?",
"text": "Which rule would make tonight funnier?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -13471,39 +13471,39 @@
],
"options": [
{
"id": "talk_like_royalty_for_ten_minutes",
"text": "Talk like royalty for ten minutes"
"id": "every_complaint_needs_a_dramatic_title",
"text": "Every complaint needs a dramatic title"
},
{
"id": "every_compliment_must_rhyme",
"text": "Every compliment must rhyme"
"id": "whoever_checks_their_phone_first_picks_the_music",
"text": "Whoever checks their phone first picks the music"
},
{
"id": "narrate_every_snack_choice",
"text": "Narrate every snack choice"
"id": "the_first_person_to_say_we_should_go_to_bed_starts_a_timer",
"text": "The first person to say “we should go to bed” starts a timer"
},
{
"id": "call_dessert_the_grand_finale",
"text": "Call dessert “the grand finale”"
"id": "every_minor_inconvenience_gets_a_movie_trailer_voice",
"text": "Every minor inconvenience gets a movie-trailer voice"
}
],
"answer_config": {
"options": [
{
"id": "talk_like_royalty_for_ten_minutes",
"text": "Talk like royalty for ten minutes"
"id": "every_complaint_needs_a_dramatic_title",
"text": "Every complaint needs a dramatic title"
},
{
"id": "every_compliment_must_rhyme",
"text": "Every compliment must rhyme"
"id": "whoever_checks_their_phone_first_picks_the_music",
"text": "Whoever checks their phone first picks the music"
},
{
"id": "narrate_every_snack_choice",
"text": "Narrate every snack choice"
"id": "the_first_person_to_say_we_should_go_to_bed_starts_a_timer",
"text": "The first person to say “we should go to bed” starts a timer"
},
{
"id": "call_dessert_the_grand_finale",
"text": "Call dessert “the grand finale”"
"id": "every_minor_inconvenience_gets_a_movie_trailer_voice",
"text": "Every minor inconvenience gets a movie-trailer voice"
}
],
"selection_style": "single"
@ -14129,7 +14129,7 @@
"id": "daily_single_choice_weekly_v1_251",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "A spontaneous bit: which option would you choose?",
"text": "Which low-effort bit would make tonight funnier?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -14143,39 +14143,39 @@
],
"options": [
{
"id": "pretend_we_host_a_cooking_show",
"text": "Pretend we host a cooking show"
"id": "narrate_dinner_like_a_true_crime_show",
"text": "Narrate dinner like a true-crime show"
},
{
"id": "act_like_tourists_in_our_kitchen",
"text": "Act like tourists in our kitchen"
"id": "review_our_week_like_sports_commentators",
"text": "Review our week like sports commentators"
},
{
"id": "give_the_snacks_job_interviews",
"text": "Give the snacks job interviews"
"id": "recreate_our_worst_customer_service_voices",
"text": "Recreate our worst customer-service voices"
},
{
"id": "review_the_evening_like_critics",
"text": "Review the evening like critics"
"id": "pitch_our_home_as_a_luxury_resort",
"text": "Pitch our home as a luxury resort"
}
],
"answer_config": {
"options": [
{
"id": "pretend_we_host_a_cooking_show",
"text": "Pretend we host a cooking show"
"id": "narrate_dinner_like_a_true_crime_show",
"text": "Narrate dinner like a true-crime show"
},
{
"id": "act_like_tourists_in_our_kitchen",
"text": "Act like tourists in our kitchen"
"id": "review_our_week_like_sports_commentators",
"text": "Review our week like sports commentators"
},
{
"id": "give_the_snacks_job_interviews",
"text": "Give the snacks job interviews"
"id": "recreate_our_worst_customer_service_voices",
"text": "Recreate our worst customer-service voices"
},
{
"id": "review_the_evening_like_critics",
"text": "Review the evening like critics"
"id": "pitch_our_home_as_a_luxury_resort",
"text": "Pitch our home as a luxury resort"
}
],
"selection_style": "single"
@ -14185,7 +14185,7 @@
"id": "daily_single_choice_weekly_v1_252",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "Which terrible acting scene should we perform?",
"text": "Which scene from adult life should we overact?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -14199,39 +14199,39 @@
],
"options": [
{
"id": "we_just_won_a_very_strange_award",
"text": "We just won a very strange award"
"id": "forgetting_why_we_entered_a_room",
"text": "Forgetting why we entered a room"
},
{
"id": "we_are_spies_ordering_dessert",
"text": "We are spies ordering dessert"
"id": "choosing_dinner_when_neither_of_us_cares",
"text": "Choosing dinner when neither of us cares"
},
{
"id": "we_missed_an_imaginary_train",
"text": "We missed an imaginary train"
"id": "pretending_well_go_to_bed_early",
"text": "Pretending well go to bed early"
},
{
"id": "we_are_rivals_in_a_cooking_show",
"text": "We are rivals in a cooking show"
"id": "hearing_an_unexpected_knock_at_the_door",
"text": "Hearing an unexpected knock at the door"
}
],
"answer_config": {
"options": [
{
"id": "we_just_won_a_very_strange_award",
"text": "We just won a very strange award"
"id": "forgetting_why_we_entered_a_room",
"text": "Forgetting why we entered a room"
},
{
"id": "we_are_spies_ordering_dessert",
"text": "We are spies ordering dessert"
"id": "choosing_dinner_when_neither_of_us_cares",
"text": "Choosing dinner when neither of us cares"
},
{
"id": "we_missed_an_imaginary_train",
"text": "We missed an imaginary train"
"id": "pretending_well_go_to_bed_early",
"text": "Pretending well go to bed early"
},
{
"id": "we_are_rivals_in_a_cooking_show",
"text": "We are rivals in a cooking show"
"id": "hearing_an_unexpected_knock_at_the_door",
"text": "Hearing an unexpected knock at the door"
}
],
"selection_style": "single"
@ -17265,7 +17265,7 @@
"id": "daily_single_choice_weekly_v1_307",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "For a date-night detail, what gets our official vote?",
"text": "Which date-night detail gets your attention first?",
"depth": 1,
"access": "premium",
"sex": "neutral",
@ -25665,7 +25665,7 @@
"id": "daily_single_choice_weekly_v1_457",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "Which kind of recognition feels most energizing?",
"text": "What kind of compliment would mean the most today?",
"depth": 2,
"access": "premium",
"sex": "neutral",
@ -25679,39 +25679,39 @@
],
"options": [
{
"id": "notice_a_creative_choice_i_made",
"text": "Notice a creative choice I made"
"id": "you_handled_that_better_than_you_think",
"text": "You handled that better than you think"
},
{
"id": "point_out_my_calm_confidence",
"text": "Point out my calm confidence"
"id": "you_still_make_me_laugh",
"text": "You still make me laugh"
},
{
"id": "celebrate_a_risk_i_took",
"text": "Celebrate a risk I took"
"id": "i_love_the_way_your_mind_works",
"text": "I love the way your mind works"
},
{
"id": "name_the_fun_i_brought",
"text": "Name the fun I brought"
"id": "you_make_ordinary_days_better",
"text": "You make ordinary days better"
}
],
"answer_config": {
"options": [
{
"id": "notice_a_creative_choice_i_made",
"text": "Notice a creative choice I made"
"id": "you_handled_that_better_than_you_think",
"text": "You handled that better than you think"
},
{
"id": "point_out_my_calm_confidence",
"text": "Point out my calm confidence"
"id": "you_still_make_me_laugh",
"text": "You still make me laugh"
},
{
"id": "celebrate_a_risk_i_took",
"text": "Celebrate a risk I took"
"id": "i_love_the_way_your_mind_works",
"text": "I love the way your mind works"
},
{
"id": "name_the_fun_i_brought",
"text": "Name the fun I brought"
"id": "you_make_ordinary_days_better",
"text": "You make ordinary days better"
}
],
"selection_style": "single"
@ -26281,7 +26281,7 @@
"id": "daily_single_choice_weekly_v1_468",
"category_id": "daily_fun_mc",
"type": "single_choice",
"text": "Which movie-night role would you claim?",
"text": "Which movie-night kiss would you enjoy most?",
"depth": 2,
"access": "premium",
"sex": "neutral",
@ -26295,39 +26295,39 @@
],
"options": [
{
"id": "official_snack_chooser",
"text": "Official snack chooser"
"id": "a_hello_kiss_before_we_press_play",
"text": "A hello kiss before we press play"
},
{
"id": "trailer_rating_judge",
"text": "Trailer-rating judge"
"id": "a_quick_kiss_during_the_previews",
"text": "A quick kiss during the previews"
},
{
"id": "plot_twist_predictor",
"text": "Plot-twist predictor"
"id": "a_longer_kiss_when_the_movie_ends",
"text": "A longer kiss when the movie ends"
},
{
"id": "ending_debate_champion",
"text": "Ending-debate champion"
"id": "a_goodnight_kiss_after_the_credits",
"text": "A goodnight kiss after the credits"
}
],
"answer_config": {
"options": [
{
"id": "official_snack_chooser",
"text": "Official snack chooser"
"id": "a_hello_kiss_before_we_press_play",
"text": "A hello kiss before we press play"
},
{
"id": "trailer_rating_judge",
"text": "Trailer-rating judge"
"id": "a_quick_kiss_during_the_previews",
"text": "A quick kiss during the previews"
},
{
"id": "plot_twist_predictor",
"text": "Plot-twist predictor"
"id": "a_longer_kiss_when_the_movie_ends",
"text": "A longer kiss when the movie ends"
},
{
"id": "ending_debate_champion",
"text": "Ending-debate champion"
"id": "a_goodnight_kiss_after_the_credits",
"text": "A goodnight kiss after the credits"
}
],
"selection_style": "single"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff