Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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)
Comment on lines +78 to +79

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the pinned calendar for every Codex cache read

The pinned calendar is passed only to this foreground refresh. The startup hydration path still calls loadCachedCodexTokenSnapshotResult on the default fetcher, and the Spend Dashboard constructs CostUsageFetcher(cacheRoot:) without a calendar, so after the system timezone changes those paths bucket the same cache using .current while this path uses the pinned zone. This can initially publish different day totals and lets dashboard scans re-bucket history; propagate the pinned calendar through the shared fetcher and cached/dashboard loaders as well.

Useful? React with 👍 / 👎.

}
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