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 {
1818 limitsNotice,
1919 paceOf,
2020 providerLimitsLabel,
21 remainingPercent,
2122} from "@t3tools/shared/usageLimits";
2223import { type ReactNode, useState } from "react";
2324import { Alert, Pressable, View } from "react-native";
......@@ -44,9 +45,10 @@ function useBarColor(driver: Driver): string | null {
4445}
4546
4647/**
47 * One window as a bar spanning its whole duration: the fill is quota spent,
48 * the hairline is how far into the window the clock is. Pace sits under the
49 * left edge, the countdown under the right, so a row reads in one glance.
48 * One window as a bar spanning its whole duration: the fill is quota left,
49 * the hairline is how much of the window is left, so even spending keeps the
50 * fill on the line. Pace sits under the left edge, the countdown under the
51 * right, so a row reads in one glance.
5052 */
5153function WindowRow(props: {
5254 readonly window: ServerProviderUsageWindow;
......@@ -54,37 +56,40 @@ function WindowRow(props: {
5456 readonly now: number;
5557}) {
5658 const { window, now } = props;
57 const used = Math.round(Math.max(0, Math.min(100, window.usedPercent)));
59 const remaining = remainingPercent(window);
5860 const elapsed = elapsedShare(window, now);
61 const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100);
5962 const pace = paceOf(window, now);
6063 const resetsIn = formatResetsIn(window, now);
6164 return (
6265 <View className="gap-1">
6366 <View className="flex-row items-baseline justify-between gap-3">
6467 <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>
6671 </View>
6772 <View className="h-3 justify-center">
6873 <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle">
6974 <View
7075 className={
71 used >= 90
76 remaining <= 10
7277 ? "h-full rounded-full bg-red-500"
73 : used >= 70
78 : remaining <= 30
7479 ? "h-full rounded-full bg-amber-500"
7580 : "h-full rounded-full bg-foreground"
7681 }
7782 style={[
78 { flex: used },
79 used < 70 && props.color ? { backgroundColor: props.color } : null,
83 { flex: remaining },
84 remaining > 30 && props.color ? { backgroundColor: props.color } : null,
8085 ]}
8186 />
82 <View style={{ flex: 100 - used }} />
87 <View style={{ flex: 100 - remaining }} />
8388 </View>
84 {elapsed !== null ? (
89 {timeLeft !== null ? (
8590 <View
8691 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 }}
8893 />
8994 ) : null}
9095 </View>
apps/web/src/components/usage/UsageLimits.tsx+13-11
......@@ -20,6 +20,7 @@ import {
2020 type LimitPace,
2121 paceOf,
2222 providerLimitsLabel,
23 remainingPercent,
2324} from "@t3tools/shared/usageLimits";
2425import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react";
2526import { Fragment, useState } from "react";
......@@ -95,14 +96,16 @@ function WindowBar({
9596 readonly now: number;
9697}) {
9798 const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat);
98 const used = Math.max(0, Math.min(100, window.usedPercent));
99 const remaining = remainingPercent(window);
99100 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);
100103 const resetsIn = formatResetsIn(window, now);
101104 const resetsAt = window.resetsAt
102105 ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)
103106 : null;
104 const summary = `${window.label}: ${Math.round(used)}% used${
105 elapsed === null ? "" : `, ${Math.round(elapsed * 100)}% of the window elapsed`
107 const summary = `${window.label}: ${remaining}% left${
108 timeLeft === null ? "" : `, ${timeLeft}% of the window left`
106109 }${resetsIn ? `, ${resetsIn}` : ""}`;
107110
108111 return (
......@@ -118,27 +121,26 @@ function WindowBar({
118121 }
119122 >
120123 <div className="absolute inset-x-0 inset-y-1.5 rounded-full bg-muted" />
121 {used > 0 ? (
124 {remaining > 0 ? (
122125 <div
123126 className="absolute inset-y-1.5 left-0 rounded-full"
124 style={{ width: `${used}%`, backgroundColor: color }}
127 style={{ width: `${remaining}%`, backgroundColor: color }}
125128 />
126129 ) : null}
127 {elapsed !== null ? (
130 {timeLeft !== null ? (
128131 <span
129132 aria-hidden
130133 className="absolute inset-y-0.5 w-px -translate-x-1/2 bg-foreground/60"
131 style={{ left: `${elapsed * 100}%` }}
134 style={{ left: `${timeLeft}%` }}
132135 />
133136 ) : null}
134137 </TooltipTrigger>
135138 <TooltipPopup side="top" className="max-w-72 text-xs">
136139 <div className="flex flex-col gap-0.5">
137140 <span className="text-foreground">
138 {Math.round(used)}% used
139 {elapsed !== null ? ` · ${Math.round(elapsed * 100)}% of the window elapsed` : ""}
141 {remaining}% left{timeLeft !== null ? ` · ${timeLeft}% of the window left` : ""}
140142 </span>
141 {elapsed !== null ? (
143 {timeLeft !== null ? (
142144 <span className="text-muted-foreground">The line is where even spending would be.</span>
143145 ) : null}
144146 {resetsAt ? (
......@@ -185,7 +187,7 @@ export function LimitWindows({
185187 <span className="flex min-w-0 items-center gap-2 text-xs">
186188 <span className="truncate text-muted-foreground">{window.label}</span>
187189 <span className="ms-auto shrink-0 font-medium text-foreground tabular-nums">
188 {Math.round(window.usedPercent)}%
190 {remainingPercent(window)}% left
189191 </span>
190192 </span>
191193 <WindowBar color={color} window={window} now={now} />
docs/user/usage.md+3-3
......@@ -39,9 +39,9 @@ the dialog.
3939
4040## Track subscription limits
4141
42**Usage → Limits** shows quota use and reset times for Codex and Claude subscriptions. It also
43compares quota consumed with time elapsed in each window, so you can judge your pace before the
44next reset.
42**Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and
43Claude subscriptions. For windows with timing data, each bar also marks how much of the window is
44left, so you can judge your pace before the next reset.
4545
4646If 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 {
2020 limitsNotice,
2121 paceOf,
2222 providersWithLimits,
23 remainingPercent,
2324} from "./usageLimits.ts";
2425
2526const now = Date.parse("2026-09-03T12:00:00.000Z");
......@@ -493,6 +494,15 @@ describe("sameUsageLimitCommandCoverage", () => {
493494 });
494495});
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
496506describe("isUsageLimitsCommand", () => {
497507 it("recognizes only the standalone local action", () => {
498508 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 {
166166 return limits.windows.length === 0 ? "No limits reported." : null;
167167}
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
169174function resetMillis(window: ServerProviderUsageWindow): number | null {
170175 if (window.resetsAt === undefined) return null;
171176 const at = Date.parse(window.resetsAt);
......@@ -184,9 +189,9 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu
184189export type LimitPace = "ahead" | "on" | "under";
185190
186191/**
187 * Usage against the clock. The bar is the whole window, so the elapsed share
188 * is also where even spending would have put the fill; within five points of
189 * it counts as on pace.
192 * Usage against the clock. Spending evenly leaves the same share of quota as
193 * there is time left in the window; within five points of that counts as on
194 * pace, further ahead means the window may run dry first.
190195 */
191196export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null {
192197 const elapsed = elapsedShare(window, now);