From ced73f420d44dafbef4dd92821c7a59132233d3f Mon Sep 17 00:00:00 2001 From: Jamie Stewart Date: Thu, 19 Mar 2026 12:16:50 +0000 Subject: [PATCH] Enterprise plan display: show spend vs limit as primary metric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On enterprise plans, session/weekly utilisation rows are always 0% and the overage (dollar spend) row is the only meaningful signal. This change makes that the default view for enterprise accounts: - UsageView: show only the spend row when extraUsageEnabled; non-enterprise accounts continue to see the existing session/weekly/sonnet rows - statusEmoji: uses overage percentage for threshold colouring when on enterprise, rather than the always-zero session/weekly utilisation - Menubar: shows "🟢 $14" instead of "🟢 0%" for enterprise accounts Adds ClaudeUsageTests with 14 unit tests covering extraUsagePercentage, statusEmoji routing, and menubar label formatting for both plan types. Co-Authored-By: Claude Sonnet 4.6 --- ClaudeUsage/ClaudeUsageApp.swift | 7 +- ClaudeUsage/UsageManager.swift | 11 +- ClaudeUsage/UsageView.swift | 58 +++--- ClaudeUsageTests/EnterpriseDisplayTests.swift | 184 ++++++++++++++++++ Package.swift | 5 + 5 files changed, 230 insertions(+), 35 deletions(-) create mode 100644 ClaudeUsageTests/EnterpriseDisplayTests.swift diff --git a/ClaudeUsage/ClaudeUsageApp.swift b/ClaudeUsage/ClaudeUsageApp.swift index 6f99ef8..a4293a6 100644 --- a/ClaudeUsage/ClaudeUsageApp.swift +++ b/ClaudeUsage/ClaudeUsageApp.swift @@ -105,9 +105,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard let button = statusItem?.button else { return } if let usage = usageManager.usage { - let sessionPct = usage.sessionPercentage let emoji = usageManager.statusEmoji - button.title = "\(emoji) \(sessionPct)%" + if usage.extraUsageEnabled, let used = usage.extraUsageUsedCredits { + button.title = "\(emoji) $\(String(format: "%.0f", used / 100))" + } else { + button.title = "\(emoji) \(usage.sessionPercentage)%" + } } else if usageManager.error != nil { button.title = "❌" } else { diff --git a/ClaudeUsage/UsageManager.swift b/ClaudeUsage/UsageManager.swift index 137e27a..7a542e3 100644 --- a/ClaudeUsage/UsageManager.swift +++ b/ClaudeUsage/UsageManager.swift @@ -62,9 +62,14 @@ class UsageManager: ObservableObject { var statusEmoji: String { guard let usage = usage else { return "❓" } - let maxUtil = max(usage.sessionUtilization, usage.weeklyUtilization) - if maxUtil >= 90 { return "🔴" } - if maxUtil >= 70 { return "🟡" } + let util: Double + if usage.extraUsageEnabled, let pct = usage.extraUsagePercentage { + util = Double(pct) + } else { + util = max(usage.sessionUtilization, usage.weeklyUtilization) + } + if util >= 90 { return "🔴" } + if util >= 70 { return "🟡" } return "🟢" } diff --git a/ClaudeUsage/UsageView.swift b/ClaudeUsage/UsageView.swift index 1a7f7e5..ab1cd95 100644 --- a/ClaudeUsage/UsageView.swift +++ b/ClaudeUsage/UsageView.swift @@ -71,42 +71,40 @@ struct UsageView: View { @ViewBuilder func usageContent(_ usage: UsageData) -> some View { VStack(spacing: 16) { - // Session usage - UsageRow( - title: "Session", - subtitle: "5-hour window", - percentage: usage.sessionPercentage, - resetsAt: usage.sessionResetsAt, - color: colorForPercentage(usage.sessionPercentage) - ) - - // Weekly usage - UsageRow( - title: "Weekly", - subtitle: "7-day window", - percentage: usage.weeklyPercentage, - resetsAt: usage.weeklyResetsAt, - color: colorForPercentage(usage.weeklyPercentage) - ) - - // Sonnet only (if available) - if let sonnetPct = usage.sonnetPercentage { - UsageRow( - title: "Sonnet Only", - subtitle: "Model-specific", - percentage: sonnetPct, - resetsAt: usage.sonnetResetsAt, - color: colorForPercentage(sonnetPct) - ) - } - - // Extra usage / overage (if enabled) if usage.extraUsageEnabled, let limit = usage.extraUsageMonthlyLimit, let used = usage.extraUsageUsedCredits { + // Enterprise: spend row only OverageRow( usedDollars: used / 100, limitDollars: limit / 100, percentage: usage.extraUsagePercentage ?? 0 ) + } else { + // Non-enterprise: rate-limit rows + UsageRow( + title: "Session", + subtitle: "5-hour window", + percentage: usage.sessionPercentage, + resetsAt: usage.sessionResetsAt, + color: colorForPercentage(usage.sessionPercentage) + ) + + UsageRow( + title: "Weekly", + subtitle: "7-day window", + percentage: usage.weeklyPercentage, + resetsAt: usage.weeklyResetsAt, + color: colorForPercentage(usage.weeklyPercentage) + ) + + if let sonnetPct = usage.sonnetPercentage { + UsageRow( + title: "Sonnet Only", + subtitle: "Model-specific", + percentage: sonnetPct, + resetsAt: usage.sonnetResetsAt, + color: colorForPercentage(sonnetPct) + ) + } } } .padding() diff --git a/ClaudeUsageTests/EnterpriseDisplayTests.swift b/ClaudeUsageTests/EnterpriseDisplayTests.swift new file mode 100644 index 0000000..320102e --- /dev/null +++ b/ClaudeUsageTests/EnterpriseDisplayTests.swift @@ -0,0 +1,184 @@ +import XCTest + +// These tests mirror the logic in UsageData and UsageManager without importing +// the app module (which is an executable target and cannot be @testable imported). +// They exist to pin the expected behaviour of the enterprise display changes. + +// MARK: - Minimal replicas of app types + +private struct UsageData { + let sessionUtilization: Double + let weeklyUtilization: Double + let extraUsageEnabled: Bool + let extraUsageMonthlyLimit: Double? + let extraUsageUsedCredits: Double? + + var sessionPercentage: Int { Int(sessionUtilization) } + + var extraUsagePercentage: Int? { + guard extraUsageEnabled, + let limit = extraUsageMonthlyLimit, + let used = extraUsageUsedCredits, + limit > 0 else { return nil } + return Int((used / limit) * 100) + } +} + +private func statusEmoji(for usage: UsageData) -> String { + let util: Double + if usage.extraUsageEnabled, let pct = usage.extraUsagePercentage { + util = Double(pct) + } else { + util = max(usage.sessionUtilization, usage.weeklyUtilization) + } + if util >= 90 { return "🔴" } + if util >= 70 { return "🟡" } + return "🟢" +} + +private func menubarLabel(for usage: UsageData, emoji: String) -> String { + if usage.extraUsageEnabled, let used = usage.extraUsageUsedCredits { + return "\(emoji) $\(String(format: "%.0f", used / 100))" + } + return "\(emoji) \(usage.sessionPercentage)%" +} + +// MARK: - Tests + +final class EnterpriseDisplayTests: XCTestCase { + + // MARK: extraUsagePercentage + + func testExtraUsagePercentage_typicalSpend_calculatesCorrectly() { + // $14.03 spent of $100 limit (stored as cents) + let usage = UsageData(sessionUtilization: 0, weeklyUtilization: 0, + extraUsageEnabled: true, + extraUsageMonthlyLimit: 10_000, + extraUsageUsedCredits: 1_403) + XCTAssertEqual(usage.extraUsagePercentage, 14) + } + + func testExtraUsagePercentage_fullLimit_returns100() { + let usage = UsageData(sessionUtilization: 0, weeklyUtilization: 0, + extraUsageEnabled: true, + extraUsageMonthlyLimit: 10_000, + extraUsageUsedCredits: 10_000) + XCTAssertEqual(usage.extraUsagePercentage, 100) + } + + func testExtraUsagePercentage_noSpend_returnsZero() { + let usage = UsageData(sessionUtilization: 0, weeklyUtilization: 0, + extraUsageEnabled: true, + extraUsageMonthlyLimit: 10_000, + extraUsageUsedCredits: 0) + XCTAssertEqual(usage.extraUsagePercentage, 0) + } + + func testExtraUsagePercentage_whenDisabled_returnsNil() { + let usage = UsageData(sessionUtilization: 50, weeklyUtilization: 50, + extraUsageEnabled: false, + extraUsageMonthlyLimit: 10_000, + extraUsageUsedCredits: 1_403) + XCTAssertNil(usage.extraUsagePercentage) + } + + func testExtraUsagePercentage_zeroLimit_returnsNil() { + let usage = UsageData(sessionUtilization: 0, weeklyUtilization: 0, + extraUsageEnabled: true, + extraUsageMonthlyLimit: 0, + extraUsageUsedCredits: 1_403) + XCTAssertNil(usage.extraUsagePercentage) + } + + func testExtraUsagePercentage_missingLimit_returnsNil() { + let usage = UsageData(sessionUtilization: 0, weeklyUtilization: 0, + extraUsageEnabled: true, + extraUsageMonthlyLimit: nil, + extraUsageUsedCredits: 1_403) + XCTAssertNil(usage.extraUsagePercentage) + } + + // MARK: statusEmoji — enterprise path + + func testStatusEmoji_enterprise_lowUsage_isGreen() { + // 14% of limit + let usage = UsageData(sessionUtilization: 0, weeklyUtilization: 0, + extraUsageEnabled: true, + extraUsageMonthlyLimit: 10_000, + extraUsageUsedCredits: 1_403) + XCTAssertEqual(statusEmoji(for: usage), "🟢") + } + + func testStatusEmoji_enterprise_mediumUsage_isYellow() { + // 75% of limit + let usage = UsageData(sessionUtilization: 0, weeklyUtilization: 0, + extraUsageEnabled: true, + extraUsageMonthlyLimit: 10_000, + extraUsageUsedCredits: 7_500) + XCTAssertEqual(statusEmoji(for: usage), "🟡") + } + + func testStatusEmoji_enterprise_highUsage_isRed() { + // 95% of limit + let usage = UsageData(sessionUtilization: 0, weeklyUtilization: 0, + extraUsageEnabled: true, + extraUsageMonthlyLimit: 10_000, + extraUsageUsedCredits: 9_500) + XCTAssertEqual(statusEmoji(for: usage), "🔴") + } + + func testStatusEmoji_enterprise_ignoresSessionWeeklyUtilization() { + // Session/weekly are maxed out, but overage spend is only 1% — should be green + let usage = UsageData(sessionUtilization: 95, weeklyUtilization: 95, + extraUsageEnabled: true, + extraUsageMonthlyLimit: 10_000, + extraUsageUsedCredits: 100) + XCTAssertEqual(statusEmoji(for: usage), "🟢") + } + + // MARK: statusEmoji — non-enterprise path + + func testStatusEmoji_nonEnterprise_usesSessionWeeklyMax() { + let usage = UsageData(sessionUtilization: 85, weeklyUtilization: 60, + extraUsageEnabled: false, + extraUsageMonthlyLimit: nil, + extraUsageUsedCredits: nil) + XCTAssertEqual(statusEmoji(for: usage), "🟡") // max is 85% + } + + func testStatusEmoji_nonEnterprise_highUsage_isRed() { + let usage = UsageData(sessionUtilization: 92, weeklyUtilization: 50, + extraUsageEnabled: false, + extraUsageMonthlyLimit: nil, + extraUsageUsedCredits: nil) + XCTAssertEqual(statusEmoji(for: usage), "🔴") + } + + // MARK: Menubar label + + func testMenubarLabel_enterprise_showsDollarAmount() { + // $14.03 → rounded to $14 in menubar + let usage = UsageData(sessionUtilization: 0, weeklyUtilization: 0, + extraUsageEnabled: true, + extraUsageMonthlyLimit: 10_000, + extraUsageUsedCredits: 1_403) + XCTAssertEqual(menubarLabel(for: usage, emoji: "🟢"), "🟢 $14") + } + + func testMenubarLabel_enterprise_roundsToNearestDollar() { + // $14.99 → $15 + let usage = UsageData(sessionUtilization: 0, weeklyUtilization: 0, + extraUsageEnabled: true, + extraUsageMonthlyLimit: 10_000, + extraUsageUsedCredits: 1_499) + XCTAssertEqual(menubarLabel(for: usage, emoji: "🟡"), "🟡 $15") + } + + func testMenubarLabel_nonEnterprise_showsSessionPercentage() { + let usage = UsageData(sessionUtilization: 45, weeklyUtilization: 30, + extraUsageEnabled: false, + extraUsageMonthlyLimit: nil, + extraUsageUsedCredits: nil) + XCTAssertEqual(menubarLabel(for: usage, emoji: "🟢"), "🟢 45%") + } +} diff --git a/Package.swift b/Package.swift index 6d00759..2c12afa 100644 --- a/Package.swift +++ b/Package.swift @@ -15,6 +15,11 @@ let package = Package( name: "ClaudeUsage", dependencies: [], path: "ClaudeUsage" + ), + .testTarget( + name: "ClaudeUsageTests", + dependencies: [], + path: "ClaudeUsageTests" ) ] )