Coverage Summary for Class: SafeToSpendKt (com.georgeci.moneysurfer.domain.model)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| SafeToSpendKt |
100%
(3/3)
|
100%
(10/10)
|
100%
(21/21)
|
100%
(96/96)
|
| SafeToSpendKt$primaryProgress$$inlined$compareByDescending$1 |
0%
(0/1)
|
|
| SafeToSpendKt$primaryProgress$$inlined$thenBy$1 |
0%
(0/1)
|
|
| SafeToSpendKt$primaryProgress$$inlined$thenByDescending$1 |
0%
(0/1)
|
|
| Total |
50%
(3/6)
|
100%
(10/10)
|
100%
(21/21)
|
100%
(96/96)
|
package com.georgeci.moneysurfer.domain.model
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.domain.primitives.Money
import com.georgeci.moneysurfer.domain.util.BudgetPeriodWindow
import kotlinx.datetime.daysUntil
/**
* The dashboard's one actionable number: how much is still safe to spend this period, plus the
* pace it has to be spent at to last.
*
* Everything here is a projection of one [BudgetProgress] — nothing is recomputed from
* transactions, so the widget and the budget screens can never disagree about the same budget.
*/
data class SafeToSpend(
val budgetName: String,
/** Negative once the budget is overspent. */
val remaining: Money,
/** What is left to spend per remaining day to land on the limit. Zero when nothing is left. */
val perDay: Money,
/** Days from today to the end of the window, counting today. Zero once the window is over. */
val daysLeft: Int,
val spent: Money,
/** The budget amount plus any rollover carry — what [remaining] is measured against. */
val limit: Money,
val window: BudgetPeriodWindow,
val status: BudgetStatus,
/**
* Workspace base currency. Non-null by construction: a figure nobody can format is not a
* figure, so [safeToSpend] withholds the whole projection rather than handing the UI an
* amount it has to invent a currency for.
*/
val currency: CurrencyCode,
) {
val isOver: Boolean get() = status == BudgetStatus.OVER
/** Spend as a fraction of [limit]; can exceed 1. A zero limit reads as 0, never as a NaN. */
val spentFraction: Float
get() = if (limit.minor <= 0L) 0f else spent.minor.toFloat() / limit.minor.toFloat()
/**
* How much of the window is already behind us, today excluded. This is the tick the spend bar
* is read against: a fill left of the tick means the money is outlasting the days, a fill right
* of it means it is not — which is the whole point of a pace bar rather than a progress bar.
*
* Today is excluded because today's spending is not done yet: counting it would put the tick
* ahead of the spend on the morning of every single day.
*/
val elapsedFraction: Float
get() {
val windowDays = window.startInclusive.daysUntil(window.endExclusive)
if (windowDays <= 0) return 1f
return ((windowDays - daysLeft).toFloat() / windowDays).coerceIn(0f, 1f)
}
}
/**
* The safe-to-spend figure for a workspace, or null when no active budget backs one — the widget's
* "set a budget" state.
*
* One budget speaks for the number rather than a sum across all of them. Budgets overlap by design
* (a general cap plus a per-category one both count the same coffee), so adding their remainders
* would double-count the spend; and they can run on different periods, which would leave "days
* left" with nothing to mean. The pick is therefore:
*
* 1. general budgets — no category filter, so they cover every expense — before category ones;
* 2. within that tier, a budget whose cadence is [preferredPeriod] — the span the screen is being
* read at — before one on any other;
* 3. then the largest limit, which is the cap the user framed the period with;
* 4. ties broken by budget id, so the headline does not jump between two equal budgets on
* recomposition.
*
* [preferredPeriod] is a tiebreak rather than a filter, and it sits *below* the general/category
* tier on purpose. A budget owns its own window, so a screen set to Week cannot reshape a monthly
* cap into a weekly one — all it can do is let a weekly budget speak where one exists. Demoting a
* €2,000 general cap behind a €20 weekly coffee budget would answer "what is safe to spend" with
* a number about coffee; passing null asks for the cap alone, which is what a caller with no
* period on screen wants.
*
* Also null when the winning progress carries no base currency — the workspace row is missing
* behind budgets that reference it, which a pull can produce for a moment. Money the app cannot
* name is worse than no money at all: `MoneyFormatter` is backed by `java.util.Currency`, which
* rejects anything that is not a three-letter ISO code.
*/
fun List<BudgetProgress>.safeToSpend(preferredPeriod: BudgetPeriod? = null): SafeToSpend? =
primaryProgress(preferredPeriod)?.toSafeToSpend()
private fun List<BudgetProgress>.primaryProgress(preferredPeriod: BudgetPeriod?): BudgetProgress? {
val active = filter { it.budget.isActive }
val general = active.filter { it.budget.categoryIds.isEmpty() }
return general.ifEmpty { active }
.sortedWith(
// `preferredPeriod == null` makes every term of this comparison false, leaving the
// limit and the id to decide — the exact order this list had before periods existed.
compareByDescending<BudgetProgress> { it.budget.period == preferredPeriod }
.thenByDescending { it.effectiveLimit.minor }
.thenBy { it.budget.id.value },
)
.firstOrNull()
}
private fun BudgetProgress.toSafeToSpend(): SafeToSpend? {
val baseCurrency = currency ?: return null
return SafeToSpend(
budgetName = budget.name,
remaining = remaining,
perDay = perDayRemaining,
daysLeft = daysLeft,
spent = spent,
limit = effectiveLimit,
window = window,
status = status,
currency = baseCurrency,
)
}