From 02c167b5fddec61cae4e494e45b838dc801ab9b7 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 17:34:53 +0400 Subject: [PATCH 1/5] Add design spec for streak-saver notification --- ...-06-05-streak-saver-notification-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-05-streak-saver-notification-design.md diff --git a/docs/superpowers/specs/2026-06-05-streak-saver-notification-design.md b/docs/superpowers/specs/2026-06-05-streak-saver-notification-design.md new file mode 100644 index 0000000..253782f --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-streak-saver-notification-design.md @@ -0,0 +1,134 @@ +# Streak-Saver Notification — Design + +**Date:** 2026-06-05 +**Status:** Approved (design); pending implementation plan +**Branch:** `feature/streak-saver-notification` (off `main`). + +## Goal + +Add a third daily reminder — a **streak-saver** — that fires in the evening only when the user's +study streak is **at risk of breaking tonight** and isn't already protected by a freeze. This is the +single highest-ROI retention nudge (case studies: ~21% retention lift, ~40% churn reduction), and it +rides the existing reminder infrastructure + the freeze system just shipped. + +## Background + +The notification system (`core/notifications/`) already has two self-rescheduling daily reminders: +- `ReminderType { Study, Goal }` (each `key` seeds a unique WorkManager name + notification id). +- A **pure** `reminderContent(type, settings, studiedToday, readyCount): NotificationContent?` — + returns null to post nothing (kept Android-free for unit tests). +- `ReminderWorker` (CoroutineWorker, KoinComponent): reads settings + cards, computes `studiedToday` + (`fsrsLastReview >= startOfDay`) and `readyCount` (`StudyQueueBuilder`), calls `reminderContent`, + posts via `Notifier`, then `scheduler.schedule(type, hour, minute)` to chain tomorrow. +- `WorkManagerReminderScheduler` (a unique self-rescheduling `OneTimeWorkRequest` per type). +- Settings: `AppSettings` + `SettingsRepository` (DataStore) hold `studyReminderEnabled/Hour(9)/Minute(0)` + and `goalReminderEnabled/Hour(20)/Minute(0)`, with combined setters `setStudyReminder(enabled,h,m)` + / `setGoalReminder(...)`. `FakeSettingsRepository` mirrors them. +- `AzriApplication.ensureReminders()` re-arms enabled reminders on launch. +- UI: `NotificationsScreen` (two Switch + `TimePicker` rows) backed by `NotificationsViewModel` + (`setStudy`/`setGoal`). +- Just shipped: `StreakProvider.observeStreak(): Flow` (`.current`) and + `StreakStateRepository.observe(): Flow` (`.freezeTokens`). + +## Decisions (from brainstorming) + +- **Fire condition:** post when `currentStreak > 0` **and** `studiedToday == 0` **and** + `freezeTokens == 0`. (Studied yesterday → streak alive; not studied today → lapses at midnight; no + freeze → a freeze can't save it.) +- **Skip when freeze-protected** (`freezeTokens == 0` is required to fire) — don't nag a safe streak. +- **Fire regardless of due count** — any review (incl. cramming) keeps the streak, so don't gate on + `readyCount`. +- **Matches the existing reminders otherwise:** an opt-in toggle + configurable time, default **20:00**, + default **off**, same "reminders" channel + `POST_NOTIFICATIONS` permission (no new infra). + +## Components + +### 1. `core/notifications/ReminderContent.kt` +- Add `StreakSaver("reminder_streak_saver")` to the `ReminderType` enum. +- Extend the pure decision signature with two defaulted params (Study/Goal ignore them): + ```kotlin + fun reminderContent( + type: ReminderType, + settings: AppSettings, + studiedToday: Int, + readyCount: Int, + currentStreak: Int = 0, + freezeTokens: Int = 0, + ): NotificationContent? + ``` +- Add the branch: + ```kotlin + ReminderType.StreakSaver -> { + if (currentStreak <= 0 || studiedToday > 0 || freezeTokens > 0) null + else NotificationContent( + title = "Keep your streak alive", + body = "Your $currentStreak-day streak ends at midnight — a quick review saves it.", + ) + } + ``` + (The `enabled` flag is NOT checked here — the worker already returns early when the reminder is + disabled, matching how Study/Goal work.) + +### 2. `core/notifications/ReminderWorker.kt` +- Inject `StreakProvider` and `StreakStateRepository` (Koin, like the existing injects). +- Compute `currentStreak = streakProvider.observeStreak().first().current` and + `freezeTokens = streakStateRepository.observe().first().freezeTokens`, and pass both into + `reminderContent(...)`. (Computed unconditionally; Study/Goal ignore them — keeps the worker simple.) +- Add `StreakSaver` to `scheduleFor`: + `ReminderType.StreakSaver -> Schedule(streakSaverEnabled, streakSaverHour, streakSaverMinute)`. + +### 3. Settings (`AppSettings` + `SettingsRepository` + DataStore + `FakeSettingsRepository`) +- `AppSettings` gains `streakSaverEnabled = false`, `streakSaverHour = 20`, `streakSaverMinute = 0`. +- DataStore keys `STREAK_SAVER_ON`/`STREAK_SAVER_HOUR`/`STREAK_SAVER_MIN`; read them in the settings + mapping with the same defaults. +- `SettingsRepository` interface + impl gain `suspend fun setStreakSaverReminder(enabled, hour, minute)` + (mirrors `setStudyReminder`/`setGoalReminder`). +- `FakeSettingsRepository` implements the new field/setter. + +### 4. `AzriApplication.ensureReminders()` +- Re-arm the streak-saver when enabled: + `if (settings.streakSaverEnabled) scheduler.schedule(ReminderType.StreakSaver, settings.streakSaverHour, settings.streakSaverMinute)`. + +### 5. UI (`feature/notifications/NotificationsViewModel.kt` + `NotificationsScreen.kt`) +- `NotificationsUiState` gains `streakSaverEnabled`/`streakSaverHour`/`streakSaverMinute`; the + settings-collection block maps them; add `setStreakSaver(enabled, hour, minute)` → repository. +- `NotificationsScreen` adds a third Switch + `TimePicker` row, "Streak saver" (subtitle e.g. + "An evening nudge when your streak is about to lapse"), identical in structure to the Study/Goal + rows, wired to `viewModel::setStreakSaver`. Enabling it requests `POST_NOTIFICATIONS` via the same + existing launcher path the other toggles use. + +## Data flow + +Evening trigger → `ReminderWorker` reads settings (streak-saver enabled? else stop), cards +(`studiedToday`), `StreakProvider` (`currentStreak`), `StreakStateRepository` (`freezeTokens`) → +pure `reminderContent(StreakSaver, …)` returns a notification iff `currentStreak>0 && studiedToday==0 +&& freezeTokens==0` → `Notifier.post` → reschedule for tomorrow. + +## Error handling + +- Disabled mid-chain → worker returns `Result.success()` without rescheduling (same as Study/Goal). +- Pure decision is null-safe and side-effect-free; a protected or already-extended streak simply + posts nothing but still reschedules. +- Reading streak/freeze state is a one-shot `.first()` on existing flows; no new failure modes. + +## Testing + +- **`ReminderContentTest`** (pure, JVM): `StreakSaver` fires for `currentStreak>0, studiedToday==0, + freezeTokens==0`; returns null for each of — `studiedToday>0` (already studied), `currentStreak==0` + (no streak), `freezeTokens>0` (protected). Singular/plural copy uses the streak count directly + ("1-day streak" is acceptable). +- **`NotificationsViewModelTest`**: `setStreakSaver(true, 21, 30)` persists via the repository and + surfaces in `uiState`. +- **`ReminderWorker.scheduleFor` + `ensureReminders`**: compile/integration-verified (the worker and + Application are Android-coupled; the emulator is unavailable for instrumented runs). + +**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) + +- Per-user-activity send-time optimization (fixed configurable time only). +- Snooze / action buttons on the notification. +- Changing the Study/Goal reminders, the streak/freeze logic, the notification channel, or sync. +- A separate "streak milestone" celebration notification (separate backlog item). From 336b9a048033be40b2fed0fcaa5aeae159c0861e Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 17:38:33 +0400 Subject: [PATCH 2/5] Make streak-saver notification fully automatic (no toggle) --- ...-06-05-streak-saver-notification-design.md | 74 ++++++++++--------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/docs/superpowers/specs/2026-06-05-streak-saver-notification-design.md b/docs/superpowers/specs/2026-06-05-streak-saver-notification-design.md index 253782f..b21a73f 100644 --- a/docs/superpowers/specs/2026-06-05-streak-saver-notification-design.md +++ b/docs/superpowers/specs/2026-06-05-streak-saver-notification-design.md @@ -38,8 +38,12 @@ The notification system (`core/notifications/`) already has two self-reschedulin - **Skip when freeze-protected** (`freezeTokens == 0` is required to fire) — don't nag a safe streak. - **Fire regardless of due count** — any review (incl. cramming) keeps the streak, so don't gate on `readyCount`. -- **Matches the existing reminders otherwise:** an opt-in toggle + configurable time, default **20:00**, - default **off**, same "reminders" channel + `POST_NOTIFICATIONS` permission (no new infra). +- **Fully automatic — no in-app toggle or time picker.** The streak-saver is always armed at a fixed + evening time (`20:00`, a code constant); there is no settings field and no Notifications-screen row + for it. The only "off switch" is the OS notification permission / the app's "reminders" channel + (which the user already controls at the system level). It reuses the existing "reminders" channel + + `POST_NOTIFICATIONS` (no new infra). The at-risk condition above is the real gate, so on most + evenings it posts nothing. ## Components @@ -69,58 +73,57 @@ The notification system (`core/notifications/`) already has two self-reschedulin (The `enabled` flag is NOT checked here — the worker already returns early when the reminder is disabled, matching how Study/Goal work.) +- Add a fixed evening time as a constant (e.g. in `ReminderContent.kt` next to the enum): + ```kotlin + const val STREAK_SAVER_HOUR = 20 + const val STREAK_SAVER_MINUTE = 0 + ``` + ### 2. `core/notifications/ReminderWorker.kt` - Inject `StreakProvider` and `StreakStateRepository` (Koin, like the existing injects). - Compute `currentStreak = streakProvider.observeStreak().first().current` and `freezeTokens = streakStateRepository.observe().first().freezeTokens`, and pass both into `reminderContent(...)`. (Computed unconditionally; Study/Goal ignore them — keeps the worker simple.) -- Add `StreakSaver` to `scheduleFor`: - `ReminderType.StreakSaver -> Schedule(streakSaverEnabled, streakSaverHour, streakSaverMinute)`. - -### 3. Settings (`AppSettings` + `SettingsRepository` + DataStore + `FakeSettingsRepository`) -- `AppSettings` gains `streakSaverEnabled = false`, `streakSaverHour = 20`, `streakSaverMinute = 0`. -- DataStore keys `STREAK_SAVER_ON`/`STREAK_SAVER_HOUR`/`STREAK_SAVER_MIN`; read them in the settings - mapping with the same defaults. -- `SettingsRepository` interface + impl gain `suspend fun setStreakSaverReminder(enabled, hour, minute)` - (mirrors `setStudyReminder`/`setGoalReminder`). -- `FakeSettingsRepository` implements the new field/setter. - -### 4. `AzriApplication.ensureReminders()` -- Re-arm the streak-saver when enabled: - `if (settings.streakSaverEnabled) scheduler.schedule(ReminderType.StreakSaver, settings.streakSaverHour, settings.streakSaverMinute)`. - -### 5. UI (`feature/notifications/NotificationsViewModel.kt` + `NotificationsScreen.kt`) -- `NotificationsUiState` gains `streakSaverEnabled`/`streakSaverHour`/`streakSaverMinute`; the - settings-collection block maps them; add `setStreakSaver(enabled, hour, minute)` → repository. -- `NotificationsScreen` adds a third Switch + `TimePicker` row, "Streak saver" (subtitle e.g. - "An evening nudge when your streak is about to lapse"), identical in structure to the Study/Goal - rows, wired to `viewModel::setStreakSaver`. Enabling it requests `POST_NOTIFICATIONS` via the same - existing launcher path the other toggles use. +- Add `StreakSaver` to `scheduleFor` — **always enabled** at the fixed time (it has no settings): + `ReminderType.StreakSaver -> Schedule(enabled = true, hour = STREAK_SAVER_HOUR, minute = STREAK_SAVER_MINUTE)`. + (So the worker never early-returns for being "disabled"; the at-risk condition in `reminderContent` + is the gate, and it always reschedules tomorrow.) + +### 3. `AzriApplication.ensureReminders()` +- **Unconditionally** arm the streak-saver on launch (no settings check): + `scheduler.schedule(ReminderType.StreakSaver, STREAK_SAVER_HOUR, STREAK_SAVER_MINUTE)`. + (Study/Goal stay gated on their enabled flags; only the streak-saver is always-on.) + +**No Settings and no UI changes** — by design (the feature is automatic). `AppSettings`, +`SettingsRepository`, DataStore, `FakeSettingsRepository`, `NotificationsViewModel`, and +`NotificationsScreen` are untouched. ## Data flow -Evening trigger → `ReminderWorker` reads settings (streak-saver enabled? else stop), cards -(`studiedToday`), `StreakProvider` (`currentStreak`), `StreakStateRepository` (`freezeTokens`) → -pure `reminderContent(StreakSaver, …)` returns a notification iff `currentStreak>0 && studiedToday==0 -&& freezeTokens==0` → `Notifier.post` → reschedule for tomorrow. +Evening trigger (fixed 20:00) → `ReminderWorker` reads cards (`studiedToday`), `StreakProvider` +(`currentStreak`), `StreakStateRepository` (`freezeTokens`) → pure `reminderContent(StreakSaver, …)` +returns a notification iff `currentStreak>0 && studiedToday==0 && freezeTokens==0` → `Notifier.post` +(silently dropped by the OS if notifications are disabled) → reschedule for tomorrow. ## Error handling -- Disabled mid-chain → worker returns `Result.success()` without rescheduling (same as Study/Goal). +- The streak-saver always reschedules (it's never "disabled" in-app); the at-risk gate decides + whether to post. - Pure decision is null-safe and side-effect-free; a protected or already-extended streak simply posts nothing but still reschedules. - Reading streak/freeze state is a one-shot `.first()` on existing flows; no new failure modes. +- If the user has revoked notification permission, `Notifier.post` is a no-op at the OS level — the + worker still completes successfully. ## Testing - **`ReminderContentTest`** (pure, JVM): `StreakSaver` fires for `currentStreak>0, studiedToday==0, freezeTokens==0`; returns null for each of — `studiedToday>0` (already studied), `currentStreak==0` - (no streak), `freezeTokens>0` (protected). Singular/plural copy uses the streak count directly - ("1-day streak" is acceptable). -- **`NotificationsViewModelTest`**: `setStreakSaver(true, 21, 30)` persists via the repository and - surfaces in `uiState`. + (no streak), `freezeTokens>0` (protected). Copy uses the streak count directly ("1-day streak" is + acceptable). - **`ReminderWorker.scheduleFor` + `ensureReminders`**: compile/integration-verified (the worker and Application are Android-coupled; the emulator is unavailable for instrumented runs). +- No settings/VM tests — there are no settings or UI changes. **Build/test prefix:** Gradle commands MUST be prefixed with `export JAVA_HOME=/opt/homebrew/opt/openjdk &&`, run from @@ -128,7 +131,10 @@ pure `reminderContent(StreakSaver, …)` returns a notification iff `currentStre ## Out of scope (v1) -- Per-user-activity send-time optimization (fixed configurable time only). +- **Any in-app toggle or time picker for the streak-saver** — it's intentionally automatic; the OS + notification controls are the only off switch. (If a user-facing control is wanted later, it's an + additive follow-up.) +- Per-user-activity send-time optimization (fixed 20:00 only). - Snooze / action buttons on the notification. - Changing the Study/Goal reminders, the streak/freeze logic, the notification channel, or sync. - A separate "streak milestone" celebration notification (separate backlog item). From 83b1cb8367da49aa09e32a519d8bdb5dbcaebaf3 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 17:42:20 +0400 Subject: [PATCH 3/5] Add implementation plan for streak-saver notification --- .../2026-06-05-streak-saver-notification.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-05-streak-saver-notification.md diff --git a/docs/superpowers/plans/2026-06-05-streak-saver-notification.md b/docs/superpowers/plans/2026-06-05-streak-saver-notification.md new file mode 100644 index 0000000..79f5e67 --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-streak-saver-notification.md @@ -0,0 +1,217 @@ +# Streak-Saver Notification 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 an automatic evening "streak-saver" notification that fires only when the study streak is at risk tonight and isn't freeze-protected. + +**Architecture:** A third `ReminderType.StreakSaver` reuses the existing self-rescheduling reminder worker. The pure `reminderContent` gains a `StreakSaver` branch gated by `currentStreak>0 && studiedToday==0 && freezeTokens==0`. The worker reads streak + freeze state and always reschedules at a fixed 20:00; `ensureReminders()` arms it unconditionally. No settings, no UI. + +**Tech Stack:** Kotlin, WorkManager (`CoroutineWorker`), Koin, JUnit4. + +**Branch:** `feature/streak-saver-notification` — **stacked on `feature/streak-freeze-repair` (PR #19)**, which provides `StreakStateRepository` + the 2-arg `StreakProvider`. Retarget this PR onto `main` once #19 merges. + +**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/notifications/ReminderContent.kt` (modify) — `StreakSaver` enum value, time constants, extended `reminderContent` signature + branch. +- `core/notifications/ReminderWorker.kt` (modify) — inject streak/freeze state, pass into `reminderContent`, `scheduleFor` always-on at fixed time. +- `AzriApplication.kt` (modify) — `ensureReminders()` arms the streak-saver unconditionally. +- `core/notifications/ReminderContentTest.kt` (modify) — pure decision tests. + +--- + +## Task 1: Pure StreakSaver decision + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/core/notifications/ReminderContent.kt` +- Test: `app/src/test/java/nart/simpleanki/core/notifications/ReminderContentTest.kt` + +- [ ] **Step 1: Write the failing tests** — append inside `class ReminderContentTest`: +```kotlin + @Test + fun streakSaver_postsWhenAtRiskAndUnprotected() { + val c = reminderContent( + ReminderType.StreakSaver, settings, studiedToday = 0, readyCount = 0, + currentStreak = 5, freezeTokens = 0, + )!! + assertEquals("Keep your streak alive", c.title) + assertTrue(c.body.contains("5-day streak")) + } + + @Test + fun streakSaver_skipsWhenAlreadyStudiedToday() { + assertNull(reminderContent( + ReminderType.StreakSaver, settings, studiedToday = 1, readyCount = 0, + currentStreak = 5, freezeTokens = 0, + )) + } + + @Test + fun streakSaver_skipsWhenNoStreak() { + assertNull(reminderContent( + ReminderType.StreakSaver, settings, studiedToday = 0, readyCount = 0, + currentStreak = 0, freezeTokens = 0, + )) + } + + @Test + fun streakSaver_skipsWhenFreezeProtected() { + assertNull(reminderContent( + ReminderType.StreakSaver, settings, studiedToday = 0, readyCount = 0, + currentStreak = 5, freezeTokens = 1, + )) + } +``` + +- [ ] **Step 2: Run to verify failure** (compile error — `StreakSaver` + the new params don't exist) + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.notifications.ReminderContentTest"` +Expected: COMPILE FAILURE. + +- [ ] **Step 3: Implement in `ReminderContent.kt`** + +Add `StreakSaver` to the enum: +```kotlin +enum class ReminderType(val key: String) { + Study("reminder_study"), + Goal("reminder_goal"), + StreakSaver("reminder_streak_saver"), +} +``` +Add the fixed-time constants (top-level in the file, e.g. just below the enum): +```kotlin +/** The streak-saver is automatic (no setting): a fixed evening time. */ +const val STREAK_SAVER_HOUR = 20 +const val STREAK_SAVER_MINUTE = 0 +``` +Extend the `reminderContent` signature with two defaulted params (Study/Goal ignore them) and add the branch: +```kotlin +fun reminderContent( + type: ReminderType, + settings: AppSettings, + studiedToday: Int, + readyCount: Int, + currentStreak: Int = 0, + freezeTokens: Int = 0, +): NotificationContent? = when (type) { + ReminderType.Study -> { + if (readyCount <= 0) null + else NotificationContent( + title = "Time to study", + body = "You have $readyCount ${cards(readyCount)} ready — a quick session keeps you sharp.", + ) + } + + ReminderType.Goal -> { + val remaining = settings.dailyGoalTotal - studiedToday + if (!settings.dailyGoalEnabled || settings.dailyGoalTotal <= 0 || remaining <= 0) null + else NotificationContent( + title = "Daily goal", + body = "You're $remaining ${cards(remaining)} short of today's goal. A few minutes gets you there.", + ) + } + + ReminderType.StreakSaver -> { + // Fire only when a streak exists, today hasn't been studied yet, and no freeze can save it. + if (currentStreak <= 0 || studiedToday > 0 || freezeTokens > 0) null + else NotificationContent( + title = "Keep your streak alive", + body = "Your $currentStreak-day streak ends at midnight — a quick review saves it.", + ) + } +} +``` +(Keep the existing `private fun cards(n: Int)` helper as-is.) + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest --tests "nart.simpleanki.core.notifications.ReminderContentTest"` +Expected: PASS (existing Study/Goal tests + the 4 new StreakSaver tests). + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/notifications/ReminderContent.kt \ + app/src/test/java/nart/simpleanki/core/notifications/ReminderContentTest.kt +git commit -m "Add streak-saver reminder decision" +``` + +--- + +## Task 2: Wire the worker + auto-arm on launch + +**Files:** +- Modify: `app/src/main/java/nart/simpleanki/core/notifications/ReminderWorker.kt` +- Modify: `app/src/main/java/nart/simpleanki/AzriApplication.kt` + +No unit test (Android-coupled `CoroutineWorker` + `Application`); verified by a compiling build. The decision logic is already covered by Task 1. + +- [ ] **Step 1: `ReminderWorker.kt` — inject streak + freeze state and pass into `reminderContent`** + +Add imports + injected deps (mirroring the existing `by inject()` lines): +```kotlin +import nart.simpleanki.core.data.repository.StreakProvider +import nart.simpleanki.core.data.repository.StreakStateRepository +``` +```kotlin + private val streakProvider: StreakProvider by inject() + private val streakStateRepository: StreakStateRepository by inject() +``` +In `doWork()`, after the existing `readyCount` line, compute the streak + freeze count and pass them into `reminderContent`: +```kotlin + val readyCount = StudyQueueBuilder.buildStudyQueue(cards, now, Int.MAX_VALUE, Int.MAX_VALUE).size + val currentStreak = streakProvider.observeStreak().first().current + val freezeTokens = streakStateRepository.observe().first().freezeTokens + + reminderContent(type, settings, studiedToday, readyCount, currentStreak, freezeTokens) + ?.let { notifier.post(type, it) } + + scheduler.schedule(type, hour, minute) // chain tomorrow + return Result.success() +``` +(Replace the existing `reminderContent(type, settings, studiedToday, readyCount)?.let { ... }` call with the 6-arg version above. `kotlinx.coroutines.flow.first` is already imported.) + +- [ ] **Step 2: `ReminderWorker.kt` — `scheduleFor` always-on at the fixed time** + +Add the `StreakSaver` case to the `AppSettings.scheduleFor(type)` `when` (it has no settings, so it's always enabled at the fixed constant time): +```kotlin + private fun AppSettings.scheduleFor(type: ReminderType): Schedule = when (type) { + ReminderType.Study -> Schedule(studyReminderEnabled, studyReminderHour, studyReminderMinute) + ReminderType.Goal -> Schedule(goalReminderEnabled, goalReminderHour, goalReminderMinute) + ReminderType.StreakSaver -> Schedule(enabled = true, hour = STREAK_SAVER_HOUR, minute = STREAK_SAVER_MINUTE) + } +``` + +- [ ] **Step 3: `AzriApplication.kt` — arm the streak-saver unconditionally** + +In `ensureReminders()`, after the Study/Goal blocks, add (import `STREAK_SAVER_HOUR`/`STREAK_SAVER_MINUTE` from `nart.simpleanki.core.notifications`): +```kotlin + // The streak-saver is automatic (no toggle): always armed at the fixed evening time. + scheduler.schedule(ReminderType.StreakSaver, STREAK_SAVER_HOUR, STREAK_SAVER_MINUTE) +``` +Add the imports: +```kotlin +import nart.simpleanki.core.notifications.STREAK_SAVER_HOUR +import nart.simpleanki.core.notifications.STREAK_SAVER_MINUTE +``` + +- [ ] **Step 4: Verify compile + full unit suite + APK** + +Run: `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:compileDebugKotlin :app:testDebugUnitTest :app:assembleDebug` +Expected: BUILD SUCCESSFUL; all unit tests pass (no behavioral change to Study/Goal). + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/nart/simpleanki/core/notifications/ReminderWorker.kt \ + app/src/main/java/nart/simpleanki/AzriApplication.kt +git commit -m "Auto-arm the streak-saver reminder and feed it streak + freeze state" +``` + +--- + +## Final verification +- [ ] `export JAVA_HOME=/opt/homebrew/opt/openjdk && ./gradlew :app:testDebugUnitTest :app:assembleDebug` → BUILD SUCCESSFUL. +- [ ] (Optional, emulator) Smoke test: with a streak alive (studied yesterday, not today) and 0 freezes, run the `ReminderWorker` for `StreakSaver` (or set the device clock to ~20:00) → a "Keep your streak alive" notification appears; with a freeze available, or after studying today, it posts nothing. From b9b2d18aa5cbf80ef7f2156d733d0fe270523336 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 17:44:14 +0400 Subject: [PATCH 4/5] Add streak-saver reminder decision --- .../core/notifications/ReminderContent.kt | 16 +++++++++ .../core/notifications/ReminderWorker.kt | 1 + .../core/notifications/ReminderContentTest.kt | 34 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/app/src/main/java/nart/simpleanki/core/notifications/ReminderContent.kt b/app/src/main/java/nart/simpleanki/core/notifications/ReminderContent.kt index d2161a1..d03de24 100644 --- a/app/src/main/java/nart/simpleanki/core/notifications/ReminderContent.kt +++ b/app/src/main/java/nart/simpleanki/core/notifications/ReminderContent.kt @@ -7,8 +7,13 @@ import nart.simpleanki.core.data.settings.dailyGoalTotal enum class ReminderType(val key: String) { Study("reminder_study"), Goal("reminder_goal"), + StreakSaver("reminder_streak_saver"), } +/** The streak-saver is automatic (no setting): a fixed evening time. */ +const val STREAK_SAVER_HOUR = 20 +const val STREAK_SAVER_MINUTE = 0 + /** What a reminder should display when it fires. */ data class NotificationContent(val title: String, val body: String) @@ -26,6 +31,8 @@ fun reminderContent( settings: AppSettings, studiedToday: Int, readyCount: Int, + currentStreak: Int = 0, + freezeTokens: Int = 0, ): NotificationContent? = when (type) { ReminderType.Study -> { if (readyCount <= 0) null @@ -43,6 +50,15 @@ fun reminderContent( body = "You're $remaining ${cards(remaining)} short of today's goal. A few minutes gets you there.", ) } + + ReminderType.StreakSaver -> { + // Fire only when a streak exists, today hasn't been studied yet, and no freeze can save it. + if (currentStreak <= 0 || studiedToday > 0 || freezeTokens > 0) null + else NotificationContent( + title = "Keep your streak alive", + body = "Your $currentStreak-day streak ends at midnight — a quick review saves it.", + ) + } } private fun cards(n: Int) = if (n == 1) "card" else "cards" diff --git a/app/src/main/java/nart/simpleanki/core/notifications/ReminderWorker.kt b/app/src/main/java/nart/simpleanki/core/notifications/ReminderWorker.kt index ed2de47..17cde54 100644 --- a/app/src/main/java/nart/simpleanki/core/notifications/ReminderWorker.kt +++ b/app/src/main/java/nart/simpleanki/core/notifications/ReminderWorker.kt @@ -52,6 +52,7 @@ class ReminderWorker( private fun AppSettings.scheduleFor(type: ReminderType): Schedule = when (type) { ReminderType.Study -> Schedule(studyReminderEnabled, studyReminderHour, studyReminderMinute) ReminderType.Goal -> Schedule(goalReminderEnabled, goalReminderHour, goalReminderMinute) + ReminderType.StreakSaver -> Schedule(enabled = true, STREAK_SAVER_HOUR, STREAK_SAVER_MINUTE) } private fun startOfDay(nowMillis: Long): Long = Calendar.getInstance().apply { diff --git a/app/src/test/java/nart/simpleanki/core/notifications/ReminderContentTest.kt b/app/src/test/java/nart/simpleanki/core/notifications/ReminderContentTest.kt index 3b6cce3..fbde727 100644 --- a/app/src/test/java/nart/simpleanki/core/notifications/ReminderContentTest.kt +++ b/app/src/test/java/nart/simpleanki/core/notifications/ReminderContentTest.kt @@ -45,4 +45,38 @@ class ReminderContentTest { assertEquals("Daily goal", c.title) assertTrue("remaining = 8", c.body.contains("8 cards")) } + + @Test + fun streakSaver_postsWhenAtRiskAndUnprotected() { + val c = reminderContent( + ReminderType.StreakSaver, settings, studiedToday = 0, readyCount = 0, + currentStreak = 5, freezeTokens = 0, + )!! + assertEquals("Keep your streak alive", c.title) + assertTrue(c.body.contains("5-day streak")) + } + + @Test + fun streakSaver_skipsWhenAlreadyStudiedToday() { + assertNull(reminderContent( + ReminderType.StreakSaver, settings, studiedToday = 1, readyCount = 0, + currentStreak = 5, freezeTokens = 0, + )) + } + + @Test + fun streakSaver_skipsWhenNoStreak() { + assertNull(reminderContent( + ReminderType.StreakSaver, settings, studiedToday = 0, readyCount = 0, + currentStreak = 0, freezeTokens = 0, + )) + } + + @Test + fun streakSaver_skipsWhenFreezeProtected() { + assertNull(reminderContent( + ReminderType.StreakSaver, settings, studiedToday = 0, readyCount = 0, + currentStreak = 5, freezeTokens = 1, + )) + } } From 4735fd3214c2018e269b040be28e64ac20c251b0 Mon Sep 17 00:00:00 2001 From: Astemir Boziev Date: Fri, 5 Jun 2026 17:47:49 +0400 Subject: [PATCH 5/5] Auto-arm the streak-saver reminder and feed it streak + freeze state --- app/src/main/java/nart/simpleanki/AzriApplication.kt | 4 ++++ .../nart/simpleanki/core/notifications/ReminderWorker.kt | 9 ++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/nart/simpleanki/AzriApplication.kt b/app/src/main/java/nart/simpleanki/AzriApplication.kt index 8c657d6..4397855 100644 --- a/app/src/main/java/nart/simpleanki/AzriApplication.kt +++ b/app/src/main/java/nart/simpleanki/AzriApplication.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import nart.simpleanki.core.data.settings.SettingsRepository import nart.simpleanki.core.data.sync.SyncWorker +import nart.simpleanki.core.notifications.STREAK_SAVER_HOUR +import nart.simpleanki.core.notifications.STREAK_SAVER_MINUTE import nart.simpleanki.core.notifications.ReminderScheduler import nart.simpleanki.core.notifications.ReminderType import nart.simpleanki.di.appModule @@ -41,6 +43,8 @@ class AzriApplication : Application() { if (settings.goalReminderEnabled) { scheduler.schedule(ReminderType.Goal, settings.goalReminderHour, settings.goalReminderMinute) } + // The streak-saver is automatic (no toggle): always armed at the fixed evening time. + scheduler.schedule(ReminderType.StreakSaver, STREAK_SAVER_HOUR, STREAK_SAVER_MINUTE) } } } diff --git a/app/src/main/java/nart/simpleanki/core/notifications/ReminderWorker.kt b/app/src/main/java/nart/simpleanki/core/notifications/ReminderWorker.kt index 17cde54..990395b 100644 --- a/app/src/main/java/nart/simpleanki/core/notifications/ReminderWorker.kt +++ b/app/src/main/java/nart/simpleanki/core/notifications/ReminderWorker.kt @@ -5,6 +5,8 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import kotlinx.coroutines.flow.first import nart.simpleanki.core.data.repository.CardRepository +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.SettingsRepository import nart.simpleanki.core.domain.fsrs.StudyQueueBuilder @@ -26,6 +28,8 @@ class ReminderWorker( private val cardRepository: CardRepository by inject() private val notifier: Notifier by inject() private val scheduler: ReminderScheduler by inject() + private val streakProvider: StreakProvider by inject() + private val streakStateRepository: StreakStateRepository by inject() override suspend fun doWork(): Result { val type = inputData.getString(REMINDER_TYPE_KEY) @@ -40,8 +44,11 @@ class ReminderWorker( val cards = cardRepository.observeAllCards().first().filter { !it.isDeleted } val studiedToday = cards.count { (it.fsrsLastReview ?: 0L) >= startOfDay(now) } val readyCount = StudyQueueBuilder.buildStudyQueue(cards, now, Int.MAX_VALUE, Int.MAX_VALUE).size + val currentStreak = streakProvider.observeStreak().first().current + val freezeTokens = streakStateRepository.observe().first().freezeTokens - reminderContent(type, settings, studiedToday, readyCount)?.let { notifier.post(type, it) } + reminderContent(type, settings, studiedToday, readyCount, currentStreak, freezeTokens) + ?.let { notifier.post(type, it) } scheduler.schedule(type, hour, minute) // chain tomorrow return Result.success()