Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,6 @@ docs/.astro/
# Swift Package Manager metadata (leave sources tracked)
# Packages/
# Package.resolved

# fanout round cache (local tooling)
.fanout-cache/
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 0.37.3 — Unreleased

### Fixed
- Cost usage: "Today" now reflects the current local day and shows $0.00 · 0 tokens when there is no usage today, instead of the latest historical bucket — across the token-cost menu card, inline dashboard, and the OpenAI/Claude Admin and Poe summaries. Thanks @LeoLin990405!

## 0.37.2 — 2026-06-22

### Added
Expand Down
17 changes: 11 additions & 6 deletions Sources/CodexBar/InlineUsageDashboardContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ extension UsageMenuCardView.Model {
}

static func openAIAPIUsageNotes(_ usage: OpenAIAPIUsageSnapshot) -> [String] {
let today = usage.latestDay
let today = usage.currentDay()
let seven = usage.last7Days
let thirty = usage.last30Days
let historyLabel = usage.historyWindowLabel
Expand All @@ -114,7 +114,7 @@ extension UsageMenuCardView.Model {
}

static func poeUsageNotes(_ usage: PoeUsageHistorySnapshot) -> [String] {
let today = usage.latestDay
let today = usage.currentDay()
let week = usage.last7Days
let month = usage.last30Days
let todayUSD = today.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? ""
Expand Down Expand Up @@ -230,7 +230,7 @@ extension UsageMenuCardView.Model {
}

static func poeInlineDashboard(_ usage: PoeUsageHistorySnapshot) -> InlineUsageDashboardModel {
let today = usage.latestDay
let today = usage.currentDay()
let week = usage.last7Days
let month = usage.last30Days
let points = usage.daily.suffix(30).map {
Expand Down Expand Up @@ -332,13 +332,18 @@ extension UsageMenuCardView.Model {
details.append(L("cost_estimate_hint"))
}
let providerName = ProviderDefaults.metadata[provider]?.displayName ?? provider.rawValue
let usesLatestLabel = provider == .bedrock || provider == .mistral
return InlineUsageDashboardModel(
accessibilityLabel: "\(providerName) \(periodLabel) cost trend",
valueStyle: Self.costValueStyle(currencyCode: snapshot.currencyCode),
kpis: [
.init(
title: provider == .bedrock || provider == .mistral ? L("Latest") : L("Today"),
value: latest?.costUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—",
title: usesLatestLabel ? L("Latest") : L("Today"),
// "Today" must show the current local day (snapshot.sessionCostUSD,
// already zeroed when there's no usage today); only "Latest"
// providers keep the newest historical bucket (#1705).
value: (usesLatestLabel ? latest?.costUSD : snapshot.sessionCostUSD)
.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—",
emphasis: true),
.init(
title: historyTitle,
Expand Down Expand Up @@ -378,7 +383,7 @@ extension UsageMenuCardView.Model {
fileprivate static func claudeAdminAPIInlineDashboard(_ usage: ClaudeAdminAPIUsageSnapshot)
-> InlineUsageDashboardModel
{
let today = usage.latestDay
let today = usage.currentDay()
let last7 = usage.last7Days
let last30 = usage.last30Days
let points = usage.daily.suffix(30).map {
Expand Down
6 changes: 3 additions & 3 deletions Sources/CodexBar/MenuDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ struct MenuDescriptor {
entries: inout [Entry],
usage: OpenAIAPIUsageSnapshot)
{
let today = usage.latestDay
let today = usage.currentDay()
let last7 = usage.last7Days
let last30 = usage.last30Days
let historyLabel = usage.historyWindowLabel
Expand All @@ -319,7 +319,7 @@ struct MenuDescriptor {
entries: inout [Entry],
usage: ClaudeAdminAPIUsageSnapshot)
{
let today = usage.latestDay
let today = usage.currentDay()
let last7 = usage.last7Days
let last30 = usage.last30Days

Expand Down Expand Up @@ -393,7 +393,7 @@ struct MenuDescriptor {
entries: inout [Entry],
usage: PoeUsageHistorySnapshot)
{
let today = usage.latestDay
let today = usage.currentDay()
let week = usage.last7Days
let month = usage.last30Days
let todayCostSuffix = today.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? ""
Expand Down
60 changes: 42 additions & 18 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,12 @@ public struct CostUsageFetcher: Sendable {
environment: environment,
since: since,
until: until)
return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays)
// AWS Cost Explorer lags, so Bedrock shows the latest billing day, not "Today".
return Self.tokenSnapshot(
from: daily,
now: now,
historyDays: clampedHistoryDays,
sessionDay: .latest)
}

var options = overrideScannerOptions ?? CostUsageScanner.Options()
Expand Down Expand Up @@ -273,26 +278,45 @@ public struct CostUsageFetcher: Sendable {
environment: environment)
}

/// How the session ("first KPI") row is selected.
enum SessionDaySelection {
/// Current local day; zero when there is no usage today (default; "Today" labels).
case today
/// Newest historical bucket; for lagged sources labeled "Latest billing day"
/// (e.g. AWS Cost Explorer), where today's row legitimately does not exist yet.
case latest
}

static func tokenSnapshot(
from daily: CostUsageDailyReport,
now: Date,
historyDays: Int = 30) -> CostUsageTokenSnapshot
historyDays: Int = 30,
sessionDay: SessionDaySelection = .today) -> CostUsageTokenSnapshot
{
// Pick the most recent day; break ties by cost/tokens to keep a stable "session" row.
let currentDay = daily.data.compactMap { entry -> (entry: CostUsageDailyReport.Entry, date: Date)? in
guard let date = CostUsageDateParser.parse(entry.date) else { return nil }
return (entry, date)
let sessionTokens: Int?
let sessionCostUSD: Double?
switch sessionDay {
case .latest:
// "Latest billing day": newest historical bucket, never zeroed.
let latest = CostUsageTokenSnapshot.latestEntry(in: daily.data)
sessionTokens = latest?.totalTokens
sessionCostUSD = latest?.costUSD
case .today:
// "Today" must reflect the current local day only, never the latest bucket:
// - a row for today => that row's values
// - history exists but no row for today => known zero ($0.00 · 0 tokens)
// - no history at all => nil (unknown)
if let currentDay = CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now) {
sessionTokens = currentDay.totalTokens
sessionCostUSD = currentDay.costUSD
} else if daily.data.isEmpty {
sessionTokens = nil
sessionCostUSD = nil
} else {
sessionTokens = 0
sessionCostUSD = 0
}
}
.max { lhs, rhs in
if lhs.date != rhs.date { return lhs.date < rhs.date }
let lCost = lhs.entry.costUSD ?? -1
let rCost = rhs.entry.costUSD ?? -1
if lCost != rCost { return lCost < rCost }
let lTokens = lhs.entry.totalTokens ?? -1
let rTokens = rhs.entry.totalTokens ?? -1
if lTokens != rTokens { return lTokens < rTokens }
return lhs.entry.date < rhs.entry.date
}?.entry
// Prefer summary totals when present; fall back to summing daily entries.
let totalFromSummary = daily.summary?.totalCostUSD
let totalFromEntries = daily.data.compactMap(\.costUSD).reduce(0, +)
Expand All @@ -302,8 +326,8 @@ public struct CostUsageFetcher: Sendable {
let last30DaysTokens = totalTokensFromSummary ?? (totalTokensFromEntries > 0 ? totalTokensFromEntries : nil)

return CostUsageTokenSnapshot(
sessionTokens: currentDay?.totalTokens,
sessionCostUSD: currentDay?.costUSD,
sessionTokens: sessionTokens,
sessionCostUSD: sessionCostUSD,
last30DaysTokens: last30DaysTokens,
last30DaysCostUSD: last30DaysCostUSD,
historyDays: historyDays,
Expand Down
44 changes: 44 additions & 0 deletions Sources/CodexBarCore/CostUsageModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,50 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable {
}
}

extension CostUsageTokenSnapshot {
/// Stable key (`yyyy-MM-dd`) identifying the local calendar day that `date` falls in.
public static func localDayKey(from date: Date, calendar: Calendar = .current) -> String {
let components = calendar.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
}

/// Entry whose parsed date maps to the same local-day key as `date`; `nil` if none.
public static func entry(
in entries: [CostUsageDailyReport.Entry],
forLocalDayContaining date: Date,
calendar: Calendar = .current) -> CostUsageDailyReport.Entry?
{
let targetKey = self.localDayKey(from: date, calendar: calendar)
return entries.first { entry in
guard let entryDate = CostUsageDateParser.parse(entry.date) else { return false }
return self.localDayKey(from: entryDate, calendar: calendar) == targetKey
}
}

/// Newest-day entry, breaking ties by cost then tokens then date string for a stable pick.
public static func latestEntry(in entries: [CostUsageDailyReport.Entry]) -> CostUsageDailyReport.Entry? {
entries.compactMap { entry -> (entry: CostUsageDailyReport.Entry, date: Date)? in
guard let date = CostUsageDateParser.parse(entry.date) else { return nil }
return (entry, date)
}
.max { lhs, rhs in
if lhs.date != rhs.date { return lhs.date < rhs.date }
let lCost = lhs.entry.costUSD ?? -1
let rCost = rhs.entry.costUSD ?? -1
if lCost != rCost { return lCost < rCost }
let lTokens = lhs.entry.totalTokens ?? -1
let rTokens = rhs.entry.totalTokens ?? -1
if lTokens != rTokens { return lTokens < rTokens }
return lhs.entry.date < rhs.entry.date
}?.entry
}

/// Entry for the local day containing `updatedAt`; `nil` when there is no usage today.
public func currentDayEntry(calendar: Calendar = .current) -> CostUsageDailyReport.Entry? {
Self.entry(in: self.daily, forLocalDayContaining: self.updatedAt, calendar: calendar)
}
}

public struct CostUsageDailyReport: Sendable, Decodable {
public struct ModelBreakdown: Sendable, Decodable, Equatable {
public let modelName: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,34 @@ public struct ClaudeAdminAPIUsageSnapshot: Codable, Equatable, Sendable {
self.summary(days: 1)
}

/// Summary for the current local day (derived from `updatedAt`), or a zero
/// summary when there is no bucket for today — so a "Today" label never shows
/// a stale historical bucket (#1705).
public func currentDay(calendar: Calendar = .current) -> Summary {
// Match on the bucket's absolute startTime so the local day is resolved
// correctly even though `day` strings are UTC-based.
let key = CostUsageTokenSnapshot.localDayKey(from: self.updatedAt, calendar: calendar)
let match = self.daily.first { bucket in
CostUsageTokenSnapshot.localDayKey(from: bucket.startTime, calendar: calendar) == key
}
guard let match else {
return Summary(
costUSD: 0,
inputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
outputTokens: 0,
totalTokens: 0)
}
return Summary(
costUSD: match.costUSD,
inputTokens: match.inputTokens,
cacheCreationInputTokens: match.cacheCreationInputTokens,
cacheReadInputTokens: match.cacheReadInputTokens,
outputTokens: match.outputTokens,
totalTokens: match.totalTokens)
}

public func summary(days: Int) -> Summary {
let selected = self.daily.suffix(max(1, days))
return Summary(
Expand Down
38 changes: 34 additions & 4 deletions Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,34 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable {
self.summary(days: 1)
}

/// Summary for the current local day (derived from `updatedAt`), or a zero
/// summary when there is no bucket for today — so a "Today" label never shows
/// a stale historical bucket (#1705).
public func currentDay(calendar: Calendar = .current) -> Summary {
// Match on the bucket's absolute startTime so the local day is resolved
// correctly even though `day` strings are UTC-based.
let key = CostUsageTokenSnapshot.localDayKey(from: self.updatedAt, calendar: calendar)
let match = self.daily.first { bucket in
CostUsageTokenSnapshot.localDayKey(from: bucket.startTime, calendar: calendar) == key
}
Comment on lines +155 to +157

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 Avoid zeroing UTC-day provider usage in local time zones

In time zones west of UTC, OpenAI Admin buckets are built as UTC 1-day windows, so the current UTC bucket for e.g. 2026-06-22 starts at 2026-06-22T00:00Z, which is still 2026-06-21 in US local time. Matching only localDayKey(bucket.startTime) therefore misses the bucket during most of the local day and the Today UI paths changed to currentDay() report $0/0 even when that bucket contains usage from today; the analogous Claude/Poe UTC-day matching has the same mismatch. Please match/aggregate by the bucket interval or build the provider daily buckets in local time instead of comparing only the bucket start day.

Useful? React with 👍 / 👎.

guard let match else {
return Summary(
costUSD: 0,
requests: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
totalTokens: 0)
}
return Summary(
costUSD: match.costUSD,
requests: match.requests,
inputTokens: match.inputTokens,
cachedInputTokens: match.cachedInputTokens,
outputTokens: match.outputTokens,
totalTokens: match.totalTokens)
}

public func summary(days: Int) -> Summary {
let selected = self.daily.suffix(max(1, days))
return Summary(
Expand Down Expand Up @@ -238,12 +266,14 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable {
modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed,
modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns)
}
let latest = self.latestDay
// sessionTokens/sessionCostUSD feed "Today" labels, so use the current local
// day (zero when no usage today), never the latest historical bucket (#1705).
let today = self.currentDay()
let total = self.last30Days
return CostUsageTokenSnapshot(
sessionTokens: latest.totalTokens,
sessionCostUSD: latest.costUSD,
sessionRequests: latest.requests,
sessionTokens: today.totalTokens,
sessionCostUSD: today.costUSD,
sessionRequests: today.requests,
last30DaysTokens: total.totalTokens,
last30DaysCostUSD: total.costUSD,
last30DaysRequests: total.requests,
Expand Down
14 changes: 14 additions & 0 deletions Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@ public struct PoeUsageHistorySnapshot: Codable, Equatable, Sendable {
self.summary(days: 1)
}

/// Summary for the current local day (derived from `updatedAt`), or a zero
/// summary when there is no bucket for today — so a "Today" label never shows
/// a stale historical bucket (#1705).
public func currentDay(calendar: Calendar = .current) -> Summary {
// Poe buckets carry only a `yyyy-MM-dd` day (no timestamp), so compare the
// day string directly against the local-day key.
let key = CostUsageTokenSnapshot.localDayKey(from: self.updatedAt, calendar: calendar)
let match = self.daily.first { $0.day == key }
guard let match else {
return Summary(points: 0, requests: 0, costUSD: nil)
}
return Summary(points: match.points, requests: match.requests, costUSD: match.costUSD)
}

public var last7Days: Summary {
self.summary(days: 7)
}
Expand Down
5 changes: 4 additions & 1 deletion Tests/CodexBarTests/CostUsageDecodingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,10 @@ struct CostUsageDecodingTests {
"""

let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8))
let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: Date())
// "Today" is the current local day (2026-05-13 here); the impossible 2026-06-31
// row must never be parsed/selected. Fixed `now` keeps this deterministic.
let now = Date(timeIntervalSince1970: 1_778_630_400) // 2026-05-13
let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now)

#expect(snapshot.sessionTokens == 30)
#expect(snapshot.sessionCostUSD == 23.45)
Expand Down
Loading
Loading