Skip to content

Commit 6b8ed4d

Browse files
committed
feat(web): render provider quota usage buckets in settings
1 parent b2025a3 commit 6b8ed4d

3 files changed

Lines changed: 174 additions & 0 deletions

File tree

apps/web/src/components/settings/SettingsPanels.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type ProviderKind,
1717
type ServerProvider,
1818
type ServerProviderModel,
19+
type ServerProviderUsageBucket,
1920
ThreadId,
2021
} from "@t3tools/contracts";
2122
import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
@@ -46,6 +47,11 @@ import {
4647
getCustomModelOptionsByProvider,
4748
resolveAppModelSelectionState,
4849
} from "../../modelSelection";
50+
import {
51+
formatUsageRemainingPercent,
52+
formatUsageResetAt,
53+
getProviderUsageBuckets,
54+
} from "../../lib/accountQuota";
4955
import { ensureNativeApi, readNativeApi } from "../../nativeApi";
5056
import { useStore } from "../../store";
5157
import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat";
@@ -60,6 +66,51 @@ import { toastManager } from "../ui/toast";
6066
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
6167
import { ProjectFavicon } from "../ProjectFavicon";
6268

69+
function ProviderUsageRows({
70+
buckets,
71+
timestampFormat,
72+
}: {
73+
buckets: ReadonlyArray<ServerProviderUsageBucket>;
74+
timestampFormat: "locale" | "12-hour" | "24-hour";
75+
}) {
76+
if (buckets.length === 0) {
77+
return null;
78+
}
79+
80+
return (
81+
<div className="px-4 pb-4 sm:px-5">
82+
<div className="space-y-3">
83+
{buckets.map((bucket) => (
84+
<div key={bucket.id} className="space-y-1.5">
85+
<div className="flex items-center justify-between gap-3">
86+
<span className="text-xs font-medium text-foreground">{bucket.label}</span>
87+
<span className="text-[11px] text-muted-foreground">
88+
{formatUsageRemainingPercent(bucket)}
89+
</span>
90+
</div>
91+
<div
92+
className="h-1.5 overflow-hidden rounded-full bg-muted"
93+
role="progressbar"
94+
aria-label={bucket.label}
95+
aria-valuemin={0}
96+
aria-valuemax={100}
97+
aria-valuenow={bucket.usedPercent}
98+
>
99+
<div
100+
className="h-full rounded-full bg-foreground/80 transition-[width]"
101+
style={{ width: `${bucket.usedPercent}%` }}
102+
/>
103+
</div>
104+
<div className="text-[11px] text-muted-foreground">
105+
{formatUsageResetAt(bucket.resetsAt, timestampFormat)}
106+
</div>
107+
</div>
108+
))}
109+
</div>
110+
</div>
111+
);
112+
}
113+
63114
const THEME_OPTIONS = [
64115
{
65116
value: "system",
@@ -1064,6 +1115,7 @@ export function GeneralSettingsPanel() {
10641115
const customModelError = customModelErrorByProvider[providerCard.provider] ?? null;
10651116
const providerDisplayName =
10661117
PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title;
1118+
const usageBuckets = getProviderUsageBuckets(providerCard.liveProvider);
10671119

10681120
return (
10691121
<div key={providerCard.provider} className="border-t border-border first:border-t-0">
@@ -1154,6 +1206,11 @@ export function GeneralSettingsPanel() {
11541206
</div>
11551207
</div>
11561208

1209+
<ProviderUsageRows
1210+
buckets={usageBuckets}
1211+
timestampFormat={settings.timestampFormat}
1212+
/>
1213+
11571214
<Collapsible
11581215
open={openProviderDetails[providerCard.provider]}
11591216
onOpenChange={(open) =>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { ServerProvider } from "@t3tools/contracts";
3+
4+
import {
5+
formatUsageRemainingPercent,
6+
formatUsageResetAt,
7+
getProviderUsageBuckets,
8+
} from "./accountQuota";
9+
10+
function makeProvider(
11+
input: Partial<ServerProvider> & Pick<ServerProvider, "provider">,
12+
): ServerProvider {
13+
return {
14+
enabled: true,
15+
installed: true,
16+
version: "0.117.0",
17+
status: "ready",
18+
auth: { status: "authenticated" },
19+
checkedAt: "2026-03-30T00:00:00.000Z",
20+
models: [],
21+
...input,
22+
};
23+
}
24+
25+
describe("accountQuota", () => {
26+
it("returns provider usage buckets in normalized order", () => {
27+
const buckets = getProviderUsageBuckets(
28+
makeProvider({
29+
provider: "codex",
30+
usage: {
31+
buckets: [
32+
{
33+
id: "fiveHour",
34+
label: "5 hour usage limit",
35+
remainingPercent: 67,
36+
usedPercent: 33,
37+
resetsAt: "2026-04-01T05:30:00.000Z",
38+
},
39+
{
40+
id: "weekly",
41+
label: "Weekly usage limit",
42+
remainingPercent: 71,
43+
usedPercent: 29,
44+
resetsAt: "2026-04-06T05:12:45.000Z",
45+
},
46+
],
47+
updatedAt: "2026-03-31T10:00:00.000Z",
48+
},
49+
}),
50+
);
51+
52+
expect(buckets).toHaveLength(2);
53+
expect(buckets[0]?.id).toBe("fiveHour");
54+
expect(buckets[1]?.id).toBe("weekly");
55+
});
56+
57+
it("formats compact remaining percentages", () => {
58+
expect(
59+
formatUsageRemainingPercent({
60+
id: "weekly",
61+
label: "Weekly usage limit",
62+
remainingPercent: 58,
63+
usedPercent: 42,
64+
resetsAt: "2026-04-06T05:12:45.000Z",
65+
}),
66+
).toBe("58% remaining");
67+
});
68+
69+
it("formats reset timestamps using the selected time format", () => {
70+
expect(formatUsageResetAt("2026-04-06T17:05:00.000Z", "24-hour")).not.toMatch(/[AP]M/);
71+
expect(formatUsageResetAt("2026-04-06T17:05:00.000Z", "12-hour")).toMatch(/[AP]M/);
72+
});
73+
74+
it("returns an empty list when usage is unavailable", () => {
75+
expect(getProviderUsageBuckets(makeProvider({ provider: "claudeAgent" }))).toEqual([]);
76+
});
77+
});

apps/web/src/lib/accountQuota.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type {
2+
ServerProvider,
3+
ServerProviderUsageBucket,
4+
TimestampFormat,
5+
} from "@t3tools/contracts";
6+
import { getTimestampFormatOptions } from "../timestampFormat";
7+
8+
export function getProviderUsageBuckets(
9+
provider: ServerProvider | null | undefined,
10+
): ReadonlyArray<ServerProviderUsageBucket> {
11+
return provider?.usage?.buckets ?? [];
12+
}
13+
14+
export function formatUsageRemainingPercent(bucket: ServerProviderUsageBucket): string {
15+
const rounded = Number.isInteger(bucket.remainingPercent)
16+
? bucket.remainingPercent
17+
: Number(bucket.remainingPercent.toFixed(1));
18+
return `${rounded}% remaining`;
19+
}
20+
21+
const usageResetFormatterCache = new Map<string, Intl.DateTimeFormat>();
22+
23+
function getUsageResetFormatter(timestampFormat: TimestampFormat): Intl.DateTimeFormat {
24+
const cached = usageResetFormatterCache.get(timestampFormat);
25+
if (cached) {
26+
return cached;
27+
}
28+
29+
const formatter = new Intl.DateTimeFormat(undefined, {
30+
month: "short",
31+
day: "numeric",
32+
...getTimestampFormatOptions(timestampFormat, false),
33+
});
34+
usageResetFormatterCache.set(timestampFormat, formatter);
35+
return formatter;
36+
}
37+
38+
export function formatUsageResetAt(isoDate: string, timestampFormat: TimestampFormat): string {
39+
return `Resets ${getUsageResetFormatter(timestampFormat).format(new Date(isoDate))}`;
40+
}

0 commit comments

Comments
 (0)