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:
null 2026-06-19 22:25:47 -05:00
parent 0b619ee7ba
commit c38e83b8ee
5 changed files with 904 additions and 6 deletions

View File

@ -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
}
}
}

View File

@ -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
)

View File

@ -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

View File

@ -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 tonights 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 = "Todays 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

View File

@ -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)
}
}