* 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:
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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user