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
Expand Up @@ -2,6 +2,9 @@

import type { ReactNode } from "react";
import QuotaCard from "./QuotaCard";
import { PROVIDER_ORDER } from "./constants";
import { compareProviderGroups } from "./utils";
import { compareTr } from "@/shared/utils/turkishText";

interface Props {
connections: any[];
Expand Down Expand Up @@ -47,50 +50,63 @@ export default function QuotaCardGrid({
}: Props) {
if (connections.length === 0) return null;

// Group connections by provider, preserving the order from sortedConnections.
// 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.
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,
})
);

return (
<div className="columns-1 2xl:columns-2 gap-6 [column-fill:_balance]">
{[...groups.entries()].map(([provider, conns]) => (
<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((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}
/>
))}
{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((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>
</div>
))}
);
})}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
calculatePercentage,
matchesProviderFilter,
buildProviderOptions,
compareQuotaConnections,
} from "./utils";
import Card from "@/shared/components/Card";
import { CardSkeleton } from "@/shared/components/Loading";
Expand Down Expand Up @@ -529,8 +530,12 @@ export default function ProviderLimits({
);

const sortedConnections = useMemo(() => {
return [...filteredConnections].sort(
(a, b) => (PROVIDER_ORDER[a.provider] || 99) - (PROVIDER_ORDER[b.provider] || 99)
return [...filteredConnections].sort((a, b) =>
compareQuotaConnections(a, b, {
providerOrder: PROVIDER_ORDER,
providerLabels: PROVIDER_LABEL,
compare: compareTr,
})
);
}, [filteredConnections]);
const visibleQuotaData = useVisibleQuotaData(sortedConnections, quotaData);
Expand Down Expand Up @@ -653,24 +658,32 @@ export default function ProviderLimits({
return true;
});

// Inside each group we still want "critical first, then alert, then ok,
// then empty; tiebreak by soonest reset". Provider order between groups
// is enforced separately via PROVIDER_ORDER.
// Provider rank stays the outer sort key so each group keeps its fixed
// slot (mirrors dashboard/providers determinism); "critical first, then
// alert, then ok, then empty; tiebreak by soonest reset" only orders
// accounts inside their own provider group.
const statusRank: Record<StatusKey, number> = {
critical: 0,
alert: 1,
ok: 2,
empty: 3,
all: 4,
};
return [...filtered].sort((a, b) => {
const sa = statusRank[statusByConnection[a.id] || "empty"];
const sb = statusRank[statusByConnection[b.id] || "empty"];
if (sa !== sb) return sa - sb;
const ra = getSoonestResetMs(visibleQuotaData[a.id]?.quotas);
const rb = getSoonestResetMs(visibleQuotaData[b.id]?.quotas);
return ra - rb;
});
return [...filtered].sort((a, b) =>
compareQuotaConnections(a, b, {
providerOrder: PROVIDER_ORDER,
providerLabels: PROVIDER_LABEL,
compare: compareTr,
accountCompare: (x, y) => {
const sx = statusRank[statusByConnection[x.id] || "empty"];
const sy = statusRank[statusByConnection[y.id] || "empty"];
if (sx !== sy) return sx - sy;
const rx = getSoonestResetMs(visibleQuotaData[x.id]?.quotas);
const ry = getSoonestResetMs(visibleQuotaData[y.id]?.quotas);
return rx - ry;
},
})
);
}, [
sortedConnections,
tierByConnection,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -595,3 +595,110 @@ export function buildProviderOptions(
}
return Array.from(seen).sort(compare);
}

// --- Deterministic quota-card ordering -------------------------------------
// Mirrors the dashboard/providers rule (`providerPageUtils.ts::
// sortProviderEntriesByName`): every level of ordering must end in a stable,
// data-independent tiebreak so cards never re-flow between refreshes.
//
// Before this, `visibleConnections` globally sorted ALL connections by
// status then soonest reset, and QuotaCardGrid grouped by first-appearance —
// so each provider group's position was decided by whichever of its accounts
// happened to sort first (status/reset change every refresh → groups
// shuffled). Provider rank is now a sort key again, so a group's position is
// fixed by PROVIDER_ORDER and account status/reset only orders accounts
// inside their own group.

export interface QuotaOrderConnection {
id?: unknown;
provider?: unknown;
name?: unknown;
email?: unknown;
displayName?: unknown;
}

/** Label/name key: providers-page `getProviderSortLabel` — case-insensitive display name. */
function quotaConnLabel(conn: QuotaOrderConnection): string {
const name = typeof conn.name === "string" ? conn.name : "";
const provider = typeof conn.provider === "string" ? conn.provider : "";
return (name || provider).toLowerCase();
}

/** Technical tiebreak key: providers-page `providerId.localeCompare(...)` — ASCII on purpose. */
function quotaConnTiebreak(conn: QuotaOrderConnection): string {
const email = typeof conn.email === "string" ? conn.email : "";
const id = typeof conn.id === "string" ? conn.id : String(conn.id ?? "");
return email || id;
}

function providerRank(provider: unknown, providerOrder: Record<string, number>): number {
const key = typeof provider === "string" ? provider : "";
return providerOrder[key] ?? 99;
}

/**
* Order connections for the quota card grid. Levels (first non-zero wins):
* 1. `PROVIDER_ORDER` rank — keeps each provider group glued to its fixed slot.
* 2. Provider label (locale-aware, case-insensitive) — orders unranked providers.
* 3. Provider key ASCII — deterministic tiebreak between aliased/equal labels.
* 4. `accountCompare` (optional) — in-group intent (critical-first, soonest reset).
* 5. Account label, then email/id ASCII — so equal-status accounts never shuffle.
*/
export function compareQuotaConnections<T extends QuotaOrderConnection>(
a: T,
b: T,
opts: {
providerOrder: Record<string, number>;
providerLabels?: Record<string, string>;
accountCompare?: (a: T, b: T) => number;
compare?: (a: string, b: string) => number;
}
): number {
const cmp = opts.compare ?? ((x: string, y: string) => x.localeCompare(y));
const labels = opts.providerLabels ?? {};

const ra = providerRank(a.provider, opts.providerOrder);
const rb = providerRank(b.provider, opts.providerOrder);
if (ra !== rb) return ra - rb;

const pa = typeof a.provider === "string" ? a.provider : "";
const pb = typeof b.provider === "string" ? b.provider : "";
const providerLabelCmp = cmp(labels[pa] ?? pa, labels[pb] ?? pb);
if (providerLabelCmp !== 0) return providerLabelCmp;
if (pa !== pb) return pa < pb ? -1 : 1;

if (opts.accountCompare) {
const acc = opts.accountCompare(a, b);
if (acc !== 0) return acc;
}

const accountLabelCmp = cmp(quotaConnLabel(a), quotaConnLabel(b));
if (accountLabelCmp !== 0) return accountLabelCmp;
const ta = quotaConnTiebreak(a);
const tb = quotaConnTiebreak(b);
return ta < tb ? -1 : ta > tb ? 1 : 0;
}

/**
* Order provider group keys for rendering. Same provider-level rule as
* `compareQuotaConnections` (rank → label → key), used by QuotaCardGrid to
* place group headers deterministically.
*/
export function compareProviderGroups(
a: string,
b: string,
opts: {
providerOrder: Record<string, number>;
providerLabels?: Record<string, string>;
compare?: (a: string, b: string) => number;
}
): number {
const cmp = opts.compare ?? ((x: string, y: string) => x.localeCompare(y));
const labels = opts.providerLabels ?? {};
const ra = providerRank(a, opts.providerOrder);
const rb = providerRank(b, opts.providerOrder);
if (ra !== rb) return ra - rb;
const labelCmp = cmp(labels[a] ?? a, labels[b] ?? b);
if (labelCmp !== 0) return labelCmp;
return a < b ? -1 : a > b ? 1 : 0;
}
Loading
Loading