Coverage Summary for Class: UpcomingRecurring (com.georgeci.moneysurfer.domain.model)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| UpcomingRecurring |
100%
(1/1)
|
100%
(2/2)
|
100%
(2/2)
|
100%
(8/8)
|
100%
(39/39)
|
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.primitives.RecurringRuleId
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.isoDayNumber
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.plus
/**
* One scheduled payment the dashboard is about to be hit by: which rule, what it costs, and the
* day it next falls due.
*
* [daysUntil] travels beside [dueDate] rather than being re-derived on the screen — "today" is a
* time zone away from the UI layer, and a card that computed it itself would drift from the list
* it was sorted into the moment the two disagreed about which day it is.
*/
data class UpcomingRecurring(
val ruleId: RecurringRuleId,
val title: String,
val amount: Money,
val currency: CurrencyCode,
val dueDate: LocalDate,
/** Whole days from the day the list was built. `0` is today, never negative. */
val daysUntil: Int,
) {
/**
* Whether this one is close enough to be worth the tint. Today and tomorrow only: the tint is
* "you cannot plan around this any more", and everything further out is exactly what the rest
* of the list is for.
*/
val isImminent: Boolean get() = daysUntil <= IMMINENT_DAYS
}
/**
* The next occurrence of each active rule, soonest first, capped at [limit].
*
* Rules the calendar can no longer place — see [nextOccurrenceOnOrAfter] — drop out rather than
* being listed without a date. Ties break on the rule id so two payments due the same day keep a
* stable order across emissions instead of swapping rows whenever the query re-runs.
*/
fun List<RecurringRule>.upcomingOccurrences(
today: LocalDate,
currency: CurrencyCode,
limit: Int,
): List<UpcomingRecurring> = asSequence()
.filter { it.isActive }
.mapNotNull { rule ->
rule.nextOccurrenceOnOrAfter(today)?.let { due ->
UpcomingRecurring(
ruleId = rule.id,
title = rule.title,
amount = rule.amount,
currency = currency,
dueDate = due,
daysUntil = (due.toEpochDays() - today.toEpochDays()).toInt(),
)
}
}
.sortedWith(compareBy({ it.dueDate }, { it.ruleId.value }))
.take(limit)
.toList()
/**
* The first day on or after [date] that this rule fires, or null when there is none within the
* probing horizon below.
*
* Derived from the schedule rather than read from [RecurringRule.nextRunAt]: that field is the
* generator's own bookkeeping — null on every rule that has not run yet, and stale on any device
* that pulled the rule before the run happened — so a widget reading it would show a date the
* calendar disagrees with. The schedule is the thing the user actually configured.
*
* A rule due *today* counts as due today even if it has already been generated. Whether a given
* occurrence has been booked is the generator's question, and this file has no transactions to ask
* it with; showing today's rent one hour after it posted is a far smaller error than hiding
* tomorrow's.
*/
fun RecurringRule.nextOccurrenceOnOrAfter(date: LocalDate): LocalDate? {
// Never before the rule itself begins, and never below a one-step interval: a zero or negative
// interval reaches the domain from remote sync, and stepping by it would not terminate.
val from = maxOf(date, startDate)
val step = schedule.interval.coerceAtLeast(1)
return when (schedule.frequency) {
RecurringFrequency.DAILY -> nextDaily(from, step)
RecurringFrequency.WEEKLY -> nextWeekly(from, step)
RecurringFrequency.MONTHLY -> nextMonthly(from, step)
RecurringFrequency.YEARLY -> nextYearly(from, step)
}
}
/** Every [step] days from the start date — the one frequency with nothing to clamp. */
private fun RecurringRule.nextDaily(from: LocalDate, step: Int): LocalDate {
val elapsed = from.toEpochDays() - startDate.toEpochDays()
val stepsTaken = ceilDiv(elapsed, step.toLong())
return startDate.plus(stepsTaken * step, DateTimeUnit.DAY)
}
/**
* The selected weekdays of every [step]-th week, counted from the week the rule starts in. A rule
* that names no weekday keeps the one its start date falls on.
*
* Two weeks are probed, not one: the first aligned week can be the week [from] sits in, whose
* matching days may all be behind it already.
*/
private fun RecurringRule.nextWeekly(from: LocalDate, step: Int): LocalDate? {
val weekdays = schedule.daysOfWeek.ifEmpty { setOf(startDate.dayOfWeek) }
val base = startDate.startOfWeek()
val elapsedWeeks = (from.startOfWeek().toEpochDays() - base.toEpochDays()) / DAYS_PER_WEEK
val alignedWeeks = ceilDiv(elapsedWeeks, step.toLong()) * step
for (probe in 0 until WEEK_PROBES) {
val weekStart = base.plus((alignedWeeks + probe.toLong() * step) * DAYS_PER_WEEK, DateTimeUnit.DAY)
weekdays
.map { weekStart.plus((it.isoDayNumber - 1).toLong(), DateTimeUnit.DAY) }
.filter { it >= from }
.minOrNull()
?.let { return it }
}
return null
}
/**
* The selected days of every [step]-th month, counted from the month the rule starts in. A rule
* that names no day of the month keeps the one its start date falls on.
*
* Several months are probed because a month can legitimately yield nothing: under
* [MissingDayPolicy.SKIP] a "the 31st" rule simply does not fire in February.
*/
private fun RecurringRule.nextMonthly(from: LocalDate, step: Int): LocalDate? {
val days = schedule.daysOfMonth.ifEmpty { setOf(startDate.day) }
val elapsedMonths = monthIndex(from) - monthIndex(startDate)
val alignedMonths = ceilDiv(elapsedMonths, step.toLong()) * step
val base = LocalDate(startDate.year, startDate.month, 1)
for (probe in 0 until MONTH_PROBES) {
val monthStart = base.plus(alignedMonths + probe.toLong() * step, DateTimeUnit.MONTH)
days
.mapNotNull { resolveDayOfMonth(monthStart, it) }
.filter { it >= from }
.minOrNull()
?.let { return it }
}
return null
}
/**
* The start date's own day and month, every [step]-th year. Probed for the same reason the monthly
* branch is: a February 29th rule under [MissingDayPolicy.SKIP] only fires in leap years.
*/
private fun RecurringRule.nextYearly(from: LocalDate, step: Int): LocalDate? {
val elapsedYears = (from.year - startDate.year).toLong()
val alignedYears = ceilDiv(elapsedYears, step.toLong()) * step
for (probe in 0 until YEAR_PROBES) {
val year = startDate.year + (alignedYears + probe.toLong() * step).toInt()
val candidate = resolveDayOfMonth(LocalDate(year, startDate.month, 1), startDate.day)
if (candidate != null && candidate >= from) return candidate
}
return null
}
/**
* [day] placed inside the month [monthStart] opens, or null when that month is too short and the
* rule's [MissingDayPolicy] says to skip it rather than pull the payment onto the last day.
*/
private fun RecurringRule.resolveDayOfMonth(monthStart: LocalDate, day: Int): LocalDate? {
val lastDay = monthStart.plus(1, DateTimeUnit.MONTH).minus(1, DateTimeUnit.DAY)
return when {
day < 1 -> null
day <= lastDay.day -> LocalDate(monthStart.year, monthStart.month, day)
schedule.missingDayPolicy == MissingDayPolicy.LAST_DAY_OF_MONTH -> lastDay
else -> null
}
}
/** Monday of the week [this] falls in — the anchor weekly alignment is counted from. */
private fun LocalDate.startOfWeek(): LocalDate =
minus((dayOfWeek.isoDayNumber - 1).toLong(), DateTimeUnit.DAY)
/** Months since year zero, so two dates can be subtracted into a plain month count. */
private fun monthIndex(date: LocalDate): Long = date.year.toLong() * MONTHS_PER_YEAR + date.month.number
/** Ceiling division for non-negative [value] — how many whole [step]s it takes to reach it. */
private fun ceilDiv(value: Long, step: Long): Long =
if (value <= 0) 0 else (value + step - 1) / step
/** See [UpcomingRecurring.isImminent] — today and tomorrow. */
private const val IMMINENT_DAYS = 1
private const val DAYS_PER_WEEK = 7
private const val MONTHS_PER_YEAR = 12
/** The aligned week [nextWeekly] probes plus the one [from] may already have run past. */
private const val WEEK_PROBES = 2
/** A year of aligned months — long enough for any day-of-month a short month can skip. */
private const val MONTH_PROBES = 12
/** Two leap cycles of aligned years, which covers a February 29th rule under any interval up to 4. */
private const val YEAR_PROBES = 8