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
|
||||
|
||||
Reference in New Issue
Block a user