Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
4437e35
Add subscription utilization history for Codex and Claude
maxceem Feb 17, 2026
5ccb8e5
Merge remote-tracking branch 'origin/main' into feature/usage-history
maxceem Feb 17, 2026
66e2ab5
Merge remote-tracking branch 'origin/main' into feature/usage-history
maxceem Feb 18, 2026
2534670
Align utilization bars to the left
maxceem Feb 18, 2026
0733d2e
Isolate plan-utilization tests from disk persistence
maxceem Feb 18, 2026
9535f1e
Default utilization chart to daily view
maxceem Feb 19, 2026
1981f51
Merge remote-tracking branch 'origin/main' into feature/usage-history
maxceem Feb 19, 2026
cfa72ea
Merge remote-tracking branch 'origin/main' into feature/usage-history
maxceem Feb 23, 2026
9dcb524
Merge remote-tracking branch 'origin/main' into feature/usage-history
maxceem Feb 25, 2026
53c86bd
Merge remote-tracking branch 'origin/main' into feature/usage-history
maxceem Mar 2, 2026
702450e
Merge remote-tracking branch 'origin/main' into feature/usage-history
maxceem Mar 6, 2026
3d1166a
Improve subscription utilization charts
maxceem Mar 6, 2026
6b7524c
Merge remote-tracking branch 'origin/main' into feature/usage-history
maxceem Mar 7, 2026
f2ab18b
Merge branch 'main' into feature/usage-history
maxceem Mar 10, 2026
77bbdd4
Scope plan utilization history by account identity
maxceem Mar 10, 2026
d159cdd
Merge remote-tracking branch 'origin/main' into feature/usage-history
maxceem Mar 15, 2026
3655d07
Scope plan utilization history to selected token accounts
maxceem Mar 15, 2026
b6ecc46
Merge tag 'v0.18.0' into feature/usage-history
maxceem Mar 16, 2026
d9e5353
Use selected token account for implicit plan history buckets
maxceem Mar 16, 2026
89d4d15
Refactor StatusMenuTests naming and remove obsolete history test
maxceem Mar 16, 2026
20b01ef
Show refreshing state and skip unscoped Claude history
maxceem Mar 16, 2026
0bf95f0
Record plan history for selected token account
maxceem Mar 16, 2026
aeb50d6
Coalesce plan utilization samples by hour bucket
maxceem Mar 17, 2026
e3d853a
Isolate plan history storage in tests
maxceem Mar 17, 2026
e99da37
Derive utilization history charts from windowed provider snapshots
maxceem Mar 17, 2026
be53dde
Normalize derived utilization by chart-period duration
maxceem Mar 17, 2026
ce3814b
Fill missing utilization periods with zero-value history bars
maxceem Mar 17, 2026
5126af0
Align weekly exact-fit bars to reset boundaries
maxceem Mar 17, 2026
b39632d
Refactor Claude source planning and harden debug probes
maxceem Mar 17, 2026
c13945f
Stabilize Claude plan utilization history account resolution
maxceem Mar 18, 2026
bf95cac
Merge branch 'main' into feature/usage-history
maxceem Mar 18, 2026
32125bd
Unify plan utilization account resolution across providers
maxceem Mar 18, 2026
6f8d41b
Clarify utilization detail copy and provenance messaging
maxceem Mar 18, 2026
017e62a
Preserve same-hour utilization samples across reset boundaries
maxceem Mar 18, 2026
3e05eb1
Preserve selected Claude history when recording secondary accounts
maxceem Mar 18, 2026
0005ee5
Extract Codex credits refresh/backfill into provider extension
maxceem Mar 18, 2026
267a2ab
Update utilization tests for same-hour window retention
maxceem Mar 18, 2026
4c10a75
Infer missing weekly reset anchors in exact-fit utilization chart
maxceem Mar 18, 2026
55fd6f5
Merge branch 'main' into feature/usage-history
maxceem Mar 18, 2026
0e3a7c2
Use Codex snapshot timestamps for history samples
maxceem Mar 19, 2026
c6c7998
Refactor usage history chart around provider series histories
maxceem Mar 20, 2026
7a320aa
Split plan utilization history into provider files
maxceem Mar 20, 2026
02fbb67
Merge branch 'main' into feature/usage-history
maxceem Mar 20, 2026
18b5a49
Prefer reset-aware plan utilization entries during coalescing
maxceem Mar 20, 2026
b85a624
Remove history migration script
maxceem Mar 20, 2026
2216520
Refine usage history submenu card and simplify submenu wiring
maxceem Mar 20, 2026
c4b0ca0
Hide usage history menu while provider data is still loading
maxceem Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
805 changes: 805 additions & 0 deletions Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift

