Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -29,6 +30,191 @@ interface Props {
compact?: boolean;
}

const STATUS_RANK: Record<CardStatus, number> = {
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<string, any>
) {
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<string, any>,
providerLabels: Record<string, string>
) {
const groups = new Map<string, typeof connections>();
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<Props, "connections"> {
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 (
<details
open={open}
onToggle={(event) => setOpen((event.target as HTMLDetailsElement).open)}
className="rounded-lg border border-border bg-surface overflow-hidden"
>
<summary className="flex items-center gap-3 px-3 py-2.5 cursor-pointer select-none hover:bg-white/[0.03] [&::-webkit-details-marker]:hidden">
<span className="material-symbols-outlined text-[16px] text-text-muted">
{open ? "expand_less" : "expand_more"}
</span>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold text-text-main leading-5 truncate">
{providerLabel}
</h3>
<p className="text-[11px] text-text-muted tabular-nums">
{activeCount} active / {connections.length} account
{connections.length !== 1 ? "s" : ""}
</p>
</div>
</summary>
<div className="px-3 pb-3">
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
{connections.map((conn) => (
<QuotaCard
key={conn.id}
connection={conn}
quota={quotaData[conn.id]}
loading={!!loading[conn.id]}
error={errors[conn.id] || null}
refreshedAt={lastRefreshedAt[conn.id]}
emailsVisible={emailsVisible}
providerLabel={providerLabels[conn.provider] || conn.provider}
onRefresh={() => 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}
/>
))}
</div>
</div>
</details>
);
}

export default function QuotaCardGrid({
connections,
quotaData,
Expand Down Expand Up @@ -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<string, typeof connections>();
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 (
<div className="columns-1 2xl:columns-2 gap-6 [column-fill:_balance]">
{orderedProviders.map((provider) => {
const conns = groups.get(provider)!;
return (
<div key={provider} className="flex flex-col gap-3 break-inside-avoid mb-6">
<h3 className="text-sm font-semibold text-text-main flex items-center gap-2">
{providerLabels[provider] || provider}
<span className="text-xs font-normal text-text-muted">
({conns.length} account{conns.length !== 1 ? "s" : ""})
</span>
</h3>
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-3">
{conns.map(renderCard)}
</div>
</div>
);
})}
<div className="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-3">
{connections.map(renderCard)}
</div>
);
}

// 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 (
<div className="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-3">
{connections.map(renderCard)}
<div className="space-y-4">
{groups.map(({ provider, connections: conns }) => (
<ProviderQuotaSection
key={provider}
provider={provider}
connections={conns}
quotaData={quotaData}
loading={loading}
errors={errors}
lastRefreshedAt={lastRefreshedAt}
emailsVisible={emailsVisible}
providerLabels={providerLabels}
onRefresh={onRefresh}
onOpenCutoff={onOpenCutoff}
onOpenResetCredits={onOpenResetCredits}
onToggleActive={onToggleActive}
togglingActiveId={togglingActiveId}
quotaVisibility={quotaVisibility}
onHideQuota={onHideQuota}
onShowQuota={onShowQuota}
redeemingResetCreditId={redeemingResetCreditId}
loadingResetCreditsId={loadingResetCreditsId}
defaultOpen
/>
))}
</div>
);
}
64 changes: 51 additions & 13 deletions tests/unit/quota-card-grid-horizontal-layout.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <div className=...>");
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, /<details\b/, "expected each provider group to use <details>");
assert.match(sourceText, /<summary\b/, "expected each provider group to expose a summary row");
assert.match(sourceText, /\bdefaultOpen\b/, "expected provider sections to default open");
});

test("QuotaCardGrid — provider sections and accounts use quota monitoring priority", () => {
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", () => {
Expand Down