diff --git a/CHANGELOG.md b/CHANGELOG.md index 356e6d42..3c3efea1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to this project will be documented in this file. Dates are I The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed +- **Quota notifications now show the pool's best availability.** The 5-hour and weekly lines independently select the highest remaining percentage and earliest reset across enabled accounts, rather than showing the reset belonging to the account with the highest remaining percentage. + ## [6.15.0] - 2026-08-31 ### Added diff --git a/README.md b/README.md index 41b52faf..e24ec692 100644 --- a/README.md +++ b/README.md @@ -300,11 +300,10 @@ running, it checks all enabled accounts and alerts through Notification Center when the best remaining 5-hour or weekly pool quota crosses 25%, 10%, or 0%. The feature is disabled by default. -Each line reports the enabled account with the most headroom in that window, -together with that same account's reset time, so the pair always describes a -quota that one account actually has. Windows a plan has switched off are -skipped rather than counted as full. Account identities are omitted for -readability and lock-screen privacy: +Each line reports the most headroom available across enabled accounts in that +window together with the earliest reset across those accounts. Windows a plan +has switched off are skipped rather than counted as full. Account identities +are omitted for readability and lock-screen privacy: ```text 5h: 10% | resets 22:30 diff --git a/docs/configuration.md b/docs/configuration.md index c025d19f..fc75d09e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -270,11 +270,12 @@ The sample above intentionally sets `"retryAllAccountsMaxRetries": 3` as a bound Quota notifications query each distinct enabled account with bounded concurrency. The 5-hour and weekly windows are tracked independently, and each threshold alerts once until that window rises above it after a reset. Each line -reports the account with the most headroom in that window plus that same -account's reset time, so the percentage and the reset always come from one -account. A window the plan has switched off reports `used_percent: 0` and is -skipped rather than scored as a full quota. Account identities are omitted from -alerts and are never persisted in notification state. +reports the most headroom across enabled accounts in that window plus the +earliest reset across those accounts. The percentage and reset may come from +different accounts. A window the plan has switched off reports +`used_percent: 0` and is skipped rather than scored as a full quota. Account +identities are omitted from alerts and are never persisted in notification +state. Set `notifyEveryCheck` to `true` to deliver the aggregate message after every successful poll interval even when no threshold was crossed. Set `thresholds` to `[]` to disable threshold alerts entirely; omitting the key diff --git a/lib/quota-notifications.ts b/lib/quota-notifications.ts index 33538815..8118b10f 100644 --- a/lib/quota-notifications.ts +++ b/lib/quota-notifications.ts @@ -79,49 +79,35 @@ type MonitorDependencies = { }; /** - * Reduce one quota window across accounts to the single account that has the - * most room left, reporting that account's own reset time. - * - * Two rules matter here: - * - * - A window the plan has switched off is skipped via {@link hasUsageWindow}. - * Such a window still reports `used_percent: 0`, so counting it would score - * a disabled window as 100% remaining and mask every other account. - * - The percentage and the reset time are taken from the *same* account. A - * max-percent/min-reset pair describes a quota no account actually has. + * Reduce one quota window across accounts to the most remaining quota and the + * earliest reset independently. A disabled window is skipped because it can + * report `used_percent: 0` and would otherwise mask active windows as 100% full. */ function aggregateWindow( summaries: readonly AccountQuotaSummary[], select: (summary: CodexUsageSummary) => CodexUsageSummary["primary"], now: number, ): AggregatedQuotaWindow { - let best: { remainingPercent: number; resetAtMs?: number } | undefined; + let remainingPercent: number | undefined; + let resetAtMs: number | undefined; for (const accountSummary of summaries) { const window = select(accountSummary.usage); if (!hasUsageWindow(window)) continue; const remaining = getUsageLeftPercent(window.usedPercent); if (remaining === undefined) continue; - const resetAtMs = - typeof window.resetAtMs === "number" && - Number.isFinite(window.resetAtMs) && - window.resetAtMs > now - ? window.resetAtMs - : undefined; - if (best === undefined || remaining > best.remainingPercent) { - best = { remainingPercent: remaining, resetAtMs }; - continue; + if (remainingPercent === undefined || remaining > remainingPercent) { + remainingPercent = remaining; } - // Tie on headroom: prefer the account that recovers first, and prefer a - // known reset over an unknown one. if ( - remaining === best.remainingPercent && - resetAtMs !== undefined && - (best.resetAtMs === undefined || resetAtMs < best.resetAtMs) + typeof window.resetAtMs === "number" && + Number.isFinite(window.resetAtMs) && + window.resetAtMs > now && + (resetAtMs === undefined || window.resetAtMs < resetAtMs) ) { - best = { remainingPercent: remaining, resetAtMs }; + resetAtMs = window.resetAtMs; } } - return best ? { remainingPercent: best.remainingPercent, resetAtMs: best.resetAtMs } : {}; + return { remainingPercent, resetAtMs }; } export function aggregateQuotaUsage( diff --git a/test/quota-notifications.test.ts b/test/quota-notifications.test.ts index 30b88bc0..fa6467b7 100644 --- a/test/quota-notifications.test.ts +++ b/test/quota-notifications.test.ts @@ -48,7 +48,7 @@ function accountUsage(options: Parameters[0]): AccountQuotaSummary } describe("quota notification aggregation", () => { - it("reports the account with the most headroom and that same account's reset", () => { + it("reports the most headroom and earliest reset independently", () => { const result = aggregateQuotaUsage( [ accountUsage({ @@ -67,17 +67,15 @@ describe("quota notification aggregation", () => { 1_000_000, ); expect(result).toEqual({ - // Second account: 60% left, resetting at its own 1_500_000. + // Second account has both the best quota and earliest reset. fiveHour: { remainingPercent: 60, resetAtMs: 1_500_000, }, - // First account: 70% left, resetting at its own 3_000_000. Taking the - // other account's earlier 2_500_000 here would describe a 70% quota - // that recovers at a time no account recovers at. + // First account has the best quota; second account resets first. weekly: { remainingPercent: 70, - resetAtMs: 3_000_000, + resetAtMs: 2_500_000, }, }); }); @@ -109,15 +107,26 @@ describe("quota notification aggregation", () => { expect(result.weekly.remainingPercent).toBe(60); }); - it("prefers the earliest reset when two accounts tie on headroom", () => { + it("selects the earliest reset regardless of headroom", () => { const result = aggregateQuotaUsage( [ - accountUsage({ fiveHourUsed: 50, fiveHourReset: 3_000 }), + accountUsage({ fiveHourUsed: 20, fiveHourReset: 3_000 }), accountUsage({ fiveHourUsed: 50, fiveHourReset: 1_500 }), ], 1_000_000, ); - expect(result.fiveHour).toEqual({ remainingPercent: 50, resetAtMs: 1_500_000 }); + expect(result.fiveHour).toEqual({ remainingPercent: 80, resetAtMs: 1_500_000 }); + }); + + it("ignores reset timestamps from windows without valid usage", () => { + const result = aggregateQuotaUsage( + [ + accountUsage({ fiveHourUsed: 50, fiveHourReset: 3_000 }), + accountUsage({ fiveHourReset: 1_500 }), + ], + 1_000_000, + ); + expect(result.fiveHour).toEqual({ remainingPercent: 50, resetAtMs: 3_000_000 }); }); it("ignores expired reset timestamps", () => {