-
Notifications
You must be signed in to change notification settings - Fork 774
Add reset warnings for unused credits approaching window reset #619
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -117,6 +117,7 @@ final class UsageStore { | |
| var pathDebugInfo: PathDebugSnapshot = .empty | ||
| var statuses: [UsageProvider: ProviderStatus] = [:] | ||
| var probeLogs: [UsageProvider: String] = [:] | ||
| var activeResetWarnings: [UsageProvider: [ResetWarning]] = [:] | ||
| var historicalPaceRevision: Int = 0 | ||
| @ObservationIgnored var lastCreditsSnapshot: CreditsSnapshot? | ||
| @ObservationIgnored var creditsFailureStreak: Int = 0 | ||
|
|
@@ -155,6 +156,7 @@ final class UsageStore { | |
| @ObservationIgnored var codexHistoricalDatasetAccountKey: String? | ||
| @ObservationIgnored var lastKnownSessionRemaining: [UsageProvider: Double] = [:] | ||
| @ObservationIgnored var lastKnownSessionWindowSource: [UsageProvider: SessionQuotaWindowSource] = [:] | ||
| @ObservationIgnored var lastResetWarningDate: [String: Date] = [:] | ||
| @ObservationIgnored var lastTokenFetchAt: [UsageProvider: Date] = [:] | ||
| @ObservationIgnored var planUtilizationHistory: [UsageProvider: PlanUtilizationHistoryBuckets] = [:] | ||
| @ObservationIgnored private var hasCompletedInitialRefresh: Bool = false | ||
|
|
@@ -430,6 +432,7 @@ final class UsageStore { | |
| await self.refreshCreditsIfNeeded(minimumSnapshotUpdatedAt: refreshStartedAt) | ||
| } | ||
|
|
||
| self.checkResetWarnings() | ||
| self.persistWidgetSnapshot(reason: "refresh") | ||
| } | ||
| } | ||
|
|
@@ -602,6 +605,74 @@ final class UsageStore { | |
| self.sessionQuotaNotifier.post(transition: transition, provider: provider, badge: nil) | ||
| } | ||
|
|
||
| // MARK: - Reset warnings | ||
|
|
||
| func checkResetWarnings() { | ||
| guard self.settings.resetWarningEnabled else { | ||
| self.activeResetWarnings = [:] | ||
| return | ||
| } | ||
|
|
||
| let warningHours = self.settings.resetWarningHours | ||
| let now = Date() | ||
| var newWarnings: [UsageProvider: [ResetWarning]] = [:] | ||
|
|
||
| for provider in self.enabledProviders() { | ||
| guard let snapshot = self.snapshots[provider] else { continue } | ||
| var providerWarnings: [ResetWarning] = [] | ||
|
|
||
| if let primary = snapshot.primary, | ||
| let warning = ResetWarningEvaluator.evaluate( | ||
| provider: provider, | ||
| window: primary, | ||
| windowKind: .session, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This hard-codes the primary window as Useful? React with 👍 / 👎. |
||
| warningHours: warningHours, | ||
| now: now) | ||
| { | ||
| providerWarnings.append(warning) | ||
| } | ||
|
|
||
| if let secondary = snapshot.secondary, | ||
| let warning = ResetWarningEvaluator.evaluate( | ||
| provider: provider, | ||
| window: secondary, | ||
| windowKind: .weekly, | ||
| warningHours: warningHours, | ||
| now: now) | ||
| { | ||
| providerWarnings.append(warning) | ||
| } | ||
|
|
||
| if !providerWarnings.isEmpty { | ||
| newWarnings[provider] = providerWarnings | ||
| for warning in providerWarnings { | ||
| let key = "reset-\(provider.rawValue)-\(warning.windowKind.rawValue)" | ||
| if ResetWarningEvaluator.shouldNotify( | ||
| warning: warning, | ||
| lastNotifiedAt: self.lastResetWarningDate[key], | ||
| now: now) | ||
| { | ||
| self.lastResetWarningDate[key] = now | ||
| self.postResetWarningNotification(warning: warning) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| self.activeResetWarnings = newWarnings | ||
| } | ||
|
|
||
| private func postResetWarningNotification(warning: ResetWarning) { | ||
| let providerName = ProviderDescriptorRegistry.descriptor(for: warning.providerID).metadata.displayName | ||
| let windowLabel = warning.windowKind == .session ? "session" : "weekly" | ||
| let hoursLeft = String(format: "%.0f", warning.hoursUntilReset) | ||
| let percentLeft = String(format: "%.0f", warning.remainingPercent) | ||
| let title = "\(providerName) \(windowLabel) resets in \(hoursLeft)h" | ||
| let body = "You still have \(percentLeft)% remaining. Consider starting long-running tasks." | ||
| let idPrefix = "reset-warning-\(warning.providerID.rawValue)-\(warning.windowKind.rawValue)" | ||
| self.sessionQuotaNotifier.postResetWarning(idPrefix: idPrefix, title: title, body: body) | ||
| } | ||
|
|
||
| private func refreshStatus(_ provider: UsageProvider) async { | ||
| guard self.settings.statusChecksEnabled else { return } | ||
| guard let meta = self.providerMetadata[provider] else { return } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| import Foundation | ||
|
|
||
| public struct ResetWarning: Equatable, Sendable { | ||
| public let providerID: UsageProvider | ||
| public let windowKind: WindowKind | ||
| public let remainingPercent: Double | ||
| public let resetsAt: Date | ||
| public let hoursUntilReset: Double | ||
|
|
||
| public enum WindowKind: String, Sendable { | ||
| case session | ||
| case weekly | ||
| } | ||
|
|
||
| public init( | ||
| providerID: UsageProvider, | ||
| windowKind: WindowKind, | ||
| remainingPercent: Double, | ||
| resetsAt: Date, | ||
| hoursUntilReset: Double) | ||
| { | ||
| self.providerID = providerID | ||
| self.windowKind = windowKind | ||
| self.remainingPercent = remainingPercent | ||
| self.resetsAt = resetsAt | ||
| self.hoursUntilReset = hoursUntilReset | ||
| } | ||
| } | ||
|
|
||
| public enum ResetWarningEvaluator { | ||
| public static let minimumRemainingPercent: Double = 20 | ||
| public static let defaultWarningHours: Int = 8 | ||
| public static let notificationCooldownHours: Double = 1 | ||
|
|
||
| public static func evaluate( | ||
| provider: UsageProvider, | ||
| window: RateWindow, | ||
| windowKind: ResetWarning.WindowKind, | ||
| warningHours: Int, | ||
| minimumRemainingPercent: Double = ResetWarningEvaluator.minimumRemainingPercent, | ||
| now: Date = .init()) -> ResetWarning? | ||
| { | ||
| guard let resetsAt = window.resetsAt else { return nil } | ||
| let secondsUntilReset = resetsAt.timeIntervalSince(now) | ||
| guard secondsUntilReset > 0 else { return nil } | ||
|
|
||
| let hoursUntilReset = secondsUntilReset / 3600 | ||
| let warningWindow = Double(warningHours) | ||
|
|
||
| guard hoursUntilReset <= warningWindow else { return nil } | ||
|
|
||
| let remaining = window.remainingPercent | ||
| guard remaining >= minimumRemainingPercent else { return nil } | ||
|
|
||
| return ResetWarning( | ||
| providerID: provider, | ||
| windowKind: windowKind, | ||
| remainingPercent: remaining, | ||
| resetsAt: resetsAt, | ||
| hoursUntilReset: hoursUntilReset) | ||
| } | ||
|
|
||
| public static func shouldNotify( | ||
| warning: ResetWarning, | ||
| lastNotifiedAt: Date?, | ||
| now: Date = .init()) -> Bool | ||
| { | ||
| guard let lastNotifiedAt else { return true } | ||
| let cooldownSeconds = Self.notificationCooldownHours * 3600 | ||
| return now.timeIntervalSince(lastNotifiedAt) >= cooldownSeconds | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reset warnings are only recalculated inside the refresh path here, but the new settings (
resetWarningEnabled/resetWarningHours) are not observed inUsageStore.observeSettingsChanges. As a result, changing those preferences does not immediately clear/re-evaluateactiveResetWarnings; in Manual refresh mode, stale warning banners can remain indefinitely until the user triggers a refresh.Useful? React with 👍 / 👎.