);
}
/**
- * Subscription quota windows from every connected environment's providers.
- * Countdowns anchor to render time rather than ticking: a live clock would
- * repaint the page every minute for no decision-changing gain.
+ * Subscription quota across every connected environment's providers and hubs,
+ * pooled per provider. Countdowns anchor to render time rather than ticking: a
+ * live clock would repaint the page every minute for no decision-changing gain.
*/
export function UsageLimitsSection({
selectedEnvironmentIds,
+ fixture = null,
}: {
readonly selectedEnvironmentIds: ReadonlySet | null;
+ /** Dev-only synthetic presentations standing in for the live ones. */
+ readonly fixture?: ReturnType | null;
}) {
- const presentations = useAtomValue(environmentPresentations.presentationsAtom);
+ const live = useAtomValue(environmentPresentations.presentationsAtom);
+ // Anchored once per mount on purpose: countdowns must not tick (see above).
+ const [now] = useState(() => Date.now());
+ const presentations: Parameters[0] = fixture ?? live;
const selected =
selectedEnvironmentIds === null
? presentations
: new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id)));
- const groups = collectLimitsGroups(selected);
- const sources = collectLimitSources(selected);
- // Anchored once per mount on purpose: countdowns must not tick (see below).
- const [now] = useState(() => Date.now());
-
- return (
-
+ );
+}
+
+/**
+ * One account's share of one pooled window: the segment, its popover, and the
+ * reset confirm. The confirm is a sibling of the popover, not a child: dialogs
+ * stack under popovers, and the popover closes as the confirm opens.
+ */
+function PoolSegment({
+ account,
+ window,
+ reset,
+ color,
+ now,
+ index,
+}: {
+ readonly account: LimitAccount;
+ readonly window: LimitPoolMember["window"];
+ readonly reset: LimitPoolWindow["resets"][number] | undefined;
+ readonly color: string;
+ readonly now: number;
+ /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */
+ readonly index: number;
+}) {
+ const [open, setOpen] = useState(false);
+ const remaining = remainingPercent(window);
+ const resetsIn = formatResetsIn(window, now);
+ const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0;
+ return (
+
+
+ }
+ >
+ {/* Translucent so the label reads over the fill for any provider colour and theme. */}
+
+ {/* The spent share is hatched, not blank: it is what the countdown restores. */}
+ {remaining < 100 && reset ? (
+
+ ) : null}
+
+ {index}
+
+
+
+ {remaining}%
+ {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */}
+
+ {resetsIn?.replace("resets in ", "↻ ") ?? ""}
+ {credits ? (
+ <>
+ {resetsIn ? (
+
+ ·
+
+ ) : null}
+
+
+ {credits}
+
+ >
+ ) : null}
+
+
+
+
+ {account.redeem ? (
+ setOpen(false)}
+ />
+ ) : (
+
+ {}}
+ />
+
+ )}
+
+ );
+}
+
+/**
+ * Below the strip at narrow widths: one row per account in bar order, carrying
+ * the text the segment has no room for. Tapping a row opens the same popover
+ * as its segment, so the two are one control with two handles.
+ */
+function LegendRow({
+ account,
+ window,
+ color,
+ now,
+ index,
+}: {
+ readonly account: LimitAccount;
+ readonly window: LimitPoolMember["window"];
+ readonly color: string;
+ readonly now: number;
+ readonly index: number;
+}) {
+ const remaining = remainingPercent(window);
+ const resetsIn = formatResetsIn(window, now);
+ const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0;
+ return (
+
+
+
+ Segment
+ {index}
+
+
+ {remaining}%
+
+ {resetsIn?.replace("resets in ", "↻ ") ?? ""}
+ {credits ? (
+ <>
+ {resetsIn ? · : null}
+
+
+ {credits}
+
+
+ {credits} reset {credits === 1 ? "credit" : "credits"} banked
+
+ >
+ ) : null}
+
+
+ );
+}
+
+/** Split out so the redeem hook only runs for accounts that can redeem. */
+function RedeemableSegmentPopup({
+ account,
+ window,
+ reset,
+ now,
+ redeemAt,
+ closePopover,
+}: {
+ readonly account: LimitAccount;
+ readonly window: LimitPoolMember["window"];
+ readonly reset: LimitPoolWindow["resets"][number] | undefined;
+ readonly now: number;
+ readonly redeemAt: NonNullable;
+ readonly closePopover: () => void;
+}) {
+ const redeem = useResetCredit(redeemAt.environmentId, redeemAt.instanceId);
+ return (
+ <>
+
+ {
+ closePopover();
+ redeem.setConfirming(true);
+ }}
+ />
+
+ void redeem.redeem()}
+ />
+ {/* The popover closed before the confirm, so the outcome needs a home outside it. */}
+ {redeem.status ? (
+
+ {redeem.status}
+
+ ) : null}
+ >
+ );
+}
+
+/**
+ * One pooled window as equal-width segments, one per account, each filled by
+ * the share of that account's quota still open. Equal widths are honest: every
+ * account contributes the same share of the pool, whatever its plan.
+ *
+ * Wide, each segment carries its own label. Narrow, the bar is a bare strip
+ * and a legend below lists the accounts in the same order; both open the
+ * same popover.
+ */
+function PoolBar({
+ pool,
+ color,
+ now,
+}: {
+ readonly pool: LimitPoolWindow;
+ readonly color: string;
+ readonly now: number;
+}) {
+ const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset]));
+ return (
+
+ );
+}
+
+/**
+ * Big pooled number and the segment bar. The bar is sorted by reset, so who
+ * refills next is its left edge; the exact time and share restored live in
+ * each segment's popover rather than a list restating the bar.
+ */
+function PoolWindowCard({
+ pool,
+ color,
+ now,
+}: {
+ readonly pool: LimitPoolWindow;
+ readonly color: string;
+ readonly now: number;
+}) {
+ // The soonest reset that hands anything back; an untouched account resets to no effect.
+ const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0);
+ return (
+
+ );
+}
+
+function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) {
+ const color = barColor(pool.driver);
+ const label = getDriverOption(pool.driver)?.label ?? String(pool.driver);
+ return (
+
+
+
+ {label}
+
+ {pool.windows.map((window) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * Accounts pooled per provider: what is open across all of them, who resets
+ * next, and how much of the pool that hands back. Answers "can I keep going"
+ * before "on which account".
+ */
+export function UsageLimitsPooled({
+ presentations,
+ now,
+}: {
+ readonly presentations: Parameters[0];
+ readonly now: number;
+}) {
+ const pools = collectLimitPools(collectLimitAccounts(presentations), now);
+ const notices = collectLimitNotices(presentations);
+ return (
+
+ {pools.length === 0 ? (
+
+ No provider on the selected environments reports subscription limits.
+
+ ) : null}
+ {pools.map((pool) => (
+
+ ))}
+
+
+ );
+}
+
+/** Sources and providers that could not be read, so a missing bar is not mistaken for a full one. */
+function LimitNotices({ notices }: { readonly notices: readonly string[] }) {
+ if (notices.length === 0) return null;
+ return (
+
+ {notices.map((notice) => (
+
{notice}
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx
index deb05f266b98028a45602cfca0afad1db9b7f1fa..066f8549f5e968c6ab839a25f48e8bf67d6d32f1 100644
--- a/apps/web/src/components/usage/UsagePage.tsx
+++ b/apps/web/src/components/usage/UsagePage.tsx
@@ -59,6 +59,7 @@ import {
import { WorkspacePageContainer } from "../WorkspacePageContainer";
import { WorkspacePageHeader } from "../WorkspacePageHeader";
import { UsageLimitsSection } from "./UsageLimits";
+import { makeLimitsFixture } from "./usageLimitsFixture";
import { UsagePriceOverrides } from "./UsagePriceOverrides";
import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart";
import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders";
@@ -109,10 +110,33 @@ export function UsagePage() {
useState | null>(null);
const { days: windowDays, window } = windowSelection;
const isPast24Hours = windowDays === 1;
- const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage(
- window,
- selectedEnvironmentIds,
- );
+ const usage = useUsage(window, selectedEnvironmentIds);
+ // Dev only: `/usage?limitsFixture=` lists synthetic environments in the
+ // picker and feeds the Limits view from them, so merge rules can be eyeballed.
+ const [fixture] = useState(() => {
+ if (!import.meta.env.DEV) return null;
+ const name = new URLSearchParams(globalThis.location.search).get("limitsFixture");
+ return name ? makeLimitsFixture(name, Date.now()) : null;
+ });
+ const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } =
+ useMemo(() => {
+ if (!fixture || !showingLimits) return usage;
+ const all = [...fixture].map(([environmentId, presentation]) => ({
+ environmentId,
+ label: presentation.entry.target.label,
+ isPending: false,
+ error: null,
+ summary: null,
+ }));
+ return {
+ ...usage,
+ environments: all,
+ selectedEnvironments:
+ selectedEnvironmentIds === null
+ ? all
+ : all.filter((environment) => selectedEnvironmentIds.has(environment.environmentId)),
+ };
+ }, [fixture, selectedEnvironmentIds, showingLimits, usage]);
const presentations = useAtomValue(environmentPresentations.presentationsAtom);
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
reportFailure: false,
@@ -166,6 +190,8 @@ export function UsagePage() {
if (refreshingRef.current) return;
if (showingLimits) {
+ // Synthetic data has nothing to re-read.
+ if (fixture) return;
refreshingRef.current = true;
setIsRefreshing(true);
void Promise.all(
@@ -349,7 +375,10 @@ export function UsagePage() {
: `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}
) : showingLimits ? (
-
+
) : isPending ? (
) : (
diff --git a/apps/web/src/components/usage/usageLimitsFixture.ts b/apps/web/src/components/usage/usageLimitsFixture.ts
new file mode 100644
index 0000000000000000000000000000000000000000..8958707b98dea72ad5e140ff9a9aba2b170591bb
--- /dev/null
+++ b/apps/web/src/components/usage/usageLimitsFixture.ts
@@ -0,0 +1,413 @@
+/**
+ * Dev-only stand-ins for `environmentPresentations` that exercise the pooled
+ * Limits view's merge rules, one scenario per named fixture. Reached with
+ * `/usage?limitsFixture=` on a dev build; never bundled otherwise.
+ */
+import {
+ EnvironmentId,
+ ProviderDriverKind,
+ ProviderInstanceId,
+ type ServerProvider,
+ type ServerProviderUsageWindow,
+ type UsageLimitSourceSnapshot,
+ UsageLimitSourceId,
+} from "@t3tools/contracts";
+
+const MINUTE = 60_000;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+
+interface Presentation {
+ readonly entry: { readonly target: { readonly label: string } };
+ readonly serverConfig: {
+ readonly providers: readonly ServerProvider[];
+ readonly usageLimitSources: readonly UsageLimitSourceSnapshot[];
+ };
+}
+
+type Fixture = ReadonlyMap;
+
+const codex = ProviderDriverKind.make("codex");
+const claude = ProviderDriverKind.make("claudeAgent");
+
+function makeHelpers(now: number) {
+ const at = (ms: number) => new Date(now + ms).toISOString();
+ const checked = (agoMs: number) => new Date(now - agoMs).toISOString();
+
+ const session = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({
+ id: "five_hour",
+ kind: "session",
+ label: "Session",
+ usedPercent: used,
+ windowDurationMins: 300,
+ resetsAt: at(resetsInMs),
+ });
+ const weekly = (
+ id: string,
+ label: string,
+ used: number,
+ resetsInMs: number,
+ ): ServerProviderUsageWindow => ({
+ id,
+ kind: "weekly",
+ label,
+ usedPercent: used,
+ windowDurationMins: 7 * 24 * 60,
+ resetsAt: at(resetsInMs),
+ });
+ /** Codex names its five-hour window `primary` and its weekly one `secondary`. */
+ const codexSession = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({
+ ...session(used, resetsInMs),
+ id: "primary",
+ });
+ const codexWeekly = (used: number, resetsInMs: number) =>
+ weekly("secondary", "Weekly", used, resetsInMs);
+
+ const provider = (
+ overrides: Partial & Pick,
+ ): ServerProvider => ({
+ enabled: true,
+ installed: true,
+ version: null,
+ status: "ready",
+ auth: { status: "authenticated" },
+ checkedAt: checked(0),
+ models: [],
+ slashCommands: [],
+ skills: [],
+ ...overrides,
+ });
+
+ const codexInstance = (input: {
+ readonly instanceId: string;
+ readonly displayName?: string;
+ readonly accentColor?: string;
+ readonly email: string;
+ readonly plan?: string;
+ readonly checkedAgoMs?: number;
+ readonly windows: readonly ServerProviderUsageWindow[];
+ readonly credits?: number;
+ }) =>
+ provider({
+ instanceId: ProviderInstanceId.make(input.instanceId),
+ driver: codex,
+ ...(input.displayName ? { displayName: input.displayName } : {}),
+ ...(input.accentColor ? { accentColor: input.accentColor } : {}),
+ auth: {
+ status: "authenticated",
+ label: input.plan ?? "ChatGPT Pro 20x Subscription",
+ email: input.email,
+ },
+ usageLimits: {
+ checkedAt: checked(input.checkedAgoMs ?? MINUTE),
+ windows: input.windows,
+ ...(input.credits
+ ? { resetCredits: { availableCount: input.credits, nextExpiresAt: at(28 * DAY) } }
+ : {}),
+ },
+ });
+
+ const claudeHubAccount = (
+ email: string | null,
+ windows: readonly ServerProviderUsageWindow[],
+ checkedAgoMs = 4 * MINUTE,
+ ): UsageLimitSourceSnapshot["accounts"][number] => ({
+ id: email ? `claude-${email}.json` : "claude-team-seat.json",
+ driver: claude,
+ ...(email ? { email } : {}),
+ plan: "Claude Subscription",
+ usageLimits: { checkedAt: checked(checkedAgoMs), windows },
+ });
+
+ const hub = (
+ id: string,
+ label: string,
+ accounts: UsageLimitSourceSnapshot["accounts"],
+ error?: string,
+ ): UsageLimitSourceSnapshot => ({
+ id: UsageLimitSourceId.make(id),
+ kind: "cliproxy",
+ label,
+ checkedAt: checked(2 * MINUTE),
+ accounts,
+ ...(error ? { error } : {}),
+ });
+
+ const environment = (
+ id: string,
+ label: string,
+ providers: readonly ServerProvider[],
+ usageLimitSources: readonly UsageLimitSourceSnapshot[] = [],
+ ): readonly [EnvironmentId, Presentation] => [
+ EnvironmentId.make(id),
+ { entry: { target: { label } }, serverConfig: { providers, usageLimitSources } },
+ ];
+
+ return {
+ at,
+ checked,
+ session,
+ weekly,
+ codexSession,
+ codexWeekly,
+ provider,
+ codexInstance,
+ claudeHubAccount,
+ hub,
+ environment,
+ };
+}
+
+const FIXTURES: Record Fixture> = {
+ /**
+ * The same Codex account signed in on two machines with different snapshot
+ * ages, plus a hub that also reports it. Must collapse to one segment with
+ * the freshest figures and both machines listed.
+ */
+ "same-account": (now) => {
+ const h = makeHelpers(now);
+ const email = "main@example.com";
+ const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [
+ {
+ id: `codex-abc-${email}-pro.json`,
+ driver: codex,
+ email,
+ plan: "ChatGPT Pro 20x Subscription",
+ usageLimits: { checkedAt: h.checked(14 * MINUTE), windows: [h.codexWeekly(70, 5 * DAY)] },
+ },
+ ];
+ return new Map([
+ h.environment(
+ "env-macbook",
+ "MacBook Pro",
+ [
+ h.codexInstance({
+ instanceId: "codex",
+ displayName: "Codex Personal",
+ accentColor: "#6366f1",
+ email,
+ windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)],
+ credits: 2,
+ }),
+ ],
+ [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)],
+ ),
+ h.environment("env-nucbox", "nucbox-1", [
+ h.codexInstance({
+ instanceId: "codex",
+ email,
+ checkedAgoMs: 9 * MINUTE,
+ windows: [h.codexSession(30, 3 * HOUR), h.codexWeekly(60, 5 * DAY)],
+ credits: 2,
+ }),
+ ]),
+ ]);
+ },
+
+ /**
+ * Three machines, no two alike: one has only Codex, one only Claude via a
+ * hub, one has both natively. Filtering to any single environment should
+ * drop whole provider sections.
+ */
+ "uneven-environments": (now) => {
+ const h = makeHelpers(now);
+ return new Map([
+ h.environment("env-macbook", "MacBook Pro", [
+ h.codexInstance({
+ instanceId: "codex",
+ displayName: "Codex Personal",
+ accentColor: "#6366f1",
+ email: "main@example.com",
+ windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)],
+ credits: 2,
+ }),
+ h.provider({
+ instanceId: ProviderInstanceId.make("claude"),
+ driver: claude,
+ auth: { status: "authenticated", label: "Claude Max", email: "main@example.com" },
+ usageLimits: {
+ checkedAt: h.checked(MINUTE),
+ windows: [
+ h.session(2, 4 * HOUR),
+ h.weekly("seven_day", "Weekly", 38, 4 * DAY),
+ h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY),
+ ],
+ },
+ }),
+ ]),
+ h.environment(
+ "env-nucbox",
+ "nucbox-1",
+ [],
+ [
+ h.hub("cliproxy-nucbox", "CLI Proxy", [
+ h.claudeHubAccount("personal@example.com", [
+ h.session(100, 4 * HOUR),
+ h.weekly("seven_day", "Weekly", 50, DAY),
+ h.weekly("seven_day_fable", "Weekly · Fable", 96, DAY),
+ ]),
+ h.claudeHubAccount("second@example.org", [
+ h.session(63, 2 * HOUR),
+ h.weekly("seven_day", "Weekly", 37, 4 * DAY),
+ h.weekly("seven_day_fable", "Weekly · Fable", 72, 4 * DAY),
+ ]),
+ ]),
+ ],
+ ),
+ h.environment("env-macmini", "Mac Mini", [
+ h.codexInstance({
+ instanceId: "codex",
+ displayName: "Codex Work",
+ email: "work@example.com",
+ windows: [h.codexSession(0, 5 * HOUR), h.codexWeekly(95, 5 * DAY)],
+ credits: 1,
+ }),
+ ]),
+ ]);
+ },
+
+ /**
+ * Codex plans that report only one window (Go reports a monthly allowance;
+ * a hub often has no five-hour figure for an account) mixed with a plan that
+ * reports both. Each pool lists only the accounts that have that window.
+ */
+ "codex-window-mix": (now) => {
+ const h = makeHelpers(now);
+ return new Map([
+ h.environment(
+ "env-macbook",
+ "MacBook Pro",
+ [
+ h.codexInstance({
+ instanceId: "codex",
+ displayName: "Codex Personal",
+ accentColor: "#6366f1",
+ email: "main@example.com",
+ windows: [h.codexSession(40, 2 * HOUR), h.codexWeekly(55, 3 * DAY)],
+ credits: 2,
+ }),
+ h.codexInstance({
+ instanceId: "codex-go",
+ displayName: "Codex Go",
+ accentColor: "#10b981",
+ email: "go@example.com",
+ plan: "ChatGPT Go Subscription",
+ windows: [
+ {
+ id: "primary",
+ kind: "monthly",
+ label: "Monthly",
+ usedPercent: 82,
+ windowDurationMins: 30 * 24 * 60,
+ resetsAt: h.at(11 * DAY),
+ },
+ ],
+ }),
+ ],
+ [
+ h.hub("cliproxy-nucbox", "CLI Proxy", [
+ {
+ id: "codex-def-work@example.com-pro.json",
+ driver: codex,
+ email: "work@example.com",
+ plan: "ChatGPT Pro 20x Subscription",
+ usageLimits: {
+ checkedAt: h.checked(3 * MINUTE),
+ windows: [h.codexWeekly(88, 6 * DAY)],
+ },
+ },
+ {
+ id: "codex-ghi-team@example.net-plus.json",
+ driver: codex,
+ email: "team@example.net",
+ plan: "ChatGPT Plus Subscription",
+ usageLimits: {
+ checkedAt: h.checked(3 * MINUTE),
+ windows: [h.codexWeekly(12, DAY)],
+ },
+ },
+ ]),
+ ],
+ ),
+ ]);
+ },
+
+ /**
+ * A hub configured on two environments, a hub that is down, a provider
+ * whose probe failed, an API-key account, and a hub account with no email.
+ */
+ "failures-and-strays": (now) => {
+ const h = makeHelpers(now);
+ const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [
+ h.claudeHubAccount("main@example.com", [
+ h.session(2, 4 * HOUR),
+ h.weekly("seven_day", "Weekly", 38, 4 * DAY),
+ h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY),
+ ]),
+ h.claudeHubAccount(null, [
+ h.session(40, 2 * HOUR),
+ h.weekly("seven_day", "Weekly", 20, 6 * DAY),
+ ]),
+ ];
+ return new Map([
+ h.environment(
+ "env-macbook",
+ "MacBook Pro",
+ [
+ h.provider({
+ instanceId: ProviderInstanceId.make("claude"),
+ driver: claude,
+ auth: { status: "authenticated", label: "Claude API Key" },
+ usageLimits: {
+ checkedAt: h.checked(MINUTE),
+ windows: [],
+ unavailable: {
+ reason: "unsupported",
+ message: "This account has no subscription limits.",
+ },
+ },
+ }),
+ ],
+ [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)],
+ ),
+ h.environment(
+ "env-nucbox",
+ "nucbox-1",
+ [],
+ [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)],
+ ),
+ h.environment(
+ "env-macmini",
+ "Mac Mini",
+ [
+ h.provider({
+ instanceId: ProviderInstanceId.make("claude"),
+ driver: claude,
+ auth: {
+ status: "authenticated",
+ label: "Claude Max",
+ email: "work@example.com",
+ },
+ usageLimits: {
+ checkedAt: h.checked(MINUTE),
+ windows: [],
+ unavailable: { reason: "probeFailed", message: "Claude timed out reading usage." },
+ },
+ }),
+ ],
+ [
+ h.hub(
+ "cliproxy-aws",
+ "AWS proxy",
+ [],
+ "fetch failed: connect ECONNREFUSED 10.0.0.4:8318",
+ ),
+ ],
+ ),
+ ]);
+ },
+};
+
+export function makeLimitsFixture(name: string, now: number): Fixture | null {
+ return Object.hasOwn(FIXTURES, name) ? FIXTURES[name]!(now) : null;
+}
diff --git a/docs/user/usage.md b/docs/user/usage.md
index 4e4196a46337cfebc3d87434b3d427ec2980f9ec..2f3e1013fdcea3e55728b7f34d5571a24699137f 100644
--- a/docs/user/usage.md
+++ b/docs/user/usage.md
@@ -39,9 +39,18 @@ the dialog.
## Track subscription limits
-**Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and
-Claude subscriptions. For windows with timing data, each bar also marks how much of the window is
-left, so you can judge your pace before the next reset.
+On web and desktop, **Usage → Limits** pools every subscription account it can see per provider, so with several Codex
+or Claude accounts across your environments and hubs you read one number per window rather than a
+list. Each window card shows how much of the pool is left and a bar with one segment per account,
+ordered by which resets soonest; when the provider reports reset times, the card also says when
+the next reset lands and how much it hands back. The hatched
+part of a segment is what that reset restores. Tap or hover a segment for the account's plan, where it is
+signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the
+segment and the **Use reset** action in that popover. On narrow screens, numbered rows below
+the bar show each account's quota, countdown, and credits. Tap a row to open its details.
+
+The same account signed in on more than one environment, or reported by a hub as well, counts once.
+Filter with the environment dropdown to see what a single machine has.
If a window looks stale, refresh Limits to re-check every provider and hub.
diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts
index fede8813e913e440bb7c2c98e9047155b96f18ca..32fb4eaa9503918702356aa3b16e4230f3b42c71 100644
--- a/packages/shared/src/usageLimits.test.ts
+++ b/packages/shared/src/usageLimits.test.ts
@@ -13,6 +13,9 @@ import {
collectProviderUsageLimits,
sameUsageLimitCommandCoverage,
withUsageLimitsCommands,
+ collectLimitAccounts,
+ collectLimitNotices,
+ collectLimitPools,
collectLimitSources,
collectLimitsGroups,
elapsedShare,
@@ -310,6 +313,381 @@ describe("collectLimitSources", () => {
});
});
+describe("pools", () => {
+ const checkedAt = "2026-09-03T11:00:00.000Z";
+ const weekly = {
+ id: "seven_day",
+ kind: "weekly",
+ label: "Weekly",
+ windowDurationMins: 7 * 24 * 60,
+ resetsAt: "2026-09-06T12:00:00.000Z",
+ } as const;
+ const claude = ProviderDriverKind.make("claudeAgent");
+ const source = {
+ id: UsageLimitSourceId.make("hub"),
+ kind: "cliproxy" as const,
+ label: "hub",
+ checkedAt,
+ };
+ const laptop = { entry: { target: { label: "Laptop" } } };
+
+ it("merges one account reported natively on two environments and by a hub into one entry", () => {
+ const native = provider({
+ driver: claude,
+ instanceId: ProviderInstanceId.make("claude"),
+ auth: { status: "authenticated", email: "Same@example.com" },
+ usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 40 }] },
+ });
+ const input = new Map([
+ [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [native] } }],
+ [
+ EnvironmentId.make("env-b"),
+ {
+ entry: { target: { label: "Desktop" } },
+ serverConfig: {
+ providers: [
+ {
+ ...native,
+ usageLimits: {
+ checkedAt: "2026-09-03T11:30:00.000Z",
+ windows: [{ ...window, usedPercent: 55 }],
+ },
+ },
+ ],
+ usageLimitSources: [
+ {
+ ...source,
+ accounts: [
+ {
+ id: "claude-same@example.com.json",
+ driver: claude,
+ email: "same@example.com",
+ plan: "Claude Subscription",
+ usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 10 }] },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ ]);
+ const accounts = collectLimitAccounts(input);
+ expect(accounts).toHaveLength(1);
+ expect(accounts[0]).toMatchObject({
+ key: "env-a:claude",
+ sourceLabel: null,
+ // Desktop's read is fresher, so its credits and its redeem are the ones on show.
+ redeem: { environmentId: "env-b", instanceId: "claude" },
+ environments: [
+ { environmentId: "env-a", label: "Laptop" },
+ { environmentId: "env-b", label: "Desktop" },
+ ],
+ });
+ // The fresher native snapshot wins; the hub row is pre-filtered by email.
+ expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55);
+ });
+
+ it("takes windows from a fresher hub read but credits and redeem from the native instance", () => {
+ const native = provider({
+ driver: claude,
+ instanceId: ProviderInstanceId.make("claude"),
+ auth: { status: "authenticated", email: "same@example.com" },
+ usageLimits: {
+ checkedAt,
+ windows: [{ ...window, usedPercent: 40 }],
+ resetCredits: { availableCount: 2 },
+ },
+ });
+ const input = new Map([
+ [
+ EnvironmentId.make("env-a"),
+ {
+ ...laptop,
+ serverConfig: {
+ providers: [native],
+ usageLimitSources: [
+ {
+ ...source,
+ accounts: [
+ {
+ id: "claude-same@example.com.json",
+ driver: claude,
+ email: "same@example.com",
+ usageLimits: {
+ checkedAt: "2026-09-03T11:30:00.000Z",
+ windows: [{ ...window, usedPercent: 55 }],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ ]);
+ const [account] = collectLimitAccounts(input);
+ expect(account?.limits.windows[0]?.usedPercent).toBe(55);
+ expect(account?.limits.resetCredits?.availableCount).toBe(2);
+ expect(account?.redeem).toEqual({ environmentId: "env-a", instanceId: "claude" });
+ expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]);
+ });
+
+ it("redeems on the environment whose snapshot supplied the credits on show", () => {
+ const stale = provider({
+ auth: { status: "authenticated", email: "same@example.com" },
+ usageLimits: {
+ checkedAt,
+ windows: [window],
+ resetCredits: { availableCount: 0 },
+ },
+ });
+ const fresh = {
+ ...stale,
+ usageLimits: {
+ checkedAt: "2026-09-03T11:30:00.000Z",
+ windows: [window],
+ resetCredits: { availableCount: 2 },
+ },
+ };
+ const input = new Map([
+ [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [stale] } }],
+ [
+ EnvironmentId.make("env-b"),
+ { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [fresh] } },
+ ],
+ ]);
+ const [account] = collectLimitAccounts(input);
+ expect(account?.limits.resetCredits?.availableCount).toBe(2);
+ expect(account?.redeem).toEqual({ environmentId: "env-b", instanceId: "codex" });
+ });
+
+ it("names an environment once however many of its instances share the account", () => {
+ const shared = provider({
+ auth: { status: "authenticated", email: "same@example.com" },
+ usageLimits: { checkedAt, windows: [window] },
+ });
+ const input = new Map([
+ [
+ EnvironmentId.make("env-a"),
+ {
+ ...laptop,
+ serverConfig: {
+ providers: [shared, { ...shared, instanceId: ProviderInstanceId.make("work") }],
+ },
+ },
+ ],
+ ]);
+ expect(collectLimitAccounts(input)[0]?.environments).toEqual([
+ { environmentId: "env-a", label: "Laptop" },
+ ]);
+ });
+
+ it("keys a hub account without an email by hub, so two environments on one hub share it", () => {
+ const seat = {
+ id: "claude-team-seat.json",
+ driver: claude,
+ usageLimits: { checkedAt, windows: [window] },
+ };
+ const hub = { ...source, accounts: [seat] };
+ const input = new Map([
+ [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { usageLimitSources: [hub] } }],
+ [
+ EnvironmentId.make("env-b"),
+ { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [hub] } },
+ ],
+ ]);
+ const accounts = collectLimitAccounts(input);
+ expect(accounts.map((account) => account.key)).toEqual(["hub:claude-team-seat.json"]);
+ expect(accounts[0]?.displayName).toBe("claude-team-seat");
+ });
+
+ it("pools windows by id across accounts and orders resets by when they land", () => {
+ const input = new Map([
+ [
+ EnvironmentId.make("env-a"),
+ {
+ ...laptop,
+ serverConfig: {
+ providers: [],
+ usageLimitSources: [
+ {
+ ...source,
+ accounts: [
+ {
+ id: "a",
+ driver: claude,
+ usageLimits: {
+ checkedAt,
+ windows: [
+ { ...window, usedPercent: 80, resetsAt: "2026-09-03T13:00:00.000Z" },
+ { ...weekly, usedPercent: 20 },
+ ],
+ },
+ },
+ {
+ id: "b",
+ driver: claude,
+ usageLimits: {
+ checkedAt,
+ windows: [{ ...window, usedPercent: 40 }],
+ },
+ },
+ {
+ id: "c",
+ driver: ProviderDriverKind.make("codex"),
+ usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] },
+ },
+ {
+ id: "unsupported",
+ driver: claude,
+ usageLimits: {
+ checkedAt,
+ windows: [],
+ unavailable: { reason: "unsupported" as const },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
+ ]);
+ const pools = collectLimitPools(collectLimitAccounts(input), now);
+ expect(pools.map((pool) => [pool.driver, pool.accounts.length])).toEqual([
+ ["claudeAgent", 2],
+ ["codex", 1],
+ ]);
+ const [session, week] = pools[0]!.windows;
+ // A member with no reset has no clock, so it does not vote on pace.
+ const untimed = collectLimitPools(
+ collectLimitAccounts(input).map((account) =>
+ account.key === "hub:b"
+ ? {
+ ...account,
+ limits: {
+ ...account.limits,
+ windows: account.limits.windows.map((w) => ({ ...w, resetsAt: undefined })),
+ },
+ }
+ : account,
+ ),
+ now,
+ );
+ // Only a votes: 80% used, 80% elapsed.
+ expect(untimed[0]?.windows[0]?.pace).toBe("on");
+ // a is 80% through its window and b 60%: the pool is 70% elapsed, 60% used.
+ expect(session).toMatchObject({
+ id: "five_hour",
+ remainingPercent: 40,
+ usedPercent: 60,
+ pace: "under",
+ });
+ expect(
+ session?.resets.map((reset) => [reset.member.account.key, reset.restoresPercent]),
+ ).toEqual([
+ ["hub:a", 40],
+ ["hub:b", 20],
+ ]);
+ expect(week).toMatchObject({ id: "seven_day", remainingPercent: 80, members: [{}] });
+ // Codex reports `primary` for both its five-hour and (on Go) monthly window.
+ const mixed = collectLimitPools(
+ [
+ ...collectLimitAccounts(input),
+ {
+ key: "go",
+ driver: claude,
+ displayName: "Go",
+ email: undefined,
+ plan: undefined,
+ accentColor: undefined,
+ environments: [],
+ sourceLabel: null,
+ redeem: null,
+ limits: {
+ checkedAt,
+ windows: [
+ {
+ id: "five_hour",
+ kind: "monthly",
+ label: "Monthly",
+ usedPercent: 82,
+ windowDurationMins: 30 * 24 * 60,
+ resetsAt: "2026-09-14T12:00:00.000Z",
+ },
+ ],
+ },
+ },
+ ],
+ now,
+ );
+ expect(mixed[0]?.windows.map((window) => [window.kind, window.members.length])).toEqual([
+ ["session", 2],
+ ["weekly", 1],
+ ["monthly", 1],
+ ]);
+ // Segments read left to right as "who refills next", matching the reset list.
+ expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]);
+ expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]);
+ });
+});
+
+describe("collectLimitNotices", () => {
+ const checkedAt = "2026-09-03T11:00:00.000Z";
+ const claude = ProviderDriverKind.make("claudeAgent");
+ const laptop = { entry: { target: { label: "Laptop" } } };
+ const hub = {
+ id: UsageLimitSourceId.make("hub"),
+ kind: "cliproxy" as const,
+ label: "hub",
+ checkedAt,
+ accounts: [],
+ };
+
+ it("names failures and silence, skips unsupported accounts, and labels environments only when several", () => {
+ const failed = provider({
+ instanceId: ProviderInstanceId.make("claude"),
+ driver: claude,
+ displayName: "Claude Max",
+ usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } },
+ });
+ const apiKey = provider({
+ instanceId: ProviderInstanceId.make("api"),
+ driver: claude,
+ usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } },
+ });
+ const silent = provider({ usageLimits: { checkedAt, windows: [] } });
+ const one = new Map([
+ [
+ EnvironmentId.make("env-a"),
+ {
+ ...laptop,
+ serverConfig: {
+ providers: [failed, apiKey, silent],
+ usageLimitSources: [
+ hub,
+ { ...hub, id: UsageLimitSourceId.make("down"), label: "down", error: "ECONNREFUSED" },
+ ],
+ },
+ },
+ ],
+ ]);
+ expect(collectLimitNotices(one)).toEqual([
+ "Claude Max: Could not read limits.",
+ "codex: No limits reported.",
+ "hub: No accounts reported.",
+ "down: ECONNREFUSED",
+ ]);
+
+ one.set(EnvironmentId.make("env-b"), {
+ entry: { target: { label: "Desktop" } },
+ serverConfig: { providers: [], usageLimitSources: [] },
+ });
+ expect(collectLimitNotices(one)[0]).toBe("Laptop · Claude Max: Could not read limits.");
+ });
+});
+
describe("/usage-limits", () => {
const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] };
const selected = provider({
diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts
index 5cb5303320bd46abc75921c9dcc1af8bc118affa..419e1944c53827bb99b78176528e3b9ec646a9aa 100644
--- a/packages/shared/src/usageLimits.ts
+++ b/packages/shared/src/usageLimits.ts
@@ -58,7 +58,9 @@ export function collectLimitsGroups(
EnvironmentId,
{
readonly entry: { readonly target: { readonly label: string } };
- readonly serverConfig: { readonly providers: readonly ServerProvider[] } | null;
+ readonly serverConfig: {
+ readonly providers?: readonly ServerProvider[] | undefined;
+ } | null;
}
>,
): readonly LimitsGroup[] {
@@ -147,6 +149,302 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined)
return normalizedEmail ? `${driver}:${normalizedEmail}` : null;
}
+/**
+ * One subscription account as the pooled views see it, whichever way it was
+ * reported. The same email signed in natively on two environments, or reported
+ * by a hub as well as natively, is one account: its quota is one bucket, so
+ * counting it twice would misstate what is left.
+ */
+export interface LimitAccount {
+ readonly key: string;
+ readonly driver: ServerProvider["driver"];
+ /** The instance's configured name, which is not sensitive; null for hub accounts. */
+ readonly displayName: string | null;
+ readonly email: string | undefined;
+ readonly plan: string | undefined;
+ readonly accentColor: string | undefined;
+ /** Environments the account is signed in on; empty when only a hub reports it. */
+ readonly environments: ReadonlyArray<{
+ readonly environmentId: EnvironmentId;
+ readonly label: string;
+ }>;
+ /** The hub that reported it, when no environment has it natively. */
+ readonly sourceLabel: string | null;
+ /** Where a reset credit can be redeemed; only native instances can. */
+ readonly redeem: {
+ readonly environmentId: EnvironmentId;
+ readonly instanceId: ProviderInstanceId;
+ } | null;
+ readonly limits: ServerProviderUsageLimits;
+}
+
+/**
+ * Every account with usable windows across the connected environments, one
+ * entry per distinct account. Native instances win over hub reports, and the
+ * freshest snapshot wins when the same account is reported twice.
+ */
+export function collectLimitAccounts(
+ presentations: Parameters[0],
+): readonly LimitAccount[] {
+ const accounts = new Map();
+ const merge = (key: string, next: LimitAccount) => {
+ const previous = accounts.get(key);
+ if (!previous) {
+ accounts.set(key, next);
+ return;
+ }
+ const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt);
+ // Two instances on one machine sharing an account still name it once.
+ const environments = [
+ ...previous.environments,
+ ...next.environments.filter(
+ (candidate) =>
+ !previous.environments.some((seen) => seen.environmentId === candidate.environmentId),
+ ),
+ ];
+ const winner = fresher ? next : previous;
+ // Windows come from the freshest snapshot, wherever it was read. Reset
+ // credits only ever come from a native instance, and the redeem must go
+ // to the instance whose credits are on show, so the two travel together:
+ // the freshest native snapshot supplies both, or neither.
+ const native = [previous, next]
+ .filter((candidate) => candidate.redeem !== null)
+ .toSorted((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0];
+ accounts.set(key, {
+ ...previous,
+ displayName: previous.displayName ?? next.displayName,
+ plan: previous.plan ?? next.plan,
+ accentColor: previous.accentColor ?? next.accentColor,
+ environments,
+ // A hub only names the account when no environment has it natively.
+ sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel),
+ redeem: native?.redeem ?? null,
+ limits: {
+ ...winner.limits,
+ ...(native?.limits.resetCredits
+ ? { resetCredits: native.limits.resetCredits }
+ : { resetCredits: undefined }),
+ },
+ });
+ };
+ for (const [environmentId, presentation] of presentations) {
+ const label = presentation.entry.target.label;
+ for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
+ if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue;
+ merge(
+ accountKey(provider.driver, provider.auth.email) ??
+ `${environmentId}:${provider.instanceId}`,
+ {
+ key: `${environmentId}:${provider.instanceId}`,
+ driver: provider.driver,
+ displayName: provider.displayName?.trim() || null,
+ email: provider.auth.email,
+ plan: provider.auth.label,
+ accentColor: provider.accentColor,
+ environments: [{ environmentId, label }],
+ sourceLabel: null,
+ redeem: { environmentId, instanceId: provider.instanceId },
+ limits: provider.usageLimits,
+ },
+ );
+ }
+ }
+ // Every hub account, including those a native instance also knows: the hub
+ // may hold a fresher read of the same subscription, and the merge above
+ // keeps the redeem target consistent with whichever snapshot wins.
+ const labelEnvironment = presentations.size > 1;
+ for (const presentation of presentations.values()) {
+ for (const source of presentation.serverConfig?.usageLimitSources ?? []) {
+ const sourceLabel = labelEnvironment
+ ? `${presentation.entry.target.label} · ${source.label}`
+ : source.label;
+ for (const account of source.accounts) {
+ if (limitsNotice(account.usageLimits) !== null) continue;
+ merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, {
+ key: `${source.id}:${account.id}`,
+ driver: account.driver,
+ displayName: account.email ? null : account.id.replace(/\.json$/i, ""),
+ email: account.email,
+ plan: account.plan,
+ accentColor: undefined,
+ environments: [],
+ sourceLabel,
+ redeem: null,
+ limits: account.usageLimits,
+ });
+ }
+ }
+ }
+ return [...accounts.values()];
+}
+
+/**
+ * What the pooled views cannot draw as a bar: a hub that failed to read, a
+ * provider whose probe failed. Accounts that can never report (API keys)
+ * are left out; there is nothing for the user to act on. The environment
+ * is named only when more than one is connected.
+ */
+export function collectLimitNotices(
+ presentations: Parameters[0],
+): readonly string[] {
+ const label = (environmentLabel: string, subject: string) =>
+ presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject;
+ const notices: string[] = [];
+ for (const presentation of presentations.values()) {
+ const environmentLabel = presentation.entry.target.label;
+ for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
+ // An account that can never report (API key) is left out; one that
+ // failed, or reported nothing at all, is worth a line.
+ if (provider.usageLimits?.unavailable?.reason === "unsupported") continue;
+ const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null;
+ const name = provider.displayName?.trim() || String(provider.driver);
+ if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`);
+ }
+ for (const source of presentation.serverConfig?.usageLimitSources ?? []) {
+ if (source.error) {
+ notices.push(`${label(environmentLabel, source.label)}: ${source.error}`);
+ } else if (source.accounts.length === 0) {
+ notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`);
+ }
+ }
+ }
+ return notices;
+}
+
+export interface LimitPoolMember {
+ readonly account: LimitAccount;
+ readonly window: ServerProviderUsageWindow;
+}
+
+/**
+ * One window id across every account that reports it: the pooled share left,
+ * pace against the clock, and the resets in the order they will land, each
+ * with the share of the pool it hands back.
+ */
+export interface LimitPoolWindow {
+ readonly id: string;
+ readonly kind: ServerProviderUsageWindow["kind"];
+ readonly label: string;
+ readonly members: readonly LimitPoolMember[];
+ readonly remainingPercent: number;
+ readonly usedPercent: number;
+ readonly pace: LimitPace | null;
+ readonly resets: ReadonlyArray<{
+ readonly member: LimitPoolMember;
+ readonly at: number;
+ /** Points of the pool the reset restores: the member's used share over the member count. */
+ readonly restoresPercent: number;
+ }>;
+}
+
+export interface LimitPool {
+ readonly driver: ServerProvider["driver"];
+ readonly accounts: readonly LimitAccount[];
+ readonly windows: readonly LimitPoolWindow[];
+}
+
+const WINDOW_KIND_ORDER: Record = {
+ session: 0,
+ weekly: 1,
+ monthly: 2,
+ other: 3,
+};
+
+/**
+ * Accounts grouped by driver, each with its windows pooled by kind and id.
+ * Window ids are stable per provider, so a hub row and a native row for the
+ * same window land in the same pool; the kind is part of the key because
+ * Codex's `primary` is a position, not a duration (five hours on paid plans,
+ * a month on Free/Go), and a monthly allowance must not average into a
+ * five-hour pool. Pools order by kind, then first appearance.
+ *
+ * `accounts` is the table order: instances the user can act on (native,
+ * named) before hub-only accounts, each group alphabetical. Each window's
+ * `members` sort by reset instead, soonest first, so a bar reads left to
+ * right as "who refills next" and matches the reset list under it.
+ */
+export function collectLimitPools(
+ accounts: readonly LimitAccount[],
+ now: number,
+): readonly LimitPool[] {
+ const byDriver = new Map();
+ for (const account of accounts) {
+ const list = byDriver.get(account.driver);
+ if (list) list.push(account);
+ else byDriver.set(account.driver, [account]);
+ }
+ return [...byDriver].map(([driver, members]) => {
+ const sorted = members.toSorted(
+ (left, right) =>
+ Number(left.redeem === null) - Number(right.redeem === null) ||
+ accountSortName(left).localeCompare(accountSortName(right)),
+ );
+ return { driver, accounts: sorted, windows: poolWindows(sorted, now) };
+ });
+}
+
+function accountSortName(account: LimitAccount): string {
+ return (account.displayName ?? account.email ?? account.key).toLowerCase();
+}
+
+function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] {
+ const byKey = new Map();
+ for (const account of accounts) {
+ for (const window of account.limits.windows) {
+ const key = `${window.kind}:${window.id}`;
+ const list = byKey.get(key);
+ if (list) list.push({ account, window });
+ else byKey.set(key, [{ account, window }]);
+ }
+ }
+ const pools = [...byKey.values()].map((unordered): LimitPoolWindow => {
+ const members = unordered.toSorted(
+ (left, right) =>
+ (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) -
+ (resetMillis(right.window) ?? Number.POSITIVE_INFINITY),
+ );
+ const first = members[0]!.window;
+ const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length;
+ // Pace compares spend against the clock, so it is judged only over the
+ // members that have a clock; a window with no reset would otherwise
+ // count as spend with no time elapsed and skew the verdict.
+ const timed = members.flatMap((m) => {
+ const share = elapsedShare(m.window, now);
+ return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }];
+ });
+ const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length;
+ const meanElapsed =
+ timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null;
+ const resets = members
+ .flatMap((member) => {
+ const at = resetMillis(member.window);
+ return at === null
+ ? []
+ : [
+ {
+ member,
+ at,
+ restoresPercent: Math.round(member.window.usedPercent / members.length),
+ },
+ ];
+ })
+ .toSorted((left, right) => left.at - right.at);
+ return {
+ id: first.id,
+ kind: first.kind,
+ label: first.label,
+ members,
+ usedPercent: Math.round(usedPercent),
+ remainingPercent: Math.round(100 - usedPercent),
+ pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed),
+ resets,
+ };
+ });
+ return pools.toSorted(
+ (left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind],
+ );
+}
+
/** The instance's configured name, else the driver's, else its raw kind. */
export function providerLimitsLabel(
provider: Pick,
@@ -195,8 +493,11 @@ export type LimitPace = "ahead" | "on" | "under";
*/
export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null {
const elapsed = elapsedShare(window, now);
- if (elapsed === null) return null;
- const gap = window.usedPercent - elapsed * 100;
+ return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed);
+}
+
+function paceOfShares(usedPercent: number, elapsed: number): LimitPace {
+ const gap = usedPercent - elapsed * 100;
if (gap > 5) return "ahead";
if (gap < -5) return "under";
return "on";