fix(streak): gentle day-based streak with a grace day + earlier milestones

The live couple streak used a raw 48h rule that reset on any 48h gap AND
inflated the count by incrementing on every answer (several a day counted
several times). Replace with StreakCalculator.nextCount: once per calendar day,
one forgiving grace day (a single missed day still continues), reset only after
2+ missed days; skip the redundant write on a same-day answer.

Celebration milestones now include the habit-building early tiers
(1/3/7/14/30/100/365), single-sourced from StreakCalculator so the count and the
celebration can't drift; the milestone dialog handles day 1 grammatically.

Tested (StreakCountTest): same-day idempotency, consecutive, grace, reset, and
partner timezone-skew (never breaks a same-boundary streak). Existing 24
StreakCalculator tests still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-06 20:46:14 -05:00
parent f0a05f779b
commit 368d8c455f
5 changed files with 156 additions and 10 deletions

View File

@ -105,10 +105,18 @@ class FirestoreCoupleDataSource @Inject constructor(
val lastAnsweredAt = snap.millisOrNull("lastAnsweredAt") ?: 0L val lastAnsweredAt = snap.millisOrNull("lastAnsweredAt") ?: 0L
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val current = (snap.getLong("streakCount") ?: 0L).toInt() 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<Unit> { cont -> suspendCancellableCoroutine<Unit> { cont ->
coupleRef(coupleId).set( coupleRef(coupleId).set(
mapOf("streakCount" to newStreak, "lastAnsweredAt" to now), mapOf("streakCount" to next.newStreak, "lastAnsweredAt" to now),
SetOptions.merge() SetOptions.merge()
) )
.addOnSuccessListener { cont.resume(Unit) } .addOnSuccessListener { cont.resume(Unit) }
@ -143,8 +151,4 @@ class FirestoreCoupleDataSource @Inject constructor(
is Timestamp -> raw.toDate().time is Timestamp -> raw.toDate().time
else -> null else -> null
} }
companion object {
private const val TWO_DAYS_MS = 48L * 60 * 60 * 1000
}
} }

View File

@ -3,7 +3,9 @@ package app.closer.domain
import app.closer.domain.model.Streak import app.closer.domain.model.Streak
import app.closer.domain.model.StreakInput import app.closer.domain.model.StreakInput
import app.closer.domain.model.StreakResult import app.closer.domain.model.StreakResult
import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
/** /**
@ -263,4 +265,48 @@ object StreakCalculator {
else -> null 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 }
} }

View File

@ -1853,17 +1853,18 @@ internal fun StreakMilestoneDialog(
) )
Spacer(Modifier.height(18.dp)) Spacer(Modifier.height(18.dp))
Text( Text(
text = "$milestone nights together", text = if (milestone == 1) "Your first night together" else "$milestone nights together",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = SettingsInk, color = SettingsInk,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
val nights = if (milestone == 1) "your first night" else "$milestone nights running"
Text( Text(
text = partnerName?.takeIf { it.isNotBlank() } 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." } ?.let { "You and $it keep showing up for each other — $nights. The small moments are adding up to something." }
?: "You keep showing up — $milestone nights running. 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, style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted, color = SettingsMuted,
textAlign = TextAlign.Center textAlign = TextAlign.Center

View File

@ -1019,7 +1019,9 @@ class HomeViewModel @Inject constructor(
companion object { companion object {
private const val TAG = "HomeViewModel" 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. // How many recent completed dates the reflection nudge scans for an un-reflected one.
private const val RECENT_DATES_FOR_REFLECTION_NUDGE = 10 private const val RECENT_DATES_FOR_REFLECTION_NUDGE = 10
} }

View File

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