M12d: frequentCategoryIds and CategoryRepository.deletable (UX-9, D-059)

ExpenseEditorOptions.frequentCategoryIds per active account (up to five active
categories by non-deleted expense count in the account, ties by sort_order) and
CategoryRepository.deletable(id) (AC-7.6), from Room queries in :data and the
same rule in :core:fakes. Room schema unchanged (version 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 08:34:22 +02:00
parent e6768121d6
commit 557af7e00a
11 changed files with 383 additions and 8 deletions
@@ -54,6 +54,20 @@ interface CategoryDao {
@Query("SELECT * FROM categories WHERE id = :id")
suspend fun get(id: String): CategoryEntity?
/**
* UX-9 (AC-7.6): true when the category exists, is not deleted, is not a default and no non-deleted expense or
* non-deleted recurring template uses it; null for an unknown id. Re-emits on every write to the three tables.
*/
@Query(
"""
SELECT c.default_key IS NULL AND c.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM expenses e WHERE e.category_id = c.id AND e.deleted_at IS NULL)
AND NOT EXISTS (SELECT 1 FROM recurring_templates t WHERE t.category_id = c.id AND t.deleted_at IS NULL)
FROM categories c WHERE c.id = :id
""",
)
fun observeDeletable(id: String): Flow<Boolean?>
@Upsert
suspend fun upsert(category: CategoryEntity)
}
@@ -103,6 +117,21 @@ interface ExpenseDao {
@Query("SELECT COUNT(*) FROM expenses WHERE pending = 1")
fun observePendingCount(): Flow<Int>
/**
* UX-9: per account, the active categories (not archived, not deleted) by the number of non-deleted expenses in that
* account that use them (pending rows included), most used first, ties by `sort_order`, then id for a stable order.
*/
@Query(
"""
SELECT e.account_id AS accountId, e.category_id AS categoryId, COUNT(*) AS uses
FROM expenses e JOIN categories c ON c.id = e.category_id
WHERE e.deleted_at IS NULL AND c.deleted_at IS NULL AND c.archived_at IS NULL
GROUP BY e.account_id, e.category_id
ORDER BY e.account_id, uses DESC, c.sort_order, c.id
""",
)
fun observeCategoryUse(): Flow<List<CategoryUse>>
@Upsert
suspend fun upsert(expense: ExpenseEntity)
@@ -206,3 +235,6 @@ interface RejectionDao {
@Query("UPDATE sync_rejections SET dismissed = 1 WHERE mutation_id = :mutationId")
suspend fun dismiss(mutationId: String)
}
/** One row of [ExpenseDao.observeCategoryUse]: how many non-deleted expenses of [accountId] use [categoryId]. */
data class CategoryUse(val accountId: String, val categoryId: String, val uses: Int)
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.plus
import se.hagstedt.kvitt.data.db.CategoryUse
import se.hagstedt.kvitt.data.db.ExpenseEntity
import se.hagstedt.kvitt.data.db.ExpenseSplitEntity
import se.hagstedt.kvitt.data.db.KvittDatabase
@@ -50,7 +51,7 @@ class ExpenseRepositoryImpl(
) : ExpenseRepository {
private val timelines = Timelines(db, local)
override fun editorOptions(): Flow<ExpenseEditorOptions> = combine(local.snapshot, prefs.editorDefaults) { s, defaults ->
override fun editorOptions(): Flow<ExpenseEditorOptions> = combine(local.snapshot, prefs.editorDefaults, db.expenses().observeCategoryUse()) { s, defaults, uses ->
val me = s.me ?: return@combine null
val partner = s.partner ?: return@combine null
val active = s.accounts.filter { !it.archived }
@@ -69,6 +70,7 @@ class ExpenseRepositoryImpl(
(last ?: other?.id)?.let { account.id to it }
}.toMap(),
today = clock.today(),
frequentCategoryIds = frequentCategoryIds(active.map { it.id }, categories.map { it.id }.toSet(), uses),
)
}.filterNotNull().distinctUntilChanged()
@@ -240,3 +242,18 @@ internal fun validDate(date: String, clock: Clock): Boolean {
}
private const val MAX_DAYS_AHEAD = 365
/** UX-9: at most this many entries per account in [ExpenseEditorOptions.frequentCategoryIds]. */
private const val FREQUENT_CATEGORIES = 5
/**
* UX-9: [uses] comes ordered from Room (per account, most used first, ties by `sort_order`); every active account gets
* an entry, possibly empty. Only ids in [activeCategories] are kept, so the list never names a category the same
* options do not offer, even while the two Room flows are one write apart.
*/
internal fun frequentCategoryIds(activeAccounts: List<AccountId>, activeCategories: Set<CategoryId>, uses: List<CategoryUse>): Map<AccountId, List<CategoryId>> {
val byAccount = uses.groupBy { AccountId(it.accountId) }
return activeAccounts.associateWith { account ->
byAccount[account].orEmpty().map { CategoryId(it.categoryId) }.filter { it in activeCategories }.take(FREQUENT_CATEGORIES)
}
}
@@ -91,6 +91,7 @@ class CategoryRepositoryImpl(
clock: Clock,
) : CategoryRepository {
private val rows = LocalRows(db, clock)
private val categories = db.categories()
override fun all(): Flow<List<Category>> = local.snapshot.map { it.categories }.distinctUntilChanged()
@@ -121,6 +122,8 @@ class CategoryRepositoryImpl(
override suspend fun delete(id: CategoryId): OnlineResult<Unit> = gate.run({ api.deleteCategory(id.value) }) { rows.writeCategoryIfNewer(it) }
override fun deletable(id: CategoryId): Flow<Boolean> = categories.observeDeletable(id.value).map { it == true }.distinctUntilChanged()
private companion object {
/** The step between orders, as AC-7.2 and AC-7.3 use. */
const val SORT_STEP = 10