diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt index 1d83cc68..3016a950 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt @@ -105,10 +105,18 @@ class FirestoreCoupleDataSource @Inject constructor( val lastAnsweredAt = snap.millisOrNull("lastAnsweredAt") ?: 0L val now = System.currentTimeMillis() val current = (snap.getLong("streakCount") ?: 0L).toInt() - val newStreak = if (lastAnsweredAt == 0L || now - lastAnsweredAt > TWO_DAYS_MS) 1 else current + 1 + // Gentle streak: once per calendar day, one forgiving grace day (see StreakCalculator). + // Replaces the old raw-48h rule that inflated the count on every answer. + val next = app.closer.domain.StreakCalculator.nextCount( + lastAnsweredAtMillis = lastAnsweredAt.takeIf { it > 0L }, + nowMillis = now, + currentStreak = current + ) + // Same-day answer → nothing to change; skip the redundant write. + if (!next.changed) return suspendCancellableCoroutine { cont -> coupleRef(coupleId).set( - mapOf("streakCount" to newStreak, "lastAnsweredAt" to now), + mapOf("streakCount" to next.newStreak, "lastAnsweredAt" to now), SetOptions.merge() ) .addOnSuccessListener { cont.resume(Unit) } @@ -143,8 +151,4 @@ class FirestoreCoupleDataSource @Inject constructor( is Timestamp -> raw.toDate().time else -> null } - - companion object { - private const val TWO_DAYS_MS = 48L * 60 * 60 * 1000 - } } diff --git a/app/src/main/java/app/closer/domain/StreakCalculator.kt b/app/src/main/java/app/closer/domain/StreakCalculator.kt index e7d89970..c15d0709 100644 --- a/app/src/main/java/app/closer/domain/StreakCalculator.kt +++ b/app/src/main/java/app/closer/domain/StreakCalculator.kt @@ -3,7 +3,9 @@ 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.Instant import java.time.LocalDate +import java.time.ZoneId import java.time.temporal.ChronoUnit /** @@ -263,4 +265,48 @@ object StreakCalculator { else -> null } } + + // ── Couple-doc streak count (millis model) ────────────────────────────────── + // The live couple streak persists only `lastAnsweredAt` + `streakCount` (not a per-day history), + // so this is the gentle-streak count that path uses. It replaces the old raw-48h rule, which reset + // on any 48h gap AND inflated the streak by incrementing on every answer (even several a day). + + /** Milestone tiers that fire a one-time celebration, smallest → largest. Early ones build the habit. */ + val COUNT_MILESTONES = listOf(1, 3, 7, 14, 30, 100, 365) + + data class NextCount(val newStreak: Int, val changed: Boolean) + + /** Grace: a gap of up to this many calendar days still continues the streak. */ + private const val GRACE_DAYS = 2L + + /** + * The next couple-streak count after an answer at [nowMillis], given the couple's stored + * [lastAnsweredAtMillis] and [currentStreak]. Calendar days in [zone]: + * - same day → unchanged (the day is already counted; idempotent, caller can skip the write) + * - next day, or one missed grace day → +1 + * - two or more missed days → reset to 1 + * - no prior answer → 1 + */ + fun nextCount( + lastAnsweredAtMillis: Long?, + nowMillis: Long, + currentStreak: Int, + zone: ZoneId = ZoneId.systemDefault(), + ): NextCount { + if (lastAnsweredAtMillis == null || lastAnsweredAtMillis <= 0L) { + return NextCount(newStreak = 1, changed = true) + } + val lastDate = Instant.ofEpochMilli(lastAnsweredAtMillis).atZone(zone).toLocalDate() + val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate() + val gap = ChronoUnit.DAYS.between(lastDate, today) + return when { + gap <= 0L -> NextCount(newStreak = currentStreak.coerceAtLeast(1), changed = false) + gap <= GRACE_DAYS -> NextCount(newStreak = currentStreak.coerceAtLeast(0) + 1, changed = true) + else -> NextCount(newStreak = 1, changed = true) + } + } + + /** The milestone just crossed by moving from [previousStreak] to [newStreak], or null. */ + fun crossedCountMilestone(previousStreak: Int, newStreak: Int): Int? = + COUNT_MILESTONES.firstOrNull { it in (previousStreak + 1)..newStreak } } 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 3a2bd583..c88d9ab5 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -1853,17 +1853,18 @@ internal fun StreakMilestoneDialog( ) Spacer(Modifier.height(18.dp)) Text( - text = "$milestone nights together", + text = if (milestone == 1) "Your first night together" else "$milestone nights together", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.SemiBold, color = SettingsInk, textAlign = TextAlign.Center ) Spacer(Modifier.height(8.dp)) + val nights = if (milestone == 1) "your first night" else "$milestone nights running" Text( text = partnerName?.takeIf { it.isNotBlank() } - ?.let { "You and $it keep showing up for each other — $milestone nights running. The small moments are adding up to something." } - ?: "You keep showing up — $milestone nights running. The small moments are adding up to something.", + ?.let { "You and $it keep showing up for each other — $nights. The small moments are adding up to something." } + ?: "You keep showing up — $nights. The small moments are adding up to something.", style = MaterialTheme.typography.bodyMedium, color = SettingsMuted, textAlign = TextAlign.Center 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 e73d3554..ec320deb 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -1019,7 +1019,9 @@ class HomeViewModel @Inject constructor( companion object { private const val TAG = "HomeViewModel" - private val STREAK_MILESTONES = listOf(7, 30, 100, 365) + // Single-sourced from StreakCalculator so the gentle-streak tiers (1/3/7/14/30/100/365) + // stay in step between the count logic and the celebration. + private val STREAK_MILESTONES = app.closer.domain.StreakCalculator.COUNT_MILESTONES // How many recent completed dates the reflection nudge scans for an un-reflected one. private const val RECENT_DATES_FOR_REFLECTION_NUDGE = 10 } diff --git a/app/src/test/java/app/closer/domain/StreakCountTest.kt b/app/src/test/java/app/closer/domain/StreakCountTest.kt new file mode 100644 index 00000000..32c48ff5 --- /dev/null +++ b/app/src/test/java/app/closer/domain/StreakCountTest.kt @@ -0,0 +1,93 @@ +package app.closer.domain + +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 +import java.time.ZoneId + +/** + * Tests the gentle couple-doc streak count (millis model) that the live [updateStreak] path uses. + */ +class StreakCountTest { + + private val utc = ZoneId.of("UTC") + + /** Epoch millis for local noon on the given date in [zone]. */ + private fun noon(date: LocalDate, zone: ZoneId = utc): Long = + date.atTime(12, 0).atZone(zone).toInstant().toEpochMilli() + + @Test + fun `first ever answer starts the streak at 1`() { + val r = StreakCalculator.nextCount(lastAnsweredAtMillis = null, nowMillis = noon(LocalDate.of(2026, 7, 6)), currentStreak = 0, zone = utc) + assertEquals(1, r.newStreak) + assertTrue(r.changed) + } + + @Test + fun `second answer the same day does not change the streak`() { + val day = LocalDate.of(2026, 7, 6) + val morning = day.atTime(8, 0).atZone(utc).toInstant().toEpochMilli() + val evening = day.atTime(21, 0).atZone(utc).toInstant().toEpochMilli() + val r = StreakCalculator.nextCount(lastAnsweredAtMillis = morning, nowMillis = evening, currentStreak = 5, zone = utc) + assertEquals(5, r.newStreak) + assertFalse("same-day answer must not bump the streak", r.changed) + } + + @Test + fun `consecutive day increments`() { + val r = StreakCalculator.nextCount( + lastAnsweredAtMillis = noon(LocalDate.of(2026, 7, 5)), + nowMillis = noon(LocalDate.of(2026, 7, 6)), + currentStreak = 5, zone = utc + ) + assertEquals(6, r.newStreak) + assertTrue(r.changed) + } + + @Test + fun `one missed grace day still continues the streak`() { + // answered the 5th, skipped the 6th, back on the 7th → grace keeps it going + val r = StreakCalculator.nextCount( + lastAnsweredAtMillis = noon(LocalDate.of(2026, 7, 5)), + nowMillis = noon(LocalDate.of(2026, 7, 7)), + currentStreak = 5, zone = utc + ) + assertEquals(6, r.newStreak) + } + + @Test + fun `two or more missed days resets to 1`() { + val r = StreakCalculator.nextCount( + lastAnsweredAtMillis = noon(LocalDate.of(2026, 7, 5)), + nowMillis = noon(LocalDate.of(2026, 7, 8)), + currentStreak = 20, zone = utc + ) + assertEquals(1, r.newStreak) + assertTrue(r.changed) + } + + @Test + fun `partner timezone skew never breaks a same-boundary streak`() { + // Partner A answers at 23:00 in Tokyo (UTC+9) on the 6th; partner B at 01:00 in New York + // (UTC-5) — a real instant ~3h later but a different wall-clock day in each zone. Using each + // device's own local date, the gap is at most 1 (grace), never a reset. + val tokyo = ZoneId.of("Asia/Tokyo") + val ny = ZoneId.of("America/New_York") + val aInstant = LocalDate.of(2026, 7, 6).atTime(23, 0).atZone(tokyo).toInstant().toEpochMilli() + val bInstant = LocalDate.of(2026, 7, 6).atTime(11, 0).atZone(ny).toInstant().toEpochMilli() + // B's device computes with the NY local date of the later instant. + val r = StreakCalculator.nextCount(lastAnsweredAtMillis = aInstant, nowMillis = bInstant, currentStreak = 3, zone = ny) + assertTrue("skew must not reset the streak", r.newStreak >= 3) + } + + @Test + fun `crossed milestone reports the tier just reached`() { + assertEquals(1, StreakCalculator.crossedCountMilestone(previousStreak = 0, newStreak = 1)) + assertEquals(3, StreakCalculator.crossedCountMilestone(previousStreak = 2, newStreak = 3)) + assertEquals(7, StreakCalculator.crossedCountMilestone(previousStreak = 6, newStreak = 7)) + assertNull(StreakCalculator.crossedCountMilestone(previousStreak = 7, newStreak = 8)) + } +}