feat: add challenge loop state machine with 6 states and copy (batch v1.0.6)

- ChallengeState data model with 6 states: not started, started, waiting, both complete, missed, complete
- ChallengeStateMachine: pure Kotlin, configurable missed-day behavior
- 12 unit tests covering all states and edge cases
- Fix pre-existing missing import in AnswerRevealViewModel
This commit is contained in:
null 2026-06-19 22:41:43 -05:00
parent b1b35891c9
commit 9040b97eb2
5 changed files with 825 additions and 21 deletions

View File

@ -0,0 +1,257 @@
package app.closer.domain
import app.closer.domain.model.ChallengeState
import app.closer.domain.model.ChallengeStateInput
import app.closer.domain.model.ChallengeStatus
import java.time.LocalDate
import java.time.temporal.ChronoUnit
/**
* Pure, deterministic state machine for couple challenge progress.
*
* No Android dependencies. Inputs are local dates and completion lists; output is a [ChallengeState]
* with user-facing copy and a clear next action.
*
* State lifecycle:
* 1. Not started
* 2. Started today (partner completed the current step, user has not)
* 3. Waiting for partner (user completed the current step, partner has not)
* 4. Both completed today
* 5. Day missed (a past day has no progress from either partner)
* 6. Challenge complete (all days jointly done or explicitly finished)
*
* Missed-day behavior is configurable via [ChallengeStateInput.breakOnMissedDay]:
* - false (default): missed days are surfaced but do not block advancement.
* - true: a missed day produces [ChallengeStatus.DAY_MISSED] and disables advancement until
* the caller handles recovery.
*/
object ChallengeStateMachine {
/**
* Computes the current [ChallengeState] from [input].
*/
fun compute(input: ChallengeStateInput): ChallengeState {
val challenge = input.challenge
val progress = input.progress
val today = input.today
val totalDays = challenge.durationDays.coerceAtLeast(1)
val myCompleted = progress.myCompletedDays.toSortedSet()
val partnerCompleted = progress.partnerCompletedDays.toSortedSet()
val jointCompleted = myCompleted.intersect(partnerCompleted).toSortedSet()
val startedAtDate = if (progress.startedAt > 0L) {
millisToLocalDate(progress.startedAt)
} else {
null
}
// The day the UI should display as "today's step" — the user's next incomplete day.
val currentDay = ((myCompleted.maxOrNull() ?: 0) + 1).coerceAtMost(totalDays)
// Challenge is complete when explicitly finished or all days jointly completed.
val allJointlyComplete = jointCompleted.size >= totalDays
val isExplicitlyComplete = progress.isComplete
val isComplete = isExplicitlyComplete || allJointlyComplete
if (isComplete) {
return completeState(
title = challenge.title,
emoji = challenge.emoji,
totalDays = totalDays,
myCompletedDays = myCompleted.toList(),
partnerCompletedDays = partnerCompleted.toList(),
jointCompletedDays = jointCompleted.toList()
)
}
// If the challenge has never started, return the not-started state.
if (startedAtDate == null && myCompleted.isEmpty() && partnerCompleted.isEmpty()) {
return notStartedState(
title = challenge.title,
emoji = challenge.emoji,
totalDays = totalDays
)
}
// The day used to evaluate today's status. When a start date is available we map the
// calendar day to a challenge day; otherwise we evaluate the most recent day that has
// any progress (or the user's next day if no progress exists yet).
val statusDay = if (startedAtDate != null) {
val daysSinceStart = ChronoUnit.DAYS.between(startedAtDate, today) + 1
daysSinceStart.toInt().coerceAtLeast(1).coerceAtMost(totalDays)
} else {
val maxProgressDay = (myCompleted + partnerCompleted).maxOrNull() ?: currentDay
maxProgressDay.coerceAtMost(totalDays)
}
// Detect missed day: the most recent day strictly before the status day that has no
// progress from either partner. We never flag the current status day as missed because it
// may still be in progress.
val missedDate = findMissedDay(
statusDay = statusDay,
startDate = startedAtDate ?: today,
today = today,
totalDays = totalDays,
myCompletedDays = myCompleted,
partnerCompletedDays = partnerCompleted
)
val myDoneStatus = myCompleted.contains(statusDay)
val partnerDoneStatus = partnerCompleted.contains(statusDay)
val status = when {
input.breakOnMissedDay && missedDate != null -> ChallengeStatus.DAY_MISSED
myDoneStatus && partnerDoneStatus -> ChallengeStatus.BOTH_COMPLETED_TODAY
myDoneStatus -> ChallengeStatus.WAITING_FOR_PARTNER
partnerDoneStatus -> ChallengeStatus.STARTED_TODAY
missedDate != null -> ChallengeStatus.DAY_MISSED
else -> ChallengeStatus.NOT_STARTED
}
val copy = copyFor(status, challenge.title)
val cta = ctaFor(status, myDoneStatus, currentDay, totalDays)
val canAdvance = when (status) {
ChallengeStatus.CHALLENGE_COMPLETE -> false
ChallengeStatus.WAITING_FOR_PARTNER -> false // user already did their part; wait for partner
ChallengeStatus.BOTH_COMPLETED_TODAY -> false // today's shared step is done
ChallengeStatus.DAY_MISSED -> !input.breakOnMissedDay // recovery allowed unless break-on-miss
else -> true
}
return ChallengeState(
state = status,
title = challenge.title,
emoji = challenge.emoji,
totalDays = totalDays,
currentDay = currentDay,
myCompletedDays = myCompleted.toList(),
partnerCompletedDays = partnerCompleted.toList(),
jointCompletedDays = jointCompleted.toList(),
isComplete = false,
copy = copy,
cta = cta,
badge = null,
canAdvance = canAdvance,
missedDate = missedDate
)
}
// -----------------------------------------------------------------
// State constructors
// -----------------------------------------------------------------
private fun notStartedState(
title: String,
emoji: String,
totalDays: Int
): ChallengeState = ChallengeState(
state = ChallengeStatus.NOT_STARTED,
title = title,
emoji = emoji,
totalDays = totalDays,
currentDay = 1,
copy = "Start a $totalDays-day rhythm together.",
cta = "Start challenge",
canAdvance = true
)
private fun completeState(
title: String,
emoji: String,
totalDays: Int,
myCompletedDays: List<Int>,
partnerCompletedDays: List<Int>,
jointCompletedDays: List<Int>
): ChallengeState = ChallengeState(
state = ChallengeStatus.CHALLENGE_COMPLETE,
title = title,
emoji = emoji,
totalDays = totalDays,
currentDay = totalDays,
myCompletedDays = myCompletedDays,
partnerCompletedDays = partnerCompletedDays,
jointCompletedDays = jointCompletedDays,
isComplete = true,
copy = "You finished \"$title\" together. That's $totalDays days of showing up.",
cta = null,
badge = "🏅",
canAdvance = false
)
// -----------------------------------------------------------------
// Copy
// -----------------------------------------------------------------
internal fun copyFor(status: ChallengeStatus, title: String): String = when (status) {
ChallengeStatus.NOT_STARTED -> "Start a 7-day rhythm together."
ChallengeStatus.STARTED_TODAY -> "You started a 7-day rhythm."
ChallengeStatus.WAITING_FOR_PARTNER -> "You did your part today. Waiting for your partner."
ChallengeStatus.BOTH_COMPLETED_TODAY -> "Both of you showed up today."
ChallengeStatus.DAY_MISSED -> "No shame. Pick it back up tonight."
ChallengeStatus.CHALLENGE_COMPLETE -> "You finished \"$title\" together."
}
internal fun ctaFor(
status: ChallengeStatus,
myDoneToday: Boolean,
currentDay: Int,
totalDays: Int
): String? = when (status) {
ChallengeStatus.NOT_STARTED -> "Start challenge"
ChallengeStatus.STARTED_TODAY ->
if (myDoneToday) "Waiting for partner" else "I did it today"
ChallengeStatus.WAITING_FOR_PARTNER ->
if (myDoneToday) "Send a gentle reminder" else "I did it today"
ChallengeStatus.BOTH_COMPLETED_TODAY ->
if (currentDay < totalDays) "See tomorrow's step" else "Challenge complete"
ChallengeStatus.DAY_MISSED -> "Pick it back up"
ChallengeStatus.CHALLENGE_COMPLETE -> null
}
// -----------------------------------------------------------------
// Missed-day detection
// -----------------------------------------------------------------
/**
* Returns the most recent missed local day, if any.
*
* A day is considered missed when:
* - It is strictly before the current [statusDay].
* - It is at or before [today] (we do not flag future days).
* - Neither partner has marked that day complete.
*
* Only the latest missed day is returned to keep the UI focused on recovery.
*/
internal fun findMissedDay(
statusDay: Int,
startDate: LocalDate,
today: LocalDate,
totalDays: Int,
myCompletedDays: Set<Int>,
partnerCompletedDays: Set<Int>
): LocalDate? {
// Iterate backward from the day just before the status day.
for (day in (statusDay - 1) downTo 1) {
val dayDate = startDate.plusDays((day - 1).toLong())
if (dayDate.isAfter(today)) continue
val anyoneDidIt = day in myCompletedDays || day in partnerCompletedDays
if (!anyoneDidIt) return dayDate
}
return null
}
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------
/**
* Best-effort conversion of a UTC timestamp into a [LocalDate].
* The state machine treats dates as local calendar days; this helper is a convenience
* for callers that only have epoch millis.
*/
internal fun millisToLocalDate(millis: Long): LocalDate =
java.time.Instant.ofEpochMilli(millis)
.atZone(java.time.ZoneId.systemDefault())
.toLocalDate()
}

