authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-09-06 00:22:15+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-05 16:22:15-07:00
logbe53bbd85044e937c359a89b6004c3d3c38ffcf1
tree70c9df93df174b795e7065246edd8262c4c875b2
parent585ce2c2ab396e4c3c5ffcfc422449c07896ddf9
signature Signed by PGP key B5690EEEBB952194

feat(usage): show remaining quota instead of used (#9889)

Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com>

5 files changed, 51 insertions(+), 29 deletions(-)

apps/mobile/src/features/usage/UsageLimitsSection.tsx+17-12
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18 limitsNotice,18 limitsNotice,
19 paceOf,19 paceOf,
20 providerLimitsLabel,20 providerLimitsLabel,
21 remainingPercent,
21} from "@t3tools/shared/usageLimits";22} from "@t3tools/shared/usageLimits";
22import { type ReactNode, useState } from "react";23import { type ReactNode, useState } from "react";
23import { Alert, Pressable, View } from "react-native";24import { Alert, Pressable, View } from "react-native";
...@@ -44,9 +45,10 @@ function useBarColor(driver: Driver): string | null {...@@ -44,9 +45,10 @@ function useBarColor(driver: Driver): string | null {
44}45}
4546
46/**47/**
47 * One window as a bar spanning its whole duration: the fill is quota spent,48 * One window as a bar spanning its whole duration: the fill is quota left,
48 * the hairline is how far into the window the clock is. Pace sits under the49 * the hairline is how much of the window is left, so even spending keeps the
49 * left edge, the countdown under the right, so a row reads in one glance.50 * fill on the line. Pace sits under the left edge, the countdown under the
51 * right, so a row reads in one glance.
50 */52 */
51function WindowRow(props: {53function WindowRow(props: {
52 readonly window: ServerProviderUsageWindow;54 readonly window: ServerProviderUsageWindow;
...@@ -54,37 +56,40 @@ function WindowRow(props: {...@@ -54,37 +56,40 @@ function WindowRow(props: {
54 readonly now: number;56 readonly now: number;
55}) {57}) {
56 const { window, now } = props;58 const { window, now } = props;
57 const used = Math.round(Math.max(0, Math.min(100, window.usedPercent)));59 const remaining = remainingPercent(window);
58 const elapsed = elapsedShare(window, now);60 const elapsed = elapsedShare(window, now);
61 const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100);
59 const pace = paceOf(window, now);62 const pace = paceOf(window, now);
60 const resetsIn = formatResetsIn(window, now);63 const resetsIn = formatResetsIn(window, now);
61 return (64 return (
62 <View className="gap-1">65 <View className="gap-1">
63 <View className="flex-row items-baseline justify-between gap-3">66 <View className="flex-row items-baseline justify-between gap-3">
64 <Text className="text-sm text-foreground">{window.label}</Text>67 <Text className="text-sm text-foreground">{window.label}</Text>
65 <Text className="text-sm font-t3-medium tabular-nums text-foreground">{used}%</Text>68 <Text className="text-sm font-t3-medium tabular-nums text-foreground">
69 {remaining}% left
70 </Text>
66 </View>71 </View>
67 <View className="h-3 justify-center">72 <View className="h-3 justify-center">
68 <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle">73 <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle">
69 <View74 <View
70 className={75 className={
71 used >= 9076 remaining <= 10
72 ? "h-full rounded-full bg-red-500"77 ? "h-full rounded-full bg-red-500"
73 : used >= 7078 : remaining <= 30
74 ? "h-full rounded-full bg-amber-500"79 ? "h-full rounded-full bg-amber-500"
75 : "h-full rounded-full bg-foreground"80 : "h-full rounded-full bg-foreground"
76 }81 }
77 style={[82 style={[
78 { flex: used },83 { flex: remaining },
79 used < 70 && props.color ? { backgroundColor: props.color } : null,84 remaining > 30 && props.color ? { backgroundColor: props.color } : null,
80 ]}85 ]}
81 />86 />
82 <View style={{ flex: 100 - used }} />87 <View style={{ flex: 100 - remaining }} />
83 </View>88 </View>
84 {elapsed !== null ? (89 {timeLeft !== null ? (
85 <View90 <View
86 className="absolute top-0 bottom-0 w-px bg-foreground"91 className="absolute top-0 bottom-0 w-px bg-foreground"
87 style={{ left: `${elapsed * 100}%`, opacity: 0.6 }}92 style={{ left: `${timeLeft}%`, opacity: 0.6 }}
88 />93 />
89 ) : null}94 ) : null}
90 </View>95 </View>
apps/web/src/components/usage/UsageLimits.tsx+13-11
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 type LimitPace,20 type LimitPace,
21 paceOf,21 paceOf,
22 providerLimitsLabel,22 providerLimitsLabel,
23 remainingPercent,
23} from "@t3tools/shared/usageLimits";24} from "@t3tools/shared/usageLimits";
24import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";25import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
25import { Fragment, useState } from "react";26import { Fragment, useState } from "react";
...@@ -95,14 +96,16 @@ function WindowBar({...@@ -95,14 +96,16 @@ function WindowBar({
95 readonly now: number;96 readonly now: number;
96}) {97}) {
97 const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);98 const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
98 const used = Math.max(0, Math.min(100, window.usedPercent));99 const remaining = remainingPercent(window);
99 const elapsed = elapsedShare(window, now);100 const elapsed = elapsedShare(window, now);
101 // The fill is quota left, so the even-spending mark is the time left.
102 const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100);
100 const resetsIn = formatResetsIn(window, now);103 const resetsIn = formatResetsIn(window, now);
101 const resetsAt = window.resetsAt104 const resetsAt = window.resetsAt
102 ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)105 ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)
103 : null;106 : null;
104 const summary = `${window.label}: ${Math.round(used)}% used${107 const summary = `${window.label}: ${remaining}% left${
105 elapsed === null ? "" : `, ${Math.round(elapsed * 100)}% of the window elapsed`108 timeLeft === null ? "" : `, ${timeLeft}% of the window left`
106 }${resetsIn ? `, ${resetsIn}` : ""}`;109 }${resetsIn ? `, ${resetsIn}` : ""}`;
107110
108 return (111 return (
...@@ -118,27 +121,26 @@ function WindowBar({...@@ -118,27 +121,26 @@ function WindowBar({
118 }121 }
119 >122 >
120 <div className="absolute inset-x-0 inset-y-1.5 rounded-full bg-muted" />123 <div className="absolute inset-x-0 inset-y-1.5 rounded-full bg-muted" />
121 {used > 0 ? (124 {remaining > 0 ? (
122 <div125 <div
123 className="absolute inset-y-1.5 left-0 rounded-full"126 className="absolute inset-y-1.5 left-0 rounded-full"
124 style={{ width: `${used}%`, backgroundColor: color }}127 style={{ width: `${remaining}%`, backgroundColor: color }}
125 />128 />
126 ) : null}129 ) : null}
127 {elapsed !== null ? (130 {timeLeft !== null ? (
128 <span131 <span
129 aria-hidden132 aria-hidden
130 className="absolute inset-y-0.5 w-px -translate-x-1/2 bg-foreground/60"133 className="absolute inset-y-0.5 w-px -translate-x-1/2 bg-foreground/60"
131 style={{ left: `${elapsed * 100}%` }}134 style={{ left: `${timeLeft}%` }}
132 />135 />
133 ) : null}136 ) : null}
134 </TooltipTrigger>137 </TooltipTrigger>
135 <TooltipPopup side="top" className="max-w-72 text-xs">138 <TooltipPopup side="top" className="max-w-72 text-xs">
136 <div className="flex flex-col gap-0.5">139 <div className="flex flex-col gap-0.5">
137 <span className="text-foreground">140 <span className="text-foreground">
138 {Math.round(used)}% used141 {remaining}% left{timeLeft !== null ? ` · ${timeLeft}% of the window left` : ""}
139 {elapsed !== null ? ` · ${Math.round(elapsed * 100)}% of the window elapsed` : ""}
140 </span>142 </span>
141 {elapsed !== null ? (143 {timeLeft !== null ? (
142 <span className="text-muted-foreground">The line is where even spending would be.</span>144 <span className="text-muted-foreground">The line is where even spending would be.</span>
143 ) : null}145 ) : null}
144 {resetsAt ? (146 {resetsAt ? (
...@@ -185,7 +187,7 @@ export function LimitWindows({...@@ -185,7 +187,7 @@ export function LimitWindows({
185 <span className="flex min-w-0 items-center gap-2 text-xs">187 <span className="flex min-w-0 items-center gap-2 text-xs">
186 <span className="truncate text-muted-foreground">{window.label}</span>188 <span className="truncate text-muted-foreground">{window.label}</span>
187 <span className="ms-auto shrink-0 font-medium text-foreground tabular-nums">189 <span className="ms-auto shrink-0 font-medium text-foreground tabular-nums">
188 {Math.round(window.usedPercent)}%190 {remainingPercent(window)}% left
189 </span>191 </span>
190 </span>192 </span>
191 <WindowBar color={color} window={window} now={now} />193 <WindowBar color={color} window={window} now={now} />
docs/user/usage.md+3-3
...@@ -39,9 +39,9 @@ the dialog....@@ -39,9 +39,9 @@ the dialog.
3939
40## Track subscription limits40## Track subscription limits
4141
42**Usage → Limits** shows quota use and reset times for Codex and Claude subscriptions. It also42**Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and
43compares quota consumed with time elapsed in each window, so you can judge your pace before the43Claude subscriptions. For windows with timing data, each bar also marks how much of the window is
44next reset.44left, so you can judge your pace before the next reset.
4545
46If a window looks stale, refresh Limits to re-check every provider and hub.46If a window looks stale, refresh Limits to re-check every provider and hub.
4747
packages/shared/src/usageLimits.test.ts+10
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 limitsNotice,20 limitsNotice,
21 paceOf,21 paceOf,
22 providersWithLimits,22 providersWithLimits,
23 remainingPercent,
23} from "./usageLimits.ts";24} from "./usageLimits.ts";
2425
25const now = Date.parse("2026-09-03T12:00:00.000Z");26const now = Date.parse("2026-09-03T12:00:00.000Z");
...@@ -493,6 +494,15 @@ describe("sameUsageLimitCommandCoverage", () => {...@@ -493,6 +494,15 @@ describe("sameUsageLimitCommandCoverage", () => {
493 });494 });
494});495});
495496
497describe("remainingPercent", () => {
498 it("inverts and clamps the reported usage", () => {
499 expect(remainingPercent(window)).toBe(60);
500 expect(remainingPercent({ ...window, usedPercent: 0 })).toBe(100);
501 expect(remainingPercent({ ...window, usedPercent: 100 })).toBe(0);
502 expect(remainingPercent({ ...window, usedPercent: 33.4 })).toBe(67);
503 });
504});
505
496describe("isUsageLimitsCommand", () => {506describe("isUsageLimitsCommand", () => {
497 it("recognizes only the standalone local action", () => {507 it("recognizes only the standalone local action", () => {
498 expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true);508 expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true);
packages/shared/src/usageLimits.ts+8-3
...@@ -166,6 +166,11 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null {...@@ -166,6 +166,11 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null {
166 return limits.windows.length === 0 ? "No limits reported." : null;166 return limits.windows.length === 0 ? "No limits reported." : null;
167}167}
168168
169/** Quota left in the window, 0..100. Bars and labels show what remains, as Codex does. */
170export function remainingPercent(window: ServerProviderUsageWindow): number {
171 return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent)));
172}
173
169function resetMillis(window: ServerProviderUsageWindow): number | null {174function resetMillis(window: ServerProviderUsageWindow): number | null {
170 if (window.resetsAt === undefined) return null;175 if (window.resetsAt === undefined) return null;
171 const at = Date.parse(window.resetsAt);176 const at = Date.parse(window.resetsAt);
...@@ -184,9 +189,9 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu...@@ -184,9 +189,9 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu
184export type LimitPace = "ahead" | "on" | "under";189export type LimitPace = "ahead" | "on" | "under";
185190
186/**191/**
187 * Usage against the clock. The bar is the whole window, so the elapsed share192 * Usage against the clock. Spending evenly leaves the same share of quota as
188 * is also where even spending would have put the fill; within five points of193 * there is time left in the window; within five points of that counts as on
189 * it counts as on pace.194 * pace, further ahead means the window may run dry first.
190 */195 */
191export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null {196export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null {
192 const elapsed = elapsedShare(window, now);197 const elapsed = elapsedShare(window, now);