v1.19: fix empty usage-history chart (always fetch utilization)
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:
@@ -24,8 +24,8 @@ android {
|
|||||||
applicationId = "me.khodak.claudeusage"
|
applicationId = "me.khodak.claudeusage"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 19
|
versionCode = 20
|
||||||
versionName = "1.18"
|
versionName = "1.19"
|
||||||
}
|
}
|
||||||
|
|
||||||
val releaseStorePassword = signingCred("KEYSTORE_PASSWORD", "storePassword")
|
val releaseStorePassword = signingCred("KEYSTORE_PASSWORD", "storePassword")
|
||||||
|
|||||||
@@ -54,17 +54,15 @@ class UsageRepository(private val prefs: PreferencesManager) {
|
|||||||
|
|
||||||
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
|
if (orgId == null) return@withContext base.copy(errorMessage = "Can't reach claude.ai")
|
||||||
|
|
||||||
if (orgUsageData?.hasRateLimitData == true) {
|
// NOTE: message-count data from the org endpoint (orgUsageData) is a FALLBACK only — do
|
||||||
prefs.resetAuthFailCount()
|
// NOT return here. The /usage utilization endpoint below is the preferred signal and the
|
||||||
return@withContext base.copy(
|
// one the weekly bar + in-app history chart depend on, so it must be attempted whenever we
|
||||||
messagesUsed = orgUsageData.messagesUsed,
|
// have an org id. Returning early here was why the history chart stayed empty: any account
|
||||||
messagesLimit = orgUsageData.messagesLimit,
|
// whose org JSON carried a message limit short-circuited before /usage, so
|
||||||
messagesRemaining = orgUsageData.messagesRemaining,
|
// fiveHourUtilization/weeklyUtilization never got populated and recordHistory() had nothing
|
||||||
resetAtEpoch = orgUsageData.resetAtEpoch
|
// 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 {
|
try {
|
||||||
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
|
val usageUrl = "https://claude.ai/api/organizations/$orgId/usage"
|
||||||
val resp = client.newCall(buildRequest(usageUrl, cookies)).execute()
|
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}")
|
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)
|
// Step 3: fallback endpoints (message-count style)
|
||||||
val endpoints = listOf(
|
val endpoints = listOf(
|
||||||
"https://claude.ai/api/organizations/$orgId/rate_limit_status",
|
"https://claude.ai/api/organizations/$orgId/rate_limit_status",
|
||||||
|
|||||||
@@ -94,13 +94,16 @@ class PreferencesManager(context: Context) {
|
|||||||
// ── Usage history (for the in-app chart) ─────────────────────────────────
|
// ── 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
|
* De-duplicates rapid double-fires (manual refresh + background worker landing
|
||||||
* together) by skipping points within [MIN_HISTORY_GAP_MS] of the last one, and
|
* 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].
|
* prunes anything older than [HISTORY_RETENTION_MS] / beyond [MAX_HISTORY_POINTS].
|
||||||
*/
|
*/
|
||||||
fun recordHistory(data: UsageData) {
|
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 now = System.currentTimeMillis()
|
||||||
val history = getHistory().toMutableList()
|
val history = getHistory().toMutableList()
|
||||||
// Throttle to at most one point per MIN_HISTORY_GAP_MS by SKIPPING a too-soon reading.
|
// 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(
|
history.add(
|
||||||
UsageSnapshot(
|
UsageSnapshot(
|
||||||
epochMs = now,
|
epochMs = now,
|
||||||
sessionPct = data.fiveHourUtilization,
|
sessionPct = sessionPct,
|
||||||
weeklyPct = data.weeklyUtilization
|
weeklyPct = data.weeklyUtilization
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ data class UsageData(
|
|||||||
|
|
||||||
val weeklyActiveDays: Int get() = Integer.bitCount(weeklyActiveDaysMask)
|
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)
|
// The best reset time we have (utilization endpoint is preferred)
|
||||||
val effectiveResetEpoch: Long get() = when {
|
val effectiveResetEpoch: Long get() = when {
|
||||||
utilizationResetAtEpoch > 0 -> utilizationResetAtEpoch
|
utilizationResetAtEpoch > 0 -> utilizationResetAtEpoch
|
||||||
|
|||||||
@@ -95,6 +95,32 @@ class UsageDataTest {
|
|||||||
assertFalse(UsageData().hasAnyReading)
|
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
|
@Test
|
||||||
fun mergedWithEmptyFetchKeepsPreviousMetrics() {
|
fun mergedWithEmptyFetchKeepsPreviousMetrics() {
|
||||||
// (a) previous has a reading; this fetch is empty (all defaults) → previous metrics kept.
|
// (a) previous has a reading; this fetch is empty (all defaults) → previous metrics kept.
|
||||||
|
|||||||
Reference in New Issue
Block a user