From 16b67f5f68a6fd2b3894a982dec783c201841b01 Mon Sep 17 00:00:00 2001 From: JK TAN Date: Tue, 11 Aug 2026 20:54:40 +0800 Subject: [PATCH] fix(dashboard): make quota providers expandable (#9025) --- .../ProviderLimits/QuotaCardGrid.tsx | 265 +++++++++++++++--- .../quota-card-grid-horizontal-layout.test.ts | 64 ++++- 2 files changed, 274 insertions(+), 55 deletions(-) diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx index e94be34c47..c59ef55d4d 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCardGrid.tsx @@ -1,9 +1,10 @@ "use client"; import type { ReactNode } from "react"; -import QuotaCard from "./QuotaCard"; +import { useState } from "react"; import { PROVIDER_ORDER } from "./constants"; -import { compareProviderGroups } from "./utils"; +import QuotaCard from "./QuotaCard"; +import { worstStatus, type CardStatus, compareProviderGroups } from "./utils"; import { compareTr } from "@/shared/utils/turkishText"; interface Props { @@ -29,6 +30,191 @@ interface Props { compact?: boolean; } +const STATUS_RANK: Record = { + critical: 0, + alert: 1, + ok: 2, + empty: 3, +}; + +function getSoonestResetMs(quotas: any[] | undefined): number { + if (!Array.isArray(quotas) || quotas.length === 0) return Number.POSITIVE_INFINITY; + const now = Date.now(); + let soonest = Number.POSITIVE_INFINITY; + for (const quota of quotas) { + if (!quota?.resetAt) continue; + const ts = new Date(quota.resetAt).getTime(); + if (Number.isFinite(ts) && ts > now && ts < soonest) soonest = ts; + } + return soonest; +} + +function getRemainingPercentage(quota: any): number { + if (quota?.unlimited) return 100; + if (typeof quota?.remainingPercentage === "number") return quota.remainingPercentage; + if (typeof quota?.total === "number" && quota.total > 0) { + const used = typeof quota.used === "number" ? quota.used : 0; + return Math.max(0, Math.min(100, Math.round(((quota.total - used) / quota.total) * 100))); + } + return 100; +} + +function getLowestRemainingPercentage(quotas: any[] | undefined): number { + if (!Array.isArray(quotas) || quotas.length === 0) return 100; + let lowest = 100; + for (const quota of quotas) { + lowest = Math.min(lowest, getRemainingPercentage(quota)); + } + return lowest; +} + +function hasUsableQuota(quotas: any[] | undefined): boolean { + if (!Array.isArray(quotas) || quotas.length === 0) return true; + return quotas.some((quota) => quota?.unlimited || getRemainingPercentage(quota) > 0); +} + +function getConnectionLabel(connection: any): string { + return String( + connection.name || connection.displayName || connection.email || connection.id || "" + ); +} + +export function sortProviderConnectionsByPriority( + connections: any[], + quotaData: Record +) { + return [...connections].sort((a, b) => { + const aActive = a.isActive ?? true; + const bActive = b.isActive ?? true; + if (aActive !== bActive) return aActive ? -1 : 1; + + const aQuotas = quotaData[a.id]?.quotas; + const bQuotas = quotaData[b.id]?.quotas; + const aUsable = hasUsableQuota(aQuotas); + const bUsable = hasUsableQuota(bQuotas); + if (aUsable !== bUsable) return aUsable ? -1 : 1; + + const statusDiff = STATUS_RANK[worstStatus(aQuotas)] - STATUS_RANK[worstStatus(bQuotas)]; + if (statusDiff !== 0) return statusDiff; + + const resetDiff = getSoonestResetMs(aQuotas) - getSoonestResetMs(bQuotas); + if (resetDiff !== 0) return resetDiff; + + const remainingDiff = + getLowestRemainingPercentage(aQuotas) - getLowestRemainingPercentage(bQuotas); + if (remainingDiff !== 0) return remainingDiff; + + return getConnectionLabel(a).localeCompare(getConnectionLabel(b)); + }); +} + +function buildProviderGroups( + connections: any[], + quotaData: Record, + providerLabels: Record +) { + const groups = new Map(); + for (const conn of connections) { + const list = groups.get(conn.provider) ?? []; + list.push(conn); + groups.set(conn.provider, list); + } + + return [...groups.entries()] + .map(([provider, conns]) => ({ + provider, + connections: sortProviderConnectionsByPriority(conns, quotaData), + })) + .sort((a, b) => + compareProviderGroups(a.provider, b.provider, { + providerOrder: PROVIDER_ORDER, + providerLabels, + compare: compareTr, + }) + ); +} + +interface ProviderQuotaSectionProps extends Omit { + provider: string; + connections: any[]; + defaultOpen?: boolean; +} + +function ProviderQuotaSection({ + provider, + connections, + defaultOpen = true, + quotaData, + loading, + errors, + lastRefreshedAt, + emailsVisible, + providerLabels, + onRefresh, + onOpenCutoff, + onOpenResetCredits, + onToggleActive, + togglingActiveId, + redeemingResetCreditId = null, + loadingResetCreditsId = null, + quotaVisibility, + onHideQuota, + onShowQuota, +}: ProviderQuotaSectionProps) { + const [open, setOpen] = useState(defaultOpen); + const activeCount = connections.filter((conn) => conn.isActive ?? true).length; + const providerLabel = providerLabels[provider] || provider; + + return ( +
setOpen((event.target as HTMLDetailsElement).open)} + className="rounded-lg border border-border bg-surface overflow-hidden" + > + + + {open ? "expand_less" : "expand_more"} + +
+

