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
@@ -0,0 +1,177 @@
package se.hagstedt.kvitt.data.repo
import kotlinx.coroutines.flow.first
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import se.hagstedt.kvitt.data.FakeServer
import se.hagstedt.kvitt.data.Harness
import se.hagstedt.kvitt.data.Harness.Companion.ACCOUNT
import se.hagstedt.kvitt.data.Harness.Companion.GROCERIES
import se.hagstedt.kvitt.data.Harness.Companion.ME
import se.hagstedt.kvitt.data.Harness.Companion.OTHER
import se.hagstedt.kvitt.data.Harness.Companion.PARTNER
import se.hagstedt.kvitt.data.db.CategoryEntity
import se.hagstedt.kvitt.data.db.ExpenseEntity
import se.hagstedt.kvitt.data.db.RecurringTemplateEntity
import se.hagstedt.kvitt.data.draft
import se.hagstedt.kvitt.data.expenseRepo
import se.hagstedt.kvitt.data.runSync
import se.hagstedt.kvitt.data.savedId
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseEditorOptions
/** UX-9 in `:data`: `ExpenseEditorOptions.frequentCategoryIds` and `CategoryRepository.deletable` from Room, reactive. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class EditorCategoriesTest {
private lateinit var h: Harness
private var serial = 0
@Before
fun setUp() {
h = Harness().ready()
h.server.addAccount(HEMMA, "Hemma", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-01-02T00:00:00.000Z")
h.server.addAccount(EMPTY, "Resor", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-01-03T00:00:00.000Z")
h.server.addAccount(ARCHIVED, "Gammalt", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-01-04T00:00:00.000Z")
h.server.accounts[ARCHIVED] = h.server.accounts.getValue(ARCHIVED).copy(archivedAt = FakeServer.T0)
runSync {
h.engine.sync()
h.local.snapshot.first { it.accounts.size == 4 }
// Custom categories whose ids sort opposite to their `sort_order`, so a tie shows which one decides.
CUSTOM.forEachIndexed { i, id -> category(id, sortOrder = 30 + 10 * i) }
}
}
@After
fun tearDown() = h.close()
private fun categories() = CategoryRepositoryImpl(h.local, h.api, h.gate, h.db, h.clock)
private suspend fun category(id: String, sortOrder: Int, key: String? = null, archived: Boolean = false, deleted: Boolean = false) {
h.db.categories().upsert(
CategoryEntity(id, key, if (key == null) "Egen $sortOrder" else null, "category", "grey", sortOrder, T, T, if (archived) T else null, if (deleted) T else null, 1, 0),
)
}
/** A synced (or, with [pending], a not yet pushed) expense row in Room. */
private suspend fun expense(category: String, account: String = ACCOUNT, deleted: Boolean = false, pending: Boolean = false): String {
val id = "0192c000-0000-7000-8000-" + (++serial).toString(16).padStart(12, '0')
h.db.expenses().upsert(
ExpenseEntity(
id, account, category, 10_000, "Köp", "", "2026-10-10", PARTNER, "default", null, null, PARTNER, PARTNER, T, T,
if (deleted) T else null, if (pending) null else 1, pending, if (pending) id else null, 0,
),
)
return id
}
private suspend fun deleteExpense(id: String) {
val row = h.db.expenses().get(id) ?: throw AssertionError("no expense $id")
h.db.expenses().upsert(row.copy(deletedAt = T))
}
private suspend fun template(category: String, deleted: Boolean = false) {
h.db.templates().upsert(
RecurringTemplateEntity(
"0192d000-0000-7000-8000-000000000001", ACCOUNT, category, 10_000, "Hyra", "", ME, "default", 25, "2026-01", null, false, null,
ME, ME, T, T, if (deleted) T else null, 1, null, 0,
),
)
}
/** The options once [predicate] holds: the flow is reactive, so a write shows without a new subscription. */
private suspend fun options(predicate: (ExpenseEditorOptions) -> Boolean): ExpenseEditorOptions = h.expenseRepo().editorOptions().first(predicate)
private suspend fun frequentOnce(account: String, expected: List<String>) {
val got = options { it.frequentCategoryIds[AccountId(account)] == expected.map(::CategoryId) }
assertEquals(expected.map(::CategoryId), got.frequentCategoryIds.getValue(AccountId(account)))
}
@Test
fun mostUsedFirst_tiesBySortOrder_atMostFive_perActiveAccount() = runSync {
repeat(3) { expense(CUSTOM[5]) }
repeat(2) { expense(GROCERIES) }
// One use each: sort_order decides (30, 40, 60, 70), not the id (CUSTOM ids descend).
listOf(CUSTOM[3], CUSTOM[0], CUSTOM[4], CUSTOM[1]).forEach { expense(it) }
expense(OTHER, account = HEMMA)
expense(OTHER, account = ARCHIVED)
val options = options { it.frequentCategoryIds[AccountId(ACCOUNT)]?.size == 5 && it.frequentCategoryIds[AccountId(HEMMA)]?.isNotEmpty() == true }
assertEquals(listOf(CUSTOM[5], GROCERIES, CUSTOM[0], CUSTOM[1], CUSTOM[3]).map(::CategoryId), options.frequentCategoryIds.getValue(AccountId(ACCOUNT)))
// Fewer than five used: only those (the editor fills its row from sort_order); none used: an empty list.
assertEquals(listOf(CategoryId(OTHER)), options.frequentCategoryIds.getValue(AccountId(HEMMA)))
assertEquals(emptyList<CategoryId>(), options.frequentCategoryIds.getValue(AccountId(EMPTY)))
// An archived account is not offered, so it has no entry.
assertEquals(setOf(ACCOUNT, HEMMA, EMPTY).map(::AccountId).toSet(), options.frequentCategoryIds.keys)
}
@Test
fun pendingCounts_deletedAndArchivedAreLeftOut_andTheListFollowsWrites() = runSync {
expense(CUSTOM[0])
expense(CUSTOM[1], pending = true)
expense(CUSTOM[1], pending = true)
val gone = expense(CUSTOM[2])
expense(CUSTOM[2])
expense(CUSTOM[2])
expense(CUSTOM[3], deleted = true)
frequentOnce(ACCOUNT, listOf(CUSTOM[2], CUSTOM[1], CUSTOM[0]))
// Deleting one CUSTOM[2] expense makes it a tie with CUSTOM[1] (2 each); sort_order puts CUSTOM[1] first.
deleteExpense(gone)
frequentOnce(ACCOUNT, listOf(CUSTOM[1], CUSTOM[2], CUSTOM[0]))
category(CUSTOM[1], sortOrder = 40, archived = true)
frequentOnce(ACCOUNT, listOf(CUSTOM[2], CUSTOM[0]))
category(CUSTOM[1], sortOrder = 40)
frequentOnce(ACCOUNT, listOf(CUSTOM[1], CUSTOM[2], CUSTOM[0]))
category(CUSTOM[0], sortOrder = 30, deleted = true)
frequentOnce(ACCOUNT, listOf(CUSTOM[1], CUSTOM[2]))
// A local save through the repository is pending and counts at once.
repeat(3) { h.expenseRepo().save(draft("Willys").copy(categoryId = CategoryId(GROCERIES))).savedId() }
frequentOnce(ACCOUNT, listOf(GROCERIES, CUSTOM[1], CUSTOM[2]))
}
@Test
fun deletable_falseForDefaults_unknownAndDeleted() = runSync {
val repo = categories()
assertFalse(repo.deletable(CategoryId(GROCERIES)).first())
assertFalse(repo.deletable(CategoryId(OTHER)).first())
assertFalse(repo.deletable(CategoryId("0192ffff-0000-7000-8000-999999999999")).first())
assertTrue(repo.deletable(CategoryId(CUSTOM[0])).first())
category(CUSTOM[0], sortOrder = 30, deleted = true)
assertFalse(repo.deletable(CategoryId(CUSTOM[0])).first { !it })
}
@Test
fun deletable_followsExpensesAndTemplates_archivedStillCounts() = runSync {
val flow = categories().deletable(CategoryId(CUSTOM[1]))
assertTrue(flow.first())
val e = expense(CUSTOM[1], pending = true)
assertFalse(flow.first { !it })
deleteExpense(e)
assertTrue(flow.first { it })
expense(CUSTOM[1], deleted = true)
assertTrue(flow.first())
template(CUSTOM[1])
assertFalse(flow.first { !it })
template(CUSTOM[1], deleted = true)
assertTrue(flow.first { it })
// Archived and in use: still not deletable (AC-7.6 counts every non-deleted expense, archived or not).
category(CUSTOM[1], sortOrder = 40, archived = true)
expense(CUSTOM[1], account = HEMMA)
assertFalse(flow.first { !it })
}
private companion object {
const val T = "2026-10-01T00:00:00.000Z"
const val HEMMA = "0192a003-0000-7000-8000-000000000002"
const val EMPTY = "0192a003-0000-7000-8000-000000000003"
const val ARCHIVED = "0192a003-0000-7000-8000-000000000004"
val CUSTOM = (0..5).map { "0192e000-0000-7000-8000-0000000000" + (0x9f - it).toString(16) }
}
}