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:
@@ -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) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user