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:
parent
f0a05f779b
commit
368d8c455f
|
|
@ -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<Unit> { 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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue