Coverage Summary for Class: SpentMonth (com.georgeci.moneysurfer.domain.model)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| SpentMonth |
100%
(4/4)
|
91.7%
(11/12)
|
100%
(17/17)
|
97.9%
(95/97)
|
| SpentMonth$Companion |
|
| Total |
100%
(4/4)
|
91.7%
(11/12)
|
100%
(17/17)
|
97.9%
(95/97)
|
package com.georgeci.moneysurfer.domain.model
import com.georgeci.moneysurfer.domain.insight.SpendTrend
import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
import com.georgeci.moneysurfer.domain.primitives.Money
import com.georgeci.moneysurfer.domain.util.TransactionPeriodWindow
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.minus
import kotlinx.datetime.plus
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* What the current month has cost so far, against the cap it was given and against the month
* before it.
*
* All three numbers are magnitudes in [currency] — the base currency the aggregates were filtered
* on, so nothing here mixes two currencies the way the old period-totals use case did.
*
* [previousSpent] is the *same stretch* of the previous month, not the whole of it: comparing 15
* elapsed days against 30 would report a fall every month and a rise on no month at all. That is
* the comparison [com.georgeci.moneysurfer.domain.insight] already makes ("against %s by the same
* day last month"), and [previousSpentMonthWindow] is the window that produces it.
*/
data class SpentMonth(
/**
* The day the figures were read on. Carried rather than derived so the elapsed-day guard under
* [delta] and the window the spend was queried for can never disagree about which day it is.
*/
val today: LocalDate,
/** Spend booked from the 1st of [today]'s month through [today] inclusive. */
val spent: Money,
/** Spend booked over the same stretch of the previous month. Zero when it booked nothing. */
val previousSpent: Money,
/**
* The monthly cap the spend is measured against, or null when no budget sets one — the
* widget's "no budget set" state. See [monthlySpendCap] for which budgets qualify.
*/
val cap: Money?,
val currency: CurrencyCode,
) {
/** Days of the month gone, counting [today] — what [delta] holds its comparison against. */
val elapsedDays: Int get() = today.day
/**
* Spend against [cap] — what a progress bar is drawn from — or null while nothing caps the
* month, which is a bar the widget leaves empty rather than one it fills to an invented limit.
*
* Can exceed 1, which the bar clamps rather than overdrawing. A zero or negative cap reads as
* 0 instead of a NaN or an infinity, the same guard [BudgetProgress.spentFraction] applies.
*/
val capFraction: Float?
get() = cap?.let {
if (it.minor <= 0L) 0f else spent.minor.toFloat() / it.minor.toFloat()
}
/**
* How this month is running against the last one, or null when the comparison would not be
* honest:
*
* - **Before [MIN_COMPARISON_DAYS] have elapsed.** On the 2nd the stretch being compared is two
* days, and one bill landing a day either side of the month boundary swings the answer by
* 100%. The insights engine stands its comparison rules down for exactly this reason, and
* #428 landed because a shorter-than-the-window baseline is worse than no baseline.
* - **When [previousSpent] is not positive.** A month that booked nothing is not a baseline a
* percentage can be read against, and "up ∞%" is not a number to put on a card.
*/
val delta: SpentMonthDelta?
get() {
if (elapsedDays < MIN_COMPARISON_DAYS || !previousSpent.isPositive()) return null
val ratio = (spent - previousSpent).minor.toDouble() / previousSpent.minor.toDouble()
return SpentMonthDelta(
trend = when {
ratio >= SPENT_MONTH_CHANGE_RATIO -> SpendTrend.Up
ratio <= -SPENT_MONTH_CHANGE_RATIO -> SpendTrend.Down
else -> SpendTrend.Flat
},
changePercent = (abs(ratio) * PERCENT_SCALE).roundToInt(),
)
}
companion object {
/**
* Elapsed days the month needs before the delta says anything. Mirrors the insights
* engine's own guard — see [delta] for why a shorter stretch is dominated by the timing of
* a single charge.
*/
const val MIN_COMPARISON_DAYS: Int = 7
}
}
/**
* Which way the month is running and by how much. [changePercent] is a magnitude — the direction
* is [trend], so the widget picks one sentence rather than printing a sign into another.
*/
data class SpentMonthDelta(
val trend: SpendTrend,
val changePercent: Int,
)
/**
* The 1st of [today]'s month through [today] — the window [SpentMonth.spent] is read over.
*
* Deliberately month-to-date rather than the whole calendar month: the figure is the answer to
* "what has this month cost", and a month's remaining days have not cost anything yet. It is also
* what makes [previousSpentMonthWindow] a like-for-like baseline.
*/
fun spentMonthWindow(today: LocalDate): TransactionPeriodWindow =
TransactionPeriodWindow(from = firstOfMonth(today), to = today)
/**
* The same stretch of the previous month: its 1st through its [today]-th day.
*
* Clamped to the previous month's length, so the 31st of March compares against the whole of
* February rather than overflowing into March — the closest same-stretch window a short month has.
*/
fun previousSpentMonthWindow(today: LocalDate): TransactionPeriodWindow {
val start = firstOfMonth(today).minus(1, DateTimeUnit.MONTH)
val lastDay = start.plus(1, DateTimeUnit.MONTH).minus(1, DateTimeUnit.DAY)
return TransactionPeriodWindow(
from = start,
to = if (today.day >= lastDay.day) lastDay else LocalDate(start.year, start.month, today.day),
)
}
/**
* Expense across [this], as a magnitude.
*
* Folded rather than taking the single row each spent-month window returns: `netByMonth` omits a
* month it has nothing for, so an empty list has to read as zero spend, and a fold covers both
* shapes without a null check that would then have to mean "no data" somewhere else.
*/
fun List<MonthlyNet>.expenseTotal(): Money =
fold(Money.zero()) { running, month -> running + month.expense }
private fun firstOfMonth(date: LocalDate): LocalDate = LocalDate(date.year, date.month, 1)
/**
* How far the month has to have moved before the change is worth a label. A whole month's spend is
* a steady number, so a tenth of it is already notable — the same call the insights engine's
* period rule makes.
*/
private const val SPENT_MONTH_CHANGE_RATIO = 0.10
private const val PERCENT_SCALE = 100