From 37ac40c9643f32ef3fea46cbf0b841abf819049e Mon Sep 17 00:00:00 2001 From: Ethan Lipnik Date: Wed, 18 Feb 2026 17:22:47 -0800 Subject: [PATCH 1/2] Improve weekly pace projection --- Sources/CodexBar/MenuBarDisplayText.swift | 22 +- Sources/CodexBar/MenuCardView.swift | 58 +++- Sources/CodexBar/MenuDescriptor.swift | 6 +- .../CodexBar/PreferencesProvidersPane.swift | 1 + .../StatusItemController+Animation.swift | 3 +- .../CodexBar/StatusItemController+Menu.swift | 1 + Sources/CodexBar/UsagePaceProfileStore.swift | 251 ++++++++++++++++++ Sources/CodexBar/UsagePaceText.swift | 71 ++++- Sources/CodexBar/UsageStore+Refresh.swift | 1 + Sources/CodexBar/UsageStore.swift | 6 + Sources/CodexBarCore/UsagePace.swift | 181 ++++++++++++- Sources/CodexBarCore/UsagePaceProfile.swift | 114 ++++++++ .../StatusItemAnimationTests.swift | 6 +- Tests/CodexBarTests/UsagePaceTests.swift | 55 ++++ Tests/CodexBarTests/UsagePaceTextTests.swift | 32 +++ 15 files changed, 773 insertions(+), 35 deletions(-) create mode 100644 Sources/CodexBar/UsagePaceProfileStore.swift create mode 100644 Sources/CodexBarCore/UsagePaceProfile.swift diff --git a/Sources/CodexBar/MenuBarDisplayText.swift b/Sources/CodexBar/MenuBarDisplayText.swift index ca65c9ce3..1d3564752 100644 --- a/Sources/CodexBar/MenuBarDisplayText.swift +++ b/Sources/CodexBar/MenuBarDisplayText.swift @@ -9,9 +9,17 @@ enum MenuBarDisplayText { return String(format: "%.0f%%", clamped) } - static func paceText(provider: UsageProvider, window: RateWindow?, now: Date = .init()) -> String? { + static func paceText( + provider: UsageProvider, + window: RateWindow?, + now: Date = .init(), + profile: UsagePaceProfile? = nil) -> String? + { guard let window else { return nil } - guard let pace = UsagePaceText.weeklyPace(provider: provider, window: window, now: now) else { return nil } + guard let pace = UsagePaceText.weeklyPace(provider: provider, window: window, now: now, profile: profile) + else { + return nil + } let deltaValue = Int(abs(pace.deltaPercent).rounded()) let sign = pace.deltaPercent >= 0 ? "+" : "-" return "\(sign)\(deltaValue)%" @@ -23,16 +31,20 @@ enum MenuBarDisplayText { percentWindow: RateWindow?, paceWindow: RateWindow?, showUsed: Bool, - now: Date = .init()) -> String? + now: Date = .init(), + paceProfile: UsagePaceProfile? = nil) -> String? { switch mode { case .percent: return self.percentText(window: percentWindow, showUsed: showUsed) case .pace: - return self.paceText(provider: provider, window: paceWindow, now: now) + return self.paceText(provider: provider, window: paceWindow, now: now, profile: paceProfile) case .both: guard let percent = percentText(window: percentWindow, showUsed: showUsed) else { return nil } - guard let pace = Self.paceText(provider: provider, window: paceWindow, now: now) else { return nil } + guard let pace = Self.paceText(provider: provider, window: paceWindow, now: now, profile: paceProfile) + else { + return nil + } return "\(percent) · \(pace)" } } diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 89a930dc1..41537c160 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -591,7 +591,50 @@ extension UsageMenuCardView.Model { let tokenCostUsageEnabled: Bool let showOptionalCreditsAndExtraUsage: Bool let hidePersonalInfo: Bool + let paceProfile: UsagePaceProfile? let now: Date + + init( + provider: UsageProvider, + metadata: ProviderMetadata, + snapshot: UsageSnapshot?, + credits: CreditsSnapshot?, + creditsError: String?, + dashboard: OpenAIDashboardSnapshot?, + dashboardError: String?, + tokenSnapshot: CostUsageTokenSnapshot?, + tokenError: String?, + account: AccountInfo, + isRefreshing: Bool, + lastError: String?, + usageBarsShowUsed: Bool, + resetTimeDisplayStyle: ResetTimeDisplayStyle, + tokenCostUsageEnabled: Bool, + showOptionalCreditsAndExtraUsage: Bool, + hidePersonalInfo: Bool, + paceProfile: UsagePaceProfile? = nil, + now: Date) + { + self.provider = provider + self.metadata = metadata + self.snapshot = snapshot + self.credits = credits + self.creditsError = creditsError + self.dashboard = dashboard + self.dashboardError = dashboardError + self.tokenSnapshot = tokenSnapshot + self.tokenError = tokenError + self.account = account + self.isRefreshing = isRefreshing + self.lastError = lastError + self.usageBarsShowUsed = usageBarsShowUsed + self.resetTimeDisplayStyle = resetTimeDisplayStyle + self.tokenCostUsageEnabled = tokenCostUsageEnabled + self.showOptionalCreditsAndExtraUsage = showOptionalCreditsAndExtraUsage + self.hidePersonalInfo = hidePersonalInfo + self.paceProfile = paceProfile + self.now = now + } } static func make(_ input: Input) -> UsageMenuCardView.Model { @@ -772,7 +815,8 @@ extension UsageMenuCardView.Model { provider: input.provider, window: weekly, now: input.now, - showUsed: input.usageBarsShowUsed) + showUsed: input.usageBarsShowUsed, + profile: input.paceProfile) var weeklyResetText = Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now) var weeklyDetailText: String? = input.provider == .zai ? zaiTimeDetail : nil if input.provider == .warp, @@ -852,9 +896,17 @@ extension UsageMenuCardView.Model { provider: UsageProvider, window: RateWindow, now: Date, - showUsed: Bool) -> PaceDetail? + showUsed: Bool, + profile: UsagePaceProfile?) -> PaceDetail? { - guard let detail = UsagePaceText.weeklyDetail(provider: provider, window: window, now: now) else { return nil } + guard let detail = UsagePaceText.weeklyDetail( + provider: provider, + window: window, + now: now, + profile: profile) + else { + return nil + } let expectedUsed = detail.expectedUsedPercent let actualUsed = window.usedPercent let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed) diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 495cbd7ef..76be10e08 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -152,7 +152,11 @@ struct MenuDescriptor { resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed, resetOverride: weeklyResetOverride) - if let paceSummary = UsagePaceText.weeklySummary(provider: provider, window: weekly) { + if let paceSummary = UsagePaceText.weeklySummary( + provider: provider, + window: weekly, + profile: store.paceProfile(for: provider)) + { entries.append(.text(paceSummary, .secondary)) } } diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index c08711077..70f965791 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -345,6 +345,7 @@ struct ProvidersPane: View { tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, hidePersonalInfo: self.settings.hidePersonalInfo, + paceProfile: self.store.paceProfile(for: provider), now: Date()) return UsageMenuCardView.Model.make(input) } diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 3c77d03e0..14428f2c9 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -389,7 +389,8 @@ extension StatusItemController { provider: provider, percentWindow: self.menuBarPercentWindow(for: provider, snapshot: snapshot), paceWindow: snapshot?.secondary, - showUsed: self.settings.usageBarsShowUsed) + showUsed: self.settings.usageBarsShowUsed, + paceProfile: self.store.paceProfile(for: provider)) } private func menuBarPercentWindow(for provider: UsageProvider, snapshot: UsageSnapshot?) -> RateWindow? { diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 2508e25f0..23e0165c5 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1207,6 +1207,7 @@ extension StatusItemController { tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: target), showOptionalCreditsAndExtraUsage: self.settings.showOptionalCreditsAndExtraUsage, hidePersonalInfo: self.settings.hidePersonalInfo, + paceProfile: self.store.paceProfile(for: target), now: Date()) return UsageMenuCardView.Model.make(input) } diff --git a/Sources/CodexBar/UsagePaceProfileStore.swift b/Sources/CodexBar/UsagePaceProfileStore.swift new file mode 100644 index 000000000..6eba572af --- /dev/null +++ b/Sources/CodexBar/UsagePaceProfileStore.swift @@ -0,0 +1,251 @@ +import CodexBarCore +import Foundation + +@MainActor +final class UsagePaceProfileStore { + private static let weeklyWindowMinutes = 7 * 24 * 60 + private static let weeklyWindowToleranceMinutes = 24 * 60 + + private struct PersistedState: Codable { + let providers: [String: ProviderState] + } + + private struct ProviderState: Codable { + var hourlyRates: [Double] + var hourlySamples: [Int] + var sampleCount: Int + var firstObservation: Date? + var lastObservation: Date? + var lastUsedPercent: Double? + var lastSeenAt: Date? + var lastResetAt: Date? + var lastWindowMinutes: Int? + + static var empty: ProviderState { + ProviderState( + hourlyRates: Array(repeating: 0, count: UsagePaceProfile.binsPerWeek), + hourlySamples: Array(repeating: 0, count: UsagePaceProfile.binsPerWeek), + sampleCount: 0, + firstObservation: nil, + lastObservation: nil, + lastUsedPercent: nil, + lastSeenAt: nil, + lastResetAt: nil, + lastWindowMinutes: nil) + } + + mutating func normalize() { + self.hourlyRates = Self.normalizeRates(self.hourlyRates) + self.hourlySamples = Self.normalizeSamples(self.hourlySamples) + self.sampleCount = max(0, self.sampleCount) + } + + mutating func record(window: RateWindow, now: Date) -> Bool { + self.normalize() + + let used = min(100, max(0, window.usedPercent)) + guard used.isFinite else { + self.updateLastObservation(window: window, now: now, used: nil) + return false + } + + guard let previousUsed = self.lastUsedPercent, + let previousSeen = self.lastSeenAt + else { + self.updateLastObservation(window: window, now: now, used: used) + return false + } + + let elapsed = now.timeIntervalSince(previousSeen) + guard elapsed >= 60, elapsed <= (6 * 60 * 60) else { + self.updateLastObservation(window: window, now: now, used: used) + return false + } + + let resetAdvanced = Self.didAdvanceReset(previous: self.lastResetAt, current: window.resetsAt) + let delta = used - previousUsed + if resetAdvanced || delta < -0.5 { + self.updateLastObservation(window: window, now: now, used: used) + return false + } + + guard delta > 0 else { + self.updateLastObservation(window: window, now: now, used: used) + return false + } + + let clampedDelta = min(delta, 40) + let rate = clampedDelta / elapsed + if rate.isFinite == false || rate <= 0 { + self.updateLastObservation(window: window, now: now, used: used) + return false + } + + let midpoint = previousSeen.addingTimeInterval(elapsed / 2) + let bin = UsagePaceProfile.binIndex(for: midpoint) + guard self.hourlyRates.indices.contains(bin), self.hourlySamples.indices.contains(bin) else { + self.updateLastObservation(window: window, now: now, used: used) + return false + } + + let priorSamples = max(0, self.hourlySamples[bin]) + let priorRate = max(0, self.hourlyRates[bin]) + let nextRate = if priorSamples == 0 { + rate + } else { + ((priorRate * Double(priorSamples)) + rate) / Double(priorSamples + 1) + } + + self.hourlyRates[bin] = nextRate + self.hourlySamples[bin] = priorSamples + 1 + self.sampleCount += 1 + self.firstObservation = self.firstObservation ?? midpoint + self.lastObservation = midpoint + self.updateLastObservation(window: window, now: now, used: used) + return true + } + + func makeProfile() -> UsagePaceProfile { + let activeBins = self.hourlySamples.reduce(into: 0) { count, samples in + if samples > 0 { + count += 1 + } + } + let spanHours = if let first = self.firstObservation, let last = self.lastObservation { + Int(max(0, last.timeIntervalSince(first)) / 3600) + } else { + 0 + } + return UsagePaceProfile( + hourlyIntensity: self.hourlyRates, + sampleCount: self.sampleCount, + activeBinCount: activeBins, + spanHours: spanHours) + } + + private mutating func updateLastObservation(window: RateWindow, now: Date, used: Double?) { + self.lastUsedPercent = used + self.lastSeenAt = now + self.lastResetAt = window.resetsAt + self.lastWindowMinutes = window.windowMinutes + } + + private static func didAdvanceReset(previous: Date?, current: Date?) -> Bool { + guard let previous, let current else { return false } + return current.timeIntervalSince(previous) > (6 * 60 * 60) + } + + private static func normalizeRates(_ input: [Double]) -> [Double] { + let cleaned = input.map { value in + if value.isFinite { + return max(0, value) + } + return 0 + } + + if cleaned.count == UsagePaceProfile.binsPerWeek { + return cleaned + } + if cleaned.count > UsagePaceProfile.binsPerWeek { + return Array(cleaned.prefix(UsagePaceProfile.binsPerWeek)) + } + return cleaned + Array(repeating: 0, count: UsagePaceProfile.binsPerWeek - cleaned.count) + } + + private static func normalizeSamples(_ input: [Int]) -> [Int] { + let cleaned = input.map { max(0, $0) } + if cleaned.count == UsagePaceProfile.binsPerWeek { + return cleaned + } + if cleaned.count > UsagePaceProfile.binsPerWeek { + return Array(cleaned.prefix(UsagePaceProfile.binsPerWeek)) + } + return cleaned + Array(repeating: 0, count: UsagePaceProfile.binsPerWeek - cleaned.count) + } + } + + private var providers: [UsageProvider: ProviderState] + + private init(providers: [UsageProvider: ProviderState]) { + self.providers = providers + } + + static func load() -> UsagePaceProfileStore { + guard let url = self.storageURL, + let data = try? Data(contentsOf: url) + else { + return UsagePaceProfileStore(providers: [:]) + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + guard let decoded = try? decoder.decode(PersistedState.self, from: data) else { + return UsagePaceProfileStore(providers: [:]) + } + + let mapped = decoded.providers.reduce(into: [UsageProvider: ProviderState]()) { partial, element in + guard let provider = UsageProvider(rawValue: element.key) else { return } + var state = element.value + state.normalize() + partial[provider] = state + } + return UsagePaceProfileStore(providers: mapped) + } + + func profile(for provider: UsageProvider) -> UsagePaceProfile { + guard let state = self.providers[provider] else { return .empty } + return state.makeProfile() + } + + func record(provider: UsageProvider, snapshot: UsageSnapshot, now: Date = .init()) { + guard let weekly = self.weeklyWindow(in: snapshot) else { return } + var state = self.providers[provider] ?? .empty + let changed = state.record(window: weekly, now: now) + self.providers[provider] = state + if changed { + self.save() + } + } + + private func weeklyWindow(in snapshot: UsageSnapshot) -> RateWindow? { + let candidates = [snapshot.secondary, snapshot.primary, snapshot.tertiary] + for candidate in candidates { + guard let candidate else { continue } + guard Self.isWeeklyWindow(candidate) else { continue } + return candidate + } + return nil + } + + private static func isWeeklyWindow(_ window: RateWindow) -> Bool { + let minutes = window.windowMinutes ?? Self.weeklyWindowMinutes + let delta = abs(minutes - Self.weeklyWindowMinutes) + return delta <= Self.weeklyWindowToleranceMinutes + } + + private func save() { + guard let url = Self.storageURL else { return } + let serialized = self.providers.reduce(into: [String: ProviderState]()) { partial, element in + partial[element.key.rawValue] = element.value + } + + let payload = PersistedState(providers: serialized) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + + guard let data = try? encoder.encode(payload) else { return } + try? data.write(to: url, options: [.atomic]) + } + + private static var storageURL: URL? { + guard let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { + return nil + } + + let dir = root.appendingPathComponent("com.steipete.codexbar", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + return dir.appendingPathComponent("pace-profiles-v1.json", isDirectory: false) + } +} diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 920e38ef9..8051fff14 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -10,17 +10,33 @@ enum UsagePaceText { } private static let minimumExpectedPercent: Double = 3 + private static let weeklyWindowMinutes = 7 * 24 * 60 + private static let weeklyWindowToleranceMinutes = 24 * 60 - static func weeklySummary(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? { - guard let detail = weeklyDetail(provider: provider, window: window, now: now) else { return nil } + static func weeklySummary( + provider: UsageProvider, + window: RateWindow, + now: Date = .init(), + profile: UsagePaceProfile? = nil) -> String? + { + guard let detail = weeklyDetail(provider: provider, window: window, now: now, profile: profile) else { + return nil + } if let rightLabel = detail.rightLabel { return "Pace: \(detail.leftLabel) · \(rightLabel)" } return "Pace: \(detail.leftLabel)" } - static func weeklyDetail(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> WeeklyDetail? { - guard let pace = weeklyPace(provider: provider, window: window, now: now) else { return nil } + static func weeklyDetail( + provider: UsageProvider, + window: RateWindow, + now: Date = .init(), + profile: UsagePaceProfile? = nil) -> WeeklyDetail? + { + guard let pace = weeklyPace(provider: provider, window: window, now: now, profile: profile) else { + return nil + } return WeeklyDetail( leftLabel: Self.detailLeftLabel(for: pace), rightLabel: Self.detailRightLabel(for: pace, now: now), @@ -41,11 +57,24 @@ enum UsagePaceText { } private static func detailRightLabel(for pace: UsagePace, now: Date) -> String? { - if pace.willLastToReset { return "Lasts until reset" } - guard let etaSeconds = pace.etaSeconds else { return nil } - let etaText = Self.durationText(seconds: etaSeconds, now: now) - if etaText == "now" { return "Runs out now" } - return "Runs out in \(etaText)" + let runwayLabel: String? = { + if pace.willLastToReset { return "Lasts until reset" } + guard let etaSeconds = pace.etaSeconds else { return nil } + let etaText = Self.durationText(seconds: etaSeconds, now: now) + if etaText == "now" { return "Runs out now" } + return "Runs out in \(etaText)" + }() + + let confidenceLabel: String? = if pace.confidence == .low, pace.isFallbackLinear { + "Low confidence" + } else { + nil + } + + if let runwayLabel, let confidenceLabel { + return "\(runwayLabel) · \(confidenceLabel)" + } + return runwayLabel ?? confidenceLabel } private static func durationText(seconds: TimeInterval, now: Date) -> String { @@ -56,11 +85,29 @@ enum UsagePaceText { return countdown } - static func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { - guard provider == .codex || provider == .claude else { return nil } + static func weeklyPace( + provider _: UsageProvider, + window: RateWindow, + now: Date, + profile: UsagePaceProfile? = nil) -> UsagePace? + { guard window.remainingPercent > 0 else { return nil } - guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) else { return nil } + guard self.isWeeklyWindow(window) else { return nil } + guard let pace = UsagePace.weekly( + window: window, + now: now, + defaultWindowMinutes: weeklyWindowMinutes, + profile: profile) + else { + return nil + } guard pace.expectedUsedPercent >= Self.minimumExpectedPercent else { return nil } return pace } + + static func isWeeklyWindow(_ window: RateWindow) -> Bool { + let minutes = window.windowMinutes ?? Self.weeklyWindowMinutes + let delta = abs(minutes - Self.weeklyWindowMinutes) + return delta <= Self.weeklyWindowToleranceMinutes + } } diff --git a/Sources/CodexBar/UsageStore+Refresh.swift b/Sources/CodexBar/UsageStore+Refresh.swift index 0c456cb75..692177afe 100644 --- a/Sources/CodexBar/UsageStore+Refresh.swift +++ b/Sources/CodexBar/UsageStore+Refresh.swift @@ -80,6 +80,7 @@ extension UsageStore { await MainActor.run { self.handleSessionQuotaTransition(provider: provider, snapshot: scoped) self.snapshots[provider] = scoped + self.paceProfileStore.record(provider: provider, snapshot: scoped, now: scoped.updatedAt) self.lastSourceLabels[provider] = result.sourceLabel self.errors[provider] = nil self.failureGates[provider]?.recordSuccess() diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 20bb7491f..d29e0b821 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -191,6 +191,7 @@ final class UsageStore { @ObservationIgnored private var tokenTimerTask: Task? @ObservationIgnored private var tokenRefreshSequenceTask: Task? @ObservationIgnored private var pathDebugRefreshTask: Task? + @ObservationIgnored let paceProfileStore: UsagePaceProfileStore @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false @@ -227,6 +228,7 @@ final class UsageStore { codexFetcher: fetcher, claudeFetcher: self.claudeFetcher, browserDetection: browserDetection) + self.paceProfileStore = UsagePaceProfileStore.load() self.providerRuntimes = Dictionary(uniqueKeysWithValues: ProviderCatalog.all.compactMap { implementation in implementation.makeRuntime().map { (implementation.id, $0) } }) @@ -346,6 +348,10 @@ final class UsageStore { self.snapshots[provider] } + func paceProfile(for provider: UsageProvider) -> UsagePaceProfile { + self.paceProfileStore.profile(for: provider) + } + func sourceLabel(for provider: UsageProvider) -> String { var label = self.lastSourceLabels[provider] ?? "" if label.isEmpty { diff --git a/Sources/CodexBarCore/UsagePace.swift b/Sources/CodexBarCore/UsagePace.swift index 3ac4c5406..275269ee3 100644 --- a/Sources/CodexBarCore/UsagePace.swift +++ b/Sources/CodexBarCore/UsagePace.swift @@ -17,11 +17,53 @@ public struct UsagePace: Sendable { public let actualUsedPercent: Double public let etaSeconds: TimeInterval? public let willLastToReset: Bool + public let model: UsagePaceModel + public let confidence: UsagePaceConfidence + public let isFallbackLinear: Bool public static func weekly( window: RateWindow, now: Date = .init(), - defaultWindowMinutes: Int = 10080) -> UsagePace? + defaultWindowMinutes: Int = 10080, + profile: UsagePaceProfile? = nil) -> UsagePace? + { + guard let context = baseContext(window: window, now: now, defaultWindowMinutes: defaultWindowMinutes) + else { + return nil + } + + if let profile { + if profile.hasSufficientData, + let profiled = Self.profiledPace(context: context, profile: profile) + { + return profiled + } + return Self.linearPace( + context: context, + confidence: .low, + isFallbackLinear: true) + } + + return Self.linearPace( + context: context, + confidence: .high, + isFallbackLinear: false) + } + + private struct Context { + let now: Date + let resetsAt: Date + let duration: TimeInterval + let timeUntilReset: TimeInterval + let elapsed: TimeInterval + let expectedLinear: Double + let actual: Double + } + + private static func baseContext( + window: RateWindow, + now: Date, + defaultWindowMinutes: Int) -> Context? { guard let resetsAt = window.resetsAt else { return nil } let minutes = window.windowMinutes ?? defaultWindowMinutes @@ -31,30 +73,99 @@ public struct UsagePace: Sendable { 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 expectedLinear = 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 + + return Context( + now: now, + resetsAt: resetsAt, + duration: duration, + timeUntilReset: timeUntilReset, + elapsed: elapsed, + expectedLinear: expectedLinear, + actual: actual) + } + + private static func linearPace( + context: Context, + confidence: UsagePaceConfidence, + isFallbackLinear: Bool) -> UsagePace + { + let delta = context.actual - context.expectedLinear let stage = Self.stage(for: delta) var etaSeconds: TimeInterval? var willLastToReset = false - if elapsed > 0, actual > 0 { - let rate = actual / elapsed + if context.elapsed > 0, context.actual > 0 { + let rate = context.actual / context.elapsed if rate > 0 { - let remaining = max(0, 100 - actual) + let remaining = max(0, 100 - context.actual) let candidate = remaining / rate - if candidate >= timeUntilReset { + if candidate >= context.timeUntilReset { willLastToReset = true } else { etaSeconds = candidate } } - } else if elapsed > 0, actual == 0 { + } else if context.elapsed > 0, context.actual == 0 { + willLastToReset = true + } + + return UsagePace( + stage: stage, + deltaPercent: delta, + expectedUsedPercent: context.expectedLinear, + actualUsedPercent: context.actual, + etaSeconds: etaSeconds, + willLastToReset: willLastToReset, + model: .linear, + confidence: confidence, + isFallbackLinear: isFallbackLinear) + } + + private static func profiledPace(context: Context, profile: UsagePaceProfile) -> UsagePace? { + let start = context.resetsAt.addingTimeInterval(-context.duration) + let pastShape = profile.integratedIntensity(from: start, to: context.now) + let fullShape = profile.integratedIntensity(from: start, to: context.resetsAt) + guard pastShape > 0, fullShape > 0 else { return nil } + + let expected = Self.clamp((pastShape / fullShape) * 100, lower: 0, upper: 100) + let delta = context.actual - expected + let stage = Self.stage(for: delta) + + let scale = context.actual / pastShape + var etaSeconds: TimeInterval? + var willLastToReset = false + + if scale > 0, context.actual > 0 { + let remaining = max(0, 100 - context.actual) + let futureShape = profile.integratedIntensity(from: context.now, to: context.resetsAt) + let projectedFuture = scale * futureShape + if projectedFuture < remaining { + willLastToReset = true + } else if let eta = Self.timeToConsume( + remainingPercent: remaining, + start: context.now, + end: context.resetsAt, + scale: scale, + profile: profile) + { + if eta >= context.timeUntilReset { + willLastToReset = true + } else { + etaSeconds = eta + } + } else { + willLastToReset = true + } + } else { willLastToReset = true } @@ -62,9 +173,59 @@ public struct UsagePace: Sendable { stage: stage, deltaPercent: delta, expectedUsedPercent: expected, - actualUsedPercent: actual, + actualUsedPercent: context.actual, etaSeconds: etaSeconds, - willLastToReset: willLastToReset) + willLastToReset: willLastToReset, + model: .timeOfDayProfile, + confidence: .high, + isFallbackLinear: false) + } + + private static func timeToConsume( + remainingPercent: Double, + start: Date, + end: Date, + scale: Double, + profile: UsagePaceProfile, + calendar: Calendar = .current) -> TimeInterval? + { + guard remainingPercent > 0 else { return 0 } + guard end > start else { return nil } + guard scale > 0 else { return nil } + + var consumed = 0.0 + var cursor = start + + while cursor < end { + let boundary = Self.nextHourBoundary(after: cursor, calendar: calendar) + let segmentEnd = min(end, boundary) + let duration = segmentEnd.timeIntervalSince(cursor) + if duration > 0 { + let intensity = profile.intensity(at: cursor, calendar: calendar) + let rate = scale * intensity + if rate > 0 { + let canConsume = rate * duration + if consumed + canConsume >= remainingPercent { + let needed = (remainingPercent - consumed) / rate + return cursor.timeIntervalSince(start) + needed + } + consumed += canConsume + } + } + cursor = segmentEnd + } + + return nil + } + + private static func nextHourBoundary(after date: Date, calendar: Calendar) -> Date { + if let interval = calendar.dateInterval(of: .hour, for: date) { + let boundary = interval.end + if boundary > date { + return boundary + } + } + return date.addingTimeInterval(3600) } private static func stage(for delta: Double) -> Stage { diff --git a/Sources/CodexBarCore/UsagePaceProfile.swift b/Sources/CodexBarCore/UsagePaceProfile.swift new file mode 100644 index 000000000..6e8807c7d --- /dev/null +++ b/Sources/CodexBarCore/UsagePaceProfile.swift @@ -0,0 +1,114 @@ +import Foundation + +public enum UsagePaceModel: String, Codable, Sendable { + case linear + case timeOfDayProfile +} + +public enum UsagePaceConfidence: String, Codable, Sendable { + case high + case low +} + +public struct UsagePaceProfile: Codable, Equatable, Sendable { + public static let binsPerWeek = 7 * 24 + public static let minimumSampleCount = 24 + public static let minimumActiveBinCount = 8 + public static let minimumSpanHours = 48 + + public let hourlyIntensity: [Double] + public let sampleCount: Int + public let activeBinCount: Int + public let spanHours: Int + + public init( + hourlyIntensity: [Double], + sampleCount: Int, + activeBinCount: Int, + spanHours: Int) + { + let normalized = Self.normalizeBins(hourlyIntensity) + self.hourlyIntensity = normalized + self.sampleCount = max(0, sampleCount) + self.activeBinCount = max(0, min(Self.binsPerWeek, activeBinCount)) + self.spanHours = max(0, spanHours) + } + + public static var empty: UsagePaceProfile { + UsagePaceProfile( + hourlyIntensity: Array(repeating: 0, count: binsPerWeek), + sampleCount: 0, + activeBinCount: 0, + spanHours: 0) + } + + public var hasSufficientData: Bool { + self.sampleCount >= Self.minimumSampleCount && + self.activeBinCount >= Self.minimumActiveBinCount && + self.spanHours >= Self.minimumSpanHours + } + + public static func binIndex(for date: Date, calendar: Calendar = .current) -> Int { + let weekday = calendar.component(.weekday, from: date) + let hour = calendar.component(.hour, from: date) + let weekdayMondayZero = (weekday + 5) % 7 + return (weekdayMondayZero * 24) + hour + } + + public func intensity(at date: Date, calendar: Calendar = .current) -> Double { + let index = Self.binIndex(for: date, calendar: calendar) + guard self.hourlyIntensity.indices.contains(index) else { return 0 } + return max(0, self.hourlyIntensity[index]) + } + + public func integratedIntensity( + from start: Date, + to end: Date, + calendar: Calendar = .current) -> Double + { + guard end > start else { return 0 } + + var total = 0.0 + var cursor = start + while cursor < end { + let nextBoundary = self.nextHourBoundary(after: cursor, calendar: calendar) + let segmentEnd = min(end, nextBoundary) + let duration = segmentEnd.timeIntervalSince(cursor) + if duration > 0 { + total += self.intensity(at: cursor, calendar: calendar) * duration + } + cursor = segmentEnd + } + + return total + } + + private func nextHourBoundary(after date: Date, calendar: Calendar) -> Date { + if let interval = calendar.dateInterval(of: .hour, for: date) { + let boundary = interval.end + if boundary > date { + return boundary + } + } + return date.addingTimeInterval(3600) + } + + private static func normalizeBins(_ values: [Double]) -> [Double] { + let clamped = values.map { value in + if value.isFinite { + return max(0, value) + } + return 0 + } + + if clamped.count == Self.binsPerWeek { + return clamped + } + + if clamped.count > Self.binsPerWeek { + return Array(clamped.prefix(Self.binsPerWeek)) + } + + return clamped + Array(repeating: 0, count: Self.binsPerWeek - clamped.count) + } +} diff --git a/Tests/CodexBarTests/StatusItemAnimationTests.swift b/Tests/CodexBarTests/StatusItemAnimationTests.swift index e4c8a5407..372da5fe2 100644 --- a/Tests/CodexBarTests/StatusItemAnimationTests.swift +++ b/Tests/CodexBarTests/StatusItemAnimationTests.swift @@ -346,7 +346,7 @@ struct StatusItemAnimationTests { } @Test - func menuBarDisplayTextHidesWhenPaceUnavailable() { + func menuBarDisplayTextShowsPaceForWeeklyWindowProviders() { let now = Date(timeIntervalSince1970: 0) let percentWindow = RateWindow(usedPercent: 40, windowMinutes: nil, resetsAt: nil, resetDescription: nil) let paceWindow = RateWindow( @@ -370,7 +370,7 @@ struct StatusItemAnimationTests { showUsed: true, now: now) - #expect(pace == nil) - #expect(both == nil) + #expect(pace == "+16%") + #expect(both == "40% · +16%") } } diff --git a/Tests/CodexBarTests/UsagePaceTests.swift b/Tests/CodexBarTests/UsagePaceTests.swift index 0fdde0133..e7bb2355c 100644 --- a/Tests/CodexBarTests/UsagePaceTests.swift +++ b/Tests/CodexBarTests/UsagePaceTests.swift @@ -74,4 +74,59 @@ struct UsagePaceTests { #expect(pace == nil) } + + @Test + func weeklyPace_marksLowConfidenceOnLinearFallbackWhenProfileIsInsufficient() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let pace = UsagePace.weekly(window: window, now: now, profile: .empty) + + #expect(pace != nil) + guard let pace else { return } + #expect(pace.model == .linear) + #expect(pace.confidence == .low) + #expect(pace.isFallbackLinear == true) + } + + @Test + func weeklyPace_usesTimeOfDayProfileWhenConfidenceIsHigh() { + let now = Date(timeIntervalSince1970: 0) + let reset = now.addingTimeInterval((4 * 24 * 3600) + (6 * 3600)) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: reset, + resetDescription: nil) + + let duration = TimeInterval(7 * 24 * 3600) + let start = reset.addingTimeInterval(-duration) + + var bins = Array(repeating: 0.2, count: UsagePaceProfile.binsPerWeek) + var cursor = start + while cursor < now { + let idx = UsagePaceProfile.binIndex(for: cursor) + bins[idx] = 2.0 + cursor = cursor.addingTimeInterval(3600) + } + + let profile = UsagePaceProfile( + hourlyIntensity: bins, + sampleCount: 120, + activeBinCount: UsagePaceProfile.binsPerWeek, + spanHours: 240) + + let pace = UsagePace.weekly(window: window, now: now, profile: profile) + + #expect(pace != nil) + guard let pace else { return } + #expect(pace.model == .timeOfDayProfile) + #expect(pace.confidence == .high) + #expect(pace.isFallbackLinear == false) + #expect(pace.expectedUsedPercent > 55) + } } diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 86c49a8ff..ba4e9653c 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -122,4 +122,36 @@ struct UsagePaceTextTests { #expect(detail == nil) } + + @Test + func weeklyPaceDetail_showsLowConfidenceBadgeWhenFallingBackToLinear() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.weeklyDetail( + provider: .codex, + window: window, + now: now, + profile: .empty) + + #expect(detail?.rightLabel?.contains("Low confidence") == true) + } + + @Test + func weeklyPaceDetail_supportsAllProvidersForWeeklyWindows() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.weeklyDetail(provider: .gemini, window: window, now: now) + + #expect(detail != nil) + } } From 7ae0fd08e4116fd020fd59f71d573c626353c422 Mon Sep 17 00:00:00 2001 From: Ethan Lipnik Date: Sat, 21 Feb 2026 11:44:13 -0800 Subject: [PATCH 2/2] Fix pacing confidence --- Sources/CodexBar/UsagePaceProfileStore.swift | 6 +- Sources/CodexBar/UsagePaceText.swift | 4 +- Sources/CodexBarCore/UsagePaceProfile.swift | 2 +- .../UsagePaceProfileStoreTests.swift | 30 ++++++++ Tests/CodexBarTests/UsagePaceTests.swift | 72 +++++++++++++++++++ Tests/CodexBarTests/UsagePaceTextTests.swift | 14 ++++ 6 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 Tests/CodexBarTests/UsagePaceProfileStoreTests.swift diff --git a/Sources/CodexBar/UsagePaceProfileStore.swift b/Sources/CodexBar/UsagePaceProfileStore.swift index 6eba572af..d7daa0f4d 100644 --- a/Sources/CodexBar/UsagePaceProfileStore.swift +++ b/Sources/CodexBar/UsagePaceProfileStore.swift @@ -218,8 +218,10 @@ final class UsagePaceProfileStore { return nil } - private static func isWeeklyWindow(_ window: RateWindow) -> Bool { - let minutes = window.windowMinutes ?? Self.weeklyWindowMinutes + static func isWeeklyWindow(_ window: RateWindow) -> Bool { + guard let minutes = window.windowMinutes else { + return false + } let delta = abs(minutes - Self.weeklyWindowMinutes) return delta <= Self.weeklyWindowToleranceMinutes } diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 8051fff14..da7521a82 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -106,7 +106,9 @@ enum UsagePaceText { } static func isWeeklyWindow(_ window: RateWindow) -> Bool { - let minutes = window.windowMinutes ?? Self.weeklyWindowMinutes + guard let minutes = window.windowMinutes else { + return false + } let delta = abs(minutes - Self.weeklyWindowMinutes) return delta <= Self.weeklyWindowToleranceMinutes } diff --git a/Sources/CodexBarCore/UsagePaceProfile.swift b/Sources/CodexBarCore/UsagePaceProfile.swift index 6e8807c7d..a2ef73558 100644 --- a/Sources/CodexBarCore/UsagePaceProfile.swift +++ b/Sources/CodexBarCore/UsagePaceProfile.swift @@ -12,7 +12,7 @@ public enum UsagePaceConfidence: String, Codable, Sendable { public struct UsagePaceProfile: Codable, Equatable, Sendable { public static let binsPerWeek = 7 * 24 - public static let minimumSampleCount = 24 + public static let minimumSampleCount = 20 public static let minimumActiveBinCount = 8 public static let minimumSpanHours = 48 diff --git a/Tests/CodexBarTests/UsagePaceProfileStoreTests.swift b/Tests/CodexBarTests/UsagePaceProfileStoreTests.swift new file mode 100644 index 000000000..9adb622af --- /dev/null +++ b/Tests/CodexBarTests/UsagePaceProfileStoreTests.swift @@ -0,0 +1,30 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +@MainActor +@Suite +struct UsagePaceProfileStoreTests { + @Test + func isWeeklyWindow_rejectsUnknownDuration() { + let window = RateWindow( + usedPercent: 40, + windowMinutes: nil, + resetsAt: Date(timeIntervalSince1970: 0).addingTimeInterval(3 * 24 * 3600), + resetDescription: nil) + + #expect(UsagePaceProfileStore.isWeeklyWindow(window) == false) + } + + @Test + func isWeeklyWindow_acceptsExplicitWeeklyDuration() { + let window = RateWindow( + usedPercent: 40, + windowMinutes: 10080, + resetsAt: Date(timeIntervalSince1970: 0).addingTimeInterval(3 * 24 * 3600), + resetDescription: nil) + + #expect(UsagePaceProfileStore.isWeeklyWindow(window)) + } +} diff --git a/Tests/CodexBarTests/UsagePaceTests.swift b/Tests/CodexBarTests/UsagePaceTests.swift index e7bb2355c..d26a3c1bd 100644 --- a/Tests/CodexBarTests/UsagePaceTests.swift +++ b/Tests/CodexBarTests/UsagePaceTests.swift @@ -93,6 +93,78 @@ struct UsagePaceTests { #expect(pace.isFallbackLinear == true) } + @Test + func weeklyPace_usesTimeOfDayProfileAtTwentySamples() { + let now = Date(timeIntervalSince1970: 0) + let reset = now.addingTimeInterval((4 * 24 * 3600) + (6 * 3600)) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: reset, + resetDescription: nil) + + let duration = TimeInterval(7 * 24 * 3600) + let start = reset.addingTimeInterval(-duration) + + var bins = Array(repeating: 0.2, count: UsagePaceProfile.binsPerWeek) + var cursor = start + while cursor < now { + let idx = UsagePaceProfile.binIndex(for: cursor) + bins[idx] = 2.0 + cursor = cursor.addingTimeInterval(3600) + } + + let profile = UsagePaceProfile( + hourlyIntensity: bins, + sampleCount: 20, + activeBinCount: 24, + spanHours: 72) + + let pace = UsagePace.weekly(window: window, now: now, profile: profile) + + #expect(pace != nil) + guard let pace else { return } + #expect(pace.model == .timeOfDayProfile) + #expect(pace.confidence == .high) + #expect(pace.isFallbackLinear == false) + } + + @Test + func weeklyPace_keepsLinearFallbackBelowTwentySamples() { + let now = Date(timeIntervalSince1970: 0) + let reset = now.addingTimeInterval((4 * 24 * 3600) + (6 * 3600)) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: reset, + resetDescription: nil) + + let duration = TimeInterval(7 * 24 * 3600) + let start = reset.addingTimeInterval(-duration) + + var bins = Array(repeating: 0.2, count: UsagePaceProfile.binsPerWeek) + var cursor = start + while cursor < now { + let idx = UsagePaceProfile.binIndex(for: cursor) + bins[idx] = 2.0 + cursor = cursor.addingTimeInterval(3600) + } + + let profile = UsagePaceProfile( + hourlyIntensity: bins, + sampleCount: 19, + activeBinCount: 24, + spanHours: 72) + + let pace = UsagePace.weekly(window: window, now: now, profile: profile) + + #expect(pace != nil) + guard let pace else { return } + #expect(pace.model == .linear) + #expect(pace.confidence == .low) + #expect(pace.isFallbackLinear == true) + } + @Test func weeklyPace_usesTimeOfDayProfileWhenConfidenceIsHigh() { let now = Date(timeIntervalSince1970: 0) diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index ba4e9653c..f2f39f34f 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -123,6 +123,20 @@ struct UsagePaceTextTests { #expect(detail == nil) } + @Test + func weeklyPaceDetail_hidesWhenWindowDurationIsUnknown() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.weeklyDetail(provider: .codex, window: window, now: now) + + #expect(detail == nil) + } + @Test func weeklyPaceDetail_showsLowConfidenceBadgeWhenFallingBackToLinear() { let now = Date(timeIntervalSince1970: 0)