Compare commits
No commits in common. "dev" and "refactor/home-decomposition" have entirely different histories.
dev
...
refactor/h
|
|
@ -101,5 +101,3 @@ 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
|
||||
|
|
|
|||
13
Future.md
13
Future.md
|
|
@ -111,19 +111,6 @@ 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).
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ fun ArtPreviewScreen(onNavigate: (String) -> Unit = {}) {
|
|||
var showStreak by remember { mutableStateOf(false) }
|
||||
|
||||
if (showStreak) {
|
||||
app.closer.ui.home.components.StreakMilestoneDialog(
|
||||
app.closer.ui.home.StreakMilestoneDialog(
|
||||
milestone = 7,
|
||||
partnerName = "Sofia",
|
||||
onDismiss = { showStreak = false }
|
||||
|
|
|
|||
|
|
@ -1,318 +0,0 @@
|
|||
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 = "Today’s 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 tonight’s 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 = "Today’s 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() } }
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
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
|
||||
)
|
||||
|
|
@ -5,11 +5,14 @@ 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
|
||||
|
|
@ -21,10 +24,14 @@ 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
|
||||
|
|
@ -68,6 +75,7 @@ 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
|
||||
|
|
@ -84,15 +92,10 @@ 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
|
||||
|
|
@ -421,8 +424,703 @@ 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
|
||||
|
|
@ -612,3 +1310,57 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ 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
|
||||
|
|
@ -47,6 +49,169 @@ 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(
|
||||
|
|
@ -548,6 +713,311 @@ 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 = "Today’s 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 tonight’s 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 = "Today’s 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"
|
||||
|
|
|
|||
|
|
@ -1,354 +0,0 @@
|
|||
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))
|
||||
|
|
@ -1,397 +0,0 @@
|
|||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
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())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,9 @@
|
|||
# Daily Patch Review Loop Policy v2
|
||||
# Daily Patch Review Loop Policy v1
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Daily Single Choice Weekday System v9 — Importer-Aligned, Human-Voice
|
||||
# Daily Single Choice Weekday System v6
|
||||
|
||||
This document defines the Closer daily weekday question pack.
|
||||
|
||||
|
|
@ -6,67 +6,26 @@ This is a special pack. It is not a normal mixed category pack.
|
|||
|
||||
## Pack Identity
|
||||
|
||||
Logical pack id:
|
||||
Recommended pack id:
|
||||
|
||||
```text
|
||||
daily_single_choice_weekly_v1
|
||||
```
|
||||
|
||||
Production category id:
|
||||
|
||||
```text
|
||||
daily_fun_mc
|
||||
```
|
||||
|
||||
Current compatibility filename:
|
||||
|
||||
```text
|
||||
daily_fun_multiple_choice_v3.json
|
||||
```
|
||||
|
||||
Possible future filename:
|
||||
Recommended future file name:
|
||||
|
||||
```text
|
||||
daily_single_choice_weekly_v1.json
|
||||
```
|
||||
|
||||
The logical pack id and the production category id are not interchangeable.
|
||||
Current compatibility file name:
|
||||
|
||||
- `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": []
|
||||
}
|
||||
```text
|
||||
daily_fun_multiple_choice_v3.json
|
||||
```
|
||||
|
||||
The example documents the required shape. In the live file, preserve the existing category display fields and icon unless they are intentionally changed.
|
||||
Use the old file name only while the app code still expects it. The content inside the file must still be single choice.
|
||||
|
||||
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:
|
||||
|
|
@ -88,7 +47,7 @@ This special daily pack uses:
|
|||
|
||||
* 511 total · 86 free · 425 premium · 511 single_choice
|
||||
|
||||
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.
|
||||
Do not apply the standard 250 question category mix to this pack.
|
||||
|
||||
## Required Type
|
||||
|
||||
|
|
@ -304,29 +263,6 @@ 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:
|
||||
|
|
@ -764,29 +700,6 @@ 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.
|
||||
|
|
@ -857,7 +770,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 511 questions in one blind pass.
|
||||
Do not write or rewrite all 500 questions in one blind pass.
|
||||
|
||||
For each weekday:
|
||||
|
||||
|
|
@ -911,11 +824,7 @@ These sources informed the daily fun rules. Do not copy their question lists. Us
|
|||
|
||||
Before shipping:
|
||||
|
||||
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).
|
||||
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).
|
||||
2. Run duplicate question checks.
|
||||
3. Run duplicate option-list checks.
|
||||
4. Check repeated openers and repeated option text.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Fun Relationship Questions Research Notes v3
|
||||
# Fun Relationship Questions Research Notes v2
|
||||
|
||||
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,14 +8,6 @@ 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.
|
||||
|
|
@ -90,11 +82,3 @@ 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.
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -8,35 +8,6 @@ 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
|
||||
|
|
@ -166,38 +137,6 @@ 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.
|
||||
|
|
@ -247,42 +186,21 @@ ECHO Sensitive questions should never pressure a partner to disclose, forgive, e
|
|||
|
||||
# 6. Question Types
|
||||
|
||||
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.
|
||||
Each question type should have a purpose.
|
||||
|
||||
## written
|
||||
|
||||
Use written questions only when a choice-based format would lose the story, nuance, or personal explanation.
|
||||
Use for stories, memories, reflection, emotional honesty, and open-ended answers.
|
||||
|
||||
A normal pack should contain 0 to 5 written questions unless a documented exception is approved.
|
||||
Good written examples:
|
||||
|
||||
```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
|
||||
}
|
||||
}
|
||||
```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?
|
||||
```
|
||||
|
||||
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:
|
||||
Avoid written questions that are too vague:
|
||||
|
||||
```text
|
||||
How can we deepen our connection?
|
||||
|
|
@ -298,42 +216,31 @@ What is one thing we could do this week that would make us feel more like a team
|
|||
|
||||
## single_choice
|
||||
|
||||
Use when the player should choose one best answer.
|
||||
Use for preferences and simple decisions.
|
||||
|
||||
Good single-choice example:
|
||||
|
||||
```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" }
|
||||
],
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
{ "id": "something_outside", "text": "Something outside" },
|
||||
{ "id": "surprise_me", "text": "Surprise me" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
```bat
|
||||
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.
|
||||
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.
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -342,65 +249,48 @@ ECHO Mirror top-level options into answer_config.options exactly.
|
|||
|
||||
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" }
|
||||
],
|
||||
"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
|
||||
}
|
||||
{ "id": "physical_affection", "text": "Physical affection" },
|
||||
{ "id": "clear_plan", "text": "Having a plan" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
```bat
|
||||
ECHO The prompt should clearly allow multiple answers.
|
||||
ECHO Multi-choice prompts should say or imply that multiple answers are okay.
|
||||
ECHO Options should not shame either partner.
|
||||
ECHO Options should not overlap excessively.
|
||||
ECHO Mirror top-level options into answer_config.options exactly.
|
||||
ECHO Include practical and emotional options when possible.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## scale
|
||||
|
||||
Use for one measurable dimension such as comfort, closeness, energy, readiness, satisfaction, confidence, importance, or frequency.
|
||||
Use for comfort level, closeness, energy, readiness, satisfaction, or frequency.
|
||||
|
||||
Good scale example:
|
||||
|
||||
```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": {
|
||||
"text": "How much do you feel like we need a real date soon?",
|
||||
"scale": {
|
||||
"min": 1,
|
||||
"max": 5,
|
||||
"min_label": "Not much",
|
||||
"max_label": "A lot"
|
||||
"max_label": "Very much"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -408,10 +298,8 @@ Use for one measurable dimension such as comfort, closeness, energy, readiness,
|
|||
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.
|
||||
```
|
||||
|
||||
|
|
@ -431,42 +319,27 @@ How much would some intentional time together help us right now?
|
|||
|
||||
## this_or_that
|
||||
|
||||
Use for a fast two-option choice.
|
||||
Use for fast, playful, low-pressure choices.
|
||||
|
||||
```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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
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?
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
```bat
|
||||
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.
|
||||
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.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 7. Free vs Premium Strategy
|
||||
|
||||
Free questions should prove the app is worth using.
|
||||
|
|
@ -564,131 +437,6 @@ 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
|
||||
|
|
@ -940,180 +688,39 @@ What tends to make date night harder for you to enjoy?
|
|||
|
||||
---
|
||||
|
||||
# 12. Production JSON Contract and Quality Rules
|
||||
# 12. JSON Quality Rules
|
||||
|
||||
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
|
||||
Every JSON file should pass these checks.
|
||||
|
||||
```bat
|
||||
ECHO [ ] Valid JSON.
|
||||
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 [ ] Category id matches file purpose.
|
||||
ECHO [ ] Category title sounds user-facing.
|
||||
ECHO [ ] Category access matches actual free/premium strategy.
|
||||
ECHO [ ] Metadata counts match actual question counts.
|
||||
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 [ ] All question IDs are unique.
|
||||
ECHO [ ] All question texts are unique.
|
||||
ECHO [ ] No near-duplicate blocks.
|
||||
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 [ ] Written answer limits are present when needed.
|
||||
ECHO [ ] No malformed keys.
|
||||
ECHO [ ] No malformed keys like m a x _ l e n g t h.
|
||||
ECHO [ ] No placeholder text.
|
||||
ECHO [ ] The file is a complete pack, not a batch or patch artifact.
|
||||
ECHO [ ] No accidental old gendered IDs unless intentionally used.
|
||||
```
|
||||
|
||||
Valid access values:
|
||||
Recommended access values:
|
||||
|
||||
```text
|
||||
free
|
||||
premium
|
||||
```
|
||||
|
||||
Valid category access values:
|
||||
Recommended category access values:
|
||||
|
||||
```text
|
||||
free
|
||||
|
|
@ -1121,15 +728,15 @@ premium
|
|||
mixed
|
||||
```
|
||||
|
||||
Valid production depth values:
|
||||
Recommended depth values:
|
||||
|
||||
```text
|
||||
1
|
||||
2
|
||||
3
|
||||
light
|
||||
medium
|
||||
deep
|
||||
```
|
||||
|
||||
Valid question types:
|
||||
Recommended question types:
|
||||
|
||||
```text
|
||||
written
|
||||
|
|
@ -1139,67 +746,26 @@ 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
|
||||
|
||||
Normal category packs may contain **no more than 150 questions**. A complete 150-question mixed pack should normally target:
|
||||
For a 250-question pack:
|
||||
|
||||
```bat
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
This structure is acceptable if the current app expects those counts.
|
||||
|
||||
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
|
||||
But quality matters more than exact counts. If a pack cannot support 250 good questions without filler, reduce the pack or split it.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1207,24 +773,7 @@ Rules:
|
|||
|
||||
Question packs must not be written in one uninterrupted bulk-generation pass.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## 14.1 Batch Size
|
||||
|
||||
|
|
@ -1538,58 +1087,47 @@ 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 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.
|
||||
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.
|
||||
|
||||
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 inside the pack and across the catalog.
|
||||
ECHO Question texts are unique inside the pack and across the catalog.
|
||||
ECHO IDs are unique.
|
||||
ECHO Question texts are unique.
|
||||
ECHO Free/premium counts match.
|
||||
ECHO Type counts match.
|
||||
ECHO Depth values are integers 1, 2, or 3.
|
||||
ECHO Tags exist on every question.
|
||||
ECHO Depth values are valid.
|
||||
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 150 questions does not justify filler. The maximum is not a quota.
|
||||
A product target of 250 questions does not justify filler.
|
||||
|
||||
If the topic cannot support the planned count without repetition:
|
||||
|
||||
|
|
@ -1707,47 +1245,33 @@ ECHO Would make a normal couple roll their eyes.
|
|||
|
||||
---
|
||||
|
||||
# 18. Agent Instructions for Rewriting JSON Packs
|
||||
# 18. Claude Instructions for Rewriting JSON Packs
|
||||
|
||||
These rules bind every agent that rewrites a pack (ChatGPT, Claude, or any other writer).
|
||||
|
||||
When an agent rewrites a pack:
|
||||
When Claude rewrites a pack:
|
||||
|
||||
```bat
|
||||
ECHO Read the existing JSON first.
|
||||
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 Preserve required schema unless told otherwise.
|
||||
ECHO Preserve required counts unless told otherwise.
|
||||
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 per file and across the full catalog.
|
||||
ECHO Report old count, new count, and net catalog change.
|
||||
ECHO Validate the final JSON.
|
||||
ECHO Report count totals.
|
||||
ECHO Report any schema changes.
|
||||
```
|
||||
|
||||
The agent should not say a file is done unless:
|
||||
Claude 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 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 IDs are unique.
|
||||
ECHO Question texts are unique.
|
||||
ECHO Free/premium counts match.
|
||||
ECHO Type counts match.
|
||||
ECHO The catalog import gate passes.
|
||||
ECHO The tone guide was followed.
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
# 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
|
||||
```
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Closer Question Quality Checklist v11 — Importer-Aligned, Human-Voice
|
||||
# Closer Question Quality Checklist v7
|
||||
|
||||
**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,92 +31,20 @@ 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 question’s 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 frozen weekday questions
|
||||
* 11 free wildcard questions
|
||||
* 511 total questions
|
||||
* 86 free questions
|
||||
* 500 total questions
|
||||
* 75 free questions
|
||||
* 425 premium questions
|
||||
* every question is single_choice
|
||||
* every weekday question has exactly one weekday tag
|
||||
* every wildcard question has `mode_wildcard` and `daily_wildcard` and no weekday tag
|
||||
* every question has a 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
|
||||
|
||||
|
|
@ -297,28 +225,6 @@ 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.
|
||||
|
|
@ -331,16 +237,10 @@ 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
|
||||
|
|
@ -394,7 +294,6 @@ 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.
|
||||
|
||||
|
|
@ -402,7 +301,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 and all 11 wildcard questions.
|
||||
1. Read 10 random questions from each weekday.
|
||||
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.
|
||||
|
|
@ -446,11 +345,6 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Closer Question Rewrite Plan v10 — Importer-Aligned
|
||||
# QUESTION_REWRITE_PLAN.md
|
||||
|
||||
## Purpose
|
||||
|
||||
|
|
@ -8,55 +8,13 @@ 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 category packs of up to 150 questions:
|
||||
For normal 250 question category packs:
|
||||
|
||||
1. Define the category purpose.
|
||||
2. Define the main subtopics.
|
||||
3. Write in batches of 25, or 15 to 20 for sensitive packs.
|
||||
3. Write in batches of 40 to 50.
|
||||
4. Review tone and repetition after each batch.
|
||||
5. Assign types, access, and depth.
|
||||
6. Validate schema.
|
||||
|
|
@ -65,30 +23,24 @@ For normal category packs of up to 150 questions:
|
|||
9. Read random questions out loud.
|
||||
10. Fix weak items before shipping.
|
||||
|
||||
## 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:
|
||||
## Standard 250 Question Mix
|
||||
|
||||
| Type | Count |
|
||||
|---|---:|
|
||||
| multi_choice | 90 |
|
||||
| single_choice | 30 |
|
||||
| scale | 15 |
|
||||
| this_or_that | 10 |
|
||||
| written | 5 |
|
||||
| multi_choice | 140 |
|
||||
| single_choice | 50 |
|
||||
| scale | 35 |
|
||||
| this_or_that | 15 |
|
||||
| written | 10 |
|
||||
|
||||
Free and premium split:
|
||||
|
||||
* 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.
|
||||
* 75 free
|
||||
* 175 premium
|
||||
|
||||
## Special Pack Exception
|
||||
|
||||
Some product-specific packs may exceed the normal 150-question maximum or use a different type mix, but only when the exception is documented.
|
||||
Some packs are product-specific and may override the standard 250 question mix.
|
||||
|
||||
A special pack must document:
|
||||
|
||||
|
|
@ -154,22 +106,17 @@ daily_single_choice_weekly_v1.json
|
|||
|
||||
Expected counts:
|
||||
|
||||
* 500 frozen weekday questions
|
||||
* 11 free wildcard questions
|
||||
* 511 total questions
|
||||
* 86 free questions
|
||||
* 500 total questions
|
||||
* 75 free questions
|
||||
* 425 premium questions
|
||||
* 511 single_choice questions
|
||||
|
||||
This is a documented special-pack exception to the normal 150-question maximum.
|
||||
* 500 single_choice questions
|
||||
|
||||
Rules:
|
||||
|
||||
* every question must be single_choice
|
||||
* every question must have 4 to 6 options
|
||||
* 4 options preferred
|
||||
* 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 question must have a weekday tag
|
||||
* every option must answer the prompt
|
||||
* no therapy worksheet tone
|
||||
* no wellness survey tone
|
||||
|
|
@ -211,7 +158,7 @@ A daily batch should feel like something users would want to tap tonight.
|
|||
|
||||
## Daily Production Loop
|
||||
|
||||
Do not write or rewrite all 511 daily questions in one pass.
|
||||
Do not write or rewrite 500 daily questions in one pass.
|
||||
|
||||
Use a bounded loop. The goal is quality control, not infinite polishing until everyone forgets why the app exists.
|
||||
|
||||
|
|
@ -229,7 +176,7 @@ Patch mode means:
|
|||
6. Review the patched questions again.
|
||||
7. Repeat only for IDs that still fail.
|
||||
|
||||
Do not rewrite all 511 because some questions failed.
|
||||
Do not rewrite all 500 because some questions failed.
|
||||
|
||||
Do not rewrite a whole weekday unless the mass rewrite exception applies.
|
||||
|
||||
|
|
@ -314,13 +261,13 @@ When marking a daily question, use one or more of these reasons:
|
|||
|
||||
### Final Pack Gate
|
||||
|
||||
After all weekdays and wildcard questions are drafted:
|
||||
After all weekdays 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 and all 11 wildcard questions.
|
||||
5. Read 10 random questions from each weekday.
|
||||
6. Mark anything weak.
|
||||
7. Fix marked questions.
|
||||
8. Run a second random sample from each weekday.
|
||||
|
|
|
|||
|
|
@ -1,464 +1,338 @@
|
|||
# Closer Question Schema v8 — Importer-Aligned
|
||||
# Closer Question Schema v6
|
||||
|
||||
**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)
|
||||
**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)
|
||||
|
||||
## Authority
|
||||
## Purpose
|
||||
|
||||
This document defines the JSON that imports correctly **today**.
|
||||
This document defines the JSON schema, question types, validation rules, and required counts for Closer question packs.
|
||||
|
||||
The active `build_db.py` behavior and the Room storage contract are authoritative for production files.
|
||||
For writing philosophy and tone, see `QUESTION_CONTENT_GUIDE.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.
|
||||
For rewrite workflow and special pack exceptions, see `QUESTION_REWRITE_PLAN.md`.
|
||||
|
||||
## Production pack shape
|
||||
## Question Types
|
||||
|
||||
Every production pack must use:
|
||||
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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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": []
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
- `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
|
||||
* 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.
|
||||
|
||||
## Depth: production values
|
||||
## single_choice
|
||||
|
||||
| Meaning | Production value |
|
||||
|---|---:|
|
||||
| Light | `1` |
|
||||
| Medium | `2` |
|
||||
| Deep | `3` |
|
||||
Select one best answer.
|
||||
|
||||
Production example:
|
||||
Use 4 to 6 options.
|
||||
|
||||
```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.
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "quality_time_001",
|
||||
"category_id": "quality_time",
|
||||
"type": "single_choice",
|
||||
"text": "Which low-key plan sounds best tonight?",
|
||||
"depth": 1,
|
||||
"access": "free",
|
||||
"tags": ["low_energy", "easy_plan", "quality_time"],
|
||||
"text": "Which kind of date sounds best this week?",
|
||||
"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" }
|
||||
],
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
{ "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" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
Rules:
|
||||
|
||||
- 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
|
||||
* 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.
|
||||
|
||||
## `multi_choice`
|
||||
## scale
|
||||
|
||||
Use when more than one answer can be true.
|
||||
Rate agreement, comfort, importance, confidence, or frequency.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
- 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
|
||||
|
||||
## `scale`
|
||||
|
||||
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": {
|
||||
"text": "How much do you feel like we need a real date soon?",
|
||||
"scale": {
|
||||
"min": 1,
|
||||
"max": 5,
|
||||
"min_label": "Not easy yet",
|
||||
"max_label": "Very easy"
|
||||
"min_label": "Not much",
|
||||
"max_label": "Very much"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
Rules:
|
||||
|
||||
- use `answer_config`
|
||||
- include `min`, `max`, `min_label`, and `max_label`
|
||||
- measure one thing only
|
||||
- keep both labels neutral and non-shaming
|
||||
* Scale labels should be gentle and neutral.
|
||||
* Do not make the low end sound bad or shameful.
|
||||
* Scale questions should measure one thing only.
|
||||
|
||||
Do not put scale settings in a separate top-level object. The importer does not read custom scale labels from there.
|
||||
## this_or_that
|
||||
|
||||
## `written`
|
||||
Very fast playful questions.
|
||||
|
||||
The importer reads written settings from `answer_config`.
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "this_or_that",
|
||||
"text": "Planned date or spontaneous date?"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
* Prompts should be quick.
|
||||
* Avoid choices that imply one partner is wrong.
|
||||
* Best used in fun, date, intimacy, home, and lifestyle packs.
|
||||
|
||||
## written
|
||||
|
||||
Reserved for questions where a short written response adds meaningful value.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "quality_time_004",
|
||||
"category_id": "quality_time",
|
||||
"type": "written",
|
||||
"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": {
|
||||
"text": "What is one small thing I do that makes you feel cared for?",
|
||||
"answer": {
|
||||
"max_length": 500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Requirements:
|
||||
Written questions require an `answer` configuration with `max_length`.
|
||||
|
||||
- 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
|
||||
Do not use written questions for basic preferences.
|
||||
|
||||
## `this_or_that`
|
||||
## Standard 250 Question Packs
|
||||
|
||||
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:
|
||||
Every standard 250 question pack must contain:
|
||||
|
||||
| Type | Count |
|
||||
|---|---:|
|
||||
| `multi_choice` | 90 |
|
||||
| `single_choice` | 30 |
|
||||
| `scale` | 15 |
|
||||
| `this_or_that` | 10 |
|
||||
| `written` | 5 |
|
||||
| multi_choice | 140 |
|
||||
| single_choice | 50 |
|
||||
| scale | 35 |
|
||||
| this_or_that | 15 |
|
||||
| written | 10 |
|
||||
|
||||
Normal full-pack access target:
|
||||
Free and premium split:
|
||||
|
||||
```text
|
||||
45 free
|
||||
105 premium
|
||||
```
|
||||
* 75 free
|
||||
* 175 premium
|
||||
|
||||
The count is a ceiling, not a quota.
|
||||
## Special Packs
|
||||
|
||||
## Daily Single-Choice Weekday System
|
||||
Special product packs may override standard counts only when documented.
|
||||
|
||||
Compatibility filename:
|
||||
The daily single choice weekday pack is a special pack.
|
||||
|
||||
```text
|
||||
daily_fun_multiple_choice_v3.json
|
||||
```
|
||||
## Daily Single Choice Weekday Pack
|
||||
|
||||
Production category id:
|
||||
|
||||
```text
|
||||
daily_fun_mc
|
||||
```
|
||||
|
||||
Logical pack id stored in metadata:
|
||||
Pack id:
|
||||
|
||||
```text
|
||||
daily_single_choice_weekly_v1
|
||||
```
|
||||
|
||||
The production file must use the normal top-level wrapper:
|
||||
Compatibility file name:
|
||||
|
||||
```text
|
||||
daily_fun_multiple_choice_v3.json
|
||||
```
|
||||
|
||||
Target counts — weekday pack (frozen; do not change these 500):
|
||||
|
||||
| Field | Count |
|
||||
|---|---:|
|
||||
| weekday total | 500 |
|
||||
| weekday free | 75 |
|
||||
| weekday premium | 425 |
|
||||
| single_choice | 500 |
|
||||
|
||||
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:
|
||||
|
||||
| Field | Count |
|
||||
|---|---:|
|
||||
| wildcard free | 11 |
|
||||
|
||||
**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
|
||||
|
||||
```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
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"questions": []
|
||||
}
|
||||
```
|
||||
|
||||
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 question’s `category_id`.
|
||||
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.
|
||||
|
||||
Required totals:
|
||||
|
||||
```text
|
||||
500 weekday questions
|
||||
11 wildcard questions
|
||||
511 total questions
|
||||
86 free
|
||||
425 premium
|
||||
511 single_choice
|
||||
## 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."
|
||||
}
|
||||
```
|
||||
|
||||
Every daily question requires:
|
||||
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.
|
||||
|
||||
```text
|
||||
category_id: daily_fun_mc
|
||||
integer depth
|
||||
access
|
||||
tags
|
||||
top-level options
|
||||
mirrored answer_config.options
|
||||
## 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" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Every weekday question must carry exactly one documented weekday tag.
|
||||
## Daily Single Choice Content Rules
|
||||
|
||||
Every wildcard must carry `mode_wildcard` and `daily_wildcard`, and must not carry a weekday tag.
|
||||
Daily options must be:
|
||||
|
||||
A patch manifest containing only changed IDs is not the Daily production pack.
|
||||
* 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
|
||||
|
||||
## Work artifacts are not production packs
|
||||
Daily options must not be mostly chores, household admin, or therapy phrasing.
|
||||
|
||||
Do not place any of these under a production filename:
|
||||
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.
|
||||
|
||||
```text
|
||||
partial batch JSON
|
||||
simple-pack batch JSON
|
||||
patch manifest
|
||||
marked-fix list
|
||||
validation report
|
||||
coverage map
|
||||
continuation note
|
||||
apply script
|
||||
review summary
|
||||
```
|
||||
Schema validation only proves the file can be parsed. It does not prove the content is good.
|
||||
|
||||
A patch must be clearly named as a patch, applied to the complete source, and followed by validation of the complete resulting pack.
|
||||
Daily packs must also pass the fun gate in `QUESTION_QUALITY_CHECKLIST.md` and the loop in `QUESTION_REWRITE_PLAN.md`.
|
||||
|
||||
## Per-file validation
|
||||
## Weekday Tags
|
||||
|
||||
Before shipping:
|
||||
Daily questions must include the correct weekday tag:
|
||||
|
||||
- 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
|
||||
* 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
|
||||
|
||||
## Catalog-wide validation
|
||||
If app code still uses older mode tags, include compatibility tags as needed without removing the new weekday tag.
|
||||
|
||||
Before rebuilding `app.db` or shipping content, validate every production pack together.
|
||||
## Depth
|
||||
|
||||
Reject the build for:
|
||||
Documented depth values:
|
||||
|
||||
- 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
|
||||
* light
|
||||
* medium
|
||||
* deep
|
||||
|
||||
A duplicate in two otherwise valid files can abort the entire import.
|
||||
Do not use numeric depth values unless the active app import code still requires numeric depth for compatibility.
|
||||
|
||||
Per-file success does not override catalog failure.
|
||||
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.
|
||||
|
|
|
|||
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
|
|
@ -185,7 +185,7 @@
|
|||
"id": "daily_single_choice_weekly_v1_002",
|
||||
"category_id": "daily_fun_mc",
|
||||
"type": "single_choice",
|
||||
"text": "How should we spend our first ten minutes together tonight?",
|
||||
"text": "Our first ten minutes together tonight: choose our official pick.",
|
||||
"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": "What should we do for one song?",
|
||||
"text": "Which choice should lead a one-song moment?",
|
||||
"depth": 1,
|
||||
"access": "premium",
|
||||
"sex": "neutral",
|
||||
|
|
@ -436,7 +436,7 @@
|
|||
},
|
||||
{
|
||||
"id": "pick_a_song_for_future_us",
|
||||
"text": "Pick a song that feels like us"
|
||||
"text": "Pick a song for future-us"
|
||||
}
|
||||
],
|
||||
"answer_config": {
|
||||
|
|
@ -455,7 +455,7 @@
|
|||
},
|
||||
{
|
||||
"id": "pick_a_song_for_future_us",
|
||||
"text": "Pick a song that feels like us"
|
||||
"text": "Pick a song for future-us"
|
||||
}
|
||||
],
|
||||
"selection_style": "single"
|
||||
|
|
@ -521,7 +521,7 @@
|
|||
"id": "daily_single_choice_weekly_v1_008",
|
||||
"category_id": "daily_fun_mc",
|
||||
"type": "single_choice",
|
||||
"text": "What should we do for ten phone-free minutes?",
|
||||
"text": "For a phone-free ten minutes, what gets our official vote?",
|
||||
"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": "Which little plan should we make for later this week?",
|
||||
"text": "A little future plan: choose our official pick.",
|
||||
"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 small gesture makes you feel chosen?",
|
||||
"text": "Which tiny gesture would make you feel specially 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": "What would make the ride home better?",
|
||||
"text": "The ride home: what gets top billing?",
|
||||
"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": "What sounds best for a no-scroll break?",
|
||||
"text": "For a no-scroll break, what deserves first place?",
|
||||
"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 surprise would be your favorite?",
|
||||
"text": "Which sweet little ambush 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": "Which little plan would you look forward to most?",
|
||||
"text": "One thing to anticipate: what gets top billing?",
|
||||
"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": "Which tiny escape sounds best?",
|
||||
"text": "For a tiny escape, which option gets the nod?",
|
||||
"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": "Which one-minute challenge sounds worth trying?",
|
||||
"text": "A sixty-second challenge—which option wins?",
|
||||
"depth": 1,
|
||||
"access": "premium",
|
||||
"sex": "neutral",
|
||||
|
|
@ -13415,39 +13415,39 @@
|
|||
],
|
||||
"options": [
|
||||
{
|
||||
"id": "draw_each_other_without_looking_down",
|
||||
"text": "Draw each other without looking down"
|
||||
"id": "build_a_snack_tower",
|
||||
"text": "Build a snack tower"
|
||||
},
|
||||
{
|
||||
"id": "find_the_oldest_photo_on_our_phones",
|
||||
"text": "Find the oldest photo on our phones"
|
||||
"id": "draw_the_other_person",
|
||||
"text": "Draw the other person"
|
||||
},
|
||||
{
|
||||
"id": "name_five_things_we_both_love",
|
||||
"text": "Name five things we both love"
|
||||
"id": "find_five_blue_things",
|
||||
"text": "Find five blue things"
|
||||
},
|
||||
{
|
||||
"id": "pitch_the_worst_possible_date_idea",
|
||||
"text": "Pitch the worst possible date idea"
|
||||
"id": "create_a_couple_slogan",
|
||||
"text": "Create a couple slogan"
|
||||
}
|
||||
],
|
||||
"answer_config": {
|
||||
"options": [
|
||||
{
|
||||
"id": "draw_each_other_without_looking_down",
|
||||
"text": "Draw each other without looking down"
|
||||
"id": "build_a_snack_tower",
|
||||
"text": "Build a snack tower"
|
||||
},
|
||||
{
|
||||
"id": "find_the_oldest_photo_on_our_phones",
|
||||
"text": "Find the oldest photo on our phones"
|
||||
"id": "draw_the_other_person",
|
||||
"text": "Draw the other person"
|
||||
},
|
||||
{
|
||||
"id": "name_five_things_we_both_love",
|
||||
"text": "Name five things we both love"
|
||||
"id": "find_five_blue_things",
|
||||
"text": "Find five blue things"
|
||||
},
|
||||
{
|
||||
"id": "pitch_the_worst_possible_date_idea",
|
||||
"text": "Pitch the worst possible date idea"
|
||||
"id": "create_a_couple_slogan",
|
||||
"text": "Create a couple slogan"
|
||||
}
|
||||
],
|
||||
"selection_style": "single"
|
||||
|
|
@ -13457,7 +13457,7 @@
|
|||
"id": "daily_single_choice_weekly_v1_239",
|
||||
"category_id": "daily_fun_mc",
|
||||
"type": "single_choice",
|
||||
"text": "Which rule would make tonight funnier?",
|
||||
"text": "Which weird little rule should we use?",
|
||||
"depth": 1,
|
||||
"access": "premium",
|
||||
"sex": "neutral",
|
||||
|
|
@ -13471,39 +13471,39 @@
|
|||
],
|
||||
"options": [
|
||||
{
|
||||
"id": "every_complaint_needs_a_dramatic_title",
|
||||
"text": "Every complaint needs a dramatic title"
|
||||
"id": "talk_like_royalty_for_ten_minutes",
|
||||
"text": "Talk like royalty for ten minutes"
|
||||
},
|
||||
{
|
||||
"id": "whoever_checks_their_phone_first_picks_the_music",
|
||||
"text": "Whoever checks their phone first picks the music"
|
||||
"id": "every_compliment_must_rhyme",
|
||||
"text": "Every compliment must rhyme"
|
||||
},
|
||||
{
|
||||
"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": "narrate_every_snack_choice",
|
||||
"text": "Narrate every snack choice"
|
||||
},
|
||||
{
|
||||
"id": "every_minor_inconvenience_gets_a_movie_trailer_voice",
|
||||
"text": "Every minor inconvenience gets a movie-trailer voice"
|
||||
"id": "call_dessert_the_grand_finale",
|
||||
"text": "Call dessert “the grand finale”"
|
||||
}
|
||||
],
|
||||
"answer_config": {
|
||||
"options": [
|
||||
{
|
||||
"id": "every_complaint_needs_a_dramatic_title",
|
||||
"text": "Every complaint needs a dramatic title"
|
||||
"id": "talk_like_royalty_for_ten_minutes",
|
||||
"text": "Talk like royalty for ten minutes"
|
||||
},
|
||||
{
|
||||
"id": "whoever_checks_their_phone_first_picks_the_music",
|
||||
"text": "Whoever checks their phone first picks the music"
|
||||
"id": "every_compliment_must_rhyme",
|
||||
"text": "Every compliment must rhyme"
|
||||
},
|
||||
{
|
||||
"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": "narrate_every_snack_choice",
|
||||
"text": "Narrate every snack choice"
|
||||
},
|
||||
{
|
||||
"id": "every_minor_inconvenience_gets_a_movie_trailer_voice",
|
||||
"text": "Every minor inconvenience gets a movie-trailer voice"
|
||||
"id": "call_dessert_the_grand_finale",
|
||||
"text": "Call dessert “the grand finale”"
|
||||
}
|
||||
],
|
||||
"selection_style": "single"
|
||||
|
|
@ -14129,7 +14129,7 @@
|
|||
"id": "daily_single_choice_weekly_v1_251",
|
||||
"category_id": "daily_fun_mc",
|
||||
"type": "single_choice",
|
||||
"text": "Which low-effort bit would make tonight funnier?",
|
||||
"text": "A spontaneous bit: which option would you choose?",
|
||||
"depth": 1,
|
||||
"access": "premium",
|
||||
"sex": "neutral",
|
||||
|
|
@ -14143,39 +14143,39 @@
|
|||
],
|
||||
"options": [
|
||||
{
|
||||
"id": "narrate_dinner_like_a_true_crime_show",
|
||||
"text": "Narrate dinner like a true-crime show"
|
||||
"id": "pretend_we_host_a_cooking_show",
|
||||
"text": "Pretend we host a cooking show"
|
||||
},
|
||||
{
|
||||
"id": "review_our_week_like_sports_commentators",
|
||||
"text": "Review our week like sports commentators"
|
||||
"id": "act_like_tourists_in_our_kitchen",
|
||||
"text": "Act like tourists in our kitchen"
|
||||
},
|
||||
{
|
||||
"id": "recreate_our_worst_customer_service_voices",
|
||||
"text": "Recreate our worst customer-service voices"
|
||||
"id": "give_the_snacks_job_interviews",
|
||||
"text": "Give the snacks job interviews"
|
||||
},
|
||||
{
|
||||
"id": "pitch_our_home_as_a_luxury_resort",
|
||||
"text": "Pitch our home as a luxury resort"
|
||||
"id": "review_the_evening_like_critics",
|
||||
"text": "Review the evening like critics"
|
||||
}
|
||||
],
|
||||
"answer_config": {
|
||||
"options": [
|
||||
{
|
||||
"id": "narrate_dinner_like_a_true_crime_show",
|
||||
"text": "Narrate dinner like a true-crime show"
|
||||
"id": "pretend_we_host_a_cooking_show",
|
||||
"text": "Pretend we host a cooking show"
|
||||
},
|
||||
{
|
||||
"id": "review_our_week_like_sports_commentators",
|
||||
"text": "Review our week like sports commentators"
|
||||
"id": "act_like_tourists_in_our_kitchen",
|
||||
"text": "Act like tourists in our kitchen"
|
||||
},
|
||||
{
|
||||
"id": "recreate_our_worst_customer_service_voices",
|
||||
"text": "Recreate our worst customer-service voices"
|
||||
"id": "give_the_snacks_job_interviews",
|
||||
"text": "Give the snacks job interviews"
|
||||
},
|
||||
{
|
||||
"id": "pitch_our_home_as_a_luxury_resort",
|
||||
"text": "Pitch our home as a luxury resort"
|
||||
"id": "review_the_evening_like_critics",
|
||||
"text": "Review the evening like critics"
|
||||
}
|
||||
],
|
||||
"selection_style": "single"
|
||||
|
|
@ -14185,7 +14185,7 @@
|
|||
"id": "daily_single_choice_weekly_v1_252",
|
||||
"category_id": "daily_fun_mc",
|
||||
"type": "single_choice",
|
||||
"text": "Which scene from adult life should we overact?",
|
||||
"text": "Which terrible acting scene should we perform?",
|
||||
"depth": 1,
|
||||
"access": "premium",
|
||||
"sex": "neutral",
|
||||
|
|
@ -14199,39 +14199,39 @@
|
|||
],
|
||||
"options": [
|
||||
{
|
||||
"id": "forgetting_why_we_entered_a_room",
|
||||
"text": "Forgetting why we entered a room"
|
||||
"id": "we_just_won_a_very_strange_award",
|
||||
"text": "We just won a very strange award"
|
||||
},
|
||||
{
|
||||
"id": "choosing_dinner_when_neither_of_us_cares",
|
||||
"text": "Choosing dinner when neither of us cares"
|
||||
"id": "we_are_spies_ordering_dessert",
|
||||
"text": "We are spies ordering dessert"
|
||||
},
|
||||
{
|
||||
"id": "pretending_well_go_to_bed_early",
|
||||
"text": "Pretending we’ll go to bed early"
|
||||
"id": "we_missed_an_imaginary_train",
|
||||
"text": "We missed an imaginary train"
|
||||
},
|
||||
{
|
||||
"id": "hearing_an_unexpected_knock_at_the_door",
|
||||
"text": "Hearing an unexpected knock at the door"
|
||||
"id": "we_are_rivals_in_a_cooking_show",
|
||||
"text": "We are rivals in a cooking show"
|
||||
}
|
||||
],
|
||||
"answer_config": {
|
||||
"options": [
|
||||
{
|
||||
"id": "forgetting_why_we_entered_a_room",
|
||||
"text": "Forgetting why we entered a room"
|
||||
"id": "we_just_won_a_very_strange_award",
|
||||
"text": "We just won a very strange award"
|
||||
},
|
||||
{
|
||||
"id": "choosing_dinner_when_neither_of_us_cares",
|
||||
"text": "Choosing dinner when neither of us cares"
|
||||
"id": "we_are_spies_ordering_dessert",
|
||||
"text": "We are spies ordering dessert"
|
||||
},
|
||||
{
|
||||
"id": "pretending_well_go_to_bed_early",
|
||||
"text": "Pretending we’ll go to bed early"
|
||||
"id": "we_missed_an_imaginary_train",
|
||||
"text": "We missed an imaginary train"
|
||||
},
|
||||
{
|
||||
"id": "hearing_an_unexpected_knock_at_the_door",
|
||||
"text": "Hearing an unexpected knock at the door"
|
||||
"id": "we_are_rivals_in_a_cooking_show",
|
||||
"text": "We are rivals in a cooking show"
|
||||
}
|
||||
],
|
||||
"selection_style": "single"
|
||||
|
|
@ -17265,7 +17265,7 @@
|
|||
"id": "daily_single_choice_weekly_v1_307",
|
||||
"category_id": "daily_fun_mc",
|
||||
"type": "single_choice",
|
||||
"text": "Which date-night detail gets your attention first?",
|
||||
"text": "For a date-night detail, what gets our official vote?",
|
||||
"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": "What kind of compliment would mean the most today?",
|
||||
"text": "Which kind of recognition feels most energizing?",
|
||||
"depth": 2,
|
||||
"access": "premium",
|
||||
"sex": "neutral",
|
||||
|
|
@ -25679,39 +25679,39 @@
|
|||
],
|
||||
"options": [
|
||||
{
|
||||
"id": "you_handled_that_better_than_you_think",
|
||||
"text": "You handled that better than you think"
|
||||
"id": "notice_a_creative_choice_i_made",
|
||||
"text": "Notice a creative choice I made"
|
||||
},
|
||||
{
|
||||
"id": "you_still_make_me_laugh",
|
||||
"text": "You still make me laugh"
|
||||
"id": "point_out_my_calm_confidence",
|
||||
"text": "Point out my calm confidence"
|
||||
},
|
||||
{
|
||||
"id": "i_love_the_way_your_mind_works",
|
||||
"text": "I love the way your mind works"
|
||||
"id": "celebrate_a_risk_i_took",
|
||||
"text": "Celebrate a risk I took"
|
||||
},
|
||||
{
|
||||
"id": "you_make_ordinary_days_better",
|
||||
"text": "You make ordinary days better"
|
||||
"id": "name_the_fun_i_brought",
|
||||
"text": "Name the fun I brought"
|
||||
}
|
||||
],
|
||||
"answer_config": {
|
||||
"options": [
|
||||
{
|
||||
"id": "you_handled_that_better_than_you_think",
|
||||
"text": "You handled that better than you think"
|
||||
"id": "notice_a_creative_choice_i_made",
|
||||
"text": "Notice a creative choice I made"
|
||||
},
|
||||
{
|
||||
"id": "you_still_make_me_laugh",
|
||||
"text": "You still make me laugh"
|
||||
"id": "point_out_my_calm_confidence",
|
||||
"text": "Point out my calm confidence"
|
||||
},
|
||||
{
|
||||
"id": "i_love_the_way_your_mind_works",
|
||||
"text": "I love the way your mind works"
|
||||
"id": "celebrate_a_risk_i_took",
|
||||
"text": "Celebrate a risk I took"
|
||||
},
|
||||
{
|
||||
"id": "you_make_ordinary_days_better",
|
||||
"text": "You make ordinary days better"
|
||||
"id": "name_the_fun_i_brought",
|
||||
"text": "Name the fun I brought"
|
||||
}
|
||||
],
|
||||
"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 kiss would you enjoy most?",
|
||||
"text": "Which movie-night role would you claim?",
|
||||
"depth": 2,
|
||||
"access": "premium",
|
||||
"sex": "neutral",
|
||||
|
|
@ -26295,39 +26295,39 @@
|
|||
],
|
||||
"options": [
|
||||
{
|
||||
"id": "a_hello_kiss_before_we_press_play",
|
||||
"text": "A hello kiss before we press play"
|
||||
"id": "official_snack_chooser",
|
||||
"text": "Official snack chooser"
|
||||
},
|
||||
{
|
||||
"id": "a_quick_kiss_during_the_previews",
|
||||
"text": "A quick kiss during the previews"
|
||||
"id": "trailer_rating_judge",
|
||||
"text": "Trailer-rating judge"
|
||||
},
|
||||
{
|
||||
"id": "a_longer_kiss_when_the_movie_ends",
|
||||
"text": "A longer kiss when the movie ends"
|
||||
"id": "plot_twist_predictor",
|
||||
"text": "Plot-twist predictor"
|
||||
},
|
||||
{
|
||||
"id": "a_goodnight_kiss_after_the_credits",
|
||||
"text": "A goodnight kiss after the credits"
|
||||
"id": "ending_debate_champion",
|
||||
"text": "Ending-debate champion"
|
||||
}
|
||||
],
|
||||
"answer_config": {
|
||||
"options": [
|
||||
{
|
||||
"id": "a_hello_kiss_before_we_press_play",
|
||||
"text": "A hello kiss before we press play"
|
||||
"id": "official_snack_chooser",
|
||||
"text": "Official snack chooser"
|
||||
},
|
||||
{
|
||||
"id": "a_quick_kiss_during_the_previews",
|
||||
"text": "A quick kiss during the previews"
|
||||
"id": "trailer_rating_judge",
|
||||
"text": "Trailer-rating judge"
|
||||
},
|
||||
{
|
||||
"id": "a_longer_kiss_when_the_movie_ends",
|
||||
"text": "A longer kiss when the movie ends"
|
||||
"id": "plot_twist_predictor",
|
||||
"text": "Plot-twist predictor"
|
||||
},
|
||||
{
|
||||
"id": "a_goodnight_kiss_after_the_credits",
|
||||
"text": "A goodnight kiss after the credits"
|
||||
"id": "ending_debate_champion",
|
||||
"text": "Ending-debate champion"
|
||||
}
|
||||
],
|
||||
"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
11534
seed/questions/fun.json
11534
seed/questions/fun.json
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
Loading…
Reference in New Issue