From f40a93e62afd5b8568952bf3d5a43b2a8b90251f Mon Sep 17 00:00:00 2001 From: ngenohkevin Date: Sat, 16 May 2026 22:30:17 +0300 Subject: [PATCH] v1.3.6: auto-dismiss stale Fuliza insight + refresh after reparse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fuliza outstanding insight is stateful — it should disappear the moment the underlying outstanding hits 0. Today it sticks until manually dismissed, even after data updates. InsightEngine now tracks a STATEFUL_KEYS set (just "fuliza:outstanding" for now). If a rule that owns one of those keys returns no candidates in this run, the row is deleted outright rather than being left around as a tombstone. SettingsViewModel.reparseAll() now also calls insightsRepository .generateAll() once the row update completes. So the moment the user finishes "Re-parse ALL transactions", any stale Fuliza alert reading the limit-as-outstanding goes away, and any newly correct alerts show up immediately. --- app/build.gradle.kts | 4 ++-- .../com/ledga/app/data/db/dao/InsightDao.kt | 3 +++ .../ledga/app/data/insights/InsightEngine.kt | 21 +++++++++++++++++++ .../app/ui/settings/SettingsViewModel.kt | 9 +++++++- 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e13f759..a30a249 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -27,8 +27,8 @@ android { applicationId = "com.ledga.app" minSdk = 26 targetSdk = 34 - versionCode = 8 - versionName = "1.3.5" + versionCode = 9 + versionName = "1.3.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/ledga/app/data/db/dao/InsightDao.kt b/app/src/main/java/com/ledga/app/data/db/dao/InsightDao.kt index 4295c36..f942bef 100644 --- a/app/src/main/java/com/ledga/app/data/db/dao/InsightDao.kt +++ b/app/src/main/java/com/ledga/app/data/db/dao/InsightDao.kt @@ -88,6 +88,9 @@ interface InsightDao { @Query("DELETE FROM insights WHERE id = :id") suspend fun deleteById(id: Long) + @Query("DELETE FROM insights WHERE naturalKey = :key") + suspend fun deleteByKey(key: String) + @Query("DELETE FROM insights WHERE generatedAt < :before AND dismissedAt IS NOT NULL") suspend fun pruneOldDismissed(before: Long) } diff --git a/app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt b/app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt index 7b53116..c983b83 100644 --- a/app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt +++ b/app/src/main/java/com/ledga/app/data/insights/InsightEngine.kt @@ -49,6 +49,13 @@ class InsightEngine @Inject constructor( } candidates.forEach { upsert(it, now) } + // Stateful keys: if a rule that "owns" a singleton key didn't emit + // this run, the underlying condition is gone — delete the stale row + // outright (vs leaving it to expire 30 days post-dismiss). + val emittedKeys = candidates.map { it.naturalKey }.toSet() + STATEFUL_KEYS.forEach { key -> + if (key !in emittedKeys) insightDao.deleteByKey(key) + } insightDao.pruneOldDismissed(before = now - 30L * DAY_MS) return candidates.size } @@ -85,5 +92,19 @@ class InsightEngine @Inject constructor( companion object { private const val DAY_MS = 86_400_000L + + /** + * Natural keys whose underlying condition is stateful — only true when + * a current event matches. If a rule using one of these keys returns + * no candidates this run, the existing insight is stale and should + * be deleted, not just re-emitted. + * + * Per-period keys (anomaly:1:2026-W12, nudge:2:2026-04, fees:2026-04) + * are NOT stateful in this sense — they refer to a fixed past period + * and should persist in history. + */ + private val STATEFUL_KEYS = setOf( + "fuliza:outstanding", + ) } } diff --git a/app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt index 0b31ee8..acf5005 100644 --- a/app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ledga/app/ui/settings/SettingsViewModel.kt @@ -9,6 +9,7 @@ import com.ledga.app.data.repository.ExportResult import com.ledga.app.data.repository.FontScale import com.ledga.app.data.repository.ImportFromFileResult import com.ledga.app.data.repository.ImportResult +import com.ledga.app.data.repository.InsightsRepository import com.ledga.app.data.repository.ReparseResult import com.ledga.app.data.repository.SettingsRepository import com.ledga.app.data.repository.SmsImporter @@ -31,7 +32,8 @@ class SettingsViewModel @Inject constructor( private val transactionRepository: TransactionRepository, private val smsImporter: SmsImporter, private val exportImportRepository: ExportImportRepository, - private val backupRepository: BackupRepository + private val backupRepository: BackupRepository, + private val insightsRepository: InsightsRepository, ) : ViewModel() { val themeMode: StateFlow = settingsRepository.getThemeMode() @@ -123,6 +125,11 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { try { _reparseAllResult.value = transactionRepository.reparseAllTransactions() + // Refresh insights immediately so the Home teaser + Insights + // screen reflect the corrected data. Stateful insights (like + // Fuliza outstanding) get auto-dismissed by the engine when + // their condition is no longer true. + insightsRepository.generateAll() } finally { _reparseAllRunning.value = false }