diff --git a/app/src/androidTest/java/nart/simpleanki/feature/paywall/PaywallContentTest.kt b/app/src/androidTest/java/nart/simpleanki/feature/paywall/PaywallContentTest.kt index 46a69e2..52c57d0 100644 --- a/app/src/androidTest/java/nart/simpleanki/feature/paywall/PaywallContentTest.kt +++ b/app/src/androidTest/java/nart/simpleanki/feature/paywall/PaywallContentTest.kt @@ -12,7 +12,8 @@ import org.junit.Rule import org.junit.Test class PaywallContentTest { - @get:Rule val rule = createComposeRule() + @get:Rule + val rule = createComposeRule() // FakeEntitlementRepository lives in unit-test sources (src/test/) and is not visible // from androidTest sources. Plans are inlined here to match DEFAULT_PLANS exactly. @@ -20,13 +21,21 @@ class PaywallContentTest { plans = listOf( PlanOption(PremiumTier.Annual, "azri_premium", "annual", "tokA", "$19.99", 19_990_000L), PlanOption(PremiumTier.Monthly, "azri_premium", "monthly", "tokM", "$2.99", 2_990_000L), - PlanOption(PremiumTier.Lifetime, "azri_premium_lifetime", null, null, "$49.99", 49_990_000L), + PlanOption( + PremiumTier.Lifetime, + "azri_premium_lifetime", + null, + null, + "$99.99", + 49_990_000L + ), ), selected = PremiumTier.Annual, loading = false, ) - @Test fun showsPlans_andTitle() { + @Test + fun showsPlans_andTitle() { rule.setContent { PaywallContent(state = loaded) } rule.onNodeWithText("Unlock Cloud Sync").assertIsDisplayed() rule.onNodeWithText("Annual").assertIsDisplayed() @@ -35,22 +44,31 @@ class PaywallContentTest { rule.onNodeWithText("Continue").assertIsDisplayed() } - @Test fun tappingPlan_selectsIt() { + @Test + fun tappingPlan_selectsIt() { var picked: PremiumTier? = null rule.setContent { PaywallContent(state = loaded, onSelect = { picked = it }) } rule.onNodeWithText("Lifetime").performClick() assertEquals(PremiumTier.Lifetime, picked) } - @Test fun continueAndRestore_fireCallbacks() { - var bought = false; var restored = false - rule.setContent { PaywallContent(state = loaded, onPurchase = { bought = true }, onRestore = { restored = true }) } + @Test + fun continueAndRestore_fireCallbacks() { + var bought = false + var restored = false + rule.setContent { + PaywallContent( + state = loaded, + onPurchase = { bought = true }, + onRestore = { restored = true }) + } rule.onNodeWithText("Continue").performClick() rule.onNodeWithText("Restore purchase").performClick() assertTrue(bought && restored) } - @Test fun plansUnavailable_showsRetry_andFiresCallback() { + @Test + fun plansUnavailable_showsRetry_andFiresCallback() { var retried = false rule.setContent { PaywallContent( diff --git a/app/src/main/java/nart/simpleanki/core/data/repository/StreakProvider.kt b/app/src/main/java/nart/simpleanki/core/data/repository/StreakProvider.kt new file mode 100644 index 0000000..5d54620 --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/data/repository/StreakProvider.kt @@ -0,0 +1,35 @@ +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import nart.simpleanki.core.domain.streak.Streak +import nart.simpleanki.core.domain.streak.StreakCalculator +import nart.simpleanki.core.domain.streak.localEpochDay +import java.util.TimeZone + +/** Derives the study [Streak] from the review logs. Stateless — pure derivation, nothing stored. */ +class StreakProvider( + private val reviewLogRepository: ReviewLogRepository, + private val now: () -> Long = { System.currentTimeMillis() }, + private val timeZone: TimeZone = TimeZone.getDefault(), +) { + /** Live streak for the home header — reacts to new review logs. */ + fun observeStreak(): Flow = + reviewLogRepository.observeLogs().map { logs -> + val days = logs.mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + StreakCalculator.compute(days, localEpochDay(now(), timeZone)) + } + + /** + * Streak treating today as studied — for the post-session summary, so it's correct even though + * the per-rating log append is fire-and-forget and may not have landed yet. + */ + suspend fun streakIncludingToday(): Streak { + val today = localEpochDay(now(), timeZone) + val days = reviewLogRepository.observeLogs().first() + .mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + .apply { add(today) } + return StreakCalculator.compute(days, today) + } +} diff --git a/app/src/main/java/nart/simpleanki/core/domain/streak/DayBucketing.kt b/app/src/main/java/nart/simpleanki/core/domain/streak/DayBucketing.kt new file mode 100644 index 0000000..56e3d3f --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/domain/streak/DayBucketing.kt @@ -0,0 +1,18 @@ +package nart.simpleanki.core.domain.streak + +import java.util.Calendar +import java.util.TimeZone + +/** + * Civil-day number for [millis] in [timeZone] — a DST-safe day index where consecutive calendar + * dates always differ by exactly 1. Reads the local Y/M/D, then rebuilds them as a UTC midnight and + * divides by a day, so 23h/25h DST days don't shift the index. (minSdk 24 rules out java.time.) + */ +fun localEpochDay(millis: Long, timeZone: TimeZone = TimeZone.getDefault()): Long { + val local = Calendar.getInstance(timeZone).apply { timeInMillis = millis } + val utcMidnight = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { + clear() + set(local.get(Calendar.YEAR), local.get(Calendar.MONTH), local.get(Calendar.DAY_OF_MONTH)) + } + return utcMidnight.timeInMillis / 86_400_000L +} diff --git a/app/src/main/java/nart/simpleanki/core/domain/streak/StreakCalculator.kt b/app/src/main/java/nart/simpleanki/core/domain/streak/StreakCalculator.kt new file mode 100644 index 0000000..058763a --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/domain/streak/StreakCalculator.kt @@ -0,0 +1,39 @@ +package nart.simpleanki.core.domain.streak + +/** A study streak in days. */ +data class Streak(val current: Int, val longest: Int) + +object StreakCalculator { + /** + * [reviewDays] = civil-day indices on which >=1 review happened; [today] = today's civil-day index. + * Pure (no timezone logic — bucket with [localEpochDay] first). + * - current: if the user studied today, or studied yesterday (the streak is still alive through + * today), the length of the consecutive run ending there; otherwise 0 (hard reset). + * - longest: the longest consecutive run anywhere in the set. + */ + fun compute(reviewDays: Set, today: Long): Streak { + if (reviewDays.isEmpty()) return Streak(0, 0) + + var longest = 1 + var run = 1 + var prev: Long? = null + for (day in reviewDays.toSortedSet()) { + run = if (prev != null && day == prev + 1) run + 1 else 1 + if (run > longest) longest = run + prev = day + } + + val anchor = when { + reviewDays.contains(today) -> today + reviewDays.contains(today - 1) -> today - 1 + else -> return Streak(0, longest) + } + var current = 0 + var day = anchor + while (reviewDays.contains(day)) { + current++ + day-- + } + return Streak(current, longest) + } +} diff --git a/app/src/main/java/nart/simpleanki/di/AppModule.kt b/app/src/main/java/nart/simpleanki/di/AppModule.kt index 71f16cd..4328d4d 100644 --- a/app/src/main/java/nart/simpleanki/di/AppModule.kt +++ b/app/src/main/java/nart/simpleanki/di/AppModule.kt @@ -42,6 +42,7 @@ import nart.simpleanki.core.data.repository.CardRepository import nart.simpleanki.core.data.repository.DeckRepository import nart.simpleanki.core.data.repository.FolderRepository import nart.simpleanki.core.data.repository.ReviewLogRepository +import nart.simpleanki.core.data.repository.StreakProvider import nart.simpleanki.core.data.settings.DataStoreSettingsRepository import nart.simpleanki.core.data.settings.SettingsRepository import nart.simpleanki.core.data.sync.FirestoreSyncService @@ -148,6 +149,7 @@ val appModule = module { single { DeckRepository(get()) } single { CardRepository(get()) } single { ReviewLogRepository(get()) } + single { StreakProvider(get()) } // Sync single { FirestoreSyncService(get()) } @@ -197,6 +199,7 @@ val appModule = module { deckRepository = get(), settingsRepository = get(), reviewLogRepository = get(), + streakProvider = get(), logManager = get(), ) } @@ -216,7 +219,8 @@ val appModule = module { deckRepository = get(), folderRepository = get(), settingsRepository = get(), - entitlementRepository = get() + entitlementRepository = get(), + streakProvider = get() ) } viewModel { DailyGoalViewModel(settingsRepository = get()) } diff --git a/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt b/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt index 790ff71..aa544a0 100644 --- a/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt +++ b/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt @@ -69,6 +69,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import nart.simpleanki.core.domain.fsrs.QueueSortOrder import nart.simpleanki.core.domain.model.ColorOption import nart.simpleanki.ui.components.AzriCard @@ -124,6 +125,22 @@ fun StudyQueueContent( topBar = { TopAppBar( title = { Text("Today", fontWeight = FontWeight.Bold) }, + actions = { + if (state.currentStreak > 0) { + Row( + modifier = Modifier.padding(end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("🔥", fontSize = 18.sp) + Text( + state.currentStreak.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.background, ), @@ -653,6 +670,7 @@ private fun StudyQueuePreview() { QueueCardItem("c2", "mitochondria", "Biology", null), ), goalTotal = 30, studiedToday = 7, + currentStreak = 7, ), onStudyAll = {}, ) diff --git a/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt b/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt index bec6b8e..415e9e5 100644 --- a/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt +++ b/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt @@ -2,6 +2,7 @@ package nart.simpleanki.feature.queue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -9,6 +10,7 @@ import kotlinx.coroutines.flow.stateIn import nart.simpleanki.core.data.repository.CardRepository import nart.simpleanki.core.data.repository.DeckRepository import nart.simpleanki.core.data.repository.FolderRepository +import nart.simpleanki.core.data.repository.StreakProvider import nart.simpleanki.core.billing.EntitlementRepository import nart.simpleanki.core.billing.Entitlements import nart.simpleanki.core.data.settings.SettingsRepository @@ -68,6 +70,8 @@ data class StudyQueueUiState( val dailyGoalEnabled: Boolean = false, val goalTotal: Int = 0, val studiedToday: Int = 0, + val currentStreak: Int = 0, + val longestStreak: Int = 0, val sortOrder: QueueSortOrder = QueueSortOrder.DueDate, /** * Whether the user owns any (non-deleted) card at all — a *lifetime* signal, not a "today" @@ -93,10 +97,11 @@ class StudyQueueViewModel( folderRepository: FolderRepository, private val settingsRepository: SettingsRepository, private val entitlementRepository: EntitlementRepository, + private val streakProvider: StreakProvider, private val now: () -> Long = { System.currentTimeMillis() }, ) : ViewModel() { - val uiState: StateFlow = + private val baseState: Flow = combine( cardRepository.observeAllCards().withDueTicks(now), deckRepository.observeDecks(), @@ -176,6 +181,11 @@ class StudyQueueViewModel( hasAnyCards = hasAnyCards, ), ) + } + + val uiState: StateFlow = + combine(baseState, streakProvider.observeStreak()) { base, streak -> + base.copy(currentStreak = streak.current, longestStreak = streak.longest) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), diff --git a/app/src/main/java/nart/simpleanki/feature/study/SessionSummary.kt b/app/src/main/java/nart/simpleanki/feature/study/SessionSummary.kt index fd8c10f..b6c7ae8 100644 --- a/app/src/main/java/nart/simpleanki/feature/study/SessionSummary.kt +++ b/app/src/main/java/nart/simpleanki/feature/study/SessionSummary.kt @@ -57,7 +57,7 @@ private val RatingColors = mapOf( Rating.Easy to Color(0xFF00C7BE), ) -/** Study "session complete" summary — mirrors iOS SessionSummaryView (streak omitted). */ +/** Study "session complete" summary — mirrors iOS SessionSummaryView (with streak badge). */ @Composable fun SessionSummary(state: StudyUiState, onDone: () -> Unit) { val accuracy = sessionAccuracy(state.ratingCounts) @@ -107,6 +107,11 @@ fun SessionSummary(state: StudyUiState, onDone: () -> Unit) { RatingDistributionBar(state.ratingCounts) } + if (state.currentStreak > 0) { + Spacer(Modifier.height(24.dp)) + StreakBadge(current = state.currentStreak, longest = state.longestStreak) + } + Spacer(Modifier.weight(1f)) Button( @@ -132,6 +137,26 @@ private fun SessionStatsRow(reviewed: Int, accuracy: Int, durationLabel: String) } } +@Composable +private fun StreakBadge(current: Int, longest: Int) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "🔥 $current day${if (current == 1) "" else "s"} streak", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color(0xFFFF9500), + ) + if (longest > current) { + Spacer(Modifier.height(4.dp)) + Text( + "Longest: $longest", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Composable private fun StatItem(icon: ImageVector, iconColor: Color, value: String, label: String) { Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -198,6 +223,7 @@ private fun SessionSummaryGoodPreview() { state = StudyUiState( loading = false, finished = true, completed = 25, durationMillis = 312_000, ratingCounts = mapOf(Rating.Again to 4, Rating.Hard to 6, Rating.Good to 10, Rating.Easy to 5), + currentStreak = 7, longestStreak = 12, ), onDone = {}, ) diff --git a/app/src/main/java/nart/simpleanki/feature/study/StudyViewModel.kt b/app/src/main/java/nart/simpleanki/feature/study/StudyViewModel.kt index 2fec198..3e1ef0e 100644 --- a/app/src/main/java/nart/simpleanki/feature/study/StudyViewModel.kt +++ b/app/src/main/java/nart/simpleanki/feature/study/StudyViewModel.kt @@ -12,6 +12,7 @@ import nart.simpleanki.core.analytics.LogManager import nart.simpleanki.core.data.repository.CardRepository import nart.simpleanki.core.data.repository.DeckRepository import nart.simpleanki.core.data.repository.ReviewLogRepository +import nart.simpleanki.core.data.repository.StreakProvider import nart.simpleanki.core.data.settings.SettingsRepository import nart.simpleanki.core.data.settings.fsrsParameters import nart.simpleanki.core.domain.fsrs.IntervalFormatter @@ -34,6 +35,8 @@ data class StudyUiState( val finished: Boolean = false, /** Wall-clock millis from session start to finish; 0 until the session finishes, then stamped once. */ val durationMillis: Long = 0, + val currentStreak: Int = 0, + val longestStreak: Int = 0, ) /** @@ -51,6 +54,7 @@ class StudyViewModel( private val settingsRepository: SettingsRepository, private val reviewLogRepository: ReviewLogRepository, private val now: () -> Long = { System.currentTimeMillis() }, + private val streakProvider: StreakProvider = StreakProvider(reviewLogRepository, now), private val logManager: LogManager = LogManager(emptyList()), ) : ViewModel() { @@ -95,6 +99,7 @@ class StudyViewModel( finished = queue.isEmpty(), durationMillis = if (queue.isEmpty()) now() - sessionStartMillis else 0, ) + if (queue.isEmpty()) refreshSummaryStreak(includingToday = false) logManager.track(Event.ReviewSessionStart(deckId, folderId)) } @@ -106,6 +111,11 @@ class StudyViewModel( .mapValues { (_, dueMillis) -> IntervalFormatter.format(dueMillis - nowMillis) } } + private fun refreshSummaryStreak(includingToday: Boolean) = viewModelScope.launch { + val s = if (includingToday) streakProvider.streakIncludingToday() else streakProvider.observeStreak().first() + _uiState.value = _uiState.value.copy(currentStreak = s.current, longestStreak = s.longest) + } + fun onReveal() { if (_uiState.value.current != null) { _uiState.value = _uiState.value.copy(isRevealed = true, showFlipHint = false) @@ -133,6 +143,7 @@ class StudyViewModel( finished = next == null, durationMillis = if (next == null) ratedAt - sessionStartMillis else prev.durationMillis, ) + if (next == null) refreshSummaryStreak(includingToday = true) logManager.track(Event.CardRated(rating)) if (next == null) logManager.track(Event.ReviewSessionComplete(prev.completed + 1)) } diff --git a/app/src/test/java/nart/simpleanki/core/data/repository/StreakProviderTest.kt b/app/src/test/java/nart/simpleanki/core/data/repository/StreakProviderTest.kt new file mode 100644 index 0000000..8e98eda --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/data/repository/StreakProviderTest.kt @@ -0,0 +1,49 @@ +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import nart.simpleanki.core.data.local.ReviewLogEntity +import nart.simpleanki.core.domain.streak.Streak +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.TimeZone + +class StreakProviderTest { + + private val utc = TimeZone.getTimeZone("UTC") + private val day = 86_400_000L + private val today = 1_700_000_000_000L + + private fun logEntity(id: String, reviewMillis: Long) = ReviewLogEntity( + id = id, cardId = "c1", rating = 3, state = 2, due = 0, stability = 1.0, difficulty = 5.0, + elapsedDays = 0.0, lastElapsedDays = 0.0, scheduledDays = 0.0, review = reviewMillis, dirty = false, + ) + + private fun provider(dao: FakeReviewLogDao) = + StreakProvider(ReviewLogRepository(dao), now = { today }, timeZone = utc) + + @Test + fun observeStreak_countsConsecutiveDaysEndingToday() = runTest { + val dao = FakeReviewLogDao() + dao.insertAll(listOf( + logEntity("a", today), + logEntity("b", today), // same day, still counts once + logEntity("c", today - day), + logEntity("d", today - 2 * day), + )) + assertEquals(Streak(3, 3), provider(dao).observeStreak().first()) + } + + @Test + fun observeStreak_noLogs_isZero() = runTest { + assertEquals(Streak(0, 0), provider(FakeReviewLogDao()).observeStreak().first()) + } + + @Test + fun streakIncludingToday_countsTodayEvenIfNotYetLogged() = runTest { + val dao = FakeReviewLogDao() + dao.insertAll(listOf(logEntity("y", today - day))) // only yesterday logged + assertEquals(Streak(1, 1), provider(dao).observeStreak().first()) + assertEquals(Streak(2, 2), provider(dao).streakIncludingToday()) + } +} diff --git a/app/src/test/java/nart/simpleanki/core/domain/streak/DayBucketingTest.kt b/app/src/test/java/nart/simpleanki/core/domain/streak/DayBucketingTest.kt new file mode 100644 index 0000000..922ce3a --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/domain/streak/DayBucketingTest.kt @@ -0,0 +1,35 @@ +package nart.simpleanki.core.domain.streak + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Calendar +import java.util.TimeZone + +class DayBucketingTest { + + private val ny = TimeZone.getTimeZone("America/New_York") + + private fun millis(tz: TimeZone, year: Int, month0: Int, day: Int, hour: Int): Long = + Calendar.getInstance(tz).apply { + clear(); set(year, month0, day, hour, 0, 0) + }.timeInMillis + + @Test fun sameLocalDate_differentTimes_sameIndex() { + val morning = millis(ny, 2026, Calendar.JUNE, 5, 8) + val evening = millis(ny, 2026, Calendar.JUNE, 5, 23) + assertEquals(localEpochDay(morning, ny), localEpochDay(evening, ny)) + } + + @Test fun consecutiveDates_differByOne() { + val d1 = millis(ny, 2026, Calendar.JUNE, 5, 12) + val d2 = millis(ny, 2026, Calendar.JUNE, 6, 9) + assertEquals(localEpochDay(d1, ny) + 1, localEpochDay(d2, ny)) + } + + @Test fun acrossDstSpringForward_stillDiffersByOne() { + // US DST spring-forward 2026 is Sun Mar 8 (a 23-hour day). The civil-day index must still +1. + val before = millis(ny, 2026, Calendar.MARCH, 8, 12) + val after = millis(ny, 2026, Calendar.MARCH, 9, 12) + assertEquals(localEpochDay(before, ny) + 1, localEpochDay(after, ny)) + } +} diff --git a/app/src/test/java/nart/simpleanki/core/domain/streak/StreakCalculatorTest.kt b/app/src/test/java/nart/simpleanki/core/domain/streak/StreakCalculatorTest.kt new file mode 100644 index 0000000..347eb31 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/domain/streak/StreakCalculatorTest.kt @@ -0,0 +1,31 @@ +package nart.simpleanki.core.domain.streak + +import org.junit.Assert.assertEquals +import org.junit.Test + +class StreakCalculatorTest { + + @Test fun empty_isZeroZero() { + assertEquals(Streak(0, 0), StreakCalculator.compute(emptySet(), today = 100)) + } + + @Test fun onlyToday_isOneOne() { + assertEquals(Streak(1, 1), StreakCalculator.compute(setOf(100L), today = 100)) + } + + @Test fun consecutiveEndingToday_countsRun() { + assertEquals(Streak(3, 3), StreakCalculator.compute(setOf(98L, 99L, 100L), today = 100)) + } + + @Test fun studiedYesterdayNotToday_streakStillAlive() { + assertEquals(Streak(2, 2), StreakCalculator.compute(setOf(98L, 99L), today = 100)) + } + + @Test fun missedAFullDay_currentResetsButLongestPersists() { + assertEquals(Streak(1, 5), StreakCalculator.compute(setOf(90L, 91L, 92L, 93L, 94L, 100L), today = 100)) + } + + @Test fun neitherTodayNorYesterday_currentZero_longestFromPastRun() { + assertEquals(Streak(0, 2), StreakCalculator.compute(setOf(96L, 97L), today = 100)) + } +} diff --git a/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt b/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt index 65eef08..1b55ebc 100644 --- a/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt +++ b/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt @@ -9,12 +9,17 @@ import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import nart.simpleanki.core.data.local.ReviewLogEntity import nart.simpleanki.core.data.repository.CardRepository import nart.simpleanki.core.data.repository.DeckRepository import nart.simpleanki.core.data.repository.FakeCardDao import nart.simpleanki.core.data.repository.FakeDeckDao import nart.simpleanki.core.data.repository.FakeFolderDao +import nart.simpleanki.core.data.repository.FakeReviewLogDao import nart.simpleanki.core.data.repository.FolderRepository +import nart.simpleanki.core.data.repository.ReviewLogRepository +import nart.simpleanki.core.data.repository.StreakProvider +import java.util.TimeZone import nart.simpleanki.core.billing.Entitlement import nart.simpleanki.core.billing.FakeEntitlementRepository import nart.simpleanki.core.billing.PremiumTier @@ -51,6 +56,8 @@ class StudyQueueViewModelTest { dateCreated = now, lastModified = now, fsrsDue = now, fsrsState = CardState.New.value, ) + private fun emptyStreak() = StreakProvider(ReviewLogRepository(FakeReviewLogDao())) + @Test fun aggregatesAcrossDecks_andBreaksDownPerDeck() = runTest { val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) @@ -60,7 +67,7 @@ class StudyQueueViewModelTest { cardRepo.upsert(review("a1", "A")); cardRepo.upsert(review("a2", "A")); cardRepo.upsert(newCard("a3", "A")) cardRepo.upsert(review("b1", "B")) - val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -94,7 +101,7 @@ class StudyQueueViewModelTest { val vm = StudyQueueViewModel( cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), - FakeSettingsRepository(), FakeEntitlementRepository(), now = clock, + FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), now = clock, ) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -116,7 +123,7 @@ class StudyQueueViewModelTest { // A review card whose due date is in the future — not ready today. cardRepo.upsert(review("a1", "A").copy(fsrsDue = now + 86_400_000L)) - val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -130,7 +137,7 @@ class StudyQueueViewModelTest { fun hasAnyCards_isFalseForNewUser_andTrueOnceACardExists() = runTest { val deckRepo = DeckRepository(FakeDeckDao(), now = { now }) val cardRepo = CardRepository(FakeCardDao(), now = { now }) - val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() // Brand-new user: no cards at all. @@ -155,7 +162,7 @@ class StudyQueueViewModelTest { cardRepo.upsert(review("a3", "A").copy(fsrsDue = now + 86_400_000L, fsrsLastReview = now - 2 * 86_400_000L)) val settings = FakeSettingsRepository(AppSettings(dailyGoalEnabled = true, newCardsTarget = 1, reviewCardsTarget = 1)) // goal total = 2 - val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), settings, FakeEntitlementRepository(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), settings, FakeEntitlementRepository(), emptyStreak(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -176,7 +183,7 @@ class StudyQueueViewModelTest { val settings = FakeSettingsRepository( AppSettings(dailyGoalEnabled = false, newCardsTarget = 1, reviewCardsTarget = 0), ) - val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), settings, FakeEntitlementRepository(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), settings, FakeEntitlementRepository(), emptyStreak(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -192,7 +199,7 @@ class StudyQueueViewModelTest { deckRepo.upsert(Deck(id = "A", name = "Spanish", folderId = "f1", dateCreated = now, lastModified = now)) cardRepo.upsert(review("a1", "A").copy(front = "hola")) - val vm = StudyQueueViewModel(cardRepo, deckRepo, folderRepo, FakeSettingsRepository(), FakeEntitlementRepository(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, folderRepo, FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -218,7 +225,7 @@ class StudyQueueViewModelTest { cardRepo.upsert(review("hard", "A").copy(fsrsDifficulty = 9.0)) val settings = FakeSettingsRepository(AppSettings(queueSortOrder = QueueSortOrder.Difficulty)) - val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), settings, FakeEntitlementRepository(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), settings, FakeEntitlementRepository(), emptyStreak(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -234,13 +241,13 @@ class StudyQueueViewModelTest { cardRepo.upsert(review("a1", "A")) val free = FakeEntitlementRepository(Entitlement(PremiumTier.None)) - val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), free, now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), free, emptyStreak(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() assertTrue(vm.uiState.value.showPremiumNudge) val premium = FakeEntitlementRepository(Entitlement(PremiumTier.Annual)) - val vm2 = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), premium, now = { now }) + val vm2 = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), premium, emptyStreak(), now = { now }) backgroundScope.launch { vm2.uiState.collect {} } runCurrent() assertFalse(vm2.uiState.value.showPremiumNudge) @@ -252,7 +259,7 @@ class StudyQueueViewModelTest { val cardRepo = CardRepository(FakeCardDao(), now = { now }) deckRepo.upsert(Deck(id = "A", name = "Alpha", dateCreated = now, lastModified = now)) cardRepo.upsert(review("a1", "A")) - val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(Entitlement(PremiumTier.None)), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(Entitlement(PremiumTier.None)), emptyStreak(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() assertTrue(vm.uiState.value.showPremiumNudge) @@ -267,7 +274,7 @@ class StudyQueueViewModelTest { CardRepository(FakeCardDao(), now = { now }), DeckRepository(FakeDeckDao(), now = { now }), FolderRepository(FakeFolderDao(), now = { now }), - settings, FakeEntitlementRepository(), now = { now }, + settings, FakeEntitlementRepository(), emptyStreak(), now = { now }, ) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -275,4 +282,26 @@ class StudyQueueViewModelTest { vm.setSortOrder(QueueSortOrder.Shuffle); runCurrent() assertEquals(QueueSortOrder.Shuffle, vm.uiState.value.sortOrder) } + + @Test + fun currentStreak_reflectsConsecutiveReviewDays() = runTest { + val day = 86_400_000L + val logDao = FakeReviewLogDao() + logDao.insertAll(listOf( + ReviewLogEntity("a", "c1", 3, 2, 0, 1.0, 5.0, 0.0, 0.0, 0.0, now, false), + ReviewLogEntity("b", "c1", 3, 2, 0, 1.0, 5.0, 0.0, 0.0, 0.0, now - day, false), + )) + val streak = StreakProvider(ReviewLogRepository(logDao), now = { now }, timeZone = TimeZone.getTimeZone("UTC")) + + val vm = StudyQueueViewModel( + CardRepository(FakeCardDao(), now = { now }), + DeckRepository(FakeDeckDao(), now = { now }), + FolderRepository(FakeFolderDao(), now = { now }), + FakeSettingsRepository(), FakeEntitlementRepository(), streak, now = { now }, + ) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + assertEquals(2, vm.uiState.value.currentStreak) + } } diff --git a/app/src/test/java/nart/simpleanki/feature/study/StudyViewModelTest.kt b/app/src/test/java/nart/simpleanki/feature/study/StudyViewModelTest.kt index 4219cc7..a6544f3 100644 --- a/app/src/test/java/nart/simpleanki/feature/study/StudyViewModelTest.kt +++ b/app/src/test/java/nart/simpleanki/feature/study/StudyViewModelTest.kt @@ -3,6 +3,7 @@ package nart.simpleanki.feature.study import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runCurrent @@ -10,12 +11,14 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import nart.simpleanki.core.analytics.FakeLogService import nart.simpleanki.core.analytics.LogManager +import nart.simpleanki.core.data.local.ReviewLogEntity import nart.simpleanki.core.data.repository.CardRepository import nart.simpleanki.core.data.repository.DeckRepository import nart.simpleanki.core.data.repository.FakeCardDao import nart.simpleanki.core.data.repository.FakeDeckDao import nart.simpleanki.core.data.repository.FakeReviewLogDao import nart.simpleanki.core.data.repository.ReviewLogRepository +import nart.simpleanki.core.data.repository.StreakProvider import nart.simpleanki.core.data.settings.AppSettings import nart.simpleanki.core.data.settings.FakeSettingsRepository import nart.simpleanki.core.domain.fsrs.QueueSortOrder @@ -24,6 +27,7 @@ import nart.simpleanki.core.domain.model.CardState import nart.simpleanki.core.domain.model.Deck import nart.simpleanki.core.domain.model.Rating import org.junit.After +import java.util.TimeZone import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -259,4 +263,43 @@ class StudyViewModelTest { assertEquals("c1", logs[0].cardId) assertEquals(Rating.Good, logs[0].rating) } + + @Test + fun finishingSession_setsCurrentStreakAtLeastOne() = runTest { + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + cardRepo.upsert(newCard("c1", deckId = "d1")) + + val vm = StudyViewModel( + "d1", null, cardRepo, DeckRepository(FakeDeckDao(), now = { now }), + FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, + ) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + vm.onReveal() + vm.onRate(Rating.Good) // last (only) card -> session finishes + runCurrent() + + assertTrue(vm.uiState.value.finished) + assertEquals(1, vm.uiState.value.currentStreak) + } + + @Test + fun emptyQueue_doesNotFabricateTodayInStreak() = runTest { + val day = 86_400_000L + val logDao = FakeReviewLogDao() + // Only yesterday logged; no cards to study today. + logDao.insertAll(listOf(ReviewLogEntity("y", "c1", 3, 2, 0, 1.0, 5.0, 0.0, 0.0, 0.0, now - day, false))) + val streak = StreakProvider(ReviewLogRepository(logDao), now = { now }, timeZone = TimeZone.getTimeZone("UTC")) + + val vm = StudyViewModel( + "d1", null, CardRepository(FakeCardDao(), now = { now }), DeckRepository(FakeDeckDao(), now = { now }), + FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakProvider = streak, + ) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + assertTrue(vm.uiState.value.finished) // empty queue -> finished immediately + assertEquals(1, vm.uiState.value.currentStreak) // yesterday alive = 1, NOT 2 (today must NOT be forced) + } } diff --git a/docs/superpowers/plans/2026-06-05-streak-system.md b/docs/superpowers/plans/2026-06-05-streak-system.md new file mode 100644 index 0000000..5a19d67 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-streak-system.md @@ -0,0 +1,709 @@ +# Streak System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Show a daily study streak (consecutive days with ≥1 review) on the "Today" home header and the session-complete summary, derived purely from the review logs. + +**Architecture:** A pure `StreakCalculator` (current + longest from civil-day indices) + a DST-safe `localEpochDay` bucketing helper feed a shared `StreakProvider` over `ReviewLogRepository.observeLogs()`. `StudyQueueViewModel` exposes a live streak (outer-combine); `StudyViewModel` exposes a race-proof post-session streak. Hard reset on a missed day; derived, not stored. + +**Tech Stack:** Kotlin, `Calendar` (minSdk 24 → no `java.time`), Jetpack Compose, Koin, JUnit4 + coroutines-test. + +**Build/test prefix:** ALL Gradle commands MUST be prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`. Run from `/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +**Spec:** `docs/superpowers/specs/2026-06-05-streak-system-design.md`. **Branch is stacked on `feature/review-log-sync` (PR #15)** — `ReviewLogRepository.observeLogs()` comes from there. + +**Note:** new tests are JVM unit tests (run normally). Compose screens are compile-verified + previews (emulator down). + +--- + +### Task 1: `StreakCalculator` + `localEpochDay` (pure core) + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/core/domain/streak/StreakCalculator.kt` +- Create: `app/src/main/java/nart/simpleanki/core/domain/streak/DayBucketing.kt` +- Test: `app/src/test/java/nart/simpleanki/core/domain/streak/StreakCalculatorTest.kt` +- Test: `app/src/test/java/nart/simpleanki/core/domain/streak/DayBucketingTest.kt` + +- [ ] **Step 1: Write the failing tests** + +Create `app/src/test/java/nart/simpleanki/core/domain/streak/StreakCalculatorTest.kt`: + +```kotlin +package nart.simpleanki.core.domain.streak + +import org.junit.Assert.assertEquals +import org.junit.Test + +class StreakCalculatorTest { + + @Test fun empty_isZeroZero() { + assertEquals(Streak(0, 0), StreakCalculator.compute(emptySet(), today = 100)) + } + + @Test fun onlyToday_isOneOne() { + assertEquals(Streak(1, 1), StreakCalculator.compute(setOf(100L), today = 100)) + } + + @Test fun consecutiveEndingToday_countsRun() { + assertEquals(Streak(3, 3), StreakCalculator.compute(setOf(98L, 99L, 100L), today = 100)) + } + + @Test fun studiedYesterdayNotToday_streakStillAlive() { + // Latest study day is yesterday (99) — streak is alive through today (100). + assertEquals(Streak(2, 2), StreakCalculator.compute(setOf(98L, 99L), today = 100)) + } + + @Test fun missedAFullDay_currentResetsButLongestPersists() { + // Studied days 90-94 (run of 5), then nothing until today only -> current 1, longest 5. + assertEquals(Streak(1, 5), StreakCalculator.compute(setOf(90L, 91L, 92L, 93L, 94L, 100L), today = 100)) + } + + @Test fun neitherTodayNorYesterday_currentZero_longestFromPastRun() { + // Last study day is 97 (two days ago) -> streak broken; longest run was 96,97 = 2. + assertEquals(Streak(0, 2), StreakCalculator.compute(setOf(96L, 97L), today = 100)) + } +} +``` + +Create `app/src/test/java/nart/simpleanki/core/domain/streak/DayBucketingTest.kt`: + +```kotlin +package nart.simpleanki.core.domain.streak + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.Calendar +import java.util.TimeZone + +class DayBucketingTest { + + private val ny = TimeZone.getTimeZone("America/New_York") + + private fun millis(tz: TimeZone, year: Int, month0: Int, day: Int, hour: Int): Long = + Calendar.getInstance(tz).apply { + clear(); set(year, month0, day, hour, 0, 0) + }.timeInMillis + + @Test fun sameLocalDate_differentTimes_sameIndex() { + val morning = millis(ny, 2026, Calendar.JUNE, 5, 8) + val evening = millis(ny, 2026, Calendar.JUNE, 5, 23) + assertEquals(localEpochDay(morning, ny), localEpochDay(evening, ny)) + } + + @Test fun consecutiveDates_differByOne() { + val d1 = millis(ny, 2026, Calendar.JUNE, 5, 12) + val d2 = millis(ny, 2026, Calendar.JUNE, 6, 9) + assertEquals(localEpochDay(d1, ny) + 1, localEpochDay(d2, ny)) + } + + @Test fun acrossDstSpringForward_stillDiffersByOne() { + // US DST spring-forward 2026 is Sun Mar 8 (a 23-hour day). The civil-day index must still +1. + val before = millis(ny, 2026, Calendar.MARCH, 8, 12) + val after = millis(ny, 2026, Calendar.MARCH, 9, 12) + assertEquals(localEpochDay(before, ny) + 1, localEpochDay(after, ny)) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail (do not compile)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.streak.*"` +Expected: FAIL — `Streak`, `StreakCalculator`, `localEpochDay` don't exist. + +- [ ] **Step 3a: Implement `StreakCalculator`** + +Create `app/src/main/java/nart/simpleanki/core/domain/streak/StreakCalculator.kt`: + +```kotlin +package nart.simpleanki.core.domain.streak + +/** A study streak in days. */ +data class Streak(val current: Int, val longest: Int) + +object StreakCalculator { + /** + * [reviewDays] = civil-day indices on which ≥1 review happened; [today] = today's civil-day index. + * Pure (no timezone logic — bucket with [localEpochDay] first). + * - current: if the user studied today, or studied yesterday (the streak is still alive through + * today), the length of the consecutive run ending there; otherwise 0 (hard reset). + * - longest: the longest consecutive run anywhere in the set. + */ + fun compute(reviewDays: Set, today: Long): Streak { + if (reviewDays.isEmpty()) return Streak(0, 0) + + var longest = 1 + var run = 1 + var prev: Long? = null + for (day in reviewDays.toSortedSet()) { + run = if (prev != null && day == prev + 1) run + 1 else 1 + if (run > longest) longest = run + prev = day + } + + val anchor = when { + reviewDays.contains(today) -> today + reviewDays.contains(today - 1) -> today - 1 + else -> return Streak(0, longest) + } + var current = 0 + var day = anchor + while (reviewDays.contains(day)) { + current++ + day-- + } + return Streak(current, longest) + } +} +``` + +- [ ] **Step 3b: Implement `localEpochDay`** + +Create `app/src/main/java/nart/simpleanki/core/domain/streak/DayBucketing.kt`: + +```kotlin +package nart.simpleanki.core.domain.streak + +import java.util.Calendar +import java.util.TimeZone + +/** + * Civil-day number for [millis] in [timeZone] — a DST-safe day index where consecutive calendar + * dates always differ by exactly 1. Reads the local Y/M/D, then rebuilds them as a UTC midnight and + * divides by a day, so 23h/25h DST days don't shift the index. (minSdk 24 rules out java.time.) + */ +fun localEpochDay(millis: Long, timeZone: TimeZone = TimeZone.getDefault()): Long { + val local = Calendar.getInstance(timeZone).apply { timeInMillis = millis } + val utcMidnight = Calendar.getInstance(TimeZone.getTimeZone("UTC")).apply { + clear() + set(local.get(Calendar.YEAR), local.get(Calendar.MONTH), local.get(Calendar.DAY_OF_MONTH)) + } + return utcMidnight.timeInMillis / 86_400_000L +} +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.streak.*"` +Expected: PASS (9 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/core/domain/streak/ app/src/test/java/nart/simpleanki/core/domain/streak/ +git commit -m "Add StreakCalculator and DST-safe day bucketing" +``` + +--- + +### Task 2: `StreakProvider` + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/core/data/repository/StreakProvider.kt` +- Modify: `app/src/main/java/nart/simpleanki/di/AppModule.kt` +- Test: `app/src/test/java/nart/simpleanki/core/data/repository/StreakProviderTest.kt` + +- [ ] **Step 1: Write the failing test** + +Create `app/src/test/java/nart/simpleanki/core/data/repository/StreakProviderTest.kt`: + +```kotlin +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import nart.simpleanki.core.data.local.ReviewLogEntity +import nart.simpleanki.core.domain.streak.Streak +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.TimeZone + +class StreakProviderTest { + + private val utc = TimeZone.getTimeZone("UTC") + private val day = 86_400_000L + private val today = 1_700_000_000_000L // some instant; we anchor everything off it + + private fun logEntity(id: String, reviewMillis: Long) = ReviewLogEntity( + id = id, cardId = "c1", rating = 3, state = 2, due = 0, stability = 1.0, difficulty = 5.0, + elapsedDays = 0.0, lastElapsedDays = 0.0, scheduledDays = 0.0, review = reviewMillis, dirty = false, + ) + + private fun provider(dao: FakeReviewLogDao) = + StreakProvider(ReviewLogRepository(dao), now = { today }, timeZone = utc) + + @Test + fun observeStreak_countsConsecutiveDaysEndingToday() = runTest { + val dao = FakeReviewLogDao() + // today, yesterday, day-before — 3 consecutive (multiple logs on one day collapse to one day). + dao.insertAll(listOf( + logEntity("a", today), + logEntity("b", today), // same day, still counts once + logEntity("c", today - day), + logEntity("d", today - 2 * day), + )) + val streak = provider(dao).observeStreak().first() + assertEquals(Streak(3, 3), streak) + } + + @Test + fun observeStreak_noLogs_isZero() = runTest { + assertEquals(Streak(0, 0), provider(FakeReviewLogDao()).observeStreak().first()) + } + + @Test + fun streakIncludingToday_countsTodayEvenIfNotYetLogged() = runTest { + val dao = FakeReviewLogDao() + dao.insertAll(listOf(logEntity("y", today - day))) // only yesterday logged + // observeStreak sees yesterday only (alive) -> current 1; including-today forces today -> 2. + assertEquals(Streak(1, 1), provider(dao).observeStreak().first()) + assertEquals(Streak(2, 2), provider(dao).streakIncludingToday()) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails (does not compile)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.data.repository.StreakProviderTest"` +Expected: FAIL — `unresolved reference: StreakProvider`. + +- [ ] **Step 3: Implement `StreakProvider`** + +Create `app/src/main/java/nart/simpleanki/core/data/repository/StreakProvider.kt`: + +```kotlin +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import nart.simpleanki.core.domain.streak.Streak +import nart.simpleanki.core.domain.streak.StreakCalculator +import nart.simpleanki.core.domain.streak.localEpochDay +import java.util.TimeZone + +/** Derives the study [Streak] from the review logs. Stateless — pure derivation, nothing stored. */ +class StreakProvider( + private val reviewLogRepository: ReviewLogRepository, + private val now: () -> Long = { System.currentTimeMillis() }, + private val timeZone: TimeZone = TimeZone.getDefault(), +) { + /** Live streak for the home header — reacts to new review logs. */ + fun observeStreak(): Flow = + reviewLogRepository.observeLogs().map { logs -> + val days = logs.mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + StreakCalculator.compute(days, localEpochDay(now(), timeZone)) + } + + /** + * Streak treating today as studied — for the post-session summary, so it's correct even though + * the per-rating log append is fire-and-forget and may not have landed yet. + */ + suspend fun streakIncludingToday(): Streak { + val today = localEpochDay(now(), timeZone) + val days = reviewLogRepository.observeLogs().first() + .mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + .apply { add(today) } + return StreakCalculator.compute(days, today) + } +} +``` + +- [ ] **Step 4: Register in DI** + +In `app/src/main/java/nart/simpleanki/di/AppModule.kt`, add after `single { ReviewLogRepository(get()) }`: + +```kotlin + single { StreakProvider(get()) } +``` + +Add `import nart.simpleanki.core.data.repository.StreakProvider` only if the file imports repositories individually (check first; skip if same-package/wildcard). + +- [ ] **Step 5: Run the test + compile** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.data.repository.StreakProviderTest" :app:compileDebugKotlin` +Expected: PASS (3 tests) + BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/core/data/repository/StreakProvider.kt app/src/main/java/nart/simpleanki/di/AppModule.kt app/src/test/java/nart/simpleanki/core/data/repository/StreakProviderTest.kt +git commit -m "Add StreakProvider deriving streak from review logs" +``` + +--- + +### Task 3: Wire streak into `StudyQueueViewModel` (Today home) + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt` +- Modify: `app/src/main/java/nart/simpleanki/di/AppModule.kt` +- Test: `app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt` + +- [ ] **Step 1: Write the failing test** + +In `StudyQueueViewModelTest.kt`, add these imports if missing: + +```kotlin +import nart.simpleanki.core.data.local.ReviewLogEntity +import nart.simpleanki.core.data.repository.FakeReviewLogDao +import nart.simpleanki.core.data.repository.ReviewLogRepository +import nart.simpleanki.core.data.repository.StreakProvider +import java.util.TimeZone +``` + +Add a helper inside the class (an empty-streak provider for the existing tests that don't care): + +```kotlin + private fun emptyStreak() = StreakProvider(ReviewLogRepository(FakeReviewLogDao())) +``` + +Add this new test (uses a UTC-anchored provider seeded with two consecutive days ending "today"): + +```kotlin + @Test + fun currentStreak_reflectsConsecutiveReviewDays() = runTest { + val day = 86_400_000L + val logDao = FakeReviewLogDao() + logDao.insertAll(listOf( + ReviewLogEntity("a", "c1", 3, 2, 0, 1.0, 5.0, 0.0, 0.0, 0.0, now, false), + ReviewLogEntity("b", "c1", 3, 2, 0, 1.0, 5.0, 0.0, 0.0, 0.0, now - day, false), + )) + val streak = StreakProvider(ReviewLogRepository(logDao), now = { now }, timeZone = TimeZone.getTimeZone("UTC")) + + val vm = StudyQueueViewModel( + CardRepository(FakeCardDao(), now = { now }), + DeckRepository(FakeDeckDao(), now = { now }), + FolderRepository(FakeFolderDao(), now = { now }), + FakeSettingsRepository(), FakeEntitlementRepository(), streak, now = { now }, + ) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + assertEquals(2, vm.uiState.value.currentStreak) + } +``` + +(Adapt the `ReviewLogEntity(...)` positional args to the real constructor field order if it differs — it is `id, cardId, rating, state, due, stability, difficulty, elapsedDays, lastElapsedDays, scheduledDays, review, dirty`.) + +- [ ] **Step 2: Update every existing `StudyQueueViewModel(...)` construction** + +In `StudyQueueViewModelTest.kt`, every existing `StudyQueueViewModel(...)` call needs the new `streakProvider` argument (it goes after `entitlementRepository`/`FakeEntitlementRepository()` and before `now = { ... }`). For each existing call shaped like +`StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(...), FakeSettingsRepository(), FakeEntitlementRepository(), now = { now })`, +insert `emptyStreak(), ` before `now = { now }`: +`StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(...), FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), now = { now })`. +Search the file for every `StudyQueueViewModel(` and apply. + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.feature.queue.StudyQueueViewModelTest"` +Expected: FAIL to compile — `StudyQueueViewModel` has no `streakProvider` parameter / `currentStreak` not on `StudyQueueUiState`. + +- [ ] **Step 4a: Add the state fields + constructor param + outer combine** + +In `StudyQueueViewModel.kt`: + +Add to `StudyQueueUiState` (after `studiedToday`): + +```kotlin + val currentStreak: Int = 0, + val longestStreak: Int = 0, +``` + +Add the import `import nart.simpleanki.core.data.repository.StreakProvider`. Add the constructor parameter after `entitlementRepository` and before `now`: + +```kotlin + private val entitlementRepository: EntitlementRepository, + private val streakProvider: StreakProvider, + private val now: () -> Long = { System.currentTimeMillis() }, +``` + +Now wrap the existing `combine(...) { ... }.stateIn(...)`. Change the existing assignment so the 5-input `combine` becomes a private `baseState` (drop its `.stateIn(...)`), then add an outer combine that folds in the streak. Concretely, the current code is: + +```kotlin + val uiState: StateFlow = + combine( + cardRepository.observeAllCards().withDueTicks(now), + deckRepository.observeDecks(), + folderRepository.observeFolders(), + settingsRepository.settings, + entitlementRepository.entitlement, + ) { (cards, nowMillis), decks, folders, settings, entitlement -> + ... // unchanged body producing StudyQueueUiState(...) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = StudyQueueUiState(), + ) +``` + +Change it to: + +```kotlin + private val baseState: Flow = + combine( + cardRepository.observeAllCards().withDueTicks(now), + deckRepository.observeDecks(), + folderRepository.observeFolders(), + settingsRepository.settings, + entitlementRepository.entitlement, + ) { (cards, nowMillis), decks, folders, settings, entitlement -> + ... // SAME body, unchanged + } + + val uiState: StateFlow = + combine(baseState, streakProvider.observeStreak()) { base, streak -> + base.copy(currentStreak = streak.current, longestStreak = streak.longest) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = StudyQueueUiState(), + ) +``` + +Add the import `import kotlinx.coroutines.flow.Flow` if not present. (The inner `combine` body is unchanged — only the surrounding declaration changes.) + +- [ ] **Step 4b: Update the Koin registration** + +In `AppModule.kt`, the `StudyQueueViewModel { ... }` block currently lists `cardRepository`, `deckRepository`, `folderRepository`, `settingsRepository`, `entitlementRepository`. Add `streakProvider = get()` after `entitlementRepository = get()`: + +```kotlin + viewModel { + StudyQueueViewModel( + cardRepository = get(), + deckRepository = get(), + folderRepository = get(), + settingsRepository = get(), + entitlementRepository = get(), + streakProvider = get(), + ) + } +``` + +(If the existing block uses positional `get()`s, add one more `get()` in the streakProvider slot instead.) + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.feature.queue.StudyQueueViewModelTest"` +Expected: PASS (new + all pre-existing tests). + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt app/src/main/java/nart/simpleanki/di/AppModule.kt app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt +git commit -m "Expose live streak in StudyQueueViewModel" +``` + +--- + +### Task 4: Wire streak into `StudyViewModel` (session summary) + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/study/StudyViewModel.kt` +- Modify: `app/src/main/java/nart/simpleanki/di/AppModule.kt` +- Test: `app/src/test/java/nart/simpleanki/feature/study/StudyViewModelTest.kt` + +This uses a **defaulted** `streakProvider` param (derived from the already-injected `reviewLogRepository`), so the existing `StudyViewModel(...)` test call sites do NOT need changes. + +- [ ] **Step 1: Write the failing test** + +In `StudyViewModelTest.kt`, add this test (model the study/flip/rate idiom on the existing `rating_appendsOneReviewLog_withCardIdAndRating` test from PR #15; adapt helper names): + +```kotlin + @Test + fun finishingSession_setsCurrentStreakAtLeastOne() = runTest { + val cardRepo = CardRepository(FakeCardDao(), now = { now }) + cardRepo.upsert(newCard("c1", deckId = "d1")) + + val vm = StudyViewModel( + "d1", null, cardRepo, DeckRepository(FakeDeckDao(), now = { now }), + FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, + ) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + vm.onReveal() + vm.onRate(Rating.Good) // last (only) card -> session finishes + runCurrent() + + assertTrue(vm.uiState.value.finished) + // streakIncludingToday forces today in, so the just-finished session yields >= 1. + assertEquals(1, vm.uiState.value.currentStreak) + } +``` + +Add the import `import org.junit.Assert.assertTrue` if missing (`FakeReviewLogDao`/`ReviewLogRepository` were imported in PR #15's Task 4). + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.feature.study.StudyViewModelTest"` +Expected: FAIL — `currentStreak` not on `StudyUiState`. + +- [ ] **Step 3a: Add state fields + defaulted provider + finish wiring** + +In `StudyViewModel.kt`: + +Add to `StudyUiState` (after `durationMillis`): + +```kotlin + val currentStreak: Int = 0, + val longestStreak: Int = 0, +``` + +Add the import `import nart.simpleanki.core.data.repository.StreakProvider`. Add a **defaulted** constructor parameter — it must come AFTER `reviewLogRepository` and `now` (its default references both), so place it right after `now`: + +```kotlin + private val reviewLogRepository: ReviewLogRepository, + private val now: () -> Long = { System.currentTimeMillis() }, + private val streakProvider: StreakProvider = StreakProvider(reviewLogRepository, now), + private val logManager: LogManager = LogManager(emptyList()), +``` + +Add a private helper that fills the summary streak (race-proof — today forced in): + +```kotlin + private fun refreshSummaryStreak() = viewModelScope.launch { + val s = streakProvider.streakIncludingToday() + _uiState.value = _uiState.value.copy(currentStreak = s.current, longestStreak = s.longest) + } +``` + +Call `refreshSummaryStreak()` at BOTH finish points: +- In `load()`, right after the `_uiState.value = StudyUiState(... finished = queue.isEmpty() ...)` assignment, add: `if (queue.isEmpty()) refreshSummaryStreak()`. +- In `onRate()`, right after the `_uiState.value = prev.copy(... finished = next == null ...)` assignment, add: `if (next == null) refreshSummaryStreak()`. + +- [ ] **Step 3b: Update the Koin registration to use the shared provider** + +In `AppModule.kt`, in the `StudyViewModel` `viewModel { params -> ... }` block, add `streakProvider = get(),` (after `reviewLogRepository = get(),`) so production uses the singleton rather than a per-VM default: + +```kotlin + StudyViewModel( + deckId = args.deckId, + folderId = args.folderId, + cardRepository = get(), + deckRepository = get(), + settingsRepository = get(), + reviewLogRepository = get(), + streakProvider = get(), + logManager = get(), + ) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.feature.study.StudyViewModelTest"` +Expected: PASS (new + all pre-existing tests — existing call sites compile because `streakProvider` is defaulted). + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/study/StudyViewModel.kt app/src/main/java/nart/simpleanki/di/AppModule.kt app/src/test/java/nart/simpleanki/feature/study/StudyViewModelTest.kt +git commit -m "Expose post-session streak in StudyViewModel" +``` + +--- + +### Task 5: Display — Today header chip + session-summary badge + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt` +- Modify: `app/src/main/java/nart/simpleanki/feature/study/SessionSummary.kt` + +Build-verified + previews. + +- [ ] **Step 1: Add the Today-header streak chip** + +In `StudyQueueScreen.kt`, the `TopAppBar` has `title = { Text("Today", fontWeight = FontWeight.Bold) }`. Add an `actions` slot showing the streak when > 0. Add these imports if missing: `androidx.compose.foundation.layout.Row`, `androidx.compose.foundation.layout.Arrangement`, `androidx.compose.foundation.layout.padding`, `androidx.compose.ui.Alignment`, `androidx.compose.ui.unit.dp`, `androidx.compose.ui.unit.sp`. In the `TopAppBar(...)`, add (alongside the existing `title =`/`colors =` args): + +```kotlin + actions = { + if (state.currentStreak > 0) { + Row( + modifier = Modifier.padding(end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("🔥", fontSize = 18.sp) + Text( + state.currentStreak.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } + }, +``` + +(If the `TopAppBar` already has an `actions = { ... }`, add the `if (state.currentStreak > 0) { ... }` block inside it instead.) + +- [ ] **Step 2: Add the session-summary streak badge** + +In `SessionSummary.kt`, remove the "streak omitted" note in the file's top comment (change `(streak omitted)` → `(with streak badge)`), and add a badge after the `RatingDistributionBar` block and before the trailing `Spacer(Modifier.weight(1f))`. Insert: + +```kotlin + if (state.currentStreak > 0) { + Spacer(Modifier.height(24.dp)) + StreakBadge(current = state.currentStreak, longest = state.longestStreak) + } +``` + +Then add the private composable (near the other private composables like `SessionStatsRow`): + +```kotlin +@Composable +private fun StreakBadge(current: Int, longest: Int) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "🔥 $current day${if (current == 1) "" else "s"} streak", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = Color(0xFFFF9500), + ) + if (longest > current) { + Spacer(Modifier.height(4.dp)) + Text( + "Longest: $longest", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} +``` + +Ensure these imports exist in `SessionSummary.kt` (add any missing): `androidx.compose.foundation.layout.Column`, `androidx.compose.foundation.layout.Spacer`, `androidx.compose.foundation.layout.height`, `androidx.compose.material3.Text`, `androidx.compose.material3.MaterialTheme`, `androidx.compose.runtime.Composable`, `androidx.compose.ui.Alignment`, `androidx.compose.ui.graphics.Color`, `androidx.compose.ui.text.font.FontWeight`, `androidx.compose.ui.unit.dp`. + +- [ ] **Step 3: Update previews** + +In `SessionSummary.kt`, find the existing `@Preview` that builds a `StudyUiState(...)` and add `currentStreak = 7, longestStreak = 12` to it so the badge renders in the preview. In `StudyQueueScreen.kt`, find a preview building `StudyQueueUiState(...)` and add `currentStreak = 7` so the header chip renders. + +- [ ] **Step 4: Verify it compiles** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt app/src/main/java/nart/simpleanki/feature/study/SessionSummary.kt +git commit -m "Show streak in Today header and session summary" +``` + +--- + +## Final verification + +- [ ] **Run the full app unit-test suite (no regressions)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest` +Expected: BUILD SUCCESSFUL. + +- [ ] **Build the debug APK end-to-end** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:assembleDebug` +Expected: BUILD SUCCESSFUL. + +- [ ] **Manual smoke (when an emulator is available)** + +- Study at least one card today → the "Today" header shows "🔥 1"; finishing the session shows a "🔥 1 day streak" badge in the summary. +- Study again the next day → header shows "🔥 2". +- (If you can backdate review logs) a gap of a full day resets the header to 0 (chip hidden) while the session summary still reflects today after studying. diff --git a/docs/superpowers/specs/2026-06-05-streak-system-design.md b/docs/superpowers/specs/2026-06-05-streak-system-design.md new file mode 100644 index 0000000..87cc7d4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-streak-system-design.md @@ -0,0 +1,154 @@ +# Streak System — Design + +**Date:** 2026-06-05 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/streak-system` — **stacked on `feature/review-log-sync` (PR #15)**, which +provides `ReviewLogRepository.observeLogs()`. Retarget the streak PR onto `main` once #15 merges. +**Sub-project 2 of 2** (sub-project 1 = review-log persistence + sync, PR #15). + +## Goal + +Show a daily study **streak** — the number of consecutive days the user has studied — on the +"Today" home header and in the session-complete summary. A streak day = **any review (≥1 card +rated)**. Missing a full day resets the streak to 0, framed encouragingly. The streak is derived +purely from the review logs persisted in sub-project 1. + +## Decisions (from brainstorming + research) + +- **Streak day = any review ≥1 card** (low barrier; works regardless of the daily-goal setting). +- **Hard reset, encouraging framing** — a full missed day resets to 0; no streak-freeze/grace in + v1 (deferred). The streak stays "alive" through today until local midnight. +- **Display:** Today home header (🔥 + count) and the session-summary badge (the reserved + `// streak omitted` slot). NOT the profile screen. +- **Derived, not stored.** No new persistence — the streak is recomputed from review-log days. (No + streak-freeze means no freeze state to persist.) +- **Longest streak**: computed (free from the same scan); shown as a small secondary line in the + session-summary badge only. Home header shows just the current 🔥. +- **Milestones**: v1 is a plain "🔥 N day streak" — no 7/30/100 celebration (deferred to v2). + +## Key technical constraints + +- **minSdk 24, no core-library desugaring → `java.time` is unavailable.** Day bucketing uses + `Calendar` (like the existing `startOfDay` helpers). +- **DST-safe bucketing:** naive `millis / 86_400_000` mis-buckets on 23h/25h days. Instead compute a + **civil-day number**: read local `YEAR/MONTH/DAY_OF_MONTH` via `Calendar`, rebuild a UTC midnight + from those, divide by a day. Consecutive calendar dates then always differ by exactly 1. + +## Components + +### 1. `core/domain/streak/StreakCalculator.kt` (new — pure, JVM-testable) + +```kotlin +data class Streak(val current: Int, val longest: Int) + +object StreakCalculator { + /** + * [reviewDays] = the set of civil-day indices on which ≥1 review happened; [today] = today's + * civil-day index. Pure — no timezone logic here. + * - current: if the latest study day is today or yesterday (streak still alive through today), + * the length of the consecutive run ending there; else 0 (hard reset on a full missed day). + * - longest: the longest consecutive run anywhere in the set. + */ + fun compute(reviewDays: Set, today: Long): Streak +} +``` + +### 2. `core/domain/streak/DayBucketing.kt` (new — the only timezone-aware piece) + +```kotlin +/** Civil-day number for [millis] in [timeZone]: DST-safe day index (consecutive dates differ by 1). */ +fun localEpochDay(millis: Long, timeZone: TimeZone = TimeZone.getDefault()): Long +``` +Implementation: a `Calendar` in `timeZone` reads `YEAR/MONTH/DAY_OF_MONTH`; rebuild those as a UTC +`Calendar` at midnight; return `utcMidnightMillis / 86_400_000`. + +### 3. `core/data/repository/StreakProvider.kt` (new — shared derivation) + +```kotlin +class StreakProvider( + private val reviewLogRepository: ReviewLogRepository, + private val now: () -> Long = { System.currentTimeMillis() }, + private val timeZone: TimeZone = TimeZone.getDefault(), +) { + fun observeStreak(): Flow = + reviewLogRepository.observeLogs().map { logs -> + val days = logs.mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + StreakCalculator.compute(days, localEpochDay(now(), timeZone)) + } + + /** Streak treating today as studied — for the post-session summary (race-proof vs. the async + * log append). */ + suspend fun streakIncludingToday(): Streak { + val today = localEpochDay(now(), timeZone) + val days = reviewLogRepository.observeLogs().first() + .mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + .apply { add(today) } + return StreakCalculator.compute(days, today) + } +} +``` +Registered as a Koin `single`, injected into both view models. Keeps the derivation logic in one +place. + +### 4. `feature/queue/StudyQueueViewModel` (modify) — Today home + +`StudyQueueUiState` gains `currentStreak: Int = 0` and `longestStreak: Int = 0`. Rather than grow the +existing 5-input `combine`, wrap it: keep the current `combine(...)` producing the base state, then +`combine(baseState, streakProvider.observeStreak()) { s, streak -> s.copy(currentStreak = +streak.current, longestStreak = streak.longest) }.stateIn(...)`. (Avoids the 6-arg combine arity +problem and leaves the existing block untouched.) + +### 5. `feature/study/StudyViewModel` (modify) — session summary + +`StudyUiState` gains `currentStreak: Int = 0` and `longestStreak: Int = 0`. When the session +finishes (the `next == null` branch in `onRate`, and the empty-queue path in `load()`), set them +from `streakProvider.streakIncludingToday()` (today forced in, so it's correct even though the log +append is fire-and-forget). Inject `StreakProvider`; update the Koin `StudyViewModel` block and +`StudyViewModelTest` constructions. + +### 6. Display + +- **`feature/queue/StudyQueueScreen` (Today header):** a small 🔥 + `currentStreak` chip near the + "Today" title, rendered only when `currentStreak > 0`. +- **`feature/study/SessionSummary` (badge):** in the reserved slot (after the stats row, before + Finish), a `StreakBadge` showing "🔥 {currentStreak} day streak" (singular "day" when 1), plus a + small "Longest: {longestStreak}" line when `longestStreak > currentStreak`. Hidden when + `currentStreak == 0`. Remove the `// streak omitted` note. + +## Data flow + +Rate cards → review logs accrue (PR #15) → `StreakProvider.observeStreak()` re-derives `Streak` from +the distinct civil-days → Today header shows 🔥 N live; on finishing a session, +`streakIncludingToday()` feeds the summary badge. + +## Error handling + +Pure derivation over local data — no I/O, no failure modes. Empty logs → `Streak(0, 0)` → both +surfaces hide the streak. DST-safe by construction. The `streakIncludingToday()` union guarantees +the summary never shows a stale 0 right after the user studied. + +## Testing + +- **`StreakCalculatorTest`** (JVM, core): empty → 0/0; only-today → 1/1; N consecutive ending today + → N; studied yesterday but not today (alive) → run still counts; a gap zeroes `current` but + `longest` reflects the earlier run; `longest` spans a past run longer than the current. +- **`DayBucketingTest`** (JVM): two times on the same local date → same index; consecutive dates → + differ by 1; a date around a DST transition still yields a +1 neighbor (use an explicit + `TimeZone`, e.g. `America/New_York`, so the test is deterministic). +- **`StudyQueueViewModelTest`**: with fake logs across N consecutive days (controlled `now`/`TimeZone` + via the injected `StreakProvider`), `uiState.currentStreak == N`; no logs → 0. +- **`StudyViewModelTest`**: finishing a session sets `currentStreak >= 1` (today counted) via the + `streakIncludingToday` path. +- **Display**: build-verified + `@Preview`s (Today header chip at 7; `StreakBadge` at 1, at 7 with a + longer "Longest", and the hidden-at-0 case). + +**Build/test prefix:** Gradle commands MUST be prefixed with +`export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, run from +`/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. New tests are JVM unit tests; the +emulator is unavailable, so Compose screens are compile-verified + previews only. + +## Out of scope (v1) + +Streak-freeze / grace days; milestone celebrations (7/30/100); a profile-screen streak; a +calendar/heatmap; notifications about an at-risk streak; persisting streak counters (it's derived); +any change to review-log persistence or sync (sub-project 1).