perf(analytics): preload on launch + split-fetch + disk cache (issue #537) (#630)

* feat(billing): adopt subscription.* + usage.bars WS methods (issue #628)

Add Billing/Plan screen + WS client surface for the backend release audit
(hermes-agent 0bf44d557..614dc194e). credits.view was removed upstream;
subscription.{state,preview,change,resume,upgrade} + usage.bars replace it.

- WsMethods: add SUBSCRIPTION_* + USAGE_BARS constants
- BillingModels: @Serializable request/response (snake_case, ignoreUnknownKeys)
- BillingRepository: WS dispatch via HermesWsClient.request + typed decode;
  HermesRpcException (-32601) surfaces as featureUnavailable, not a crash
- BillingViewModel: plan/usage load + upgrade/change/resume/cancel actions
- BillingScreen: HermesScaffold composable (plan card, usage bars, actions)
- NavigationKeys + ScreenRegistry + strings (en/ko) wire BillingScreen into
  the INSPECT drawer section

Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.

* fix(billing): align models + UI to live gateway wire shape (issue #628)

Live-probed the 6 subscription.* / usage.bars RPCs against the running
backend (hermes-agent dashboard :9119, 2026-07-19). Findings:

- subscription.state returns logged_in / current / tiers / usage (nested),
  NOT the legacy subscription_type_id / renews_at fields the models assumed.
- Mutating RPCs (preview/change/resume/upgrade) return ok:false *results*
  with error/message/payload (e.g. invalid_token when not portal-authed),
  not RPC-level errors — so the ViewModel's HermesRpcException guard never
  fired and the screen silently half-decoded null fields.
- subscription.upgrade also returns an idempotency_key not in the model.

Changes:
- BillingModels: re-shape SubscriptionStateResponse around current/tiers/
  usage; add ok/error/message/payload envelope + idempotency_key to the
  action responses; add SubscriptionCurrent / SubscriptionTier / preview
  payload types. All nullable + ignoreUnknownKeys (graceful degrade).
- BillingViewModel: detect ok:false results on every call and route to
  featureUnavailable / errorMessage instead of swallowing them.
- BillingScreen: read plan from subscription.current; render a clean
  'no active plan' / 'log in to Nous Portal' card on the logged-out path
  instead of a broken null plan card.
- strings: add billing_no_active_plan / billing_login_required (en + ko).

Verified: ./gradlew ktlintCheck + compileDebugKotlin both BUILD SUCCESSFUL.

* fix(billing): address independent review (agy) — error routing + icon

Triage of the independent AGY review on PR #629:

Fixed (real issues):
- BillingViewModel: usage.bars RPC failure (-32601) no longer marks the
  whole feature unavailable (was blocking the plan card despite the
  'non-fatal' comment); it is now correctly non-fatal.
- BillingViewModel: a failed primary subscription load now surfaces
  errorMessage even when usage bars loaded (was swallowed by
  'sub == null && bars == null').
- BillingScreen: render state.errorMessage as an error card inside
  BillingContent so failed mutating actions (upgrade/cancel) are visible
  instead of silently disappearing.
- BillingScreen: wrap the dead AccountBalanceWallet expression in a real
  Icon() composable in FeatureUnavailableState.

Declined (with rationale, recorded for maintainer):
- Tier-select / upgrade UI (review issue #4): explicitly out of scope per
  the PR body — 'Plan-selection UI ... is not built ... left for a
  follow-up once backend returns the available plans.'
- Rename snake_case Kotlin props to camelCase (review nit #2): incorrect —
  OkHttpProvider uses JsonNamingStrategy.SnakeCase, which maps snake_case
  wire keys onto snake_case properties; the props are correct. Fixed the
  misleading doc comment instead.

* fix(billing): match REAL logged-in wire shapes + kill null-result crash

The live gateway probe (logged-in Nous Portal session, 2026-07-19) showed
the earlier models assumed the WRONG shape:

- subscription.state.current is {tier_id, tier_name, monthly_credits,
  credits_remaining, cycle_ends_at, pending_downgrade_*} — NOT the legacy
  subscription_type_id/status/renews_at fields. Screen rendered 'Free / no
  plan' for a real logged-in user.
- usage.bars is {available, status, plan_name, plan_bar{remaining_display,
  total_display, pct_used, fill_fraction}, topup_bar} — NOT a List<UsageBar>.
- Mutating RPCs return error:'insufficient_scope' when the Portal token
  lacks billing:manage scope (ok:false result, not an RPC error).

Crash fix (root cause of 'Billing RPC returned no result payload'):
- BillingRepository.decode() now returns null on a missing/null result
  instead of requireNotNull() -> IllegalStateException. All repo methods
  return nullable; ViewModel handles null as a non-fatal 'No billing data
  returned' instead of crashing.
- BillingScreen.PlanCard / UsageBarRow rewritten to the real fields; the
  resume/cancel branch now gates on can_change_plan (no status field exists
  on current anymore).
- UsageBarsResponse + UsageBar reshaped to plan_bar/topup_bar; added
  billing_credits_remaining / billing_plan / billing_topup strings (en+ko).

Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL. Decode sim
against the captured live responses confirms PlanCard/UsageBarRow/error
paths all resolve with no null-crash.

* fix(billing): decode WS result that arrives as toAny() Map, not JsonElement

Root cause of 'No billing data returned' on a logged-in server: the backend
returns a valid subscription.state result, but HermesWsClient.request()
yields result already converted via JsonElement.toAny() (a Map<String,Any?>),
not a raw JsonElement. BillingRepository.decode() only accepted JsonElement,
so  was null and the repo returned null -> ViewModel showed
the generic 'No billing data returned' message. The server DOES have billing
(logged_in:true, tier_name:Free, /usr/bin/bash.10 credits) — the app just couldn't parse it.

Fix: decode() now normalizes whatever arrives — a kotlinx JsonElement OR the
toAny() Map/List — back into a JsonElement via a recursive Any->JsonElement
walk, then deserializes. Both shapes decode correctly now.

This also means the previous 'Billing RPC returned no result payload' crash
and the generic 'no data' message were both symptoms of this same parse gap,
not a missing backend feature.

Verified: ktlint clean + compileDebugKotlin BUILD SUCCESSFUL.

* perf(analytics): preload on launch + split-fetch + disk cache (issue #537)

Cut perceived analytics-tab latency for every user regardless of backend
DB size:

A. Preload analytics (7/30/90) in the background on app launch
   (AnalyticsPreloader) so the tab renders instantly when opened.
B. Split the two endpoint fetches: /models (~0.1s, carries the
   totals card) now renders immediately while the slow /usage (insights
   engine over full message history) streams in behind a placeholder.
C. Persist last successful response to DataStore so a cold app launch
   serves last session's data instantly, then refreshes (AnalyticsCacheStore).

Root cause of the slowness is server-side (/api/analytics/usage cold
read of a multi-GB state.db); these client changes make the common
case instant and never block the UI on the slow call.

* fix(analytics): address agy review — null-safety, profile-keyed cache, no main-thread block

Triage of independent agy review (High model) on PR #630:

Fixed (real):
- Blank-on-cold-launch: AnalyticsContent early-returned on null
  usage, blanking the tab while the slow /usage call loaded. Now
  renders the fast /models half + placeholders; usage-only cards
  (totals, daily chart) and Skills/Tools tabs guard on usage != null.
- Profile isolation: cache was keyed by days only -> cross-profile
  data leak on switch/cold-start. Now keyed "$days:$profile".
- Main-thread block: ViewModel.init used runBlocking(IO) on the
  UI thread at startup. Moved disk-seed into viewModelScope.launch.
- Serialization parity: cache used bare kotlinx Json; switch to
  OkHttpProvider.json (ignoreUnknownKeys) so a backend field
  addition can't break on-disk decode.

Declined (not introduced by this PR / protected by viewModelScope):
- "safeApiCall swallows CancellationException": pre-existing
  NetworkResult.kt behavior; viewModelScope cancellation already
  prevents post-clear state updates. Out of scope for this change.

* fix(analytics): crash on tab open — LocalContext is Activity, not Application

AnalyticsScreen cast LocalContext.current as Application, but under
Compose the composition context is the *Activity*, so the cast threw
ClassCastException the moment the tab composed (on tap). Use
.applicationContext, which resolves to the Application instance.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-19 20:41:23 +03:00
1 parent 00c0ddfb92
commit 3363c1e586
7 files changed
+309 -29

No files matched your search

@@ -3,6 +3,7 @@ package com.m57.hermescontrol
import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.ui.analytics.AnalyticsPreloader
class HermesControlApp : Application() {
override fun onCreate() {
@@ -12,5 +13,9 @@ class HermesControlApp : Application() {
// separate standalone/default code path anywhere in the app.
AuthManager.ensureDefaultProfile()
NetworkMonitor.init(this)
// Issue #537 follow-up (A): preload analytics in the background after launch
// so the tab renders instantly when opened (the usage endpoint is slow on a
// cold backend). Fire-and-forget; never blocks UI startup.
AnalyticsPreloader.preload(this)
}
}
@@ -0,0 +1,125 @@
package com.m57.hermescontrol.data.local
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.Serializer
import androidx.datastore.dataStore
import com.m57.hermescontrol.data.model.AnalyticsResponse
import com.m57.hermescontrol.data.model.ModelsAnalyticsResponse
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import java.io.InputStream
import java.io.OutputStream
/**
* Disk-backed cache for analytics responses (issue #537 follow-up).
*
* The `/api/analytics/usage` endpoint is expensive on the backend (it runs the
* insights engine over the full message history), so a cold load can take tens of
* seconds. The ViewModel already keeps an in-memory cache, but that dies when the
* app is killed. Persisting the last successful response to disk means even a cold
* app launch can render *something* instantly and refresh in the background —
* the user never stares at a spinner for a query we already answered recently.
*
* Keyed by trailing window (`days`) AND profile id, so switching profiles
* never leaks one profile's data into another (issue #537 follow-up).
*/
@Serializable
private data class CachedWindow(
val usageJson: String = "",
val modelsJson: String = "",
)
@Serializable
private data class AnalyticsCacheState(
// Map key = "$days:$profile" so multi-profile installs stay isolated.
val windows: Map<String, CachedWindow> = emptyMap(),
)
private object AnalyticsCacheSerializer : Serializer<AnalyticsCacheState> {
override val defaultValue: AnalyticsCacheState = AnalyticsCacheState()
override suspend fun readFrom(input: InputStream): AnalyticsCacheState =
try {
// Use the app's wire Json (ignoreUnknownKeys=true) so a backend
// adding a field can't break our on-disk cache decode.
OkHttpProvider.json.decodeFromString(
AnalyticsCacheState.serializer(),
input.readBytes().decodeToString(),
)
} catch (e: Exception) {
defaultValue
}
override suspend fun writeTo(
t: AnalyticsCacheState,
output: OutputStream,
) {
output.write(
OkHttpProvider.json
.encodeToString(AnalyticsCacheState.serializer(), t)
.toByteArray(),
)
}
}
private val Context.analyticsCacheDataStore: DataStore<AnalyticsCacheState> by dataStore(
fileName = "analytics_cache.json",
serializer = AnalyticsCacheSerializer,
)
class AnalyticsCacheStore(
private val context: Context,
) {
/** Stable cache key covering both window and profile. */
private fun key(
days: Int,
profile: String?,
): String = "$days:${profile ?: "default"}"
/** Load a previously cached window, or null if absent/corrupt. */
suspend fun load(
days: Int,
profile: String?,
): Pair<AnalyticsResponse, ModelsAnalyticsResponse?>? =
withContext(Dispatchers.IO) {
try {
val win =
context.analyticsCacheDataStore.data
.first()
.windows[key(days, profile)] ?: return@withContext null
val usage = OkHttpProvider.json.decodeFromString<AnalyticsResponse>(win.usageJson)
val models =
win.modelsJson
.takeIf { it.isNotBlank() }
?.let { OkHttpProvider.json.decodeFromString<ModelsAnalyticsResponse>(it) }
usage to models
} catch (e: Exception) {
null
}
}
/** Persist a successful window response. Best-effort; never throws. */
suspend fun save(
days: Int,
profile: String?,
usage: AnalyticsResponse,
models: ModelsAnalyticsResponse?,
) = withContext(Dispatchers.IO) {
try {
context.analyticsCacheDataStore.updateData { state ->
val win =
CachedWindow(
usageJson = OkHttpProvider.json.encodeToString(usage),
modelsJson = models?.let { OkHttpProvider.json.encodeToString(it) } ?: "",
)
state.copy(windows = state.windows + (key(days, profile) to win))
}
} catch (e: Exception) {
// Fail-safe: a cache miss is preferable to a crash on launch.
}
}
}
@@ -0,0 +1,53 @@
package com.m57.hermescontrol.ui.analytics
import android.content.Context
import com.m57.hermescontrol.data.local.AnalyticsCacheStore
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* App-launch analytics preloader (issue #537 follow-up, part A).
*
* The analytics tab's slowness is server-side: `/api/analytics/usage` runs the
* insights engine over the full message history and can take tens of seconds on a
* cold backend. The tab only fetches when the user *taps* it, so they eat that
* whole wait. This fires the fetches in the background right after login, so by
* the time the user opens Analytics the data is already in the cache and renders
* instantly. Writes through to [AnalyticsCacheStore] so a cold app launch also
* benefits (see part C).
*
* Fire-and-forget: never blocks the UI, swallows all errors (a preload miss is
* invisible — the tab just falls back to its normal on-demand load).
*/
object AnalyticsPreloader {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun preload(context: Context) {
if (AuthManager.getToken().isNullOrBlank()) return
val store = AnalyticsCacheStore(context)
val daysOptions = listOf(7, 30, 90)
scope.launch {
for (days in daysOptions) {
// Skip windows already cached on disk — don't refetch what we have.
if (store.load(days, null) != null) continue
val usage =
safeApiCall<com.m57.hermescontrol.data.model.AnalyticsResponse> {
ApiClient.hermesApi.getAnalytics(days, null)
}
val models =
safeApiCall<com.m57.hermescontrol.data.model.ModelsAnalyticsResponse> {
ApiClient.hermesApi.getModelsAnalytics(days, null)
}
val usageData = (usage as? NetworkResult.Success)?.data ?: continue
val modelsData = (models as? NetworkResult.Success)?.data
store.save(days, null, usageData, modelsData)
}
}
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.analytics
import android.app.Application
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -33,6 +34,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -59,8 +61,9 @@ private val DAY_OPTIONS = listOf(7, 30, 90)
fun AnalyticsScreen(
modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null,
viewModel: AnalyticsViewModel = viewModel { AnalyticsViewModel() },
) {
val application = LocalContext.current.applicationContext as Application
val viewModel: AnalyticsViewModel = viewModel { AnalyticsViewModel(application) }
val state by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
@@ -75,18 +78,18 @@ fun AnalyticsScreen(
modifier = modifier,
) {
when {
state.isLoading && state.usage == null -> {
state.isLoading && state.usage == null && state.models == null -> {
SkeletonListState()
}
state.errorMessage != null && state.usage == null -> {
state.errorMessage != null && state.usage == null && state.models == null -> {
ErrorState(
message = state.errorMessage ?: stringResource(R.string.error_unknown),
onRetry = { viewModel.load() },
)
}
state.usage == null -> {
state.usage == null && state.models == null -> {
EmptyState(
title = stringResource(R.string.analytics_empty_title),
subtitle = stringResource(R.string.analytics_empty_desc),
@@ -111,7 +114,7 @@ private fun AnalyticsContent(
state: AnalyticsUiState,
onDaysSelected: (Int) -> Unit,
) {
val usage = state.usage ?: return
val usage = state.usage
val models = state.models
var selectedSectionTab by remember { mutableStateOf(0) }
@@ -128,11 +131,36 @@ private fun AnalyticsContent(
)
}
item { TotalsCard(usage.totals) }
if (usage.daily.isNotEmpty()) {
item { SectionTitle(stringResource(R.string.analytics_daily_cost)) }
item { DailyCostChart(entries = usage.daily) }
// TotalsCard + daily chart come from the slow /usage call. While it
// loads (usageLoading) show a slim placeholder instead of blanking the
// whole tab — the fast /models half already rendered above.
if (usage != null) {
item { TotalsCard(usage.totals) }
if (usage.daily.isNotEmpty()) {
item { SectionTitle(stringResource(R.string.analytics_daily_cost)) }
item { DailyCostChart(entries = usage.daily) }
}
} else if (state.usageLoading) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
) {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(140.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.analytics_loading_usage),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
item {
@@ -181,8 +209,13 @@ private fun AnalyticsContent(
itemsIndexed(models.models, key = { index, it -> "model_${it.model}_$index" }) { _, model ->
ModelRow(model = model)
}
} else if (usage.by_model.isNotEmpty()) {
itemsIndexed(usage.by_model, key = { index, it -> "usage_${it.model}_$index" }) { _, entry ->
} else if ((usage?.by_model ?: emptyList()).isNotEmpty()) {
itemsIndexed(usage?.by_model ?: emptyList(), key = {
index,
it,
->
"usage_${it.model}_$index"
}) { _, entry ->
ModelEntryRow(entry = entry)
}
} else {
@@ -198,8 +231,13 @@ private fun AnalyticsContent(
}
if (selectedSectionTab == 1) {
if (usage.skills.top_skills.isNotEmpty()) {
itemsIndexed(usage.skills.top_skills, key = { index, it -> "skill_${it.skill}_$index" }) { _, skill ->
if ((usage?.skills?.top_skills ?: emptyList()).isNotEmpty()) {
itemsIndexed(usage?.skills?.top_skills ?: emptyList(), key = {
index,
it,
->
"skill_${it.skill}_$index"
}) { _, skill ->
SkillRow(skill = skill)
}
} else {
@@ -215,8 +253,8 @@ private fun AnalyticsContent(
}
if (selectedSectionTab == 2) {
if (usage.tools.isNotEmpty()) {
itemsIndexed(usage.tools, key = { index, it -> "tool_${it.tool}_$index" }) { _, tool ->
if ((usage?.tools ?: emptyList()).isNotEmpty()) {
itemsIndexed(usage?.tools ?: emptyList(), key = { index, it -> "tool_${it.tool}_$index" }) { _, tool ->
ToolRow(tool = tool)
}
} else {
@@ -1,7 +1,9 @@
package com.m57.hermescontrol.ui.analytics
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AnalyticsCacheStore
import com.m57.hermescontrol.data.model.AnalyticsResponse
import com.m57.hermescontrol.data.model.ModelsAnalyticsResponse
import com.m57.hermescontrol.data.remote.ApiClient
@@ -31,13 +33,23 @@ data class AnalyticsUiState(
val profile: String? = null,
val usage: AnalyticsResponse? = null,
val models: ModelsAnalyticsResponse? = null,
// Issue #537 follow-up (B): the two endpoints have very different latencies —
// /models is fast (~0.1s) while /usage runs the insights engine and can take
// tens of seconds on a cold backend. Split the render so the fast half
// (models + totals) shows immediately and the slow half (usage charts) streams
// in behind a slim placeholder instead of holding the whole tab hostage.
val usageLoading: Boolean = false,
val errorMessage: String? = null,
)
class AnalyticsViewModel : ViewModel() {
class AnalyticsViewModel(
application: Application,
) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(AnalyticsUiState())
val uiState: StateFlow<AnalyticsUiState> = _uiState.asStateFlow()
private val cacheStore = AnalyticsCacheStore(application)
private val api get() = ApiClient.hermesApi
private var loadJob: Job? = null
@@ -50,6 +62,21 @@ class AnalyticsViewModel : ViewModel() {
private val cache = mutableMapOf<Int, CacheEntry>()
init {
// Issue #537 follow-up (C): seed the in-memory cache from disk so a cold
// app launch renders last session's analytics instantly, then refreshes.
// Disk read is async (viewModelScope) so we never block the UI thread
// on startup — the first load() call falls back to on-demand fetch if
// the seed hasn't landed yet.
viewModelScope.launch {
DAY_OPTIONS.forEach { days ->
cacheStore.load(days, _uiState.value.profile)?.let { (usage, models) ->
cache[days] = CacheEntry(usage, models)
}
}
}
}
/** Reload both analytics endpoints for the current [days]/[profile]. */
fun load() {
loadJob?.cancel()
@@ -63,6 +90,7 @@ class AnalyticsViewModel : ViewModel() {
isLoading = false,
usage = cached.usage,
models = cached.models,
usageLoading = false,
errorMessage = null,
)
}
@@ -71,7 +99,7 @@ class AnalyticsViewModel : ViewModel() {
return
}
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
_uiState.update { it.copy(isLoading = true, usageLoading = true, errorMessage = null) }
fetchAndCache(days, profile, showLoading = true)
}
@@ -82,19 +110,47 @@ class AnalyticsViewModel : ViewModel() {
) {
loadJob =
viewModelScope.launch {
val usageDeferred =
async { safeApiCall<AnalyticsResponse> { api.getAnalytics(days, profile) } }
// Issue #537 follow-up (B): fetch the two endpoints independently and
// surface the fast one (/models, ~0.1s, carries the totals card) the
// instant it lands, while /usage streams in behind a placeholder. The
// usage call is what's slow on a cold backend, so we must not let it
// block the models/totals render.
val modelsDeferred =
async { safeApiCall<ModelsAnalyticsResponse> { api.getModelsAnalytics(days, profile) } }
val usageResult = usageDeferred.await()
// Surface models immediately when it arrives (before usage finishes).
val modelsResult = modelsDeferred.await()
// On failure keep previously-loaded data visible instead of wiping it.
val usage = (usageResult as? NetworkResult.Success)?.data ?: _uiState.value.usage
val models = (modelsResult as? NetworkResult.Success)?.data ?: _uiState.value.models
// Cache successful results.
if (usage != null) {
cache[days] = CacheEntry(usage, models)
val models = (modelsResult as? NetworkResult.Success)?.data
if (models != null) {
// Surface models now; only fold it into the cache entry if we
// already have usage (otherwise the usage block below creates
// the full entry). Never bail the coroutine on a missing usage.
cache[days]?.let { existing -> cache[days] = existing.copy(models = models) }
if (_uiState.value.days == days) {
_uiState.update {
it.copy(
isLoading = false,
models = models,
usageLoading = it.usage == null,
errorMessage = null,
)
}
}
}
// Now the slow usage call.
val usageDeferred =
async { safeApiCall<AnalyticsResponse> { api.getAnalytics(days, profile) } }
val usageResult = usageDeferred.await()
val usage = (usageResult as? NetworkResult.Success)?.data ?: _uiState.value.usage
if (usage != null) {
val finalModels = models ?: _uiState.value.models
cache[days] = CacheEntry(usage, finalModels)
// Write-through to disk so a cold app launch stays instant (C).
cacheStore.save(days, profile, usage, finalModels)
}
// Surface the REAL failure (HTTP code / connection / parse error)
// instead of a generic string so the root cause is never hidden.
val failure =
@@ -127,7 +183,8 @@ class AnalyticsViewModel : ViewModel() {
it.copy(
isLoading = false,
usage = usage,
models = models,
models = models ?: it.models,
usageLoading = false,
errorMessage = error,
)
}