From 5e8992b3a4b4959d6bb6698a0cc1ff1916d40e91 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:19:38 -0700 Subject: [PATCH 1/2] Add Copilot weekly pace support Derive Copilot reset windows from the existing assigned/reset dates so the shared pace UI can show whether chat usage is ahead or behind schedule. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- .../CodexBar/UsageStore+HistoricalPace.swift | 2 +- Sources/CodexBarCore/CopilotUsageModels.swift | 42 +++++++++++++++++ .../Copilot/CopilotUsageFetcher.swift | 14 +++--- ...dexPresentationCharacterizationTests.swift | 45 +++++++++++++++++++ .../CopilotUsageModelsTests.swift | 24 ++++++++++ 5 files changed, 121 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBar/UsageStore+HistoricalPace.swift b/Sources/CodexBar/UsageStore+HistoricalPace.swift index 9a1650a29..dd95e1ade 100644 --- a/Sources/CodexBar/UsageStore+HistoricalPace.swift +++ b/Sources/CodexBar/UsageStore+HistoricalPace.swift @@ -5,7 +5,7 @@ import Foundation extension UsageStore { func supportsWeeklyPace(for provider: UsageProvider) -> Bool { switch provider { - case .codex, .claude: + case .codex, .claude, .copilot: true default: false diff --git a/Sources/CodexBarCore/CopilotUsageModels.swift b/Sources/CodexBarCore/CopilotUsageModels.swift index fef52b64c..c9f46209b 100644 --- a/Sources/CodexBarCore/CopilotUsageModels.swift +++ b/Sources/CodexBarCore/CopilotUsageModels.swift @@ -203,11 +203,30 @@ public struct CopilotUsageResponse: Sendable, Decodable { } } + public struct QuotaWindow: Sendable, Equatable { + public let assignedAt: Date + public let resetsAt: Date + public let windowMinutes: Int + } + public let quotaSnapshots: QuotaSnapshots public let copilotPlan: String public let assignedDate: String? public let quotaResetDate: String? + public var quotaWindow: QuotaWindow? { + guard let assignedAt = Self.parseQuotaDate(self.assignedDate), + let resetsAt = Self.parseQuotaDate(self.quotaResetDate), + resetsAt > assignedAt + else { + return nil + } + + let windowMinutes = Int((resetsAt.timeIntervalSince(assignedAt) / 60).rounded()) + guard windowMinutes > 0 else { return nil } + return QuotaWindow(assignedAt: assignedAt, resetsAt: resetsAt, windowMinutes: windowMinutes) + } + private enum CodingKeys: String, CodingKey { case quotaSnapshots = "quota_snapshots" case copilotPlan = "copilot_plan" @@ -294,4 +313,27 @@ public struct CopilotUsageResponse: Sendable, Decodable { } return snapshot } + + private static func parseQuotaDate(_ value: String?) -> Date? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = isoFormatter.date(from: value) { + return date + } + isoFormatter.formatOptions = [.withInternetDateTime] + if let date = isoFormatter.date(from: value) { + return date + } + + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .gregorian) + formatter.timeZone = TimeZone.current + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: value) + } } diff --git a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift index ddf41a10a..07b85b5b3 100644 --- a/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Copilot/CopilotUsageFetcher.swift @@ -35,8 +35,9 @@ public struct CopilotUsageFetcher: Sendable { } let usage = try JSONDecoder().decode(CopilotUsageResponse.self, from: data) - let premium = self.makeRateWindow(from: usage.quotaSnapshots.premiumInteractions) - let chat = self.makeRateWindow(from: usage.quotaSnapshots.chat) + let quotaWindow = usage.quotaWindow + let premium = self.makeRateWindow(from: usage.quotaSnapshots.premiumInteractions, quotaWindow: quotaWindow) + let chat = self.makeRateWindow(from: usage.quotaSnapshots.chat, quotaWindow: quotaWindow) let primary: RateWindow? let secondary: RateWindow? @@ -74,7 +75,10 @@ public struct CopilotUsageFetcher: Sendable { request.setValue("2025-04-01", forHTTPHeaderField: "X-Github-Api-Version") } - private func makeRateWindow(from snapshot: CopilotUsageResponse.QuotaSnapshot?) -> RateWindow? { + private func makeRateWindow( + from snapshot: CopilotUsageResponse.QuotaSnapshot?, + quotaWindow: CopilotUsageResponse.QuotaWindow?) -> RateWindow? + { guard let snapshot else { return nil } guard !snapshot.isPlaceholder else { return nil } guard snapshot.hasPercentRemaining else { return nil } @@ -83,8 +87,8 @@ public struct CopilotUsageFetcher: Sendable { return RateWindow( usedPercent: usedPercent, - windowMinutes: nil, // Not provided - resetsAt: nil, // Not provided per-quota in the simplified snapshot + windowMinutes: quotaWindow?.windowMinutes, + resetsAt: quotaWindow?.resetsAt, resetDescription: nil) } } diff --git a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift index 047a15246..19ae9983e 100644 --- a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift @@ -346,6 +346,51 @@ struct CodexPresentationCharacterizationTests { #expect(store.codexCookieCacheScopeForOpenAIWeb() == nil) } + @Test + func `Copilot menu includes weekly pace summary when reset window is known`() { + let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-copilot-weekly-pace") + settings.statusChecksEnabled = false + + let now = Date() + let fetcher = UsageFetcher(environment: [:]) + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 23, + windowMinutes: 31 * 24 * 60, + resetsAt: now.addingTimeInterval(5 * 24 * 60 * 60), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 23, + windowMinutes: 31 * 24 * 60, + resetsAt: now.addingTimeInterval(5 * 24 * 60 * 60), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .copilot, + accountEmail: "copilot@example.com", + accountOrganization: nil, + loginMethod: "individual_pro")), + provider: .copilot) + + let descriptor = MenuDescriptor.build( + provider: .copilot, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false, + includeContextualActions: false) + + let lines = self.textLines(from: descriptor) + #expect(lines.contains(where: { $0.hasPrefix("Chat:") })) + #expect(lines.contains(where: { $0.hasPrefix("Pace:") && $0.contains("reserve") })) + } + @Test func `zai menu descriptor includes Tokens MCP and 5-hour rows`() { let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-zai-three-quota") diff --git a/Tests/CodexBarTests/CopilotUsageModelsTests.swift b/Tests/CodexBarTests/CopilotUsageModelsTests.swift index 9ad621af0..4910c77cd 100644 --- a/Tests/CodexBarTests/CopilotUsageModelsTests.swift +++ b/Tests/CodexBarTests/CopilotUsageModelsTests.swift @@ -35,6 +35,30 @@ struct CopilotUsageModelsTests { #expect(response.quotaSnapshots.chat?.remaining == 150) } + @Test + func `builds quota window from assigned and reset dates`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "free", + "assigned_date": "2025-01-01", + "quota_reset_date": "2025-02-01", + "quota_snapshots": { + "chat": { + "entitlement": 200, + "remaining": 75, + "percent_remaining": 37.5, + "quota_id": "chat" + } + } + } + """) + + let quotaWindow = try #require(response.quotaWindow) + #expect(quotaWindow.windowMinutes == 31 * 24 * 60) + #expect(quotaWindow.resetsAt > quotaWindow.assignedAt) + } + @Test func `decodes chat only quota snapshots payload`() throws { let response = try Self.decodeFixture( From 6e8b43534fee6c09fc04531f7c01cf545cf3b7b6 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:12:04 -0700 Subject: [PATCH 2/2] fix: align Copilot pace with calendar month Derive Copilot premium-request pacing from the calendar month ending at quota_reset_date instead of the assigned_date window. Keep Copilot on a single visible row and add regression coverage for the corrected monthly reserve math. --- Sources/CodexBar/MenuCardView.swift | 35 +++++++++++---- Sources/CodexBar/MenuDescriptor.swift | 19 +++++++- .../CodexBar/PreferencesProvidersPane.swift | 14 +++--- .../StatusItemController+Animation.swift | 8 +++- .../CodexBar/StatusItemController+Menu.swift | 14 +++--- Sources/CodexBarCore/CopilotUsageModels.swift | 13 ++++-- ...dexPresentationCharacterizationTests.swift | 7 +-- .../CopilotUsageModelsTests.swift | 44 ++++++++++++++++-- Tests/CodexBarTests/UsagePaceTextTests.swift | 45 +++++++++++++++++++ 9 files changed, 170 insertions(+), 29 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index d211a9d96..5552a6f3c 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -950,6 +950,23 @@ extension UsageMenuCardView.Model { input: input, projection: codexProjection, percentStyle: percentStyle)) + } else if input.provider == .copilot { + let copilotWindow = snapshot.primary ?? snapshot.secondary + if let copilotWindow { + let paceDetail = Self.weeklyPaceDetail( + window: copilotWindow, + now: input.now, + pace: input.weeklyPace, + showUsed: input.usageBarsShowUsed) + metrics.append(Self.primaryMetric( + input: input, + primary: copilotWindow, + percentStyle: percentStyle, + zaiTokenDetail: zaiTokenDetail, + openRouterQuotaDetail: openRouterQuotaDetail, + titleOverride: input.metadata.sessionLabel, + paceDetail: paceDetail)) + } } else if let primary = snapshot.primary { metrics.append(Self.primaryMetric( input: input, @@ -958,7 +975,7 @@ extension UsageMenuCardView.Model { zaiTokenDetail: zaiTokenDetail, openRouterQuotaDetail: openRouterQuotaDetail)) } - if input.provider != .codex, let weekly = snapshot.secondary { + if input.provider != .codex, input.provider != .copilot, let weekly = snapshot.secondary { metrics.append(Self.secondaryMetric( input: input, weekly: weekly, @@ -1033,7 +1050,9 @@ extension UsageMenuCardView.Model { primary: RateWindow, percentStyle: PercentStyle, zaiTokenDetail: String?, - openRouterQuotaDetail: String?) -> Metric + openRouterQuotaDetail: String?, + titleOverride: String? = nil, + paceDetail: PaceDetail? = nil) -> Metric { var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now) @@ -1059,16 +1078,16 @@ extension UsageMenuCardView.Model { } return Metric( id: "primary", - title: input.metadata.sessionLabel, + title: titleOverride ?? input.metadata.sessionLabel, percent: Self.clamped( input.usageBarsShowUsed ? primary.usedPercent : primary.remainingPercent), percentStyle: percentStyle, resetText: primaryResetText, detailText: primaryDetailText, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true) + detailLeftText: paceDetail?.leftLabel, + detailRightText: paceDetail?.rightLabel, + pacePercent: paceDetail?.pacePercent, + paceOnTop: paceDetail?.paceOnTop ?? true) } private static func secondaryMetric( @@ -1278,7 +1297,7 @@ extension UsageMenuCardView.Model { let actualPercent = showUsed ? actualUsed : (100 - actualUsed) if expectedPercent.isFinite == false || actualPercent.isFinite == false { return nil } let paceOnTop = actualUsed <= expectedUsed - let pacePercent: Double? = if detail.stage == .onTrack { nil } else { expectedPercent } + let pacePercent: Double? = if detail.stage == UsagePace.Stage.onTrack { nil } else { expectedPercent } return PaceDetail( leftLabel: detail.leftLabel, rightLabel: detail.rightLabel, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 68c81a10a..811f9da63 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -163,6 +163,12 @@ struct MenuDescriptor { window: primaryWindow, resetStyle: resetStyle, showUsed: settings.usageBarsShowUsed) + if provider == .copilot, + let pace = store.weeklyPace(provider: provider, window: primary) + { + let paceSummary = UsagePaceText.weeklySummary(pace: pace) + entries.append(.text(paceSummary, .secondary)) + } if provider == .warp || provider == .kilo, let detail = primary.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines), !detail.isEmpty @@ -170,7 +176,7 @@ struct MenuDescriptor { entries.append(.text(detail, .secondary)) } } - if let weekly = snap.secondary { + if provider != .copilot, let weekly = snap.secondary { let weeklyResetOverride: String? = { guard provider == .warp || provider == .kilo || provider == .perplexity else { return nil } let detail = weekly.resetDescription?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -198,6 +204,17 @@ struct MenuDescriptor { let paceSummary = UsagePaceText.weeklySummary(pace: pace) entries.append(.text(paceSummary, .secondary)) } + } else if provider == .copilot, snap.primary == nil, let weekly = snap.secondary { + Self.appendRateWindow( + entries: &entries, + title: meta.sessionLabel, + window: weekly, + resetStyle: resetStyle, + showUsed: settings.usageBarsShowUsed) + if let pace = store.weeklyPace(provider: provider, window: weekly) { + let paceSummary = UsagePaceText.weeklySummary(pace: pace) + entries.append(.text(paceSummary, .secondary)) + } } if meta.supportsOpus, let opus = snap.tertiary { // Perplexity purchased credits don't reset; show the balance as plain text. diff --git a/Sources/CodexBar/PreferencesProvidersPane.swift b/Sources/CodexBar/PreferencesProvidersPane.swift index 9e8a63640..77804e3f0 100644 --- a/Sources/CodexBar/PreferencesProvidersPane.swift +++ b/Sources/CodexBar/PreferencesProvidersPane.swift @@ -535,14 +535,18 @@ struct ProvidersPane: View { tokenError = nil } - let weeklyPace = if let codexProjection, - let weekly = codexProjection.rateWindow(for: .weekly) + let weeklyPace: UsagePace? = if let codexProjection, + let weekly = codexProjection.rateWindow(for: .weekly) { self.store.weeklyPace(provider: provider, window: weekly, now: now) + } else if provider == .copilot, + let window = snapshot?.primary ?? snapshot?.secondary + { + self.store.weeklyPace(provider: provider, window: window, now: now) + } else if let window = snapshot?.secondary { + self.store.weeklyPace(provider: provider, window: window, now: now) } else { - snapshot?.secondary.flatMap { window in - self.store.weeklyPace(provider: provider, window: window, now: now) - } + nil } let input = UsageMenuCardView.Model.Input( provider: provider, diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 7206412fd..594aca549 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -484,7 +484,13 @@ extension StatusItemController { case .percent: pace = nil case .pace, .both: - let weeklyWindow = codexProjection?.rateWindow(for: .weekly) ?? snapshot?.secondary + let weeklyWindow = if let codexWeekly = codexProjection?.rateWindow(for: .weekly) { + codexWeekly + } else if provider == .copilot { + snapshot?.primary ?? snapshot?.secondary + } else { + snapshot?.secondary + } pace = weeklyWindow.flatMap { window in self.store.weeklyPace(provider: provider, window: window, now: now) } diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index 332ba5ab4..74e0c8f1d 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -1408,14 +1408,18 @@ extension StatusItemController { let sourceLabel = snapshotOverride == nil ? self.store.sourceLabel(for: target) : nil let kiloAutoMode = target == .kilo && self.settings.kiloUsageDataSource == .auto - let weeklyPace = if let codexProjection, - let weekly = codexProjection.rateWindow(for: .weekly) + let weeklyPace: UsagePace? = if let codexProjection, + let weekly = codexProjection.rateWindow(for: .weekly) { self.store.weeklyPace(provider: target, window: weekly, now: now) + } else if target == .copilot, + let window = snapshot?.primary ?? snapshot?.secondary + { + self.store.weeklyPace(provider: target, window: window, now: now) + } else if let window = snapshot?.secondary { + self.store.weeklyPace(provider: target, window: window, now: now) } else { - snapshot?.secondary.flatMap { window in - self.store.weeklyPace(provider: target, window: window, now: now) - } + nil } let input = UsageMenuCardView.Model.Input( provider: target, diff --git a/Sources/CodexBarCore/CopilotUsageModels.swift b/Sources/CodexBarCore/CopilotUsageModels.swift index c9f46209b..3f39aaf22 100644 --- a/Sources/CodexBarCore/CopilotUsageModels.swift +++ b/Sources/CodexBarCore/CopilotUsageModels.swift @@ -215,8 +215,8 @@ public struct CopilotUsageResponse: Sendable, Decodable { public let quotaResetDate: String? public var quotaWindow: QuotaWindow? { - guard let assignedAt = Self.parseQuotaDate(self.assignedDate), - let resetsAt = Self.parseQuotaDate(self.quotaResetDate), + guard let resetsAt = Self.parseQuotaDate(self.quotaResetDate), + let assignedAt = Self.calendarMonthStart(for: resetsAt), resetsAt > assignedAt else { return nil @@ -332,8 +332,15 @@ public struct CopilotUsageResponse: Sendable, Decodable { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.calendar = Calendar(identifier: .gregorian) - formatter.timeZone = TimeZone.current + formatter.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt formatter.dateFormat = "yyyy-MM-dd" return formatter.date(from: value) } + + private static func calendarMonthStart(for resetDate: Date) -> Date? { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt + let resetMonth = calendar.dateInterval(of: .month, for: resetDate)?.start + return calendar.date(byAdding: .month, value: -1, to: resetMonth ?? resetDate) + } } diff --git a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift index 19ae9983e..34e97e11b 100644 --- a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift @@ -347,8 +347,8 @@ struct CodexPresentationCharacterizationTests { } @Test - func `Copilot menu includes weekly pace summary when reset window is known`() { - let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-copilot-weekly-pace") + func `Copilot menu shows a single premium row with pace summary`() { + let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-copilot-monthly-pace") settings.statusChecksEnabled = false let now = Date() @@ -387,7 +387,8 @@ struct CodexPresentationCharacterizationTests { includeContextualActions: false) let lines = self.textLines(from: descriptor) - #expect(lines.contains(where: { $0.hasPrefix("Chat:") })) + #expect(lines.contains(where: { $0.hasPrefix("Premium:") })) + #expect(lines.contains(where: { $0.hasPrefix("Chat:") }) == false) #expect(lines.contains(where: { $0.hasPrefix("Pace:") && $0.contains("reserve") })) } diff --git a/Tests/CodexBarTests/CopilotUsageModelsTests.swift b/Tests/CodexBarTests/CopilotUsageModelsTests.swift index 4910c77cd..904b2410a 100644 --- a/Tests/CodexBarTests/CopilotUsageModelsTests.swift +++ b/Tests/CodexBarTests/CopilotUsageModelsTests.swift @@ -36,12 +36,12 @@ struct CopilotUsageModelsTests { } @Test - func `builds quota window from assigned and reset dates`() throws { + func `builds calendar month quota window from reset date`() throws { let response = try Self.decodeFixture( """ { "copilot_plan": "free", - "assigned_date": "2025-01-01", + "assigned_date": "2025-01-12", "quota_reset_date": "2025-02-01", "quota_snapshots": { "chat": { @@ -55,8 +55,46 @@ struct CopilotUsageModelsTests { """) let quotaWindow = try #require(response.quotaWindow) + let utc = TimeZone(secondsFromGMT: 0) ?? .gmt + let calendar = Calendar(identifier: .gregorian) #expect(quotaWindow.windowMinutes == 31 * 24 * 60) - #expect(quotaWindow.resetsAt > quotaWindow.assignedAt) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.assignedAt).year == 2025) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.assignedAt).month == 1) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.assignedAt).day == 1) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.resetsAt).year == 2025) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.resetsAt).month == 2) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.resetsAt).day == 1) + } + + @Test + func `ignores assigned date when reset date indicates calendar month cycle`() throws { + let response = try Self.decodeFixture( + """ + { + "copilot_plan": "individual_pro", + "assigned_date": "2026-03-22T16:03:34-07:00", + "quota_reset_date": "2026-05-01", + "quota_snapshots": { + "premium_interactions": { + "entitlement": 1500, + "remaining": 1139, + "percent_remaining": 75.9, + "quota_id": "premium_interactions" + } + } + } + """) + + let quotaWindow = try #require(response.quotaWindow) + let utc = TimeZone(secondsFromGMT: 0) ?? .gmt + let calendar = Calendar(identifier: .gregorian) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.assignedAt).year == 2026) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.assignedAt).month == 4) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.assignedAt).day == 1) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.resetsAt).year == 2026) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.resetsAt).month == 5) + #expect(calendar.dateComponents(in: utc, from: quotaWindow.resetsAt).day == 1) + #expect(quotaWindow.windowMinutes == 30 * 24 * 60) } @Test diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 987f2d1e8..07ff07f3c 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -51,6 +51,51 @@ struct UsagePaceTextTests { #expect(summary == "Pace: 7% in deficit · Runs out in 3d") } + @Test + func `pace summary uses deficit reserve wording for monthly windows too`() { + let now = Date(timeIntervalSince1970: 0) + let pace = UsagePace( + stage: .farBehind, + deltaPercent: -49, + expectedUsedPercent: 49, + actualUsedPercent: 0, + etaSeconds: nil, + willLastToReset: true, + runOutProbability: nil) + + let summary = UsagePaceText.weeklySummary(pace: pace, now: now) + + #expect(summary == "Pace: 49% in reserve · Lasts until reset") + } + + @Test + func `calendar month premium pace rounds to expected reserve`() throws { + let calendar = Calendar(identifier: .gregorian) + let utc = TimeZone(secondsFromGMT: 0) ?? .gmt + let now = try #require(calendar.date(from: DateComponents( + timeZone: utc, + year: 2026, + month: 4, + day: 10, + hour: 20))) + let resetAt = try #require(calendar.date(from: DateComponents( + timeZone: utc, + year: 2026, + month: 5, + day: 1, + hour: 0))) + let window = RateWindow( + usedPercent: 24.1, + windowMinutes: 30 * 24 * 60, + resetsAt: resetAt, + resetDescription: nil) + let pace = try #require(UsagePace.weekly(window: window, now: now)) + + let summary = UsagePaceText.weeklySummary(pace: pace, now: now) + + #expect(summary == "Pace: 9% in reserve · Lasts until reset") + } + @Test func `weekly pace detail formats rounded risk when available`() { let now = Date(timeIntervalSince1970: 0)