diff --git a/Sources/CodexBar/MenuBarLayoutEditor.swift b/Sources/CodexBar/MenuBarLayoutEditor.swift index e6ae63c5e0..b7a306f503 100644 --- a/Sources/CodexBar/MenuBarLayoutEditor.swift +++ b/Sources/CodexBar/MenuBarLayoutEditor.swift @@ -659,9 +659,7 @@ private struct MenuBarLayoutPreview: View { { session = projection.menuBarSelectableRateWindow(for: .session) weekly = projection.menuBarSelectableRateWindow(for: .weekly) - automatic = projection.visibleRateLanes.lazy - .compactMap { projection.menuBarSelectableRateWindow(for: $0) } - .first + automatic = projection.automaticMenuBarWindow() } else { let semanticWindows = MenuBarLayoutSemanticWindowResolver.windows( provider: provider, diff --git a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift index c5f0c18529..ca42ed5615 100644 --- a/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift +++ b/Sources/CodexBar/MenuCardQuotaWarningMarkers.swift @@ -1,12 +1,14 @@ import CodexBarCore extension CodexConsumerProjection.RateLane { - var quotaWarningWindow: QuotaWarningWindow { + var quotaWarningWindow: QuotaWarningWindow? { switch self { case .session: .session case .weekly: .weekly + case .monthly: + nil } } } diff --git a/Sources/CodexBar/MenuCardView+SessionEquivalent.swift b/Sources/CodexBar/MenuCardView+SessionEquivalent.swift index 18a57c3e6b..4adfbb20da 100644 --- a/Sources/CodexBar/MenuCardView+SessionEquivalent.swift +++ b/Sources/CodexBar/MenuCardView+SessionEquivalent.swift @@ -22,20 +22,24 @@ extension UsageMenuCardView.Model { projection.visibleRateLanes.compactMap { lane in guard let window = projection.rateWindow(for: lane) else { return nil } - let title: String + let title = CodexConsumerProjection.rateTitle( + lane: lane, + windowMinutes: window.windowMinutes, + sessionLabel: input.metadata.sessionLabel, + weeklyLabel: input.metadata.weeklyLabel) let id: String let paceDetail: PaceDetail? switch lane { case .session: - title = L(input.metadata.sessionLabel) id = "primary" + // UsagePaceText.sessionPace suppresses weekly/monthly durations centrally; + // unknown durations in the session lane keep their existing pace. paceDetail = Self.sessionPaceDetail( provider: input.provider, window: window, now: input.now, showUsed: input.usageBarsShowUsed) case .weekly: - title = L(input.metadata.weeklyLabel) id = "secondary" paceDetail = Self.weeklyPaceDetail( provider: input.provider, @@ -43,6 +47,9 @@ extension UsageMenuCardView.Model { now: input.now, pace: Self.standardWeeklyPace(input: input, window: window), showUsed: input.usageBarsShowUsed) + case .monthly: + id = "monthly" + paceDetail = nil } return Metric( @@ -57,7 +64,7 @@ extension UsageMenuCardView.Model { pacePercent: paceDetail?.pacePercent, paceOnTop: paceDetail?.paceOnTop ?? true, warningMarkerPercents: Self.warningMarkerPercents( - thresholds: input.quotaWarningThresholds[lane.quotaWarningWindow], + thresholds: lane.quotaWarningWindow.flatMap { input.quotaWarningThresholds[$0] }, showUsed: input.usageBarsShowUsed), workdayMarkerPercents: lane == .weekly ? workDayMarkerPercents( diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 5c8a5652b0..4aa29b6ed8 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -708,7 +708,13 @@ struct MenuDescriptor { if provider == .factory, snapshot.tertiary != nil { return ("5-hour", L("Weekly"), L("Monthly"), true) } - let primaryLabel = if provider == .grok { + let primaryLabel = if provider == .codex { + CodexConsumerProjection.rateTitle( + lane: .session, + windowMinutes: snapshot.primary?.windowMinutes, + sessionLabel: metadata.sessionLabel, + weeklyLabel: metadata.weeklyLabel) + } else if provider == .grok { GrokProviderDescriptor.primaryLabel(window: snapshot.primary) ?? metadata.sessionLabel } else if provider == .crof { CrofProviderDescriptor.primaryLabel(snapshot: snapshot) @@ -723,7 +729,13 @@ struct MenuDescriptor { } else { metadata.sessionLabel } - let secondaryLabel = if provider == .amp { + let secondaryLabel = if provider == .codex { + CodexConsumerProjection.rateTitle( + lane: .weekly, + windowMinutes: snapshot.secondary?.windowMinutes, + sessionLabel: metadata.sessionLabel, + weeklyLabel: metadata.weeklyLabel) + } else if provider == .amp { AmpProviderDescriptor.secondaryLabel(details: snapshot.ampUsage) ?? metadata.weeklyLabel } else if provider == .alibabatokenplan { AlibabaTokenPlanProviderDescriptor.secondaryLabel(window: snapshot.secondary) ?? metadata.weeklyLabel diff --git a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift index d6ec4e6a70..df1d24c2e6 100644 --- a/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift +++ b/Sources/CodexBar/PlanUtilizationHistoryChartMenuView.swift @@ -193,18 +193,19 @@ struct PlanUtilizationHistoryChartMenuView: View { for history in histories { guard !history.entries.isEmpty else { continue } guard history.windowMinutes > 0 else { continue } - guard allowedNames?.contains(history.name) ?? true else { continue } + let effectiveName = Self.effectiveSeriesName(provider: provider, history: history) + guard allowedNames?.contains(effectiveName) ?? true else { continue } - let canonicalWindowMinutes = history.name.canonicalWindowMinutes(history.windowMinutes) - let selection = SeriesSelection(name: history.name, windowMinutes: canonicalWindowMinutes) + let canonicalWindowMinutes = effectiveName.canonicalWindowMinutes(history.windowMinutes) + let selection = SeriesSelection(name: effectiveName, windowMinutes: canonicalWindowMinutes) if let existingHistory = historiesBySelection[selection] { historiesBySelection[selection] = PlanUtilizationSeriesHistory( - name: history.name, + name: effectiveName, windowMinutes: canonicalWindowMinutes, entries: Self.mergedEntries(existingHistory.entries + history.entries)) } else { historiesBySelection[selection] = PlanUtilizationSeriesHistory( - name: history.name, + name: effectiveName, windowMinutes: canonicalWindowMinutes, entries: history.entries) } @@ -230,6 +231,22 @@ struct PlanUtilizationHistoryChartMenuView: View { } } + /// Histories recorded before duration-based classification stored a 43,200-minute Codex window + /// under its payload slot (session for primary, weekly for secondary). Fold those into the + /// monthly series so the chart does not split or hide the window's history. + private nonisolated static func effectiveSeriesName( + provider: UsageProvider, + history: PlanUtilizationSeriesHistory) -> PlanUtilizationSeriesName + { + if provider == .codex, + history.windowMinutes == CodexConsumerProjection.monthlyWindowMinutes, + history.name == .session || history.name == .weekly + { + return .monthly + } + return history.name + } + nonisolated static func mergedEntries( _ entries: [PlanUtilizationHistoryEntry]) -> [PlanUtilizationHistoryEntry] { @@ -248,8 +265,7 @@ struct PlanUtilizationHistoryChartMenuView: View { var names: Set = [] switch provider { case .codex: - if snapshot.primary != nil { names.insert(.session) } - if snapshot.secondary != nil { names.insert(.weekly) } + names = CodexConsumerProjection.planUtilizationSeriesNames(snapshot: snapshot) case .claude: if snapshot.primary != nil { names.insert(.session) } if snapshot.secondary != nil { names.insert(.weekly) } diff --git a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift index ea54df5c79..be1104d035 100644 --- a/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift +++ b/Sources/CodexBar/Providers/Codex/CodexConsumerProjection.swift @@ -175,8 +175,13 @@ struct CodexConsumerProjection { enum RateLane: String { case session case weekly + case monthly } + static let sessionWindowMinutes = 5 * 60 + static let weeklyWindowMinutes = 7 * 24 * 60 + static let monthlyWindowMinutes = 30 * 24 * 60 + enum SupplementalMetric: String { case codeReview } @@ -311,7 +316,7 @@ struct CodexConsumerProjection { session: window, weekly: self.rateWindowsByLane[.weekly], evaluationTime: self.evaluationTime) - case .weekly: + case .weekly, .monthly: return window } } @@ -335,6 +340,47 @@ struct CodexConsumerProjection { return nil } + /// Automatic keeps the standard session window unless a longer window (e.g. a 30-day + /// primary) would hide a genuine weekly quota from the menu bar. + func automaticMenuBarWindow() -> RateWindow? { + let windows = self.visibleRateLanes.compactMap { + self.menuBarSelectableRateWindow(for: $0) + } + guard let weekly = self.menuBarSelectableRateWindow(for: .weekly), + windows.contains(where: { + $0.windowMinutes.map { $0 > Self.weeklyWindowMinutes } ?? false + }) + else { + return windows.first + } + return weekly + } + + static func rateTitle( + lane: RateLane, + windowMinutes: Int?, + sessionLabel: String, + weeklyLabel: String) -> String + { + switch windowMinutes { + case self.sessionWindowMinutes: + L(sessionLabel) + case self.weeklyWindowMinutes: + L(weeklyLabel) + case self.monthlyWindowMinutes: + L("Monthly") + default: + switch lane { + case .session: + L(sessionLabel) + case .weekly: + L(weeklyLabel) + case .monthly: + L("Monthly") + } + } + } + var nextMenuBarStateChangeAt: Date? { self.rateWindowsByLane.values.compactMap { window in guard window.remainingPercent <= 0, @@ -407,7 +453,7 @@ struct CodexConsumerProjection { } private static func planUtilizationLanes(from rateWindowsByLane: [RateLane: RateWindow]) -> [PlanUtilizationLane] { - let semanticOrder: [RateLane] = [.session, .weekly] + let semanticOrder: [RateLane] = [.session, .weekly, .monthly] return semanticOrder.compactMap { lane in guard let window = rateWindowsByLane[lane] else { return nil } return PlanUtilizationLane(role: self.planUtilizationRole(for: lane), window: window) @@ -420,9 +466,15 @@ struct CodexConsumerProjection { .session case .weekly: .weekly + case .monthly: + .monthly } } + static func planUtilizationSeriesNames(snapshot: UsageSnapshot) -> Set { + Set(self.rateWindowsByLane(snapshot: snapshot).keys.map { self.planUtilizationRole(for: $0) }) + } + private enum SnapshotSlot { case primary case secondary @@ -432,10 +484,12 @@ struct CodexConsumerProjection { guard let window else { return nil } let lane: RateLane = switch window.windowMinutes { - case 300: + case Self.sessionWindowMinutes: .session - case 10080: + case Self.weeklyWindowMinutes: .weekly + case Self.monthlyWindowMinutes: + .monthly default: switch slot { case .primary: @@ -591,7 +645,9 @@ extension UsageStore { usedPercent: usedPercent, windowMinutes: nil, resetsAt: nil, resetDescription: nil) case .primaryAndSecondary: return windows.prefix(2).max(by: { $0.usedPercent < $1.usedPercent }) - case .automatic, .primary, .monthlyPlan: + case .automatic: + return projection.automaticMenuBarWindow() + case .primary, .monthlyPlan: return first } } diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexResetBackfill.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexResetBackfill.swift new file mode 100644 index 0000000000..481931d86b --- /dev/null +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexResetBackfill.swift @@ -0,0 +1,134 @@ +import CodexBarCore +import Foundation + +/// Reset-time backfill for Codex rate windows: rebuilds raw snapshot slots from cached lane data so +/// missing reset timestamps survive refreshes without disturbing fresh quota values. +extension UsageStore { + nonisolated static func codexBackfillingResetWindows( + _ snapshot: UsageSnapshot, + from cached: UsageSnapshot) -> UsageSnapshot + { + let primary = self.codexBackfilledSlotWindow( + slotWindow: snapshot.primary, + lane: .session, + snapshot: snapshot, + cached: cached) + let secondary = self.codexBackfilledSlotWindow( + slotWindow: snapshot.secondary, + lane: .weekly, + snapshot: snapshot, + cached: cached) + guard primary != snapshot.primary || secondary != snapshot.secondary else { return snapshot } + return snapshot.with(primary: primary, secondary: secondary) + } + + /// Rebuilds one raw snapshot slot during reset backfill. Monthly-classified windows live outside + /// the session/weekly lane lookup, so a fresh 30-day window must be preserved in place (with its + /// own reset backfill) instead of being dropped or overwritten by a stale cached lane window. + private nonisolated static func codexBackfilledSlotWindow( + slotWindow: RateWindow?, + lane: CodexConsumerProjection.RateLane, + snapshot: UsageSnapshot, + cached: UsageSnapshot) -> RateWindow? + { + if let slotWindow, slotWindow.windowMinutes == CodexConsumerProjection.monthlyWindowMinutes { + let cachedMonthly = [cached.primary, cached.secondary, cached.tertiary] + .compactMap(\.self) + .first { $0.windowMinutes == CodexConsumerProjection.monthlyWindowMinutes } + return self.codexBackfillingResetWindow(slotWindow, from: cachedMonthly) + } + return self.codexBackfillingResetWindow( + CodexConsumerProjection.sourceRateWindow(for: lane, snapshot: snapshot), + from: CodexConsumerProjection.sourceRateWindow(for: lane, snapshot: cached)) + } + + nonisolated static func codexMergedResetBackfillSnapshot( + _ snapshots: [UsageSnapshot], + now: Date = Date()) -> UsageSnapshot? + { + var primary = self.codexPreferredResetBackfillWindow( + snapshots.enumerated().compactMap { index, snapshot in + CodexConsumerProjection.sourceRateWindow(for: .session, snapshot: snapshot) + .map { (window: $0, updatedAt: snapshot.updatedAt, priority: index) } + }, + now: now) + var secondary = self.codexPreferredResetBackfillWindow( + snapshots.enumerated().compactMap { index, snapshot in + CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: snapshot) + .map { (window: $0, updatedAt: snapshot.updatedAt, priority: index) } + }, + now: now) + let monthly = self.codexPreferredResetBackfillWindow( + snapshots.enumerated().compactMap { index, snapshot in + Self.monthlyRateWindow(in: snapshot) + .map { (window: $0, updatedAt: snapshot.updatedAt, priority: index) } + }, + now: now) + if let monthly, let monthlyReset = monthly.resetsAt { + if primary == nil { + primary = monthly + } else if secondary == nil { + secondary = monthly + } else if let primaryReset = primary?.resetsAt, monthlyReset > primaryReset { + primary = monthly + } else if let secondaryReset = secondary?.resetsAt, monthlyReset > secondaryReset { + secondary = monthly + } + } + guard primary != nil || secondary != nil else { return nil } + return UsageSnapshot( + primary: primary, + secondary: secondary, + updatedAt: snapshots.map(\.updatedAt).max() ?? now) + } + + private nonisolated static func monthlyRateWindow(in snapshot: UsageSnapshot) -> RateWindow? { + [snapshot.primary, snapshot.secondary, snapshot.tertiary] + .compactMap(\.self) + .first { $0.windowMinutes == CodexConsumerProjection.monthlyWindowMinutes } + } + + private nonisolated static func codexPreferredResetBackfillWindow( + _ windows: [(window: RateWindow, updatedAt: Date, priority: Int)], + now: Date) -> RateWindow? + { + windows + .filter { ($0.window.resetsAt ?? .distantPast) > now } + .max { lhs, rhs in + if lhs.updatedAt != rhs.updatedAt { + return lhs.updatedAt < rhs.updatedAt + } + if lhs.priority != rhs.priority { + return lhs.priority < rhs.priority + } + let lhsReset = lhs.window.resetsAt ?? .distantPast + let rhsReset = rhs.window.resetsAt ?? .distantPast + if lhsReset != rhsReset { + return lhsReset < rhsReset + } + return (lhs.window.windowMinutes ?? 0) < (rhs.window.windowMinutes ?? 0) + } + .map(\.window) + } + + private nonisolated static func codexBackfillingResetWindow( + _ window: RateWindow?, + from cached: RateWindow?) -> RateWindow? + { + guard let cached, + let resetsAt = cached.resetsAt, + resetsAt > Date() + else { + return window + } + if let window { + return window.backfillingResetTime(from: cached) + } + guard let windowMinutes = cached.windowMinutes, windowMinutes > 0 else { return nil } + return RateWindow( + usedPercent: cached.usedPercent, + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: cached.resetDescription) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift index 6fe60fc426..8c60ea861e 100644 --- a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift +++ b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift @@ -129,10 +129,7 @@ extension StatusItemController { { let session = projection.menuBarSelectableRateWindow(for: .session) let weekly = projection.menuBarSelectableRateWindow(for: .weekly) - let automatic = projection.visibleRateLanes - .lazy - .compactMap { projection.menuBarSelectableRateWindow(for: $0) } - .first + let automatic = projection.automaticMenuBarWindow() return (session, weekly, automatic) } diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index f864f549de..eb1584b255 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -158,6 +158,14 @@ enum UsagePaceText { guard provider == .codex || provider == .claude || provider == .ollama || provider == .antigravity || provider == .kimi || provider == .notion else { return nil } + // Suppress session pace only for windows that classify into a longer lane (weekly/monthly). + // Unknown durations fall back to the session lane and keep their existing pace behavior. + if provider == .codex, let minutes = window.windowMinutes, + minutes == CodexConsumerProjection.weeklyWindowMinutes || + minutes == CodexConsumerProjection.monthlyWindowMinutes + { + return nil + } if provider == .ollama, window.windowMinutes == nil { return nil } diff --git a/Sources/CodexBar/UsageStore+TokenAccounts.swift b/Sources/CodexBar/UsageStore+TokenAccounts.swift index 6a33e928d9..ee91f0f5b9 100644 --- a/Sources/CodexBar/UsageStore+TokenAccounts.swift +++ b/Sources/CodexBar/UsageStore+TokenAccounts.swift @@ -1216,87 +1216,6 @@ extension UsageStore { return trimmed } - nonisolated static func codexBackfillingResetWindows( - _ snapshot: UsageSnapshot, - from cached: UsageSnapshot) -> UsageSnapshot - { - let primary = self.codexBackfillingResetWindow( - CodexConsumerProjection.sourceRateWindow(for: .session, snapshot: snapshot), - from: CodexConsumerProjection.sourceRateWindow(for: .session, snapshot: cached)) - let secondary = self.codexBackfillingResetWindow( - CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: snapshot), - from: CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: cached)) - guard primary != snapshot.primary || secondary != snapshot.secondary else { return snapshot } - return snapshot.with(primary: primary, secondary: secondary) - } - - nonisolated static func codexMergedResetBackfillSnapshot( - _ snapshots: [UsageSnapshot], - now: Date = Date()) -> UsageSnapshot? - { - let primary = self.codexPreferredResetBackfillWindow( - snapshots.enumerated().compactMap { index, snapshot in - CodexConsumerProjection.sourceRateWindow(for: .session, snapshot: snapshot) - .map { (window: $0, updatedAt: snapshot.updatedAt, priority: index) } - }, - now: now) - let secondary = self.codexPreferredResetBackfillWindow( - snapshots.enumerated().compactMap { index, snapshot in - CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: snapshot) - .map { (window: $0, updatedAt: snapshot.updatedAt, priority: index) } - }, - now: now) - guard primary != nil || secondary != nil else { return nil } - return UsageSnapshot( - primary: primary, - secondary: secondary, - updatedAt: snapshots.map(\.updatedAt).max() ?? now) - } - - private nonisolated static func codexPreferredResetBackfillWindow( - _ windows: [(window: RateWindow, updatedAt: Date, priority: Int)], - now: Date) -> RateWindow? - { - windows - .filter { ($0.window.resetsAt ?? .distantPast) > now } - .max { lhs, rhs in - if lhs.updatedAt != rhs.updatedAt { - return lhs.updatedAt < rhs.updatedAt - } - if lhs.priority != rhs.priority { - return lhs.priority < rhs.priority - } - let lhsReset = lhs.window.resetsAt ?? .distantPast - let rhsReset = rhs.window.resetsAt ?? .distantPast - if lhsReset != rhsReset { - return lhsReset < rhsReset - } - return (lhs.window.windowMinutes ?? 0) < (rhs.window.windowMinutes ?? 0) - } - .map(\.window) - } - - private nonisolated static func codexBackfillingResetWindow( - _ window: RateWindow?, - from cached: RateWindow?) -> RateWindow? - { - guard let cached, - let resetsAt = cached.resetsAt, - resetsAt > Date() - else { - return window - } - if let window { - return window.backfillingResetTime(from: cached) - } - guard let windowMinutes = cached.windowMinutes, windowMinutes > 0 else { return nil } - return RateWindow( - usedPercent: cached.usedPercent, - windowMinutes: windowMinutes, - resetsAt: resetsAt, - resetDescription: cached.resetDescription) - } - func recordFetchedTokenAccountPlanUtilizationHistory( provider: UsageProvider, samples: [(account: ProviderTokenAccount, snapshot: UsageSnapshot)], diff --git a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift index 53f625df30..ab11d1d585 100644 --- a/Sources/CodexBar/UsageStore+WidgetSnapshot.swift +++ b/Sources/CodexBar/UsageStore+WidgetSnapshot.swift @@ -342,12 +342,11 @@ extension UsageStore { now: now) return projection.visibleRateLanes.compactMap { lane in guard let window = projection.sourceRateWindow(for: lane) else { return nil } - let title = switch lane { - case .session: - metadata?.sessionLabel ?? "Session" - case .weekly: - metadata?.weeklyLabel ?? "Weekly" - } + let title = CodexConsumerProjection.rateTitle( + lane: lane, + windowMinutes: window.windowMinutes, + sessionLabel: metadata?.sessionLabel ?? "Session", + weeklyLabel: metadata?.weeklyLabel ?? "Weekly") return WidgetSnapshot.WidgetUsageRowSnapshot( id: lane.rawValue, title: title, diff --git a/Tests/CodexBarTests/CodexConsumerProjectionTests.swift b/Tests/CodexBarTests/CodexConsumerProjectionTests.swift index 464096b7da..35417fea42 100644 --- a/Tests/CodexBarTests/CodexConsumerProjectionTests.swift +++ b/Tests/CodexBarTests/CodexConsumerProjectionTests.swift @@ -470,6 +470,82 @@ struct CodexConsumerProjectionTests { #expect(session.resetsAt == sessionReset) } + @Test + func `thirty day primary window maps to a monthly lane instead of session`() { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-30day-primary") + let now = Date(timeIntervalSince1970: 1_800_000_000) + + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 55, + windowMinutes: 43200, + resetsAt: now.addingTimeInterval(24 * 86400), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 5, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 86400), + resetDescription: nil), + updatedAt: now), + provider: .codex) + + let projection = store.codexConsumerProjection(surface: .liveCard, now: now) + + #expect(projection.visibleRateLanes == [.monthly, .weekly]) + #expect(projection.rateWindow(for: .session) == nil) + #expect(projection.rateWindow(for: .monthly)?.windowMinutes == 43200) + #expect(projection.planUtilizationLanes.map(\.role.rawValue) == ["weekly", "monthly"]) + } + + @Test + func `automatic menu bar metric prefers the weekly window over a thirty day primary`() { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-30day-automatic") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 55, + windowMinutes: 43200, + resetsAt: now.addingTimeInterval(24 * 86400), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 5, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 86400), + resetDescription: nil), + updatedAt: now) + + let projection = store.codexConsumerProjection( + surface: .menuBar, + snapshotOverride: snapshot, + now: now) + + #expect(projection.automaticMenuBarWindow()?.windowMinutes == 10080) + #expect(store.codexMenuBarMetricWindow(snapshot: snapshot, now: now)?.windowMinutes == 10080) + } + + @Test + func `automatic menu bar metric keeps the standard five hour session primary`() { + let store = self.makeStore(suite: "CodexConsumerProjectionTests-standard-automatic") + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 55, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(3 * 3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 5, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 86400), + resetDescription: nil), + updatedAt: now) + + let window = store.codexMenuBarMetricWindow(snapshot: snapshot, now: now) + + #expect(window?.windowMinutes == 300) + } + private func makeStore(suite: String) -> UsageStore { let defaults = UserDefaults(suiteName: suite)! defaults.removePersistentDomain(forName: suite) diff --git a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift index e548571cf5..21593c2ce5 100644 --- a/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift +++ b/Tests/CodexBarTests/CodexPresentationCharacterizationTests.swift @@ -46,6 +46,51 @@ struct CodexPresentationCharacterizationTests { #expect(lines.contains(where: { $0.hasPrefix("Weekly:") })) } + @Test + func `monthly Codex primary submenu omits session pace text`() { + let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-monthly-primary") + settings.statusChecksEnabled = false + + let fetcher = UsageFetcher() + let store = UsageStore( + fetcher: fetcher, + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + startupBehavior: .testing) + let now = Date() + store._setSnapshotForTesting( + UsageSnapshot( + primary: RateWindow( + usedPercent: 90, + windowMinutes: 43200, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 5, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 86400), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "plus")), + provider: .codex) + + let descriptor = MenuDescriptor.build( + provider: .codex, + store: store, + settings: settings, + account: fetcher.loadAccountInfo(), + updateReady: false, + includeContextualActions: false) + + let lines = self.textLines(from: descriptor) + #expect(lines.contains(where: { $0.hasPrefix("Monthly:") })) + #expect(!lines.contains(where: { $0.hasPrefix("Pace:") })) + } + @Test func `Codex menu does not surface identity from another provider snapshot`() { let settings = self.makeSettingsStore(suite: "CodexPresentationCharacterizationTests-provider-silo") diff --git a/Tests/CodexBarTests/CodexResetBackfillSemanticsTests.swift b/Tests/CodexBarTests/CodexResetBackfillSemanticsTests.swift index 892ba10da1..8df3f1743f 100644 --- a/Tests/CodexBarTests/CodexResetBackfillSemanticsTests.swift +++ b/Tests/CodexBarTests/CodexResetBackfillSemanticsTests.swift @@ -37,6 +37,135 @@ struct CodexResetBackfillSemanticsTests { #expect(merged.secondary?.windowMinutes == 10080) #expect(merged.secondary?.resetsAt == weeklyReset) } + + @Test + func `reset backfill preserves a monthly primary window`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let monthlyReset = now.addingTimeInterval(11 * 24 * 60 * 60) + let weeklyReset = now.addingTimeInterval(3 * 24 * 60 * 60) + let fresh = UsageSnapshot( + primary: RateWindow( + usedPercent: 41, + windowMinutes: 43200, + resetsAt: monthlyReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil), + updatedAt: now) + let cached = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-60)) + + let backfilled = UsageStore.codexBackfillingResetWindows(fresh, from: cached) + + #expect(backfilled.primary?.windowMinutes == 43200) + #expect(backfilled.primary?.usedPercent == 41) + #expect(backfilled.primary?.resetsAt == monthlyReset) + #expect(backfilled.secondary?.windowMinutes == 10080) + #expect(backfilled.secondary?.resetsAt == weeklyReset) + } + + @Test + func `reset backfill does not overwrite a monthly primary with a stale cached session window`() { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let monthlyReset = now.addingTimeInterval(11 * 24 * 60 * 60) + let fresh = UsageSnapshot( + primary: RateWindow( + usedPercent: 41, + windowMinutes: 43200, + resetsAt: monthlyReset, + resetDescription: nil), + secondary: nil, + updatedAt: now) + let cached = UsageSnapshot( + primary: RateWindow( + usedPercent: 88, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 60 * 60), + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60)) + + let backfilled = UsageStore.codexBackfillingResetWindows(fresh, from: cached) + + #expect(backfilled.primary?.windowMinutes == 43200) + #expect(backfilled.primary?.usedPercent == 41) + #expect(backfilled.primary?.resetsAt == monthlyReset) + } + + @Test + func `merged reset cache keeps a monthly reset for a fresh reset-less monthly primary`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let monthlyReset = now.addingTimeInterval(11 * 24 * 60 * 60) + let cached = UsageSnapshot( + primary: RateWindow( + usedPercent: 41, + windowMinutes: 43200, + resetsAt: monthlyReset, + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-60)) + let fresh = UsageSnapshot( + primary: RateWindow( + usedPercent: 41, + windowMinutes: 43200, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: now) + + let merged = try #require(UsageStore.codexMergedResetBackfillSnapshot([cached, fresh], now: now)) + let backfilled = UsageStore.codexBackfillingResetWindows(fresh, from: merged) + + #expect(merged.primary?.windowMinutes == 43200) + #expect(merged.primary?.resetsAt == monthlyReset) + #expect(backfilled.primary?.windowMinutes == 43200) + #expect(backfilled.primary?.resetsAt == monthlyReset) + } + + @Test + func `merged reset cache prefers a newer monthly primary over a stale session candidate`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let monthlyReset = now.addingTimeInterval(11 * 24 * 60 * 60) + let weeklyReset = now.addingTimeInterval(3 * 24 * 60 * 60) + let staleSession = UsageSnapshot( + primary: RateWindow( + usedPercent: 17, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(4 * 60 * 60), + resetDescription: nil), + secondary: nil, + updatedAt: now.addingTimeInterval(-100)) + let monthlyShape = UsageSnapshot( + primary: RateWindow( + usedPercent: 41, + windowMinutes: 43200, + resetsAt: monthlyReset, + resetDescription: nil), + secondary: RateWindow( + usedPercent: 12, + windowMinutes: 10080, + resetsAt: weeklyReset, + resetDescription: nil), + updatedAt: now.addingTimeInterval(-50)) + + let merged = try #require(UsageStore.codexMergedResetBackfillSnapshot( + [staleSession, monthlyShape], + now: now)) + + #expect(merged.primary?.windowMinutes == 43200) + #expect(merged.primary?.resetsAt == monthlyReset) + #expect(merged.secondary?.windowMinutes == 10080) + #expect(merged.secondary?.resetsAt == weeklyReset) + } } extension CodexAccountScopedRefreshTests { diff --git a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift index 19d5070175..0423a79e16 100644 --- a/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift +++ b/Tests/CodexBarTests/MenuCardModelCodexProjectionTests.swift @@ -757,6 +757,72 @@ struct MenuCardModelCodexProjectionTests { #expect(model.creditsText == nil) } + + @Test + func `codex card titles follow window duration instead of slot position`() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 55, + windowMinutes: 43200, + resetsAt: now.addingTimeInterval(24 * 86400), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 5, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(6 * 86400), + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "user@example.com", + accountOrganization: nil, + loginMethod: "Pro")) + let projection = CodexConsumerProjection.make( + surface: .liveCard, + context: CodexConsumerProjection.Context( + snapshot: snapshot, + rawUsageError: nil, + liveCredits: nil, + rawCreditsError: nil, + liveDashboard: nil, + rawDashboardError: nil, + dashboardAttachmentAuthorized: false, + dashboardRequiresLogin: false, + now: now)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + codexProjection: projection, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "user@example.com", plan: "Pro"), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + codexSparkUsageVisible: false, + hidePersonalInfo: false, + now: now)) + + #expect(model.metrics.map(\.title) == ["Monthly", "Weekly"]) + #expect(model.metrics.map(\.id) == ["monthly", "secondary"]) + let monthly = try #require(model.metrics.first { $0.id == "monthly" }) + #expect(monthly.detailLeftText == nil) + #expect(monthly.detailRightText == nil) + #expect(monthly.warningMarkerPercents.isEmpty) + #expect(monthly.resetText != nil) + } } struct MenuCardModelCodexSparkVisibilityTests { diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index a703689995..f204ec7ee9 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -278,6 +278,25 @@ struct UsagePaceTextTests { #expect(detail?.rightLabel == "Lasts until reset · 1.5× headroom") } + @Test + func `Codex session pace suppressed for weekly and monthly durations but kept for fallback shapes`() { + let now = Date(timeIntervalSince1970: 0) + func window(minutes: Int?) -> RateWindow { + RateWindow( + usedPercent: 50, + windowMinutes: minutes, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + } + + #expect(UsagePaceText.sessionPace(provider: .codex, window: window(minutes: 10080), now: now) == nil) + #expect(UsagePaceText.sessionPace(provider: .codex, window: window(minutes: 43200), now: now) == nil) + // Unknown durations fall back to the session lane and must keep their pre-existing pace. + #expect(UsagePaceText.sessionPace(provider: .codex, window: window(minutes: 540), now: now) != nil) + #expect(UsagePaceText.sessionPace(provider: .codex, window: window(minutes: nil), now: now) != nil) + #expect(UsagePaceText.sessionPace(provider: .codex, window: window(minutes: 300), now: now) != nil) + } + @Test func `session pace summary formats single line text`() { let now = Date(timeIntervalSince1970: 0) diff --git a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift index 87fd30f0c7..f52c72d9e4 100644 --- a/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift +++ b/Tests/CodexBarTests/UsageStorePlanUtilizationTests.swift @@ -168,6 +168,59 @@ struct UsageStorePlanUtilizationTests { #expect(model.selectedSeries == "session:300") } + @MainActor + @Test + func `native chart shows monthly codex tab for a thirty day primary`() { + let histories = [ + planSeries(name: .monthly, windowMinutes: 43200, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 55), + ]), + planSeries(name: .weekly, windowMinutes: 10080, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 5), + ]), + ] + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 5, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: histories, + provider: .codex, + snapshot: snapshot) + + #expect(model.visibleSeries == ["weekly:10080", "monthly:43200"]) + #expect(model.selectedSeries == "weekly:10080") + } + + @MainActor + @Test + func `native chart folds legacy thirty day session and weekly history into monthly`() { + let histories = [ + planSeries(name: .session, windowMinutes: 43200, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_000_000), usedPercent: 80), + ]), + planSeries(name: .weekly, windowMinutes: 43200, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_086_400), usedPercent: 70), + ]), + planSeries(name: .monthly, windowMinutes: 43200, entries: [ + planEntry(at: Date(timeIntervalSince1970: 1_700_172_800), usedPercent: 55), + ]), + ] + let snapshot = UsageSnapshot( + primary: RateWindow(usedPercent: 55, windowMinutes: 43200, resetsAt: nil, resetDescription: nil), + secondary: RateWindow(usedPercent: 5, windowMinutes: 10080, resetsAt: nil, resetDescription: nil), + updatedAt: Date(timeIntervalSince1970: 1_700_000_000)) + + let model = PlanUtilizationHistoryChartMenuView._modelSnapshotForTesting( + histories: histories, + provider: .codex, + snapshot: snapshot) + + #expect(model.visibleSeries == ["monthly:43200"]) + #expect(model.selectedSeries == "monthly:43200") + } + @MainActor @Test func `claude history tabs match current snapshot bars`() { @@ -525,6 +578,41 @@ struct UsageStorePlanUtilizationTests { #expect(text == "No weekly utilization data yet.") } + @MainActor + @Test + func `plan history records thirty day codex window as monthly series`() async { + let store = Self.makeStore() + let now = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 55, + windowMinutes: 43200, + resetsAt: now.addingTimeInterval(24 * 86400), + resetDescription: nil), + secondary: RateWindow( + usedPercent: 5, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(7 * 86400), + resetDescription: nil), + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: "plus")) + store._setSnapshotForTesting(snapshot, provider: .codex) + + await store.recordPlanUtilizationHistorySample( + provider: .codex, + snapshot: snapshot, + now: now) + + let histories = store.planUtilizationHistory(for: .codex) + #expect(histories.contains { $0.name == .monthly && $0.windowMinutes == 43200 }) + #expect(histories.contains { $0.name == .weekly && $0.windowMinutes == 10080 }) + #expect(!histories.contains { $0.name == .session }) + } + @MainActor @Test func `plan history selects current account bucket`() throws {