diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index febb8121..0e3ac50d 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -29,6 +29,7 @@ struct UsageMenuCardView: View { let percent: Double let percentStyle: PercentStyle let resetText: String? + let detailText: String? var percentLabel: String { String(format: "%.0f%% %@", self.percent, self.percentStyle.labelSuffix) @@ -123,6 +124,12 @@ struct UsageMenuCardView: View { .foregroundStyle(.secondary) } } + if let detail = metric.detailText { + Text(detail) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } } } if let credits = self.model.creditsText { @@ -210,6 +217,7 @@ extension UsageMenuCardView.Model { let lastError: String? let usageBarsShowUsed: Bool let tokenCostUsageEnabled: Bool + let now: Date } static func make(_ input: Input) -> UsageMenuCardView.Model { @@ -218,12 +226,7 @@ extension UsageMenuCardView.Model { snapshot: input.snapshot, account: input.account) let planText = Self.plan(for: input.provider, snapshot: input.snapshot, account: input.account) - let metrics = Self.metrics( - provider: input.provider, - metadata: input.metadata, - snapshot: input.snapshot, - dashboard: input.dashboard, - usageBarsShowUsed: input.usageBarsShowUsed) + let metrics = Self.metrics(input: input) let creditsText = Self.creditsLine(metadata: input.metadata, credits: input.credits, error: input.creditsError) let creditsHintText = Self.dashboardHint(provider: input.provider, error: input.dashboardError) let tokenUsage = Self.tokenUsageSection( @@ -302,47 +305,47 @@ extension UsageMenuCardView.Model { return ("Not fetched yet", .info) } - private static func metrics( - provider: UsageProvider, - metadata: ProviderMetadata, - snapshot: UsageSnapshot?, - dashboard: OpenAIDashboardSnapshot?, - usageBarsShowUsed: Bool) -> [Metric] - { - guard let snapshot else { return [] } + private static func metrics(input: Input) -> [Metric] { + guard let snapshot = input.snapshot else { return [] } var metrics: [Metric] = [] - let percentStyle: PercentStyle = usageBarsShowUsed ? .used : .left + let percentStyle: PercentStyle = input.usageBarsShowUsed ? .used : .left metrics.append(Metric( id: "primary", - title: metadata.sessionLabel, - percent: Self.clamped(usageBarsShowUsed ? snapshot.primary.usedPercent : snapshot.primary.remainingPercent), + title: input.metadata.sessionLabel, + percent: Self.clamped( + input.usageBarsShowUsed ? snapshot.primary.usedPercent : snapshot.primary.remainingPercent), percentStyle: percentStyle, - resetText: Self.resetText(for: snapshot.primary, prefersCountdown: true))) + resetText: Self.resetText(for: snapshot.primary, prefersCountdown: true), + detailText: nil)) if let weekly = snapshot.secondary { + let paceText = UsagePaceText.weekly(provider: input.provider, window: weekly, now: input.now) metrics.append(Metric( id: "secondary", - title: metadata.weeklyLabel, - percent: Self.clamped(usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), + title: input.metadata.weeklyLabel, + percent: Self.clamped(input.usageBarsShowUsed ? weekly.usedPercent : weekly.remainingPercent), percentStyle: percentStyle, - resetText: Self.resetText(for: weekly, prefersCountdown: true))) + resetText: Self.resetText(for: weekly, prefersCountdown: true), + detailText: paceText)) } - if metadata.supportsOpus, let opus = snapshot.tertiary { + if input.metadata.supportsOpus, let opus = snapshot.tertiary { metrics.append(Metric( id: "tertiary", - title: metadata.opusLabel ?? "Sonnet", - percent: Self.clamped(usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), + title: input.metadata.opusLabel ?? "Sonnet", + percent: Self.clamped(input.usageBarsShowUsed ? opus.usedPercent : opus.remainingPercent), percentStyle: percentStyle, - resetText: Self.resetText(for: opus, prefersCountdown: true))) + resetText: Self.resetText(for: opus, prefersCountdown: true), + detailText: nil)) } - if provider == .codex, let remaining = dashboard?.codeReviewRemainingPercent { - let percent = usageBarsShowUsed ? (100 - remaining) : remaining + if input.provider == .codex, let remaining = input.dashboard?.codeReviewRemainingPercent { + let percent = input.usageBarsShowUsed ? (100 - remaining) : remaining metrics.append(Metric( id: "code-review", title: "Code review", percent: Self.clamped(percent), percentStyle: percentStyle, - resetText: nil)) + resetText: nil, + detailText: nil)) } return metrics } diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 4ae49849..88a8c1e5 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -109,6 +109,9 @@ struct MenuDescriptor { Self.appendRateWindow(entries: &entries, title: meta.sessionLabel, window: snap.primary) if let weekly = snap.secondary { Self.appendRateWindow(entries: &entries, title: meta.weeklyLabel, window: weekly) + if let paceText = UsagePaceText.weekly(provider: provider, window: weekly) { + entries.append(.text(paceText, .secondary)) + } } else if provider == .claude { entries.append(.text("Weekly usage unavailable for this account.", .secondary)) } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index fd163eb6..62a1661d 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -459,7 +459,8 @@ extension StatusItemController { isRefreshing: self.store.isRefreshing, lastError: self.store.error(for: target), usageBarsShowUsed: self.settings.usageBarsShowUsed, - tokenCostUsageEnabled: self.settings.isCCUsageCostUsageEffectivelyEnabled(for: target)) + tokenCostUsageEnabled: self.settings.isCCUsageCostUsageEffectivelyEnabled(for: target), + now: Date()) return UsageMenuCardView.Model.make(input) } } diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift new file mode 100644 index 00000000..a0164026 --- /dev/null +++ b/Sources/CodexBar/UsagePaceText.swift @@ -0,0 +1,48 @@ +import CodexBarCore +import Foundation + +enum UsagePaceText { + static func weekly(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? { + guard provider == .codex || provider == .claude else { return nil } + guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) else { return nil } + + let label = Self.label(for: pace.stage) + let deltaSuffix = Self.deltaSuffix(for: pace) + let etaSuffix = Self.etaSuffix(for: pace, now: now) + + if let etaSuffix { + return "Pace: \(label)\(deltaSuffix) · \(etaSuffix)" + } + return "Pace: \(label)\(deltaSuffix)" + } + + private static func label(for stage: UsagePace.Stage) -> String { + switch stage { + case .onTrack: "On track" + case .slightlyAhead, .ahead, .farAhead: "High" + case .slightlyBehind, .behind, .farBehind: "Low" + } + } + + private static func deltaSuffix(for pace: UsagePace) -> String { + let deltaValue = Int(abs(pace.deltaPercent).rounded()) + let sign = pace.deltaPercent >= 0 ? "+" : "-" + return " (\(sign)\(deltaValue)%)" + } + + private static func etaSuffix(for pace: UsagePace, now: Date) -> String? { + if pace.willLastToReset { return "Lasts to reset" } + guard let etaSeconds = pace.etaSeconds else { return nil } + return Self.leftText(seconds: etaSeconds, now: now) + } + + private static func leftText(seconds: TimeInterval, now: Date) -> String { + let date = now.addingTimeInterval(seconds) + let countdown = UsageFormatter.resetCountdownDescription(from: date, now: now) + if countdown == "now" { return "now" } + if countdown.hasPrefix("in ") { + return "\(countdown.dropFirst(3)) left" + } + return "\(countdown) left" + } +} diff --git a/Sources/CodexBarCore/UsagePace.swift b/Sources/CodexBarCore/UsagePace.swift new file mode 100644 index 00000000..3ac4c540 --- /dev/null +++ b/Sources/CodexBarCore/UsagePace.swift @@ -0,0 +1,81 @@ +import Foundation + +public struct UsagePace: Sendable { + public enum Stage: Sendable { + case onTrack + case slightlyAhead + case ahead + case farAhead + case slightlyBehind + case behind + case farBehind + } + + public let stage: Stage + public let deltaPercent: Double + public let expectedUsedPercent: Double + public let actualUsedPercent: Double + public let etaSeconds: TimeInterval? + public let willLastToReset: Bool + + public static func weekly( + window: RateWindow, + now: Date = .init(), + defaultWindowMinutes: Int = 10080) -> UsagePace? + { + guard let resetsAt = window.resetsAt else { return nil } + let minutes = window.windowMinutes ?? defaultWindowMinutes + guard minutes > 0 else { return nil } + + let duration = TimeInterval(minutes) * 60 + let timeUntilReset = resetsAt.timeIntervalSince(now) + guard timeUntilReset > 0 else { return nil } + guard timeUntilReset <= duration else { return nil } + let elapsed = Self.clamp(duration - timeUntilReset, lower: 0, upper: duration) + let expected = Self.clamp((elapsed / duration) * 100, lower: 0, upper: 100) + let actual = Self.clamp(window.usedPercent, lower: 0, upper: 100) + if elapsed == 0, actual > 0 { + return nil + } + let delta = actual - expected + let stage = Self.stage(for: delta) + + var etaSeconds: TimeInterval? + var willLastToReset = false + + if elapsed > 0, actual > 0 { + let rate = actual / elapsed + if rate > 0 { + let remaining = max(0, 100 - actual) + let candidate = remaining / rate + if candidate >= timeUntilReset { + willLastToReset = true + } else { + etaSeconds = candidate + } + } + } else if elapsed > 0, actual == 0 { + willLastToReset = true + } + + return UsagePace( + stage: stage, + deltaPercent: delta, + expectedUsedPercent: expected, + actualUsedPercent: actual, + etaSeconds: etaSeconds, + willLastToReset: willLastToReset) + } + + private static func stage(for delta: Double) -> Stage { + let absDelta = abs(delta) + if absDelta <= 2 { return .onTrack } + if absDelta <= 6 { return delta >= 0 ? .slightlyAhead : .slightlyBehind } + if absDelta <= 12 { return delta >= 0 ? .ahead : .behind } + return delta >= 0 ? .farAhead : .farBehind + } + + private static func clamp(_ value: Double, lower: Double, upper: Double) -> Double { + min(upper, max(lower, value)) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index d1a96dc6..5f189358 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -51,7 +51,8 @@ struct MenuCardModelTests { isRefreshing: false, lastError: nil, usageBarsShowUsed: false, - tokenCostUsageEnabled: false)) + tokenCostUsageEnabled: false, + now: now)) #expect(model.providerName == "Codex") #expect(model.metrics.count == 2) @@ -103,7 +104,8 @@ struct MenuCardModelTests { isRefreshing: false, lastError: nil, usageBarsShowUsed: true, - tokenCostUsageEnabled: false)) + tokenCostUsageEnabled: false, + now: now)) #expect(model.metrics.first?.title == "Session") #expect(model.metrics.first?.percent == 22) @@ -145,7 +147,8 @@ struct MenuCardModelTests { isRefreshing: false, lastError: nil, usageBarsShowUsed: false, - tokenCostUsageEnabled: false)) + tokenCostUsageEnabled: false, + now: now)) #expect(model.metrics.contains { $0.title == "Code review" && $0.percent == 73 }) } @@ -180,7 +183,8 @@ struct MenuCardModelTests { isRefreshing: false, lastError: nil, usageBarsShowUsed: false, - tokenCostUsageEnabled: false)) + tokenCostUsageEnabled: false, + now: now)) #expect(model.metrics.count == 1) #expect(model.metrics.first?.title == "Session") @@ -204,7 +208,8 @@ struct MenuCardModelTests { isRefreshing: false, lastError: "Probe failed for Codex", usageBarsShowUsed: false, - tokenCostUsageEnabled: false)) + tokenCostUsageEnabled: false, + now: Date())) #expect(model.subtitleStyle == .error) #expect(model.subtitleText.contains("Probe failed")) @@ -228,7 +233,8 @@ struct MenuCardModelTests { isRefreshing: false, lastError: nil, usageBarsShowUsed: false, - tokenCostUsageEnabled: false)) + tokenCostUsageEnabled: false, + now: Date())) #expect(model.planText == nil) #expect(model.email.isEmpty) diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift new file mode 100644 index 00000000..2fd9a260 --- /dev/null +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -0,0 +1,81 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@Suite +struct UsagePaceTextTests { + @Test + func weeklyPaceText_includesEtaWhenRunningOutBeforeReset() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let text = UsagePaceText.weekly(provider: .codex, window: window, now: now) + + #expect(text == "Pace: High (+7%) · 3d left") + } + + @Test + func weeklyPaceText_showsResetSafeWhenPaceIsSlow() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 10, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let text = UsagePaceText.weekly(provider: .codex, window: window, now: now) + + #expect(text == "Pace: Low (-33%) · Lasts to reset") + } + + @Test + func weeklyPaceText_hidesWhenResetIsMissing() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 10, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil) + + let text = UsagePaceText.weekly(provider: .codex, window: window, now: now) + + #expect(text == nil) + } + + @Test + func weeklyPaceText_hidesWhenResetIsInPastOrTooFar() { + let now = Date(timeIntervalSince1970: 0) + let pastWindow = RateWindow( + usedPercent: 10, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(-60), + resetDescription: nil) + let farFutureWindow = RateWindow( + usedPercent: 10, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(9 * 24 * 3600), + resetDescription: nil) + + #expect(UsagePaceText.weekly(provider: .codex, window: pastWindow, now: now) == nil) + #expect(UsagePaceText.weekly(provider: .codex, window: farFutureWindow, now: now) == nil) + } + + @Test + func weeklyPaceText_hidesWhenNoElapsedButUsageExists() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 5, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 24 * 3600), + resetDescription: nil) + + let text = UsagePaceText.weekly(provider: .codex, window: window, now: now) + + #expect(text == nil) + } +}