Coverage Summary for Class: GenerateInsightsUseCaseKt (com.georgeci.moneysurfer.domain.usecase)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| GenerateInsightsUseCaseKt |
100%
(1/1)
|
100%
(3/3)
|
|
100%
(12/12)
|
100%
(69/69)
|
package com.georgeci.moneysurfer.domain.usecase
import com.georgeci.moneysurfer.domain.auth.SessionPointers
import com.georgeci.moneysurfer.domain.insight.Insight
import com.georgeci.moneysurfer.domain.insight.InsightInput
import com.georgeci.moneysurfer.domain.insight.generateInsights
import com.georgeci.moneysurfer.domain.model.SpendScope
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.WorkspaceId
import com.georgeci.moneysurfer.domain.repositories.CategoryRepository
import com.georgeci.moneysurfer.domain.repositories.RecurringRuleRepository
import com.georgeci.moneysurfer.domain.repositories.SpendAnalyticsRepository
import com.georgeci.moneysurfer.domain.repositories.WorkspaceRepository
import com.georgeci.moneysurfer.domain.util.TransactionPeriodWindow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlinx.datetime.toLocalDateTime
import kotlinx.datetime.yearMonth
import org.koin.core.annotation.Single
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
/**
* The two or three sentences the dashboard's Insights widget shows: which category moved, whether
* the month is running hot, and what the live subscriptions come to.
*
* Reads only aggregates — two `GROUP BY` rollups, the category tree and the recurring rules — so
* the amount of data crossing the boundary stays in the tens of rows whatever the workspace holds.
* The rules themselves are a pure function; everything here is the plumbing that feeds them.
*
* Re-emits when any input moves: logging a transaction changes the numbers, renaming a category
* changes the sentence, pausing a subscription changes the count, and the workspace base currency
* decides which rows are counted at all.
*/
@Single
@OptIn(ExperimentalCoroutinesApi::class)
class GenerateInsightsUseCase(
private val spendAnalytics: SpendAnalyticsRepository,
private val categoryRepository: CategoryRepository,
private val recurringRuleRepository: RecurringRuleRepository,
private val workspaceRepository: WorkspaceRepository,
private val session: SessionPointers,
private val clock: ClockUseCase,
) {
operator fun invoke(
timeZone: TimeZone = TimeZone.currentSystemDefault(),
): Flow<List<Insight>> =
session.currentWorkspaceId.flatMapLatest { workspaceId ->
workspaceId ?: return@flatMapLatest flowOf(emptyList())
combine(baseCurrency(workspaceId), today(timeZone), ::Pair)
.flatMapLatest { (currency, today) ->
// The aggregates *filter* on the base currency, so a null one matches nothing.
// Emitting empty rather than querying keeps a device that has not pulled the
// workspace row yet from showing insights built from no rows at all.
currency ?: return@flatMapLatest flowOf(emptyList())
insights(workspaceId, currency, today)
}
}
/**
* Today's date, re-emitted when the calendar rolls over.
*
* Read once, both windows would freeze for the life of the subscription: the workspace pointer
* does not re-emit at midnight, so a desktop app left open on the dashboard would still be
* calling July "this month" on the 2nd of August — with that day's transactions falling
* outside every window and no way to self-correct short of a restart. Sleeping to the next
* local midnight costs one timer per subscriber and re-derives exactly when the answer changes.
*/
private fun today(timeZone: TimeZone): Flow<LocalDate> = flow {
while (true) {
val now = clock.now()
val today = now.toLocalDateTime(timeZone).date
emit(today)
val nextMidnight = today.plus(1, DateTimeUnit.DAY).atStartOfDayIn(timeZone)
// Floored: a clock that jumps backwards must cost a minute of staleness, not a
// busy loop re-running every aggregate as fast as the dispatcher allows.
delay((nextMidnight - now).coerceAtLeast(MIN_ROLLOVER_SLEEP))
}
}
/**
* The workspace base currency, live.
*
* Observed rather than read once, for the reason
* [GetCategorySpendHistoryUseCase] documents: the session pointer is restored independently of
* the `workspaces` row, so a latched `null` would leave an empty widget on screen for as long
* as the caller stayed subscribed.
*/
private fun baseCurrency(workspaceId: WorkspaceId): Flow<CurrencyCode?> =
workspaceRepository.getAll()
.map { workspaces -> workspaces.firstOrNull { it.id == workspaceId }?.baseCurrency }
.distinctUntilChanged()
private fun insights(
workspaceId: WorkspaceId,
currency: CurrencyCode,
today: LocalDate,
): Flow<List<Insight>> {
val windows = monthToDateWindows(today)
return combine(
spendAnalytics.byCategory(SpendScope(workspaceId, currency, windows.current)),
spendAnalytics.byCategory(SpendScope(workspaceId, currency, windows.previous)),
categoryRepository.getByWorkspaceId(workspaceId),
recurringRuleRepository.getByWorkspaceId(workspaceId),
) { currentSpend, previousSpend, categories, rules ->
val expenseCategoryIds = categories
.filter { it.type == CategoryType.EXPENSE }
.mapTo(mutableSetOf()) { it.id }
generateInsights(
InsightInput(
periodKey = today.yearMonth.toString(),
elapsedDays = windows.currentDays,
baselineDays = windows.previousDays,
currency = currency,
currentSpend = currentSpend,
previousSpend = previousSpend,
categoryNames = categories.associate { it.id to it.name },
// A salary paid in every month is a schedule, not a subscription.
expenseSchedules = rules.filter { it.categoryId in expenseCategoryIds },
),
)
}
}
}
/**
* The pair of windows a comparison reads, with the lengths that decide whether comparing them means
* anything.
*
* The lengths travel with the windows rather than being re-derived from them: they are known
* exactly where the windows are built, from dates that are non-null by construction, so nothing
* downstream has to cope with the unbounded window [TransactionPeriodWindow] also allows.
*/
private data class ComparisonWindows(
val current: TransactionPeriodWindow,
val previous: TransactionPeriodWindow,
val currentDays: Int,
val previousDays: Int,
)
/** See [GenerateInsightsUseCase.today] — the floor that keeps a backwards clock jump cheap. */
private val MIN_ROLLOVER_SLEEP: Duration = 1.minutes
/**
* The two windows every comparison rule reads: this month so far, and the same stretch of the
* month before it.
*
* Month-to-date against month-to-date, *not* against the whole of last month. Three days of July
* measured against all of June reads as a 90% fall in every category, so an engine built that way
* would spend the first week of every month congratulating the user for not having spent the money
* yet. Both windows are the same number of days here, which removes that bias — though not the
* small-sample volatility of the first few days, which the rules themselves stand down for.
*
* The previous window is clamped to its own month's last day rather than spilling into the current
* one. That leaves the pair unequal in length on the 29th-31st after a shorter month, which is not
* a comparison at all — `generateInsights` detects it from the two lengths and stays quiet.
*
* Built directly rather than through `periodWindow(Month, ...)`, which always spans a whole
* calendar month — the point here is a part-month window with a matching part-month baseline.
*/
private fun monthToDateWindows(today: LocalDate): ComparisonWindows {
val currentStart = LocalDate(today.year, today.month, 1)
val previousStart = currentStart.minus(1, DateTimeUnit.MONTH)
val previousEnd = minOf(
previousStart.plus(today.day - 1, DateTimeUnit.DAY),
currentStart.minus(1, DateTimeUnit.DAY),
)
return ComparisonWindows(
current = TransactionPeriodWindow(currentStart, today),
previous = TransactionPeriodWindow(previousStart, previousEnd),
currentDays = daysCovered(currentStart, today),
previousDays = daysCovered(previousStart, previousEnd),
)
}
/** Days a closed range covers, both ends included. */
private fun daysCovered(from: LocalDate, to: LocalDate): Int =
(to.toEpochDays() - from.toEpochDays() + 1).toInt()