diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 748b5517e..90e9dc5a7 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -4,6 +4,13 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BIN_DIR="${ROOT_DIR}/.build/lint-tools/bin" +SWIFTLINT_CACHE_DIR="${ROOT_DIR}/.build/swiftlint-cache" + +mkdir -p "${SWIFTLINT_CACHE_DIR}" + +export XCODE_DEFAULT_TOOLCHAIN_OVERRIDE="${XCODE_DEFAULT_TOOLCHAIN_OVERRIDE:-/Library/Developer/CommandLineTools}" +export TOOLCHAIN_DIR="${TOOLCHAIN_DIR:-/Library/Developer/CommandLineTools}" +export SWIFTLINT_CACHE_PATH="${SWIFTLINT_CACHE_PATH:-${SWIFTLINT_CACHE_DIR}}" ensure_tools() { # Always delegate to the installer so pinned versions are enforced. diff --git a/Scripts/package_app.sh b/Scripts/package_app.sh index 08ee481a0..12861b6cf 100755 --- a/Scripts/package_app.sh +++ b/Scripts/package_app.sh @@ -6,6 +6,11 @@ SIGNING_MODE=${CODEXBAR_SIGNING:-} ROOT=$(cd "$(dirname "$0")/.." && pwd) cd "$ROOT" +MODULE_CACHE_DIR="${ROOT}/.build/module-cache" +export CLANG_MODULE_CACHE_PATH="${CLANG_MODULE_CACHE_PATH:-${MODULE_CACHE_DIR}/clang}" +export SWIFTPM_MODULECACHE_OVERRIDE="${SWIFTPM_MODULECACHE_OVERRIDE:-${MODULE_CACHE_DIR}/swiftpm}" +mkdir -p "${CLANG_MODULE_CACHE_PATH}" "${SWIFTPM_MODULECACHE_OVERRIDE}" + # Load version info source "$ROOT/version.env" @@ -36,15 +41,15 @@ fi patch_keyboard_shortcuts() { local util_path="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift" + local recorder_path="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Recorder.swift" if [[ ! -f "$util_path" ]]; then return 0 fi if grep -q "keyboardShortcutsSafeBundle" "$util_path"; then - return 0 - fi - - chmod +w "$util_path" || true - python3 - "$util_path" <<'PY' + : + else + chmod +w "$util_path" || true + python3 - "$util_path" <<'PY' import sys from pathlib import Path @@ -96,6 +101,24 @@ if marker not in text: text = text.replace(marker, "}\n\n" + inject + "\n\nextension Data {") path.write_text(text) PY + fi + + if [[ -f "$recorder_path" ]]; then + chmod +w "$recorder_path" || true + python3 - "$recorder_path" <<'PY' +import re +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +text = path.read_text() +if "xcodePreview" not in text: + sys.exit(0) + +updated = re.sub(r'\n#Preview\s*\{.*?\n\}\n', '\n', text, flags=re.S) +path.write_text(updated) +PY + fi } KEYBOARD_SHORTCUTS_UTIL="$ROOT/.build/checkouts/KeyboardShortcuts/Sources/KeyboardShortcuts/Utilities.swift" diff --git a/Sources/CodexBar/MenuCardView.swift b/Sources/CodexBar/MenuCardView.swift index 789a517e1..05d1a21ff 100644 --- a/Sources/CodexBar/MenuCardView.swift +++ b/Sources/CodexBar/MenuCardView.swift @@ -35,6 +35,9 @@ struct UsageMenuCardView: View { let detailRightText: String? let pacePercent: Double? let paceOnTop: Bool + let forecastPercent: Double? + let forecastOverflow: Bool + let forecastText: String? var percentLabel: String { String(format: "%.0f%% %@", self.percent, self.percentStyle.labelSuffix) @@ -334,8 +337,7 @@ private struct MetricRow: View { percent: self.metric.percent, tint: self.progressColor, accessibilityLabel: self.metric.percentStyle.accessibilityLabel, - pacePercent: self.metric.pacePercent, - paceOnTop: self.metric.paceOnTop) + overlay: self.progressOverlay) VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline) { Text(self.metric.percentLabel) @@ -374,9 +376,22 @@ private struct MetricRow: View { .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) .lineLimit(1) } + if let forecastText = self.metric.forecastText { + Text(forecastText) + .font(.footnote) + .foregroundStyle(MenuHighlightStyle.secondary(self.isHighlighted)) + .lineLimit(1) + } } .frame(maxWidth: .infinity, alignment: .leading) } + + private var progressOverlay: UsageProgressBar.Overlay? { + if let forecastPercent = self.metric.forecastPercent { + return .forecast(percent: forecastPercent, isOverflow: self.metric.forecastOverflow) + } + return .pace(percent: self.metric.pacePercent, onTop: self.metric.paceOnTop) + } } private struct UsageNotesContent: View { @@ -905,6 +920,7 @@ extension UsageMenuCardView.Model { let zaiTimeDetail = Self.zaiLimitDetailText(limit: zaiUsage?.timeLimit) let openRouterQuotaDetail = Self.openRouterQuotaDetail(provider: input.provider, snapshot: snapshot) if let primary = snapshot.primary { + let forecastDetail = Self.forecastDetail(window: primary, now: input.now, showUsed: input.usageBarsShowUsed) var primaryDetailText: String? = input.provider == .zai ? zaiTokenDetail : nil var primaryResetText = Self.resetText(for: primary, style: input.resetTimeDisplayStyle, now: input.now) if input.provider == .openrouter, @@ -932,7 +948,10 @@ extension UsageMenuCardView.Model { detailLeftText: nil, detailRightText: nil, pacePercent: nil, - paceOnTop: true)) + paceOnTop: true, + forecastPercent: forecastDetail?.displayPercent, + forecastOverflow: forecastDetail?.isOverflow ?? false, + forecastText: forecastDetail?.summary)) } if let weekly = snapshot.secondary { let paceDetail = Self.weeklyPaceDetail( @@ -940,6 +959,7 @@ extension UsageMenuCardView.Model { now: input.now, pace: input.weeklyPace, showUsed: input.usageBarsShowUsed) + let forecastDetail = Self.forecastDetail(window: weekly, now: input.now, showUsed: input.usageBarsShowUsed) var weeklyResetText = Self.resetText(for: weekly, style: input.resetTimeDisplayStyle, now: input.now) var weeklyDetailText: String? = input.provider == .zai ? zaiTimeDetail : nil if input.provider == .warp, @@ -968,7 +988,10 @@ extension UsageMenuCardView.Model { detailLeftText: paceDetail?.leftLabel, detailRightText: paceDetail?.rightLabel, pacePercent: paceDetail?.pacePercent, - paceOnTop: paceDetail?.paceOnTop ?? true)) + paceOnTop: paceDetail?.paceOnTop ?? true, + forecastPercent: forecastDetail?.displayPercent, + forecastOverflow: forecastDetail?.isOverflow ?? false, + forecastText: forecastDetail?.summary)) } if input.provider == .kilo, metrics.contains(where: { $0.id == "primary" }), @@ -983,6 +1006,7 @@ extension UsageMenuCardView.Model { } } if input.metadata.supportsOpus, let opus = snapshot.tertiary { + let forecastDetail = Self.forecastDetail(window: opus, now: input.now, showUsed: input.usageBarsShowUsed) metrics.append(Metric( id: "tertiary", title: input.metadata.opusLabel ?? "Sonnet", @@ -993,7 +1017,10 @@ extension UsageMenuCardView.Model { detailLeftText: nil, detailRightText: nil, pacePercent: nil, - paceOnTop: true)) + paceOnTop: true, + forecastPercent: forecastDetail?.displayPercent, + forecastOverflow: forecastDetail?.isOverflow ?? false, + forecastText: forecastDetail?.summary)) } if input.provider == .codex, let remaining = input.dashboard?.codeReviewRemainingPercent { @@ -1008,7 +1035,10 @@ extension UsageMenuCardView.Model { detailLeftText: nil, detailRightText: nil, pacePercent: nil, - paceOnTop: true)) + paceOnTop: true, + forecastPercent: nil, + forecastOverflow: false, + forecastText: nil)) } return metrics } @@ -1051,6 +1081,12 @@ extension UsageMenuCardView.Model { let paceOnTop: Bool } + private struct ForecastDetail { + let displayPercent: Double + let isOverflow: Bool + let summary: String + } + private static func weeklyPaceDetail( window: RateWindow, now: Date, @@ -1073,6 +1109,20 @@ extension UsageMenuCardView.Model { paceOnTop: paceOnTop) } + private static func forecastDetail( + window: RateWindow, + now: Date, + showUsed: Bool) -> ForecastDetail? + { + guard let projection = UsageProjection.linear(window: window, now: now) else { return nil } + let displayPercent = UsageProjectionText.displayPercent(projection: projection, showUsed: showUsed) + guard displayPercent.isFinite else { return nil } + return ForecastDetail( + displayPercent: displayPercent, + isOverflow: projection.isProjectedToOverflow, + summary: UsageProjectionText.summary(projection: projection, now: now)) + } + private static func creditsLine( metadata: ProviderMetadata, credits: CreditsSnapshot?, diff --git a/Sources/CodexBar/MenuHighlightStyle.swift b/Sources/CodexBar/MenuHighlightStyle.swift index be76fe04a..ec06dbc23 100644 --- a/Sources/CodexBar/MenuHighlightStyle.swift +++ b/Sources/CodexBar/MenuHighlightStyle.swift @@ -1,7 +1,15 @@ import SwiftUI +private struct MenuItemHighlightedKey: EnvironmentKey { + static let defaultValue = false +} + +// swiftformat:disable:next environmentEntry extension EnvironmentValues { - @Entry var menuItemHighlighted: Bool = false + var menuItemHighlighted: Bool { + get { self[MenuItemHighlightedKey.self] } + set { self[MenuItemHighlightedKey.self] = newValue } + } } enum MenuHighlightStyle { diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index 58a55deb5..79615fddb 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -383,8 +383,7 @@ private struct ProviderMetricInlineRow: View { percent: self.metric.percent, tint: self.progressColor, accessibilityLabel: self.metric.percentStyle.accessibilityLabel, - pacePercent: self.metric.pacePercent, - paceOnTop: self.metric.paceOnTop) + overlay: self.progressOverlay) .frame(minWidth: ProviderSettingsMetrics.metricBarWidth, maxWidth: .infinity) HStack(alignment: .firstTextBaseline, spacing: 8) { @@ -423,12 +422,24 @@ private struct ProviderMetricInlineRow: View { .font(.footnote) .foregroundStyle(.tertiary) } + if let forecastText = self.metric.forecastText, !forecastText.isEmpty { + Text(forecastText) + .font(.footnote) + .foregroundStyle(.tertiary) + } } .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 2) } + private var progressOverlay: UsageProgressBar.Overlay? { + if let forecastPercent = self.metric.forecastPercent { + return .forecast(percent: forecastPercent, isOverflow: self.metric.forecastOverflow) + } + return .pace(percent: self.metric.pacePercent, onTop: self.metric.paceOnTop) + } + private var detailText: String? { guard let detailText = self.metric.detailText, !detailText.isEmpty else { return nil } return detailText diff --git a/Sources/CodexBar/UsageProgressBar.swift b/Sources/CodexBar/UsageProgressBar.swift index 28b467b86..dd3ec6146 100644 --- a/Sources/CodexBar/UsageProgressBar.swift +++ b/Sources/CodexBar/UsageProgressBar.swift @@ -2,21 +2,40 @@ import SwiftUI /// Static progress fill with no implicit animations, used inside the menu card. struct UsageProgressBar: View { - private static let paceStripeCount = 3 - private static func paceStripeWidth(for scale: CGFloat) -> CGFloat { + struct Overlay: Equatable { + enum Style: Equatable { + case pace(isDeficit: Bool) + case forecast(isOverflow: Bool) + } + + let percent: Double + let style: Style + + static func pace(percent: Double?, onTop: Bool) -> Overlay? { + guard let percent else { return nil } + return Overlay(percent: percent, style: .pace(isDeficit: onTop == false)) + } + + static func forecast(percent: Double?, isOverflow: Bool) -> Overlay? { + guard let percent else { return nil } + return Overlay(percent: percent, style: .forecast(isOverflow: isOverflow)) + } + } + + private static let overlayStripeCount = 3 + private static func overlayStripeWidth(for scale: CGFloat) -> CGFloat { 2 } - private static func paceStripeSpan(for scale: CGFloat) -> CGFloat { - let stripeCount = max(1, Self.paceStripeCount) - return Self.paceStripeWidth(for: scale) * CGFloat(stripeCount) + private static func overlayStripeSpan(for scale: CGFloat) -> CGFloat { + let stripeCount = max(1, Self.overlayStripeCount) + return Self.overlayStripeWidth(for: scale) * CGFloat(stripeCount) } let percent: Double let tint: Color let accessibilityLabel: String - let pacePercent: Double? - let paceOnTop: Bool + let overlay: Overlay? @Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.displayScale) private var displayScale @@ -24,14 +43,12 @@ struct UsageProgressBar: View { percent: Double, tint: Color, accessibilityLabel: String, - pacePercent: Double? = nil, - paceOnTop: Bool = true) + overlay: Overlay? = nil) { self.percent = percent self.tint = tint self.accessibilityLabel = accessibilityLabel - self.pacePercent = pacePercent - self.paceOnTop = paceOnTop + self.overlay = overlay } private var clamped: Double { @@ -42,18 +59,18 @@ struct UsageProgressBar: View { GeometryReader { proxy in let scale = max(self.displayScale, 1) let fillWidth = proxy.size.width * self.clamped / 100 - let paceWidth = proxy.size.width * Self.clampedPercent(self.pacePercent) / 100 + let overlayWidth = proxy.size.width * Self.clampedPercent(self.overlay?.percent) / 100 let tipWidth = max(25, proxy.size.height * 6.5) let stripeInset = 1 / scale - let tipOffset = paceWidth - tipWidth + (Self.paceStripeSpan(for: scale) / 2) + stripeInset - let showTip = self.pacePercent != nil && tipWidth > 0.5 + let tipOffset = overlayWidth - tipWidth + (Self.overlayStripeSpan(for: scale) / 2) + stripeInset + let showTip = self.shouldShowOverlay && tipWidth > 0.5 let needsPunchCompositing = showTip let bar = ZStack(alignment: .leading) { Capsule() .fill(MenuHighlightStyle.progressTrack(self.isHighlighted)) self.actualBar(width: fillWidth) if showTip { - self.paceTip(width: tipWidth) + self.overlayTip(width: tipWidth) .offset(x: tipOffset) } } @@ -82,21 +99,23 @@ struct UsageProgressBar: View { .allowsHitTesting(false) } - private func paceTip(width: CGFloat) -> some View { - let isDeficit = self.paceOnTop == false - let useDeficitRed = isDeficit && self.isHighlighted == false - return GeometryReader { proxy in + private var shouldShowOverlay: Bool { + guard let overlay else { return false } + switch overlay.style { + case .pace: + return true + case .forecast: + return abs(Self.clampedPercent(overlay.percent) - self.clamped) >= 0.5 + } + } + + private func overlayTip(width: CGFloat) -> some View { + GeometryReader { proxy in let size = proxy.size let rect = CGRect(origin: .zero, size: size) let scale = max(self.displayScale, 1) - let stripes = Self.paceStripePaths(size: size, scale: scale) - let stripeColor: Color = if self.isHighlighted { - .white - } else if useDeficitRed { - .red - } else { - .green - } + let stripes = Self.overlayStripePaths(size: size, scale: scale) + let stripeColor = self.overlayStripeColor() ZStack { Canvas { context, _ in @@ -116,7 +135,21 @@ struct UsageProgressBar: View { .allowsHitTesting(false) } - private static func paceStripePaths(size: CGSize, scale: CGFloat) -> (punched: Path, center: Path) { + private func overlayStripeColor() -> Color { + guard let overlay else { return .white } + if self.isHighlighted { + return .white + } + + switch overlay.style { + case let .pace(isDeficit): + return isDeficit ? .red : .green + case let .forecast(isOverflow): + return isOverflow ? .red : self.tint + } + } + + private static func overlayStripePaths(size: CGSize, scale: CGFloat) -> (punched: Path, center: Path) { let rect = CGRect(origin: .zero, size: size) let extend = size.height * 2 let stripeTopY: CGFloat = -extend @@ -125,7 +158,7 @@ struct UsageProgressBar: View { (value * scale).rounded() / scale } - let stripeWidth = Self.paceStripeWidth(for: scale) + let stripeWidth = Self.overlayStripeWidth(for: scale) let punchWidth = stripeWidth * 3 let stripeInset = 1 / scale let stripeAnchorX = align(rect.maxX - stripeInset) diff --git a/Sources/CodexBar/UsageProjectionText.swift b/Sources/CodexBar/UsageProjectionText.swift new file mode 100644 index 000000000..bf55c7dee --- /dev/null +++ b/Sources/CodexBar/UsageProjectionText.swift @@ -0,0 +1,16 @@ +import CodexBarCore +import Foundation + +enum UsageProjectionText { + static func summary(projection: UsageProjection, now _: Date = .init()) -> String { + let percent = projection.projectedUsedPercentAtReset + return String(format: "Forecast %.0f%% at reset", percent) + } + + static func displayPercent(projection: UsageProjection, showUsed: Bool) -> Double { + if showUsed { + return projection.projectedUsedPercentAtReset + } + return projection.projectedRemainingPercentAtReset + } +} diff --git a/Sources/CodexBarCore/UsageProjection.swift b/Sources/CodexBarCore/UsageProjection.swift new file mode 100644 index 000000000..8b2faa1df --- /dev/null +++ b/Sources/CodexBarCore/UsageProjection.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Forecasts end-of-window usage by linearly extending the current observed burn rate. +public struct UsageProjection: Sendable, Equatable { + public let actualUsedPercent: Double + public let projectedUsedPercentAtReset: Double + public let projectedRemainingPercentAtReset: Double + public let isProjectedToOverflow: Bool + public let elapsedSeconds: TimeInterval + public let remainingSeconds: TimeInterval + + public init( + actualUsedPercent: Double, + projectedUsedPercentAtReset: Double, + projectedRemainingPercentAtReset: Double, + isProjectedToOverflow: Bool, + elapsedSeconds: TimeInterval, + remainingSeconds: TimeInterval) + { + self.actualUsedPercent = actualUsedPercent + self.projectedUsedPercentAtReset = projectedUsedPercentAtReset + self.projectedRemainingPercentAtReset = projectedRemainingPercentAtReset + self.isProjectedToOverflow = isProjectedToOverflow + self.elapsedSeconds = elapsedSeconds + self.remainingSeconds = remainingSeconds + } + + public static func linear( + window: RateWindow, + now: Date = .init(), + minimumElapsedSeconds: TimeInterval = 5 * 60) -> UsageProjection? + { + guard let resetsAt = window.resetsAt else { return nil } + guard let minutes = window.windowMinutes else { return nil } + guard minutes > 0 else { return nil } + + let duration = TimeInterval(minutes) * 60 + let remaining = resetsAt.timeIntervalSince(now) + guard remaining > 0 else { return nil } + guard remaining <= duration else { return nil } + + let elapsed = duration - remaining + guard elapsed >= minimumElapsedSeconds else { return nil } + + let actual = max(0, window.usedPercent) + let projectedUsed = actual == 0 ? 0 : actual + ((actual / elapsed) * remaining) + let projectedRemaining = 100 - projectedUsed + + return UsageProjection( + actualUsedPercent: actual, + projectedUsedPercentAtReset: projectedUsed, + projectedRemainingPercentAtReset: projectedRemaining, + isProjectedToOverflow: projectedUsed > 100, + elapsedSeconds: elapsed, + remainingSeconds: remaining) + } +} diff --git a/Tests/CodexBarTests/MenuCardModelTests.swift b/Tests/CodexBarTests/MenuCardModelTests.swift index 48e2a7803..261482030 100644 --- a/Tests/CodexBarTests/MenuCardModelTests.swift +++ b/Tests/CodexBarTests/MenuCardModelTests.swift @@ -4,6 +4,7 @@ import SwiftUI import Testing @testable import CodexBar +// swiftlint:disable type_body_length @Suite struct MenuCardModelTests { @Test @@ -839,4 +840,186 @@ struct MenuCardModelTests { #expect(primary.resetText == nil) #expect(primary.detailText == "10/100 credits") } + + @Test + func showsForecastOnTimedPrimaryRow() throws { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 60, + resetsAt: now.addingTimeInterval(30 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.forecastPercent == 50) + #expect(primary.forecastOverflow == false) + #expect(primary.forecastText == "Forecast 50% at reset") + } + + @Test + func showsOverflowForecastOnWeeklyRow() throws { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: nil, + secondary: RateWindow( + usedPercent: 80, + windowMinutes: 100, + resetsAt: now.addingTimeInterval(50 * 60), + resetDescription: nil), + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let weekly = try #require(model.metrics.first) + #expect(weekly.forecastPercent == 160) + #expect(weekly.forecastOverflow == true) + #expect(weekly.forecastText == "Forecast 160% at reset") + } + + @Test + func hidesForecastWhenTimingIsInsufficient() throws { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 15, + windowMinutes: 60, + resetsAt: now.addingTimeInterval(58 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: true, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.forecastPercent == nil) + #expect(primary.forecastText == nil) + #expect(primary.forecastOverflow == false) + } + + @Test + func showsForecastTextInUsedTermsWhileOverlayTracksDisplayedRemainingPercent() throws { + let now = Date(timeIntervalSince1970: 0) + let metadata = try #require(ProviderDefaults.metadata[.codex]) + let snapshot = UsageSnapshot( + primary: RateWindow( + usedPercent: 25, + windowMinutes: 60, + resetsAt: now.addingTimeInterval(30 * 60), + resetDescription: nil), + secondary: nil, + tertiary: nil, + updatedAt: now, + identity: ProviderIdentitySnapshot( + providerID: .codex, + accountEmail: "codex@example.com", + accountOrganization: nil, + loginMethod: nil)) + + let model = UsageMenuCardView.Model.make(.init( + provider: .codex, + metadata: metadata, + snapshot: snapshot, + credits: nil, + creditsError: nil, + dashboard: nil, + dashboardError: nil, + tokenSnapshot: nil, + tokenError: nil, + account: AccountInfo(email: "codex@example.com", plan: nil), + isRefreshing: false, + lastError: nil, + usageBarsShowUsed: false, + resetTimeDisplayStyle: .countdown, + tokenCostUsageEnabled: false, + showOptionalCreditsAndExtraUsage: true, + hidePersonalInfo: false, + now: now)) + + let primary = try #require(model.metrics.first) + #expect(primary.percent == 75) + #expect(primary.forecastPercent == 50) + #expect(primary.forecastText == "Forecast 50% at reset") + } } + +// swiftlint:enable type_body_length diff --git a/Tests/CodexBarTests/UsageProjectionTests.swift b/Tests/CodexBarTests/UsageProjectionTests.swift new file mode 100644 index 000000000..87c0fa990 --- /dev/null +++ b/Tests/CodexBarTests/UsageProjectionTests.swift @@ -0,0 +1,119 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite +struct UsageProjectionTests { + @Test + func linearProjection_computesProjectedOverrunFromCurrentRate() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 50, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let projection = try #require(UsageProjection.linear(window: window, now: now)) + + #expect(projection.actualUsedPercent == 50) + #expect(abs(projection.projectedUsedPercentAtReset - 116.667) < 0.01) + #expect(abs(projection.projectedRemainingPercentAtReset + 16.667) < 0.01) + #expect(projection.isProjectedToOverflow) + #expect(abs(projection.elapsedSeconds - (3 * 24 * 3600)) < 1) + #expect(abs(projection.remainingSeconds - (4 * 24 * 3600)) < 1) + } + + @Test + func linearProjection_returnsInRangeForecastWhenBurnRateFitsWindow() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 20, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let projection = try #require(UsageProjection.linear(window: window, now: now)) + + #expect(abs(projection.projectedUsedPercentAtReset - 46.667) < 0.01) + #expect(abs(projection.projectedRemainingPercentAtReset - 53.333) < 0.01) + #expect(projection.isProjectedToOverflow == false) + } + + @Test + func linearProjection_keepsZeroUsageAtZeroAfterMinimumElapsed() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 0, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(4 * 24 * 3600), + resetDescription: nil) + + let projection = try #require(UsageProjection.linear(window: window, now: now)) + + #expect(projection.actualUsedPercent == 0) + #expect(projection.projectedUsedPercentAtReset == 0) + #expect(projection.projectedRemainingPercentAtReset == 100) + #expect(projection.isProjectedToOverflow == false) + } + + @Test + func linearProjection_returnsExactHundredWhenRateEndsAtLimit() throws { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 25, + windowMinutes: 100, + resetsAt: now.addingTimeInterval(75 * 60), + resetDescription: nil) + + let projection = try #require( + UsageProjection.linear( + window: window, + now: now, + minimumElapsedSeconds: 60)) + + #expect(projection.projectedUsedPercentAtReset == 100) + #expect(projection.projectedRemainingPercentAtReset == 0) + #expect(projection.isProjectedToOverflow == false) + } + + @Test + func linearProjection_hidesWhenTimingInfoIsMissingOrOutsideWindow() { + let now = Date(timeIntervalSince1970: 0) + let missingReset = RateWindow( + usedPercent: 10, + windowMinutes: 10080, + resetsAt: nil, + resetDescription: nil) + let missingWindow = RateWindow( + usedPercent: 10, + windowMinutes: nil, + resetsAt: now.addingTimeInterval(2 * 24 * 3600), + resetDescription: nil) + let tooFar = RateWindow( + usedPercent: 10, + windowMinutes: 10080, + resetsAt: now.addingTimeInterval(9 * 24 * 3600), + resetDescription: nil) + + #expect(UsageProjection.linear(window: missingReset, now: now) == nil) + #expect(UsageProjection.linear(window: missingWindow, now: now) == nil) + #expect(UsageProjection.linear(window: tooFar, now: now) == nil) + } + + @Test + func linearProjection_hidesWhenElapsedTimeIsBelowStabilityThreshold() { + let now = Date(timeIntervalSince1970: 0) + let window = RateWindow( + usedPercent: 1, + windowMinutes: 60, + resetsAt: now.addingTimeInterval(56 * 60), + resetDescription: nil) + + let projection = UsageProjection.linear( + window: window, + now: now, + minimumElapsedSeconds: 5 * 60) + + #expect(projection == nil) + } +} diff --git a/docs/ui.md b/docs/ui.md index 3c9c7c3bf..718864d35 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -38,6 +38,20 @@ When usage is in deficit, the right-hand label shows an estimated "Runs out in Pace is calculated for Codex and Claude weekly windows only and is hidden when less than 3% of the window has elapsed. +## Forecast tracking + +Forecast answers a different question from pace: if the current burn rate continues until reset, where will the window end up? + +- **Forecast X% at reset** – projected end-of-window usage at the current observed rate. +- Forecast is shown only when the row has both a concrete reset time and a known window duration. +- Forecast values above `100%` stay above `100%` in text and are clamped only for bar drawing. +- If bars are configured to show remaining quota, the overlay follows the remaining-scale bar while the text still reports projected usage at reset. + +Forecast does not replace pace: + +- **pace** = how far ahead of or behind budget you are right now +- **forecast** = where the window is expected to land by reset if nothing changes + ## Preferences notes - Advanced: “Disable Keychain access” turns off browser cookie import; paste Cookie headers manually in Providers. - Display: “Overview tab providers” controls which providers appear in Merge Icons → Overview (up to 3).