Skip to content
63 changes: 63 additions & 0 deletions Sources/CodexBar/SettingsStore+Defaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,9 @@ extension SettingsStore {
if changed {
self.costUsageSettingsRevision &+= 1
}
if newValue {
self.pinCostUsageBucketTimeZoneIfNeeded()
}
self.noteBackgroundWorkSettingsChanged()
}
}
Expand Down Expand Up @@ -544,6 +547,66 @@ extension SettingsStore {
}
}

var costUsageBucketTimeZoneIdentifier: String {
get { self.defaultsState.costUsageBucketTimeZoneIdentifier }
set {
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = CostUsageBucketTimeZone.isValidIdentifier(trimmed) ? trimmed : ""
let changed = self.defaultsState.costUsageBucketTimeZoneIdentifier != normalized
self.defaultsState.costUsageBucketTimeZoneIdentifier = normalized
self.userDefaults.set(normalized, forKey: "tokenCostUsageBucketTimeZone")
if changed {
self.costUsageSettingsRevision &+= 1
}
}
}

var costUsageBucketCalendar: Calendar {
CostUsageBucketTimeZone.calendar(identifier: self.costUsageBucketTimeZoneIdentifier)
}

var openCodexUsageLogsEnabled: Bool {
get { self.defaultsState.openCodexUsageLogsEnabled }
set {
let changed = self.defaultsState.openCodexUsageLogsEnabled != newValue
self.defaultsState.openCodexUsageLogsEnabled = newValue
self.userDefaults.set(newValue, forKey: "openCodexUsageLogsEnabled")
if changed {
self.costUsageSettingsRevision &+= 1
}
}
}

var hideNativeCodexCostWhenOpenCodexPresent: Bool {
get { self.defaultsState.hideNativeCodexCostWhenOpenCodexPresent }
set {
let changed = self.defaultsState.hideNativeCodexCostWhenOpenCodexPresent != newValue
self.defaultsState.hideNativeCodexCostWhenOpenCodexPresent = newValue
self.userDefaults.set(newValue, forKey: "hideNativeCodexCostWhenOpenCodexPresent")
if changed {
self.costUsageSettingsRevision &+= 1
}
}
}

var spendDashboardHiddenSourceIDs: [String] {
get { self.defaultsState.spendDashboardHiddenSourceIDs }
set {
let normalized = Array(Set(newValue.filter { !$0.isEmpty })).sorted()
let changed = self.defaultsState.spendDashboardHiddenSourceIDs != normalized
self.defaultsState.spendDashboardHiddenSourceIDs = normalized
self.userDefaults.set(normalized, forKey: "spendDashboardHiddenSourceIDs")
if changed {
self.costUsageSettingsRevision &+= 1
}
}
}

func pinCostUsageBucketTimeZoneIfNeeded() {
guard self.costUsageBucketTimeZoneIdentifier.isEmpty else { return }
self.costUsageBucketTimeZoneIdentifier = CostUsageBucketTimeZone.pinIdentifier()
}