Large diffs are not rendered by default.

249 changes: 249 additions & 0 deletions Sources/CodexBar/PlanUtilizationHistoryStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import CodexBarCore
import Foundation

struct PlanUtilizationSeriesName: RawRepresentable, Hashable, Codable, Sendable, ExpressibleByStringLiteral {
let rawValue: String

init(rawValue: String) {
self.rawValue = rawValue
}

init(stringLiteral value: StringLiteralType) {
self.rawValue = value
}

static let session: Self = "session"
static let weekly: Self = "weekly"
static let opus: Self = "opus"
}

struct PlanUtilizationHistoryEntry: Codable, Sendable, Equatable {
let capturedAt: Date
let usedPercent: Double
let resetsAt: Date?
}

struct PlanUtilizationSeriesHistory: Codable, Sendable, Equatable {
let name: PlanUtilizationSeriesName
let windowMinutes: Int
let entries: [PlanUtilizationHistoryEntry]

init(name: PlanUtilizationSeriesName, windowMinutes: Int, entries: [PlanUtilizationHistoryEntry]) {
self.name = name
self.windowMinutes = windowMinutes
self.entries = entries.sorted { lhs, rhs in
if lhs.capturedAt != rhs.capturedAt {
return lhs.capturedAt < rhs.capturedAt
}
if lhs.usedPercent != rhs.usedPercent {
return lhs.usedPercent < rhs.usedPercent
}
let lhsReset = lhs.resetsAt?.timeIntervalSince1970 ?? Date.distantPast.timeIntervalSince1970
let rhsReset = rhs.resetsAt?.timeIntervalSince1970 ?? Date.distantPast.timeIntervalSince1970
return lhsReset < rhsReset
}
}

var latestCapturedAt: Date? {
self.entries.last?.capturedAt
}
}

struct PlanUtilizationHistoryBuckets: Sendable, Equatable {
var preferredAccountKey: String?
var unscoped: [PlanUtilizationSeriesHistory] = []
var accounts: [String: [PlanUtilizationSeriesHistory]] = [:]

func histories(for accountKey: String?) -> [PlanUtilizationSeriesHistory] {
guard let accountKey, !accountKey.isEmpty else { return self.unscoped }
return self.accounts[accountKey] ?? []
}

mutating func setHistories(_ histories: [PlanUtilizationSeriesHistory], for accountKey: String?) {
let sorted = Self.sortedHistories(histories)
guard let accountKey, !accountKey.isEmpty else {
self.unscoped = sorted
return
}
if sorted.isEmpty {
self.accounts.removeValue(forKey: accountKey)
} else {
self.accounts[accountKey] = sorted
}
}

var isEmpty: Bool {
self.unscoped.isEmpty && self.accounts.values.allSatisfy(\.isEmpty)
}

private static func sortedHistories(_ histories: [PlanUtilizationSeriesHistory]) -> [PlanUtilizationSeriesHistory] {
histories.sorted { lhs, rhs in
if lhs.windowMinutes != rhs.windowMinutes {
return lhs.windowMinutes < rhs.windowMinutes
}
return lhs.name.rawValue < rhs.name.rawValue
}
}
}

private struct ProviderHistoryFile: Codable, Sendable {
let preferredAccountKey: String?
let unscoped: [PlanUtilizationSeriesHistory]
let accounts: [String: [PlanUtilizationSeriesHistory]]
}

private struct ProviderHistoryDocument: Codable, Sendable {
let version: Int
let preferredAccountKey: String?
let unscoped: [PlanUtilizationSeriesHistory]
let accounts: [String: [PlanUtilizationSeriesHistory]]
}

