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,118 @@
package se.hagstedt.kvitt.fakes
import se.hagstedt.kvitt.model.AccountForecast
import se.hagstedt.kvitt.model.CategoryTrend
import se.hagstedt.kvitt.model.ForecastMonth
import se.hagstedt.kvitt.model.InsightsRepository
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.SpendForecast
import se.hagstedt.kvitt.model.SpendTrend
import se.hagstedt.kvitt.model.SpendTrends
import se.hagstedt.kvitt.model.TrendPoint
import kotlin.math.abs
import kotlin.math.sign
/**
* The server's E-34 and E-35 (D-133, D-141) over the scenario, so Insights screens render without a server. The rules
* are the endpoints' as `contracts/openapi.yaml` describes them (whole months before the current one, empty months
* count, half-up to öre, trailing 3-month moving average; forecast = templates due + the 3-month average of spend not
* generated from a template). Offline is [OnlineResult.Offline]; [FakeScenario.insightsFailure] fails both reads.
*/
class FakeInsightsRepository(private val store: FakeStore) : InsightsRepository {
override suspend fun trends(months: Int, measure: Measure): OnlineResult<SpendTrends> = store.online { s ->
when {
months !in 1..MAX_MONTHS -> OnlineResult.Failed("invalid_request", "months")
else -> s.insightsFailure?.let { OnlineResult.Failed(it, null) } ?: OnlineResult.Ok(s.trends(months, measure))
}
}
override suspend fun forecast(measure: Measure): OnlineResult<SpendForecast> = store.online { s ->
s.insightsFailure?.let { OnlineResult.Failed(it, null) } ?: OnlineResult.Ok(s.forecast(measure))
}
private companion object {
const val MAX_MONTHS = 24
}
}
/** D-128's window, counted in whole months here (§15.8). */
private const val WINDOW = 3
private const val PERCENT = 100L
private const val MONTHS_PER_YEAR = 12
/** A month as a count of months since year 0, so neighbours are ±1. */
private fun monthIndex(period: String): Int = period.substring(0, 4).toInt() * MONTHS_PER_YEAR + period.substring(5, 7).toInt() - 1
private fun period(index: Int): String = "${index / MONTHS_PER_YEAR}-${(index % MONTHS_PER_YEAR + 1).toString().padStart(2, '0')}"
private fun divHalfUp(a: Long, b: Long): Long {
val q = a / b
val r = a % b
return if (2 * abs(r) >= abs(b)) q + a.sign * b.sign else q
}
private fun FakeScenario.measured(e: FakeExpense, measure: Measure): Long =
if (measure == Measure.HOUSEHOLD) e.amountOre else e.shares.firstOrNull { it.userId == me.id }?.shareOre ?: 0L
private fun FakeScenario.trends(months: Int, measure: Measure): SpendTrends {
val current = monthIndex(today)
val whole = (current - months until current).map(::period)
val live = expenses.filter { !it.deleted && it.date <= today }
fun trend(of: List<FakeExpense>): SpendTrend {
val byMonth = of.groupBy { it.date.substring(0, 7) }.mapValues { (_, list) -> list.sumOf { measured(it, measure) } }
val amounts = whole.map { byMonth[it] ?: 0L }
val total = amounts.sum()
val average = divHalfUp(total, months.toLong())
val last = amounts.last()
val points = whole.mapIndexed { i, month ->
val slice = amounts.subList((i - WINDOW + 1).coerceAtLeast(0), i + 1)
TrendPoint(month, amounts[i], divHalfUp(slice.sum(), slice.size.toLong()))
}
return SpendTrend(
totalOre = total,
averageOre = average,
lastMonthOre = last,
lastMonthVsAveragePct = if (average == 0L) null else divHalfUp((last - average) * PERCENT, average).toInt(),
currentMonthOre = byMonth[period(current)] ?: 0L,
points = points,
)
}
val inScope = live.filter { monthIndex(it.date) in current - months..current }
val categoryTrends = inScope.groupBy { it.categoryId }
.map { (id, list) -> CategoryTrend(id, category(id), trend(list)) }
.filter { it.trend.totalOre != 0L || it.trend.currentMonthOre != 0L }
.sortedWith(compareByDescending<CategoryTrend> { it.trend.totalOre }.thenBy { it.categoryId.value })
return SpendTrends(measure, today, period(current), months, trend(live), categoryTrends)
}
private fun RecurringTemplate.dueIn(month: String): Boolean = !paused && startPeriod <= month && endPeriod.let { it == null || month <= it }
private fun FakeScenario.recurringAmount(t: RecurringTemplate, measure: Measure): Long {
if (measure == Measure.HOUSEHOLD) return t.draft.amountOre ?: 0L
val shares = (split(t.draft) as? SplitOutcome.Ok)?.shares.orEmpty()
return shares.firstOrNull { it.userId == me.id }?.shareOre ?: 0L
}
private fun FakeScenario.forecast(measure: Measure): SpendForecast {
val current = monthIndex(today)
val live = expenses.filter { !it.deleted && it.date <= today }
val forecasts = accountsInOrder().map { account ->
val own = live.filter { it.accountId == account.id }
val window = own.filter { it.recurringTemplateId == null && monthIndex(it.date) in current - WINDOW until current }
val baseline = divHalfUp(window.sumOf { measured(it, measure) }, WINDOW.toLong())
val templates = templates.filter { it.draft.accountId == account.id }
AccountForecast(
accountId = account.id,
account = account,
currentMonthOre = own.filter { monthIndex(it.date) == current }.sumOf { measured(it, measure) },
months = (1..2).map { ahead ->
val month = period(current + ahead)
val recurring = templates.filter { it.dueIn(month) }.sumOf { recurringAmount(it, measure) }
ForecastMonth(month, recurring, baseline, recurring + baseline)
},
)
}
return SpendForecast(measure, today, period(current), forecasts)
}
@@ -9,6 +9,7 @@ class FakeRepositories(scenario: FakeScenario = FakeScenarios.empty) {
val expenses = FakeExpenseRepository(store)
val settlements = FakeSettlementRepository(store)
val analytics = FakeAnalyticsRepository(store)
val insights = FakeInsightsRepository(store)
val profile = FakeProfileRepository(store)
val categories = FakeCategoryRepository(store)
val recurring = FakeRecurringRepository(store)
@@ -66,6 +66,8 @@ data class FakeSettlement(
* per account on this phone.
* @property lastAttemptFailed the last sync cycle failed (`SyncSummary.lastAttemptFailed`, false while one runs).
* @property pairingInviteKind the invite kind `pair` reports; null: `partner` for a member, `admin_setup` for the admin.
* @property insightsFailure the code the Insights reads fail with while online (`network`: the server could not be
* reached); null: they succeed (D-141).
*/
data class FakeScenario(
val me: User,
@@ -92,6 +94,7 @@ data class FakeScenario(
val lastCategoryFor: Map<AccountId, CategoryId>,
val lastAttemptFailed: Boolean = false,
val pairingInviteKind: InviteKind? = null,
val insightsFailure: String? = null,
) {
val users: List<User> get() = listOfNotNull(me, partner)
}
@@ -0,0 +1,83 @@
package se.hagstedt.kvitt.fakes.analytics
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TemplateId
/**
* Scenarios of S-11's Insights section (D-133, D-141), on [AnalyticsScenarios]' household (today 2026-10-17, so the
* whole months are April to September 2026 and the forecast is November and December).
*
* - [loaded]: [AnalyticsScenarios.loaded] with three templates (rent and home insurance on Huset, the insurance ending
* in November so December differs; a paused SL card on Vardag, never forecast), the rent and insurance expenses
* generated from them (so the baseline leaves them out), and a new account "Bilen" without history.
* - [emptyHousehold]: two accounts and no expenses or templates: zero trends, no categories, a zero forecast.
* - [offline]: [loaded] while offline: the reads report Offline (§9.9).
* - [unreachable]: [loaded] online, but the server cannot be reached (`Failed("network")`).
* - [long]: [AnalyticsScenarios.long] with a template on its long-named account.
*/
object InsightsScenarios {
private val me = FakeScenarios.owner.id
private val rent = template(0x01, "Hyra", AnalyticsScenarios.huset, AnalyticsScenarios.housing, 1_033_300, 3, endPeriod = null)
private val insurance = template(0x02, "Hemförsäkring", AnalyticsScenarios.huset, AnalyticsScenarios.insurance, 32_900, 5, endPeriod = "2026-11")
private val travelCard = template(0x03, "SL-kort", AnalyticsScenarios.vardag, AnalyticsScenarios.transport, 98_000, 14, endPeriod = null, paused = true)
/** A new account with no expenses: its forecast is zero (no recurring, no history). */
val bilen = Account(AccountId("01920000-0000-7000-8000-000000000a07"), "Bilen", mapOf(me to 5000, FakeScenarios.partner.id to 5000), "2026-10-01T09:00:00.000Z", archived = false)
val loaded: FakeScenario = AnalyticsScenarios.loaded.let { base ->
base.copy(
accounts = base.accounts + bilen,
templates = listOf(rent, insurance, travelCard),
expenses = base.expenses.map { e ->
when (e.description) {
"Hyra" -> e.copy(recurringTemplateId = rent.id)
"Hemförsäkring" -> e.copy(recurringTemplateId = insurance.id)
else -> e
}
},
)
}
val emptyHousehold: FakeScenario = FakeScenarios.empty.copy(accounts = listOf(AnalyticsScenarios.vardag, bilen))
val offline: FakeScenario = loaded.copy(online = false)
val unreachable: FakeScenario = loaded.copy(insightsFailure = "network")
val long: FakeScenario = AnalyticsScenarios.long.let { base ->
val account = base.accounts[1]
base.copy(templates = listOf(template(0x04, "Arrende för sommarstugan", account, base.categories.first(), 1_250_000, 28, endPeriod = null)))
}
private fun template(n: Int, description: String, account: Account, category: Category, ore: Long, day: Int, endPeriod: String?, paused: Boolean = false) =
RecurringTemplate(
id = TemplateId("01920000-0000-7000-8000-0000000007" + n.toString(16).padStart(2, '0')),
draft = ExpenseDraft(
id = null,
accountId = account.id,
categoryId = category.id,
amountOre = ore,
description = description,
notes = "",
date = "2026-10-17",
payer = me,
mode = SplitMode.DEFAULT,
ratioBp = null,
exactOre = null,
fullOn = null,
),
dayOfMonth = day,
startPeriod = "2025-01",
endPeriod = endPeriod,
paused = paused,
nextOccurrence = null,
)
}
@@ -0,0 +1,85 @@
package se.hagstedt.kvitt.fakes
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
import se.hagstedt.kvitt.fakes.analytics.InsightsScenarios
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SpendForecast
import se.hagstedt.kvitt.model.SpendTrends
/** The Insights fake (D-141) follows E-34's and E-35's rules over the scenarios (today 2026-10-17). */
class FakeInsightsRepositoryTest {
private suspend fun trends(scenario: FakeScenario, measure: Measure = Measure.HOUSEHOLD): SpendTrends =
(FakeRepositories(scenario).insights.trends(6, measure) as OnlineResult.Ok).value
private suspend fun forecast(scenario: FakeScenario, measure: Measure = Measure.HOUSEHOLD): SpendForecast =
(FakeRepositories(scenario).insights.forecast(measure) as OnlineResult.Ok).value
@Test
fun trendsCoverTheWholeMonthsBeforeTheCurrentOneWithEmptyMonthsAsZero() = runTest {
val t = trends(InsightsScenarios.loaded)
assertEquals("2026-10", t.currentMonth)
assertEquals(listOf("2026-04", "2026-05", "2026-06", "2026-07", "2026-08", "2026-09"), t.household.points.map { it.month })
assertEquals(0L, t.household.points.first().amountOre)
assertEquals(t.household.points.sumOf { it.amountOre }, t.household.totalOre)
assertEquals((t.household.totalOre * 2 + 6) / 12, t.household.averageOre)
assertEquals(t.household.points.last().amountOre, t.household.lastMonthOre)
assertTrue(t.household.currentMonthOre > 0)
// Sorted by total, rent first; categories spent only this month are listed too.
assertEquals(AnalyticsScenarios.housing.id, t.categories.first().categoryId)
assertEquals(t.categories.sortedByDescending { it.trend.totalOre }.map { it.trend.totalOre }, t.categories.map { it.trend.totalOre })
assertTrue(t.categories.all { it.category != null })
}
@Test
fun movingAverageIsTrailingOverThreeMonths() = runTest {
val p = trends(InsightsScenarios.loaded).household.points
assertEquals(p[0].amountOre, p[0].movingAverageOre)
val last = p.takeLast(3).sumOf { it.amountOre }
assertEquals((last * 2 + 3) / 6, p.last().movingAverageOre)
}
@Test
fun forecastIsTemplatesDuePlusTheThreeMonthBaselineOfOtherSpend() = runTest {
val f = forecast(InsightsScenarios.loaded)
assertEquals(listOf("2026-11", "2026-12"), f.accounts.first().months.map { it.month })
val huset = f.accounts.single { it.accountId == AnalyticsScenarios.huset.id }
// Rent and insurance in November, rent only in December (the insurance ends in November).
assertEquals(listOf(1_066_200L, 1_033_300L), huset.months.map { it.recurringOre })
// Electricity July to September (127 050 + 131 200 + 135 350) ÷ 3; the generated rent and insurance are left out.
assertEquals(listOf(131_200L, 131_200L), huset.months.map { it.baselineOre })
assertTrue(huset.months.all { it.forecastOre == it.recurringOre + it.baselineOre })
// The paused SL card is never forecast; the new account has nothing at all.
assertTrue(f.accounts.single { it.accountId == AnalyticsScenarios.vardag.id }.months.all { it.recurringOre == 0L })
val bilen = f.accounts.single { it.accountId == InsightsScenarios.bilen.id }
assertEquals(0L, bilen.currentMonthOre)
assertTrue(bilen.months.all { it.forecastOre == 0L })
}
@Test
fun myShareUsesTheSharesAGenerationWouldWrite() = runTest {
val huset = forecast(InsightsScenarios.loaded, Measure.MY_SHARE).accounts.single { it.accountId == AnalyticsScenarios.huset.id }
assertEquals(516_650L + 16_450L, huset.months.first().recurringOre)
}
@Test
fun anEmptyHouseholdHasZeroTrendsAndNoCategories() = runTest {
val t = trends(InsightsScenarios.emptyHousehold)
assertEquals(0L, t.household.totalOre)
assertNull(t.household.lastMonthVsAveragePct)
assertTrue(t.categories.isEmpty())
assertEquals(2, forecast(InsightsScenarios.emptyHousehold).accounts.size)
}
@Test
fun offlineAndUnreachableFailLikeTheServerWould() = runTest {
assertEquals(OnlineResult.Offline, FakeRepositories(InsightsScenarios.offline).insights.trends(6, Measure.HOUSEHOLD))
assertEquals(OnlineResult.Failed("network", null), FakeRepositories(InsightsScenarios.unreachable).insights.forecast(Measure.HOUSEHOLD))
assertEquals(OnlineResult.Failed("invalid_request", "months"), FakeRepositories(InsightsScenarios.loaded).insights.trends(25, Measure.HOUSEHOLD))
}
}
@@ -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)
}
}
@@ -0,0 +1,69 @@
package se.hagstedt.kvitt.data.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
// Response bodies of the advanced analytics, E-34 and E-35 (D-133, `contracts/openapi.yaml` TrendsResponse, ForecastResponse).
/** E-34 `TrendPoint`: `month` is `YYYY-MM`. */
@Serializable
data class TrendPointDto(
val month: String,
@SerialName("amount_ore") val amountOre: Long,
@SerialName("moving_average_ore") val movingAverageOre: Long,
)
/** E-34 `Trend`; `last_month_vs_average_pct` is always present, null when the average is 0. */
@Serializable
data class TrendDto(
@SerialName("total_ore") val totalOre: Long,
@SerialName("average_ore") val averageOre: Long,
@SerialName("last_month_ore") val lastMonthOre: Long,
@SerialName("last_month_vs_average_pct") val lastMonthVsAveragePct: Int?,
@SerialName("current_month_ore") val currentMonthOre: Long,
val points: List<TrendPointDto>,
)
/** E-34 `CategoryTrend`. */
@Serializable
data class CategoryTrendDto(
@SerialName("category_id") val categoryId: String,
val trend: TrendDto,
)
/** E-34 response. */
@Serializable
data class TrendsResponse(
val measure: String,
val today: String,
@SerialName("current_month") val currentMonth: String,
val months: Int,
val household: TrendDto,
val categories: List<CategoryTrendDto>,
)
/** E-35 `ForecastMonth`. */
@Serializable
data class ForecastMonthDto(
val month: String,
@SerialName("recurring_ore") val recurringOre: Long,
@SerialName("baseline_ore") val baselineOre: Long,
@SerialName("forecast_ore") val forecastOre: Long,
)
/** E-35 `AccountForecast`. */
@Serializable
data class AccountForecastDto(
@SerialName("account_id") val accountId: String,
@SerialName("current_month_ore") val currentMonthOre: Long,
val months: List<ForecastMonthDto>,
)
/** E-35 response. */
@Serializable
data class ForecastResponse(
val measure: String,
val today: String,
@SerialName("current_month") val currentMonth: String,
val accounts: List<AccountForecastDto>,
)
@@ -135,6 +135,15 @@ class KvittApi(
suspend fun proposal(request: ProposalRequest): ApiResult<ProposalResponse> =
authed(HttpMethod.Post, "/settlements/proposal") { jsonBody(request) }
// E-34, E-35 (D-133): `measure` is the wire value `household` or `my_share`.
suspend fun analyticsTrends(months: Int, measure: String): ApiResult<TrendsResponse> = authed(HttpMethod.Get, "/analytics/trends") {
parameter("months", months)
parameter("measure", measure)
}
suspend fun analyticsForecast(measure: String): ApiResult<ForecastResponse> = authed(HttpMethod.Get, "/analytics/forecast") {
parameter("measure", measure)
}
private suspend inline fun <reified T> authed(
method: HttpMethod,
path: String,
@@ -46,6 +46,7 @@ import se.hagstedt.kvitt.data.repo.CategoryRepositoryImpl
import se.hagstedt.kvitt.data.repo.DeviceRepositoryImpl
import se.hagstedt.kvitt.data.repo.ExpenseRepositoryImpl
import se.hagstedt.kvitt.data.repo.HomeRepositoryImpl
import se.hagstedt.kvitt.data.repo.InsightsRepositoryImpl
import se.hagstedt.kvitt.data.repo.InviteRepositoryImpl
import se.hagstedt.kvitt.data.repo.LocalData
import se.hagstedt.kvitt.data.repo.PhonePrefs
@@ -75,6 +76,7 @@ import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.DeviceRepository
import se.hagstedt.kvitt.model.ExpenseRepository
import se.hagstedt.kvitt.model.HomeRepository
import se.hagstedt.kvitt.model.InsightsRepository
import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.RecurringRepository
@@ -266,6 +268,9 @@ object DataModule {
@Provides @Singleton
fun analytics(local: LocalData, clock: Clock): AnalyticsRepositoryImpl = AnalyticsRepositoryImpl(local, clock)
@Provides @Singleton
fun insights(local: LocalData, api: KvittApi, gate: OnlineGate): InsightsRepositoryImpl = InsightsRepositoryImpl(local, api, gate)
@Provides @Singleton
fun profile(local: LocalData, api: KvittApi, gate: OnlineGate, db: KvittDatabase, clock: Clock): ProfileRepositoryImpl =
ProfileRepositoryImpl(local, api, gate, db, clock)
@@ -303,6 +308,7 @@ abstract class DataBindingsModule {
@Binds abstract fun expenses(impl: ExpenseRepositoryImpl): ExpenseRepository
@Binds abstract fun settlements(impl: SettlementRepositoryImpl): SettlementRepository
@Binds abstract fun analytics(impl: AnalyticsRepositoryImpl): AnalyticsRepository
@Binds abstract fun insights(impl: InsightsRepositoryImpl): InsightsRepository
@Binds abstract fun profile(impl: ProfileRepositoryImpl): ProfileRepository
@Binds abstract fun categories(impl: CategoryRepositoryImpl): CategoryRepository
@Binds abstract fun recurring(impl: RecurringRepositoryImpl): RecurringRepository
@@ -0,0 +1,79 @@
package se.hagstedt.kvitt.data.repo
import kotlinx.coroutines.flow.first
import se.hagstedt.kvitt.data.api.AccountForecastDto
import se.hagstedt.kvitt.data.api.ErrorCodes
import se.hagstedt.kvitt.data.api.ForecastResponse
import se.hagstedt.kvitt.data.api.KvittApi
import se.hagstedt.kvitt.data.api.TrendDto
import se.hagstedt.kvitt.data.api.TrendsResponse
import se.hagstedt.kvitt.data.connectivity.OnlineGate
import se.hagstedt.kvitt.model.AccountForecast
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.CategoryTrend
import se.hagstedt.kvitt.model.ForecastMonth
import se.hagstedt.kvitt.model.InsightsRepository
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SpendForecast
import se.hagstedt.kvitt.model.SpendTrend
import se.hagstedt.kvitt.model.SpendTrends
import se.hagstedt.kvitt.model.TrendPoint
/**
* S-11's Insights (D-133, D-141): E-34 and E-35 through [OnlineGate] (§9.9: offline without a request; a network
* failure is `Failed("network")`). Nothing is stored. Category and account ids are resolved from the phone's copy,
* deleted rows included, so history still has names; an id the phone does not hold maps to null.
*/
class InsightsRepositoryImpl(private val local: LocalData, private val api: KvittApi, private val gate: OnlineGate) : InsightsRepository {
override suspend fun trends(months: Int, measure: Measure): OnlineResult<SpendTrends> {
if (months !in MONTHS) return OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "months")
return gate.run({ api.analyticsTrends(months, measure.wire()) }) { it.toModel(local.snapshot.first(), measure) }
}
override suspend fun forecast(measure: Measure): OnlineResult<SpendForecast> =
gate.run({ api.analyticsForecast(measure.wire()) }) { it.toModel(local.snapshot.first(), measure) }
companion object {
/** E-34 `months`: 1 to 24 (`invalid_request` otherwise). */
val MONTHS = 1..24
}
}
private fun Measure.wire(): String = name.lowercase()
/** The response's measure; an unknown wire value from a newer server reads as the one requested. */
private fun measureOf(wire: String, requested: Measure): Measure = Measure.entries.firstOrNull { it.name.lowercase() == wire } ?: requested
internal fun TrendsResponse.toModel(s: Snapshot, requested: Measure): SpendTrends = SpendTrends(
measure = measureOf(measure, requested),
today = today,
currentMonth = currentMonth,
months = months,
household = household.toModel(),
categories = categories.map { CategoryTrend(CategoryId(it.categoryId), s.category(it.categoryId), it.trend.toModel()) },
)
private fun TrendDto.toModel(): SpendTrend = SpendTrend(
totalOre = totalOre,
averageOre = averageOre,
lastMonthOre = lastMonthOre,
lastMonthVsAveragePct = lastMonthVsAveragePct,
currentMonthOre = currentMonthOre,
points = points.map { TrendPoint(it.month, it.amountOre, it.movingAverageOre) },
)
internal fun ForecastResponse.toModel(s: Snapshot, requested: Measure): SpendForecast = SpendForecast(
measure = measureOf(measure, requested),
today = today,
currentMonth = currentMonth,
accounts = accounts.map { it.toModel(s) },
)
private fun AccountForecastDto.toModel(s: Snapshot): AccountForecast = AccountForecast(
accountId = AccountId(accountId),
account = s.account(accountId),
currentMonthOre = currentMonthOre,
months = months.map { ForecastMonth(it.month, it.recurringOre, it.baselineOre, it.forecastOre) },
)
@@ -88,6 +88,8 @@ internal fun serializerFor(path: String): KSerializer<*>? {
31 -> PullPage.serializer()
32 -> if (request) PushRequest.serializer() else PushResponse.serializer()
33 -> if (request) ProposalRequest.serializer() else ProposalResponse.serializer()
34 -> TrendsResponse.serializer()
35 -> ForecastResponse.serializer()
else -> null
}
}
@@ -0,0 +1,137 @@
package se.hagstedt.kvitt.data.repo
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import se.hagstedt.kvitt.data.Harness
import se.hagstedt.kvitt.data.Harness.Companion.ACCOUNT
import se.hagstedt.kvitt.data.Harness.Companion.GROCERIES
import se.hagstedt.kvitt.data.api.ErrorCodes
import se.hagstedt.kvitt.data.api.ErrorDto
import se.hagstedt.kvitt.data.api.KvittApi
import se.hagstedt.kvitt.data.api.KvittJson
import se.hagstedt.kvitt.data.api.examplesDir
import se.hagstedt.kvitt.data.runSync
import se.hagstedt.kvitt.model.AccountForecast
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.ForecastMonth
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SpendForecast
import se.hagstedt.kvitt.model.SpendTrend
import se.hagstedt.kvitt.model.SpendTrends
import se.hagstedt.kvitt.model.TrendPoint
import java.io.File
/** D-141: E-34 and E-35 through `InsightsRepositoryImpl`, mapped from the committed examples (§14.3.5), online only (§9.9). */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36])
class InsightsRepositoryTest {
private lateinit var h: Harness
private lateinit var repo: InsightsRepositoryImpl
private fun example(dir: String) = File(examplesDir(), "$dir/response.json").readText()
@Before
fun setUp() {
h = Harness().ready()
repo = InsightsRepositoryImpl(h.local, h.api, h.gate)
h.server.restHandler = { _, path ->
when (path) {
"/analytics/trends" -> HttpStatusCode.OK to example("E-34-analytics-trends")
"/analytics/forecast" -> HttpStatusCode.OK to example("E-35-analytics-forecast")
else -> null
}
}
}
@After
fun tearDown() = h.close()
@Test
fun trendsMapTheE34ExampleAndResolveCategoriesFromThePhone() = runSync {
val result = repo.trends(3, Measure.HOUSEHOLD)
val trends = (result as OnlineResult.Ok).value
val request = h.requests.single()
assertEquals(HttpMethod.Get, request.method)
assertEquals("/analytics/trends", request.url.encodedPath)
assertEquals("3", request.url.parameters["months"])
assertEquals("household", request.url.parameters["measure"])
assertEquals("Bearer ${Harness.TOKEN}", request.headers[HttpHeaders.Authorization])
assertEquals("1", request.headers[KvittApi.PROTOCOL_HEADER])
assertEquals(Harness.APP_KEY, request.headers[KvittApi.APP_KEY_HEADER])
val household = SpendTrend(
totalOre = 153_000, averageOre = 51_000, lastMonthOre = 78_000, lastMonthVsAveragePct = 53, currentMonthOre = 12_000,
points = listOf(TrendPoint("2026-07", 40_000, 40_000), TrendPoint("2026-08", 35_000, 37_500), TrendPoint("2026-09", 78_000, 51_000)),
)
val expected = SpendTrends(Measure.HOUSEHOLD, "2026-10-17", "2026-10", 3, household, trends.categories)
assertEquals(expected, trends)
assertEquals(listOf(CategoryId(GROCERIES), CategoryId("01920000-0000-7000-8000-000000000003")), trends.categories.map { it.categoryId })
assertEquals(DefaultCategoryKey.GROCERIES, trends.categories[0].category?.defaultKey)
assertNull("a category the phone does not hold has no row", trends.categories[1].category)
assertEquals(103, trends.categories[0].trend.lastMonthVsAveragePct)
assertEquals(-25, trends.categories[1].trend.lastMonthVsAveragePct)
assertEquals(listOf(30_000L, 0L, 63_000L), trends.categories[0].trend.points.map { it.amountOre })
}
@Test
fun forecastMapsTheE35ExampleWithTheRequestedMeasure() = runSync {
val forecast = (repo.forecast(Measure.MY_SHARE) as OnlineResult.Ok).value
val request = h.requests.single()
assertEquals("/analytics/forecast", request.url.encodedPath)
assertEquals("my_share", request.url.parameters["measure"])
assertNull("E-35 takes no months", request.url.parameters["months"])
// The example says household; the response's measure wins over the requested one.
assertEquals(Measure.HOUSEHOLD, forecast.measure)
val first = AccountForecast(
AccountId(ACCOUNT),
forecast.accounts[0].account,
12_000,
listOf(ForecastMonth("2026-11", 4_900, 40_000, 44_900), ForecastMonth("2026-12", 0, 40_000, 40_000)),
)
assertEquals(SpendForecast(Measure.HOUSEHOLD, "2026-10-17", "2026-10", forecast.accounts), forecast)
assertEquals(first, forecast.accounts[0])
assertEquals("Vardag", forecast.accounts[0].account?.name)
assertEquals(listOf(null, null), forecast.accounts.drop(1).map { it.account })
assertEquals(listOf(16_000L, 16_000L), forecast.accounts[1].months.map { it.forecastOre })
}
@Test
fun offlineIsOfflineWithoutARequest() = runSync {
h.monitor.state.value = false
assertEquals(OnlineResult.Offline, repo.trends(6, Measure.HOUSEHOLD))
assertEquals(OnlineResult.Offline, repo.forecast(Measure.HOUSEHOLD))
assertTrue(h.requests.isEmpty())
}
@Test
fun aNetworkFailureIsFailedNetwork() = runSync {
h.server.failNetwork = true
assertEquals(OnlineResult.Failed(ErrorCodes.NETWORK, null), repo.trends(6, Measure.HOUSEHOLD))
}
@Test
fun serverErrorsKeepTheirCodeAndField() = runSync {
h.server.restHandler = { _, _ -> HttpStatusCode.BadRequest to KvittJson.encodeToString(ErrorDto.serializer(), ErrorDto("invalid_request", "bad", "measure")) }
assertEquals(OnlineResult.Failed("invalid_request", "measure"), repo.forecast(Measure.HOUSEHOLD))
}
@Test
fun monthsOutsideOneToTwentyFourAreRefusedWithoutARequest() = runSync {
assertEquals(OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "months"), repo.trends(0, Measure.HOUSEHOLD))
assertEquals(OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "months"), repo.trends(25, Measure.HOUSEHOLD))
assertTrue(h.requests.isEmpty())
}
}
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
@@ -72,12 +73,19 @@ fun AnalyticsScreen(
onNavigate: (KvittDestination) -> Unit,
modifier: Modifier = Modifier,
viewModel: AnalyticsViewModel = hiltViewModel(),
insightsViewModel: InsightsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
AnalyticsScreen(state, viewModel, onOpenDrillDown, onNavigate, modifier)
val insights by insightsViewModel.state.collectAsStateWithLifecycle()
// Entering S-11 and every change of the measure toggle refresh the Insights (D-141).
LaunchedEffect(state.filter.measure) { insightsViewModel.show(state.filter.measure) }
AnalyticsScreen(state, viewModel, onOpenDrillDown, onNavigate, modifier, insights = insights, insightsEvents = insightsViewModel)
}
/** The stateless S-11 that previews and screenshot tests render. */
/**
* The stateless S-11 that previews and screenshot tests render. [insights] is the Insights section below the local
* analytics (D-141); null renders none.
*/
@Composable
fun AnalyticsScreen(
state: AnalyticsUiState,
@@ -86,6 +94,8 @@ fun AnalyticsScreen(
onNavigate: (KvittDestination) -> Unit,
modifier: Modifier = Modifier,
scrollState: ScrollState = rememberScrollState(),
insights: InsightsUiState? = null,
insightsEvents: InsightsEvents = NoInsightsEvents,
) {
val scrolled by remember(scrollState) { derivedStateOf { scrollState.value > 0 } }
KvittScaffold(
@@ -108,8 +118,11 @@ fun AnalyticsScreen(
when (val content = state.content) {
null -> Unit
AnalyticsContent.Loading -> LoadingCards()
is AnalyticsContent.Ready -> Ready(content, events) { bucket, category, account ->
onOpenDrillDown(DrillDownArgs(content.data.filter, bucket, category, account))
is AnalyticsContent.Ready -> {
Ready(content, events) { bucket, category, account ->
onOpenDrillDown(DrillDownArgs(content.data.filter, bucket, category, account))
}
if (insights != null) InsightsSection(insights, content.accounts, insightsEvents)
}
}
}
@@ -311,3 +324,9 @@ private fun LoadingCards() {
private const val SKELETON_ROWS = 3
/** No Insights events: the default of the stateless S-11 when no section is rendered. */
private object NoInsightsEvents : InsightsEvents {
override fun onRetryInsights() = Unit
override fun onSelectCategory(id: CategoryId) = Unit
}
@@ -0,0 +1,97 @@
package se.hagstedt.kvitt.feature.analytics
import se.hagstedt.kvitt.model.AccountForecast
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.CategoryTrend
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SpendForecast
import se.hagstedt.kvitt.model.SpendTrends
import kotlin.math.abs
/** One load of S-11's Insights (D-141): the server's trends and forecast for one measure. */
data class Insights(val trends: SpendTrends, val forecast: SpendForecast)
/** Where the Insights section stands; the last loaded [InsightsUiState.data] stays visible under every status. */
sealed interface InsightsStatus {
/** A request is running, or none has been made yet. */
data object Loading : InsightsStatus
data object Loaded : InsightsStatus
/** Offline (§9.9): "Kräver anslutning". */
data object NeedsConnection : InsightsStatus
/** The last request failed: `network` is "Kunde inte nå servern", other codes their §14.3.2 text; with "Försök igen". */
data class Failed(val code: String) : InsightsStatus
}
/**
* S-11's Insights section: the [measure] it shows (S-11's measure toggle), its [status], the last data loaded for that
* measure in this session ([data], null before the first success), and the category whose line chart is open.
*/
data class InsightsUiState(val measure: Measure, val status: InsightsStatus, val data: Insights?, val selectedCategory: CategoryId?)
/** The Insights section's events (the InsightsViewModel implements them; screenshot tests pass their own). */
interface InsightsEvents {
fun onRetryInsights()
fun onSelectCategory(id: CategoryId)
}
/** The rules of the Insights section (D-141, D-142), pure so the unit tests reach them. */
object InsightsRules {
/** D-142: the whole months of the trends (E-34 `months`), the server's default and S-11's default range (AC-14.2). */
const val MONTHS = 6
/** D-133: the forecast baseline's window, D-128's moving-average window counted in whole months. */
const val BASELINE_MONTHS = AnalyticsRules.MOVING_AVERAGE_WINDOW
/** Both reads succeed, or the first one that did not: offline before a failure. */
fun combine(trends: OnlineResult<SpendTrends>, forecast: OnlineResult<SpendForecast>): OnlineResult<Insights> = when {
trends is OnlineResult.Ok && forecast is OnlineResult.Ok -> OnlineResult.Ok(Insights(trends.value, forecast.value))
trends == OnlineResult.Offline || forecast == OnlineResult.Offline -> OnlineResult.Offline
trends is OnlineResult.Failed -> trends
forecast is OnlineResult.Failed -> forecast
else -> OnlineResult.Offline
}
/** The section's status: offline always reads "Kräver anslutning"; online, the last attempt for the measure (none yet: loading). */
fun status(online: Boolean, attempt: InsightsStatus?): InsightsStatus = if (!online) InsightsStatus.NeedsConnection else attempt ?: InsightsStatus.Loading
/** The status a finished load leaves. */
fun statusOf(result: OnlineResult<Insights>): InsightsStatus = when (result) {
is OnlineResult.Ok -> InsightsStatus.Loaded
OnlineResult.Offline -> InsightsStatus.NeedsConnection
is OnlineResult.Failed -> InsightsStatus.Failed(result.code)
}
/** A load starts when online and the measure has no successful load yet, or on [force] (entering S-11, "Försök igen"). */
fun shouldLoad(online: Boolean, attempt: InsightsStatus?, force: Boolean): Boolean =
online && attempt != InsightsStatus.Loading && (force || attempt != InsightsStatus.Loaded)
/** The categories the section lists: those the phone holds a row for (deleted included); unknown ids are left out. */
fun shownCategories(trends: SpendTrends): List<CategoryTrend> = trends.categories.filter { it.category != null }
/** The category whose chart is open: [selected] while it is listed, else the first (the largest total). */
fun selectedCategory(trends: SpendTrends, selected: CategoryId?): CategoryTrend? {
val shown = shownCategories(trends)
return shown.firstOrNull { it.categoryId == selected } ?: shown.firstOrNull()
}
/** No spending in the whole months nor so far this month: the trend cards give way to one line. */
fun isEmpty(trends: SpendTrends): Boolean =
trends.household.totalOre == 0L && trends.household.currentMonthOre == 0L && shownCategories(trends).isEmpty()
/** An account with nothing to forecast and nothing spent this month shows one line instead of its months. */
fun hasNothing(forecast: AccountForecast): Boolean = forecast.currentMonthOre == 0L && forecast.months.all { it.forecastOre == 0L }
/** The direction of a last-month change for the S-12 caption words (D-128): 0 % is "på snittet". */
fun comparison(lastMonth: String, pct: Int?): AverageComparison? = pct?.let {
val direction = when {
it > 0 -> AverageComparison.Direction.ABOVE
it < 0 -> AverageComparison.Direction.BELOW
else -> AverageComparison.Direction.ON
}
AverageComparison(lastMonth, abs(it).toLong(), direction)
}
}
@@ -0,0 +1,341 @@
package se.hagstedt.kvitt.feature.analytics
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import kotlinx.datetime.LocalDate
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.chart.ChartCard
import se.hagstedt.kvitt.design.chart.LineKind
import se.hagstedt.kvitt.design.chart.LineLegend
import se.hagstedt.kvitt.design.chart.LineLegendItem
import se.hagstedt.kvitt.design.chart.LinePoint
import se.hagstedt.kvitt.design.chart.SpendLineChart
import se.hagstedt.kvitt.design.component.AccountTile
import se.hagstedt.kvitt.design.component.AmountStyle
import se.hagstedt.kvitt.design.component.AmountTone
import se.hagstedt.kvitt.design.component.ArchivedBadge
import se.hagstedt.kvitt.design.component.Banner
import se.hagstedt.kvitt.design.component.BannerKind
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.CategoryTile
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.MoneyText
import se.hagstedt.kvitt.design.component.NeedsConnectionBanner
import se.hagstedt.kvitt.design.component.SectionHeader
import se.hagstedt.kvitt.design.component.Skeleton
import se.hagstedt.kvitt.design.component.categoryName
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.spokenMoney
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.design.format.commonFailureText
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountForecast
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.CategoryTrend
import se.hagstedt.kvitt.model.ForecastMonth
import se.hagstedt.kvitt.model.SpendTrend
import se.hagstedt.kvitt.model.SpendTrends
import se.hagstedt.kvitt.core.design.R as DesignR
/**
* S-11's Insights section (D-133, D-141), below "Per konto" (or the empty state): heading "Insikter" with the months
* and measure; a "Kräver anslutning" banner offline, or "Kunde inte nå servern" with "Försök igen" after a failure,
* above the last data loaded this session; skeleton cards before the first load. The data: "Utgifter per månad" (the
* household's or my share's line chart with the average and the moving average from the server), "Kategorier per
* månad" (the chosen category's line chart over the list of categories), and "Prognos" with a card per account.
* Emits its parts into the caller's column.
*/
@Composable
internal fun InsightsSection(state: InsightsUiState, accounts: List<Account>, events: InsightsEvents) {
Column(Modifier.fillMaxWidth()) {
SectionHeader(stringResource(R.string.analytics_insights_title))
Text(
pluralStringResource(R.plurals.analytics_insights_caption, InsightsRules.MONTHS, InsightsRules.MONTHS, stringResource(measureLabel(state.measure))),
Modifier.padding(horizontal = KvittTheme.spacing.s2),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
when (val status = state.status) {
InsightsStatus.NeedsConnection -> NeedsConnectionBanner(stringResource(R.string.analytics_insights_offline_body))
is InsightsStatus.Failed -> Banner(
BannerKind.Error,
KvittIcon.CloudOff,
stringResource(commonFailureText(status.code) ?: DesignR.string.error_server),
) { content ->
KvittButton(stringResource(DesignR.string.common_retry), events::onRetryInsights, kind = ButtonKind.Text, contentColor = content, icon = KvittIcon.Refresh)
}
InsightsStatus.Loading, InsightsStatus.Loaded -> Unit
}
val data = state.data
when {
data != null -> InsightsContent(data, state.selectedCategory, accounts, events)
state.status == InsightsStatus.Loading -> InsightsSkeleton()
else -> Unit
}
}
@Composable
private fun InsightsContent(data: Insights, selected: CategoryId?, accounts: List<Account>, events: InsightsEvents) {
val trends = data.trends
if (InsightsRules.isEmpty(trends)) {
KvittCard(Modifier.fillMaxWidth()) {
Text(
pluralStringResource(R.plurals.analytics_insights_no_spending, trends.months, trends.months),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
ChartCard(stringResource(R.string.analytics_insights_trend)) {
TrendChart(stringResource(measureLabel(trends.measure)), trends.household, MaterialTheme.colorScheme.primary, trends)
}
CategoryTrends(trends, selected, events)
}
ForecastHeader()
data.forecast.accounts.forEach { forecast ->
val index = accounts.indexOfFirst { it.id == forecast.accountId }
val account = forecast.account ?: accounts.getOrNull(index) ?: return@forEach
AccountForecastCard(account, if (index >= 0) index else accounts.size, forecast, data.forecast.currentMonth)
}
}
/**
* One trend as D-128's line chart: the months, the server's average as the dashed line and its moving average (3
* months); then the legend, the average per month and the last whole month against it, and the current month so far.
*/
@Composable
private fun TrendChart(name: String, trend: SpendTrend, color: Color, trends: SpendTrends) {
val points = trend.points
val overlays = points.size > 1 && trend.averageOre > 0L
val window = AnalyticsRules.MOVING_AVERAGE_WINDOW
SpendLineChart(
points = points.map { LinePoint(it.month, it.amountOre) },
color = color,
summary = trendSummary(name, trend),
averageOre = trend.averageOre.takeIf { overlays },
movingAverage = if (overlays) points.map { it.movingAverageOre } else emptyList(),
)
LineLegend(
listOfNotNull(
LineLegendItem(name, LineKind.Series),
LineLegendItem(stringResource(R.string.analytics_average), LineKind.Average).takeIf { overlays },
LineLegendItem(stringResource(R.string.analytics_moving_average, window), LineKind.MovingAverage).takeIf { overlays },
),
color,
)
val language = kvittLanguage()
val separator = stringResource(DesignR.string.common_separator_dot)
val average = stringResource(R.string.analytics_insights_average_per_month, MoneyFormatter.format(trend.averageOre, language))
val comparison = points.lastOrNull()?.let { InsightsRules.comparison(it.month, trend.lastMonthVsAveragePct) }?.let { comparisonWords(it) }
Column {
Text(listOfNotNull(average, comparison).joinToString(separator), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
stringResource(R.string.analytics_insights_so_far, bucketName(trends.currentMonth, capitalized = false), MoneyFormatter.format(trend.currentMonthOre, language)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** "Mat & hushåll över tid, apr–sep 2026: från … till …, totalt …, snitt …." (D-128's sentence, `analytics_summary_line`). */
@Composable
private fun trendSummary(name: String, trend: SpendTrend): String {
val first = trend.points.firstOrNull()
val last = trend.points.lastOrNull()
if (first == null || last == null) return name
val range = DateFormatter.monthRange(monthStart(first.month), monthStart(last.month), kvittLanguage())
val total = spokenMoney(trend.totalOre)
return if (trend.points.size == 1) {
stringResource(R.string.analytics_summary_line_single, name, range, total)
} else {
stringResource(
R.string.analytics_summary_line,
name,
range,
spokenMoney(first.amountOre),
bucketName(first.month, capitalized = false),
spokenMoney(last.amountOre),
bucketName(last.month, capitalized = false),
total,
spokenMoney(trend.averageOre),
)
}
}
private fun monthStart(month: String): LocalDate = LocalDate.parse("$month-01")
/** "September 2026: 53 % över snittet" (D-128's caption words). */
@Composable
private fun comparisonWords(comparison: AverageComparison): String {
val month = bucketName(comparison.key, capitalized = true)
val percent = stringResource(DesignR.string.common_percent, comparison.percent.toString())
return when (comparison.direction) {
AverageComparison.Direction.ABOVE -> stringResource(R.string.analytics_vs_average_above, month, percent)
AverageComparison.Direction.BELOW -> stringResource(R.string.analytics_vs_average_below, month, percent)
AverageComparison.Direction.ON -> stringResource(R.string.analytics_vs_average_on, month)
}
}
/** "Kategorier per månad": the chosen category's chart, then every category as a radio row (tile, name, average, change). */
@Composable
private fun CategoryTrends(trends: SpendTrends, selected: CategoryId?, events: InsightsEvents) {
val current = InsightsRules.selectedCategory(trends, selected) ?: return
val category = current.category ?: return
ChartCard(stringResource(R.string.analytics_insights_categories)) {
TrendChart(categoryName(category), current.trend, KvittTheme.colors.category(category.color), trends)
Column(Modifier.fillMaxWidth().selectableGroup(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
InsightsRules.shownCategories(trends).forEach { row ->
CategoryTrendRow(row, row.categoryId == current.categoryId) { events.onSelectCategory(row.categoryId) }
}
}
}
}
@Composable
private fun CategoryTrendRow(row: CategoryTrend, selected: Boolean, onClick: () -> Unit) {
val category = row.category ?: return
val language = kvittLanguage()
val pct = row.trend.lastMonthVsAveragePct
val change = when {
pct == null -> null
pct > 0 -> stringResource(R.string.analytics_insights_change_up, stringResource(DesignR.string.common_percent, pct.toString()))
pct < 0 -> stringResource(R.string.analytics_insights_change_down, stringResource(DesignR.string.common_percent, (-pct).toString()))
else -> stringResource(DesignR.string.common_percent, "0")
}
val fill = if (selected) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent
Row(
Modifier
.fillMaxWidth()
.heightIn(min = KvittTheme.spacing.touchTarget)
.clip(KvittTheme.shapes.field)
.background(fill)
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
.padding(horizontal = KvittTheme.spacing.s2, vertical = KvittTheme.spacing.s2),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
CategoryTile(category, KvittDimens.tileBreakdown)
Column(Modifier.weight(1f)) {
Text(categoryName(category), style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
stringResource(R.string.analytics_insights_average_per_month, MoneyFormatter.format(row.trend.averageOre, language)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (change != null) Text(change, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
/** "Prognos" and how it is made (D-133). */
@Composable
private fun ForecastHeader() {
Column(Modifier.fillMaxWidth().padding(horizontal = KvittTheme.spacing.s2).semantics(mergeDescendants = true) { }) {
Text(stringResource(R.string.analytics_insights_forecast), style = MaterialTheme.typography.titleMedium)
Text(
pluralStringResource(R.plurals.analytics_insights_forecast_caption, InsightsRules.BASELINE_MONTHS, InsightsRules.BASELINE_MONTHS),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** One account's forecast: tile, name and the current month so far, then the two months with their parts. */
@Composable
private fun AccountForecastCard(account: Account, index: Int, forecast: AccountForecast, currentMonth: String) {
val language = kvittLanguage()
KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
Row(
Modifier.semantics(mergeDescendants = true) { },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
AccountTile(account.name, index, size = KvittDimens.tileBreakdown, muted = account.archived)
Column(Modifier.weight(1f)) {
Text(account.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
stringResource(R.string.analytics_insights_so_far, bucketName(currentMonth, capitalized = false), MoneyFormatter.format(forecast.currentMonthOre, language)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (account.archived) ArchivedBadge()
}
if (InsightsRules.hasNothing(forecast)) {
Text(stringResource(R.string.analytics_insights_forecast_none), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
forecast.months.forEach { ForecastMonthRow(it) }
}
}
}
@Composable
private fun ForecastMonthRow(month: ForecastMonth) {
val language = kvittLanguage()
val name = bucketName(month.month, capitalized = true)
val spoken = stringResource(
R.string.analytics_insights_forecast_month_spoken,
name,
spokenMoney(month.forecastOre),
spokenMoney(month.recurringOre),
spokenMoney(month.baselineOre),
)
Row(Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken }, verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(name, style = MaterialTheme.typography.bodyLarge)
Text(
stringResource(R.string.analytics_insights_forecast_parts, MoneyFormatter.format(month.recurringOre, language), MoneyFormatter.format(month.baselineOre, language)),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
MoneyText(month.forecastOre, AmountStyle.Medium, AmountTone.Neutral)
}
}
/** Before the first load: a chart card and an account card in skeleton form, read as "Hämtar insikter". */
@Composable
private fun InsightsSkeleton() {
val loading = stringResource(R.string.analytics_insights_loading)
Column(
Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = loading },
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
ChartCard(stringResource(R.string.analytics_insights_trend)) {
Skeleton(KvittDimens.chartPlot, KvittDimens.chartPlot, Modifier.fillMaxWidth())
}
KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
Skeleton(KvittDimens.menuMinWidth / 2, KvittTheme.spacing.s4)
repeat(SKELETON_MONTHS) { Skeleton(KvittDimens.chartPlot, KvittTheme.spacing.s8, Modifier.fillMaxWidth()) }
}
}
}
/** The forecast's two months (E-35). */
private const val SKELETON_MONTHS = 2
@@ -0,0 +1,75 @@
package se.hagstedt.kvitt.feature.analytics
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.InsightsRepository
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.OnlineResult
import javax.inject.Inject
/**
* S-11's Insights (D-133, D-141): the server's trends and forecast, online only (§9.9). The data of each measure is
* kept in memory for the life of S-11's back stack entry and stays on screen while offline or after a failure; nothing
* is stored. A load runs when S-11 shows ([show]), when the measure changes to one without data, when the phone comes
* online, and on "Försök igen".
*/
@HiltViewModel
class InsightsViewModel @Inject constructor(
private val repository: InsightsRepository,
connectivity: ConnectivityRepository,
) : ViewModel(), InsightsEvents {
private val measure = MutableStateFlow(Measure.HOUSEHOLD)
private val online = MutableStateFlow<Boolean?>(null)
private val cache = MutableStateFlow<Map<Measure, Insights>>(emptyMap())
private val attempts = MutableStateFlow<Map<Measure, InsightsStatus>>(emptyMap())
private val selected = MutableStateFlow<CategoryId?>(null)
val state: StateFlow<InsightsUiState> = combine(measure, online, cache, attempts, selected) { m, o, c, a, s ->
InsightsUiState(m, if (o == null) InsightsStatus.Loading else InsightsRules.status(o, a[m]), c[m], s)
}.stateIn(viewModelScope, SharingStarted.Eagerly, InsightsUiState(measure.value, InsightsStatus.Loading, null, null))
init {
viewModelScope.launch {
connectivity.online.distinctUntilChanged().onEach { online.value = it }.collect { load(measure.value, force = true) }
}
}
/** S-11 is on screen with [measure] (its measure toggle): refresh it. */
fun show(measure: Measure) {
val changed = this.measure.value != measure
this.measure.value = measure
load(measure, force = !changed || attempts.value[measure] != InsightsStatus.Loaded)
}
override fun onRetryInsights() = load(measure.value, force = true)
override fun onSelectCategory(id: CategoryId) = selected.update { id }
private fun load(m: Measure, force: Boolean) {
if (!InsightsRules.shouldLoad(online.value == true, attempts.value[m], force)) return
attempts.update { it + (m to InsightsStatus.Loading) }
viewModelScope.launch {
val result = coroutineScope {
val trends = async { repository.trends(InsightsRules.MONTHS, m) }
val forecast = async { repository.forecast(m) }
InsightsRules.combine(trends.await(), forecast.await())
}
if (result is OnlineResult.Ok) cache.update { it + (m to result.value) }
attempts.update { it + (m to InsightsRules.statusOf(result)) }
}
}
}
@@ -50,4 +50,30 @@
<string name="analytics_vs_average_on">%1$s: på snittet</string>
<string name="analytics_summary_line">%1$s över tid, %2$s: från %3$s i %4$s till %5$s i %6$s, totalt %7$s, snitt %8$s.</string>
<string name="analytics_summary_line_single">%1$s över tid, %2$s: totalt %3$s.</string>
<!-- Insikter (D-133, D-141): serverns trender och prognos under S-11:s diagram, bara med anslutning (§9.9). -->
<string name="analytics_insights_title">Insikter</string>
<plurals name="analytics_insights_caption">
<item quantity="one">Senaste %1$d hela månaden · %2$s</item>
<item quantity="other">Senaste %1$d hela månaderna · %2$s</item>
</plurals>
<string name="analytics_insights_offline_body">Insikterna räknas fram på servern. Anslut till internet för att se dem.</string>
<string name="analytics_insights_loading">Hämtar insikter</string>
<string name="analytics_insights_trend">Utgifter per månad</string>
<string name="analytics_insights_categories">Kategorier per månad</string>
<string name="analytics_insights_average_per_month">Snitt %1$s i månaden</string>
<string name="analytics_insights_so_far">Hittills i %1$s: %2$s</string>
<string name="analytics_insights_change_up">+%1$s</string>
<string name="analytics_insights_change_down">−%1$s</string>
<plurals name="analytics_insights_no_spending">
<item quantity="one">Inga utgifter den senaste %1$d hela månaden</item>
<item quantity="other">Inga utgifter de senaste %1$d hela månaderna</item>
</plurals>
<string name="analytics_insights_forecast">Prognos</string>
<plurals name="analytics_insights_forecast_caption">
<item quantity="one">Återkommande utgifter som förfaller, plus snittet av övriga utgifter den senaste %1$d månaden</item>
<item quantity="other">Återkommande utgifter som förfaller, plus snittet av övriga utgifter de senaste %1$d månaderna</item>
</plurals>
<string name="analytics_insights_forecast_parts">Återkommande %1$s + snitt %2$s</string>
<string name="analytics_insights_forecast_none">Inga återkommande utgifter och inga utgifter på sistone</string>
<string name="analytics_insights_forecast_month_spoken">%1$s: %2$s, återkommande %3$s, snitt %4$s</string>
</resources>
@@ -50,4 +50,30 @@
<string name="analytics_vs_average_on">%1$s: on average</string>
<string name="analytics_summary_line">%1$s over time, %2$s: from %3$s in %4$s to %5$s in %6$s, %7$s in total, average %8$s.</string>
<string name="analytics_summary_line_single">%1$s over time, %2$s: %3$s in total.</string>
<!-- Insights (D-133, D-141): the server's trends and forecast below S-11's charts, online only (§9.9). -->
<string name="analytics_insights_title">Insights</string>
<plurals name="analytics_insights_caption">
<item quantity="one">Last %1$d whole month · %2$s</item>
<item quantity="other">Last %1$d whole months · %2$s</item>
</plurals>
<string name="analytics_insights_offline_body">Insights are calculated on the server. Connect to the internet to see them.</string>
<string name="analytics_insights_loading">Loading insights</string>
<string name="analytics_insights_trend">Spending per month</string>
<string name="analytics_insights_categories">Categories per month</string>
<string name="analytics_insights_average_per_month">Average %1$s a month</string>
<string name="analytics_insights_so_far">So far in %1$s: %2$s</string>
<string name="analytics_insights_change_up">+%1$s</string>
<string name="analytics_insights_change_down">−%1$s</string>
<plurals name="analytics_insights_no_spending">
<item quantity="one">No spending in the last %1$d whole month</item>
<item quantity="other">No spending in the last %1$d whole months</item>
</plurals>
<string name="analytics_insights_forecast">Forecast</string>
<plurals name="analytics_insights_forecast_caption">
<item quantity="one">Recurring expenses due, plus the average of other spending in the last %1$d month</item>
<item quantity="other">Recurring expenses due, plus the average of other spending in the last %1$d months</item>
</plurals>
<string name="analytics_insights_forecast_parts">Recurring %1$s + average %2$s</string>
<string name="analytics_insights_forecast_none">No recurring expenses and no recent spending</string>
<string name="analytics_insights_forecast_month_spoken">%1$s: %2$s, recurring %3$s, average %4$s</string>
</resources>
@@ -0,0 +1,116 @@
package se.hagstedt.kvitt.feature.analytics
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import se.hagstedt.kvitt.design.testing.MainDispatcherRule
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
import se.hagstedt.kvitt.fakes.analytics.InsightsScenarios
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.OnlineResult
/** S-11's Insights state holder (D-141): online only (§9.9), the last data kept for the session. */
@OptIn(ExperimentalCoroutinesApi::class)
class InsightsViewModelTest {
@get:Rule
val main = MainDispatcherRule()
private fun setup(scenario: FakeScenario): Pair<FakeRepositories, InsightsViewModel> {
val fakes = FakeRepositories(scenario)
return fakes to InsightsViewModel(fakes.insights, fakes.connectivity)
}
@Test
fun onlineLoadsTrendsAndForecastForTheHousehold() = runTest {
val (_, vm) = setup(InsightsScenarios.loaded)
val state = vm.state.first { it.status == InsightsStatus.Loaded }
val data = requireNotNull(state.data)
assertEquals(Measure.HOUSEHOLD, data.trends.measure)
assertEquals(InsightsRules.MONTHS, data.trends.months)
assertEquals(5, data.forecast.accounts.size)
}
@Test
fun offlineShowsNeedsConnectionWithoutData() = runTest {
val (_, vm) = setup(InsightsScenarios.offline)
vm.show(Measure.HOUSEHOLD)
val state = vm.state.first { it.status == InsightsStatus.NeedsConnection }
assertNull(state.data)
}
@Test
fun goingOfflineKeepsTheLastDataUnderTheHint_andComingBackLoadsAgain() = runTest {
val (fakes, vm) = setup(InsightsScenarios.loaded)
val loaded = vm.state.first { it.status == InsightsStatus.Loaded }.data
fakes.store.edit { it.copy(online = false) }
val offline = vm.state.first { it.status == InsightsStatus.NeedsConnection }
assertEquals(loaded, offline.data)
fakes.store.edit { it.copy(online = true) }
assertEquals(loaded, vm.state.first { it.status == InsightsStatus.Loaded }.data)
}
@Test
fun aNetworkFailureKeepsTheLastData_andRetryRecovers() = runTest {
val (fakes, vm) = setup(InsightsScenarios.loaded)
val loaded = vm.state.first { it.status == InsightsStatus.Loaded }.data
fakes.store.edit { it.copy(insightsFailure = "network") }
vm.show(Measure.HOUSEHOLD)
val failed = vm.state.first { it.status is InsightsStatus.Failed }
assertEquals(InsightsStatus.Failed("network"), failed.status)
assertEquals(loaded, failed.data)
fakes.store.edit { it.copy(insightsFailure = null) }
vm.onRetryInsights()
assertEquals(InsightsStatus.Loaded, vm.state.first { it.status == InsightsStatus.Loaded }.status)
}
@Test
fun theMeasureToggleLoadsMyShare_andTheHouseholdDataIsKept() = runTest {
val (_, vm) = setup(InsightsScenarios.loaded)
val household = vm.state.first { it.status == InsightsStatus.Loaded }.data
vm.show(Measure.MY_SHARE)
val share = vm.state.first { it.measure == Measure.MY_SHARE && it.status == InsightsStatus.Loaded }
assertEquals(Measure.MY_SHARE, share.data?.trends?.measure)
vm.show(Measure.HOUSEHOLD)
val back = vm.state.first { it.measure == Measure.HOUSEHOLD }
assertEquals(household, back.data)
}
@Test
fun selectingACategoryOpensItsChart() = runTest {
val (_, vm) = setup(InsightsScenarios.loaded)
val data = requireNotNull(vm.state.first { it.status == InsightsStatus.Loaded }.data)
assertEquals(AnalyticsScenarios.housing.id, InsightsRules.selectedCategory(data.trends, null)?.categoryId)
vm.onSelectCategory(AnalyticsScenarios.travel.id)
val selected = vm.state.first { it.selectedCategory != null }.selectedCategory
assertEquals(AnalyticsScenarios.travel.id, InsightsRules.selectedCategory(data.trends, selected)?.categoryId)
}
@Test
fun rulesCombineStatusesAndLoads() {
assertEquals(OnlineResult.Offline, InsightsRules.combine(OnlineResult.Offline, OnlineResult.Failed("network", null)))
assertEquals(OnlineResult.Failed("internal", null), InsightsRules.combine(OnlineResult.Failed("internal", null), OnlineResult.Failed("network", null)))
assertEquals(InsightsStatus.NeedsConnection, InsightsRules.status(online = false, attempt = InsightsStatus.Loaded))
assertEquals(InsightsStatus.Loading, InsightsRules.status(online = true, attempt = null))
assertFalse(InsightsRules.shouldLoad(online = false, attempt = null, force = true))
assertFalse(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Loading, force = true))
assertFalse(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Loaded, force = false))
assertTrue(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Loaded, force = true))
assertTrue(InsightsRules.shouldLoad(online = true, attempt = InsightsStatus.Failed("network"), force = false))
}
@Test
fun comparisonWordsFollowTheSignOfThePercent() {
assertEquals(AverageComparison("2026-09", 53, AverageComparison.Direction.ABOVE), InsightsRules.comparison("2026-09", 53))
assertEquals(AverageComparison("2026-09", 25, AverageComparison.Direction.BELOW), InsightsRules.comparison("2026-09", -25))
assertEquals(AverageComparison.Direction.ON, InsightsRules.comparison("2026-09", 0)?.direction)
assertNull(InsightsRules.comparison("2026-09", null))
}
}
@@ -0,0 +1,142 @@
package se.hagstedt.kvitt.feature.analytics.screenshot
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.junit4.v2.createComposeRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.testing.ScreenSnapshots
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.analytics.AnalyticsScenarios
import se.hagstedt.kvitt.fakes.analytics.InsightsScenarios
import se.hagstedt.kvitt.feature.analytics.AnalyticsContent
import se.hagstedt.kvitt.feature.analytics.AnalyticsRules
import se.hagstedt.kvitt.feature.analytics.AnalyticsScreen
import se.hagstedt.kvitt.feature.analytics.AnalyticsUiState
import se.hagstedt.kvitt.feature.analytics.Insights
import se.hagstedt.kvitt.feature.analytics.InsightsEvents
import se.hagstedt.kvitt.feature.analytics.InsightsRules
import se.hagstedt.kvitt.feature.analytics.InsightsSection
import se.hagstedt.kvitt.feature.analytics.InsightsStatus
import se.hagstedt.kvitt.feature.analytics.InsightsUiState
import se.hagstedt.kvitt.feature.analytics.PeriodDialog
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.OnlineResult
/**
* S-11's Insights section (D-141) in each state, × light/dark × sv/en, plus 200 % for long content (D-098). The section
* renders in S-11's column (gutter, gap 10); `_scrolled` frames show its end (the forecast cards), `s11_insights_screen`
* the end of the whole S-11 with the section under "Per konto". Re-record with
* `tools/gradle-serial.sh :feature:analytics:recordRoborazziDebug` and open every changed PNG.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
class InsightsScreenshotTest(private val name: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
AnalyticsSnapshots.snap(compose, name, largeFont = name == "s11_insights_long", content = states.getValue(name))
}
companion object {
/** The Insights of [scenario] as the server would answer them, or null when the fake reads fail. */
private fun insights(scenario: FakeScenario, measure: Measure = Measure.HOUSEHOLD): Insights? = runBlocking {
val fakes = FakeRepositories(scenario.copy(online = true, insightsFailure = null))
(InsightsRules.combine(fakes.insights.trends(InsightsRules.MONTHS, measure), fakes.insights.forecast(measure)) as? OnlineResult.Ok)?.value
}
private fun accounts(scenario: FakeScenario): List<Account> = runBlocking { FakeRepositories(scenario).accounts.all().first() }
private fun section(scenario: FakeScenario, state: InsightsUiState, scrolled: Boolean = false): @Composable () -> Unit {
val accounts = accounts(scenario)
return { SectionFrame(state, accounts, scrolled) }
}
@Composable
private fun SectionFrame(state: InsightsUiState, accounts: List<Account>, scrolled: Boolean) {
val scroll = remember { ScrollState(if (scrolled) Int.MAX_VALUE else 0) }
Column(
Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
InsightsSection(state, accounts, NoInsightsEvents)
}
}
private fun ui(status: InsightsStatus, data: Insights?, selected: CategoryId? = null, measure: Measure = Measure.HOUSEHOLD) =
InsightsUiState(measure, status, data, selected)
private val loaded = insights(InsightsScenarios.loaded)
/** The whole S-11 (default filter) scrolled to its end, with the section loaded. */
private val screenContent: AnalyticsContent = runBlocking {
val fakes = FakeRepositories(InsightsScenarios.loaded)
AnalyticsContent.Ready(fakes.analytics.analytics(AnalyticsRules.defaultFilter).first(), fakes.categories.all().first(), fakes.accounts.all().first())
}
private val screen: @Composable () -> Unit = {
val content = screenContent
val scroll = remember { ScrollState(Int.MAX_VALUE) }
AnalyticsScreen(
AnalyticsUiState(AnalyticsRules.defaultFilter, PeriodDialog.None, content),
NoEvents,
{},
{},
scrollState = scroll,
insights = ui(InsightsStatus.Loaded, loaded),
)
}
val states: Map<String, @Composable () -> Unit> = buildMap {
put("s11_insights_loaded", section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, loaded)))
put("s11_insights_loaded_scrolled", section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, loaded), scrolled = true))
put("s11_insights_category", section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, loaded, selected = AnalyticsScenarios.travel.id), scrolled = false))
put(
"s11_insights_my_share",
section(InsightsScenarios.loaded, ui(InsightsStatus.Loaded, insights(InsightsScenarios.loaded, Measure.MY_SHARE), measure = Measure.MY_SHARE)),
)
put("s11_insights_loading", section(InsightsScenarios.loaded, ui(InsightsStatus.Loading, null)))
put("s11_insights_offline", section(InsightsScenarios.offline, ui(InsightsStatus.NeedsConnection, null)))
put("s11_insights_offline_kept", section(InsightsScenarios.offline, ui(InsightsStatus.NeedsConnection, loaded)))
put("s11_insights_unreachable", section(InsightsScenarios.unreachable, ui(InsightsStatus.Failed("network"), loaded)))
put("s11_insights_unreachable_empty", section(InsightsScenarios.unreachable, ui(InsightsStatus.Failed("network"), null)))
put("s11_insights_empty", section(InsightsScenarios.emptyHousehold, ui(InsightsStatus.Loaded, insights(InsightsScenarios.emptyHousehold))))
put("s11_insights_long", section(InsightsScenarios.long, ui(InsightsStatus.Loaded, insights(InsightsScenarios.long))))
put("s11_insights_long_scrolled", section(InsightsScenarios.long, ui(InsightsStatus.Loaded, insights(InsightsScenarios.long)), scrolled = true))
put("s11_insights_screen", screen)
}
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun names(): List<Array<Any>> = states.keys.map { arrayOf<Any>(it) }
}
}
/** Insights events that do nothing: screenshots render a fixed state. */
internal object NoInsightsEvents : InsightsEvents {
override fun onRetryInsights() = Unit
override fun onSelectCategory(id: CategoryId) = Unit
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB