Coverage Summary for Class: GetCategorySpendHistoryUseCase (com.georgeci.moneysurfer.domain.usecase)

Class Method, % Branch, % Line, % Instruction, %
GetCategorySpendHistoryUseCase 100% (7/7) 87.5% (7/8) 100% (37/37) 100% (41/41)
GetCategorySpendHistoryUseCase$baseCurrency-DBbEM4U$$inlined$map$1 0% (0/2)
GetCategorySpendHistoryUseCase$baseCurrency-DBbEM4U$$inlined$map$1$1
GetCategorySpendHistoryUseCase$baseCurrency-DBbEM4U$$inlined$map$1$2 0% (0/1)
GetCategorySpendHistoryUseCase$baseCurrency-DBbEM4U$$inlined$map$1$2$1
GetCategorySpendHistoryUseCase$historyOf-Rac-wQQ$$inlined$flatMapLatest$1 0% (0/1)
GetCategorySpendHistoryUseCase$historyOf_Rac_wQQ$lambda$0$$inlined$map$1 0% (0/2)
GetCategorySpendHistoryUseCase$historyOf_Rac_wQQ$lambda$0$$inlined$map$1$1
GetCategorySpendHistoryUseCase$historyOf_Rac_wQQ$lambda$0$$inlined$map$1$2 0% (0/1)
GetCategorySpendHistoryUseCase$historyOf_Rac_wQQ$lambda$0$$inlined$map$1$2$1
GetCategorySpendHistoryUseCase$invoke-ayLZWDE$$inlined$flatMapLatest$1 0% (0/1)
GetCategorySpendHistoryUseCase$invoke_ayLZWDE$lambda$0$$inlined$flatMapLatest$1 0% (0/1)
Total 43.8% (7/16) 87.5% (7/8) 100% (37/37) 100% (41/41)


 package com.georgeci.moneysurfer.domain.usecase
 
 import com.georgeci.moneysurfer.domain.auth.SessionPointers
 import com.georgeci.moneysurfer.domain.model.CategorySpendHistory
 import com.georgeci.moneysurfer.domain.model.CategoryTree
 import com.georgeci.moneysurfer.domain.model.buildCategorySpendHistory
 import com.georgeci.moneysurfer.domain.model.trailingMonths
 import com.georgeci.moneysurfer.domain.primitives.CategoryId
 import com.georgeci.moneysurfer.domain.primitives.CategoryType
 import com.georgeci.moneysurfer.domain.primitives.ClockUseCase
 import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
 import com.georgeci.moneysurfer.domain.primitives.TransactionType
 import com.georgeci.moneysurfer.domain.primitives.WorkspaceId
 import com.georgeci.moneysurfer.domain.repositories.CategoryRepository
 import com.georgeci.moneysurfer.domain.repositories.CategorySpendRepository
 import com.georgeci.moneysurfer.domain.repositories.WorkspaceRepository
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.map
 import kotlinx.datetime.TimeZone
 import kotlinx.datetime.YearMonth
 import kotlinx.datetime.toLocalDateTime
 import kotlinx.datetime.yearMonth
 import org.koin.core.annotation.Single
 
 /**
  * The trailing-[CategorySpendHistory.TREND_MONTHS] spend picture for one category: the trend
  * columns, this month's roll-up, and the per-subcategory split.
  *
  * Re-emits when any of the three inputs changes — renaming or reparenting a subcategory reshapes
  * the split without a transaction moving, logging a transaction changes the numbers without the
  * tree moving, and the workspace base currency decides which rows count at all, so all three
  * flows have to be live.
  */
 @Single
 @OptIn(ExperimentalCoroutinesApi::class)
 class GetCategorySpendHistoryUseCase(
     private val categoryRepository: CategoryRepository,
     private val spendRepository: CategorySpendRepository,
     private val workspaceRepository: WorkspaceRepository,
     private val session: SessionPointers,
     private val clock: ClockUseCase,
 ) {
 
     operator fun invoke(
         categoryId: CategoryId,
         timeZone: TimeZone = TimeZone.currentSystemDefault(),
     ): Flow<CategorySpendHistory> =
         session.currentWorkspaceId.flatMapLatest { workspaceId ->
             workspaceId ?: return@flatMapLatest flowOf(CategorySpendHistory.Empty)
 
             val months = trailingMonths(
                 anchor = clock.now().toLocalDateTime(timeZone).date.yearMonth,
                 count = CategorySpendHistory.TREND_MONTHS,
             )
 
             baseCurrency(workspaceId).flatMapLatest { currency ->
                 historyOf(workspaceId, categoryId, currency, months)
             }
         }
 
     /**
      * The workspace base currency, live.
      *
      * Observed rather than read once, because the query *filters* on it: the session pointer lives
      * in preferences and is restored independently of the `workspaces` row, so a device that has
      * not pulled the workspace yet resolves `null` — which matches no transaction and would leave
      * an empty trend latched on screen for as long as the caller stayed subscribed. Re-pointing on
      * a currency change falls out of the same subscription.
      *
      * [distinctUntilChanged] keeps a rename or an archive flip from re-running the aggregate.
      */
     private fun baseCurrency(workspaceId: WorkspaceId): Flow<CurrencyCode?> =
         workspaceRepository.getAll()
             .map { workspaces -> workspaces.firstOrNull { it.id == workspaceId }?.baseCurrency }
             .distinctUntilChanged()
 
     private fun historyOf(
         workspaceId: WorkspaceId,
         categoryId: CategoryId,
         baseCurrency: CurrencyCode?,
         months: List<YearMonth>,
     ): Flow<CategorySpendHistory> =
         categoryRepository.getByWorkspaceId(workspaceId).flatMapLatest { categories ->
             val root = categories.firstOrNull { it.id == categoryId }
                 ?: return@flatMapLatest flowOf(CategorySpendHistory.Empty)
 
             // Query the whole subtree in one go — the breakdown needs the per-child rows and
             // the trend needs them summed, so a parent-only query would just be re-fetched.
             val subtree = (CategoryTree.descendantsOf(categories, categoryId) + categoryId).toList()
 
             spendRepository.monthlyTotals(
                 workspaceId = workspaceId,
                 categoryIds = subtree,
                 type = root.type.toTransactionType(),
                 baseCurrency = baseCurrency,
                 fromMonth = months.first(),
                 toMonth = months.last(),
             ).map { totals ->
                 buildCategorySpendHistory(
                     categories = categories,
                     rootId = categoryId,
                     totals = totals,
                     months = months,
                 )
             }
         }
 }
 
 /**
  * A transfer category books no income and no expense of its own — the pair of legs is written
  * against the accounts. Reading it as an expense keeps the query well-formed and the trend
  * honestly empty rather than special-casing a third branch through every caller.
  */
 private fun CategoryType.toTransactionType(): TransactionType = when (this) {
     CategoryType.INCOME -> TransactionType.INCOME
     CategoryType.EXPENSE, CategoryType.TRANSFER -> TransactionType.EXPENSE
 }