struct PlanUtilizationHistoryStore: Sendable {
fileprivate static let providerSchemaVersion = 1

let directoryURL: URL?

init(directoryURL: URL? = Self.defaultDirectoryURL()) {
self.directoryURL = directoryURL
}

static func defaultAppSupport() -> Self {
Self()
}

func load() -> [UsageProvider: PlanUtilizationHistoryBuckets] {
self.loadProviderFiles()
}

func save(_ providers: [UsageProvider: PlanUtilizationHistoryBuckets]) {
guard let directoryURL = self.directoryURL else { return }
do {
try FileManager.default.createDirectory(
at: directoryURL,
withIntermediateDirectories: true)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.sortedKeys]

for provider in UsageProvider.allCases {
let fileURL = self.providerFileURL(for: provider)
let buckets = providers[provider] ?? PlanUtilizationHistoryBuckets()
guard !buckets.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
continue
}

let payload = ProviderHistoryDocument(
version: Self.providerSchemaVersion,
preferredAccountKey: buckets.preferredAccountKey,
unscoped: Self.sortedHistories(buckets.unscoped),
accounts: Self.sortedAccounts(buckets.accounts))
let data = try encoder.encode(payload)
try data.write(to: fileURL, options: Data.WritingOptions.atomic)
}
} catch {
// Best-effort persistence only.
}
}

private func loadProviderFiles() -> [UsageProvider: PlanUtilizationHistoryBuckets] {
guard self.directoryURL != nil else { return [:] }

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601

var output: [UsageProvider: PlanUtilizationHistoryBuckets] = [:]

for provider in UsageProvider.allCases {
let fileURL = self.providerFileURL(for: provider)
guard FileManager.default.fileExists(atPath: fileURL.path) else { continue }
guard let data = try? Data(contentsOf: fileURL),
let decoded = try? decoder.decode(ProviderHistoryDocument.self, from: data)
else {
continue
}

let history = ProviderHistoryFile(
preferredAccountKey: decoded.preferredAccountKey,
unscoped: decoded.unscoped,
accounts: decoded.accounts)
output[provider] = Self.decodeProvider(history)
}

return output
}

private static func decodeProviders(
_ providers: [String: ProviderHistoryFile]) -> [UsageProvider: PlanUtilizationHistoryBuckets]
{
var output: [UsageProvider: PlanUtilizationHistoryBuckets] = [:]
for (rawProvider, providerHistory) in providers {
guard let provider = UsageProvider(rawValue: rawProvider) else { continue }
output[provider] = Self.decodeProvider(providerHistory)
}
return output
}

private static func decodeProvider(_ providerHistory: ProviderHistoryFile) -> PlanUtilizationHistoryBuckets {
PlanUtilizationHistoryBuckets(
preferredAccountKey: providerHistory.preferredAccountKey,
unscoped: self.sortedHistories(providerHistory.unscoped),
accounts: Dictionary(
uniqueKeysWithValues: providerHistory.accounts.compactMap { accountKey, histories in
let sorted = Self.sortedHistories(histories)
guard !sorted.isEmpty else { return nil }
return (accountKey, sorted)
}))
}

private static func sortedAccounts(
_ accounts: [String: [PlanUtilizationSeriesHistory]]) -> [String: [PlanUtilizationSeriesHistory]]
{
Dictionary(
uniqueKeysWithValues: accounts.compactMap { accountKey, histories in
let sorted = Self.sortedHistories(histories)
guard !sorted.isEmpty else { return nil }
return (accountKey, sorted)
})
}

private static func sortedHistories(_ histories: [PlanUtilizationSeriesHistory]) -> [PlanUtilizationSeriesHistory] {
histories.sorted { lhs, rhs in
if lhs.windowMinutes != rhs.windowMinutes {
return lhs.windowMinutes < rhs.windowMinutes
}
return lhs.name.rawValue < rhs.name.rawValue
}
}

private static func defaultDirectoryURL() -> URL? {
guard let root = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
return nil
}
let dir = root.appendingPathComponent("com.steipete.codexbar", isDirectory: true)
return dir.appendingPathComponent("history", isDirectory: true)
}

private func providerFileURL(for provider: UsageProvider) -> URL {
let directoryURL = self.directoryURL ?? URL(fileURLWithPath: "/dev/null", isDirectory: true)
return directoryURL.appendingPathComponent("\(provider.rawValue).json", isDirectory: false)
}
}

