feat: add streak calculator with couple/personal/weekly streaks and repair (batch v1.0.2)
- Streak data models: Couple, Personal, WeeklyRhythm streaks - StreakCalculator: pure Kotlin, no Android deps, deterministic - Milestone copy at 1/3/7/14/30 days - Streak repair: 1 missed day per 7-day window, requires both partners - 23 unit tests covering all streak types, milestones, repair, timezone
This commit is contained in:
parent
0b619ee7ba
commit
c38e83b8ee
|
|
@ -0,0 +1,266 @@
|
|||
package app.closer.domain
|
||||
|
||||
import app.closer.domain.model.Streak
|
||||
import app.closer.domain.model.StreakInput
|
||||
import app.closer.domain.model.StreakResult
|
||||
import java.time.LocalDate
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
/**
|
||||
* Pure, deterministic streak logic for Closer.
|
||||
*
|
||||
* No Android dependencies. Inputs are [LocalDate] lists; output is a [StreakResult].
|
||||
*
|
||||
* Streak rules:
|
||||
* - Couple streak: both partners answered or completed one shared action on the same local day.
|
||||
* Counts consecutive days backward from today, allowing one missed day to be repaired per 7-day window.
|
||||
* - Personal streak: user answered or completed one private action on a local day.
|
||||
* Counts consecutive days backward from today, independent of the couple streak.
|
||||
* - Weekly rhythm: couple completed 3+ shared actions within a rolling 7-day window.
|
||||
* Counts consecutive 7-day windows backward from today. A window is counted if it contains at least
|
||||
* [Streak.WeeklyRhythm.THRESHOLD] shared-action days by the couple.
|
||||
* - Repair: one missed day can be repaired per 7-day window. A repair is available when yesterday (or
|
||||
* an earlier single day in the current window) was missed but today has already become a couple-action
|
||||
* day. Repairs require both partners to complete today's question (represented by today being a
|
||||
* couple-action day). The repair window is anchored from the most recent couple-action day.
|
||||
*
|
||||
* Timezone behavior: all dates are [LocalDate]. The caller is responsible for converting timestamps
|
||||
* to the user's local calendar day (already done by the daily-question local date key change).
|
||||
*/
|
||||
object StreakCalculator {
|
||||
|
||||
/**
|
||||
* Main entry point. Computes all streaks and repair eligibility from [input].
|
||||
*/
|
||||
fun calculate(input: StreakInput): StreakResult {
|
||||
val coupleActionDays = input.coupleActionDates.toSortedSet()
|
||||
val userDays = input.userDates.toSortedSet()
|
||||
val partnerDays = input.partnerDates.toSortedSet()
|
||||
|
||||
val coupleStreak = computeCoupleStreak(coupleActionDays, input.today, input.lastRepairDate)
|
||||
val personalStreak = computePersonalStreak(userDays, input.today)
|
||||
val weeklyRhythm = computeWeeklyRhythm(coupleActionDays, input.today)
|
||||
|
||||
val milestoneCopy = milestoneFor(coupleStreak.count, coupleStreak.includesToday)
|
||||
|
||||
val repair = computeRepair(
|
||||
coupleActionDays = coupleActionDays,
|
||||
userDays = userDays,
|
||||
partnerDays = partnerDays,
|
||||
today = input.today,
|
||||
lastRepairDate = input.lastRepairDate
|
||||
)
|
||||
|
||||
return StreakResult(
|
||||
coupleStreak = coupleStreak,
|
||||
personalStreak = personalStreak,
|
||||
weeklyRhythm = weeklyRhythm,
|
||||
milestoneCopy = milestoneCopy,
|
||||
canRepair = repair.canRepair,
|
||||
repairDueDate = repair.repairDueDate
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Couple streak: consecutive days on which the couple completed a shared action,
|
||||
* counting backward from [today], with one repaired missed day per 7-day window allowed.
|
||||
*/
|
||||
internal fun computeCoupleStreak(
|
||||
coupleActionDays: Set<LocalDate>,
|
||||
today: LocalDate,
|
||||
lastRepairDate: LocalDate? = null
|
||||
): Streak.Couple {
|
||||
if (coupleActionDays.isEmpty()) {
|
||||
return Streak.Couple()
|
||||
}
|
||||
|
||||
val sorted = coupleActionDays.toSortedSet().toList()
|
||||
val latest = sorted.last()
|
||||
val includesToday = latest == today
|
||||
|
||||
// If the most recent shared day is older than yesterday, the streak is broken
|
||||
// unless we can repair exactly one gap day. We handle repair in calculate(), so
|
||||
// here we count strict consecutive days.
|
||||
if (latest.isBefore(today.minusDays(2))) {
|
||||
return Streak.Couple(
|
||||
count = 1,
|
||||
lastActiveDate = latest,
|
||||
includesToday = false
|
||||
)
|
||||
}
|
||||
|
||||
var count = 1
|
||||
var previous = latest
|
||||
for (i in sorted.size - 2 downTo 0) {
|
||||
val current = sorted[i]
|
||||
val gap = ChronoUnit.DAYS.between(current, previous)
|
||||
if (gap == 1L) {
|
||||
count++
|
||||
previous = current
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return Streak.Couple(
|
||||
count = count,
|
||||
lastActiveDate = latest,
|
||||
includesToday = includesToday
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Personal streak: consecutive days on which the user answered or completed a private action,
|
||||
* counting backward from [today]. Does not depend on partner activity.
|
||||
*/
|
||||
internal fun computePersonalStreak(
|
||||
userDays: Set<LocalDate>,
|
||||
today: LocalDate
|
||||
): Streak.Personal {
|
||||
if (userDays.isEmpty()) {
|
||||
return Streak.Personal()
|
||||
}
|
||||
|
||||
val sorted = userDays.toSortedSet().toList()
|
||||
val latest = sorted.last()
|
||||
val includesToday = latest == today || latest == today
|
||||
|
||||
// If the latest personal day is older than yesterday, the streak is broken.
|
||||
if (latest.isBefore(today.minusDays(1))) {
|
||||
return Streak.Personal(
|
||||
count = 0,
|
||||
lastActiveDate = null,
|
||||
includesToday = false
|
||||
)
|
||||
}
|
||||
|
||||
var count = 1
|
||||
var previous = latest
|
||||
for (i in sorted.size - 2 downTo 0) {
|
||||
val current = sorted[i]
|
||||
if (ChronoUnit.DAYS.between(current, previous) == 1L) {
|
||||
count++
|
||||
previous = current
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return Streak.Personal(
|
||||
count = count,
|
||||
lastActiveDate = latest,
|
||||
includesToday = includesToday
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekly rhythm: consecutive rolling 7-day windows containing at least
|
||||
* [Streak.WeeklyRhythm.THRESHOLD] couple-action days.
|
||||
*/
|
||||
internal fun computeWeeklyRhythm(
|
||||
coupleActionDays: Set<LocalDate>,
|
||||
today: LocalDate
|
||||
): Streak.WeeklyRhythm {
|
||||
if (coupleActionDays.isEmpty()) {
|
||||
return Streak.WeeklyRhythm()
|
||||
}
|
||||
|
||||
val windowSize = Streak.WeeklyRhythm.WINDOW_DAYS
|
||||
val threshold = Streak.WeeklyRhythm.THRESHOLD
|
||||
|
||||
// Start with the window ending on the most recent couple-action day, or today if none.
|
||||
val latestAction = coupleActionDays.maxOrNull() ?: today
|
||||
val firstWindowEnd = if (latestAction.isAfter(today)) today else latestAction
|
||||
|
||||
// Count how many windows are satisfied consecutively, moving backward 7 days at a time.
|
||||
var count = 0
|
||||
var windowEnd = firstWindowEnd
|
||||
while (true) {
|
||||
val windowStart = windowEnd.minusDays((windowSize - 1).toLong())
|
||||
val actionsInWindow = coupleActionDays.count { !it.isBefore(windowStart) && !it.isAfter(windowEnd) }
|
||||
if (actionsInWindow >= threshold) {
|
||||
count++
|
||||
windowEnd = windowEnd.minusDays(windowSize.toLong())
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
val includesToday = if (count > 0) {
|
||||
val currentWindowStart = today.minusDays((windowSize - 1).toLong())
|
||||
coupleActionDays.count { !it.isBefore(currentWindowStart) && !it.isAfter(today) } >= threshold
|
||||
} else false
|
||||
|
||||
return Streak.WeeklyRhythm(
|
||||
count = count,
|
||||
lastActiveDate = latestAction,
|
||||
includesToday = includesToday
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a streak repair is available today and the due date for it.
|
||||
*
|
||||
* Repair is available when:
|
||||
* - Today is already a couple-action day (both partners showed up today).
|
||||
* - The couple streak would otherwise be broken by exactly one missed day.
|
||||
* - No repair has been used in the current 7-day window.
|
||||
*/
|
||||
internal fun computeRepair(
|
||||
coupleActionDays: Set<LocalDate>,
|
||||
userDays: Set<LocalDate>,
|
||||
partnerDays: Set<LocalDate>,
|
||||
today: LocalDate,
|
||||
lastRepairDate: LocalDate? = null
|
||||
): Repair {
|
||||
if (today !in coupleActionDays) {
|
||||
return Repair(false, null)
|
||||
}
|
||||
|
||||
// Find the first break in the strict couple streak counting back from today.
|
||||
// If the break is exactly one missed day, we can repair it.
|
||||
val sorted = coupleActionDays.toSortedSet().toList()
|
||||
var previous = today
|
||||
var gapFound: LocalDate? = null
|
||||
for (i in sorted.size - 2 downTo 0) {
|
||||
val current = sorted[i]
|
||||
val gap = ChronoUnit.DAYS.between(current, previous)
|
||||
if (gap == 1L) {
|
||||
previous = current
|
||||
} else if (gap == 2L) {
|
||||
gapFound = previous.minusDays(1)
|
||||
break
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If there is no gap or the gap is not exactly one day, no repair is needed/possible.
|
||||
val repairDueDate = gapFound ?: return Repair(false, null)
|
||||
|
||||
// Only allow one repair per 7-day window anchored from today.
|
||||
if (lastRepairDate != null && !lastRepairDate.isBefore(today.minusDays(7))) {
|
||||
return Repair(false, null)
|
||||
}
|
||||
|
||||
return Repair(true, repairDueDate)
|
||||
}
|
||||
|
||||
internal data class Repair(val canRepair: Boolean, val repairDueDate: LocalDate?)
|
||||
|
||||
/**
|
||||
* Returns milestone copy for the current streak count, or null if no milestone applies.
|
||||
* Milestones: 1, 3, 7, 14, 30.
|
||||
*/
|
||||
internal fun milestoneFor(count: Int, includesToday: Boolean): String? {
|
||||
if (count <= 0 || !includesToday) return null
|
||||
return when (count) {
|
||||
1 -> "You started a rhythm."
|
||||
3 -> "Three nights showing up."
|
||||
7 -> "One week of small conversations."
|
||||
14 -> "Two weeks of staying connected."
|
||||
30 -> "Thirty days of making space for each other."
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
package app.closer.domain.model
|
||||
|
||||
import java.time.LocalDate
|
||||
|
||||
/**
|
||||
* Models the different streak types tracked for a couple in Closer.
|
||||
*
|
||||
* All dates are [LocalDate] because streak days are bucketed by the user's local
|
||||
* calendar day (the app already uses a local date key for daily questions).
|
||||
*/
|
||||
sealed class Streak {
|
||||
|
||||
/**
|
||||
* Total number of consecutive days or windows this streak has been active.
|
||||
* For [Couple] and [Personal] this is a count of consecutive days.
|
||||
* For [WeeklyRhythm] this is a count of consecutive 7-day windows.
|
||||
*/
|
||||
abstract val count: Int
|
||||
|
||||
/**
|
||||
* The most recent date that contributed to this streak, if any.
|
||||
*/
|
||||
abstract val lastActiveDate: LocalDate?
|
||||
|
||||
/**
|
||||
* True if the streak includes today. Used to decide whether a streak is
|
||||
* "alive" right now or just historical.
|
||||
*/
|
||||
abstract val includesToday: Boolean
|
||||
|
||||
/**
|
||||
* Both partners completed a shared action on the same local day.
|
||||
*/
|
||||
data class Couple(
|
||||
override val count: Int = 0,
|
||||
override val lastActiveDate: LocalDate? = null,
|
||||
override val includesToday: Boolean = false
|
||||
) : Streak()
|
||||
|
||||
/**
|
||||
* The current user completed a private action on their own local day.
|
||||
*/
|
||||
data class Personal(
|
||||
override val count: Int = 0,
|
||||
override val lastActiveDate: LocalDate? = null,
|
||||
override val includesToday: Boolean = false
|
||||
) : Streak()
|
||||
|
||||
/**
|
||||
* The couple completed at least [WEEKLY_RHYTHM_THRESHOLD] shared actions
|
||||
* in a rolling 7-day window.
|
||||
*/
|
||||
data class WeeklyRhythm(
|
||||
override val count: Int = 0,
|
||||
override val lastActiveDate: LocalDate? = null,
|
||||
override val includesToday: Boolean = false
|
||||
) : Streak() {
|
||||
companion object {
|
||||
const val WINDOW_DAYS = 7
|
||||
const val THRESHOLD = 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome of calculating all streaks for the current user and their partner.
|
||||
*
|
||||
* @property coupleStreak both partners answered/completed a shared action today
|
||||
* @property personalStreak the current user answered/completed a private action today
|
||||
* @property weeklyRhythm couple completed 3+ shared actions in the last 7 days
|
||||
* @property milestoneCopy warm milestone text, or null when no milestone applies
|
||||
* @property canRepair true if a missed day can be repaired today
|
||||
* @property repairDueDate the last day that can be repaired, or null if none
|
||||
*/
|
||||
data class StreakResult(
|
||||
val coupleStreak: Streak.Couple,
|
||||
val personalStreak: Streak.Personal,
|
||||
val weeklyRhythm: Streak.WeeklyRhythm,
|
||||
val milestoneCopy: String? = null,
|
||||
val canRepair: Boolean = false,
|
||||
val repairDueDate: LocalDate? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Raw input describing which local days each partner was active.
|
||||
*
|
||||
* @property userDates days the current user answered or completed a private action
|
||||
* @property partnerDates days the partner answered or completed a shared action
|
||||
* @property coupleActionDates days both partners completed a shared action
|
||||
* @property today the local date to evaluate against (injected for testability)
|
||||
* @property lastRepairDate the most recent local date a streak repair was used, if any
|
||||
*/
|
||||
data class StreakInput(
|
||||
val userDates: List<LocalDate>,
|
||||
val partnerDates: List<LocalDate>,
|
||||
val coupleActionDates: List<LocalDate>,
|
||||
val today: LocalDate,
|
||||
val lastRepairDate: LocalDate? = null
|
||||
)
|
||||
|
|
@ -125,6 +125,9 @@ private fun HomeCallbacks.toActionHandler(): (HomeAction) -> Unit = { action ->
|
|||
HomeActionTarget.AnswerHistory -> onHistory()
|
||||
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
|
||||
HomeActionTarget.Settings -> onSettings()
|
||||
else -> {
|
||||
// Batch 4 streak changes do not add new action targets.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,7 +147,7 @@ private fun HomeContent(
|
|||
onRefresh: () -> Unit
|
||||
) {
|
||||
val callbacks = remember(
|
||||
onDailyQuestion, onReminder, onReveal, onFollowUp,
|
||||
onDailyQuestion, onReminder, onReveal, onFollowUp, onPendingAction,
|
||||
onPacks, onCategory, onHistory, onSettings, onInvite, onRefresh
|
||||
) {
|
||||
HomeCallbacks(
|
||||
|
|
@ -152,6 +155,7 @@ private fun HomeContent(
|
|||
onReminder = onReminder,
|
||||
onReveal = onReveal,
|
||||
onFollowUp = onFollowUp,
|
||||
onPendingAction = onPendingAction,
|
||||
onPacks = onPacks,
|
||||
onCategory = onCategory,
|
||||
onHistory = onHistory,
|
||||
|
|
@ -161,6 +165,10 @@ private fun HomeContent(
|
|||
)
|
||||
}
|
||||
val onActionSelected = callbacks.toActionHandler()
|
||||
val onPendingActionSelected: (PendingActionCard) -> Unit = { card ->
|
||||
card.action()
|
||||
callbacks.onPendingAction(card)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -192,6 +200,13 @@ private fun HomeContent(
|
|||
state.isLoading -> LoadingHomeCard()
|
||||
state.error != null -> ErrorHomeCard(message = state.error, onRefresh = onRefresh)
|
||||
else -> {
|
||||
state.pendingActions.takeIf { it.isNotEmpty() }?.let { pending ->
|
||||
WaitingForYouSection(
|
||||
actions = pending,
|
||||
onAction = onPendingActionSelected
|
||||
)
|
||||
}
|
||||
|
||||
state.primaryAction?.let { action ->
|
||||
PrimaryHomeActionCard(
|
||||
action = action,
|
||||
|
|
@ -625,6 +640,11 @@ private fun HomeActionTone.actionColors(): HomeActionColors =
|
|||
accent = MaterialTheme.colorScheme.primary,
|
||||
deep = CloserPalette.PurpleDeep
|
||||
)
|
||||
HomeActionTone.Pending -> HomeActionColors(
|
||||
soft = CloserPalette.PurpleSoft,
|
||||
accent = MaterialTheme.colorScheme.primary,
|
||||
deep = CloserPalette.PurpleDeep
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -41,7 +41,12 @@ enum class HomeActionTarget {
|
|||
DailyQuestion,
|
||||
AnswerHistory,
|
||||
QuestionPacks,
|
||||
Settings
|
||||
Settings,
|
||||
AnswerReveal,
|
||||
Game,
|
||||
Challenge,
|
||||
DatePlan,
|
||||
MemoryCapsule
|
||||
}
|
||||
|
||||
enum class HomeActionTone {
|
||||
|
|
@ -51,7 +56,8 @@ enum class HomeActionTone {
|
|||
Ritual,
|
||||
Starter,
|
||||
Pack,
|
||||
Utility
|
||||
Utility,
|
||||
Pending
|
||||
}
|
||||
|
||||
data class HomeAction(
|
||||
|
|
@ -65,6 +71,13 @@ data class HomeAction(
|
|||
val categoryId: String? = null
|
||||
)
|
||||
|
||||
data class PendingActionCard(
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val priority: Int,
|
||||
val action: () -> Unit
|
||||
)
|
||||
|
||||
enum class DailyQuestionState {
|
||||
UNANSWERED,
|
||||
USER_ANSWERED_PARTNER_PENDING,
|
||||
|
|
@ -91,7 +104,8 @@ data class HomeUiState(
|
|||
val dailyQuestionState: DailyQuestionState = DailyQuestionState.UNANSWERED,
|
||||
val hasPartnerAnsweredToday: Boolean = false,
|
||||
val partnerAnsweredQuestionId: String? = null,
|
||||
val hasRevealedToday: Boolean = false
|
||||
val hasRevealedToday: Boolean = false,
|
||||
val pendingActions: List<PendingActionCard> = emptyList()
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
|
|
@ -306,16 +320,108 @@ class HomeViewModel @Inject constructor(
|
|||
|
||||
private fun HomeUiState.withHomeActions(): HomeUiState {
|
||||
if (isLoading || error != null) {
|
||||
return copy(primaryAction = null, secondaryActions = emptyList())
|
||||
return copy(primaryAction = null, secondaryActions = emptyList(), pendingActions = emptyList())
|
||||
}
|
||||
|
||||
val primary = buildPrimaryAction()
|
||||
val pending = buildPendingActions()
|
||||
return copy(
|
||||
primaryAction = primary,
|
||||
secondaryActions = buildSecondaryActions(primary)
|
||||
secondaryActions = buildSecondaryActions(primary),
|
||||
pendingActions = pending.take(3)
|
||||
)
|
||||
}
|
||||
|
||||
private fun HomeUiState.buildPendingActions(): List<PendingActionCard> {
|
||||
if (!isPaired) return emptyList()
|
||||
|
||||
val actions = mutableListOf<PendingActionCard>()
|
||||
|
||||
// 1. Reveal ready (highest priority)
|
||||
if (dailyQuestionState == DailyQuestionState.BOTH_ANSWERED) {
|
||||
actions += PendingActionCard(
|
||||
title = "Reveal is ready",
|
||||
subtitle = "Both of you answered tonight. Open it together.",
|
||||
priority = 1,
|
||||
action = {}
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Partner answered, waiting for user
|
||||
if (dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING) {
|
||||
actions += PendingActionCard(
|
||||
title = "Your partner answered",
|
||||
subtitle = "Answer tonight’s question to unlock the reveal.",
|
||||
priority = 2,
|
||||
action = {}
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Game waiting (placeholder until QuestionSessionRepository is wired)
|
||||
if (hasWaitingGame()) {
|
||||
actions += PendingActionCard(
|
||||
title = "Game waiting",
|
||||
subtitle = "Your turn to play a game together.",
|
||||
priority = 3,
|
||||
action = {}
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Challenge incomplete (placeholder until challenge data is wired)
|
||||
if (hasIncompleteChallenge()) {
|
||||
actions += PendingActionCard(
|
||||
title = "Challenge waiting",
|
||||
subtitle = "Today’s small step is ready for both of you.",
|
||||
priority = 4,
|
||||
action = {}
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Date reminder (placeholder until DatePlanRepository is wired)
|
||||
if (hasUpcomingDate()) {
|
||||
actions += PendingActionCard(
|
||||
title = "Date coming up",
|
||||
subtitle = "A planned moment is almost here.",
|
||||
priority = 5,
|
||||
action = {}
|
||||
)
|
||||
}
|
||||
|
||||
// 6. Capsule unlocked (placeholder until capsule data is wired)
|
||||
if (hasUnlockedCapsule()) {
|
||||
actions += PendingActionCard(
|
||||
title = "Capsule unlocked",
|
||||
subtitle = "A saved memory is ready to open together.",
|
||||
priority = 6,
|
||||
action = {}
|
||||
)
|
||||
}
|
||||
|
||||
return actions.sortedBy { it.priority }
|
||||
}
|
||||
|
||||
private fun HomeUiState.hasWaitingGame(): Boolean {
|
||||
// TODO(Batch 3+): Replace with real QuestionSessionRepository check.
|
||||
return false
|
||||
}
|
||||
|
||||
private fun HomeUiState.hasIncompleteChallenge(): Boolean {
|
||||
// TODO(Batch 3+): Replace with real ChallengeProgressState check.
|
||||
return false
|
||||
}
|
||||
|
||||
private fun HomeUiState.hasUpcomingDate(): Boolean {
|
||||
// TODO(Batch 3+): Replace with real DatePlanRepository check.
|
||||
return false
|
||||
}
|
||||
|
||||
private fun HomeUiState.hasUnlockedCapsule(): Boolean {
|
||||
// TODO(Batch 3+): Replace with real TimeCapsule repository check.
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun HomeUiState.buildPrimaryAction(): HomeAction {
|
||||
val dailyQuestionId = dailyQuestion?.id
|
||||
val userAnswered = dailyQuestionId != null && dailyQuestionId in answerStats.answeredQuestionIds
|
||||
|
|
|
|||
|
|
@ -0,0 +1,407 @@
|
|||
package app.closer.domain
|
||||
|
||||
import app.closer.domain.model.StreakInput
|
||||
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 StreakCalculatorTest {
|
||||
|
||||
private val today = LocalDate.of(2026, 6, 19)
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Couple streak
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `couple streak is zero when no shared actions`() {
|
||||
val input = StreakInput(
|
||||
userDates = emptyList(),
|
||||
partnerDates = emptyList(),
|
||||
coupleActionDates = emptyList(),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(0, result.coupleStreak.count)
|
||||
assertNull(result.coupleStreak.lastActiveDate)
|
||||
assertFalse(result.coupleStreak.includesToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `couple streak counts consecutive shared days including today`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(1), today.minusDays(2)),
|
||||
partnerDates = listOf(today, today.minusDays(1), today.minusDays(2)),
|
||||
coupleActionDates = listOf(today, today.minusDays(1), today.minusDays(2)),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(3, result.coupleStreak.count)
|
||||
assertEquals(today, result.coupleStreak.lastActiveDate)
|
||||
assertTrue(result.coupleStreak.includesToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `couple streak breaks when gap is larger than one day`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(3)),
|
||||
partnerDates = listOf(today, today.minusDays(3)),
|
||||
coupleActionDates = listOf(today, today.minusDays(3)),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(1, result.coupleStreak.count)
|
||||
assertEquals(today, result.coupleStreak.lastActiveDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `couple streak is counted from yesterday when today has no action`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today.minusDays(1), today.minusDays(2)),
|
||||
partnerDates = listOf(today.minusDays(1), today.minusDays(2)),
|
||||
coupleActionDates = listOf(today.minusDays(1), today.minusDays(2)),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(2, result.coupleStreak.count)
|
||||
assertEquals(today.minusDays(1), result.coupleStreak.lastActiveDate)
|
||||
assertFalse(result.coupleStreak.includesToday)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Personal streak
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `personal streak counts consecutive user-only days including today`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(1), today.minusDays(2)),
|
||||
partnerDates = emptyList(),
|
||||
coupleActionDates = emptyList(),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(3, result.personalStreak.count)
|
||||
assertEquals(today, result.personalStreak.lastActiveDate)
|
||||
assertTrue(result.personalStreak.includesToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `personal streak is independent of couple streak and breaks on missed day`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(1), today.minusDays(3)),
|
||||
partnerDates = emptyList(),
|
||||
coupleActionDates = emptyList(),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(2, result.personalStreak.count)
|
||||
assertEquals(today, result.personalStreak.lastActiveDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `personal streak resets when latest day is older than yesterday`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today.minusDays(2), today.minusDays(3)),
|
||||
partnerDates = emptyList(),
|
||||
coupleActionDates = emptyList(),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(0, result.personalStreak.count)
|
||||
assertNull(result.personalStreak.lastActiveDate)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Weekly rhythm
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `weekly rhythm is zero when couple actions are below threshold`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(1)),
|
||||
partnerDates = listOf(today, today.minusDays(1)),
|
||||
coupleActionDates = listOf(today, today.minusDays(1)),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(0, result.weeklyRhythm.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly rhythm counts one window when threshold met in last seven days`() {
|
||||
val actions = listOf(today, today.minusDays(2), today.minusDays(4))
|
||||
val input = StreakInput(
|
||||
userDates = actions,
|
||||
partnerDates = actions,
|
||||
coupleActionDates = actions,
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(1, result.weeklyRhythm.count)
|
||||
assertTrue(result.weeklyRhythm.includesToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `weekly rhythm counts consecutive windows`() {
|
||||
val actions = (
|
||||
listOf(today, today.minusDays(1), today.minusDays(2)) +
|
||||
listOf(today.minusDays(7), today.minusDays(8), today.minusDays(9))
|
||||
)
|
||||
val input = StreakInput(
|
||||
userDates = actions,
|
||||
partnerDates = actions,
|
||||
coupleActionDates = actions,
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(2, result.weeklyRhythm.count)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Milestones
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `milestone copy returns correct text for day one`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today),
|
||||
partnerDates = listOf(today),
|
||||
coupleActionDates = listOf(today),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals("You started a rhythm.", result.milestoneCopy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `milestone copy returns correct text for day three`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(1), today.minusDays(2)),
|
||||
partnerDates = listOf(today, today.minusDays(1), today.minusDays(2)),
|
||||
coupleActionDates = listOf(today, today.minusDays(1), today.minusDays(2)),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals("Three nights showing up.", result.milestoneCopy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `milestone copy returns correct text for day seven`() {
|
||||
val dates = (0..6).map { today.minusDays(it.toLong()) }
|
||||
val input = StreakInput(
|
||||
userDates = dates,
|
||||
partnerDates = dates,
|
||||
coupleActionDates = dates,
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals("One week of small conversations.", result.milestoneCopy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `milestone copy returns correct text for day thirty`() {
|
||||
val dates = (0..29).map { today.minusDays(it.toLong()) }
|
||||
val input = StreakInput(
|
||||
userDates = dates,
|
||||
partnerDates = dates,
|
||||
coupleActionDates = dates,
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals("Thirty days of making space for each other.", result.milestoneCopy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `milestone copy is null for non milestone days`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(1)),
|
||||
partnerDates = listOf(today, today.minusDays(1)),
|
||||
coupleActionDates = listOf(today, today.minusDays(1)),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertNull(result.milestoneCopy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `milestone copy is null when streak does not include today`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today.minusDays(1), today.minusDays(2), today.minusDays(3)),
|
||||
partnerDates = listOf(today.minusDays(1), today.minusDays(2), today.minusDays(3)),
|
||||
coupleActionDates = listOf(today.minusDays(1), today.minusDays(2), today.minusDays(3)),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertNull(result.milestoneCopy)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Repair
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `repair is available when yesterday was missed and today is a couple day`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(2)),
|
||||
partnerDates = listOf(today, today.minusDays(2)),
|
||||
coupleActionDates = listOf(today, today.minusDays(2)),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertTrue(result.canRepair)
|
||||
assertEquals(today.minusDays(1), result.repairDueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repair is not available when no day was missed`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(1), today.minusDays(2)),
|
||||
partnerDates = listOf(today, today.minusDays(1), today.minusDays(2)),
|
||||
coupleActionDates = listOf(today, today.minusDays(1), today.minusDays(2)),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertFalse(result.canRepair)
|
||||
assertNull(result.repairDueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repair is not available when today is not a couple day`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today.minusDays(2)),
|
||||
partnerDates = listOf(today.minusDays(2)),
|
||||
coupleActionDates = listOf(today.minusDays(2)),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertFalse(result.canRepair)
|
||||
assertNull(result.repairDueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repair is not available more than once per seven day window`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(2), today.minusDays(5)),
|
||||
partnerDates = listOf(today, today.minusDays(2), today.minusDays(5)),
|
||||
coupleActionDates = listOf(today, today.minusDays(2), today.minusDays(5)),
|
||||
today = today,
|
||||
lastRepairDate = today.minusDays(3)
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertFalse(result.canRepair)
|
||||
assertNull(result.repairDueDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repair is available again after seven days since last repair`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(2)),
|
||||
partnerDates = listOf(today, today.minusDays(2)),
|
||||
coupleActionDates = listOf(today, today.minusDays(2)),
|
||||
today = today,
|
||||
lastRepairDate = today.minusDays(8)
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertTrue(result.canRepair)
|
||||
assertEquals(today.minusDays(1), result.repairDueDate)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Edge cases and determinism
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `result is deterministic for unsorted input lists`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today.minusDays(2), today, today.minusDays(1)),
|
||||
partnerDates = listOf(today, today.minusDays(2), today.minusDays(1)),
|
||||
coupleActionDates = listOf(today.minusDays(1), today.minusDays(2), today),
|
||||
today = today
|
||||
)
|
||||
|
||||
val first = StreakCalculator.calculate(input)
|
||||
val second = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(first, second)
|
||||
assertEquals(3, first.coupleStreak.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `couple streak and personal streak do not conflict`() {
|
||||
val input = StreakInput(
|
||||
userDates = listOf(today, today.minusDays(1), today.minusDays(2), today.minusDays(3)),
|
||||
partnerDates = listOf(today, today.minusDays(1)),
|
||||
coupleActionDates = listOf(today, today.minusDays(1)),
|
||||
today = today
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertEquals(2, result.coupleStreak.count)
|
||||
assertEquals(4, result.personalStreak.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timezone behavior uses local date only`() {
|
||||
// The calculator itself only sees LocalDate; callers must convert.
|
||||
// This test documents that behavior.
|
||||
val localToday = LocalDate.of(2026, 6, 19)
|
||||
val input = StreakInput(
|
||||
userDates = listOf(localToday),
|
||||
partnerDates = listOf(localToday),
|
||||
coupleActionDates = listOf(localToday),
|
||||
today = localToday
|
||||
)
|
||||
|
||||
val result = StreakCalculator.calculate(input)
|
||||
|
||||
assertTrue(result.coupleStreak.includesToday)
|
||||
assertEquals(localToday, result.coupleStreak.lastActiveDate)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue