| author | |
| committer | |
| log | be53bbd85044e937c359a89b6004c3d3c38ffcf1 |
| tree | 70c9df93df174b795e7065246edd8262c4c875b2 |
| parent | 585ce2c2ab396e4c3c5ffcfc422449c07896ddf9 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 18 | limitsNotice, |
| 19 | 19 | paceOf, |
| 20 | 20 | providerLimitsLabel, |
| 21 | remainingPercent, | |
| 21 | 22 | } from "@t3tools/shared/usageLimits"; |
| 22 | 23 | import { type ReactNode, useState } from "react"; |
| 23 | 24 | import { Alert, Pressable, View } from "react-native"; |
| ... | ... | @@ -44,9 +45,10 @@ function useBarColor(driver: Driver): string | null { |
| 44 | 45 | } |
| 45 | 46 | |
| 46 | 47 | /** |
| 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. | |
| 50 | 52 | */ |
| 51 | 53 | function WindowRow(props: { |
| 52 | 54 | readonly window: ServerProviderUsageWindow; |
| ... | ... | @@ -54,37 +56,40 @@ function WindowRow(props: { |
| 54 | 56 | readonly now: number; |
| 55 | 57 | }) { |
| 56 | 58 | const { window, now } = props; |
| 57 | const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); | |
| 59 | const remaining = remainingPercent(window); | |
| 58 | 60 | const elapsed = elapsedShare(window, now); |
| 61 | const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); | |
| 59 | 62 | const pace = paceOf(window, now); |
| 60 | 63 | const resetsIn = formatResetsIn(window, now); |
| 61 | 64 | return ( |
| 62 | 65 | <View className="gap-1"> |
| 63 | 66 | <View className="flex-row items-baseline justify-between gap-3"> |
| 64 | 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 | 71 | </View> |
| 67 | 72 | <View className="h-3 justify-center"> |
| 68 | 73 | <View className="h-1.5 flex-row overflow-hidden rounded-full bg-subtle"> |
| 69 | 74 | <View |
| 70 | 75 | className={ |
| 71 | used >= 90 | |
| 76 | remaining <= 10 | |
| 72 | 77 | ? "h-full rounded-full bg-red-500" |
| 73 | : used >= 70 | |
| 78 | : remaining <= 30 | |
| 74 | 79 | ? "h-full rounded-full bg-amber-500" |
| 75 | 80 | : "h-full rounded-full bg-foreground" |
| 76 | 81 | } |
| 77 | 82 | 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, | |
| 80 | 85 | ]} |
| 81 | 86 | /> |
| 82 | <View style={{ flex: 100 - used }} /> | |
| 87 | <View style={{ flex: 100 - remaining }} /> | |
| 83 | 88 | </View> |
| 84 | {elapsed !== null ? ( | |
| 89 | {timeLeft !== null ? ( | |
| 85 | 90 | <View |
| 86 | 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 | 94 | ) : null} |
| 90 | 95 | </View> |
apps/web/src/components/usage/UsageLimits.tsx+13-11| ... | ... | @@ -20,6 +20,7 @@ import { |
| 20 | 20 | type LimitPace, |
| 21 | 21 | paceOf, |
| 22 | 22 | providerLimitsLabel, |
| 23 | remainingPercent, | |
| 23 | 24 | } from "@t3tools/shared/usageLimits"; |
| 24 | 25 | import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; |
| 25 | 26 | import { Fragment, useState } from "react"; |
| ... | ... | @@ -95,14 +96,16 @@ function WindowBar({ |
| 95 | 96 | readonly now: number; |
| 96 | 97 | }) { |
| 97 | 98 | const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); |
| 98 | const used = Math.max(0, Math.min(100, window.usedPercent)); | |
| 99 | const remaining = remainingPercent(window); | |
| 99 | 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 | 103 | const resetsIn = formatResetsIn(window, now); |
| 101 | 104 | const resetsAt = window.resetsAt |
| 102 | 105 | ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) |
| 103 | 106 | : 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` | |
| 106 | 109 | }${resetsIn ? `, ${resetsIn}` : ""}`; |
| 107 | 110 | |
| 108 | 111 | return ( |
| ... | ... | @@ -118,27 +121,26 @@ function WindowBar({ |
| 118 | 121 | } |
| 119 | 122 | > |
| 120 | 123 | <div className="absolute inset-x-0 inset-y-1.5 rounded-full bg-muted" /> |
| 121 | {used > 0 ? ( | |
| 124 | {remaining > 0 ? ( | |
| 122 | 125 | <div |
| 123 | 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 | 129 | ) : null} |
| 127 | {elapsed !== null ? ( | |
| 130 | {timeLeft !== null ? ( | |
| 128 | 131 | <span |
| 129 | 132 | aria-hidden |
| 130 | 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 | 136 | ) : null} |
| 134 | 137 | </TooltipTrigger> |
| 135 | 138 | <TooltipPopup side="top" className="max-w-72 text-xs"> |
| 136 | 139 | <div className="flex flex-col gap-0.5"> |
| 137 | 140 | <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` : ""} | |
| 140 | 142 | </span> |
| 141 | {elapsed !== null ? ( | |
| 143 | {timeLeft !== null ? ( | |
| 142 | 144 | <span className="text-muted-foreground">The line is where even spending would be.</span> |
| 143 | 145 | ) : null} |
| 144 | 146 | {resetsAt ? ( |
| ... | ... | @@ -185,7 +187,7 @@ export function LimitWindows({ |
| 185 | 187 | <span className="flex min-w-0 items-center gap-2 text-xs"> |
| 186 | 188 | <span className="truncate text-muted-foreground">{window.label}</span> |
| 187 | 189 | <span className="ms-auto shrink-0 font-medium text-foreground tabular-nums"> |
| 188 | {Math.round(window.usedPercent)}% | |
| 190 | {remainingPercent(window)}% left | |
| 189 | 191 | </span> |
| 190 | 192 | </span> |
| 191 | 193 | <WindowBar color={color} window={window} now={now} /> |
docs/user/usage.md+3-3| ... | ... | @@ -39,9 +39,9 @@ the dialog. |
| 39 | 39 | |
| 40 | 40 | ## Track subscription limits |
| 41 | 41 | |
| 42 | **Usage → Limits** shows quota use and reset times for Codex and Claude subscriptions. It also | |
| 43 | compares quota consumed with time elapsed in each window, so you can judge your pace before the | |
| 44 | next reset. | |
| 42 | **Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and | |
| 43 | Claude subscriptions. For windows with timing data, each bar also marks how much of the window is | |
| 44 | left, so you can judge your pace before the next reset. | |
| 45 | 45 | |
| 46 | 46 | If a window looks stale, refresh Limits to re-check every provider and hub. |
| 47 | 47 |
packages/shared/src/usageLimits.test.ts+10| ... | ... | @@ -20,6 +20,7 @@ import { |
| 20 | 20 | limitsNotice, |
| 21 | 21 | paceOf, |
| 22 | 22 | providersWithLimits, |
| 23 | remainingPercent, | |
| 23 | 24 | } from "./usageLimits.ts"; |
| 24 | 25 | |
| 25 | 26 | const now = Date.parse("2026-09-03T12:00:00.000Z"); |
| ... | ... | @@ -493,6 +494,15 @@ describe("sameUsageLimitCommandCoverage", () => { |
| 493 | 494 | }); |
| 494 | 495 | }); |
| 495 | 496 | |
| 497 | describe("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 | ||
| 496 | 506 | describe("isUsageLimitsCommand", () => { |
| 497 | 507 | it("recognizes only the standalone local action", () => { |
| 498 | 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 | 166 | return limits.windows.length === 0 ? "No limits reported." : null; |
| 167 | 167 | } |
| 168 | 168 | |
| 169 | /** Quota left in the window, 0..100. Bars and labels show what remains, as Codex does. */ | |
| 170 | export function remainingPercent(window: ServerProviderUsageWindow): number { | |
| 171 | return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent))); | |
| 172 | } | |
| 173 | ||
| 169 | 174 | function resetMillis(window: ServerProviderUsageWindow): number | null { |
| 170 | 175 | if (window.resetsAt === undefined) return null; |
| 171 | 176 | const at = Date.parse(window.resetsAt); |
| ... | ... | @@ -184,9 +189,9 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu |
| 184 | 189 | export type LimitPace = "ahead" | "on" | "under"; |
| 185 | 190 | |
| 186 | 191 | /** |
| 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. | |
| 190 | 195 | */ |
| 191 | 196 | export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { |
| 192 | 197 | const elapsed = elapsedShare(window, now); |