Coverage Summary for Class: GetUpcomingRecurringUseCaseKt (com.georgeci.moneysurfer.domain.usecase)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| GetUpcomingRecurringUseCaseKt |
100%
(1/1)
|
100%
(1/1)
|
|
100%
(1/1)
|
100%
(6/6)
|
package com.georgeci.moneysurfer.domain.usecase
import com.georgeci.moneysurfer.domain.auth.SessionPointers
import com.georgeci.moneysurfer.domain.model.UpcomingRecurring
import com.georgeci.moneysurfer.domain.model.upcomingOccurrences
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.RecurringRuleRepository
import com.georgeci.moneysurfer.domain.repositories.WorkspaceRepository
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.plus
import kotlinx.datetime.toLocalDateTime
import org.koin.core.annotation.Single
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
/**
* The next few scheduled payments of the active workspace, soonest first — what the dashboard's
* upcoming card lists.
*
* Every occurrence is computed from the rule's own schedule (see
* [com.georgeci.moneysurfer.domain.model.nextOccurrenceOnOrAfter]), so the list is right as soon as
* the rules themselves are: a device that has pulled a rule can date it without waiting for the
* generator to run. Empty — not absent — while no workspace is selected or its base currency has
* not arrived yet, which is the card's own "nothing scheduled" state.
*/
@Single
@OptIn(ExperimentalCoroutinesApi::class)
class GetUpcomingRecurringUseCase(
private val recurringRuleRepository: RecurringRuleRepository,
private val workspaceRepository: WorkspaceRepository,
private val session: SessionPointers,
private val clock: ClockUseCase,
) {
operator fun invoke(
limit: Int = UPCOMING_LIMIT,
timeZone: TimeZone = TimeZone.currentSystemDefault(),
): Flow<List<UpcomingRecurring>> =
session.currentWorkspaceId.flatMapLatest { workspaceId ->
workspaceId ?: return@flatMapLatest flowOf(emptyList())
combine(
recurringRuleRepository.getByWorkspaceId(workspaceId),
baseCurrency(workspaceId),
today(timeZone),
) { rules, currency, today ->
// Rules carry an amount but no currency of their own — they are the workspace's, so
// they are quoted in its base currency. A workspace row that has not arrived yet
// leaves nothing to quote them in, and an invented currency would print the wrong
// symbol beside a real amount.
currency ?: return@combine emptyList()
rules.upcomingOccurrences(today = today, currency = currency, limit = limit)
}.distinctUntilChanged()
}
/**
* The workspace base currency, live — observed rather than read once for the reason
* [GenerateInsightsUseCase] documents: the session pointer is restored independently of the
* `workspaces` row, so a latched `null` would leave the card empty 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()
/**
* Today's date, re-emitted when the calendar rolls over. Same shape and the same reason as
* [GenerateInsightsUseCase.today]: nothing else in this chain moves at midnight, so a dashboard
* left open overnight would keep calling yesterday "Today" — on the one widget whose whole job
* is to say how close a payment is.
*/
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-dating every rule as fast as the dispatcher allows.
delay((nextMidnight - now).coerceAtLeast(MIN_ROLLOVER_SLEEP))
}
}
}
/** Three rows is what the full-size card draws; the compact one keeps the first two. */
private const val UPCOMING_LIMIT = 3
/** See [GetUpcomingRecurringUseCase.today] — the floor that keeps a backwards clock jump cheap. */
private val MIN_ROLLOVER_SLEEP: Duration = 1.minutes