v1.19: fix empty usage-history chart (always fetch utilization)
Build APK / build (push) Successful in 1m52s

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.
This commit is contained in:
2026-06-12 01:48:04 +00:00
parent 31e18ed5e9
commit 58e3a0fcd7
5 changed files with 67 additions and 15 deletions
@@ -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",
@@ -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
)
)
@@ -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
@@ -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.