extension ProviderHistoryDocument {
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let version = try container.decode(Int.self, forKey: .version)
guard version == PlanUtilizationHistoryStore.providerSchemaVersion else {
throw DecodingError.dataCorruptedError(
forKey: .version,
in: container,
debugDescription: "Unsupported provider history schema version \(version)")
}
self.version = version
self.preferredAccountKey = try container.decodeIfPresent(String.self, forKey: .preferredAccountKey)
self.unscoped = try container.decode([PlanUtilizationSeriesHistory].self, forKey: .unscoped)
self.accounts = try container.decode([String: [PlanUtilizationSeriesHistory]].self, forKey: .accounts)
}
}
104 changes: 104 additions & 0 deletions Sources/CodexBar/Providers/Codex/UsageStore+CodexRefresh.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import CodexBarCore
import Foundation

@MainActor
extension UsageStore {
nonisolated static let codexSnapshotWaitTimeoutSeconds: TimeInterval = 6
nonisolated static let codexSnapshotPollIntervalNanoseconds: UInt64 = 100_000_000

func refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: Date? = nil) async {
guard self.isEnabled(.codex) else { return }
do {
let credits = try await self.codexFetcher.loadLatestCredits(
keepCLISessionsAlive: self.settings.debugKeepCLISessionsAlive)
await MainActor.run {
self.credits = credits
self.lastCreditsError = nil
self.lastCreditsSnapshot = credits
self.creditsFailureStreak = 0
}
let codexSnapshot = await MainActor.run {
self.snapshots[.codex]
}
if let minimumSnapshotUpdatedAt,
codexSnapshot == nil || codexSnapshot?.updatedAt ?? .distantPast < minimumSnapshotUpdatedAt
{
self.scheduleCodexPlanHistoryBackfill(
minimumSnapshotUpdatedAt: minimumSnapshotUpdatedAt)
return
}

self.cancelCodexPlanHistoryBackfill()
guard let codexSnapshot else { return }
await self.recordPlanUtilizationHistorySample(
provider: .codex,
snapshot: codexSnapshot,
now: codexSnapshot.updatedAt)
} catch {
let message = error.localizedDescription
if message.localizedCaseInsensitiveContains("data not available yet") {
await MainActor.run {
if let cached = self.lastCreditsSnapshot {
self.credits = cached
self.lastCreditsError = nil
} else {
self.credits = nil
self.lastCreditsError = "Codex credits are still loading; will retry shortly."
}
}
return
}

await MainActor.run {
self.creditsFailureStreak += 1
if let cached = self.lastCreditsSnapshot {
self.credits = cached
let stamp = cached.updatedAt.formatted(date: .abbreviated, time: .shortened)
self.lastCreditsError =
"Last Codex credits refresh failed: \(message). Cached values from \(stamp)."
} else {
self.lastCreditsError = message
self.credits = nil
}
}
}
}

func waitForCodexSnapshot(minimumUpdatedAt: Date) async -> UsageSnapshot? {
let deadline = Date().addingTimeInterval(Self.codexSnapshotWaitTimeoutSeconds)

while Date() < deadline {
if Task.isCancelled { return nil }
if let snapshot = await MainActor.run(body: { self.snapshots[.codex] }),
snapshot.updatedAt >= minimumUpdatedAt
{
return snapshot
}
try? await Task.sleep(nanoseconds: Self.codexSnapshotPollIntervalNanoseconds)
}

return nil
}

func scheduleCodexPlanHistoryBackfill(
minimumSnapshotUpdatedAt: Date)
{
self.cancelCodexPlanHistoryBackfill()
self.codexPlanHistoryBackfillTask = Task { @MainActor [weak self] in
guard let self else { return }
guard let snapshot = await self.waitForCodexSnapshot(minimumUpdatedAt: minimumSnapshotUpdatedAt) else {
return
}
await self.recordPlanUtilizationHistorySample(
provider: .codex,
snapshot: snapshot,
now: snapshot.updatedAt)
self.codexPlanHistoryBackfillTask = nil
}
}

func cancelCodexPlanHistoryBackfill() {
self.codexPlanHistoryBackfillTask?.cancel()
self.codexPlanHistoryBackfillTask = nil
}
}
Loading