var costComparisonPeriodsEnabled: Bool {
get { self.defaultsState.costComparisonPeriodsEnabled }
set {
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/SettingsStore+MenuObservation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ extension SettingsStore {
_ = self.costUsageEnabled
_ = self.codexLocalSessionCostLedgerEnabled
_ = self.costUsageHistoryDays
_ = self.costUsageBucketTimeZoneIdentifier
_ = self.openCodexUsageLogsEnabled
_ = self.hideNativeCodexCostWhenOpenCodexPresent
_ = self.spendDashboardHiddenSourceIDs
_ = self.costComparisonPeriodsEnabled
_ = self.costSummaryDisplayStyle
_ = self.appLanguage
Expand Down
15 changes: 15 additions & 0 deletions Sources/CodexBar/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,17 @@ extension SettingsStore {
forKey: "codexLocalSessionCostLedgerEnabled") as? Bool ?? false
let rawCostUsageHistoryDays = userDefaults.object(forKey: "tokenCostUsageHistoryDays") as? Int ?? 30
let costUsageHistoryDays = max(1, min(365, rawCostUsageHistoryDays))
let storedBucketTimeZone = userDefaults.string(forKey: "tokenCostUsageBucketTimeZone") ?? ""
let costUsageBucketTimeZoneIdentifier = CostUsageBucketTimeZone.isValidIdentifier(storedBucketTimeZone)
? storedBucketTimeZone
: (costUsageEnabled ? CostUsageBucketTimeZone.pinIdentifier() : "")
if costUsageEnabled, storedBucketTimeZone.isEmpty, !costUsageBucketTimeZoneIdentifier.isEmpty {
userDefaults.set(costUsageBucketTimeZoneIdentifier, forKey: "tokenCostUsageBucketTimeZone")
}
let openCodexUsageLogsEnabled = userDefaults.object(forKey: "openCodexUsageLogsEnabled") as? Bool ?? false
let hideNativeCodexCostWhenOpenCodexPresent = userDefaults.object(
forKey: "hideNativeCodexCostWhenOpenCodexPresent") as? Bool ?? false
let spendDashboardHiddenSourceIDs = userDefaults.stringArray(forKey: "spendDashboardHiddenSourceIDs") ?? []
let costComparisonPeriodsEnabled = userDefaults.object(
forKey: "costComparisonPeriodsEnabled") as? Bool ?? false
let costSummaryDisplayStyleRaw = Self.loadCostSummaryDisplayStyleRaw(
Expand Down Expand Up @@ -673,6 +684,10 @@ extension SettingsStore {
costUsageEnabled: costUsageEnabled,
codexLocalSessionCostLedgerEnabled: codexLocalSessionCostLedgerEnabled,
costUsageHistoryDays: costUsageHistoryDays,
costUsageBucketTimeZoneIdentifier: costUsageBucketTimeZoneIdentifier,
openCodexUsageLogsEnabled: openCodexUsageLogsEnabled,
hideNativeCodexCostWhenOpenCodexPresent: hideNativeCodexCostWhenOpenCodexPresent,
spendDashboardHiddenSourceIDs: spendDashboardHiddenSourceIDs,
costComparisonPeriodsEnabled: costComparisonPeriodsEnabled,
costSummaryDisplayStyleRaw: costSummaryDisplayStyleRaw,
hidePersonalInfo: hidePersonalInfo,
Expand Down
4 changes: 4 additions & 0 deletions Sources/CodexBar/SettingsStoreState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ struct SettingsDefaultsState {
var costUsageEnabled: Bool
var codexLocalSessionCostLedgerEnabled: Bool
var costUsageHistoryDays: Int
var costUsageBucketTimeZoneIdentifier: String
var openCodexUsageLogsEnabled: Bool
var hideNativeCodexCostWhenOpenCodexPresent: Bool
var spendDashboardHiddenSourceIDs: [String]
var costComparisonPeriodsEnabled: Bool
var costSummaryDisplayStyleRaw: String
var hidePersonalInfo: Bool
Expand Down
7 changes: 5 additions & 2 deletions Sources/CodexBar/UsageStore+CodexCostCatchUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,9 @@ extension UsageStore {
if let override = self._test_codexCostCatchUpStatusOverride {
return await override(codexHomePath)
}
return await self.costUsageFetcher.codexScanCatchUpStatus(codexHomePath: codexHomePath)
return await self.costUsageFetcher.codexScanCatchUpStatus(
codexHomePath: codexHomePath,
calendar: self.settings.costUsageBucketCalendar)
}

private func advanceCodexCostCatchUp(
Expand All @@ -314,7 +316,8 @@ extension UsageStore {
return try await self.costUsageFetcher.advanceCodexScanCatchUp(
now: now,
codexHomePath: codexHomePath,
historyDays: historyDays)
historyDays: historyDays,
calendar: self.settings.costUsageBucketCalendar)
}

private func codexCostCatchUpDecision(
Expand Down
14 changes: 10 additions & 4 deletions Sources/CodexBar/UsageStore+SpendDashboardCodexCostCatchUp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,11 @@ extension UsageStore {
statuses[account.cacheIdentity] = await override(account)
} else {
statuses[account.cacheIdentity] = await CostUsageFetcher(
cacheRoot: SpendDashboardSource.codexCacheRoot(for: account))
.codexScanCatchUpStatus(codexHomePath: account.homePath)
cacheRoot: SpendDashboardSource.codexCacheRoot(for: account),
calendar: self.settings.costUsageBucketCalendar)
.codexScanCatchUpStatus(
codexHomePath: account.homePath,
calendar: self.settings.costUsageBucketCalendar)
}
}
return statuses
Expand All @@ -291,11 +294,14 @@ extension UsageStore {
if let override = self._test_spendDashboardCodexCostCatchUpAdvanceOverride {
return try await override(account, now, historyDays)
}
return try await CostUsageFetcher(cacheRoot: SpendDashboardSource.codexCacheRoot(for: account))
return try await CostUsageFetcher(
cacheRoot: SpendDashboardSource.codexCacheRoot(for: account),
calendar: self.settings.costUsageBucketCalendar)
.advanceCodexScanCatchUp(
now: now,
codexHomePath: account.homePath,
historyDays: historyDays)
historyDays: historyDays,
calendar: self.settings.costUsageBucketCalendar)
}

private func spendDashboardCodexCostCatchUpDecision(
Expand Down
6 changes: 4 additions & 2 deletions Sources/CodexBar/UsageStore+TokenCost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ extension UsageStore {
historyDays: historyDays,
cursorCookieHeaderOverride: cursorCookieHeaderOverride,
allowPricingRefresh: allowPricingRefresh,
bypassScannerDebounce: true)
bypassScannerDebounce: true,
calendar: self.settings.costUsageBucketCalendar)
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
Expand Down Expand Up @@ -228,7 +229,8 @@ extension UsageStore {
await self.costUsageFetcher.loadCachedCodexTokenSnapshotResult(
now: now,
codexHomePath: scope.codexHomePath,
historyDays: historyDays)
historyDays: historyDays,
calendar: self.settings.costUsageBucketCalendar)
.map {
(
snapshot: $0.snapshot,
Expand Down
5 changes: 4 additions & 1 deletion Sources/CodexBarCore/CodexLocalProjectUsageIndexer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,10 @@ extension CodexLocalProjectUsageIndexer {
var skippedFiles = 0
var sessionBuckets: [String: SessionBucket] = [:]
let files = cache.files.sorted(by: { $0.key < $1.key }).filter {
$0.value.touchesCodexScanWindow(sinceKey: range.sinceKey, untilKey: range.untilKey)
$0.value.touchesCodexScanWindow(
sinceKey: range.sinceKey,
untilKey: range.untilKey,
calendar: range.calendar)
}
progress?(CodexLocalProjectUsageIndexProgress(
phase: .indexingProjects,
Expand Down
185 changes: 185 additions & 0 deletions Sources/CodexBarCore/CostProvenance.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import Foundation

/// How a cost figure was produced. This is display-time accounting, not a billing receipt.
public enum CostProvenance: String, Sendable, Equatable, Codable {
/// Token counts × public API list prices.
case listPriceEstimate
/// Vendor-reported metered spend (for example Cursor plan deductions).
case vendorMetered
/// Window mixes list-price rows with vendor-metered rows.
case mixed
case unknown

public var isBillingReceipt: Bool {
false
}

/// Narrow a snapshot-level provenance to the costs actually present in a window.
/// Daily vendor-reported rows stay vendor-metered even when `meteredCostUSD` is absent.
public static func forWindow(
snapshot: CostProvenance,
hasWindowCosts: Bool,
includesMetered: Bool) -> CostProvenance
{
switch snapshot {
case .vendorMetered:
includesMetered || hasWindowCosts ? .vendorMetered : .unknown
case .mixed:
switch (includesMetered, hasWindowCosts) {
case (true, true):
.mixed
case (true, false):
.vendorMetered
case (false, true):
.listPriceEstimate
case (false, false):
.unknown
}
case .listPriceEstimate:
hasWindowCosts ? .listPriceEstimate : .unknown
case .unknown:
.unknown
}
}
}

/// Request/row coverage for a cost window. Counts stay independent so a missing
/// category is `0` rather than collapsing into another bucket.
public struct CostUsageCoverageCounts: Sendable, Equatable, Codable {
public var priced: Int
public var unpriced: Int
public var unmetered: Int
public var estimated: Int

public init(priced: Int = 0, unpriced: Int = 0, unmetered: Int = 0, estimated: Int = 0) {
self.priced = max(0, priced)
self.unpriced = max(0, unpriced)
self.unmetered = max(0, unmetered)
self.estimated = max(0, estimated)
}

public var total: Int {
self.priced + self.unpriced + self.unmetered + self.estimated
}

public var coverageRatio: Double? {
let measured = self.priced + self.estimated
let denominator = self.total
guard denominator > 0 else { return nil }
return Double(measured) / Double(denominator)
}

public mutating func merge(_ other: CostUsageCoverageCounts) {
self.priced += other.priced
self.unpriced += other.unpriced
self.unmetered += other.unmetered
self.estimated += other.estimated
}

public static func + (lhs: Self, rhs: Self) -> Self {
var merged = lhs
merged.merge(rhs)
return merged
}
}

/// Token-class mix. `nil` means the source did not establish that class — never treat as zero.
public struct CostUsageTokenMix: Sendable, Equatable, Codable {
public var inputTokens: Int?
public var outputTokens: Int?
public var cacheReadTokens: Int?
public var cacheCreationTokens: Int?
public var reasoningTokens: Int?

public init(
inputTokens: Int? = nil,
outputTokens: Int? = nil,
cacheReadTokens: Int? = nil,
cacheCreationTokens: Int? = nil,
reasoningTokens: Int? = nil)
{
self.inputTokens = Self.nonnegative(inputTokens)
self.outputTokens = Self.nonnegative(outputTokens)
self.cacheReadTokens = Self.nonnegative(cacheReadTokens)
self.cacheCreationTokens = Self.nonnegative(cacheCreationTokens)
self.reasoningTokens = Self.nonnegative(reasoningTokens)
}

public var hasAnyClass: Bool {
self.inputTokens != nil
|| self.outputTokens != nil
|| self.cacheReadTokens != nil
|| self.cacheCreationTokens != nil
|| self.reasoningTokens != nil
}

public mutating func merge(_ other: CostUsageTokenMix) {
self.inputTokens = Self.add(self.inputTokens, other.inputTokens)
self.outputTokens = Self.add(self.outputTokens, other.outputTokens)
self.cacheReadTokens = Self.add(self.cacheReadTokens, other.cacheReadTokens)
self.cacheCreationTokens = Self.add(self.cacheCreationTokens, other.cacheCreationTokens)
self.reasoningTokens = Self.add(self.reasoningTokens, other.reasoningTokens)
}

public static func + (lhs: Self, rhs: Self) -> Self {
var merged = lhs
merged.merge(rhs)
return merged
}

public static func from(entry: CostUsageDailyReport.Entry) -> Self {
Self(
inputTokens: entry.inputTokens,
outputTokens: entry.outputTokens,
cacheReadTokens: entry.cacheReadTokens,
cacheCreationTokens: entry.cacheCreationTokens,
reasoningTokens: entry.reasoningTokens)
}

private static func nonnegative(_ value: Int?) -> Int? {
guard let value, value >= 0 else { return nil }
return value
}

private static func add(_ lhs: Int?, _ rhs: Int?) -> Int? {
switch (lhs, rhs) {
case let (left?, right?):
let (result, overflow) = left.addingReportingOverflow(right)
return overflow ? nil : result
case let (left?, nil):
return left
case let (nil, right?):
return right
case (nil, nil):
return nil
}
}
}

/// Pinned IANA timezone used to bucket cost-usage days. Re-bucketing the same history
/// under a different zone would move midnight-adjacent events and inflate totals.
public enum CostUsageBucketTimeZone: Sendable {
public static func calendar(identifier: String?) -> Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = self.timeZone(identifier: identifier)
return calendar
}

public static func timeZone(identifier: String?) -> TimeZone {
if let identifier {
let trimmed = identifier.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty, let zone = TimeZone(identifier: trimmed) {
return zone
}
}
return .current
}

public static func pinIdentifier(from timeZone: TimeZone = .current) -> String {
timeZone.identifier
}

public static func isValidIdentifier(_ identifier: String) -> Bool {
TimeZone(identifier: identifier) != nil
}
}
Loading
Loading