From 9d52bc824e1d11fc3f758bf68711254b27edde43 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 15:53:05 +0400 Subject: [PATCH 1/7] Add design spec for streak freeze and repair --- .../2026-06-05-streak-freeze-repair-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-05-streak-freeze-repair-design.md diff --git a/docs/superpowers/specs/2026-06-05-streak-freeze-repair-design.md b/docs/superpowers/specs/2026-06-05-streak-freeze-repair-design.md new file mode 100644 index 0000000..f14c64a --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-streak-freeze-repair-design.md @@ -0,0 +1,183 @@ +# Streak Freeze + Repair — Design + +**Date:** 2026-06-05 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/streak-freeze-repair` (off `main`). + +## Goal + +Make study streaks more forgiving (and stickier) by adding **freezes** — earned by studying, which +auto-cover a missed day so the streak survives — and **repair** — a free, limited way to restore a +streak that just broke. Today the streak hard-resets on any missed day. This extends it without +changing the (good) derivation core. + +## Key constraint & the core idea + +Today the streak is **purely derived**: `StreakCalculator.compute(reviewDays, today)` over the civil +days with ≥1 review (from synced review logs); any gap hard-resets. Freeze/repair need **stored +state**. The elegant part: **`StreakCalculator` does not change** — a frozen day is just another +"active day," so the streak becomes `StreakCalculator.compute(reviewDays ∪ frozenDays, today)`. All +new complexity (which missed days become frozen, earning freezes, repair) lives in **one new +reconciler**; the calculator stays a pure function. + +## Decisions (from brainstorming) + +- **Freeze economy: earn by studying, no currency.** Earn **1 freeze per 7 streak-days**, store up + to **2**, auto-consume **1 per missed day**. Free for everyone; no billing, no gem economy. +- **Repair: free, limited.** Offered when the streak broke from a **single recent missed day** + (yesterday) **and** the user has **studied today**; capped to **once per 30 days**. One tap freezes + that gap day and the streak returns. Multi-day breaks are not repairable in v1 (YAGNI). +- **Persistence: synced via Firestore**, through the existing `SyncManager` (last-write-wins by + `lastModified`), as a single per-user `streak_state` doc — so the streak number stays consistent + across devices (the streak itself already syncs via review logs). + +## Components + +### 1. `StreakState` (new persisted state) +```kotlin +data class StreakState( + val freezeTokens: Int = 0, // available freezes (earned, capped at FREEZE_CAP) + val frozenDays: Set = emptySet(), // civil-days covered by an auto-freeze or a repair + val freezesAwardedForRun: Int = 0, // freezes already granted for the current unbroken run (idempotent earning) + val lastReconciledDay: Long = 0, // today-index we last advanced to (reconcile once per civil day) + val lastRepairDay: Long = 0, // civil-day of the last repair (for the once-per-30-days cap) + val lastModified: Long = 0, // for sync LWW +) +``` +Constants: `FREEZE_CAP = 2`, `FREEZE_EARN_EVERY = 7`, `REPAIR_COOLDOWN_DAYS = 30`. + +### 2. `StreakStateManager` (new — the reconciler) +Pure-ish logic (civil-day math via the existing `localEpochDay`), persisting through +`StreakStateRepository`. `reconcile(reviewDays, state, today)` returns an updated `StreakState` +(idempotent — safe to call repeatedly): + +1. **One-shot per day:** if `today <= state.lastReconciledDay`, only re-run the earning step + (cheap/idempotent) and return; otherwise continue. +2. **Auto-freeze:** let `active = reviewDays ∪ state.frozenDays`; `lastActive = max(active ≤ today)`. + For each fully-elapsed missed day `d` in `lastActive+1 .. today-1` (not already active): if + `freezeTokens > 0`, add `d` to `frozenDays` and decrement `freezeTokens`; else **stop** (the + streak breaks at `d`). +3. **Recompute** `current = StreakCalculator.compute(reviewDays ∪ frozenDays, today).current`. +4. **Break reset:** if the run is broken (the consecutive run ending at `today`/`today-1` is shorter + than `freezesAwardedForRun*7`, i.e. a new run started), reset `freezesAwardedForRun` to + `current / FREEZE_EARN_EVERY` (floor) — see "first run" below. +5. **Earn:** while `current >= (freezesAwardedForRun + 1) * FREEZE_EARN_EVERY` and + `freezeTokens < FREEZE_CAP`: `freezeTokens++`, `freezesAwardedForRun++`. (If the cap blocks an + award, still advance `freezesAwardedForRun` so the next freeze is earned at the next multiple — + no retroactive flood when a token is later spent.) +6. Set `lastReconciledDay = today`; bump `lastModified`; persist if anything changed (mark dirty). + +**First run / migration init:** when no prior state exists, initialize `freezesAwardedForRun = +current / FREEZE_EARN_EVERY` so an existing long streak doesn't instantly dump freezes; earning +proceeds from the next multiple. + +`reconcile()` is called (a) on app foreground/start and (b) after a completed review session — both +idempotent. It is an explicit suspend call, **never inside a Flow** (no side-effects in observation). + +### 3. Repair +`repairEligibility(reviewDays, state, today): RepairOffer?` (pure): returns an offer when — +- the streak is currently **broken** (`compute(...).current == 0` over `reviewDays ∪ frozenDays`), +- there is exactly one uncovered missed day at `today-1` whose preceding run (ending `today-2`) had + length ≥ 1 (a real streak was lost), +- the user **studied today** (`today ∈ reviewDays`), +- `today - state.lastRepairDay >= REPAIR_COOLDOWN_DAYS`. + +The offer carries the would-be-restored streak length. `repair(state, today)` adds the gap day +(`today-1`) to `frozenDays`, sets `lastRepairDay = today`, bumps `lastModified`/dirty, and persists. +The next streak compute then includes the restored run. + +### 4. Persistence (Room) + sync (Firestore) +- **`StreakStateEntity`** — single-row table `streak_state` (`@PrimaryKey id: String` = a fixed key + `"current"`). `frozenDays` stored as a sorted comma-delimited `String` via a small Room + `TypeConverter` (or an encoded column the repository owns). Fields mirror `StreakState` + `dirty`. +- **`StreakStateDao`** — `observe(): Flow`, `get()`, `upsert()`, `getDirty()`, + `clearDirty(lastModified)`. +- **`StreakStateRepository`** — `observe(): Flow` (defaulting to `StreakState()` when the + row is absent), `get()`, `update(StreakState)` (stamps `lastModified` + `dirty = true`). +- **Room migration `MIGRATION_2_3`** — `CREATE TABLE streak_state (...)`; column types/nullability + must match the entity exactly (no SQL `DEFAULT`), per the `MIGRATION_1_2` precedent. `version = 3`; + add the entity + `streakStateDao()` to `AzriDatabase`. +- **`StreakStateDto`** (Firestore) — mirrors the fields; `frozenDays` as a `List` array; + `lastModified` as `Timestamp`. Single doc at `users/{uid}/streakState/current`. +- **`RemoteSyncSource`** gains `fetchStreakState(uid): StreakStateDto?` and + `pushStreakState(uid, dto)`; **`FirestoreSyncService`** implements them via the existing `col` + helper. +- **`SyncManager`** gains a streak-state block: push the dirty row → clear dirty; pull → apply when + `shouldApplyRemote(localLastModified, remoteLastModified)` (last-write-wins, same as folders). Add + `streakStateDao` to the constructor + Koin. +- **Koin** (`AppModule`): register the DAO, repository, and `StreakStateManager`; add + `streakStateDao` to the `SyncManager` provider; add `.addMigrations(MIGRATION_2_3)`. + +### 5. `StreakProvider` (modify) +Combine logs with frozen days so the displayed streak honors freezes: +```kotlin +fun observeStreak(): Flow = + combine(reviewLogRepository.observeLogs(), streakStateRepository.observe()) { logs, state -> + val days = logs.mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + days += state.frozenDays + StreakCalculator.compute(days, localEpochDay(now(), timeZone)) + } +``` +`streakIncludingToday()` similarly unions `frozenDays` (plus today). The existing `StreakCalculator` +tests stay green (unchanged). + +### 6. UI +- **Today header (`StudyQueueScreen`):** next to the 🔥 chip, show a small **❄️ + `freezeTokens`** + when `freezeTokens > 0`. When a repair offer exists, show a **"Repair your N-day streak"** banner + with a one-tap button (calls the VM's `repairStreak()`). `StudyQueueUiState` gains + `freezeCount: Int` and `repairOffer: RepairOffer?`; `StudyQueueViewModel` reconciles on init and + exposes these (combine the existing streak flow with the streak-state flow). +- **Session summary (`SessionSummary`):** when a freeze covered a day this cycle, show **"❄️ Freeze + used — streak safe"**; show remaining freeze count; surface the repair offer here too. + `StudyViewModel` reconciles on the finish path and exposes freeze/repair fields in `StudyUiState`. + +## Data flow + +Study → review logs accrue (synced) → on app foreground / session finish, `StreakStateManager` +reconciles (auto-freeze missed days, earn freezes), persisting `StreakState` (dirty → synced LWW) → +`StreakProvider` computes the streak over `reviewDays ∪ frozenDays` → Today header shows 🔥 + ❄️; +if the streak broke recently and the user studied today, a repair offer appears → tap → gap day +frozen → streak restored. + +## Error handling + +- Reconcile is idempotent and guarded by `lastReconciledDay`; re-running it (multiple foregrounds, + re-sync) doesn't double-spend or double-award. +- Auto-freeze only ever spends available tokens; with no tokens the streak breaks exactly as today. +- Cross-device: LWW by `lastModified` resolves the rare case of two devices reconciling the same day + (acceptable — the streak from synced logs is already consistent; freezes are a small overlay). +- A repair is bounded (single day, once/30-days, requires studying today) so it can't be abused. + +## Testing + +- **`StreakStateManager` / reconcile** (JVM, pure + fake repo): auto-freeze covers 1 missed day; + covers up to `FREEZE_CAP` consecutive missed days then breaks; earning grants at 7/14 and respects + the cap; award idempotency across repeated reconciles and recompute; first-run init doesn't flood; + break resets `freezesAwardedForRun`. +- **Repair** (pure): eligible only with a single `today-1` gap + studied-today + a prior run + the + 30-day cooldown; `repair()` restores the streak and stamps `lastRepairDay`; not offered for + multi-day gaps or within cooldown. +- **`StreakStateRepository`** (fake DAO): defaults to `StreakState()` when absent; `update` stamps + `lastModified` + dirty; `frozenDays` String round-trips. +- **`StreakProvider`** (fake repos): freeze fills a gap so the streak survives; no frozen days = + unchanged behavior. +- **`SyncManager`** (extend, fake remote): dirty streak-state pushed then cleared; newer remote + applied, older skipped (LWW). +- **`MIGRATION_2_3`**: exercised by the build + (if infra present) a migration test; column names + match the entity. +- **VMs / Compose**: `StudyQueueViewModel` exposes `freezeCount` + `repairOffer`; the header chip + + repair banner render (previews / `@Preview`); `SessionSummary` shows freeze-used + repair. Emulator + unavailable ⇒ Compose is compile/preview-verified; instrumented sources compile-checked. + +**Build/test prefix:** Gradle commands MUST be prefixed with +`export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, run from +`/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +## Out of scope (v1) + +- Purchasable/premium freezes, paid instant repair, any billing (decided: earn-by-studying + free + repair). +- Multi-day repair, streak-freeze gifting, streak milestones / leagues (separate backlog items). +- A dedicated streak/calendar screen — v1 surfaces freeze count + repair on the Today header and the + session summary only. +- Changing `StreakCalculator`, the review-log persistence/sync, or the daily-reminder system. From 881a1325de3cb766bf5ec09ee96e9581b6cf7884 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 16:09:16 +0400 Subject: [PATCH 2/7] Add implementation plan for streak freeze and repair --- .../plans/2026-06-05-streak-freeze-repair.md | 805 ++++++++++++++++++ 1 file changed, 805 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-streak-freeze-repair.md diff --git a/docs/superpowers/plans/2026-06-05-streak-freeze-repair.md b/docs/superpowers/plans/2026-06-05-streak-freeze-repair.md new file mode 100644 index 0000000..74ac82c --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-streak-freeze-repair.md @@ -0,0 +1,805 @@ +# Streak Freeze + Repair 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:** Add earn-by-studying streak freezes (auto-cover a missed day) and a free, limited streak repair, synced via Firestore, without changing the pure `StreakCalculator`. + +**Architecture:** A pure reconciler (`StreakReconciler`) decides which missed days become "frozen" and when freezes are earned; a frozen day is just another active day fed to the existing `StreakCalculator`. New synced `StreakState` (Room + Firestore LWW) persists freezes/frozen-days. A `StreakStateManager` ties the reconciler to the repositories; `StreakProvider` unions frozen days into the streak. UI shows a ❄️ count + a repair banner. + +**Tech Stack:** Kotlin, Room (entity/DAO/migration v2→v3), Firestore sync via `SyncManager` (LWW), Koin, Jetpack Compose Material3, JUnit4 + coroutines-test. + +**Build/test prefix:** ALL Gradle commands MUST be prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&` and run from `/Users/astemirboziev/Developer/SimpleAnkiProject/azri_android`. + +**Commit rule:** No "claude" mention in commit messages; no Co-Authored-By / attribution trailer. Don't `git add` the unrelated untracked `docs/superpowers/plans/2026-06-04-realtime-study-queue.md`. + +--- + +## File Structure + +- `core/domain/streak/StreakState.kt` (new) — `StreakState`, `RepairOffer`, `StreakReconciler` (pure). +- `core/data/local/RoomEntities.kt` (modify) — add `StreakStateEntity`. +- `core/data/local/dao/Daos.kt` (modify) — add `StreakStateDao`. +- `core/data/local/StreakStateMappers.kt` (new) — entity↔domain (frozenDays String codec). +- `core/data/local/AzriDatabase.kt` (modify) — version 3, entity, dao, `MIGRATION_2_3`. +- `core/data/repository/Repositories.kt` (modify) — add `StreakStateRepository`. +- `core/data/firestore/FirestoreDtos.kt` (modify) — add `StreakStateDto` + entity mappers. +- `core/data/sync/RemoteSyncSource.kt`, `FirestoreSyncService.kt`, `SyncManager.kt` (modify) — streak-state sync. +- `core/data/repository/StreakStateManager.kt` (new) — reconcile/repair over repositories. +- `core/data/repository/StreakProvider.kt` (modify) — union frozen days. +- `di/AppModule.kt` (modify) — DB migration + DAO + repos + manager + SyncManager arg + VM wiring. +- `feature/queue/StudyQueueViewModel.kt` + `StudyQueueScreen.kt` (modify) — freeze chip + repair banner. +- `feature/study/StudyViewModel.kt` + `SessionSummary.kt` (modify) — freeze-used + repair in summary. +- Tests: `StreakReconcilerTest`, `StreakStateRepositoryTest`, `StreakProviderTest` (extend), `SyncManagerTest` (extend), `StudyQueueViewModelTest`/`StudyViewModelTest` (extend); `FakeStreakStateDao` in `FakeDaos.kt`. + +--- + +## Task 1: Pure reconciler + repair logic + +**Files:** +- Create: `app/src/main/java/nart/simpleanki/core/domain/streak/StreakState.kt` +- Test: `app/src/test/java/nart/simpleanki/core/domain/streak/StreakReconcilerTest.kt` + +- [ ] **Step 1: Write the failing tests** + +Create `StreakReconcilerTest.kt`: +```kotlin +package nart.simpleanki.core.domain.streak + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertNotNull +import org.junit.Test + +class StreakReconcilerTest { + + // Helper: a fresh state that has already been reconciled (so first-run seeding doesn't apply). + private fun seeded(today: Long, tokens: Int = 0, awarded: Int = 0, frozen: Set = emptySet()) = + StreakState(freezeTokens = tokens, frozenDays = frozen, freezesAwardedForRun = awarded, lastReconciledDay = today) + + @Test + fun autoFreeze_coversSingleMissedDay_whenTokenAvailable() { + // Studied days 1..5, then missed day 6; today = 7. One token covers day 6. + val reviews = setOf(1L, 2, 3, 4, 5) + val state = seeded(today = 5, tokens = 1) + val out = StreakReconciler.reconcile(reviews, state, today = 7) + assertEquals(setOf(6L), out.frozenDays) + assertEquals(0, out.freezeTokens) + // Streak survives across the frozen gap: days 1..5 + frozen 6 + ... today=7 not studied yet, + // so current run ends at 6 (alive through today since 6 == today-1). + assertEquals(6, StreakCalculator.compute(reviews + out.frozenDays, 7).current) + } + + @Test + fun autoFreeze_breaks_whenNoTokens() { + val reviews = setOf(1L, 2, 3, 4, 5) + val state = seeded(today = 5, tokens = 0) + val out = StreakReconciler.reconcile(reviews, state, today = 7) + assertEquals(emptySet(), out.frozenDays) + assertEquals(0, StreakCalculator.compute(reviews + out.frozenDays, 7).current) // broke + } + + @Test + fun earn_grantsOneFreezePerSevenDays_cappedAtTwo() { + // Studied 1..7 contiguous; today = 7. Should earn 1 freeze. + val reviews = (1L..7L).toSet() + val out = StreakReconciler.reconcile(reviews, seeded(today = 6), today = 7) + assertEquals(1, out.freezeTokens) + assertEquals(1, out.freezesAwardedForRun) + // Studied 1..21 contiguous → would earn 3, but cap is 2. + val reviews3 = (1L..21L).toSet() + val out3 = StreakReconciler.reconcile(reviews3, seeded(today = 20), today = 21) + assertEquals(2, out3.freezeTokens) + assertEquals(3, out3.freezesAwardedForRun) // counter advances past the cap + } + + @Test + fun earn_isIdempotent_acrossRepeatedReconciles() { + val reviews = (1L..7L).toSet() + val once = StreakReconciler.reconcile(reviews, seeded(today = 6), today = 7) + val twice = StreakReconciler.reconcile(reviews, once, today = 7) + assertEquals(once.freezeTokens, twice.freezeTokens) + assertEquals(once.freezesAwardedForRun, twice.freezesAwardedForRun) + } + + @Test + fun firstRun_doesNotFloodFreezes_forPreexistingStreak() { + // Never reconciled (lastReconciledDay = 0); user already has a 10-day streak. + val reviews = (1L..10L).toSet() + val out = StreakReconciler.reconcile(reviews, StreakState(), today = 10) + assertEquals(0, out.freezeTokens) // seeded awarded = 10/7 = 1, no new award at 10 + assertEquals(1, out.freezesAwardedForRun) + } + + @Test + fun brokenRun_resetsAwardCounter() { + // A new short run after a break: awarded should drop to current/7. + val reviews = setOf(20L) // single day, today = 20 + val out = StreakReconciler.reconcile(reviews, seeded(today = 19, awarded = 3), today = 20) + assertEquals(0, out.freezesAwardedForRun) + } + + @Test + fun repair_offeredOnlyForSingleRecentGap_afterStudyingToday() { + // Studied 1..5, missed 6, studied today=7. Prior run (ending 5)=5, gap=6, today studied. + val reviews = setOf(1L, 2, 3, 4, 5, 7) + val state = seeded(today = 7) // broken: day 6 not frozen + val offer = StreakReconciler.repairEligibility(reviews, state, today = 7) + assertNotNull(offer) + assertEquals(7, offer!!.restoredStreak) // prior run 5 + frozen gap + today = 7 + + val repaired = StreakReconciler.repair(state, today = 7) + assertEquals(setOf(6L), repaired.frozenDays) + assertEquals(7L, repaired.lastRepairDay) + assertEquals(7, StreakCalculator.compute(reviews + repaired.frozenDays, 7).current) + } + + @Test + fun repair_notOffered_withoutStudyingToday_orWithinCooldown_orMultiDayGap() { + // Not studied today. + assertNull(StreakReconciler.repairEligibility(setOf(1L, 2, 3, 4, 5), seeded(today = 7), today = 7)) + // Within cooldown. + val recentRepair = seeded(today = 7).copy(lastRepairDay = 6) + assertNull(StreakReconciler.repairEligibility(setOf(1L, 2, 3, 4, 5, 7), recentRepair, today = 7)) + // Multi-day gap (missed 5 and 6). + assertNull(StreakReconciler.repairEligibility(setOf(1L, 2, 3, 4, 7), seeded(today = 7), today = 7)) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail (compile failure — symbols don't exist)** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.streak.StreakReconcilerTest"` +Expected: COMPILE FAILURE. + +- [ ] **Step 3: Implement `StreakState.kt`** + +Create `app/src/main/java/nart/simpleanki/core/domain/streak/StreakState.kt`: +```kotlin +package nart.simpleanki.core.domain.streak + +/** Persisted, synced streak overlay: freezes earned and the days they cover. */ +data class StreakState( + val freezeTokens: Int = 0, + val frozenDays: Set = emptySet(), + val freezesAwardedForRun: Int = 0, + val lastReconciledDay: Long = 0, + val lastRepairDay: Long = 0, +) + +/** A streak that can be restored by a (free, limited) repair. */ +data class RepairOffer(val restoredStreak: Int) + +/** + * Pure streak-overlay logic. Operates on civil-day indices (bucket with `localEpochDay` first). + * A frozen day is treated as an active day by [StreakCalculator], so the run survives the gap. + */ +object StreakReconciler { + const val FREEZE_CAP = 2 + const val FREEZE_EARN_EVERY = 7 + const val REPAIR_COOLDOWN_DAYS = 30L + + /** Advances [state] for [today]: auto-freeze elapsed missed days, then earn freezes. Idempotent. */ + fun reconcile(reviewDays: Set, state: StreakState, today: Long): StreakState { + var frozen = state.frozenDays + var tokens = state.freezeTokens + var awarded = state.freezesAwardedForRun + + // 1. Auto-freeze fully-elapsed missed days (once per civil day). + if (today > state.lastReconciledDay) { + val active = reviewDays + frozen + val lastActive = active.filter { it <= today }.maxOrNull() + if (lastActive != null) { + var d = lastActive + 1 + while (d <= today - 1) { + if (d !in reviewDays && d !in frozen) { + if (tokens > 0) { frozen = frozen + d; tokens -= 1 } else break + } + d++ + } + } + } + + val current = StreakCalculator.compute(reviewDays + frozen, today).current + + // First-ever reconcile: seed the counter so a pre-existing streak doesn't dump freezes. + if (state.lastReconciledDay == 0L) { + awarded = maxOf(awarded, current / FREEZE_EARN_EVERY) + } + // A new (shorter) run started: reset the counter to the current run. + if (current < awarded * FREEZE_EARN_EVERY) { + awarded = current / FREEZE_EARN_EVERY + } + // Earn one freeze per new multiple reached (counter advances even when the cap blocks a grant). + while (current >= (awarded + 1) * FREEZE_EARN_EVERY) { + awarded += 1 + if (tokens < FREEZE_CAP) tokens += 1 + } + + return state.copy( + freezeTokens = tokens, + frozenDays = frozen, + freezesAwardedForRun = awarded, + lastReconciledDay = maxOf(state.lastReconciledDay, today), + ) + } + + /** A repair is offered for a single missed day at [today]-1, only after studying today, once per cooldown. */ + fun repairEligibility(reviewDays: Set, state: StreakState, today: Long): RepairOffer? { + if (today !in reviewDays) return null + if (state.lastRepairDay != 0L && today - state.lastRepairDay < REPAIR_COOLDOWN_DAYS) return null + val active = reviewDays + state.frozenDays + if ((today - 1) in active) return null // no gap at yesterday → nothing to repair + val priorRun = runEndingAt(active, today - 2) + if (priorRun < 1) return null // no real streak existed before the gap + return RepairOffer(restoredStreak = priorRun + 2) // prior run + frozen gap + today + } + + /** Freezes the [today]-1 gap day and records the repair. Caller persists. */ + fun repair(state: StreakState, today: Long): StreakState = + state.copy(frozenDays = state.frozenDays + (today - 1), lastRepairDay = today) + + private fun runEndingAt(days: Set, end: Long): Int { + var n = 0 + var d = end + while (d in days) { n++; d-- } + return n + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.domain.streak.StreakReconcilerTest"` +Expected: PASS (all 8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/nart/simpleanki/core/domain/streak/StreakState.kt \ + app/src/test/java/nart/simpleanki/core/domain/streak/StreakReconcilerTest.kt +git commit -m "Add pure streak freeze/repair reconciler" +``` + +--- + +## Task 2: Room persistence (entity, DAO, migration, repository) + +**Files:** +- Modify: `core/data/local/RoomEntities.kt`, `core/data/local/dao/Daos.kt`, `core/data/local/AzriDatabase.kt`, `core/data/repository/Repositories.kt`, `di/AppModule.kt` +- Create: `core/data/local/StreakStateMappers.kt` +- Test: `app/src/test/java/nart/simpleanki/core/data/repository/StreakStateRepositoryTest.kt`, and add `FakeStreakStateDao` to `FakeDaos.kt` + +- [ ] **Step 1: Add the entity** (`RoomEntities.kt`, append): +```kotlin +@Entity(tableName = "streak_state") +data class StreakStateEntity( + @PrimaryKey val id: String = "current", + val freezeTokens: Int, + /** Civil-day indices covered by a freeze/repair, sorted, comma-separated (empty string = none). */ + val frozenDays: String, + val freezesAwardedForRun: Int, + val lastReconciledDay: Long, + val lastRepairDay: Long, + val lastModified: Long, + val dirty: Boolean = true, +) +``` +(Ensure `androidx.room.Entity` / `PrimaryKey` are imported — they already are for the other entities in this file.) + +- [ ] **Step 2: Add the DAO** (`dao/Daos.kt`, append): +```kotlin +@Dao +interface StreakStateDao { + @Query("SELECT * FROM streak_state WHERE id = 'current'") + fun observe(): Flow + + @Query("SELECT * FROM streak_state WHERE id = 'current'") + suspend fun get(): StreakStateEntity? + + @Upsert + suspend fun upsert(entity: StreakStateEntity) + + @Query("SELECT * FROM streak_state WHERE dirty = 1") + suspend fun getDirty(): StreakStateEntity? + + @Query("UPDATE streak_state SET dirty = 0 WHERE id = 'current' AND lastModified = :lastModified") + suspend fun clearDirty(lastModified: Long) +} +``` +(Match the existing import style in `Daos.kt`: `androidx.room.Dao/Query/Upsert`, `kotlinx.coroutines.flow.Flow`, and `StreakStateEntity` from `nart.simpleanki.core.data.local`. If the file uses `@Upsert` elsewhere, reuse it; otherwise use `@Insert(onConflict = OnConflictStrategy.REPLACE)`.) + +- [ ] **Step 3: Add mappers** — create `core/data/local/StreakStateMappers.kt`: +```kotlin +package nart.simpleanki.core.data.local + +import nart.simpleanki.core.domain.streak.StreakState + +fun StreakStateEntity.toDomain(): StreakState = StreakState( + freezeTokens = freezeTokens, + frozenDays = frozenDays.split(",").filter { it.isNotBlank() }.map { it.toLong() }.toSet(), + freezesAwardedForRun = freezesAwardedForRun, + lastReconciledDay = lastReconciledDay, + lastRepairDay = lastRepairDay, +) + +fun StreakState.toEntity(lastModified: Long, dirty: Boolean): StreakStateEntity = StreakStateEntity( + id = "current", + freezeTokens = freezeTokens, + frozenDays = frozenDays.sorted().joinToString(","), + freezesAwardedForRun = freezesAwardedForRun, + lastReconciledDay = lastReconciledDay, + lastRepairDay = lastRepairDay, + lastModified = lastModified, + dirty = dirty, +) +``` + +- [ ] **Step 4: Bump the DB + add migration** (`AzriDatabase.kt`): + - Add `StreakStateEntity::class` to `entities`, set `version = 3`, add `abstract fun streakStateDao(): StreakStateDao` and its import. + - Append the migration (column types/nullability MUST match the entity; no SQL DEFAULT — mirror `MIGRATION_1_2`): +```kotlin +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `streak_state` (" + + "`id` TEXT NOT NULL, `freezeTokens` INTEGER NOT NULL, `frozenDays` TEXT NOT NULL, " + + "`freezesAwardedForRun` INTEGER NOT NULL, `lastReconciledDay` INTEGER NOT NULL, " + + "`lastRepairDay` INTEGER NOT NULL, `lastModified` INTEGER NOT NULL, " + + "`dirty` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + } +} +``` + +- [ ] **Step 5: Add the repository** (`Repositories.kt`, append): +```kotlin +class StreakStateRepository( + private val dao: StreakStateDao, + private val now: () -> Long = { System.currentTimeMillis() }, +) { + fun observe(): Flow = + dao.observe().map { it?.toDomain() ?: StreakState() } + + suspend fun get(): StreakState = dao.get()?.toDomain() ?: StreakState() + + suspend fun update(state: StreakState) { + dao.upsert(state.toEntity(lastModified = now(), dirty = true)) + } +} +``` +(Imports: `nart.simpleanki.core.data.local.dao.StreakStateDao`, `nart.simpleanki.core.data.local.toDomain`/`toEntity`, `nart.simpleanki.core.domain.streak.StreakState`, `kotlinx.coroutines.flow.Flow/map` — match the file's existing imports.) + +- [ ] **Step 6: Add `FakeStreakStateDao`** to `FakeDaos.kt`: +```kotlin +class FakeStreakStateDao : StreakStateDao { + private val store = MutableStateFlow(null) + override fun observe(): Flow = store + override suspend fun get(): StreakStateEntity? = store.value + override suspend fun upsert(entity: StreakStateEntity) { store.value = entity } + override suspend fun getDirty(): StreakStateEntity? = store.value?.takeIf { it.dirty } + override suspend fun clearDirty(lastModified: Long) { + store.value?.let { if (it.lastModified == lastModified) store.value = it.copy(dirty = false) } + } +} +``` +(Match existing imports in `FakeDaos.kt`: `StreakStateDao`, `StreakStateEntity`, `MutableStateFlow`, `Flow`.) + +- [ ] **Step 7: Write the repository test** — `StreakStateRepositoryTest.kt`: +```kotlin +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import nart.simpleanki.core.domain.streak.StreakState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreakStateRepositoryTest { + private val now = 1_700_000_000_000L + + @Test + fun observe_defaultsToEmptyState_whenAbsent() = runTest { + val repo = StreakStateRepository(FakeStreakStateDao(), now = { now }) + assertEquals(StreakState(), repo.observe().first()) + assertEquals(StreakState(), repo.get()) + } + + @Test + fun update_stampsLastModifiedAndDirty_andRoundTripsFrozenDays() = runTest { + val dao = FakeStreakStateDao() + val repo = StreakStateRepository(dao, now = { now }) + repo.update(StreakState(freezeTokens = 2, frozenDays = setOf(6L, 3L), freezesAwardedForRun = 1, lastReconciledDay = 7)) + val saved = dao.get()!! + assertEquals(now, saved.lastModified) + assertTrue(saved.dirty) + assertEquals("3,6", saved.frozenDays) // sorted + assertEquals(setOf(3L, 6L), repo.get().frozenDays) + } +} +``` + +- [ ] **Step 8: Wire Koin** (`AppModule.kt`): + - In the Room builder, add `.addMigrations(MIGRATION_2_3)` (alongside `MIGRATION_1_2`) and import `MIGRATION_2_3`. + - Add `single { get().streakStateDao() }`. + - Add `single { StreakStateRepository(get()) }`. + +- [ ] **Step 9: Verify** — compile + the new unit test + full suite: + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest` +Expected: BUILD SUCCESSFUL; `StreakStateRepositoryTest` passes. + +- [ ] **Step 10: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/data/local app/src/main/java/nart/simpleanki/core/data/repository/Repositories.kt \ + app/src/main/java/nart/simpleanki/di/AppModule.kt \ + app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt \ + app/src/test/java/nart/simpleanki/core/data/repository/StreakStateRepositoryTest.kt +git commit -m "Persist streak state in Room with migration" +``` + +--- + +## Task 3: Firestore sync for streak state + +**Files:** +- Modify: `core/data/firestore/FirestoreDtos.kt`, `core/data/sync/RemoteSyncSource.kt`, `core/data/sync/FirestoreSyncService.kt`, `core/data/sync/SyncManager.kt`, `di/AppModule.kt` +- Test: `app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt` (extend) + +- [ ] **Step 1: Write the failing SyncManager tests** (append to `SyncManagerTest.kt`) + +Add to the inline `FakeRemote`: a field `var streakState: StreakStateDto? = null`, a `var pushedStreakState: StreakStateDto? = null`, and: +```kotlin + override suspend fun fetchStreakState(uid: String) = streakState + override suspend fun pushStreakState(uid: String, dto: StreakStateDto) { pushedStreakState = dto } +``` +Add a helper and two tests: +```kotlin + private fun streakEntity(lastModified: Long, dirty: Boolean) = + nart.simpleanki.core.data.local.StreakStateEntity( + id = "current", freezeTokens = 1, frozenDays = "3,6", freezesAwardedForRun = 1, + lastReconciledDay = 7, lastRepairDay = 0, lastModified = lastModified, dirty = dirty, + ) + + @Test + fun streakState_pushDirty_thenClearDirty() = runTest { + val dao = FakeStreakStateDao() + dao.upsert(streakEntity(lastModified = 100, dirty = true)) + val remote = FakeRemote() + val (m, _) = media() + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), dao, remote, m) + sync.sync("u1") + assertEquals(100L, remote.pushedStreakState!!.lastModifiedMillis()) + assertFalse(dao.get()!!.dirty) + } + + @Test + fun streakState_pull_appliesNewer_skipsOlder() = runTest { + val dao = FakeStreakStateDao() + dao.upsert(streakEntity(lastModified = 100, dirty = false)) + val newer = StreakStateDto.fromEntity(streakEntity(lastModified = 200, dirty = false)).apply { freezeTokens = 2 } + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), dao, + FakeRemote(streakState = newer), media().first) + sync.sync("u1") + assertEquals(2, dao.get()!!.freezeTokens) // newer remote applied + } +``` +(Adjust `media().first`/destructuring to match the existing test's `media()` helper shape — it returns `Pair(MediaManager, uploader)`; use `val (m, _) = media()` as the other tests do.) + +- [ ] **Step 2: Run to verify failure** (compile error — `StreakStateDto`, the remote methods, and the 7-arg `SyncManager` don't exist): + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.data.sync.SyncManagerTest"` +Expected: COMPILE FAILURE. + +- [ ] **Step 3: Add `StreakStateDto`** (`FirestoreDtos.kt`, append; mirror the file's `@PropertyName`/`Timestamp` style): +```kotlin +data class StreakStateDto( + @DocumentId var id: String? = "current", + @get:PropertyName("freeze_tokens") @set:PropertyName("freeze_tokens") var freezeTokens: Int = 0, + @get:PropertyName("frozen_days") @set:PropertyName("frozen_days") var frozenDays: List = emptyList(), + @get:PropertyName("freezes_awarded_for_run") @set:PropertyName("freezes_awarded_for_run") var freezesAwardedForRun: Int = 0, + @get:PropertyName("last_reconciled_day") @set:PropertyName("last_reconciled_day") var lastReconciledDay: Long = 0, + @get:PropertyName("last_repair_day") @set:PropertyName("last_repair_day") var lastRepairDay: Long = 0, + @get:PropertyName("last_modified") @set:PropertyName("last_modified") var lastModified: Timestamp = Timestamp(Date(0)), +) { + fun lastModifiedMillis(): Long = lastModified.toMillis() + + fun toEntity(dirty: Boolean) = nart.simpleanki.core.data.local.StreakStateEntity( + id = "current", + freezeTokens = freezeTokens, + frozenDays = frozenDays.sorted().joinToString(","), + freezesAwardedForRun = freezesAwardedForRun, + lastReconciledDay = lastReconciledDay, + lastRepairDay = lastRepairDay, + lastModified = lastModified.toMillis(), + dirty = dirty, + ) + + companion object { + fun fromEntity(e: nart.simpleanki.core.data.local.StreakStateEntity) = StreakStateDto( + id = "current", + freezeTokens = e.freezeTokens, + frozenDays = e.frozenDays.split(",").filter { it.isNotBlank() }.map { it.toLong() }, + freezesAwardedForRun = e.freezesAwardedForRun, + lastReconciledDay = e.lastReconciledDay, + lastRepairDay = e.lastRepairDay, + lastModified = e.lastModified.toTimestamp(), + ) + } +} +``` +(`toMillis()`/`toTimestamp()` are the existing private helpers in this file — they're accessible to code in the same file. If a member function can't see the file-private extensions, inline `Timestamp(Date(this))` / `toDate().time`.) + +- [ ] **Step 4: Extend `RemoteSyncSource`**: +```kotlin + suspend fun fetchStreakState(uid: String): StreakStateDto? + suspend fun pushStreakState(uid: String, dto: StreakStateDto) +``` +(import `StreakStateDto`.) + +- [ ] **Step 5: Implement in `FirestoreSyncService`** (single doc `users/{uid}/streakState/current`): +```kotlin + override suspend fun fetchStreakState(uid: String): StreakStateDto? = + col(uid, "streakState").document("current").get().await().toObject(StreakStateDto::class.java) + + override suspend fun pushStreakState(uid: String, dto: StreakStateDto) { + col(uid, "streakState").document("current").set(dto).await() + } +``` +(`await` and `toObject` are already used in this file; import `StreakStateDto`.) + +- [ ] **Step 6: Wire `SyncManager`**: + - Add `private val streakStateDao: StreakStateDao,` to the constructor (after `reviewLogDao`, before `remote`), and import it. + - In `push(uid)`, after the review-logs block: +```kotlin + streakStateDao.getDirty()?.let { row -> + remote.pushStreakState(uid, StreakStateDto.fromEntity(row)) + streakStateDao.clearDirty(row.lastModified) + } +``` + - In `pull(uid)`, after the review-logs pull: +```kotlin + remote.fetchStreakState(uid)?.let { dto -> + if (shouldApplyRemote(streakStateDao.get()?.lastModified, dto.lastModifiedMillis())) { + streakStateDao.upsert(dto.toEntity(dirty = false)) + } + } +``` + - Import `StreakStateDto`. + +- [ ] **Step 7: Update Koin `SyncManager` provider** (`AppModule.kt`) to pass `streakStateDao = get()` (or positionally after `reviewLogDao`). + +- [ ] **Step 8: Run the SyncManager tests + full suite** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest` +Expected: BUILD SUCCESSFUL; the two new streak-state sync tests pass; existing sync tests still pass (their `SyncManager(...)` constructions must add the new `FakeStreakStateDao()` arg — update every `SyncManager(...)` call in `SyncManagerTest` to include it in the `reviewLogDao`→`remote` gap). + +- [ ] **Step 9: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/data/firestore/FirestoreDtos.kt \ + app/src/main/java/nart/simpleanki/core/data/sync app/src/main/java/nart/simpleanki/di/AppModule.kt \ + app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt +git commit -m "Sync streak state via Firestore (last-write-wins)" +``` + +--- + +## Task 4: StreakStateManager + StreakProvider integration + +**Files:** +- Create: `core/data/repository/StreakStateManager.kt` +- Modify: `core/data/repository/StreakProvider.kt`, `di/AppModule.kt` +- Test: `app/src/test/java/nart/simpleanki/core/data/repository/StreakStateManagerTest.kt`, `StreakProviderTest.kt` (extend) + +- [ ] **Step 1: Write the failing tests** — `StreakStateManagerTest.kt`: +```kotlin +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import nart.simpleanki.core.data.local.dao.ReviewLogDao +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import java.util.TimeZone + +class StreakStateManagerTest { + // Civil-day d (UTC) → millis at noon, so localEpochDay(UTC) == d. + private val utc = TimeZone.getTimeZone("UTC") + private fun dayMillis(d: Long) = d * 86_400_000L + 43_200_000L + + private fun logsRepo(days: List): ReviewLogRepository { + val dao = FakeReviewLogDao() + // Reuse the repo's append to create logs on the given civil days. + val repo = ReviewLogRepository(dao, newId = { java.util.UUID.randomUUID().toString() }) + return repo.also { r -> + kotlinx.coroutines.runBlocking { + days.forEach { d -> r.append("c1", reviewLogOn(dayMillis(d))) } + } + } + } + + @Test + fun reconcile_persistsAutoFreeze_andProvidesFreezeCount() = runTest { + val stateRepo = StreakStateRepository(FakeStreakStateDao(), now = { 0L }) + // pre-seed a token + a prior reconcile so first-run seeding doesn't apply + stateRepo.update(nart.simpleanki.core.domain.streak.StreakState(freezeTokens = 1, lastReconciledDay = 5)) + val mgr = StreakStateManager(stateRepo, logsRepo((1L..5L).toList()), now = { dayMillis(7) }, timeZone = utc) + mgr.reconcile() + assertEquals(setOf(6L), stateRepo.get().frozenDays) // day 6 frozen + } + + @Test + fun repair_offeredAndRestores() = runTest { + val stateRepo = StreakStateRepository(FakeStreakStateDao(), now = { 0L }) + stateRepo.update(nart.simpleanki.core.domain.streak.StreakState(lastReconciledDay = 7)) + val mgr = StreakStateManager(stateRepo, logsRepo(listOf(1L, 2, 3, 4, 5, 7)), now = { dayMillis(7) }, timeZone = utc) + assertNotNull(mgr.repairOffer()) + mgr.repair() + assertEquals(setOf(6L), stateRepo.get().frozenDays) + } +} +``` +(`reviewLogOn(millis)` builds a `ReviewLog` with `review = millis` and the other FSRS fields defaulted/zeroed — match the `ReviewLog` constructor. If a test helper for this already exists in the repository tests, reuse it; otherwise add a small private builder.) + +- [ ] **Step 2: Run to verify failure** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.data.repository.StreakStateManagerTest"` +Expected: COMPILE FAILURE. + +- [ ] **Step 3: Implement `StreakStateManager.kt`**: +```kotlin +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.first +import nart.simpleanki.core.domain.streak.RepairOffer +import nart.simpleanki.core.domain.streak.StreakReconciler +import nart.simpleanki.core.domain.streak.localEpochDay +import java.util.TimeZone + +/** Ties the pure [StreakReconciler] to persisted state + review logs. Call [reconcile] on app + * foreground and after a session; never inside a Flow. */ +class StreakStateManager( + private val streakStateRepository: StreakStateRepository, + private val reviewLogRepository: ReviewLogRepository, + private val now: () -> Long = { System.currentTimeMillis() }, + private val timeZone: TimeZone = TimeZone.getDefault(), +) { + private suspend fun reviewDays(): Set = + reviewLogRepository.observeLogs().first().mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + + suspend fun reconcile() { + val today = localEpochDay(now(), timeZone) + val state = streakStateRepository.get() + val updated = StreakReconciler.reconcile(reviewDays(), state, today) + if (updated != state) streakStateRepository.update(updated) + } + + suspend fun repairOffer(): RepairOffer? { + val today = localEpochDay(now(), timeZone) + return StreakReconciler.repairEligibility(reviewDays(), streakStateRepository.get(), today) + } + + suspend fun repair() { + val today = localEpochDay(now(), timeZone) + streakStateRepository.update(StreakReconciler.repair(streakStateRepository.get(), today)) + } +} +``` + +- [ ] **Step 4: Update `StreakProvider`** to union frozen days. Constructor gains `streakStateRepository`: +```kotlin +class StreakProvider( + private val reviewLogRepository: ReviewLogRepository, + private val streakStateRepository: StreakStateRepository, + private val now: () -> Long = { System.currentTimeMillis() }, + private val timeZone: TimeZone = TimeZone.getDefault(), +) { + fun observeStreak(): Flow = + combine(reviewLogRepository.observeLogs(), streakStateRepository.observe()) { logs, state -> + val days = logs.mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + days += state.frozenDays + StreakCalculator.compute(days, localEpochDay(now(), timeZone)) + } + + suspend fun streakIncludingToday(): Streak { + val today = localEpochDay(now(), timeZone) + val days = reviewLogRepository.observeLogs().first() + .mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + days += streakStateRepository.observe().first().frozenDays + days += today + return StreakCalculator.compute(days, today) + } +} +``` +(Add `import kotlinx.coroutines.flow.combine`.) + +**IMPORTANT — fix the broken call site:** `StudyViewModel.kt:57` currently defaults +`private val streakProvider: StreakProvider = StreakProvider(reviewLogRepository, now)`, which no +longer compiles after this constructor change. Fix it by **removing the inline default** and making +it an injected param: +```kotlin + private val streakProvider: StreakProvider, +``` +Then: +- In `AppModule.kt`, the `StudyViewModel(...)` Koin block must pass `streakProvider = get()`. +- In `StudyViewModelTest`, every `StudyViewModel(...)` construction that relied on the default must + now pass `streakProvider = StreakProvider(reviewLogRepository, StreakStateRepository(FakeStreakStateDao()), now = { now })` + (build it from the same fake review-log repo the test already uses). +`StudyQueueViewModel` already takes `streakProvider` as a required injected param (no inline default), +so only its Koin call resolves `get()` — unchanged. + +- [ ] **Step 5: Extend `StreakProviderTest`** — a freeze fills a gap so the streak survives: +```kotlin + @Test + fun frozenDay_fillsGap_soStreakSurvives() = runTest { + // logs on civil days 1..5, today = 7, frozen day 6 → run alive through today-1. + val logs = ReviewLogRepository(FakeReviewLogDao()) + listOf(1L, 2, 3, 4, 5).forEach { d -> logs.append("c1", reviewLogOn(d * 86_400_000L + 43_200_000L)) } + val stateRepo = StreakStateRepository(FakeStreakStateDao(), now = { 0L }) + stateRepo.update(StreakState(frozenDays = setOf(6L))) + val provider = StreakProvider(logs, stateRepo, now = { 7L * 86_400_000L + 43_200_000L }, + timeZone = TimeZone.getTimeZone("UTC")) + assertEquals(6, provider.observeStreak().first().current) + } +``` +(Reuse the existing `StreakProviderTest` constructions — they must now pass a `StreakStateRepository(FakeStreakStateDao())` as the new 2nd arg.) + +- [ ] **Step 6: Koin** (`AppModule.kt`): change `single { StreakProvider(get()) }` → `single { StreakProvider(get(), get()) }`; add `single { StreakStateManager(get(), get()) }`. + +- [ ] **Step 7: Run the tests + full suite** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest` +Expected: BUILD SUCCESSFUL; new manager/provider tests pass; existing `StreakProviderTest` updated and green. + +- [ ] **Step 8: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/data/repository/StreakStateManager.kt \ + app/src/main/java/nart/simpleanki/core/data/repository/StreakProvider.kt \ + 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/core/data/repository/StreakStateManagerTest.kt \ + app/src/test/java/nart/simpleanki/core/data/repository/StreakProviderTest.kt \ + app/src/test/java/nart/simpleanki/feature/study/StudyViewModelTest.kt +git commit -m "Add StreakStateManager and union freezes into StreakProvider" +``` + +--- + +## Task 5: ViewModels + UI (freeze chip + repair banner) + +**Files:** +- Modify: `feature/queue/StudyQueueViewModel.kt`, `feature/queue/StudyQueueScreen.kt`, `feature/study/StudyViewModel.kt`, `feature/study/SessionSummary.kt`, `di/AppModule.kt` +- Test: extend `StudyQueueViewModelTest.kt` + +- [ ] **Step 1: Write the failing VM test** (append to `StudyQueueViewModelTest.kt`): with a fake setup where `freezeTokens > 0` and a repair is eligible, `uiState.freezeCount` and `uiState.repairOffer` are exposed. (Construct the VM with a `StreakStateManager`/`StreakProvider` over fakes pre-seeded so a freeze token exists and yesterday is a single gap with today studied; assert `freezeCount` and `repairOffer != null`.) Mirror the existing streak test in this file for wiring. + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.feature.queue.StudyQueueViewModelTest"` → COMPILE FAILURE. + +- [ ] **Step 2: `StudyQueueViewModel`** — inject `StreakStateManager`; add `freezeCount: Int = 0` and `repairOffer: RepairOffer? = null` to `StudyQueueUiState`; call `streakStateManager.reconcile()` in `init` (in a `viewModelScope.launch`), then fold the streak-state flow + a computed repair offer into the state (extend the existing outer `combine` that already adds streak, or add another `combine` stage reading `streakStateRepository.observe()` for `freezeCount` and a one-shot `repairOffer()` after reconcile). Add `fun repairStreak()` → `viewModelScope.launch { streakStateManager.repair(); refresh repairOffer/streak }`. + +- [ ] **Step 3: `StudyQueueScreen`** — in the header `actions` next to the 🔥 chip, when `state.freezeCount > 0` show `Text("❄️ ${state.freezeCount}")` styled like the streak chip. Add a repair banner item at the top of the `LazyColumn` when `state.repairOffer != null`: a `Card`/`Row` "Repair your ${state.repairOffer.restoredStreak}-day streak" + an `OutlinedButton("Repair", onClick = onRepair)`. Thread `onRepair: () -> Unit = {}` through `StudyQueueScreen`/`StudyQueueContent` to `viewModel::repairStreak`. + +- [ ] **Step 4: `StudyViewModel` + `SessionSummary`** — inject `StreakStateManager`; on the session-finish path call `streakStateManager.reconcile()` before reading the streak; add `freezeCount: Int` and `repairOffer: RepairOffer?` to `StudyUiState`; expose them; add `fun repairStreak()`. In `SessionSummary`, when `state.repairOffer != null` show the same repair affordance, and show `"❄️ ${state.freezeCount}"` near the streak badge. (A "freeze used" line is optional polish — show `"❄️ Freeze used — streak safe"` when the reconcile added a frozen day this cycle; if detecting that is awkward, omit it and just show the freeze count. Keep scope tight.) + +- [ ] **Step 5: Koin** — add `streakStateManager = get()` to the `StudyQueueViewModel` and `StudyViewModel` provider blocks. + +- [ ] **Step 6: Verify** — VM test + compile both targets + assemble: + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:assembleDebug` +Expected: BUILD SUCCESSFUL; the VM test passes. + +- [ ] **Step 7: Commit** +```bash +git add app/src/main/java/nart/simpleanki/feature/queue app/src/main/java/nart/simpleanki/feature/study \ + app/src/main/java/nart/simpleanki/di/AppModule.kt \ + app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt +git commit -m "Surface streak freezes and repair in the queue and session summary" +``` + +--- + +## Final verification + +- [ ] Full suite + APK: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest :app:assembleDebug` → BUILD SUCCESSFUL. +- [ ] (Optional, emulator) Smoke test: build a streak, force a missed day (adjust device date or seed logs), reopen → a freeze is consumed and the 🔥 survives with ❄️ shown; with no freeze, the streak breaks and a "Repair your N-day streak" banner appears after studying today; tap → streak restored. From 4264b1cd8c5d25724fdf138dac45d11fbb22fd48 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 16:11:56 +0400 Subject: [PATCH 3/7] Add pure streak freeze/repair reconciler --- .../core/domain/streak/StreakState.kt | 94 ++++++++++++++++++ .../domain/streak/StreakReconcilerTest.kt | 97 +++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 app/src/main/java/nart/simpleanki/core/domain/streak/StreakState.kt create mode 100644 app/src/test/java/nart/simpleanki/core/domain/streak/StreakReconcilerTest.kt diff --git a/app/src/main/java/nart/simpleanki/core/domain/streak/StreakState.kt b/app/src/main/java/nart/simpleanki/core/domain/streak/StreakState.kt new file mode 100644 index 0000000..503f781 --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/domain/streak/StreakState.kt @@ -0,0 +1,94 @@ +package nart.simpleanki.core.domain.streak + +/** + * Persisted, synced streak overlay: freezes earned and the days they cover. + * [frozenDays], [lastReconciledDay] and [lastRepairDay] are civil-day indices (as produced by + * `localEpochDay`), not millis. + */ +data class StreakState( + val freezeTokens: Int = 0, + val frozenDays: Set = emptySet(), // civil-day index + val freezesAwardedForRun: Int = 0, + val lastReconciledDay: Long = 0, // civil-day index + val lastRepairDay: Long = 0, // civil-day index +) + +/** A streak that can be restored by a (free, limited) repair. */ +data class RepairOffer(val restoredStreak: Int) + +/** + * Pure streak-overlay logic. Operates on civil-day indices (bucket with `localEpochDay` first). + * A frozen day is treated as an active day by [StreakCalculator], so the run survives the gap. + */ +object StreakReconciler { + const val FREEZE_CAP = 2 + const val FREEZE_EARN_EVERY = 7 + const val REPAIR_COOLDOWN_DAYS = 30L + + /** Advances [state] for [today]: auto-freeze elapsed missed days, then earn freezes. Idempotent. */ + fun reconcile(reviewDays: Set, state: StreakState, today: Long): StreakState { + var frozen = state.frozenDays + var tokens = state.freezeTokens + var awarded = state.freezesAwardedForRun + + if (today > state.lastReconciledDay) { + val active = reviewDays + frozen + val lastActive = active.filter { it <= today }.maxOrNull() + if (lastActive != null) { + var d = lastActive + 1 + while (d <= today - 1) { + if (d !in reviewDays && d !in frozen) { + if (tokens > 0) { frozen = frozen + d; tokens -= 1 } else break + } + d++ + } + } + } + + val current = StreakCalculator.compute(reviewDays + frozen, today).current + + if (state.lastReconciledDay == 0L) { + awarded = maxOf(awarded, current / FREEZE_EARN_EVERY) + } + if (current < awarded * FREEZE_EARN_EVERY) { + awarded = current / FREEZE_EARN_EVERY + } + while (current >= (awarded + 1) * FREEZE_EARN_EVERY) { + awarded += 1 + if (tokens < FREEZE_CAP) tokens += 1 + } + + return state.copy( + freezeTokens = tokens, + frozenDays = frozen, + freezesAwardedForRun = awarded, + lastReconciledDay = maxOf(state.lastReconciledDay, today), + ) + } + + /** + * A repair is offered for a single missed day at [today]-1, only after studying today, once per + * cooldown. Assumes [reconcile] has already run for [today] (so an auto-freeze that would cover + * [today]-1 is already in [state].frozenDays). + */ + fun repairEligibility(reviewDays: Set, state: StreakState, today: Long): RepairOffer? { + if (today !in reviewDays) return null + if (state.lastRepairDay != 0L && today - state.lastRepairDay < REPAIR_COOLDOWN_DAYS) return null + val active = reviewDays + state.frozenDays + if ((today - 1) in active) return null + val priorRun = runEndingAt(active, today - 2) + if (priorRun < 1) return null + return RepairOffer(restoredStreak = priorRun + 2) + } + + /** Freezes the [today]-1 gap day and records the repair. Caller persists. */ + fun repair(state: StreakState, today: Long): StreakState = + state.copy(frozenDays = state.frozenDays + (today - 1), lastRepairDay = today) + + private fun runEndingAt(days: Set, end: Long): Int { + var n = 0 + var d = end + while (d in days) { n++; d-- } + return n + } +} diff --git a/app/src/test/java/nart/simpleanki/core/domain/streak/StreakReconcilerTest.kt b/app/src/test/java/nart/simpleanki/core/domain/streak/StreakReconcilerTest.kt new file mode 100644 index 0000000..fd6877c --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/domain/streak/StreakReconcilerTest.kt @@ -0,0 +1,97 @@ +package nart.simpleanki.core.domain.streak + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertNotNull +import org.junit.Test + +class StreakReconcilerTest { + + private fun seeded(today: Long, tokens: Int = 0, awarded: Int = 0, frozen: Set = emptySet()) = + StreakState(freezeTokens = tokens, frozenDays = frozen, freezesAwardedForRun = awarded, lastReconciledDay = today) + + @Test + fun autoFreeze_coversSingleMissedDay_whenTokenAvailable() { + val reviews = setOf(1L, 2, 3, 4, 5) + val state = seeded(today = 5, tokens = 1) + val out = StreakReconciler.reconcile(reviews, state, today = 7) + assertEquals(setOf(6L), out.frozenDays) + assertEquals(0, out.freezeTokens) + assertEquals(6, StreakCalculator.compute(reviews + out.frozenDays, 7).current) + } + + @Test + fun autoFreeze_breaks_whenNoTokens() { + val reviews = setOf(1L, 2, 3, 4, 5) + val state = seeded(today = 5, tokens = 0) + val out = StreakReconciler.reconcile(reviews, state, today = 7) + assertEquals(emptySet(), out.frozenDays) + assertEquals(0, StreakCalculator.compute(reviews + out.frozenDays, 7).current) + } + + @Test + fun earn_grantsOneFreezePerSevenDays_cappedAtTwo() { + val reviews = (1L..7L).toSet() + val out = StreakReconciler.reconcile(reviews, seeded(today = 6), today = 7) + assertEquals(1, out.freezeTokens) + assertEquals(1, out.freezesAwardedForRun) + val reviews3 = (1L..21L).toSet() + val out3 = StreakReconciler.reconcile(reviews3, seeded(today = 20), today = 21) + assertEquals(2, out3.freezeTokens) + assertEquals(3, out3.freezesAwardedForRun) + } + + @Test + fun earn_isIdempotent_acrossRepeatedReconciles() { + val reviews = (1L..7L).toSet() + val once = StreakReconciler.reconcile(reviews, seeded(today = 6), today = 7) + val twice = StreakReconciler.reconcile(reviews, once, today = 7) + assertEquals(once.freezeTokens, twice.freezeTokens) + assertEquals(once.freezesAwardedForRun, twice.freezesAwardedForRun) + } + + @Test + fun firstRun_doesNotFloodFreezes_forPreexistingStreak() { + val reviews = (1L..10L).toSet() + val out = StreakReconciler.reconcile(reviews, StreakState(), today = 10) + assertEquals(0, out.freezeTokens) + assertEquals(1, out.freezesAwardedForRun) + } + + @Test + fun brokenRun_resetsAwardCounter() { + val reviews = setOf(20L) + val out = StreakReconciler.reconcile(reviews, seeded(today = 19, awarded = 3), today = 20) + assertEquals(0, out.freezesAwardedForRun) + } + + @Test + fun repair_offeredOnlyForSingleRecentGap_afterStudyingToday() { + val reviews = setOf(1L, 2, 3, 4, 5, 7) + val state = seeded(today = 7) + val offer = StreakReconciler.repairEligibility(reviews, state, today = 7) + assertNotNull(offer) + assertEquals(7, offer!!.restoredStreak) + val repaired = StreakReconciler.repair(state, today = 7) + assertEquals(setOf(6L), repaired.frozenDays) + assertEquals(7L, repaired.lastRepairDay) + assertEquals(7, StreakCalculator.compute(reviews + repaired.frozenDays, 7).current) + } + + @Test + fun repair_cooldownBoundary_29DaysBlocked_30DaysAllowed() { + val reviews = setOf(1L, 2, 3, 4, 5, 7) + // 29 days since last repair → still within cooldown → no offer. + assertNull(StreakReconciler.repairEligibility(reviews, seeded(today = 7).copy(lastRepairDay = 7 - 29), today = 7)) + // 30 days since last repair → cooldown elapsed → offered. + assertNotNull(StreakReconciler.repairEligibility(reviews, seeded(today = 7).copy(lastRepairDay = 7 - 30), today = 7)) + } + + @Test + fun repair_notOffered_withoutStudyingToday_orWithinCooldown_orMultiDayGap() { + assertNull(StreakReconciler.repairEligibility(setOf(1L, 2, 3, 4, 5), seeded(today = 7), today = 7)) + val recentRepair = seeded(today = 7).copy(lastRepairDay = 6) + assertNull(StreakReconciler.repairEligibility(setOf(1L, 2, 3, 4, 5, 7), recentRepair, today = 7)) + assertNull(StreakReconciler.repairEligibility(setOf(1L, 2, 3, 4, 7), seeded(today = 7), today = 7)) + } +} From 1f8a7eba7fcb70d0a04a7faee790f33a9436f3fe Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 16:22:39 +0400 Subject: [PATCH 4/7] Persist streak state in Room with migration --- .../core/data/local/AzriDatabase.kt | 18 +++++++++-- .../core/data/local/RoomEntities.kt | 13 ++++++++ .../core/data/local/StreakStateMappers.kt | 22 +++++++++++++ .../simpleanki/core/data/local/dao/Daos.kt | 19 ++++++++++++ .../core/data/repository/Repositories.kt | 16 ++++++++++ .../main/java/nart/simpleanki/di/AppModule.kt | 6 +++- .../core/data/repository/FakeDaos.kt | 13 ++++++++ .../repository/StreakStateRepositoryTest.kt | 31 +++++++++++++++++++ 8 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/nart/simpleanki/core/data/local/StreakStateMappers.kt create mode 100644 app/src/test/java/nart/simpleanki/core/data/repository/StreakStateRepositoryTest.kt diff --git a/app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt b/app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt index 766dcc2..8eab2fc 100644 --- a/app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt +++ b/app/src/main/java/nart/simpleanki/core/data/local/AzriDatabase.kt @@ -8,10 +8,11 @@ import nart.simpleanki.core.data.local.dao.CardDao import nart.simpleanki.core.data.local.dao.DeckDao import nart.simpleanki.core.data.local.dao.FolderDao import nart.simpleanki.core.data.local.dao.ReviewLogDao +import nart.simpleanki.core.data.local.dao.StreakStateDao @Database( - entities = [CardEntity::class, DeckEntity::class, FolderEntity::class, ReviewLogEntity::class], - version = 2, + entities = [CardEntity::class, DeckEntity::class, FolderEntity::class, ReviewLogEntity::class, StreakStateEntity::class], + version = 3, exportSchema = false, ) abstract class AzriDatabase : RoomDatabase() { @@ -19,6 +20,7 @@ abstract class AzriDatabase : RoomDatabase() { abstract fun deckDao(): DeckDao abstract fun folderDao(): FolderDao abstract fun reviewLogDao(): ReviewLogDao + abstract fun streakStateDao(): StreakStateDao } /** @@ -40,3 +42,15 @@ val MIGRATION_1_2 = object : Migration(1, 2) { db.execSQL("CREATE INDEX IF NOT EXISTS `index_review_logs_review` ON `review_logs` (`review`)") } } + +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `streak_state` (" + + "`id` TEXT NOT NULL, `freezeTokens` INTEGER NOT NULL, `frozenDays` TEXT NOT NULL, " + + "`freezesAwardedForRun` INTEGER NOT NULL, `lastReconciledDay` INTEGER NOT NULL, " + + "`lastRepairDay` INTEGER NOT NULL, `lastModified` INTEGER NOT NULL, " + + "`dirty` INTEGER NOT NULL, PRIMARY KEY(`id`))", + ) + } +} diff --git a/app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt b/app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt index a03ffb4..463dd81 100644 --- a/app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt +++ b/app/src/main/java/nart/simpleanki/core/data/local/RoomEntities.kt @@ -89,3 +89,16 @@ data class ReviewLogEntity( val review: Long, val dirty: Boolean = true, ) + +@Entity(tableName = "streak_state") +data class StreakStateEntity( + @PrimaryKey val id: String = "current", + val freezeTokens: Int, + /** Civil-day indices covered by a freeze/repair, sorted, comma-separated (empty string = none). */ + val frozenDays: String, + val freezesAwardedForRun: Int, + val lastReconciledDay: Long, + val lastRepairDay: Long, + val lastModified: Long, + val dirty: Boolean = true, +) diff --git a/app/src/main/java/nart/simpleanki/core/data/local/StreakStateMappers.kt b/app/src/main/java/nart/simpleanki/core/data/local/StreakStateMappers.kt new file mode 100644 index 0000000..caf207d --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/data/local/StreakStateMappers.kt @@ -0,0 +1,22 @@ +package nart.simpleanki.core.data.local + +import nart.simpleanki.core.domain.streak.StreakState + +fun StreakStateEntity.toDomain(): StreakState = StreakState( + freezeTokens = freezeTokens, + frozenDays = frozenDays.split(",").filter { it.isNotBlank() }.map { it.toLong() }.toSet(), + freezesAwardedForRun = freezesAwardedForRun, + lastReconciledDay = lastReconciledDay, + lastRepairDay = lastRepairDay, +) + +fun StreakState.toEntity(lastModified: Long, dirty: Boolean): StreakStateEntity = StreakStateEntity( + id = "current", + freezeTokens = freezeTokens, + frozenDays = frozenDays.sorted().joinToString(","), + freezesAwardedForRun = freezesAwardedForRun, + lastReconciledDay = lastReconciledDay, + lastRepairDay = lastRepairDay, + lastModified = lastModified, + dirty = dirty, +) diff --git a/app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt b/app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt index 98da398..c66917e 100644 --- a/app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt +++ b/app/src/main/java/nart/simpleanki/core/data/local/dao/Daos.kt @@ -9,6 +9,7 @@ import nart.simpleanki.core.data.local.CardEntity import nart.simpleanki.core.data.local.DeckEntity import nart.simpleanki.core.data.local.FolderEntity import nart.simpleanki.core.data.local.ReviewLogEntity +import nart.simpleanki.core.data.local.StreakStateEntity @Dao interface FolderDao { @@ -97,3 +98,21 @@ interface ReviewLogDao { @Query("SELECT * FROM review_logs ORDER BY review") fun observeAll(): Flow> } + +@Dao +interface StreakStateDao { + @Query("SELECT * FROM streak_state WHERE id = 'current'") + fun observe(): Flow + + @Query("SELECT * FROM streak_state WHERE id = 'current'") + suspend fun get(): StreakStateEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: StreakStateEntity) + + @Query("SELECT * FROM streak_state WHERE dirty = 1") + suspend fun getDirty(): StreakStateEntity? + + @Query("UPDATE streak_state SET dirty = 0 WHERE id = 'current' AND lastModified = :lastModified") + suspend fun clearDirty(lastModified: Long) +} diff --git a/app/src/main/java/nart/simpleanki/core/data/repository/Repositories.kt b/app/src/main/java/nart/simpleanki/core/data/repository/Repositories.kt index 047a5a9..da30487 100644 --- a/app/src/main/java/nart/simpleanki/core/data/repository/Repositories.kt +++ b/app/src/main/java/nart/simpleanki/core/data/repository/Repositories.kt @@ -7,12 +7,14 @@ import nart.simpleanki.core.data.local.dao.CardDao import nart.simpleanki.core.data.local.dao.DeckDao import nart.simpleanki.core.data.local.dao.FolderDao import nart.simpleanki.core.data.local.dao.ReviewLogDao +import nart.simpleanki.core.data.local.dao.StreakStateDao import nart.simpleanki.core.data.local.toDomain import nart.simpleanki.core.data.local.toEntity import nart.simpleanki.core.domain.model.Card import nart.simpleanki.core.domain.model.Deck import nart.simpleanki.core.domain.model.Folder import nart.simpleanki.core.domain.model.ReviewLog +import nart.simpleanki.core.domain.streak.StreakState import java.util.UUID /** @@ -125,3 +127,17 @@ class ReviewLogRepository( fun observeLogs(): Flow> = dao.observeAll().map { rows -> rows.map { it.toDomain() } } } + +class StreakStateRepository( + private val dao: StreakStateDao, + private val now: () -> Long = { System.currentTimeMillis() }, +) { + fun observe(): Flow = + dao.observe().map { it?.toDomain() ?: StreakState() } + + suspend fun get(): StreakState = dao.get()?.toDomain() ?: StreakState() + + suspend fun update(state: StreakState) { + dao.upsert(state.toEntity(lastModified = now(), dirty = true)) + } +} diff --git a/app/src/main/java/nart/simpleanki/di/AppModule.kt b/app/src/main/java/nart/simpleanki/di/AppModule.kt index 97f67ea..10aab2b 100644 --- a/app/src/main/java/nart/simpleanki/di/AppModule.kt +++ b/app/src/main/java/nart/simpleanki/di/AppModule.kt @@ -34,6 +34,7 @@ import nart.simpleanki.core.csv.CsvImportService import nart.simpleanki.core.csv.DefaultCsvImportService import nart.simpleanki.core.data.local.AzriDatabase import nart.simpleanki.core.data.local.MIGRATION_1_2 +import nart.simpleanki.core.data.local.MIGRATION_2_3 import nart.simpleanki.core.data.media.FirebaseMediaRepository import nart.simpleanki.core.data.media.LocalMediaStore import nart.simpleanki.core.data.media.MediaManager @@ -43,6 +44,7 @@ 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.repository.StreakStateRepository import nart.simpleanki.core.data.settings.DataStoreSettingsRepository import nart.simpleanki.core.data.settings.SettingsRepository import nart.simpleanki.core.data.sync.FirestoreSyncService @@ -135,7 +137,7 @@ val appModule = module { // Local persistence (Room) single { Room.databaseBuilder(androidContext(), AzriDatabase::class.java, "azri.db") - .addMigrations(MIGRATION_1_2) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) .fallbackToDestructiveMigration(dropAllTables = true) .build() } @@ -143,6 +145,7 @@ val appModule = module { single { get().deckDao() } single { get().cardDao() } single { get().reviewLogDao() } + single { get().streakStateDao() } // Repositories single { FolderRepository(get()) } @@ -150,6 +153,7 @@ val appModule = module { single { CardRepository(get()) } single { ReviewLogRepository(get()) } single { StreakProvider(get()) } + single { StreakStateRepository(get()) } // Sync single { FirestoreSyncService(get()) } diff --git a/app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt b/app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt index 74cb9ac..ab8ec42 100644 --- a/app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt +++ b/app/src/test/java/nart/simpleanki/core/data/repository/FakeDaos.kt @@ -7,10 +7,12 @@ import nart.simpleanki.core.data.local.CardEntity import nart.simpleanki.core.data.local.DeckEntity import nart.simpleanki.core.data.local.FolderEntity import nart.simpleanki.core.data.local.ReviewLogEntity +import nart.simpleanki.core.data.local.StreakStateEntity import nart.simpleanki.core.data.local.dao.CardDao import nart.simpleanki.core.data.local.dao.DeckDao import nart.simpleanki.core.data.local.dao.FolderDao import nart.simpleanki.core.data.local.dao.ReviewLogDao +import nart.simpleanki.core.data.local.dao.StreakStateDao /** In-memory fakes implementing the Room DAO interfaces for pure-JVM repository tests. */ @@ -83,3 +85,14 @@ class FakeReviewLogDao : ReviewLogDao { override fun observeAll(): Flow> = store.map { m -> m.values.sortedBy { it.review } } } + +class FakeStreakStateDao : StreakStateDao { + private val store = MutableStateFlow(null) + override fun observe(): Flow = store + override suspend fun get(): StreakStateEntity? = store.value + override suspend fun upsert(entity: StreakStateEntity) { store.value = entity } + override suspend fun getDirty(): StreakStateEntity? = store.value?.takeIf { it.dirty } + override suspend fun clearDirty(lastModified: Long) { + store.value?.let { if (it.lastModified == lastModified) store.value = it.copy(dirty = false) } + } +} diff --git a/app/src/test/java/nart/simpleanki/core/data/repository/StreakStateRepositoryTest.kt b/app/src/test/java/nart/simpleanki/core/data/repository/StreakStateRepositoryTest.kt new file mode 100644 index 0000000..20e0e23 --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/data/repository/StreakStateRepositoryTest.kt @@ -0,0 +1,31 @@ +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import nart.simpleanki.core.domain.streak.StreakState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class StreakStateRepositoryTest { + private val now = 1_700_000_000_000L + + @Test + fun observe_defaultsToEmptyState_whenAbsent() = runTest { + val repo = StreakStateRepository(FakeStreakStateDao(), now = { now }) + assertEquals(StreakState(), repo.observe().first()) + assertEquals(StreakState(), repo.get()) + } + + @Test + fun update_stampsLastModifiedAndDirty_andRoundTripsFrozenDays() = runTest { + val dao = FakeStreakStateDao() + val repo = StreakStateRepository(dao, now = { now }) + repo.update(StreakState(freezeTokens = 2, frozenDays = setOf(6L, 3L), freezesAwardedForRun = 1, lastReconciledDay = 7)) + val saved = dao.get()!! + assertEquals(now, saved.lastModified) + assertTrue(saved.dirty) + assertEquals("3,6", saved.frozenDays) + assertEquals(setOf(3L, 6L), repo.get().frozenDays) + } +} From 98b2db07c0e9dd12e36dd0712892cd0f1e8e2c69 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 16:31:34 +0400 Subject: [PATCH 5/7] Sync streak state via Firestore (last-write-wins) --- .../core/data/firestore/FirestoreDtos.kt | 37 +++++++++++++ .../core/data/sync/FirestoreSyncService.kt | 8 +++ .../core/data/sync/RemoteSyncSource.kt | 4 ++ .../simpleanki/core/data/sync/SyncManager.kt | 12 +++++ .../main/java/nart/simpleanki/di/AppModule.kt | 2 +- .../core/data/sync/SyncManagerTest.kt | 53 +++++++++++++++---- 6 files changed, 106 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/nart/simpleanki/core/data/firestore/FirestoreDtos.kt b/app/src/main/java/nart/simpleanki/core/data/firestore/FirestoreDtos.kt index 32f6f6b..b2b7354 100644 --- a/app/src/main/java/nart/simpleanki/core/data/firestore/FirestoreDtos.kt +++ b/app/src/main/java/nart/simpleanki/core/data/firestore/FirestoreDtos.kt @@ -227,3 +227,40 @@ data class ReviewLogDto( ) } } + +// MARK: - Streak state (single doc per user: users/{uid}/streakState/current) + +data class StreakStateDto( + @DocumentId var id: String? = "current", + @get:PropertyName("freeze_tokens") @set:PropertyName("freeze_tokens") var freezeTokens: Int = 0, + @get:PropertyName("frozen_days") @set:PropertyName("frozen_days") var frozenDays: List = emptyList(), + @get:PropertyName("freezes_awarded_for_run") @set:PropertyName("freezes_awarded_for_run") var freezesAwardedForRun: Int = 0, + @get:PropertyName("last_reconciled_day") @set:PropertyName("last_reconciled_day") var lastReconciledDay: Long = 0, + @get:PropertyName("last_repair_day") @set:PropertyName("last_repair_day") var lastRepairDay: Long = 0, + @get:PropertyName("last_modified") @set:PropertyName("last_modified") var lastModified: Timestamp = Timestamp(Date(0)), +) { + fun lastModifiedMillis(): Long = lastModified.toMillis() + + fun toEntity(dirty: Boolean) = nart.simpleanki.core.data.local.StreakStateEntity( + id = "current", + freezeTokens = freezeTokens, + frozenDays = frozenDays.sorted().joinToString(","), + freezesAwardedForRun = freezesAwardedForRun, + lastReconciledDay = lastReconciledDay, + lastRepairDay = lastRepairDay, + lastModified = lastModified.toMillis(), + dirty = dirty, + ) + + companion object { + fun fromEntity(e: nart.simpleanki.core.data.local.StreakStateEntity) = StreakStateDto( + id = "current", + freezeTokens = e.freezeTokens, + frozenDays = e.frozenDays.split(",").filter { it.isNotBlank() }.map { it.toLong() }, + freezesAwardedForRun = e.freezesAwardedForRun, + lastReconciledDay = e.lastReconciledDay, + lastRepairDay = e.lastRepairDay, + lastModified = e.lastModified.toTimestamp(), + ) + } +} diff --git a/app/src/main/java/nart/simpleanki/core/data/sync/FirestoreSyncService.kt b/app/src/main/java/nart/simpleanki/core/data/sync/FirestoreSyncService.kt index 27e8c12..1559680 100644 --- a/app/src/main/java/nart/simpleanki/core/data/sync/FirestoreSyncService.kt +++ b/app/src/main/java/nart/simpleanki/core/data/sync/FirestoreSyncService.kt @@ -6,6 +6,7 @@ import nart.simpleanki.core.data.firestore.CardDto import nart.simpleanki.core.data.firestore.DeckDto import nart.simpleanki.core.data.firestore.FolderDto import nart.simpleanki.core.data.firestore.ReviewLogDto +import nart.simpleanki.core.data.firestore.StreakStateDto /** * Firestore-backed [RemoteSyncSource]. Collections mirror the iOS layout exactly: @@ -42,6 +43,13 @@ class FirestoreSyncService( override suspend fun pushReviewLogs(uid: String, dtos: List) = push(uid, "reviewLogs", dtos) { it.id } + override suspend fun fetchStreakState(uid: String): StreakStateDto? = + col(uid, "streakState").document("current").get().await().toObject(StreakStateDto::class.java) + + override suspend fun pushStreakState(uid: String, dto: StreakStateDto) { + col(uid, "streakState").document("current").set(dto).await() + } + private suspend fun push(uid: String, name: String, dtos: List, id: (T) -> String?) { if (dtos.isEmpty()) return val batch = firestore.batch() diff --git a/app/src/main/java/nart/simpleanki/core/data/sync/RemoteSyncSource.kt b/app/src/main/java/nart/simpleanki/core/data/sync/RemoteSyncSource.kt index cbb3819..7f73ad3 100644 --- a/app/src/main/java/nart/simpleanki/core/data/sync/RemoteSyncSource.kt +++ b/app/src/main/java/nart/simpleanki/core/data/sync/RemoteSyncSource.kt @@ -4,6 +4,7 @@ import nart.simpleanki.core.data.firestore.CardDto import nart.simpleanki.core.data.firestore.DeckDto import nart.simpleanki.core.data.firestore.FolderDto import nart.simpleanki.core.data.firestore.ReviewLogDto +import nart.simpleanki.core.data.firestore.StreakStateDto /** * Remote sync seam over Firestore. Implemented by [FirestoreSyncService]; faked in tests. @@ -21,4 +22,7 @@ interface RemoteSyncSource { suspend fun fetchReviewLogs(uid: String): List suspend fun pushReviewLogs(uid: String, dtos: List) + + suspend fun fetchStreakState(uid: String): StreakStateDto? + suspend fun pushStreakState(uid: String, dto: StreakStateDto) } diff --git a/app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt b/app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt index 4450ec8..3e763e2 100644 --- a/app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt +++ b/app/src/main/java/nart/simpleanki/core/data/sync/SyncManager.kt @@ -4,10 +4,12 @@ import nart.simpleanki.core.data.firestore.CardDto import nart.simpleanki.core.data.firestore.DeckDto import nart.simpleanki.core.data.firestore.FolderDto import nart.simpleanki.core.data.firestore.ReviewLogDto +import nart.simpleanki.core.data.firestore.StreakStateDto import nart.simpleanki.core.data.local.dao.CardDao import nart.simpleanki.core.data.local.dao.DeckDao import nart.simpleanki.core.data.local.dao.FolderDao import nart.simpleanki.core.data.local.dao.ReviewLogDao +import nart.simpleanki.core.data.local.dao.StreakStateDao import nart.simpleanki.core.data.local.toDomain import nart.simpleanki.core.data.local.toEntity import nart.simpleanki.core.data.media.MediaManager @@ -27,6 +29,7 @@ class SyncManager( private val deckDao: DeckDao, private val cardDao: CardDao, private val reviewLogDao: ReviewLogDao, + private val streakStateDao: StreakStateDao, private val remote: RemoteSyncSource, private val media: MediaManager, ) { @@ -72,6 +75,10 @@ class SyncManager( remote.pushReviewLogs(uid, rows.map { ReviewLogDto.fromDomain(it.toDomain()) }) rows.forEach { reviewLogDao.clearDirty(it.id) } } + streakStateDao.getDirty()?.let { row -> + remote.pushStreakState(uid, StreakStateDto.fromEntity(row)) + streakStateDao.clearDirty(row.lastModified) + } } private suspend fun pull(uid: String) { @@ -108,6 +115,11 @@ class SyncManager( .map { it.toDomain().toEntity(dirty = false) } .takeIf { it.isNotEmpty() } ?.let { reviewLogDao.insertAll(it) } + remote.fetchStreakState(uid)?.let { dto -> + if (shouldApplyRemote(streakStateDao.get()?.lastModified, dto.lastModifiedMillis())) { + streakStateDao.upsert(dto.toEntity(dirty = false)) + } + } } companion object { diff --git a/app/src/main/java/nart/simpleanki/di/AppModule.kt b/app/src/main/java/nart/simpleanki/di/AppModule.kt index 10aab2b..f9634db 100644 --- a/app/src/main/java/nart/simpleanki/di/AppModule.kt +++ b/app/src/main/java/nart/simpleanki/di/AppModule.kt @@ -157,7 +157,7 @@ val appModule = module { // Sync single { FirestoreSyncService(get()) } - single { SyncManager(get(), get(), get(), get(), get(), get()) } + single { SyncManager(get(), get(), get(), get(), get(), get(), get()) } // Billing / entitlement single { EntitlementCache(androidContext()) } diff --git a/app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt b/app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt index 198d9d8..17fc72d 100644 --- a/app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt +++ b/app/src/test/java/nart/simpleanki/core/data/sync/SyncManagerTest.kt @@ -13,10 +13,12 @@ import nart.simpleanki.core.data.media.FakeMediaUploader import nart.simpleanki.core.data.media.LocalMediaStore import nart.simpleanki.core.data.media.MediaManager import nart.simpleanki.core.data.local.toDomain +import nart.simpleanki.core.data.firestore.StreakStateDto 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.FakeStreakStateDao import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -34,10 +36,12 @@ class SyncManagerTest { var decks: MutableList = mutableListOf(), var cards: MutableList = mutableListOf(), var reviewLogs: MutableList = mutableListOf(), + var streakState: StreakStateDto? = null, ) : RemoteSyncSource { val pushedFolders = mutableListOf() val pushedCards = mutableListOf() val pushedReviewLogs = mutableListOf() + var pushedStreakState: StreakStateDto? = null override suspend fun fetchFolders(uid: String) = folders override suspend fun pushFolders(uid: String, dtos: List) { pushedFolders += dtos } override suspend fun fetchDecks(uid: String) = decks @@ -46,6 +50,8 @@ class SyncManagerTest { override suspend fun pushCards(uid: String, dtos: List) { pushedCards += dtos } override suspend fun fetchReviewLogs(uid: String) = reviewLogs override suspend fun pushReviewLogs(uid: String, dtos: List) { pushedReviewLogs += dtos } + override suspend fun fetchStreakState(uid: String) = streakState + override suspend fun pushStreakState(uid: String, dto: StreakStateDto) { pushedStreakState = dto } } private fun ts(millis: Long) = Timestamp(Date(millis)) @@ -87,7 +93,7 @@ class SyncManagerTest { folderDao.upsertAll(listOf(FolderEntity(id = "f1", name = "A", lastModified = 5, dirty = true))) val remote = FakeRemote() val (m, _) = media() - val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), remote, m) + val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -112,7 +118,7 @@ class SyncManagerTest { ) ) val (m, _) = media() - val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), remote, m) + val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -131,7 +137,7 @@ class SyncManagerTest { ) ) val (m, _) = media() - val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), remote, m) + val sync = SyncManager(folderDao, FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -146,7 +152,7 @@ class SyncManagerTest { val name = m.saveImage(byteArrayOf(1, 2, 3)) cardDao.upsertAll(listOf(cardEntity(id = "c1", image = name, imagePath = null, dirty = true))) val remote = FakeRemote() - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), remote, m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -161,7 +167,7 @@ class SyncManagerTest { val cardDao = FakeCardDao() val (m, up) = media() cardDao.upsertAll(listOf(cardEntity(id = "c1", image = null, imagePath = null, dirty = true))) - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeRemote(), m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeStreakStateDao(), FakeRemote(), m) sync.sync("u1") @@ -180,7 +186,7 @@ class SyncManagerTest { CardDto(id = "c1", image = "pic.jpg", imagePath = "users/u/images/pic.jpg", lastModified = ts(100)), ), ) - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), remote, m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -196,7 +202,7 @@ class SyncManagerTest { val name = m.saveImage(byteArrayOf(1, 2, 3)) cardDao.upsertAll(listOf(cardEntity(id = "c1", image = name, imagePath = null, dirty = true))) val remote = FakeRemote() - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), remote, m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), cardDao, FakeReviewLogDao(), FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -211,7 +217,7 @@ class SyncManagerTest { logDao.insertAll(listOf(reviewLogEntity("l1", dirty = true))) val remote = FakeRemote() val (m, _) = media() - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), logDao, remote, m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), logDao, FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -228,7 +234,7 @@ class SyncManagerTest { ReviewLogDto.fromDomain(reviewLogEntity("l2", dirty = false).toDomain()), )) val (m, _) = media() - val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), logDao, remote, m) + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), logDao, FakeStreakStateDao(), remote, m) sync.sync("u1") @@ -237,4 +243,33 @@ class SyncManagerTest { // only the seed l1 and the synced l2 were ever forwarded to insertAll — not l1 twice. assertEquals(listOf("l1", "l2"), logDao.inserted.map { it.id }) } + + private fun streakEntity(lastModified: Long, dirty: Boolean) = + nart.simpleanki.core.data.local.StreakStateEntity( + id = "current", freezeTokens = 1, frozenDays = "3,6", freezesAwardedForRun = 1, + lastReconciledDay = 7, lastRepairDay = 0, lastModified = lastModified, dirty = dirty, + ) + + @Test + fun streakState_pushDirty_thenClearDirty() = runTest { + val dao = FakeStreakStateDao() + dao.upsert(streakEntity(lastModified = 100, dirty = true)) + val remote = FakeRemote() + val (m, _) = media() + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), dao, remote, m) + sync.sync("u1") + assertEquals(100L, remote.pushedStreakState!!.lastModifiedMillis()) + assertFalse(dao.get()!!.dirty) + } + + @Test + fun streakState_pull_appliesNewer() = runTest { + val dao = FakeStreakStateDao() + dao.upsert(streakEntity(lastModified = 100, dirty = false)) + val newer = StreakStateDto.fromEntity(streakEntity(lastModified = 200, dirty = false)).apply { freezeTokens = 2 } + val (m, _) = media() + val sync = SyncManager(FakeFolderDao(), FakeDeckDao(), FakeCardDao(), FakeReviewLogDao(), dao, FakeRemote(streakState = newer), m) + sync.sync("u1") + assertEquals(2, dao.get()!!.freezeTokens) + } } From 0a9b338c0c3f71f8aeab512ccd48c06365e5a2f6 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 16:43:12 +0400 Subject: [PATCH 6/7] Add StreakStateManager and union freezes into StreakProvider StreakStateManager ties StreakReconciler to persisted state + review logs. StreakProvider now combines review logs with frozen days so gaps covered by freeze tokens count toward the consecutive streak. --- .../core/data/repository/StreakProvider.kt | 11 +++-- .../data/repository/StreakStateManager.kt | 48 +++++++++++++++++++ .../main/java/nart/simpleanki/di/AppModule.kt | 5 +- .../feature/study/StudyViewModel.kt | 4 +- .../data/repository/StreakProviderTest.kt | 17 ++++++- .../data/repository/StreakStateManagerTest.kt | 43 +++++++++++++++++ .../feature/queue/StudyQueueViewModelTest.kt | 6 ++- .../feature/study/StudyViewModelTest.kt | 33 +++++++------ 8 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/nart/simpleanki/core/data/repository/StreakStateManager.kt create mode 100644 app/src/test/java/nart/simpleanki/core/data/repository/StreakStateManagerTest.kt 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 index 5d54620..44fa7cc 100644 --- a/app/src/main/java/nart/simpleanki/core/data/repository/StreakProvider.kt +++ b/app/src/main/java/nart/simpleanki/core/data/repository/StreakProvider.kt @@ -1,23 +1,25 @@ package nart.simpleanki.core.data.repository import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine 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. */ +/** Derives the study [Streak] from the review logs, unioned with frozen days from [StreakStateRepository]. */ class StreakProvider( private val reviewLogRepository: ReviewLogRepository, + private val streakStateRepository: StreakStateRepository, 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 -> + combine(reviewLogRepository.observeLogs(), streakStateRepository.observe()) { logs, state -> val days = logs.mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + days += state.frozenDays StreakCalculator.compute(days, localEpochDay(now(), timeZone)) } @@ -29,7 +31,8 @@ class StreakProvider( val today = localEpochDay(now(), timeZone) val days = reviewLogRepository.observeLogs().first() .mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } - .apply { add(today) } + days += streakStateRepository.get().frozenDays + days += today return StreakCalculator.compute(days, today) } } diff --git a/app/src/main/java/nart/simpleanki/core/data/repository/StreakStateManager.kt b/app/src/main/java/nart/simpleanki/core/data/repository/StreakStateManager.kt new file mode 100644 index 0000000..cc4d337 --- /dev/null +++ b/app/src/main/java/nart/simpleanki/core/data/repository/StreakStateManager.kt @@ -0,0 +1,48 @@ +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.flow.first +import nart.simpleanki.core.domain.streak.RepairOffer +import nart.simpleanki.core.domain.streak.StreakReconciler +import nart.simpleanki.core.domain.streak.localEpochDay +import java.util.TimeZone + +/** + * Ties the pure [StreakReconciler] to persisted [StreakStateRepository] + review logs. Call + * [reconcile] on app foreground and after a session — never inside a Flow (it persists state). + */ +class StreakStateManager( + private val streakStateRepository: StreakStateRepository, + private val reviewLogRepository: ReviewLogRepository, + private val now: () -> Long = { System.currentTimeMillis() }, + private val timeZone: TimeZone = TimeZone.getDefault(), +) { + private suspend fun reviewDays(): Set = + reviewLogRepository.observeLogs().first().mapTo(mutableSetOf()) { localEpochDay(it.review, timeZone) } + + suspend fun reconcile() { + val today = localEpochDay(now(), timeZone) + val state = streakStateRepository.get() + val updated = StreakReconciler.reconcile(reviewDays(), state, today) + if (updated != state) streakStateRepository.update(updated) + } + + /** + * Whether a free repair is available for yesterday's gap. Assumes [reconcile] has already run for + * today, so any auto-freeze that would already cover yesterday is reflected in the persisted state + * before eligibility is judged. + */ + suspend fun repairOffer(): RepairOffer? { + val today = localEpochDay(now(), timeZone) + return StreakReconciler.repairEligibility(reviewDays(), streakStateRepository.get(), today) + } + + /** + * Applies a repair, freezing yesterday's gap. Callers must serialize this with [reconcile]: the + * read-modify-write is not atomic. The intended call sites (app foreground + a button handler) are + * already sequential. + */ + suspend fun repair() { + val today = localEpochDay(now(), timeZone) + streakStateRepository.update(StreakReconciler.repair(streakStateRepository.get(), today)) + } +} diff --git a/app/src/main/java/nart/simpleanki/di/AppModule.kt b/app/src/main/java/nart/simpleanki/di/AppModule.kt index f9634db..fd1d086 100644 --- a/app/src/main/java/nart/simpleanki/di/AppModule.kt +++ b/app/src/main/java/nart/simpleanki/di/AppModule.kt @@ -44,6 +44,7 @@ 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.repository.StreakStateManager import nart.simpleanki.core.data.repository.StreakStateRepository import nart.simpleanki.core.data.settings.DataStoreSettingsRepository import nart.simpleanki.core.data.settings.SettingsRepository @@ -152,8 +153,9 @@ val appModule = module { single { DeckRepository(get()) } single { CardRepository(get()) } single { ReviewLogRepository(get()) } - single { StreakProvider(get()) } single { StreakStateRepository(get()) } + single { StreakProvider(get(), get()) } + single { StreakStateManager(get(), get()) } // Sync single { FirestoreSyncService(get()) } @@ -203,6 +205,7 @@ val appModule = module { deckRepository = get(), settingsRepository = get(), reviewLogRepository = get(), + streakStateRepository = get(), streakProvider = get(), logManager = get(), ) 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 3e1ef0e..0e0ec27 100644 --- a/app/src/main/java/nart/simpleanki/feature/study/StudyViewModel.kt +++ b/app/src/main/java/nart/simpleanki/feature/study/StudyViewModel.kt @@ -13,6 +13,7 @@ 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.repository.StreakStateRepository import nart.simpleanki.core.data.settings.SettingsRepository import nart.simpleanki.core.data.settings.fsrsParameters import nart.simpleanki.core.domain.fsrs.IntervalFormatter @@ -54,7 +55,8 @@ 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 streakStateRepository: StreakStateRepository, + private val streakProvider: StreakProvider = StreakProvider(reviewLogRepository, streakStateRepository, now), private val logManager: LogManager = LogManager(emptyList()), ) : ViewModel() { 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 index 8e98eda..9e50b22 100644 --- a/app/src/test/java/nart/simpleanki/core/data/repository/StreakProviderTest.kt +++ b/app/src/test/java/nart/simpleanki/core/data/repository/StreakProviderTest.kt @@ -4,6 +4,8 @@ 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 nart.simpleanki.core.domain.streak.StreakState +import nart.simpleanki.core.domain.streak.localEpochDay import org.junit.Assert.assertEquals import org.junit.Test import java.util.TimeZone @@ -19,8 +21,19 @@ class StreakProviderTest { 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) + private fun provider(dao: FakeReviewLogDao, stateDao: FakeStreakStateDao = FakeStreakStateDao()) = + StreakProvider(ReviewLogRepository(dao), StreakStateRepository(stateDao), now = { today }, timeZone = utc) + + @Test + fun frozenDay_fillsGap_soStreakSurvives() = runTest { + val dao = FakeReviewLogDao() + dao.insertAll(listOf(logEntity("a", today), logEntity("c", today - 2 * day))) // gap at yesterday + val stateDao = FakeStreakStateDao() + StreakStateRepository(stateDao, now = { today }).update( + StreakState(frozenDays = setOf(localEpochDay(today - day, utc))), + ) + assertEquals(3, provider(dao, stateDao).observeStreak().first().current) + } @Test fun observeStreak_countsConsecutiveDaysEndingToday() = runTest { diff --git a/app/src/test/java/nart/simpleanki/core/data/repository/StreakStateManagerTest.kt b/app/src/test/java/nart/simpleanki/core/data/repository/StreakStateManagerTest.kt new file mode 100644 index 0000000..755fd4a --- /dev/null +++ b/app/src/test/java/nart/simpleanki/core/data/repository/StreakStateManagerTest.kt @@ -0,0 +1,43 @@ +package nart.simpleanki.core.data.repository + +import kotlinx.coroutines.test.runTest +import nart.simpleanki.core.data.local.ReviewLogEntity +import nart.simpleanki.core.domain.streak.StreakState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import java.util.TimeZone + +class StreakStateManagerTest { + private val utc = TimeZone.getTimeZone("UTC") + private val day = 86_400_000L + private fun dayMillis(epochDay: Long) = epochDay * day + day / 2 // noon UTC → localEpochDay == epochDay + + private fun log(epochDay: Long) = ReviewLogEntity( + id = "r$epochDay", cardId = "c1", rating = 3, state = 2, due = 0, stability = 1.0, difficulty = 5.0, + elapsedDays = 0.0, lastElapsedDays = 0.0, scheduledDays = 0.0, review = dayMillis(epochDay), dirty = false, + ) + + @Test + fun reconcile_persistsAutoFreeze() = runTest { + val logDao = FakeReviewLogDao() + logDao.insertAll((1L..5L).map { log(it) }) + val stateRepo = StreakStateRepository(FakeStreakStateDao(), now = { 0L }) + stateRepo.update(StreakState(freezeTokens = 1, lastReconciledDay = 5)) + val mgr = StreakStateManager(stateRepo, ReviewLogRepository(logDao), now = { dayMillis(7) }, timeZone = utc) + mgr.reconcile() + assertEquals(setOf(6L), stateRepo.get().frozenDays) + } + + @Test + fun repairOffer_andRepair_restoresFrozenGap() = runTest { + val logDao = FakeReviewLogDao() + logDao.insertAll(listOf(1L, 2, 3, 4, 5, 7).map { log(it) }) + val stateRepo = StreakStateRepository(FakeStreakStateDao(), now = { 0L }) + stateRepo.update(StreakState(lastReconciledDay = 7)) + val mgr = StreakStateManager(stateRepo, ReviewLogRepository(logDao), now = { dayMillis(7) }, timeZone = utc) + assertNotNull(mgr.repairOffer()) + mgr.repair() + assertEquals(setOf(6L), stateRepo.get().frozenDays) + } +} 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 1b55ebc..2e1ea68 100644 --- a/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt +++ b/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt @@ -16,9 +16,11 @@ 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.FakeStreakStateDao 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.repository.StreakStateRepository import java.util.TimeZone import nart.simpleanki.core.billing.Entitlement import nart.simpleanki.core.billing.FakeEntitlementRepository @@ -56,7 +58,7 @@ class StudyQueueViewModelTest { dateCreated = now, lastModified = now, fsrsDue = now, fsrsState = CardState.New.value, ) - private fun emptyStreak() = StreakProvider(ReviewLogRepository(FakeReviewLogDao())) + private fun emptyStreak() = StreakProvider(ReviewLogRepository(FakeReviewLogDao()), StreakStateRepository(FakeStreakStateDao())) @Test fun aggregatesAcrossDecks_andBreaksDownPerDeck() = runTest { @@ -291,7 +293,7 @@ class StudyQueueViewModelTest { 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 streak = StreakProvider(ReviewLogRepository(logDao), StreakStateRepository(FakeStreakStateDao()), now = { now }, timeZone = TimeZone.getTimeZone("UTC")) val vm = StudyQueueViewModel( CardRepository(FakeCardDao(), now = { now }), 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 a6544f3..318965e 100644 --- a/app/src/test/java/nart/simpleanki/feature/study/StudyViewModelTest.kt +++ b/app/src/test/java/nart/simpleanki/feature/study/StudyViewModelTest.kt @@ -17,8 +17,10 @@ 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.FakeStreakStateDao import nart.simpleanki.core.data.repository.ReviewLogRepository import nart.simpleanki.core.data.repository.StreakProvider +import nart.simpleanki.core.data.repository.StreakStateRepository import nart.simpleanki.core.data.settings.AppSettings import nart.simpleanki.core.data.settings.FakeSettingsRepository import nart.simpleanki.core.domain.fsrs.QueueSortOrder @@ -54,7 +56,7 @@ class StudyViewModelTest { repo.upsert(newCard("c1", deckId = "d1")) repo.upsert(newCard("c2", deckId = "d2")) // null deckId = global queue: cards from every deck are included. - val vm = StudyViewModel(null, null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }) + val vm = StudyViewModel(null, null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakStateRepository = StreakStateRepository(FakeStreakStateDao())) runCurrent() assertEquals(2, vm.uiState.value.remaining) } @@ -68,7 +70,7 @@ class StudyViewModelTest { cardRepo.upsert(newCard("c1", deckId = "d1")) // inside folder f1 cardRepo.upsert(newCard("c2", deckId = "d2")) // outside f1 - val vm = StudyViewModel(null, "f1", cardRepo, deckRepo, FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }) + val vm = StudyViewModel(null, "f1", cardRepo, deckRepo, FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakStateRepository = StreakStateRepository(FakeStreakStateDao())) runCurrent() assertEquals(1, vm.uiState.value.remaining) @@ -80,7 +82,7 @@ class StudyViewModelTest { val repo = CardRepository(FakeCardDao(), now = { now }) repo.upsert(newCard("c1")) repo.upsert(newCard("c2")) - val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }) + val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakStateRepository = StreakStateRepository(FakeStreakStateDao())) runCurrent() val s = vm.uiState.value @@ -98,7 +100,7 @@ class StudyViewModelTest { val repo = CardRepository(FakeCardDao(), now = { now }) repo.upsert(newCard("c1")) repo.upsert(newCard("c2")) - val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }) + val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakStateRepository = StreakStateRepository(FakeStreakStateDao())) runCurrent() vm.onRate(Rating.Good) @@ -113,7 +115,7 @@ class StudyViewModelTest { val repo = CardRepository(dao, now = { now }) repo.upsert(newCard("c1")) repo.upsert(newCard("c2")) - val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }) + val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakStateRepository = StreakStateRepository(FakeStreakStateDao())) runCurrent() vm.onReveal() @@ -138,7 +140,7 @@ class StudyViewModelTest { fun ratingAllCards_finishesSession() = runTest { val repo = CardRepository(FakeCardDao(), now = { now }) repo.upsert(newCard("c1")) - val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }) + val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakStateRepository = StreakStateRepository(FakeStreakStateDao())) runCurrent() vm.onRate(Rating.Easy) @@ -161,7 +163,7 @@ class StudyViewModelTest { repo.upsert(reviewCard("easy", 2.0)) repo.upsert(reviewCard("hard", 9.0)) val settings = FakeSettingsRepository(AppSettings(queueSortOrder = QueueSortOrder.Difficulty)) - val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), settings, ReviewLogRepository(FakeReviewLogDao()), now = { now }) + val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), settings, ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakStateRepository = StreakStateRepository(FakeStreakStateDao())) runCurrent() assertEquals("hard", vm.uiState.value.current?.id) // hardest card first } @@ -171,7 +173,7 @@ class StudyViewModelTest { val repo = CardRepository(FakeCardDao(), now = { now }) repo.upsert(newCard("c1")) val log = FakeLogService() - StudyViewModel(null, null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, logManager = LogManager(listOf(log))) + StudyViewModel(null, null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakStateRepository = StreakStateRepository(FakeStreakStateDao()), logManager = LogManager(listOf(log))) runCurrent() assertTrue(log.events.any { it.eventName == "review_session_start" }) } @@ -181,7 +183,7 @@ class StudyViewModelTest { val repo = CardRepository(FakeCardDao(), now = { now }) repo.upsert(newCard("c1")) val log = FakeLogService() - val vm = StudyViewModel(null, null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, logManager = LogManager(listOf(log))) + val vm = StudyViewModel(null, null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakStateRepository = StreakStateRepository(FakeStreakStateDao()), logManager = LogManager(listOf(log))) runCurrent() vm.onRate(Rating.Good) runCurrent() @@ -197,7 +199,7 @@ class StudyViewModelTest { val repo = CardRepository(FakeCardDao(), now = { now }) repo.upsert(newCard("c1")) repo.upsert(newCard("c2")) - val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }) + val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakStateRepository = StreakStateRepository(FakeStreakStateDao())) runCurrent() // Hint visible at session start. @@ -219,7 +221,7 @@ class StudyViewModelTest { @Test fun emptyDeck_finishesImmediately() = runTest { val repo = CardRepository(FakeCardDao(), now = { now }) - val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }) + val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, streakStateRepository = StreakStateRepository(FakeStreakStateDao())) runCurrent() assertTrue(vm.uiState.value.finished) assertNull(vm.uiState.value.current) @@ -230,7 +232,7 @@ class StudyViewModelTest { var clock = now val repo = CardRepository(FakeCardDao(), now = { clock }) repo.upsert(newCard("c1")) - val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { clock }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { clock }) + val vm = StudyViewModel("d1", null, repo, DeckRepository(FakeDeckDao(), now = { clock }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { clock }, streakStateRepository = StreakStateRepository(FakeStreakStateDao())) runCurrent() // Session started at `now`; advance the clock 3s, then rate the only card to finish. clock = now + 3_000 @@ -251,6 +253,7 @@ class StudyViewModelTest { val vm = StudyViewModel( "d1", null, cardRepo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), logRepo, now = { now }, + streakStateRepository = StreakStateRepository(FakeStreakStateDao()), ) runCurrent() @@ -272,6 +275,7 @@ class StudyViewModelTest { val vm = StudyViewModel( "d1", null, cardRepo, DeckRepository(FakeDeckDao(), now = { now }), FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, + streakStateRepository = StreakStateRepository(FakeStreakStateDao()), ) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -290,11 +294,12 @@ class StudyViewModelTest { 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 streak = StreakProvider(ReviewLogRepository(logDao), StreakStateRepository(FakeStreakStateDao()), 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, + FakeSettingsRepository(), ReviewLogRepository(FakeReviewLogDao()), now = { now }, + streakStateRepository = StreakStateRepository(FakeStreakStateDao()), streakProvider = streak, ) backgroundScope.launch { vm.uiState.collect {} } runCurrent() From fabd29067dd00e58c35bc00ffdfd372765a67402 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 17:02:51 +0400 Subject: [PATCH 7/7] Surface streak freezes and repair in the queue and session summary --- .../data/repository/StreakStateManager.kt | 10 +- .../main/java/nart/simpleanki/di/AppModule.kt | 5 +- .../feature/queue/StudyQueueScreen.kt | 42 +++++++ .../feature/queue/StudyQueueViewModel.kt | 37 +++++- .../feature/study/SessionSummary.kt | 29 ++++- .../simpleanki/feature/study/StudyScreen.kt | 4 +- .../feature/study/StudyViewModel.kt | 17 ++- .../data/repository/StreakStateManagerTest.kt | 13 ++ .../feature/queue/StudyQueueViewModelTest.kt | 116 ++++++++++++++++-- 9 files changed, 251 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/nart/simpleanki/core/data/repository/StreakStateManager.kt b/app/src/main/java/nart/simpleanki/core/data/repository/StreakStateManager.kt index cc4d337..84de53c 100644 --- a/app/src/main/java/nart/simpleanki/core/data/repository/StreakStateManager.kt +++ b/app/src/main/java/nart/simpleanki/core/data/repository/StreakStateManager.kt @@ -30,10 +30,16 @@ class StreakStateManager( * Whether a free repair is available for yesterday's gap. Assumes [reconcile] has already run for * today, so any auto-freeze that would already cover yesterday is reflected in the persisted state * before eligibility is judged. + * + * [includeToday] forces today into the day set — for the post-session summary, so the offer is + * correct even though the per-rating review-log append is fire-and-forget and may not have landed + * in the logs yet. */ - suspend fun repairOffer(): RepairOffer? { + suspend fun repairOffer(includeToday: Boolean = false): RepairOffer? { val today = localEpochDay(now(), timeZone) - return StreakReconciler.repairEligibility(reviewDays(), streakStateRepository.get(), today) + val days = reviewDays() + val effective = if (includeToday) days + today else days + return StreakReconciler.repairEligibility(effective, streakStateRepository.get(), today) } /** diff --git a/app/src/main/java/nart/simpleanki/di/AppModule.kt b/app/src/main/java/nart/simpleanki/di/AppModule.kt index fd1d086..14f804b 100644 --- a/app/src/main/java/nart/simpleanki/di/AppModule.kt +++ b/app/src/main/java/nart/simpleanki/di/AppModule.kt @@ -206,6 +206,7 @@ val appModule = module { settingsRepository = get(), reviewLogRepository = get(), streakStateRepository = get(), + streakStateManager = get(), streakProvider = get(), logManager = get(), ) @@ -227,7 +228,9 @@ val appModule = module { folderRepository = get(), settingsRepository = get(), entitlementRepository = get(), - streakProvider = get() + streakProvider = get(), + streakStateRepository = get(), + streakStateManager = 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 dcc19d5..daa7f25 100644 --- a/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt +++ b/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueScreen.kt @@ -103,6 +103,7 @@ fun StudyQueueScreen( onAddCards = onAddCards, onOpenPaywall = onOpenPaywall, onDismissNudge = viewModel::dismissPremiumNudge, + onRepair = viewModel::repairStreak, ) if (showGoalSheet) { DailyGoalEditorSheet(onDismiss = { showGoalSheet = false }) @@ -123,12 +124,27 @@ fun StudyQueueContent( onAddCards: () -> Unit = {}, onOpenPaywall: () -> Unit = {}, onDismissNudge: () -> Unit = {}, + onRepair: () -> Unit = {}, ) { Scaffold( topBar = { TopAppBar( title = { Text("Today", fontWeight = FontWeight.Bold) }, actions = { + if (state.freezeCount > 0) { + Row( + modifier = Modifier.padding(end = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text("❄️", fontSize = 18.sp) + Text( + state.freezeCount.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + } if (state.currentStreak > 0) { Row( modifier = Modifier.padding(end = 12.dp), @@ -172,6 +188,9 @@ fun StudyQueueContent( .padding(padding), contentPadding = PaddingValues(bottom = 24.dp), ) { + if (state.repairOffer != null) { + item { StreakRepairBanner(restoredStreak = state.repairOffer.restoredStreak, onRepair = onRepair) } + } if (state.showPremiumNudge) { item { PremiumNudgeCard(onClick = onOpenPaywall, onDismiss = onDismissNudge) } } @@ -223,6 +242,29 @@ private fun PremiumNudgeCard(onClick: () -> Unit, onDismiss: () -> Unit) { } } +@Composable +private fun StreakRepairBanner(restoredStreak: Int, onRepair: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f)) { + Text("Streak broken", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text( + "Repair to restore your $restoredStreak-day streak", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton(onClick = onRepair) { Text("Repair") } + } + } +} + @Composable private fun QueueHeader(sortOrder: QueueSortOrder, onSortChange: (QueueSortOrder) -> Unit) { var menuOpen by remember { mutableStateOf(false) } 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 415e9e5..62e1f41 100644 --- a/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt +++ b/app/src/main/java/nart/simpleanki/feature/queue/StudyQueueViewModel.kt @@ -3,6 +3,7 @@ package nart.simpleanki.feature.queue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -11,6 +12,8 @@ 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.data.repository.StreakStateManager +import nart.simpleanki.core.data.repository.StreakStateRepository import nart.simpleanki.core.billing.EntitlementRepository import nart.simpleanki.core.billing.Entitlements import nart.simpleanki.core.data.settings.SettingsRepository @@ -18,6 +21,7 @@ import nart.simpleanki.core.data.settings.dailyGoalTotal import nart.simpleanki.core.domain.fsrs.QueueSortOrder import nart.simpleanki.core.domain.fsrs.StudyQueueBuilder import nart.simpleanki.core.domain.fsrs.withDueTicks +import nart.simpleanki.core.domain.streak.RepairOffer import kotlinx.coroutines.launch import kotlin.random.Random import java.util.Calendar @@ -72,6 +76,8 @@ data class StudyQueueUiState( val studiedToday: Int = 0, val currentStreak: Int = 0, val longestStreak: Int = 0, + val freezeCount: Int = 0, + val repairOffer: RepairOffer? = null, val sortOrder: QueueSortOrder = QueueSortOrder.DueDate, /** * Whether the user owns any (non-deleted) card at all — a *lifetime* signal, not a "today" @@ -98,9 +104,20 @@ class StudyQueueViewModel( private val settingsRepository: SettingsRepository, private val entitlementRepository: EntitlementRepository, private val streakProvider: StreakProvider, + private val streakStateRepository: StreakStateRepository, + private val streakStateManager: StreakStateManager, private val now: () -> Long = { System.currentTimeMillis() }, ) : ViewModel() { + private val _repairOffer = MutableStateFlow(null) + + init { + viewModelScope.launch { + streakStateManager.reconcile() + _repairOffer.value = streakStateManager.repairOffer() + } + } + private val baseState: Flow = combine( cardRepository.observeAllCards().withDueTicks(now), @@ -184,8 +201,18 @@ class StudyQueueViewModel( } val uiState: StateFlow = - combine(baseState, streakProvider.observeStreak()) { base, streak -> - base.copy(currentStreak = streak.current, longestStreak = streak.longest) + combine( + baseState, + streakProvider.observeStreak(), + streakStateRepository.observe(), + _repairOffer, + ) { base, streak, streakState, repair -> + base.copy( + currentStreak = streak.current, + longestStreak = streak.longest, + freezeCount = streakState.freezeTokens, + repairOffer = repair, + ) }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), @@ -194,6 +221,12 @@ class StudyQueueViewModel( fun dismissPremiumNudge() = viewModelScope.launch { settingsRepository.setPremiumNudgeDismissed(true) } + fun repairStreak() = viewModelScope.launch { + // Clear the offer first so the combine never emits a healed streak alongside a stale offer. + _repairOffer.value = null + streakStateManager.repair() + } + /** Persist the chosen order; selecting Shuffle also rolls a new seed (so re-tapping reshuffles). */ fun setSortOrder(order: QueueSortOrder) = viewModelScope.launch { settingsRepository.setQueueSortOrder(order) 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 b6c7ae8..8a81432 100644 --- a/app/src/main/java/nart/simpleanki/feature/study/SessionSummary.kt +++ b/app/src/main/java/nart/simpleanki/feature/study/SessionSummary.kt @@ -24,6 +24,7 @@ import androidx.compose.material.icons.outlined.TrackChanges import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -57,9 +58,12 @@ private val RatingColors = mapOf( Rating.Easy to Color(0xFF00C7BE), ) -/** Study "session complete" summary — mirrors iOS SessionSummaryView (with streak badge). */ +/** + * Study "session complete" summary — mirrors iOS SessionSummaryView. Shows the streak badge, the + * remaining freeze-token count, and a repair offer (with a Repair button) when one is available. + */ @Composable -fun SessionSummary(state: StudyUiState, onDone: () -> Unit) { +fun SessionSummary(state: StudyUiState, onDone: () -> Unit, onRepair: () -> Unit = {}) { val accuracy = sessionAccuracy(state.ratingCounts) val haptics = LocalHapticFeedback.current var appeared by remember { mutableStateOf(false) } @@ -112,6 +116,27 @@ fun SessionSummary(state: StudyUiState, onDone: () -> Unit) { StreakBadge(current = state.currentStreak, longest = state.longestStreak) } + if (state.freezeCount > 0) { + Spacer(Modifier.height(8.dp)) + Text( + "❄️ ${state.freezeCount} freeze${if (state.freezeCount == 1) "" else "s"} remaining", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (state.repairOffer != null) { + Spacer(Modifier.height(16.dp)) + Text( + "Streak broken — repair to restore your ${state.repairOffer.restoredStreak}-day streak", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = onRepair) { Text("Repair") } + } + Spacer(Modifier.weight(1f)) Button( diff --git a/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt b/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt index 84f9573..8f4381f 100644 --- a/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt +++ b/app/src/main/java/nart/simpleanki/feature/study/StudyScreen.kt @@ -57,6 +57,7 @@ fun StudyScreen( onReveal = viewModel::onReveal, onRate = viewModel::onRate, onDone = onDone, + onRepair = viewModel::repairStreak, ) } @@ -68,6 +69,7 @@ fun StudyContent( onReveal: () -> Unit, onRate: (Rating) -> Unit, onDone: () -> Unit, + onRepair: () -> Unit = {}, ) { Scaffold( topBar = { @@ -85,7 +87,7 @@ fun StudyContent( Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { when { state.loading -> CircularProgressIndicator() - state.finished -> SessionSummary(state, onDone) + state.finished -> SessionSummary(state, onDone, onRepair) else -> StudyCard(state, onReveal, onRate) } } 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 0e0ec27..acf13cf 100644 --- a/app/src/main/java/nart/simpleanki/feature/study/StudyViewModel.kt +++ b/app/src/main/java/nart/simpleanki/feature/study/StudyViewModel.kt @@ -13,8 +13,10 @@ 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.repository.StreakStateManager import nart.simpleanki.core.data.repository.StreakStateRepository import nart.simpleanki.core.data.settings.SettingsRepository +import nart.simpleanki.core.domain.streak.RepairOffer import nart.simpleanki.core.data.settings.fsrsParameters import nart.simpleanki.core.domain.fsrs.IntervalFormatter import nart.simpleanki.core.domain.fsrs.SchedulingService @@ -38,6 +40,8 @@ data class StudyUiState( val durationMillis: Long = 0, val currentStreak: Int = 0, val longestStreak: Int = 0, + val freezeCount: Int = 0, + val repairOffer: RepairOffer? = null, ) /** @@ -56,6 +60,7 @@ class StudyViewModel( private val reviewLogRepository: ReviewLogRepository, private val now: () -> Long = { System.currentTimeMillis() }, private val streakStateRepository: StreakStateRepository, + private val streakStateManager: StreakStateManager = StreakStateManager(streakStateRepository, reviewLogRepository, now), private val streakProvider: StreakProvider = StreakProvider(reviewLogRepository, streakStateRepository, now), private val logManager: LogManager = LogManager(emptyList()), ) : ViewModel() { @@ -114,8 +119,18 @@ class StudyViewModel( } private fun refreshSummaryStreak(includingToday: Boolean) = viewModelScope.launch { + if (includingToday) streakStateManager.reconcile() val s = if (includingToday) streakProvider.streakIncludingToday() else streakProvider.observeStreak().first() - _uiState.value = _uiState.value.copy(currentStreak = s.current, longestStreak = s.longest) + val freeze = streakStateRepository.get().freezeTokens + val repair = if (includingToday) streakStateManager.repairOffer(includeToday = true) else null + _uiState.value = _uiState.value.copy( + currentStreak = s.current, longestStreak = s.longest, freezeCount = freeze, repairOffer = repair, + ) + } + + fun repairStreak() = viewModelScope.launch { + streakStateManager.repair() + refreshSummaryStreak(includingToday = true) } fun onReveal() { diff --git a/app/src/test/java/nart/simpleanki/core/data/repository/StreakStateManagerTest.kt b/app/src/test/java/nart/simpleanki/core/data/repository/StreakStateManagerTest.kt index 755fd4a..244687e 100644 --- a/app/src/test/java/nart/simpleanki/core/data/repository/StreakStateManagerTest.kt +++ b/app/src/test/java/nart/simpleanki/core/data/repository/StreakStateManagerTest.kt @@ -5,6 +5,7 @@ import nart.simpleanki.core.data.local.ReviewLogEntity import nart.simpleanki.core.domain.streak.StreakState import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Test import java.util.TimeZone @@ -40,4 +41,16 @@ class StreakStateManagerTest { mgr.repair() assertEquals(setOf(6L), stateRepo.get().frozenDays) } + + @Test + fun repairOffer_includeToday_offersEvenWhenTodayLogNotYetLanded() = runTest { + // logs on civil days 1..5, gap at 6, today = 7, but NO log for day 7 yet. + val logDao = FakeReviewLogDao() + logDao.insertAll((1L..5L).map { log(it) }) + val stateRepo = StreakStateRepository(FakeStreakStateDao(), now = { 0L }) + stateRepo.update(StreakState(lastReconciledDay = 7)) + val mgr = StreakStateManager(stateRepo, ReviewLogRepository(logDao), now = { dayMillis(7) }, timeZone = utc) + assertNull(mgr.repairOffer()) // today not in logs → no offer + assertNotNull(mgr.repairOffer(includeToday = true)) // forcing today → offered + } } 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 2e1ea68..d25d787 100644 --- a/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt +++ b/app/src/test/java/nart/simpleanki/feature/queue/StudyQueueViewModelTest.kt @@ -20,7 +20,9 @@ import nart.simpleanki.core.data.repository.FakeStreakStateDao 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.repository.StreakStateManager import nart.simpleanki.core.data.repository.StreakStateRepository +import nart.simpleanki.core.domain.streak.StreakState import java.util.TimeZone import nart.simpleanki.core.billing.Entitlement import nart.simpleanki.core.billing.FakeEntitlementRepository @@ -59,6 +61,11 @@ class StudyQueueViewModelTest { ) private fun emptyStreak() = StreakProvider(ReviewLogRepository(FakeReviewLogDao()), StreakStateRepository(FakeStreakStateDao())) + private fun emptyStreakStateRepo() = StreakStateRepository(FakeStreakStateDao()) + private fun emptyStreakStateManager( + repo: StreakStateRepository = emptyStreakStateRepo(), + logRepo: ReviewLogRepository = ReviewLogRepository(FakeReviewLogDao()), + ) = StreakStateManager(repo, logRepo, now = { now }) @Test fun aggregatesAcrossDecks_andBreaksDownPerDeck() = runTest { @@ -69,7 +76,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(), emptyStreak(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -103,7 +110,7 @@ class StudyQueueViewModelTest { val vm = StudyQueueViewModel( cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), - FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), now = clock, + FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), now = clock, ) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -125,7 +132,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(), emptyStreak(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -139,7 +146,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(), emptyStreak(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() // Brand-new user: no cards at all. @@ -164,7 +171,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(), emptyStreak(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), settings, FakeEntitlementRepository(), emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -185,7 +192,7 @@ class StudyQueueViewModelTest { val settings = FakeSettingsRepository( AppSettings(dailyGoalEnabled = false, newCardsTarget = 1, reviewCardsTarget = 0), ) - val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), settings, FakeEntitlementRepository(), emptyStreak(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), settings, FakeEntitlementRepository(), emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -201,7 +208,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(), emptyStreak(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, folderRepo, FakeSettingsRepository(), FakeEntitlementRepository(), emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -227,7 +234,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(), emptyStreak(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), settings, FakeEntitlementRepository(), emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -243,13 +250,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, emptyStreak(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), free, emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), 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, emptyStreak(), now = { now }) + val vm2 = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), premium, emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), now = { now }) backgroundScope.launch { vm2.uiState.collect {} } runCurrent() assertFalse(vm2.uiState.value.showPremiumNudge) @@ -261,7 +268,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)), emptyStreak(), now = { now }) + val vm = StudyQueueViewModel(cardRepo, deckRepo, FolderRepository(FakeFolderDao(), now = { now }), FakeSettingsRepository(), FakeEntitlementRepository(Entitlement(PremiumTier.None)), emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), now = { now }) backgroundScope.launch { vm.uiState.collect {} } runCurrent() assertTrue(vm.uiState.value.showPremiumNudge) @@ -276,7 +283,7 @@ class StudyQueueViewModelTest { CardRepository(FakeCardDao(), now = { now }), DeckRepository(FakeDeckDao(), now = { now }), FolderRepository(FakeFolderDao(), now = { now }), - settings, FakeEntitlementRepository(), emptyStreak(), now = { now }, + settings, FakeEntitlementRepository(), emptyStreak(), emptyStreakStateRepo(), emptyStreakStateManager(), now = { now }, ) backgroundScope.launch { vm.uiState.collect {} } runCurrent() @@ -299,11 +306,94 @@ class StudyQueueViewModelTest { CardRepository(FakeCardDao(), now = { now }), DeckRepository(FakeDeckDao(), now = { now }), FolderRepository(FakeFolderDao(), now = { now }), - FakeSettingsRepository(), FakeEntitlementRepository(), streak, now = { now }, + FakeSettingsRepository(), FakeEntitlementRepository(), streak, emptyStreakStateRepo(), emptyStreakStateManager(), now = { now }, ) backgroundScope.launch { vm.uiState.collect {} } runCurrent() assertEquals(2, vm.uiState.value.currentStreak) } + + @Test + fun freezeCount_isExposed() = runTest { + val stateDao = FakeStreakStateDao() + val stateRepo = StreakStateRepository(stateDao, now = { now }) + stateRepo.update(StreakState(freezeTokens = 2, lastReconciledDay = 999_999)) + // Share one ReviewLogRepository between provider and manager so they read the same logs. + val logRepo = ReviewLogRepository(FakeReviewLogDao()) + + val vm = StudyQueueViewModel( + cardRepository = CardRepository(FakeCardDao(), now = { now }), + deckRepository = DeckRepository(FakeDeckDao(), now = { now }), + folderRepository = FolderRepository(FakeFolderDao(), now = { now }), + settingsRepository = FakeSettingsRepository(), + entitlementRepository = FakeEntitlementRepository(), + streakProvider = StreakProvider(logRepo, stateRepo, now = { now }), + streakStateRepository = stateRepo, + streakStateManager = StreakStateManager(stateRepo, logRepo, now = { now }), + now = { now }, + ) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + assertEquals(2, vm.uiState.value.freezeCount) + } + + @Test + fun repairStreak_appliesOffer_andClearsIt() = runTest { + val utc = TimeZone.getTimeZone("UTC") + // Civil-day-noon millis (UTC) for a given epoch-day index — stable, DST-free bucketing. + fun dayNoon(epochDay: Long) = epochDay * 86_400_000L + 43_200_000L + // "now" is fixed at noon of `today`; the manager, provider and clock all bucket in UTC. + val today = 19_675L + val nowMillis = dayNoon(today) + + fun log(id: String, epochDay: Long) = + ReviewLogEntity(id, "c1", 3, 2, 0, 1.0, 5.0, 0.0, 0.0, 0.0, dayNoon(epochDay), false) + + val logDao = FakeReviewLogDao() + // A prior 2-day run ending at today-2 (T-3, T-2), a gap at today-1, and study today. + logDao.insertAll(listOf( + log("a", today - 3), + log("b", today - 2), + log("c", today), + )) + val logRepo = ReviewLogRepository(logDao) + + val stateDao = FakeStreakStateDao() + val stateRepo = StreakStateRepository(stateDao, now = { nowMillis }) + // Seed lastReconciledDay = today so reconcile() in init is a no-op (no auto-freeze of the gap). + stateRepo.update(StreakState(lastReconciledDay = today)) + + val provider = StreakProvider(logRepo, stateRepo, now = { nowMillis }, timeZone = utc) + val manager = StreakStateManager(stateRepo, logRepo, now = { nowMillis }, timeZone = utc) + + val vm = StudyQueueViewModel( + cardRepository = CardRepository(FakeCardDao(), now = { nowMillis }), + deckRepository = DeckRepository(FakeDeckDao(), now = { nowMillis }), + folderRepository = FolderRepository(FakeFolderDao(), now = { nowMillis }), + settingsRepository = FakeSettingsRepository(), + entitlementRepository = FakeEntitlementRepository(), + streakProvider = provider, + streakStateRepository = stateRepo, + streakStateManager = manager, + now = { nowMillis }, + ) + backgroundScope.launch { vm.uiState.collect {} } + runCurrent() + + // An offer is surfaced: prior run of 2 (T-3, T-2) + 2 => 4-day restore. + val offer = vm.uiState.value.repairOffer + assertTrue("expected a repair offer", offer != null) + assertEquals(4, offer!!.restoredStreak) + // Before repair the gap breaks the run: only today is active. + assertEquals(1, vm.uiState.value.currentStreak) + + vm.repairStreak() + runCurrent() + + // Offer cleared, and the frozen gap restores the run through to today. + assertEquals(null, vm.uiState.value.repairOffer) + assertEquals(4, vm.uiState.value.currentStreak) + } }