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 }