diff --git a/Sources/CodexBar/InlineUsageDashboardContent.swift b/Sources/CodexBar/InlineUsageDashboardContent.swift index 762007bdc0..82656c7c15 100644 --- a/Sources/CodexBar/InlineUsageDashboardContent.swift +++ b/Sources/CodexBar/InlineUsageDashboardContent.swift @@ -76,7 +76,7 @@ extension UsageMenuCardView.Model { if input.provider == .poe, let usage = input.snapshot?.poeUsage { - return self.poeUsageNotes(usage) + return self.poeUsageNotes(usage, now: input.now) } if input.provider == .ollama, @@ -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 @@ -113,8 +113,12 @@ extension UsageMenuCardView.Model { return notes } - static func poeUsageNotes(_ usage: PoeUsageHistorySnapshot) -> [String] { - let today = usage.latestDay + static func poeUsageNotes( + _ usage: PoeUsageHistorySnapshot, + now: Date = Date(), + calendar: Calendar = .current) -> [String] + { + let today = usage.currentDay(now: now, calendar: calendar) let week = usage.last7Days let month = usage.last30Days let todayUSD = today.costUSD.map { " · \(UsageFormatter.usdString($0))" } ?? "" @@ -196,7 +200,7 @@ extension UsageMenuCardView.Model { let usage = input.snapshot?.poeUsage, !usage.daily.isEmpty { - return Self.poeInlineDashboard(usage) + return Self.poeInlineDashboard(usage, now: input.now) } if [.codex, .claude, .vertexai, .bedrock].contains(input.provider), input.tokenCostUsageEnabled, @@ -229,8 +233,12 @@ extension UsageMenuCardView.Model { } } - static func poeInlineDashboard(_ usage: PoeUsageHistorySnapshot) -> InlineUsageDashboardModel { - let today = usage.latestDay + static func poeInlineDashboard( + _ usage: PoeUsageHistorySnapshot, + now: Date = Date(), + calendar: Calendar = .current) -> InlineUsageDashboardModel + { + let today = usage.currentDay(now: now, calendar: calendar) let week = usage.last7Days let month = usage.last30Days let points = usage.daily.suffix(30).map { @@ -318,7 +326,9 @@ extension UsageMenuCardView.Model { value: cost, accessibilityValue: "\(entry.date): \(Self.costString(cost, currencyCode: snapshot.currencyCode))") } - let latest = snapshot.daily.max { lhs, rhs in lhs.date < rhs.date } + let latest = CostUsageTokenSnapshot.latestEntry(in: snapshot.daily) + let usesLatestPrimary = provider == .bedrock || provider == .mistral + let primaryCostUSD = usesLatestPrimary ? latest?.costUSD : snapshot.sessionCostUSD var details: [String] = [] if let topModel = Self.topCostModel(from: snapshot.daily) { details.append("\(L("Top model")): \(Self.shortModelName(topModel))") @@ -337,8 +347,8 @@ extension UsageMenuCardView.Model { 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: usesLatestPrimary ? L("Latest") : L("Today"), + value: primaryCostUSD.map { Self.costString($0, currencyCode: snapshot.currencyCode) } ?? "—", emphasis: true), .init( title: historyTitle, @@ -378,7 +388,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 { diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 75cc63d059..fb8a88ba24 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -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 @@ -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 @@ -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))" } ?? "" diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index e019ff608e..c4d59ca60e 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -116,7 +116,11 @@ public struct CostUsageFetcher: Sendable { environment: environment, since: since, until: until) - return Self.tokenSnapshot(from: daily, now: now, historyDays: clampedHistoryDays) + return Self.tokenSnapshot( + from: daily, + now: now, + historyDays: clampedHistoryDays, + useCurrentLocalDayForSession: false) } var options = overrideScannerOptions ?? CostUsageScanner.Options() @@ -276,23 +280,27 @@ public struct CostUsageFetcher: Sendable { static func tokenSnapshot( from daily: CostUsageDailyReport, now: Date, - historyDays: Int = 30) -> CostUsageTokenSnapshot + historyDays: Int = 30, + useCurrentLocalDayForSession: Bool = true) -> 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 sessionEntry = useCurrentLocalDayForSession + ? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now) + : CostUsageTokenSnapshot.latestEntry(in: daily.data) + let hasHistoricalRows = !daily.data.isEmpty + let sessionTokens: Int? = if let sessionEntry { + sessionEntry.totalTokens + } else if hasHistoricalRows { + 0 + } else { + nil + } + let sessionCostUSD: Double? = if let sessionEntry { + sessionEntry.costUSD + } else if hasHistoricalRows { + 0 + } else { + nil } - .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, +) @@ -302,8 +310,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, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 30dcbb7012..d2a6a4d2c7 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -40,6 +40,41 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { self.daily = daily self.updatedAt = updatedAt } + + public func currentDayEntry(calendar: Calendar = .current) -> CostUsageDailyReport.Entry? { + Self.entry(in: self.daily, forLocalDayContaining: self.updatedAt, calendar: calendar) + } + + 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 + } + + public static func entry( + in entries: [CostUsageDailyReport.Entry], + forLocalDayContaining date: Date, + calendar: Calendar = .current) -> CostUsageDailyReport.Entry? + { + let dayKey = CostUsageLocalDay.key(from: date, calendar: calendar) + return entries.first { entry in + let rawDate = entry.date.trimmingCharacters(in: .whitespacesAndNewlines) + if rawDate == dayKey { return true } + guard let parsed = CostUsageDateParser.parse(rawDate) else { return false } + return CostUsageLocalDay.key(from: parsed, calendar: calendar) == dayKey + } + } } public struct CostUsageDailyReport: Sendable, Decodable { @@ -806,7 +841,29 @@ enum CostUsageDateParser { formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = timeZone formatter.dateFormat = format + formatter.isLenient = false threadDict[cacheKey] = formatter return formatter } } + +enum CostUsageBucketInterval { + static func contains( + _ date: Date, + startTime: Date, + endTime: Date) -> Bool + { + guard startTime < endTime else { return false } + return startTime <= date && date < endTime + } +} + +enum CostUsageLocalDay { + static func key(from date: Date, calendar: Calendar = .current) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + let year = components.year ?? 0 + let month = components.month ?? 0 + let day = components.day ?? 0 + return String(format: "%04d-%02d-%02d", year, month, day) + } +} diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageSnapshot.swift index f9b82ab0c8..96d30a20d8 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeAdminAPIUsageSnapshot.swift @@ -129,10 +129,30 @@ public struct ClaudeAdminAPIUsageSnapshot: Codable, Equatable, Sendable { self.summary(days: 7) } + public var currentDay: Summary { + self.summary(forLocalDayContaining: self.updatedAt) + } + public var latestDay: Summary { self.summary(days: 1) } + public func summary(forLocalDayContaining date: Date, calendar _: Calendar = .current) -> Summary { + let selected = self.daily.filter { bucket in + CostUsageBucketInterval.contains( + date, + startTime: bucket.startTime, + endTime: bucket.endTime) + } + return Summary( + costUSD: selected.reduce(0) { $0 + $1.costUSD }, + inputTokens: selected.reduce(0) { $0 + $1.inputTokens }, + cacheCreationInputTokens: selected.reduce(0) { $0 + $1.cacheCreationInputTokens }, + cacheReadInputTokens: selected.reduce(0) { $0 + $1.cacheReadInputTokens }, + outputTokens: selected.reduce(0) { $0 + $1.outputTokens }, + totalTokens: selected.reduce(0) { $0 + $1.totalTokens }) + } + public func summary(days: Int) -> Summary { let selected = self.daily.suffix(max(1, days)) return Summary( diff --git a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift index 9f741ea994..5b4030eca8 100644 --- a/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenAI/OpenAIAPIUsageSnapshot.swift @@ -126,7 +126,7 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { } public var last30Days: Summary { - self.summary(days: self.historyDays) + self.historyDays == 1 ? self.currentDay : self.summary(days: self.historyDays) } public var historyWindowLabel: String { @@ -141,10 +141,30 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { self.summary(days: 7) } + public var currentDay: Summary { + self.summary(forLocalDayContaining: self.updatedAt) + } + public var latestDay: Summary { self.summary(days: 1) } + public func summary(forLocalDayContaining date: Date, calendar _: Calendar = .current) -> Summary { + let selected = self.daily.filter { bucket in + CostUsageBucketInterval.contains( + date, + startTime: bucket.startTime, + endTime: bucket.endTime) + } + return Summary( + costUSD: selected.reduce(0) { $0 + $1.costUSD }, + requests: selected.reduce(0) { $0 + $1.requests }, + inputTokens: selected.reduce(0) { $0 + $1.inputTokens }, + cachedInputTokens: selected.reduce(0) { $0 + $1.cachedInputTokens }, + outputTokens: selected.reduce(0) { $0 + $1.outputTokens }, + totalTokens: selected.reduce(0) { $0 + $1.totalTokens }) + } + public func summary(days: Int) -> Summary { let selected = self.daily.suffix(max(1, days)) return Summary( @@ -238,12 +258,12 @@ public struct OpenAIAPIUsageSnapshot: Codable, Equatable, Sendable { modelsUsed: modelsUsed.isEmpty ? nil : modelsUsed, modelBreakdowns: modelBreakdowns.isEmpty ? nil : modelBreakdowns) } - let latest = self.latestDay + 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, diff --git a/Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift b/Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift index 63cceb5e58..8c5c6d30da 100644 --- a/Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift +++ b/Sources/CodexBarCore/Providers/Poe/PoeUsageHistorySnapshot.swift @@ -71,6 +71,14 @@ public struct PoeUsageHistorySnapshot: Codable, Equatable, Sendable { self.summary(days: 1) } + public func currentDay(now: Date = Date(), calendar: Calendar = .current) -> Summary { + let selected = self.entries.filter { calendar.isDate($0.createdAt, inSameDayAs: now) } + let points = selected.reduce(0) { $0 + max(0, $1.points) } + let costValues = selected.compactMap(\.costUSD).map { max(0, $0) } + let cost: Double? = costValues.isEmpty ? nil : costValues.reduce(0, +) + return Summary(points: points, requests: selected.count, costUSD: cost) + } + public var last7Days: Summary { self.summary(days: 7) } diff --git a/Tests/CodexBarTests/AdminAPIUsageLocalDaySelectionTests.swift b/Tests/CodexBarTests/AdminAPIUsageLocalDaySelectionTests.swift new file mode 100644 index 0000000000..fb4a530a23 --- /dev/null +++ b/Tests/CodexBarTests/AdminAPIUsageLocalDaySelectionTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct AdminAPIUsageLocalDaySelectionTests { + @Test + func `OpenAI current day includes UTC bucket containing positive timezone morning`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney") + let now = try Self.date(year: 2026, month: 5, day: 18, hour: 8, timeZoneIdentifier: "Australia/Sydney") + let staleUTCStart = try Self.date(year: 2026, month: 5, day: 16, hour: 0, timeZoneIdentifier: "UTC") + let overlappingUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC") + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-16", + startTime: staleUTCStart, + endTime: staleUTCStart.addingTimeInterval(86400), + costUSD: 9, + requests: 9, + inputTokens: 900, + cachedInputTokens: 90, + outputTokens: 90, + totalTokens: 990, + lineItems: [], + models: []), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-17", + startTime: overlappingUTCStart, + endTime: overlappingUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + requests: 3, + inputTokens: 200, + cachedInputTokens: 20, + outputTokens: 30, + totalTokens: 250, + lineItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 2.5) + #expect(today.requests == 3) + #expect(today.totalTokens == 250) + } + + @Test + func `OpenAI current day does not sum adjacent UTC buckets after positive timezone UTC rollover`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney") + let now = try Self.date(year: 2026, month: 5, day: 18, hour: 16, timeZoneIdentifier: "Australia/Sydney") + let previousUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC") + let currentUTCStart = try Self.date(year: 2026, month: 5, day: 18, hour: 0, timeZoneIdentifier: "UTC") + let usage = OpenAIAPIUsageSnapshot( + daily: [ + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-17", + startTime: previousUTCStart, + endTime: previousUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + requests: 3, + inputTokens: 200, + cachedInputTokens: 20, + outputTokens: 30, + totalTokens: 250, + lineItems: [], + models: []), + OpenAIAPIUsageSnapshot.DailyBucket( + day: "2026-05-18", + startTime: currentUTCStart, + endTime: currentUTCStart.addingTimeInterval(86400), + costUSD: 4.5, + requests: 5, + inputTokens: 400, + cachedInputTokens: 40, + outputTokens: 50, + totalTokens: 490, + lineItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 4.5) + #expect(today.requests == 5) + #expect(today.totalTokens == 490) + } + + @Test + func `Claude Admin current day includes UTC bucket containing positive timezone morning`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "Australia/Sydney") + let now = try Self.date(year: 2026, month: 5, day: 18, hour: 8, timeZoneIdentifier: "Australia/Sydney") + let staleUTCStart = try Self.date(year: 2026, month: 5, day: 16, hour: 0, timeZoneIdentifier: "UTC") + let overlappingUTCStart = try Self.date(year: 2026, month: 5, day: 17, hour: 0, timeZoneIdentifier: "UTC") + let usage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-05-16", + startTime: staleUTCStart, + endTime: staleUTCStart.addingTimeInterval(86400), + costUSD: 9, + inputTokens: 900, + cacheCreationInputTokens: 90, + cacheReadInputTokens: 45, + outputTokens: 90, + totalTokens: 1125, + costItems: [], + models: []), + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-05-17", + startTime: overlappingUTCStart, + endTime: overlappingUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + inputTokens: 200, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, + outputTokens: 30, + totalTokens: 260, + costItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 2.5) + #expect(today.inputTokens == 200) + #expect(today.totalTokens == 260) + } + + @Test + func `Claude Admin current day does not sum adjacent UTC buckets after negative timezone UTC rollover`() throws { + let calendar = try Self.calendar(timeZoneIdentifier: "America/Los_Angeles") + let now = try Self.date(year: 2026, month: 6, day: 22, hour: 20, timeZoneIdentifier: "America/Los_Angeles") + let previousUTCStart = try Self.date(year: 2026, month: 6, day: 22, hour: 0, timeZoneIdentifier: "UTC") + let currentUTCStart = try Self.date(year: 2026, month: 6, day: 23, hour: 0, timeZoneIdentifier: "UTC") + let usage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-06-22", + startTime: previousUTCStart, + endTime: previousUTCStart.addingTimeInterval(86400), + costUSD: 2.5, + inputTokens: 200, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 10, + outputTokens: 30, + totalTokens: 260, + costItems: [], + models: []), + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2026-06-23", + startTime: currentUTCStart, + endTime: currentUTCStart.addingTimeInterval(86400), + costUSD: 4.5, + inputTokens: 400, + cacheCreationInputTokens: 40, + cacheReadInputTokens: 20, + outputTokens: 50, + totalTokens: 510, + costItems: [], + models: []), + ], + updatedAt: now) + + let today = usage.summary(forLocalDayContaining: now, calendar: calendar) + + #expect(today.costUSD == 4.5) + #expect(today.inputTokens == 400) + #expect(today.totalTokens == 510) + } + + private static func calendar(timeZoneIdentifier: String) throws -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: timeZoneIdentifier)) + return calendar + } + + private static func date( + year: Int, + month: Int, + day: Int, + hour: Int, + timeZoneIdentifier: String) throws -> Date + { + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.timeZone = TimeZone(identifier: timeZoneIdentifier) + components.year = year + components.month = month + components.day = day + components.hour = hour + return try #require(components.date) + } +} diff --git a/Tests/CodexBarTests/ClaudeAdminAPIInlineDashboardModelTests.swift b/Tests/CodexBarTests/ClaudeAdminAPIInlineDashboardModelTests.swift new file mode 100644 index 0000000000..0c86ea287a --- /dev/null +++ b/Tests/CodexBarTests/ClaudeAdminAPIInlineDashboardModelTests.swift @@ -0,0 +1,71 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct ClaudeAdminAPIInlineDashboardModelTests { + @Test + func `claude admin api usage gets inline dashboard`() throws { + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let usage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), + costUSD: 1.25, + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950, + costItems: [ + ClaudeAdminAPIUsageSnapshot.CostBreakdown(name: "Claude Sonnet Usage", costUSD: 1.25), + ], + models: [ + ClaudeAdminAPIUsageSnapshot.ModelBreakdown( + name: "claude-sonnet-4-20250514", + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950), + ]), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: usage.toUsageSnapshot(), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.isEmpty) + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.00") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: $1.25") + #expect(model.inlineUsageDashboard?.detailLines + .contains { $0.hasPrefix("30d:") && $0.contains("tokens") } == true) + #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: claude-sonnet-4-20250514") == true) + #expect(model.planText == "Admin API") + } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } +} diff --git a/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift b/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift index 6a63827e99..51ccf8b7b1 100644 --- a/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeAdminAPIUsageTests.swift @@ -181,6 +181,33 @@ struct ClaudeAdminAPIUsageTests { #expect(usage.identity?.loginMethod == "Admin API") } + @Test + func `current day summary is zero when Claude admin history is stale`() throws { + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) + let apiUsage = ClaudeAdminAPIUsageSnapshot( + daily: [ + ClaudeAdminAPIUsageSnapshot.DailyBucket( + day: "2023-11-14", + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), + costUSD: 8.5, + inputTokens: 1000, + cacheCreationInputTokens: 400, + cacheReadInputTokens: 300, + outputTokens: 250, + totalTokens: 1950, + costItems: [], + models: []), + ], + updatedAt: now) + + #expect(apiUsage.currentDay.costUSD == 0) + #expect(apiUsage.currentDay.totalTokens == 0) + #expect(apiUsage.latestDay.costUSD == 8.5) + #expect(apiUsage.latestDay.totalTokens == 1950) + } + @Test func `fetch strategy reports admin api source label`() async throws { let strategy = ClaudeAdminAPIFetchStrategy(usageFetcher: { apiKey in @@ -193,4 +220,8 @@ struct ClaudeAdminAPIUsageTests { #expect(result.sourceLabel == "admin-api") #expect(result.usage.identity?.loginMethod == "Admin API") } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/CostUsageDecodingTests.swift b/Tests/CodexBarTests/CostUsageDecodingTests.swift index 33eb9df2c1..acf4f65ea0 100644 --- a/Tests/CodexBarTests/CostUsageDecodingTests.swift +++ b/Tests/CodexBarTests/CostUsageDecodingTests.swift @@ -381,7 +381,7 @@ struct CostUsageDecodingTests { } @Test - func `token snapshot selects most recent day`() throws { + func `token snapshot selects current local day`() throws { let json = """ { "type": "daily", @@ -404,7 +404,7 @@ struct CostUsageDecodingTests { """ let report = try JSONDecoder().decode(CostUsageDailyReport.self, from: Data(json.utf8)) - let now = Date(timeIntervalSince1970: 1_766_275_200) // 2025-12-21 + let now = try Self.localNoon(year: 2025, month: 12, day: 21) let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now) #expect(snapshot.sessionTokens == 10) #expect(snapshot.sessionCostUSD == 4.56) @@ -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()) + let snapshot = CostUsageFetcher.tokenSnapshot( + from: report, + now: Date(), + useCurrentLocalDayForSession: false) #expect(snapshot.sessionTokens == 30) #expect(snapshot.sessionCostUSD == 23.45) @@ -493,4 +496,8 @@ struct CostUsageDecodingTests { let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: Date()) #expect(snapshot.last30DaysCostUSD == nil) } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift new file mode 100644 index 0000000000..d587151c1e --- /dev/null +++ b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct CostUsageTokenSnapshotDaySelectionTests { + @Test + func `token snapshot reports zero today when latest history row is stale`() throws { + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let report = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-05-15", + inputTokens: 200, + outputTokens: 100, + totalTokens: 300, + costUSD: 1.5, + modelsUsed: nil, + modelBreakdowns: nil), + ], + summary: nil) + + let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now) + + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.sessionTokens == 0) + #expect(snapshot.last30DaysCostUSD == 1.5) + #expect(snapshot.last30DaysTokens == 300) + #expect(snapshot.currentDayEntry() == nil) + } + + @Test + func `token snapshot uses current local day instead of newest historical row`() throws { + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let report = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-05-17", + inputTokens: 200, + outputTokens: 100, + totalTokens: 300, + costUSD: 1.5, + modelsUsed: nil, + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-05-18", + inputTokens: 20, + outputTokens: 10, + totalTokens: 30, + costUSD: 0.15, + modelsUsed: nil, + modelBreakdowns: nil), + ], + summary: nil) + + let snapshot = CostUsageFetcher.tokenSnapshot(from: report, now: now) + + #expect(snapshot.sessionCostUSD == 0.15) + #expect(snapshot.sessionTokens == 30) + #expect(snapshot.last30DaysCostUSD == 1.65) + #expect(snapshot.last30DaysTokens == 330) + } + + @Test + func `token snapshot can preserve latest bucket semantics`() throws { + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let report = CostUsageDailyReport( + data: [ + CostUsageDailyReport.Entry( + date: "2026-05-15", + inputTokens: 200, + outputTokens: 100, + totalTokens: 300, + costUSD: 1.5, + modelsUsed: nil, + modelBreakdowns: nil), + ], + summary: nil) + + let snapshot = CostUsageFetcher.tokenSnapshot( + from: report, + now: now, + useCurrentLocalDayForSession: false) + + #expect(snapshot.sessionCostUSD == 1.5) + #expect(snapshot.sessionTokens == 300) + } + + @Test + func `latest entry ignores invalid calendar dates`() { + let latest = CostUsageTokenSnapshot.latestEntry(in: [ + CostUsageDailyReport.Entry( + date: "2026-06-31", + inputTokens: nil, + outputTokens: nil, + totalTokens: 999, + costUSD: 9.99, + modelsUsed: nil, + modelBreakdowns: nil), + CostUsageDailyReport.Entry( + date: "2026-06-30", + inputTokens: nil, + outputTokens: nil, + totalTokens: 100, + costUSD: 1, + modelsUsed: nil, + modelBreakdowns: nil), + ]) + + #expect(latest?.date == "2026-06-30") + } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + var components = DateComponents() + components.calendar = Calendar.current + components.year = year + components.month = month + components.day = day + components.hour = 12 + return try #require(components.date) + } +} diff --git a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift index 2584e5dfed..ab0385c909 100644 --- a/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift +++ b/Tests/CodexBarTests/InlineCostHistoryDashboardLabelTests.swift @@ -4,6 +4,55 @@ import Testing @testable import CodexBar struct InlineCostHistoryDashboardLabelTests { + @Test + func `local cost history Today KPI uses current day session value`() throws { + let now = Date(timeIntervalSince1970: 1_700_179_200) + let metadata = try #require(ProviderDefaults.metadata[.claude]) + let tokenSnapshot = CostUsageTokenSnapshot( + sessionTokens: 0, + sessionCostUSD: 0, + last30DaysTokens: 275, + last30DaysCostUSD: 0.25, + daily: [ + CostUsageDailyReport.Entry( + date: "2023-11-15", + inputTokens: 200, + outputTokens: 75, + totalTokens: 275, + costUSD: 0.25, + modelsUsed: nil, + modelBreakdowns: nil), + ], + updatedAt: now) + + let model = UsageMenuCardView.Model.make(.init( + provider: .claude, + metadata: metadata, + snapshot: UsageSnapshot( + primary: nil, + secondary: nil, + updatedAt: now), + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: tokenSnapshot, + tokenError: nil, + account: AccountInfo(email: nil, plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: true, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + #expect(model.inlineUsageDashboard?.kpis.first?.title == "Today") + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.00") + #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-15: $0.25") + } + @Test func `local cost history KPI titles preserve one day and dynamic windows`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 18dfc1443a..752c803b72 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -60,66 +60,6 @@ struct OverviewMenuCardVisibilityTests { } struct ProviderInlineDashboardModelTests { - @Test - func `claude admin api usage gets inline dashboard`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) - let metadata = try #require(ProviderDefaults.metadata[.claude]) - let usage = ClaudeAdminAPIUsageSnapshot( - daily: [ - ClaudeAdminAPIUsageSnapshot.DailyBucket( - day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), - costUSD: 1.25, - inputTokens: 1000, - cacheCreationInputTokens: 400, - cacheReadInputTokens: 300, - outputTokens: 250, - totalTokens: 1950, - costItems: [ - ClaudeAdminAPIUsageSnapshot.CostBreakdown(name: "Claude Sonnet Usage", costUSD: 1.25), - ], - models: [ - ClaudeAdminAPIUsageSnapshot.ModelBreakdown( - name: "claude-sonnet-4-20250514", - inputTokens: 1000, - cacheCreationInputTokens: 400, - cacheReadInputTokens: 300, - outputTokens: 250, - totalTokens: 1950), - ]), - ], - updatedAt: now) - - let model = UsageMenuCardView.Model.make(.init( - provider: .claude, - metadata: metadata, - snapshot: usage.toUsageSnapshot(), - credits: nil, - creditsError: nil, - dashboard: nil, - dashboardError: nil, - tokenSnapshot: nil, - tokenError: nil, - account: AccountInfo(email: nil, plan: nil), - isRefreshing: false, - lastError: nil, - usageBarsShowUsed: false, - resetTimeDisplayStyle: .countdown, - tokenCostUsageEnabled: false, - showOptionalCreditsAndExtraUsage: true, - hidePersonalInfo: false, - now: now)) - - #expect(model.metrics.isEmpty) - #expect(model.inlineUsageDashboard?.kpis.first?.value == "$1.25") - #expect(model.inlineUsageDashboard?.points.first?.accessibilityValue == "2023-11-14: $1.25") - #expect(model.inlineUsageDashboard?.detailLines - .contains { $0.hasPrefix("30d:") && $0.contains("tokens") } == true) - #expect(model.inlineUsageDashboard?.detailLines.contains("Top model: claude-sonnet-4-20250514") == true) - #expect(model.planText == "Admin API") - } - @Test func `openrouter period usage gets inline dashboard`() throws { let now = Date(timeIntervalSince1970: 1_700_179_200) diff --git a/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift b/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift index d45b96429e..e7954bf1f1 100644 --- a/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift +++ b/Tests/CodexBarTests/MenuDescriptorOpenAIAPITests.swift @@ -22,13 +22,15 @@ struct MenuDescriptorOpenAIAPITests { fetcher: UsageFetcher(environment: [:]), browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let now = Date(timeIntervalSince1970: 1_700_179_200) + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let firstDay = try Self.localNoon(year: 2023, month: 11, day: 13) + let secondDay = try Self.localNoon(year: 2023, month: 11, day: 14) let usage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-13", - startTime: now.addingTimeInterval(-86400), - endTime: now, + startTime: firstDay, + endTime: firstDay.addingTimeInterval(86400), costUSD: 5, requests: 8, inputTokens: 100, @@ -47,8 +49,8 @@ struct MenuDescriptorOpenAIAPITests { ]), OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: secondDay, + endTime: secondDay.addingTimeInterval(86400), costUSD: 12.5, requests: 40, inputTokens: 1000, @@ -83,10 +85,14 @@ struct MenuDescriptorOpenAIAPITests { return text } - #expect(lines.contains("Today: $12.50 · 1.5K tokens")) + #expect(lines.contains("Today: $0.00 · 0 tokens")) #expect(lines.contains("7d: $17.50 · 48 requests")) #expect(lines.contains("30d: $17.50 · 48 requests")) #expect(lines.contains("Top model: gpt-5.2-codex")) #expect(!lines.contains("No usage yet")) } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/MenuDescriptorPoeTests.swift b/Tests/CodexBarTests/MenuDescriptorPoeTests.swift index 1f5dc5b212..1771944115 100644 --- a/Tests/CodexBarTests/MenuDescriptorPoeTests.swift +++ b/Tests/CodexBarTests/MenuDescriptorPoeTests.swift @@ -72,7 +72,7 @@ struct MenuDescriptorPoeTests { browserDetection: BrowserDetection(cacheTTL: 0), settings: settings) - let now = Date(timeIntervalSince1970: 1_717_171_717) + let now = Date() let history = PoeUsageHistorySnapshot( entries: [ .init( diff --git a/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift index b13eabe477..cb97d2e468 100644 --- a/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift +++ b/Tests/CodexBarTests/OpenAIAPIMenuCardModelTests.swift @@ -6,14 +6,15 @@ import Testing struct OpenAIAPIMenuCardModelTests { @Test func `admin usage model shows summaries and spend without fake quota bars`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) let metadata = try #require(ProviderDefaults.metadata[.openai]) let apiUsage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), costUSD: 12.5, requests: 40, inputTokens: 1000, @@ -57,13 +58,13 @@ struct OpenAIAPIMenuCardModelTests { #expect(model.metrics.isEmpty) #expect(model.openAIAPIUsage != nil) - #expect(model.inlineUsageDashboard?.kpis.first?.value == "$12.50") + #expect(model.inlineUsageDashboard?.kpis.first?.value == "$0.00") #expect(model.inlineUsageDashboard?.kpis.last?.title == "Requests") #expect(model.inlineUsageDashboard?.kpis.last?.value == "40") #expect(model.inlineUsageDashboard?.points.count == 1) #expect(model.inlineUsageDashboard?.detailLines.contains("30d requests: 40 requests") == true) #expect(model.providerCost == nil) - #expect(model.usageNotes.contains { $0.contains("Today: $12.50") }) + #expect(model.usageNotes.contains { $0.contains("Today: $0.00") }) #expect(model.usageNotes.contains("Top model: gpt-5.2")) #expect(model.creditsText == nil) #expect(model.planText == "Admin API") @@ -119,14 +120,15 @@ struct OpenAIAPIMenuCardModelTests { @Test func `admin usage model can show cost card summary`() throws { - let now = Date(timeIntervalSince1970: 1_700_179_200) + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let bucketDay = try Self.localNoon(year: 2023, month: 11, day: 14) let metadata = try #require(ProviderDefaults.metadata[.openai]) let apiUsage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: bucketDay, + endTime: bucketDay.addingTimeInterval(86400), costUSD: 12.5, requests: 40, inputTokens: 1000, @@ -159,8 +161,12 @@ struct OpenAIAPIMenuCardModelTests { now: now)) #expect(ProviderDescriptorRegistry.descriptor(for: .openai).tokenCost.supportsTokenCost) - #expect(model.tokenUsage?.sessionLine == "Today: $12.50 · 1.5K tokens") + #expect(model.tokenUsage?.sessionLine == "Today: $0.00 · 0 tokens") #expect(model.tokenUsage?.monthLine == "Last 30 days: $12.50 · 1.5K tokens") #expect(model.tokenUsage?.hintLine == "Reported by OpenAI Admin API organization usage.") } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } diff --git a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift index a9cd8f50ee..fbf298bdf4 100644 --- a/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift +++ b/Tests/CodexBarTests/OpenAIAPIUsageFetcherTests.swift @@ -385,14 +385,16 @@ struct OpenAIAPIUsageFetcherTests { } @Test - func `maps project scoped admin usage to cost token snapshot`() { - let now = Date(timeIntervalSince1970: 1_700_179_200) + func `maps project scoped admin usage to cost token snapshot`() throws { + let now = try Self.localNoon(year: 2023, month: 11, day: 17) + let firstDay = try Self.localNoon(year: 2023, month: 11, day: 13) + let secondDay = try Self.localNoon(year: 2023, month: 11, day: 14) let apiUsage = OpenAIAPIUsageSnapshot( daily: [ OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-13", - startTime: now.addingTimeInterval(-86400), - endTime: now, + startTime: firstDay, + endTime: firstDay.addingTimeInterval(86400), costUSD: 2.25, requests: 3, inputTokens: 300, @@ -411,8 +413,8 @@ struct OpenAIAPIUsageFetcherTests { ]), OpenAIAPIUsageSnapshot.DailyBucket( day: "2023-11-14", - startTime: now, - endTime: now.addingTimeInterval(86400), + startTime: secondDay, + endTime: secondDay.addingTimeInterval(86400), costUSD: 8.5, requests: 42, inputTokens: 1000, @@ -442,9 +444,11 @@ struct OpenAIAPIUsageFetcherTests { #expect(usage.identity?.accountOrganization == "Project: proj_abc") #expect(snapshot.historyDays == 7) #expect(snapshot.currencyCode == "USD") - #expect(snapshot.sessionCostUSD == 8.5) - #expect(snapshot.sessionTokens == 1250) - #expect(snapshot.sessionRequests == 42) + #expect(apiUsage.currentDay.costUSD == 0) + #expect(apiUsage.currentDay.totalTokens == 0) + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.sessionTokens == 0) + #expect(snapshot.sessionRequests == 0) #expect(snapshot.last30DaysCostUSD == 10.75) #expect(snapshot.last30DaysTokens == 1750) #expect(snapshot.last30DaysRequests == 45) @@ -461,6 +465,10 @@ struct OpenAIAPIUsageFetcherTests { else { return nil } return components.queryItems?.first(where: { $0.name == name })?.value } + + private static func localNoon(year: Int, month: Int, day: Int) throws -> Date { + try #require(Calendar.current.date(from: DateComponents(year: year, month: month, day: day, hour: 12))) + } } private actor OpenAIAdminUsagePaginationScript: ProviderHTTPTransport { diff --git a/Tests/CodexBarTests/PoeCurrentDayPresentationTests.swift b/Tests/CodexBarTests/PoeCurrentDayPresentationTests.swift new file mode 100644 index 0000000000..33ae1c108a --- /dev/null +++ b/Tests/CodexBarTests/PoeCurrentDayPresentationTests.swift @@ -0,0 +1,36 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +struct PoeCurrentDayPresentationTests { + @Test + func `Poe notes and dashboard do not label stale usage as Today`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/London")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-06-23T12:00:00Z")) + let yesterday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T12:00:00Z")) + let usage = PoeUsageHistorySnapshot( + entries: [ + .init( + id: "stale", + createdAt: yesterday, + model: "GPT-4o", + usageType: "chat", + points: 100, + costUSD: 0.10), + ], + daily: [ + .init(day: "2026-06-22", points: 100, requests: 1, costUSD: 0.10), + ], + updatedAt: now) + + let notes = UsageMenuCardView.Model.poeUsageNotes(usage, now: now, calendar: calendar) + let dashboard = UsageMenuCardView.Model.poeInlineDashboard(usage, now: now, calendar: calendar) + + #expect(notes.first == "Today: 0 points · 0 requests") + #expect(dashboard.kpis.first?.title == "Today") + #expect(dashboard.kpis.first?.value == "0 points") + #expect(!dashboard.detailLines.contains(where: { $0.hasPrefix("Today USD:") })) + } +} diff --git a/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift b/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift index c24fc82f5b..b7a0f70d2d 100644 --- a/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift +++ b/Tests/CodexBarTests/PoeUsageHistorySnapshotTests.swift @@ -148,6 +148,76 @@ struct PoeUsageHistorySnapshotTests { #expect(snapshot.last30Days == snapshot.summary(days: 30)) } + @Test + func `current day does not reuse a stale latest bucket`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/London")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-06-23T12:00:00Z")) + let yesterday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T12:00:00Z")) + let snapshot = PoeUsageHistorySnapshot( + entries: [ + self.makeEntry( + id: "stale", + createdAt: yesterday, + model: "GPT-4o", + usageType: "chat", + points: 100, + costUSD: 0.10), + ], + daily: [ + PoeUsageHistorySnapshot.DailyBucket( + day: "2026-06-22", + points: 100, + requests: 1, + costUSD: 0.10), + ], + updatedAt: now) + + #expect(snapshot.latestDay.points == 100) + #expect(snapshot.currentDay(now: now, calendar: calendar).points == 0) + #expect(snapshot.currentDay(now: now, calendar: calendar).requests == 0) + #expect(snapshot.currentDay(now: now, calendar: calendar).costUSD == nil) + } + + @Test + func `current day filters raw entries across a UTC bucket boundary`() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "America/Los_Angeles")) + let now = try #require(ISO8601DateFormatter().date(from: "2026-06-23T01:00:00Z")) + let localToday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T20:00:00Z")) + let localYesterday = try #require(ISO8601DateFormatter().date(from: "2026-06-22T06:00:00Z")) + let snapshot = PoeUsageHistorySnapshot( + entries: [ + self.makeEntry( + id: "today", + createdAt: localToday, + model: "GPT-4o", + usageType: "chat", + points: 80, + costUSD: 0.08), + self.makeEntry( + id: "yesterday", + createdAt: localYesterday, + model: "Claude", + usageType: "chat", + points: 20, + costUSD: 0.02), + ], + daily: [ + PoeUsageHistorySnapshot.DailyBucket( + day: "2026-06-22", + points: 100, + requests: 2, + costUSD: 0.10), + ], + updatedAt: now) + + let current = snapshot.currentDay(now: now, calendar: calendar) + #expect(current.points == 80) + #expect(current.requests == 1) + #expect(current.costUSD == 0.08) + } + // MARK: - topModels / topModel @Test