diff --git a/frontend/src/features/accounts/components/accounts-page.tsx b/frontend/src/features/accounts/components/accounts-page.tsx
index f1d5b293c..48b6cc1d1 100644
--- a/frontend/src/features/accounts/components/accounts-page.tsx
+++ b/frontend/src/features/accounts/components/accounts-page.tsx
@@ -10,6 +10,7 @@ import { AccountDetail } from "@/features/accounts/components/account-detail";
import { AccountList } from "@/features/accounts/components/account-list";
import { AccountsSkeleton } from "@/features/accounts/components/accounts-skeleton";
import { ImportDialog } from "@/features/accounts/components/import-dialog";
+import { ResetCreditConfirmDialog } from "@/features/accounts/components/reset-credit-confirm-dialog";
import { AuthExportDialog } from "@/features/accounts/components/auth-export-dialog";
import { useAccounts } from "@/features/accounts/hooks/use-accounts";
import {
@@ -53,6 +54,8 @@ export function AccountsPage() {
const importDialog = useDialogState();
const oauthDialog = useDialogState();
const deleteDialog = useDialogState();
+ type ResetCreditDialogTarget = { accountId: string; availableResetCredits: number };
+ const resetCreditDialog = useDialogState();
const exportDialog = useDialogState();
const [deleteHistory, setDeleteHistory] = useState(false);
@@ -178,6 +181,13 @@ export function AccountsPage() {
.then((result) => exportDialog.show(result))
.catch(() => null);
}}
+ onResetCredit={(accountId) => {
+ const account = accountsQuery.data?.find((item) => item.accountId === accountId);
+ resetCreditDialog.show({
+ accountId,
+ availableResetCredits: account?.availableResetCredits ?? 0,
+ });
+ }}
onLimitWarmupChange={(accountId, enabled) =>
void limitWarmupMutation.mutateAsync({ accountId, enabled })
}
@@ -235,6 +245,15 @@ export function AccountsPage() {
onOpenChange={exportDialog.onOpenChange}
/>
+ {resetCreditDialog.data ? (
+
+ ) : null}
+
({
+ toastSuccess: vi.fn(),
+ toastError: vi.fn(),
+}));
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: toastSuccess,
+ error: toastError,
+ },
+}));
+
+const SNAPSHOT_URL = "/api/accounts/acc_primary/rate-limit-reset-credits";
+const CONSUME_URL = "/api/accounts/acc_primary/rate-limit-reset-credits/consume";
+
+function createTestQueryClient() {
+ return new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+}
+
+function renderWithClient(ui: ReactElement) {
+ const queryClient = createTestQueryClient();
+ const renderResult = render(
+ {ui},
+ );
+ return { queryClient, ...renderResult };
+}
+
+function snapshotResponse() {
+ return HttpResponse.json({
+ availableCount: 1,
+ nearestExpiresAt: "2026-01-08T12:00:00.000Z",
+ credits: [
+ {
+ id: "credit_soonest",
+ status: "available",
+ resetType: "rate_limit_reset",
+ grantedAt: "2025-12-31T12:00:00.000Z",
+ expiresAt: "2026-01-08T12:00:00.000Z",
+ title: "Banked rate-limit reset",
+ description: "Redeems a reset of the soonest rate-limit window.",
+ redeemedAt: null,
+ redeemStartedAt: null,
+ },
+ ],
+ });
+}
+
+describe("ResetCreditConfirmDialog", () => {
+ it("confirms and consumes the soonest reset credit, then invalidates queries", async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ const consumeCalled = vi.fn();
+ server.use(
+ http.get(SNAPSHOT_URL, snapshotResponse),
+ http.post(CONSUME_URL, () => {
+ consumeCalled();
+ return HttpResponse.json({
+ code: "rate_limit_reset",
+ windowsReset: 1,
+ redeemedAt: "2026-01-01T12:00:00.000Z",
+ });
+ }),
+ );
+
+ const { queryClient } = renderWithClient(
+ ,
+ );
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ // Snapshot loads the available count and soonest credit expiry.
+ expect(await screen.findByText("1 free rate limit reset")).toBeInTheDocument();
+ expect(screen.getByText(/Reset expires on \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/)).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Redeem credit" }));
+
+ await vi.waitFor(() => expect(consumeCalled).toHaveBeenCalledTimes(1));
+ await vi.waitFor(() =>
+ expect(toastSuccess).toHaveBeenCalledWith("Rate-limit window reset (1)"),
+ );
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["accounts", "list"] });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["accounts", "trends"] });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["dashboard", "overview"] });
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["dashboard", "projections"] });
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+
+ it("surfaces an error toast and does not invalidate when consume fails", async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+ server.use(
+ http.get(SNAPSHOT_URL, snapshotResponse),
+ http.post(CONSUME_URL, () =>
+ HttpResponse.json(
+ {
+ error: {
+ code: "no_reset_credit_available",
+ message: "No reset credit available",
+ },
+ },
+ { status: 409 },
+ ),
+ ),
+ );
+
+ const { queryClient } = renderWithClient(
+ ,
+ );
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ expect(await screen.findByText("1 free rate limit reset")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Redeem credit" }));
+
+ await vi.waitFor(() =>
+ expect(toastError).toHaveBeenCalledWith("No reset credit available"),
+ );
+ expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["accounts", "list"] });
+ expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ["dashboard", "overview"] });
+ // Failure leaves the dialog open for retry.
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+
+ it("shows a loading state while the reset-credit snapshot is fetching", () => {
+ server.use(
+ http.get(SNAPSHOT_URL, async () => {
+ await new Promise(() => {});
+ return snapshotResponse();
+ }),
+ );
+
+ renderWithClient(
+ ,
+ );
+
+ expect(screen.getByText("Loading reset credit details...")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Redeem credit" })).toBeDisabled();
+ });
+
+ it("shows an error message and keeps confirm disabled when the snapshot fetch fails", async () => {
+ server.use(
+ http.get(SNAPSHOT_URL, () =>
+ HttpResponse.json(
+ {
+ error: {
+ code: "service_unavailable",
+ message: "Reset credits unavailable",
+ },
+ },
+ { status: 503 },
+ ),
+ ),
+ );
+
+ renderWithClient(
+ ,
+ );
+
+ expect(await screen.findByText("Reset credits unavailable")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Redeem credit" })).toBeDisabled();
+ });
+
+ it("handles a null snapshot response without allowing redeem", async () => {
+ server.use(http.get(SNAPSHOT_URL, () => HttpResponse.json(null)));
+
+ renderWithClient(
+ ,
+ );
+
+ expect(await screen.findByText("0 free rate limit resets")).toBeInTheDocument();
+ expect(screen.getByText("Reset credit details are not available yet.")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Redeem credit" })).toBeDisabled();
+ });
+
+ it("allows redeeming an available credit when expiry is null", async () => {
+ const user = userEvent.setup();
+ const consumeCalled = vi.fn();
+ server.use(
+ http.get(SNAPSHOT_URL, () =>
+ HttpResponse.json({
+ availableCount: 1,
+ nearestExpiresAt: null,
+ credits: [
+ {
+ id: "credit_no_expiry",
+ status: "available",
+ resetType: "rate_limit_reset",
+ grantedAt: "2025-12-31T12:00:00.000Z",
+ expiresAt: null,
+ title: "Persistent banked reset",
+ description: "Redeems a reset credit without an upstream expiry.",
+ redeemedAt: null,
+ redeemStartedAt: null,
+ },
+ ],
+ }),
+ ),
+ http.post(CONSUME_URL, () => {
+ consumeCalled();
+ return HttpResponse.json({
+ code: "rate_limit_reset",
+ windowsReset: 1,
+ redeemedAt: "2026-01-01T12:00:00.000Z",
+ });
+ }),
+ );
+
+ renderWithClient(
+ ,
+ );
+
+ expect(await screen.findByText("1 free rate limit reset")).toBeInTheDocument();
+ expect(screen.getByText("No upcoming expiry data available.")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("button", { name: "Redeem credit" }));
+
+ await vi.waitFor(() => expect(consumeCalled).toHaveBeenCalledTimes(1));
+ });
+
+ it("treats a loaded null snapshot as unavailable even when the summary count is stale", async () => {
+ server.use(
+ http.get(SNAPSHOT_URL, () => HttpResponse.json(null)),
+ );
+
+ renderWithClient(
+ ,
+ );
+
+ expect(await screen.findByText("0 free rate limit resets")).toBeInTheDocument();
+ expect(screen.getByText("Reset credit details are not available yet.")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Redeem credit" })).toBeDisabled();
+ });
+});
diff --git a/frontend/src/features/accounts/components/reset-credit-confirm-dialog.tsx b/frontend/src/features/accounts/components/reset-credit-confirm-dialog.tsx
new file mode 100644
index 000000000..ea4a8fc77
--- /dev/null
+++ b/frontend/src/features/accounts/components/reset-credit-confirm-dialog.tsx
@@ -0,0 +1,174 @@
+import { ConfirmDialog } from "@/components/confirm-dialog";
+import {
+ useAccountMutations,
+ useRateLimitResetCredits,
+} from "@/features/accounts/hooks/use-accounts";
+import type { RateLimitResetCreditItem } from "@/features/accounts/schemas";
+import { cn } from "@/lib/utils";
+import { getErrorMessage } from "@/utils/errors";
+import { formatLocalDateTimeSeconds, formatSingleUnitRemaining } from "@/utils/formatters";
+
+export type ResetCreditConfirmDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ accountId: string | null;
+ /** Count from account summary when the per-account cache GET has not populated yet. */
+ summaryAvailableCount?: number;
+};
+
+function pickSoonestAvailableCredit(
+ credits: RateLimitResetCreditItem[] | undefined,
+): RateLimitResetCreditItem | null {
+ if (!credits || credits.length === 0) {
+ return null;
+ }
+ const available = credits.filter((credit) => credit.status === "available");
+ if (available.length === 0) {
+ return null;
+ }
+ return available.reduce((soonest, credit) => {
+ const creditExpiresAt = credit.expiresAt
+ ? new Date(credit.expiresAt).getTime()
+ : Number.POSITIVE_INFINITY;
+ const soonestExpiresAt = soonest.expiresAt
+ ? new Date(soonest.expiresAt).getTime()
+ : Number.POSITIVE_INFINITY;
+ return creditExpiresAt < soonestExpiresAt ? credit : soonest;
+ });
+}
+
+function CreditExpiryLine({
+ expiresAt,
+ label,
+ suffix,
+ colorClass,
+}: {
+ expiresAt: string | null | undefined;
+ label: string;
+ suffix?: string;
+ colorClass?: string;
+}) {
+ if (!expiresAt) {
+ return {label}{suffix ? ` ${suffix}` : ""}
;
+ }
+ const countdown = formatSingleUnitRemaining(expiresAt);
+ return (
+
+ {label}{" "}
+ {formatLocalDateTimeSeconds(expiresAt)}{" "}
+
+ ({countdown.label})
+
+ {suffix ? ` ${suffix}` : ""}
+
+ );
+}
+
+export function ResetCreditConfirmDialog({
+ open,
+ onOpenChange,
+ accountId,
+ summaryAvailableCount = 0,
+}: ResetCreditConfirmDialogProps) {
+ const { resetCreditConsumeMutation } = useAccountMutations();
+ const snapshotQuery = useRateLimitResetCredits(accountId, open);
+ const snapshotLoading = snapshotQuery.isPending;
+ const snapshotError = snapshotQuery.isError;
+ const snapshotErrorMessage = getErrorMessage(
+ snapshotQuery.error,
+ "Failed to load reset credit details",
+ );
+ const soonest = pickSoonestAvailableCredit(snapshotQuery.data?.credits);
+ const otherCredits = (snapshotQuery.data?.credits ?? []).filter(
+ (c) => c.status === "available" && c.id !== soonest?.id,
+ );
+ const availableCount = snapshotQuery.isSuccess
+ ? (snapshotQuery.data?.availableCount ?? 0)
+ : summaryAvailableCount;
+ const pending = resetCreditConsumeMutation.isPending;
+ const confirmDisabled =
+ pending || !accountId || snapshotLoading || snapshotError || availableCount <= 0;
+
+ const handleConfirm = () => {
+ if (!accountId || pending) {
+ return;
+ }
+ void resetCreditConsumeMutation
+ .mutateAsync(accountId)
+ .then(() => {
+ onOpenChange(false);
+ })
+ .catch(() => {
+ // onError already surfaced a toast; leave the dialog open for retry.
+ });
+ };
+
+ const handleOpenChange = (next: boolean) => {
+ // Keep the dialog mounted while the redeem request is in-flight so the
+ // confirm button can render its gated state and the user can't dismiss
+ // mid-request. It closes once the promise settles.
+ if (!next && pending) {
+ return;
+ }
+ onOpenChange(next);
+ };
+
+ return (
+
+
+ {snapshotLoading ? (
+
Loading reset credit details...
+ ) : snapshotError ? (
+
{snapshotErrorMessage}
+ ) : (
+ <>
+
+ {availableCount} free rate limit reset{availableCount !== 1 ? "s" : ""}
+
+ {soonest ? (
+
+
+ {otherCredits.map((credit) => (
+
+ ))}
+ {!soonest.expiresAt && otherCredits.length === 0 ? (
+
No upcoming expiry data available.
+ ) : null}
+
+ ) : availableCount > 0 ? (
+
No upcoming expiry data available.
+ ) : snapshotQuery.data === null ? (
+
+ Reset credit details are not available yet.
+
+ ) : null}
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/features/accounts/hooks/use-accounts.ts b/frontend/src/features/accounts/hooks/use-accounts.ts
index 06be82872..616063bdf 100644
--- a/frontend/src/features/accounts/hooks/use-accounts.ts
+++ b/frontend/src/features/accounts/hooks/use-accounts.ts
@@ -2,14 +2,16 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
+ consumeRateLimitResetCredit,
deleteAccount,
exportAccountAuth,
getAccountTrends,
+ getRateLimitResetCredits,
importAccount,
listAccounts,
pauseAccount,
- reactivateAccount,
probeAccount,
+ reactivateAccount,
setAccountAlias,
updateAccount,
updateAccountLimitWarmup,
@@ -177,6 +179,24 @@ export function useAccountMutations() {
},
});
+ const resetCreditConsumeMutation = useMutation({
+ mutationFn: (accountId: string) => consumeRateLimitResetCredit(accountId),
+ onSuccess: (data) => {
+ const resetCount = data.windowsReset ?? 0;
+ toast.success(
+ `Rate-limit window${resetCount === 1 ? "" : "s"} reset (${resetCount})`,
+ );
+ void queryClient.invalidateQueries({ queryKey: ["accounts", "list"] });
+ void queryClient.invalidateQueries({ queryKey: ["accounts", "trends"] });
+ void queryClient.invalidateQueries({ queryKey: ["accounts", "reset-credits"] });
+ void queryClient.invalidateQueries({ queryKey: ["dashboard", "overview"] });
+ void queryClient.invalidateQueries({ queryKey: ["dashboard", "projections"] });
+ },
+ onError: (error: Error) => {
+ toast.error(error.message || "Reset credit redeem failed");
+ },
+ });
+
return {
importMutation,
pauseMutation,
@@ -188,9 +208,22 @@ export function useAccountMutations() {
limitWarmupMutation,
routingPolicyMutation,
updateMutation,
+ resetCreditConsumeMutation,
};
}
+export function useRateLimitResetCredits(
+ accountId: string | null,
+ enabled: boolean,
+) {
+ return useQuery({
+ queryKey: ["accounts", "reset-credits", accountId],
+ queryFn: () => getRateLimitResetCredits(accountId as string),
+ enabled: enabled && !!accountId,
+ staleTime: 0,
+ });
+}
+
export function useAccountTrends(accountId: string | null) {
return useQuery({
queryKey: ["accounts", "trends", accountId],
diff --git a/frontend/src/features/accounts/schemas.test.ts b/frontend/src/features/accounts/schemas.test.ts
index e98a105e9..014586d91 100644
--- a/frontend/src/features/accounts/schemas.test.ts
+++ b/frontend/src/features/accounts/schemas.test.ts
@@ -4,8 +4,10 @@ import {
AccountAuthExportResponseSchema,
AccountProbeResponseSchema,
AccountSummarySchema,
+ ConsumeRateLimitResetCreditResponseSchema,
ImportStateSchema,
OAuthStateSchema,
+ RateLimitResetCreditsSnapshotSchema,
} from "@/features/accounts/schemas";
const ISO = "2026-01-01T00:00:00+00:00";
@@ -187,3 +189,50 @@ describe("AccountProbeResponseSchema", () => {
expect(parsed.accountId).toBe("acc-1");
});
});
+
+describe("RateLimitResetCreditsSnapshotSchema", () => {
+ it("parses reset-credit items when nullable backend fields are omitted or null", () => {
+ const parsed = RateLimitResetCreditsSnapshotSchema.parse({
+ availableCount: 2,
+ nearestExpiresAt: null,
+ credits: [
+ {
+ id: "credit-1",
+ expiresAt: ISO,
+ },
+ {
+ id: "credit-2",
+ status: null,
+ expiresAt: null,
+ },
+ ],
+ });
+
+ expect(parsed.credits[0]?.status).toBeUndefined();
+ expect(parsed.credits[1]?.status).toBeNull();
+ });
+});
+
+describe("ConsumeRateLimitResetCreditResponseSchema", () => {
+ it("parses consume responses when nullable backend fields are omitted or null", () => {
+ expect(
+ ConsumeRateLimitResetCreditResponseSchema.parse({
+ redeemedAt: ISO,
+ }),
+ ).toMatchObject({
+ redeemedAt: ISO,
+ });
+
+ expect(
+ ConsumeRateLimitResetCreditResponseSchema.parse({
+ code: null,
+ windowsReset: null,
+ redeemedAt: null,
+ }),
+ ).toMatchObject({
+ code: null,
+ windowsReset: null,
+ redeemedAt: null,
+ });
+ });
+});
diff --git a/frontend/src/features/accounts/schemas.ts b/frontend/src/features/accounts/schemas.ts
index 08f7d874c..378b9cca3 100644
--- a/frontend/src/features/accounts/schemas.ts
+++ b/frontend/src/features/accounts/schemas.ts
@@ -91,6 +91,32 @@ export const AccountSummarySchema = z.object({
limitWarmupEnabled: z.boolean().default(false),
limitWarmup: AccountLimitWarmupStatusSchema.nullable().optional(),
isEmailDuplicate: z.boolean().optional(),
+ availableResetCredits: z.number().nullable().optional(),
+ resetCreditNearestExpiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
+});
+
+const RateLimitResetCreditItemSchema = z.object({
+ id: z.string(),
+ status: z.string().nullable().optional(),
+ resetType: z.string().nullable().optional(),
+ grantedAt: z.iso.datetime({ offset: true }).nullable().optional(),
+ expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
+ title: z.string().nullable().optional(),
+ description: z.string().nullable().optional(),
+ redeemedAt: z.iso.datetime({ offset: true }).nullable().optional(),
+ redeemStartedAt: z.iso.datetime({ offset: true }).nullable().optional(),
+});
+
+export const RateLimitResetCreditsSnapshotSchema = z.object({
+ availableCount: z.number(),
+ nearestExpiresAt: z.iso.datetime({ offset: true }).nullable(),
+ credits: z.array(RateLimitResetCreditItemSchema),
+});
+
+export const ConsumeRateLimitResetCreditResponseSchema = z.object({
+ code: z.string().nullable().optional(),
+ windowsReset: z.number().nullable().optional(),
+ redeemedAt: z.iso.datetime({ offset: true }).nullable(),
});
export const AccountTrendsResponseSchema = z.object({
@@ -281,6 +307,13 @@ export const ImportStateSchema = z.object({
export type UsageTrendPoint = z.infer;
export type AccountSummary = z.infer;
+export type RateLimitResetCreditItem = z.infer;
+export type RateLimitResetCreditsSnapshot = z.infer<
+ typeof RateLimitResetCreditsSnapshotSchema
+>;
+export type ConsumeRateLimitResetCreditResponse = z.infer<
+ typeof ConsumeRateLimitResetCreditResponseSchema
+>;
export type AccountRoutingPolicy = z.infer;
export type AccountAliasResponse = z.infer;
export type AccountLimitWarmupStatus = z.infer<
diff --git a/frontend/src/features/accounts/sorting.test.ts b/frontend/src/features/accounts/sorting.test.ts
new file mode 100644
index 000000000..27501faca
--- /dev/null
+++ b/frontend/src/features/accounts/sorting.test.ts
@@ -0,0 +1,103 @@
+import { describe, expect, it } from "vitest";
+
+import { DEFAULT_ACCOUNT_SORT_MODE, sortAccountsForDisplay } from "@/features/accounts/sorting";
+import type { AccountSummary } from "@/features/accounts/schemas";
+import { createAccountSummary } from "@/test/mocks/factories";
+
+const BOTH = "both";
+
+describe("sortAccountsForDisplay — most_reset_credits", () => {
+ it("uses most reset credits as the default sort mode", () => {
+ expect(DEFAULT_ACCOUNT_SORT_MODE).toBe("most_reset_credits");
+ });
+
+ it("orders accounts by available reset credits descending", () => {
+ const fewer = createAccountSummary({
+ accountId: "acc-fewer",
+ displayName: "Fewer",
+ availableResetCredits: 1,
+ });
+ const more = createAccountSummary({
+ accountId: "acc-more",
+ displayName: "More",
+ availableResetCredits: 4,
+ });
+
+ const sorted = sortAccountsForDisplay([fewer, more], BOTH, "most_reset_credits");
+
+ expect(sorted.map((account) => account.accountId)).toEqual([
+ "acc-more",
+ "acc-fewer",
+ ]);
+ });
+
+ it("breaks ties by soonest expiry ascending", () => {
+ const later = createAccountSummary({
+ accountId: "acc-later",
+ displayName: "Later",
+ availableResetCredits: 2,
+ resetCreditNearestExpiresAt: "2026-02-01T00:00:00.000Z",
+ });
+ const sooner = createAccountSummary({
+ accountId: "acc-sooner",
+ displayName: "Sooner",
+ availableResetCredits: 2,
+ resetCreditNearestExpiresAt: "2026-01-10T00:00:00.000Z",
+ });
+
+ const sorted = sortAccountsForDisplay([later, sooner], BOTH, "most_reset_credits");
+
+ expect(sorted.map((account) => account.accountId)).toEqual([
+ "acc-sooner",
+ "acc-later",
+ ]);
+ });
+
+ it("sorts accounts with null expiry after accounts that have one", () => {
+ const noExpiry = createAccountSummary({
+ accountId: "acc-no-expiry",
+ displayName: "No Expiry",
+ availableResetCredits: 3,
+ resetCreditNearestExpiresAt: null,
+ });
+ const withExpiry = createAccountSummary({
+ accountId: "acc-with-expiry",
+ displayName: "With Expiry",
+ availableResetCredits: 3,
+ resetCreditNearestExpiresAt: "2026-01-10T00:00:00.000Z",
+ });
+
+ const sorted = sortAccountsForDisplay(
+ [noExpiry, withExpiry],
+ BOTH,
+ "most_reset_credits",
+ ) as AccountSummary[];
+
+ expect(sorted.map((account) => account.accountId)).toEqual([
+ "acc-with-expiry",
+ "acc-no-expiry",
+ ]);
+ });
+
+ it("still sorts credits descending when both accounts have null expiry", () => {
+ const low = createAccountSummary({
+ accountId: "acc-low",
+ displayName: "Low",
+ availableResetCredits: 1,
+ resetCreditNearestExpiresAt: null,
+ });
+ const high = createAccountSummary({
+ accountId: "acc-high",
+ displayName: "High",
+ availableResetCredits: 5,
+ resetCreditNearestExpiresAt: null,
+ });
+
+ const sorted = sortAccountsForDisplay([low, high], BOTH, "most_reset_credits");
+
+ expect(sorted.map((account) => account.accountId)).toEqual([
+ "acc-high",
+ "acc-low",
+ ]);
+ });
+});
diff --git a/frontend/src/features/accounts/sorting.ts b/frontend/src/features/accounts/sorting.ts
index aa58e59e1..ab932ffb1 100644
--- a/frontend/src/features/accounts/sorting.ts
+++ b/frontend/src/features/accounts/sorting.ts
@@ -2,16 +2,22 @@ import type { AccountSummary } from "@/features/accounts/schemas";
import type { AccountQuotaDisplayPreference } from "@/hooks/use-account-quota-display";
import { parseDate } from "@/utils/formatters";
-export type AccountSortMode = "reset_soonest" | "reset_latest" | "name_asc" | "name_desc";
+export type AccountSortMode =
+ | "reset_soonest"
+ | "reset_latest"
+ | "name_asc"
+ | "name_desc"
+ | "most_reset_credits";
export const ACCOUNT_SORT_OPTIONS: readonly { value: AccountSortMode; label: string }[] = [
{ value: "reset_soonest", label: "Reset time (soonest)" },
{ value: "reset_latest", label: "Reset time (latest)" },
+ { value: "most_reset_credits", label: "Most reset credits" },
{ value: "name_asc", label: "Name (A-Z)" },
{ value: "name_desc", label: "Name (Z-A)" },
] as const;
-export const DEFAULT_ACCOUNT_SORT_MODE: AccountSortMode = "reset_soonest";
+export const DEFAULT_ACCOUNT_SORT_MODE: AccountSortMode = "most_reset_credits";
function visibleQuotaResetTimestamps(
account: AccountSummary,
@@ -50,6 +56,25 @@ function compareResetTimestamps(leftReset: number, rightReset: number, direction
return direction === "desc" ? rightReset - leftReset : leftReset - rightReset;
}
+function resetCreditNearestExpiry(account: AccountSummary): number {
+ const parsed = parseDate(account.resetCreditNearestExpiresAt);
+ return parsed ? parsed.getTime() : Number.POSITIVE_INFINITY;
+}
+
+function compareByResetCredits(left: AccountSummary, right: AccountSummary): number {
+ const leftCount = left.availableResetCredits ?? 0;
+ const rightCount = right.availableResetCredits ?? 0;
+ if (leftCount !== rightCount) {
+ return rightCount - leftCount;
+ }
+ // Tiebreak by soonest expiry ascending; null expiry (Infinity) sorts last.
+ return compareResetTimestamps(
+ resetCreditNearestExpiry(left),
+ resetCreditNearestExpiry(right),
+ "asc",
+ );
+}
+
export function sortAccountsForDisplay(
accounts: AccountSummary[],
quotaDisplay: AccountQuotaDisplayPreference,
@@ -58,7 +83,12 @@ export function sortAccountsForDisplay(
return accounts
.slice()
.sort((left, right) => {
- if (sortMode === "reset_latest" || sortMode === "reset_soonest") {
+ if (sortMode === "most_reset_credits") {
+ const creditComparison = compareByResetCredits(left, right);
+ if (creditComparison !== 0) {
+ return creditComparison;
+ }
+ } else if (sortMode === "reset_latest" || sortMode === "reset_soonest") {
const leftReset = accountResetTimestamp(left, quotaDisplay);
const rightReset = accountResetTimestamp(right, quotaDisplay);
const resetComparison = compareResetTimestamps(
diff --git a/frontend/src/features/apis/components/api-detail.tsx b/frontend/src/features/apis/components/api-detail.tsx
index 52b6f9a0c..8be0715cb 100644
--- a/frontend/src/features/apis/components/api-detail.tsx
+++ b/frontend/src/features/apis/components/api-detail.tsx
@@ -170,7 +170,7 @@ export function ApiDetail({
className={
hasDonutData
? "mt-4 border-t pt-4 lg:mt-0 lg:max-w-[75%] lg:flex-1 lg:border-t-0 lg:border-l lg:pl-4 lg:pt-0"
- : ""
+ : "lg:w-full"
}
data-testid="api-trend-panel"
>
diff --git a/frontend/src/features/auth/components/auth-gate.test.tsx b/frontend/src/features/auth/components/auth-gate.test.tsx
index 41133d17c..1251f2688 100644
--- a/frontend/src/features/auth/components/auth-gate.test.tsx
+++ b/frontend/src/features/auth/components/auth-gate.test.tsx
@@ -1,5 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { AuthGate } from "@/features/auth/components/auth-gate";
import { useAuthStore } from "@/features/auth/hooks/use-auth";
@@ -24,11 +24,16 @@ function setAuthState(
describe("AuthGate", () => {
beforeEach(() => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
setAuthState({
refreshSession: vi.fn().mockResolvedValue(undefined),
});
});
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
it("shows login form when unauthenticated", async () => {
const refreshSession = vi.fn().mockResolvedValue(undefined);
setAuthState({
diff --git a/frontend/src/features/dashboard/components/account-card.test.tsx b/frontend/src/features/dashboard/components/account-card.test.tsx
index 31f010003..06cae0835 100644
--- a/frontend/src/features/dashboard/components/account-card.test.tsx
+++ b/frontend/src/features/dashboard/components/account-card.test.tsx
@@ -1,5 +1,6 @@
import { act, render, screen } from "@testing-library/react";
-import { afterEach, describe, expect, it } from "vitest";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { AccountCard } from "@/features/dashboard/components/account-card";
import { usePrivacyStore } from "@/hooks/use-privacy";
@@ -108,4 +109,43 @@ describe("AccountCard", () => {
expect(screen.getByRole("button", { name: "Enable limit warm-up for Read Only Account" })).toBeDisabled();
});
+
+ it("shows reset action when reset credits are available", () => {
+ const account = createAccountSummary({
+ availableResetCredits: 2,
+ resetCreditNearestExpiresAt: "2026-01-03T12:00:00.000Z",
+ });
+
+ render();
+
+ expect(screen.getByRole("button", { name: "Reset (2)" })).toBeInTheDocument();
+ });
+
+ it("hides reset action when no reset credits are available", () => {
+ const account = createAccountSummary({ availableResetCredits: 0 });
+
+ render();
+
+ expect(screen.queryByRole("button", { name: /Reset \(/ })).not.toBeInTheDocument();
+ });
+
+ it("disables reset action for paused accounts", async () => {
+ const user = userEvent.setup();
+ const onAction = vi.fn();
+ const account = createAccountSummary({
+ accountId: "acc-paused",
+ displayName: "Paused Account",
+ status: "paused",
+ availableResetCredits: 1,
+ resetCreditNearestExpiresAt: "2026-01-03T12:00:00.000Z",
+ });
+
+ render();
+
+ const resetButton = screen.getByRole("button", { name: "Reset (1)" });
+ expect(resetButton).toBeDisabled();
+
+ await user.click(resetButton);
+ expect(onAction).not.toHaveBeenCalledWith(account, "reset-credit");
+ });
});
diff --git a/frontend/src/features/dashboard/components/account-card.tsx b/frontend/src/features/dashboard/components/account-card.tsx
index afeba5e25..9e9cb19b3 100644
--- a/frontend/src/features/dashboard/components/account-card.tsx
+++ b/frontend/src/features/dashboard/components/account-card.tsx
@@ -11,9 +11,15 @@ import {
quotaBarColor,
quotaBarTrack,
} from "@/utils/account-status";
-import { formatDateTimeInline, formatPercentNullable, formatQuotaResetLabel, formatSlug } from "@/utils/formatters";
+import {
+ formatDateTimeInline,
+ formatPercentNullable,
+ formatQuotaResetLabel,
+ formatSingleUnitRemaining,
+ formatSlug,
+} from "@/utils/formatters";
-export type AccountAction = "details" | "resume" | "reauth" | "warmup-toggle";
+export type AccountAction = "details" | "resume" | "reauth" | "warmup-toggle" | "reset-credit";
export type AccountCardProps = {
account: AccountSummary;
@@ -105,6 +111,22 @@ export function AccountCard({ account, showAccountId = false, readOnly = false,
const warmupDetail = account.limitWarmup
? `${formatSlug(account.limitWarmup.status)} | ${account.limitWarmup.window === "primary" ? "5h" : "weekly"} | ${formatSlug(account.limitWarmup.model)} | ${formatDateTimeInline(account.limitWarmup.completedAt ?? account.limitWarmup.attemptedAt)}`
: "No attempts";
+ const availableResetCredits = account.availableResetCredits ?? 0;
+ const hasResetCredits = availableResetCredits > 0;
+ const resetCreditDisabled =
+ readOnly || status === "paused" || status === "reauth" || status === "deactivated";
+ const resetCountdown = account.resetCreditNearestExpiresAt
+ ? formatSingleUnitRemaining(account.resetCreditNearestExpiresAt)
+ : null;
+ const resetButtonTitle = resetCreditDisabled
+ ? status === "paused"
+ ? "Resume account to redeem reset credits"
+ : status === "reauth" || status === "deactivated"
+ ? "Re-authenticate account to redeem reset credits"
+ : "Reset credits unavailable"
+ : resetCountdown
+ ? `Reset (${availableResetCredits}) · ${resetCountdown.label}`
+ : `Reset (${availableResetCredits})`;
return (
@@ -184,6 +206,31 @@ export function AccountCard({ account, showAccountId = false, readOnly = false,
Details
+ {hasResetCredits ? (
+
+ ) : null}
{status === "paused" && (