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