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,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())
}
}