From b9471ce98c0807b91d113cdb0b41cbf4659eea21 Mon Sep 17 00:00:00 2001 From: John Larkin Date: Wed, 11 Feb 2026 17:40:18 -0500 Subject: [PATCH] [feat] hourly pace section --- Sources/CodexBar/MenuCardView.swift | 34 +++++++- Sources/CodexBar/MenuDescriptor.swift | 3 + Sources/CodexBar/UsagePaceText.swift | 34 +++++++- Tests/CodexBarTests/UsagePaceTests.swift | 22 ++++++ Tests/CodexBarTests/UsagePaceTextTests.swift | 82 ++++++++++++++++++++ 5 files changed, 170 insertions(+), 5 deletions(-) diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 89a930dc1..8a177e8c8 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -754,6 +754,11 @@ extension UsageMenuCardView.Model { if input.provider == .warp, primary.resetsAt == nil { primaryResetText = nil } + let sessionPaceDetail = Self.sessionPaceDetail( + provider: input.provider, + window: primary, + now: input.now, + showUsed: input.usageBarsShowUsed) metrics.append(Metric( id: "primary", title: input.metadata.sessionLabel, @@ -762,10 +767,10 @@ extension UsageMenuCardView.Model { percentStyle: percentStyle, resetText: primaryResetText, detailText: primaryDetailText, - detailLeftText: nil, - detailRightText: nil, - pacePercent: nil, - paceOnTop: true)) + detailLeftText: sessionPaceDetail?.leftLabel, + detailRightText: sessionPaceDetail?.rightLabel, + pacePercent: sessionPaceDetail?.pacePercent, + paceOnTop: sessionPaceDetail?.paceOnTop ?? true)) } if let weekly = snapshot.secondary { let paceDetail = Self.weeklyPaceDetail( @@ -848,6 +853,27 @@ extension UsageMenuCardView.Model { let paceOnTop: Bool } + private static func sessionPaceDetail( + provider: UsageProvider, + window: RateWindow, + now: Date, + showUsed: Bool) -> PaceDetail? + { + guard let detail = UsagePaceText.sessionDetail(provider: provider, window: window, now: now) else { return nil } + let expectedUsed = detail.expectedUsedPercent + let actualUsed = window.usedPercent + let expectedPercent = showUsed ? expectedUsed : (100 - expectedUsed) + 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 } + return PaceDetail( + leftLabel: detail.leftLabel, + rightLabel: detail.rightLabel, + pacePercent: pacePercent, + paceOnTop: paceOnTop) + } + private static func weeklyPaceDetail( provider: UsageProvider, window: RateWindow, diff --git a/Sources/CodexBar/MenuDescriptor.swift b/Sources/CodexBar/MenuDescriptor.swift index 495cbd7ef..a62984d4b 100644 --- a/Sources/CodexBar/MenuDescriptor.swift +++ b/Sources/CodexBar/MenuDescriptor.swift @@ -138,6 +138,9 @@ struct MenuDescriptor { { entries.append(.text(detail, .secondary)) } + if let paceSummary = UsagePaceText.sessionSummary(provider: provider, window: primary) { + entries.append(.text(paceSummary, .secondary)) + } } if let weekly = snap.secondary { let weeklyResetOverride: String? = { diff --git a/Sources/CodexBar/UsagePaceText.swift b/Sources/CodexBar/UsagePaceText.swift index 920e38ef9..718f6663e 100644 --- a/Sources/CodexBar/UsagePaceText.swift +++ b/Sources/CodexBar/UsagePaceText.swift @@ -56,10 +56,42 @@ enum UsagePaceText { return countdown } + static func sessionPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { + Self.pace(provider: provider, window: window, now: now, defaultWindowMinutes: 300) + } + + static func sessionDetail(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> WeeklyDetail? { + guard let pace = sessionPace(provider: provider, window: window, now: now) else { return nil } + return WeeklyDetail( + leftLabel: Self.detailLeftLabel(for: pace), + rightLabel: Self.detailRightLabel(for: pace, now: now), + expectedUsedPercent: pace.expectedUsedPercent, + stage: pace.stage) + } + + static func sessionSummary(provider: UsageProvider, window: RateWindow, now: Date = .init()) -> String? { + guard let detail = sessionDetail(provider: provider, window: window, now: now) else { return nil } + if let rightLabel = detail.rightLabel { + return "Pace: \(detail.leftLabel) ยท \(rightLabel)" + } + return "Pace: \(detail.leftLabel)" + } + static func weeklyPace(provider: UsageProvider, window: RateWindow, now: Date) -> UsagePace? { + Self.pace(provider: provider, window: window, now: now, defaultWindowMinutes: 10080) + } + + private static func pace( + provider: UsageProvider, + window: RateWindow, + now: Date, + defaultWindowMinutes: Int) -> UsagePace? + { guard provider == .codex || provider == .claude else { return nil } guard window.remainingPercent > 0 else { return nil } - guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 10080) else { return nil } + guard let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: defaultWindowMinutes) else { + return nil + } guard pace.expectedUsedPercent >= Self.minimumExpectedPercent else { return nil } return pace } diff --git a/Tests/CodexBarTests/UsagePaceTests.swift b/Tests/CodexBarTests/UsagePaceTests.swift index 0fdde0133..1f7394a86 100644 --- a/Tests/CodexBarTests/UsagePaceTests.swift +++ b/Tests/CodexBarTests/UsagePaceTests.swift @@ -61,6 +61,28 @@ struct UsagePaceTests { #expect(UsagePace.weekly(window: tooFar, now: now) == nil) } + @Test + func sessionPace_computesDeltaAndEtaFor5HourWindow() { + let now = Date(timeIntervalSince1970: 0) + // 300-minute (5-hour) window, 2 hours remaining => 3 hours elapsed + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let pace = UsagePace.weekly(window: window, now: now, defaultWindowMinutes: 300) + + #expect(pace != nil) + guard let pace else { return } + // elapsed = 3h of 5h => expected = 60% + #expect(abs(pace.expectedUsedPercent - 60.0) < 0.01) + // delta = 50 - 60 = -10 => behind (in reserve) + #expect(abs(pace.deltaPercent - (-10.0)) < 0.01) + #expect(pace.stage == .behind) + #expect(pace.willLastToReset == true) + } + @Test func weeklyPace_hidesWhenUsageExistsButNoElapsed() { let now = Date(timeIntervalSince1970: 0) diff --git a/Tests/CodexBarTests/UsagePaceTextTests.swift b/Tests/CodexBarTests/UsagePaceTextTests.swift index 86c49a8ff..f1849dbdd 100644 --- a/Tests/CodexBarTests/UsagePaceTextTests.swift +++ b/Tests/CodexBarTests/UsagePaceTextTests.swift @@ -122,4 +122,86 @@ struct UsagePaceTextTests { #expect(detail == nil) } + + // MARK: - Session pace (5-hour window) + + @Test + func sessionPaceDetail_providesLeftRightLabels() { + let now = Date(timeIntervalSince1970: 0) + // 300-minute window, 2h remaining => 3h elapsed out of 5h + // expected = 60%, actual = 80% => 20% ahead (in deficit) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now) + + #expect(detail != nil) + #expect(detail?.leftLabel == "20% in deficit") + #expect(detail?.rightLabel != nil) + #expect(detail?.stage == .farAhead) + } + + @Test + func sessionPaceDetail_reportsLastsUntilReset() { + let now = Date(timeIntervalSince1970: 0) + // 300-minute window, 2h remaining => 3h elapsed + // expected = 60%, actual = 10% => far behind (in reserve) + let window = RateWindow( + usedPercent: 10, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now) + + #expect(detail != nil) + #expect(detail?.leftLabel == "50% in reserve") + #expect(detail?.rightLabel == "Lasts until reset") + } + + @Test + func sessionPaceSummary_formatsSingleLineText() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 80, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let summary = UsagePaceText.sessionSummary(provider: .claude, window: window, now: now) + + #expect(summary != nil) + #expect(summary?.hasPrefix("Pace:") == true) + } + + @Test + func sessionPaceDetail_hidesForUnsupportedProvider() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: now.addingTimeInterval(2 * 3600), + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .zai, window: window, now: now) + + #expect(detail == nil) + } + + @Test + func sessionPaceDetail_hidesWhenResetIsMissing() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 300, + resetsAt: nil, + resetDescription: nil) + + let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now) + + #expect(detail == nil) + } }