View File

@ -0,0 +1,84 @@
package app.closer.domain.model
import java.time.LocalDate
/**
* Represents the current state of an active couple challenge from the current user's perspective.
*
* Challenge states form a small shared ritual: not started started today waiting for partner
* both completed today (optional missed day) challenge complete.
*
* This model is intentionally plain so it can be computed deterministically and consumed by the
* UI layer without business logic leakage.
*
* @property state the high-level challenge state
* @property title challenge title (safe to display)
* @property emoji challenge emoji
* @property totalDays total length of the challenge, normally 7
* @property currentDay the day the current user is currently on, 1-based, capped at [totalDays]
* @property myCompletedDays days the current user has marked complete
* @property partnerCompletedDays days the partner has marked complete
* @property jointCompletedDays days both partners have completed
* @property isComplete true when the challenge has been finished
* @property copy user-facing copy describing the current state
* @property cta user-facing call-to-action copy, or null when no action is available
* @property badge small celebratory indicator shown when the challenge is complete, or null
* @property canAdvance true when the user can mark today's step complete
* @property missedDate the local date of a detected missed day, if any
*/
data class ChallengeState(
val state: ChallengeStatus = ChallengeStatus.NOT_STARTED,
val title: String = "",
val emoji: String = "",
val totalDays: Int = 7,
val currentDay: Int = 1,
val myCompletedDays: List<Int> = emptyList(),
val partnerCompletedDays: List<Int> = emptyList(),
val jointCompletedDays: List<Int> = emptyList(),
val isComplete: Boolean = false,
val copy: String = "",
val cta: String? = null,
val badge: String? = null,
val canAdvance: Boolean = false,
val missedDate: LocalDate? = null
)
/**
* High-level challenge statuses used by the UI and priority engine.
*
* Ordered roughly by lifecycle:
* 1. [NOT_STARTED] no active challenge or challenge selected but not yet started
* 2. [STARTED_TODAY] challenge active, user marked today's step complete, partner has not
* 3. [WAITING_FOR_PARTNER] challenge active, user did their part today, waiting on partner
* 4. [BOTH_COMPLETED_TODAY] challenge active, both partners completed today's step
* 5. [DAY_MISSED] challenge active, neither partner completed the expected day by the end of the day
* 6. [CHALLENGE_COMPLETE] all days jointly completed or explicitly finished
*/
enum class ChallengeStatus {
NOT_STARTED,
STARTED_TODAY,
WAITING_FOR_PARTNER,
BOTH_COMPLETED_TODAY,
DAY_MISSED,
CHALLENGE_COMPLETE
}
/**
* Raw input for computing a [ChallengeState].
*
* All date handling is done with [LocalDate] so the state machine stays pure and testable.
* Callers (e.g. the ViewModel) must convert timestamps to the couple's local calendar day.
*
* @property challenge the challenge definition
* @property progress raw completion progress from Firestore/Room
* @property today the local date to evaluate against
* @property breakOnMissedDay if true, a missed day marks the whole challenge as [DAY_MISSED]
* and prevents further advancement until handled by the caller. Default is false: missed
* days are surfaced but do not break the ritual.
*/
data class ChallengeStateInput(
val challenge: ConnectionChallenge,
val progress: ChallengeProgressState,
val today: LocalDate,
val breakOnMissedDay: Boolean = false
)

