diff --git a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt index 777a65f8..0bd00987 100644 --- a/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt +++ b/app/src/main/java/com/m57/hermescontrol/HermesControlApp.kt @@ -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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AnalyticsCacheStore.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AnalyticsCacheStore.kt new file mode 100644 index 00000000..ce77eef3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AnalyticsCacheStore.kt @@ -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 = emptyMap(), +) + +private object AnalyticsCacheSerializer : Serializer { + 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 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? = + withContext(Dispatchers.IO) { + try { + val win = + context.analyticsCacheDataStore.data + .first() + .windows[key(days, profile)] ?: return@withContext null + val usage = OkHttpProvider.json.decodeFromString(win.usageJson) + val models = + win.modelsJson + .takeIf { it.isNotBlank() } + ?.let { OkHttpProvider.json.decodeFromString(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. + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsPreloader.kt b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsPreloader.kt new file mode 100644 index 00000000..304c0d11 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsPreloader.kt @@ -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 { + ApiClient.hermesApi.getAnalytics(days, null) + } + val models = + safeApiCall { + 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) + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt index 8a4ce327..6fe43a0f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt @@ -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 { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsViewModel.kt index 84f0e942..3210ad4b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsViewModel.kt @@ -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 = _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() + 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 { 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 { 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 { 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, ) } diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index a5ee5a46..17f921c7 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -737,6 +737,7 @@ 캐시 읽기 추론 일일 예상 비용 + 사용 분석 불러오는 중… 이 기간에 기록된 비용이 없습니다. %1$d회 사용 • 전체의 %2$d%% %1$d회 호출 • 전체의 %2$d%% diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6d7b4791..cc99cfd9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -881,6 +881,7 @@ Cache read Reasoning Daily estimated cost + Loading usage analytics… No cost recorded in this period. %1$d uses \u2022 %2$d%% of total %1$d calls \u2022 %2$d%% of total