diff --git a/app/src/main/java/app/closer/domain/StreakCalculator.kt b/app/src/main/java/app/closer/domain/StreakCalculator.kt new file mode 100644 index 00000000..50f673a6 --- /dev/null +++ b/app/src/main/java/app/closer/domain/StreakCalculator.kt @@ -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, + 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, + 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, + 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, + userDays: Set, + partnerDays: Set, + 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 + } + } +} diff --git a/app/src/main/java/app/closer/domain/model/Streak.kt b/app/src/main/java/app/closer/domain/model/Streak.kt new file mode 100644 index 00000000..af76727f --- /dev/null +++ b/app/src/main/java/app/closer/domain/model/Streak.kt @@ -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, + val partnerDates: List, + val coupleActionDates: List, + val today: LocalDate, + val lastRepairDate: LocalDate? = null +) diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 7489c30b..bd6514f6 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -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 diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 4f7ad11a..231ae12a 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -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 = 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 { + if (!isPaired) return emptyList() + + val actions = mutableListOf() + + // 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 diff --git a/app/src/test/java/app/closer/domain/StreakCalculatorTest.kt b/app/src/test/java/app/closer/domain/StreakCalculatorTest.kt new file mode 100644 index 00000000..1420d20e --- /dev/null +++ b/app/src/test/java/app/closer/domain/StreakCalculatorTest.kt @@ -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) + } +}