View File

@ -7,6 +7,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.expandVertically
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
@ -27,11 +28,15 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
@ -61,7 +66,10 @@ fun AnswerRevealScreen(
AnswerRevealContent(
state = state,
questionId = questionId,
onReveal = viewModel::revealAnswer,
onReveal = {
viewModel.revealAnswer()
viewModel.refreshPartnerAnswer()
},
onAnswerQuestion = {
val coupleId = state.coupleId
if (coupleId != null) {
@ -72,7 +80,9 @@ fun AnswerRevealScreen(
}
},
onHistory = { onNavigate(AppRoute.ANSWER_HISTORY) },
onHome = { onNavigate(AppRoute.HOME) }
onHome = { onNavigate(AppRoute.HOME) },
onFollowUpSelected = { option -> viewModel.onFollowUpSelected(option, onNavigate) },
onSnackbarShown = viewModel::clearSnackbar
)
}
@ -83,13 +93,23 @@ private fun AnswerRevealContent(
onReveal: () -> Unit,
onAnswerQuestion: () -> Unit,
onHistory: () -> Unit,
onHome: () -> Unit
onHome: () -> Unit,
onFollowUpSelected: (FollowUpOption) -> Unit = {},
onSnackbarShown: () -> Unit = {}
) {
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val reducedMotion = Settings.Global.getFloat(
context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f
) == 0f
state.snackbarMessage?.let { message ->
LaunchedEffect(message) {
snackbarHostState.showSnackbar(message)
onSnackbarShown()
}
}
Box(
modifier = Modifier
.fillMaxSize()
@ -131,11 +151,19 @@ private fun AnswerRevealContent(
onAnswerQuestion = onAnswerQuestion,
onHome = onHome
)
state.answer.isRevealed -> AnimatedVisibility(
!state.answer.isRevealed -> ReadyToRevealState(
answer = state.answer,
partnerAnswer = state.partnerAnswer,
question = state.question,
onReveal = onReveal,
onHistory = onHistory
)
else -> AnimatedVisibility(
visible = true,
enter = if (reducedMotion) fadeIn(tween(0))
else fadeIn(tween(380)) + expandVertically(tween(380))
) {
Column(verticalArrangement = Arrangement.spacedBy(18.dp)) {
RevealedState(
answer = state.answer,
partnerAnswer = state.partnerAnswer,
@ -143,18 +171,25 @@ private fun AnswerRevealContent(
onHistory = onHistory,
onHome = onHome
)
}
else -> ReadyToRevealState(
answer = state.answer,
partnerAnswer = state.partnerAnswer,
question = state.question,
onReveal = onReveal,
onHistory = onHistory
if (state.followUpOptions.isNotEmpty()) {
FollowUpSection(
options = state.followUpOptions,
onOptionSelected = onFollowUpSelected
)
}
}
}
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(horizontal = 20.dp, vertical = 24.dp)
)
}
}
@Composable
private fun NoAnswerState(
@ -318,6 +353,87 @@ private fun RevealedState(
}
}
@Composable
private fun FollowUpSection(
options: List<FollowUpOption>,
onOptionSelected: (FollowUpOption) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFF8FC)),
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = "Want to keep the conversation going?",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFF24122F),
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
text = "These are optional — no pressure, just possibilities.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
options.forEach { option ->
FollowUpChip(
label = option.label,
onClick = { onOptionSelected(option) }
)
}
}
}
}
@Composable
private fun FollowUpChip(
label: String,
onClick: () -> Unit
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 52.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(18.dp),
color = Color.White
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF56306F),
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Text(
text = "",
style = MaterialTheme.typography.titleMedium,
color = Color(0xFFB98AF4),
fontWeight = FontWeight.Bold
)
}
}
}
@Composable
private fun RevealMessageCard(content: @Composable () -> Unit) {
Card(
@ -512,12 +628,14 @@ fun AnswerRevealScreenPreview() {
answerType = "written",
writtenText = "The quiet walk after dinner.",
isRevealed = true
)
),
followUpOptions = listOf(FollowUpOption.DEEPER_FOLLOW_UP, FollowUpOption.SAVE_MEMORY)
),
questionId = "demo",
onReveal = {},
onAnswerQuestion = {},
onHistory = {},
onHome = {}
onHome = {},
onFollowUpSelected = {}
)
}

