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
Binary file added .github/pr-proof/overview-spend-summary.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 0.52.1 — Unreleased

### Added
- Overview: show selected-provider usage and spend with partial-price, coverage, and provenance context (#3023). Thanks @Chipagosfinest!
- CLI: add `codexbar usage --format toon` emitting the JSON payload as TOON v4.1 for agents that prefer denser structured output (#2996, #3021). Thanks @elijahfriedman and @teseo for the spec!
- Usage & Spend: add an All time range alongside 7d/30d, backed by 365 days of local history with dedicated Claude and Cursor spend snapshot slots (#3009). Thanks @Yuxin-Qiao!
- Usage & Spend: add explicit cost provenance, coverage counters, and a pinned IANA day-bucketing timezone while making uncertain Codex fork accounting fail closed (#3015). Thanks @Yuxin-Qiao!
Expand Down
26 changes: 26 additions & 0 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,32 @@ extension StatusItemController {
let t0 = CACurrentMediaTime()
defer { self.logChartRenderDurationIfSlow("addOverviewRows(\(rows.count))", startedAt: t0) }

let spendProviders = overviewProviders.filter { self.settings.costSummaryShowsInline(for: $0) }
let spendModel = self.overviewSpendDashboardModel(providers: spendProviders)
if !spendModel.groups.isEmpty {
let spendSummary = OverviewSpendSummary(
model: spendModel,
providerCount: spendProviders.count)
let summaryItem = self.makeMenuCardItem(
OverviewSpendSummaryCardView(
summary: spendSummary,
days: spendModel.requestedDays,
width: menuWidth),
id: "overviewSpendSummary",
width: menuWidth,
heightCacheScope: "overviewSpendSummary",
heightCacheFingerprint: [
spendSummary.primarySpendText,
spendSummary.providerCoverageText,
spendSummary.tokenText ?? "",
spendSummary.historyCoverageText,
spendSummary.pricingCoverageText,
spendSummary.provenanceText,
].joined(separator: "|"))
menu.addItem(summaryItem)
menu.addItem(.separator())
}

for (index, row) in rows.enumerated() {
let identifier = "\(Self.overviewRowIdentifierPrefix)\(row.provider.rawValue)"
let storageText = self.store.storageFootprintText(for: row.provider)
Expand Down
145 changes: 145 additions & 0 deletions Sources/CodexBar/StatusItemController+OverviewSpend.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import CodexBarCore
import Foundation
import SwiftUI

struct OverviewSpendSummary: Equatable {
let primarySpendText: String
let providerCoverageText: String
let tokenText: String?
let historyCoverageText: String
let pricingCoverageText: String
let provenanceText: String
let isPartial: Bool

init(model: SpendDashboardModel, providerCount: Int) {
let includedProviders = model.groups.flatMap(\.providers)
let providerCount = max(max(0, providerCount), includedProviders.count)
let pricedProviderCount = includedProviders.count { $0.totalCost != nil }
let tokenProviderCount = includedProviders.count { $0.totalTokens != nil }
let isPartial = pricedProviderCount > 0 && pricedProviderCount < providerCount
self.isPartial = isPartial

self.primarySpendText = model.groups.isEmpty
? L("Spend unavailable")
: model.groups.map { group in
let text = spendDashboardGroupCostText(group)
guard isPartial, group.totalCost != nil, !text.hasPrefix("~") else { return text }
return "~\(text)"
}.joined(separator: " · ")
self.providerCoverageText = L(
"%d of %d subscriptions have spend",
pricedProviderCount,
providerCount)

let tokens = Self.safeTokenSum(model.groups.compactMap(\.totalTokens))
self.tokenText = tokens.map {
let value = ShareStatsFormatting.compactCount($0)
let isPartial = tokenProviderCount < providerCount
return L("%@ tokens", isPartial ? "~\(value)" : value)
}

let coveredDays = includedProviders.count < providerCount
? 0
: model.groups.map(\.coveredDayCount).min() ?? 0
self.historyCoverageText = spendDashboardCoverageText(
covered: coveredDays,
requested: model.requestedDays)

let coverage = model.groups.reduce(into: CostUsageCoverageCounts()) { result, group in
result.merge(group.coverage)
}
self.pricingCoverageText = spendDashboardCoverageChipText(coverage)
self.provenanceText = model.groups
.map(\.provenance)
.reduce(into: [CostProvenance]()) { values, provenance in
guard !values.contains(provenance) else { return }
values.append(provenance)
}
.map(spendDashboardProvenanceText)
.joined(separator: " · ")
}

private static func safeTokenSum(_ values: [Int]) -> Int? {
guard !values.isEmpty else { return nil }
var total = 0
for value in values {
let result = total.addingReportingOverflow(value)
guard !result.overflow else { return nil }
total = result.partialValue
}
return total
}
}

struct OverviewSpendSummaryCardView: View {
let summary: OverviewSpendSummary
let days: Int
let width: CGFloat

var body: some View {
VStack(alignment: .leading, spacing: 7) {
HStack(spacing: 6) {
Text(L("Usage & Spend"))
.font(.headline.weight(.semibold))
Text("·")
Text(spendDashboardDayRangeText(self.days))
}
.foregroundStyle(.secondary)

Text(self.summary.primarySpendText)
.font(.system(.title2, design: .rounded, weight: .bold))
.monospacedDigit()
.lineLimit(2)

HStack(spacing: 8) {
Text(self.summary.providerCoverageText)
if let tokenText = self.summary.tokenText {
Text("·")
Text(tokenText)
}
}
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)

Text("\(self.summary.historyCoverageText) · \(self.summary.provenanceText)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
Text(self.summary.pricingCoverageText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
.padding(.horizontal, UsageMenuCardLayout.horizontalPadding)
.padding(.vertical, 10)
.frame(width: self.width, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.accentColor.opacity(0.08))
.padding(.horizontal, 6)
}
}
}

extension StatusItemController {
func overviewSpendDashboardModel(
providers: [UsageProvider],
now: Date = Date()) -> SpendDashboardModel
{
let inputs = providers.compactMap { provider -> SpendDashboardModel.ProviderInput? in
guard let snapshot = self.store.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot else {
return nil
}
return SpendDashboardModel.ProviderInput(
provider: provider,
displayName: self.store.metadata(for: provider).displayName,
snapshot: snapshot)
}
return SpendDashboardModel.build(
inputs: inputs,
requestedDays: self.settings.costUsageHistoryDays,
now: now,
preferredCurrencyCode: self.settings.preferredCurrencyCode)
Comment on lines +139 to +143

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 Overview spend

When the system time zone has changed since cost tracking pinned costUsageBucketTimeZoneIdentifier, this call falls back to Calendar.current, unlike SpendDashboardController.rebuildModel, which passes the configured bucket calendar. Around either zone's midnight, the Overview can therefore interpret stored YYYY-MM-DD buckets in a different zone from the scanner and include or exclude a boundary day, making its spend and coverage disagree with Usage & Spend; pass settings.costUsageBucketCalendar into this build.

Useful? React with 👍 / 👎.

}
}
138 changes: 138 additions & 0 deletions Tests/CodexBarTests/OverviewSpendSummaryTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import CodexBarCore
import Foundation
import Testing
@testable import CodexBar

struct OverviewSpendSummaryTests {
@Test
func `summary marks incomplete provider coverage as partial`() {
let group = self.group(
providers: [
self.provider(.codex, tokens: 4_800_000, cost: 412.64),
self.provider(.claude, tokens: nil, cost: nil),
self.provider(.openrouter, tokens: 9_640_000, cost: 282.74),
self.provider(.cursor, tokens: 1_250_000, cost: 64.18),
],
totalTokens: 15_690_000,
totalCost: 759.56,
coverage: CostUsageCoverageCounts(priced: 3, unpriced: 1))

let summary = OverviewSpendSummary(
model: SpendDashboardModel(requestedDays: 30, groups: [group]),
providerCount: 4)

#expect(summary.primarySpendText == "~$759.56")
#expect(summary.providerCoverageText == "3 of 4 subscriptions have spend")
#expect(summary.tokenText == "~15.7M tokens")
#expect(summary.historyCoverageText == "Coverage: 30 / 30")
#expect(summary.pricingCoverageText == "Priced 3 · Unpriced 1 · Unmetered 0 · Estimated 0")
#expect(summary.provenanceText == "List-price equivalent")
#expect(summary.isPartial)
}

@Test
func `summary keeps distinct currencies separate`() {
let usd = self.group(
currencyCode: "USD",
providers: [self.provider(.codex, tokens: 1000, cost: 12)],
totalTokens: 1000,
totalCost: 12,
coveredDayCount: 7)
let eur = self.group(
currencyCode: "EUR",
providers: [self.provider(.claude, tokens: 2000, cost: 8)],
totalTokens: 2000,
totalCost: 8,
coveredDayCount: 7,
provenance: .vendorMetered)

let summary = OverviewSpendSummary(
model: SpendDashboardModel(requestedDays: 7, groups: [eur, usd]),
providerCount: 2)

#expect(summary.primarySpendText.contains("$12.00"))
#expect(summary.primarySpendText.contains("€8.00"))
#expect(summary.providerCoverageText == "2 of 2 subscriptions have spend")
#expect(summary.tokenText == "3K tokens")
#expect(summary.historyCoverageText == "Coverage: 7 / 7")
#expect(summary.provenanceText == "Plan metered · List-price equivalent")
#expect(!summary.isPartial)
}

@Test
func `summary keeps wholly unpriced spend unavailable`() {
let group = self.group(
providers: [self.provider(.claude, tokens: 2000, cost: nil)],
totalTokens: 2000,
totalCost: nil,
coverage: CostUsageCoverageCounts(unpriced: 1),
provenance: .unknown)

let summary = OverviewSpendSummary(
model: SpendDashboardModel(requestedDays: 30, groups: [group]),
providerCount: 1)

#expect(summary.primarySpendText == "Spend unavailable")
#expect(summary.providerCoverageText == "0 of 1 subscriptions have spend")
#expect(summary.pricingCoverageText == "Priced 0 · Unpriced 1 · Unmetered 0 · Estimated 0")
#expect(summary.provenanceText == "Spend unavailable")
#expect(!summary.isPartial)
}

@Test
func `summary marks a missing selected provider as partial`() {
let group = self.group(
providers: [self.provider(.codex, tokens: 1000, cost: 12)],
totalTokens: 1000,
totalCost: 12)

let summary = OverviewSpendSummary(
model: SpendDashboardModel(requestedDays: 30, groups: [group]),
providerCount: 2)

#expect(summary.primarySpendText == "~$12.00")
#expect(summary.providerCoverageText == "1 of 2 subscriptions have spend")
#expect(summary.tokenText == "~1K tokens")
#expect(summary.historyCoverageText == "Coverage: 0 / 30")
#expect(summary.isPartial)
}

private func provider(
_ provider: UsageProvider,
tokens: Int?,
cost: Double?) -> SpendDashboardModel.ProviderRow
{
SpendDashboardModel.ProviderRow(
id: provider.rawValue,
rank: 1,
provider: provider,
displayName: provider.rawValue,
totalTokens: tokens,
totalCost: cost,
coveredDayCount: 30)
}

private func group(
currencyCode: String = "USD",
providers: [SpendDashboardModel.ProviderRow],
totalTokens: Int?,
totalCost: Double?,
coveredDayCount: Int = 30,
coverage: CostUsageCoverageCounts? = nil,
provenance: CostProvenance = .listPriceEstimate) -> SpendDashboardModel.CurrencyGroup
{
SpendDashboardModel.CurrencyGroup(
currencyCode: currencyCode,
providers: providers,
models: [],
projects: [],
dailyPoints: [],
totalTokens: totalTokens,
totalCost: totalCost,
coveredDayCount: coveredDayCount,
chartDomain: Date(timeIntervalSince1970: 0)...Date(timeIntervalSince1970: 86400),
modelHistoryCompleteness: totalCost == nil ? .incomplete : .complete,
coverage: coverage ?? CostUsageCoverageCounts(priced: providers.count),
provenance: provenance)
}
}
6 changes: 3 additions & 3 deletions Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -962,7 +962,7 @@ struct ProviderArchitectureGatekeeperTests {
reason: "The memory-pressure debug fixture installs its synthetic entry in the Codex cache slot."),
SuppressedProviderReference(
path: "Sources/CodexBar/StatusItemController+Menu.swift",
line: 1087,
line: 1113,
anchor: "controller.refreshOpenMenuIfStillVisible(menu, provider: .codex)",
expectedProviderIDs: ["codex"],
reason: "This provider-specific app branch passes its already-selected identity to a shared helper."),
Expand Down Expand Up @@ -2482,15 +2482,15 @@ struct ProviderArchitectureGatekeeperTests {
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
AllowedProviderConstruct(
path: "Sources/CodexBar/StatusItemController+Menu.swift",
line: 1120,
line: 1146,
anchor: "return .provider((self.resolvedMenuProvider(enabledProviders: enabledProviders) ?? .codex).instanceID)",
expectedProviderIDs: ["codex"],
expectedReferenceCount: 1,
expectedReferenceFingerprint: ["codex@0"],
reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."),
AllowedProviderConstruct(
path: "Sources/CodexBar/StatusItemController+Menu.swift",
line: 1133,
line: 1159,
anchor: "return self.store.enabledFirstPartyProvidersForDisplay().first ?? .codex",
expectedProviderIDs: ["codex"],
expectedReferenceCount: 1,
Expand Down
9 changes: 9 additions & 0 deletions Tests/CodexBarTests/SpendDashboardScreenshotRenderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ final class SpendDashboardScreenshotRenderTests: XCTestCase {
let renders: [(String, AnyView)] = [
("usage-spend-30d", AnyView(Self.chrome(selectedDays: 30, group: thirtyGroup))),
("usage-spend-all", AnyView(Self.chrome(selectedDays: SpendDashboardSource.scanDays, group: allGroup))),
(
"overview-spend-summary",
AnyView(
OverviewSpendSummaryCardView(
summary: OverviewSpendSummary(model: thirty, providerCount: 2),
days: 30,
width: 320)
.padding(.vertical, 8)
.background(Color(nsColor: .windowBackgroundColor)))),
]
for (name, view) in renders {
let data = try XCTUnwrap(Self.pngData(for: view), "render failed for \(name)")
Expand Down
Loading