W6 Android Insights on S-11: server trends and forecast (E-34, E-35), online only (D-141 to D-143)

InsightsRepository in :core:model (SpendTrends, SpendForecast and parts); :data DTOs for E-34/E-35
(round-trip the contract examples, fixing ExampleCoverageTest), InsightsRepositoryImpl through KvittApi and
OnlineGate with category/account names from Room (deleted rows included); FakeInsightsRepository with
scenarios (loaded, empty household, account without history, offline, unreachable, long); the Insights
section below S-11's charts: household trend and per-category trends on SpendLineChart with average and
moving average, a forecast card per account, "Needs connection" / "Could not reach the server" over the last
data of the session. Strings sv/en; 41 goldens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 21:15:02 +02:00
parent 415f9f5efd
commit 1c9856bfba
61 changed files with 1509 additions and 4 deletions
@@ -0,0 +1,53 @@
package se.hagstedt.kvitt.model
// Insights (S-11's Insights section, D-133, D-141), SPEC §14.2.4: the server's advanced analytics, E-34
// `GET /analytics/trends` and E-35 `GET /analytics/forecast`, online only (§9.9). Nothing is stored on the phone.
// Months are `YYYY-MM` (Europe/Stockholm); amounts are öre for the requested measure.
/** One whole month of a trend: its spend and the trailing moving average over 3 months (the first months average what exists, D-128). */
data class TrendPoint(val month: String, val amountOre: Long, val movingAverageOre: Long)
/**
* The E-34 `Trend` of the household or of one category over the whole months before the current one: [averageOre] is
* [totalOre] ÷ months half-up (empty months count); [lastMonthVsAveragePct] the last whole month against the average in
* whole percent of the average (null when the average is 0); [currentMonthOre] the current month so far (not a point).
*/
data class SpendTrend(
val totalOre: Long,
val averageOre: Long,
val lastMonthOre: Long,
val lastMonthVsAveragePct: Int?,
val currentMonthOre: Long,
val points: List<TrendPoint>,
)
/** [category] from the phone's copy, archived and deleted categories included; null when the phone does not hold the row. */
data class CategoryTrend(val categoryId: CategoryId, val category: Category?, val trend: SpendTrend)
/** E-34: [months] whole months before [currentMonth]; [categories] by total descending, then id (the server's order). */
data class SpendTrends(
val measure: Measure,
val today: String,
val currentMonth: String,
val months: Int,
val household: SpendTrend,
val categories: List<CategoryTrend>,
)
/** One of the two calendar months after the current one: recurring templates due + the 3-month baseline (D-133). */
data class ForecastMonth(val month: String, val recurringOre: Long, val baselineOre: Long, val forecastOre: Long)
/** [account] from the phone's copy (null when the phone does not hold the row); [months] the next two months in order. */
data class AccountForecast(val accountId: AccountId, val account: Account?, val currentMonthOre: Long, val months: List<ForecastMonth>)
/** E-35: every non-deleted account, archived included, by `created_at`, then id (the server's order). */
data class SpendForecast(val measure: Measure, val today: String, val currentMonth: String, val accounts: List<AccountForecast>)
/**
* The Insights reads (D-141). Online only: [OnlineResult.Offline] without a request when offline (§9.9),
* `Failed("network")` when the server could not be reached, else the §14.3.2 code. [trends] takes 1 to 24 months.
*/
interface InsightsRepository {
suspend fun trends(months: Int, measure: Measure): OnlineResult<SpendTrends>
suspend fun forecast(measure: Measure): OnlineResult<SpendForecast>
}
@@ -74,4 +74,22 @@ class ContractTest {
)
.forEach { assertEquals(it, CategoryIcons.ALL.single { icon -> icon == it }) }
}
/** D-141: the Insights types carry E-34's `Trend` and E-35's `AccountForecast` fields by name; the percent is nullable. */
@Test
fun insightsTypesCarryTheE34AndE35Fields() {
val trend = SpendTrend(
totalOre = 0,
averageOre = 0,
lastMonthOre = 0,
lastMonthVsAveragePct = null,
currentMonthOre = 0,
points = listOf(TrendPoint(month = "2026-09", amountOre = 0, movingAverageOre = 0)),
)
val trends = SpendTrends(Measure.MY_SHARE, today = "2026-10-17", currentMonth = "2026-10", months = 1, household = trend, categories = emptyList())
val month = ForecastMonth(month = "2026-11", recurringOre = 4_900, baselineOre = 40_000, forecastOre = 44_900)
val forecast = SpendForecast(Measure.HOUSEHOLD, "2026-10-17", "2026-10", listOf(AccountForecast(AccountId("a"), null, currentMonthOre = 0, months = listOf(month))))
assertEquals(null, trends.household.lastMonthVsAveragePct)
assertEquals(month.recurringOre + month.baselineOre, forecast.accounts.single().months.single().forecastOre)
}
}