From 58e3a0fcd7acb43db183f7c55541cab1a08499fd Mon Sep 17 00:00:00 2001 From: Amir Khodak Date: Fri, 12 Jun 2026 01:48:04 +0000 Subject: [PATCH] v1.19: fix empty usage-history chart (always fetch utilization) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recordHistory() only ever stored fiveHourUtilization/weeklyUtilization, but fetchUsage() returned early with message-count data — before calling the /usage utilization endpoint — whenever the org JSON carried a message limit. So utilization was never populated and the history chart stayed stuck on 'Collecting history…'. The prior chart fix only corrected the throttle. - UsageRepository: always attempt /usage (preferred signal that drives the weekly bar + history); fall back to org message-count data only when utilization is unavailable. - UsageData.sessionReadingPct: utilization preferred, message-count % fallback, -1f when no reading — so message-only accounts also build history. - PreferencesManager.recordHistory: record the session line from sessionReadingPct instead of utilization-only. - UsageDataTest: cover sessionReadingPct. --- app/build.gradle.kts | 4 +-- .../me/khodak/claudeusage/UsageRepository.kt | 30 ++++++++++++------- .../claudeusage/data/PreferencesManager.kt | 9 ++++-- .../me/khodak/claudeusage/data/UsageData.kt | 13 ++++++++ .../me/khodak/claudeusage/UsageDataTest.kt | 26 ++++++++++++++++ 5 files changed, 67 insertions(+), 15 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c8449c..e03353e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "me.khodak.claudeusage" minSdk = 26 targetSdk = 34 - versionCode = 19 - versionName = "1.18" + versionCode = 20 + versionName = "1.19" } val releaseStorePassword = signingCred("KEYSTORE_PASSWORD", "storePassword") diff --git a/app/src/main/java/me/khodak/claudeusage/UsageRepository.kt b/app/src/main/java/me/khodak/claudeusage/UsageRepository.kt index 6282f18..391aa2e 100644 --- a/app/src/main/java/me/khodak/claudeusage/UsageRepository.kt +++ b/app/src/main/java/me/khodak/claudeusage/UsageRepository.kt @@ -54,17 +54,15 @@ class UsageRepository(private val prefs: PreferencesManager) { if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai") - if (orgUsageData?.hasRateLimitData == true) { - prefs.resetAuthFailCount() - return@withContext base.copy( - messagesUsed = orgUsageData.messagesUsed, - messagesLimit = orgUsageData.messagesLimit, - messagesRemaining = orgUsageData.messagesRemaining, - resetAtEpoch = orgUsageData.resetAtEpoch - ) - } + // NOTE: message-count data from the org endpoint (orgUsageData) is a FALLBACK only — do + // NOT return here. The /usage utilization endpoint below is the preferred signal and the + // one the weekly bar + in-app history chart depend on, so it must be attempted whenever we + // have an org id. Returning early here was why the history chart stayed empty: any account + // whose org JSON carried a message limit short-circuited before /usage, so + // fiveHourUtilization/weeklyUtilization never got populated and recordHistory() had nothing + // to store. The org message data is used only if /usage yields nothing (fallback below). - // Step 2: /usage endpoint — returns utilization percentages + // Step 2: /usage endpoint — returns utilization percentages (preferred signal) try { val usageUrl = "https://claude.ai/api/organizations/$orgId/usage" val resp = client.newCall(buildRequest(usageUrl, cookies)).execute() @@ -90,6 +88,18 @@ class UsageRepository(private val prefs: PreferencesManager) { if (BuildConfig.DEBUG) Log.w(TAG, "/usage failed: ${e.message}") } + // /usage gave no utilization → fall back to the message-count data the org endpoint + // already returned (this is the early-return that used to live before Step 2). + if (orgUsageData?.hasRateLimitData == true) { + prefs.resetAuthFailCount() + return@withContext base.copy( + messagesUsed = orgUsageData.messagesUsed, + messagesLimit = orgUsageData.messagesLimit, + messagesRemaining = orgUsageData.messagesRemaining, + resetAtEpoch = orgUsageData.resetAtEpoch + ) + } + // Step 3: fallback endpoints (message-count style) val endpoints = listOf( "https://claude.ai/api/organizations/$orgId/rate_limit_status", diff --git a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt index bb44fcc..c851271 100644 --- a/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt +++ b/app/src/main/java/me/khodak/claudeusage/data/PreferencesManager.kt @@ -94,13 +94,16 @@ class PreferencesManager(context: Context) { // ── Usage history (for the in-app chart) ───────────────────────────────── /** - * Append a history point if [data] carries a real utilization reading. + * Append a history point if [data] carries a real reading. + * The session line uses [UsageData.sessionReadingPct] (utilization preferred, message-count + * progress as fallback) so accounts that only expose message counts still build history. * De-duplicates rapid double-fires (manual refresh + background worker landing * together) by skipping points within [MIN_HISTORY_GAP_MS] of the last one, and * prunes anything older than [HISTORY_RETENTION_MS] / beyond [MAX_HISTORY_POINTS]. */ fun recordHistory(data: UsageData) { - if (data.fiveHourUtilization < 0f && data.weeklyUtilization < 0f) return + val sessionPct = data.sessionReadingPct + if (sessionPct < 0f && data.weeklyUtilization < 0f) return val now = System.currentTimeMillis() val history = getHistory().toMutableList() // Throttle to at most one point per MIN_HISTORY_GAP_MS by SKIPPING a too-soon reading. @@ -111,7 +114,7 @@ class PreferencesManager(context: Context) { history.add( UsageSnapshot( epochMs = now, - sessionPct = data.fiveHourUtilization, + sessionPct = sessionPct, weeklyPct = data.weeklyUtilization ) ) diff --git a/app/src/main/java/me/khodak/claudeusage/data/UsageData.kt b/app/src/main/java/me/khodak/claudeusage/data/UsageData.kt index 26b1f79..a0fcff9 100644 --- a/app/src/main/java/me/khodak/claudeusage/data/UsageData.kt +++ b/app/src/main/java/me/khodak/claudeusage/data/UsageData.kt @@ -43,6 +43,19 @@ data class UsageData( val weeklyActiveDays: Int get() = Integer.bitCount(weeklyActiveDaysMask) + /** + * Session utilization as a 0-100 reading for the history chart, preferring the /usage + * utilization endpoint and falling back to message-count progress. Returns -1f when there is + * no usable reading — so message-only accounts (no utilization endpoint) still build history + * instead of leaving the chart permanently on "Collecting history…". + */ + val sessionReadingPct: Float get() = when { + fiveHourUtilization >= 0f -> fiveHourUtilization + messagesLimit > 0 && effectiveUsed >= 0 -> + ((effectiveUsed.toFloat() / messagesLimit.toFloat()) * 100f).coerceIn(0f, 100f) + else -> -1f + } + // The best reset time we have (utilization endpoint is preferred) val effectiveResetEpoch: Long get() = when { utilizationResetAtEpoch > 0 -> utilizationResetAtEpoch diff --git a/app/src/test/java/me/khodak/claudeusage/UsageDataTest.kt b/app/src/test/java/me/khodak/claudeusage/UsageDataTest.kt index b745276..2719b2e 100644 --- a/app/src/test/java/me/khodak/claudeusage/UsageDataTest.kt +++ b/app/src/test/java/me/khodak/claudeusage/UsageDataTest.kt @@ -95,6 +95,32 @@ class UsageDataTest { assertFalse(UsageData().hasAnyReading) } + @Test + fun sessionReadingPctPrefersUtilization() { + // Utilization present → used verbatim (kept as Float, not truncated), even alongside messages. + val d = UsageData(fiveHourUtilization = 42.5f, messagesUsed = 10, messagesLimit = 100) + assertEquals(42.5f, d.sessionReadingPct, 0f) + } + + @Test + fun sessionReadingPctFallsBackToMessages() { + // No utilization → derive from message counts so message-only accounts still get history. + val d = UsageData(messagesUsed = 30, messagesLimit = 120) + assertEquals(25f, d.sessionReadingPct, 0f) + } + + @Test + fun sessionReadingPctMessagesCoercedTo100() { + val d = UsageData(messagesUsed = 150, messagesLimit = 100) + assertEquals(100f, d.sessionReadingPct, 0f) + } + + @Test + fun sessionReadingPctNegativeWhenNoReading() { + // No utilization and no message data → -1f, so recordHistory skips the point. + assertEquals(-1f, UsageData().sessionReadingPct, 0f) + } + @Test fun mergedWithEmptyFetchKeepsPreviousMetrics() { // (a) previous has a reading; this fetch is empty (all defaults) → previous metrics kept.