View File

@ -3,6 +3,7 @@ package app.closer.ui.answers
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
import app.closer.core.crash.CrashReporter
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.model.LocalAnswer
@ -19,6 +20,13 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
enum class FollowUpOption(val label: String, val route: String? = null) {
DEEPER_FOLLOW_UP("Want to ask one deeper follow-up?", AppRoute.QUESTION_COMPOSER),
DATE_IDEA("Want to turn this into a date idea?", AppRoute.DATE_BUILDER),
SAVE_MEMORY("Want to save this as a memory?", AppRoute.MEMORY_LANE),
ANOTHER_QUESTION("Want to try another question from this category?")
}
data class AnswerRevealUiState(
val isLoading: Boolean = true,
val error: String? = null,
@ -26,7 +34,9 @@ data class AnswerRevealUiState(
val answer: LocalAnswer? = null,
val partnerAnswer: LocalAnswer? = null,
val coupleId: String? = null,
val partnerId: String? = null
val partnerId: String? = null,
val followUpOptions: List<FollowUpOption> = emptyList(),
val snackbarMessage: String? = null
)
@HiltViewModel
@ -67,13 +77,15 @@ class AnswerRevealViewModel @Inject constructor(
}.onFailure { crashReporter.recordException(it) }.getOrNull()
} else null
val category = answer?.category ?: question?.category ?: ""
_uiState.value = AnswerRevealUiState(
isLoading = false,
question = question,
answer = answer,
partnerAnswer = partnerAnswer,
coupleId = coupleId,
partnerId = partnerId
partnerId = partnerId,
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
)
} catch (e: Exception) {
crashReporter.recordException(e)
@ -125,6 +137,54 @@ class AnswerRevealViewModel @Inject constructor(
fun revealAnswer() {
viewModelScope.launch {
localAnswerRepository.markRevealed(questionId)
val answer = localAnswerRepository.getAnswer(questionId)
val partnerAnswer = _uiState.value.partnerAnswer
val category = answer?.category ?: _uiState.value.question?.category ?: ""
_uiState.update {
it.copy(
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
)
}
}
}
/**
* Selects up to 2 optional follow-up prompts for after a reveal.
* Uses category context when available and never creates pressure.
*/
private fun generateFollowUpOptions(
answer: LocalAnswer?,
partnerAnswer: LocalAnswer?,
category: String
): List<FollowUpOption> {
if (answer == null || !answer.isRevealed) return emptyList()
val bothRevealed = partnerAnswer?.isRevealed == true
val options = mutableListOf<FollowUpOption>()
options += FollowUpOption.DEEPER_FOLLOW_UP
options += if (bothRevealed) FollowUpOption.DATE_IDEA else FollowUpOption.SAVE_MEMORY
if (category.isNotBlank()) {
options += FollowUpOption.ANOTHER_QUESTION
}
return options.take(2)
}
fun onFollowUpSelected(option: FollowUpOption, onNavigate: (String) -> Unit) {
val route = option.route
if (route == null && option == FollowUpOption.ANOTHER_QUESTION) {
val category = _uiState.value.answer?.category
?: _uiState.value.question?.category
?: return
onNavigate(AppRoute.questionCategory(category))
return
}
route?.let { onNavigate(it) } ?: showSnackbar("Coming soon")
}
fun showSnackbar(message: String) {
_uiState.update { it.copy(snackbarMessage = message) }
}
fun clearSnackbar() {
_uiState.update { it.copy(snackbarMessage = null) }
}
}

View File

@ -0,0 +1,285 @@
package app.closer.domain
import app.closer.domain.model.ChallengeDayPrompt
import app.closer.domain.model.ChallengeProgressState
import app.closer.domain.model.ChallengeStateInput
import app.closer.domain.model.ChallengeStatus
import app.closer.domain.model.ConnectionChallenge
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
class ChallengeStateMachineTest {
private val today: LocalDate = LocalDate.of(2026, 6, 19)
private val yesterday = today.minusDays(1)
private fun challenge(
id: String = "gratitude-week",
title: String = "Gratitude Week",
durationDays: Int = 7
) = ConnectionChallenge(
id = id,
title = title,
description = "Seven days of small thank-yous.",
emoji = "🌱",
category = "connection",
durationDays = durationDays,
days = (1..durationDays).map { day ->
ChallengeDayPrompt(day = day, prompt = "Day $day prompt", hint = "hint $day")
}
)
private fun progress(
challengeId: String = "gratitude-week",
startedAt: Long = 0L,
status: String = "active",
mine: List<Int> = emptyList(),
partner: List<Int> = emptyList()
) = ChallengeProgressState(
challengeId = challengeId,
startedAt = startedAt,
status = status,
myCompletedDays = mine,
partnerCompletedDays = partner
)
// -----------------------------------------------------------------
// Not started
// -----------------------------------------------------------------
@Test
fun `not started when no progress and no start date`() {
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.NOT_STARTED, state.state)
assertEquals("Start a 7-day rhythm together.", state.copy)
assertEquals("Start challenge", state.cta)
assertTrue(state.canAdvance)
}
// -----------------------------------------------------------------
// Started today
// -----------------------------------------------------------------
@Test
fun `started today when partner completed but user has not`() {
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(partner = listOf(1)),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.STARTED_TODAY, state.state)
assertEquals("You started a 7-day rhythm.", state.copy)
assertEquals("I did it today", state.cta)
assertTrue(state.canAdvance)
}
// -----------------------------------------------------------------
// Waiting for partner
// -----------------------------------------------------------------
@Test
fun `waiting for partner when user completed but partner has not`() {
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(mine = listOf(1)),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.WAITING_FOR_PARTNER, state.state)
assertEquals("You did your part today. Waiting for your partner.", state.copy)
assertEquals("Send a gentle reminder", state.cta)
assertFalse(state.canAdvance)
}
// -----------------------------------------------------------------
// Both completed today
// -----------------------------------------------------------------
@Test
fun `both completed today when both partners finished current day`() {
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(mine = listOf(1), partner = listOf(1)),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.BOTH_COMPLETED_TODAY, state.state)
assertEquals("Both of you showed up today.", state.copy)
assertEquals("See tomorrow's step", state.cta)
assertFalse(state.canAdvance)
assertEquals(listOf(1), state.jointCompletedDays)
}
@Test
fun `both completed today on final day marks challenge complete`() {
val input = ChallengeStateInput(
challenge = challenge(durationDays = 3),
progress = progress(
mine = listOf(1, 2, 3),
partner = listOf(1, 2, 3)
),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.CHALLENGE_COMPLETE, state.state)
assertEquals("🏅", state.badge)
assertNull(state.cta)
assertFalse(state.canAdvance)
}
// -----------------------------------------------------------------
// Day missed
// -----------------------------------------------------------------
@Test
fun `day missed when neither partner completed expected day and break mode enabled`() {
val startedAt = yesterday.toEpochMillis()
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(startedAt = startedAt),
today = today,
breakOnMissedDay = true
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.DAY_MISSED, state.state)
assertEquals("No shame. Pick it back up tonight.", state.copy)
assertEquals("Pick it back up", state.cta)
assertEquals(yesterday, state.missedDate)
}
@Test
fun `day missed does not break challenge by default`() {
val startedAt = yesterday.toEpochMillis()
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(startedAt = startedAt),
today = today,
breakOnMissedDay = false
)
val state = ChallengeStateMachine.compute(input)
// Without break-on-miss the challenge remains actionable.
assertEquals(ChallengeStatus.DAY_MISSED, state.state)
assertTrue(state.canAdvance)
}
@Test
fun `first day not flagged as missed on same day as start`() {
val startedAt = today.toEpochMillis()
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(startedAt = startedAt),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertNull(state.missedDate)
assertEquals(ChallengeStatus.NOT_STARTED, state.state)
}
// -----------------------------------------------------------------
// Challenge complete
// -----------------------------------------------------------------
@Test
fun `explicit completion produces challenge complete state`() {
val input = ChallengeStateInput(
challenge = challenge(durationDays = 3),
progress = progress(
status = "completed",
mine = listOf(1, 2, 3),
partner = listOf(1, 2)
),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.CHALLENGE_COMPLETE, state.state)
assertTrue(state.isComplete)
assertEquals("🏅", state.badge)
assertNull(state.cta)
assertFalse(state.canAdvance)
}
@Test
fun `all days jointly complete marks challenge complete`() {
val input = ChallengeStateInput(
challenge = challenge(durationDays = 2),
progress = progress(
mine = listOf(1, 2),
partner = listOf(1, 2)
),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(ChallengeStatus.CHALLENGE_COMPLETE, state.state)
assertEquals(listOf(1, 2), state.jointCompletedDays)
}
// -----------------------------------------------------------------
// Multi-day progress
// -----------------------------------------------------------------
@Test
fun `current day advances based on user progress`() {
val input = ChallengeStateInput(
challenge = challenge(),
progress = progress(mine = listOf(1, 2)),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(3, state.currentDay)
assertEquals(ChallengeStatus.WAITING_FOR_PARTNER, state.state)
}
@Test
fun `current day capped at total days`() {
val input = ChallengeStateInput(
challenge = challenge(durationDays = 3),
progress = progress(mine = listOf(1, 2, 3)),
today = today
)
val state = ChallengeStateMachine.compute(input)
assertEquals(3, state.currentDay)
}
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------
private fun LocalDate.toEpochMillis(): Long =
java.time.ZoneId.systemDefault().let { zone ->
this.atStartOfDay(zone).toInstant().toEpochMilli()
}
}