+ {providerLabel} +

+

+ {activeCount} active / {connections.length} account + {connections.length !== 1 ? "s" : ""} +

+
+
+
+
+ {connections.map((conn) => ( + onRefresh(conn.id, conn.provider)} + onOpenCutoff={() => onOpenCutoff(conn)} + onOpenResetCredits={() => onOpenResetCredits?.(conn.id, conn.provider)} + onToggleActive={(nextActive) => onToggleActive(conn.id, nextActive)} + togglingActive={togglingActiveId === conn.id} + redeemingResetCredit={redeemingResetCreditId === conn.id} + loadingResetCredits={loadingResetCreditsId === conn.id} + quotaVisibility={quotaVisibility} + onHideQuota={onHideQuota ? (q) => onHideQuota(conn.provider, q) : undefined} + onShowQuota={onShowQuota ? (q) => onShowQuota(conn.provider, q) : undefined} + /> + ))} +
+
+
+ ); +} + export default function QuotaCardGrid({ connections, quotaData, @@ -75,52 +261,47 @@ export default function QuotaCardGrid({ /> ); - // Default (non-compact) layout: group connections by provider (preserving - // in-group order), then order the groups deterministically: PROVIDER_ORDER - // rank → label (locale-aware) → key. Without this the group order followed - // first-appearance in the status/reset-sorted list, so groups shuffled - // whenever quota refreshed. - if (!compact) { - const groups = new Map(); - for (const conn of connections) { - const list = groups.get(conn.provider) ?? []; - list.push(conn); - groups.set(conn.provider, list); - } - const orderedProviders = [...groups.keys()].sort((a, b) => - compareProviderGroups(a, b, { - providerOrder: PROVIDER_ORDER, - providerLabels, - compare: compareTr, - }) - ); - + // Compact mode: flat 3-column card grid, across all connections. + if (compact) { return ( -
- {orderedProviders.map((provider) => { - const conns = groups.get(provider)!; - return ( -
-

- {providerLabels[provider] || provider} - - ({conns.length} account{conns.length !== 1 ? "s" : ""}) - -

-
- {conns.map(renderCard)} -
-
- ); - })} +
+ {connections.map(renderCard)}
); } - // Compact mode: flat 3-column card grid, across all connections. + // Default layout: expandable provider sections. Provider groups are ordered + // deterministically (PROVIDER_ORDER rank → label → key) so the group order + // never shuffles between quota refreshes, and connections are + // priority-sorted within each provider. + const groups = buildProviderGroups(connections, quotaData, providerLabels); + return ( -
- {connections.map(renderCard)} +
+ {groups.map(({ provider, connections: conns }) => ( + + ))}
); } diff --git a/tests/unit/quota-card-grid-horizontal-layout.test.ts b/tests/unit/quota-card-grid-horizontal-layout.test.ts index 447d6ddc33..e43dfad628 100644 --- a/tests/unit/quota-card-grid-horizontal-layout.test.ts +++ b/tests/unit/quota-card-grid-horizontal-layout.test.ts @@ -1,17 +1,16 @@ -// #3520 — Provider Quota page should use horizontal whitespace better. +// Provider Quota page should be easy to scan provider-by-provider. // -// QuotaCardGrid previously stacked provider groups vertically via a single -// `flex flex-col` container and sized card columns from fixed viewport -// breakpoints. This regression guard asserts the shipped JSX structure and -// grouping logic directly: +// QuotaCardGrid used to flow provider groups into CSS columns. That made +// high-account operators scan left/right across unrelated providers and made +// Codex/Claude quota priority hard to follow. This regression guard asserts +// the shipped JSX structure and grouping logic directly: // 1. Grouping still produces one header per distinct provider with the // correct account count ("N account(s)"). // 2. Each provider group's cards auto-fit into as many 280px columns as that // group's actual width supports, while a card can shrink to the group's // width when its container is narrower than 280px. -// 3. Provider groups themselves flow into multiple columns on wide screens -// (`columns-*`) instead of an unconditional vertical `flex flex-col` -// stack. +// 3. Provider groups themselves are one-column expandable sections, not a +// two-column masonry flow. // // Note: QuotaCardGrid's sibling QuotaCard pulls in next/image + provider-icon // resolution that only works inside the real Next.js runtime, so this file @@ -98,11 +97,50 @@ function extractDivClassNames(sourcePath: string): string[] { return classNames; } -test("QuotaCardGrid (#3520) — outer container flows groups into multiple columns, not a single vertical stack", () => { - const [outerClassName] = extractDivClassNames(COMPONENT_PATH); - assert.ok(outerClassName, "expected the component to render an outer
"); - assert.match(outerClassName, /\bcolumns-/); - assert.notEqual(outerClassName, "flex flex-col gap-6"); +test("QuotaCardGrid — outer provider sections stay in one vertical reading order", () => { + const classNames = extractDivClassNames(COMPONENT_PATH); + const outerClassName = classNames.find((className) => /\bspace-y-\d+\b/.test(className)); + assert.ok( + outerClassName, + "expected the component to render a vertical provider-section container" + ); + assert.doesNotMatch( + outerClassName!, + /\bcolumns-/, + "provider sections should not flow into two CSS columns" + ); + assert.match( + outerClassName, + /(?:^|\s)(?:space-y-\d+|flex)(?:\s|$)/, + "provider sections should render as one vertical list" + ); +}); + +test("QuotaCardGrid — provider groups are expandable details sections", () => { + const sourceText = fs.readFileSync(COMPONENT_PATH, "utf8"); + assert.match(sourceText, /"); + assert.match(sourceText, / { + const sourceText = fs.readFileSync(COMPONENT_PATH, "utf8"); + assert.match( + sourceText, + /\bPROVIDER_ORDER\b/, + "expected provider sections to use provider order" + ); + assert.match( + sourceText, + /\bsortProviderConnectionsByPriority\b/, + "expected accounts inside a provider to use a dedicated priority sort" + ); + assert.match(sourceText, /\bisActive\b/, "expected active accounts to sort before inactive ones"); + assert.match( + sourceText, + /\bgetSoonestResetMs\b/, + "expected account priority to consider the next quota reset" + ); }); test("QuotaCardGrid (#3520) — cards follow actual group width with a narrow-container fallback", () => {