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
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@
"jsonc-parser": "^3.3.1",
"lowdb": "^7.0.1",
"lucide-react": "^1.33.0",
"magic-grid": "^3.4.7",
"marked": "^18.0.11",
"marked-terminal": "^7.3.0",
"material-symbols": "^0.47.0",
Expand Down Expand Up @@ -439,8 +440,8 @@
"lockfile-lint": "^5.0.1",
"node-loader": "^2.1.0",
"opencode-ai": "1.18.30",
"pretendard": "^1.3.9",
"playwright-ctrf-json-reporter": "^0.0.29",
"pretendard": "^1.3.9",
"prettier": "^3.9.6",
"promptfoo": "^0.123.0",
"size-limit": "^13.0.3",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
"use client";

import type { ReactNode } from "react";
import { useState } from "react";
import { PROVIDER_ORDER } from "./constants";
/**
* QuotaCardGrid — ONE unified MagicGrid board of quota cards.
*
* Every connection — any provider, any account count — renders as the same
* fixed-width card and flows into the same grid (shortest-column-first), so
* single-account providers sit right next to multi-account ones instead of
* stretching across the whole row or hiding in a per-provider section.
* Provider identity lives on the card itself (icon + label + plan badge).
*
* Layout engine: magic-grid (MagicGrid). Cards keep their CSS width
* (`w-full` on phones → 1 column; `sm:w-[280px]` → as many 280px columns as
* fit, capped by maxColumns). MagicGrid only computes positions; we drive
* `positionItems()` from our own ResizeObserver / MutationObserver / window
* resize listeners so every listener is disconnectable on unmount (the
* library's `listen()` leaks a window listener and has no destroy).
*/

import { useEffect, useRef } from "react";
import MagicGrid from "magic-grid";
import QuotaCard from "./QuotaCard";
import { worstStatus, type CardStatus, compareProviderGroups } from "./utils";
import { compareTr } from "@/shared/utils/turkishText";
import { worstStatus, type CardStatus } from "./utils";

interface Props {
connections: any[];
Expand All @@ -15,7 +30,7 @@ interface Props {
lastRefreshedAt: Record<string, string | undefined>;
emailsVisible: boolean;
providerLabels: Record<string, string>;
renderInlineQuotaSummary?: (quota: any) => ReactNode;
renderInlineQuotaSummary?: (quota: any) => React.ReactNode;
onRefresh: (id: string, provider: string) => void;
onOpenCutoff: (connection: any) => void;
onOpenResetCredits?: (id: string, provider: string) => void;
Expand All @@ -27,6 +42,7 @@ interface Props {
quotaVisibility?: Record<string, { hidden?: string[] }>;
onHideQuota?: (provider: string, quota: any) => void;
onShowQuota?: (provider: string, quota: any) => void;
/** Compact home-widget density: narrower cards on the same board. */
compact?: boolean;
}

Expand Down Expand Up @@ -115,112 +131,11 @@ export function sortProviderConnectionsByPriority(
});
}

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-fill,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>
);
}
/** Card width per density mode — phones go full-width (single MagicGrid column). */
const CARD_WIDTH_CLASS = {
full: "w-full sm:w-[280px]",
compact: "w-full sm:w-[260px]",
} as const;

export default function QuotaCardGrid({
connections,
Expand All @@ -243,71 +158,85 @@ export default function QuotaCardGrid({
onShowQuota,
compact = false,
}: Props) {
if (connections.length === 0) return null;

const renderCard = (conn: (typeof connections)[number]) => (
<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}
/>
);
const containerRef = useRef<HTMLDivElement | null>(null);

const sorted = sortProviderConnectionsByPriority(connections, quotaData);

// MagicGrid lifecycle: one instance per mount; `static: true` bypasses the
// item-count gate so we can position partial loads, and we NEVER call
// listen() (it registers an unremovable window resize listener). All
// reposition triggers are ours and cleaned up below.
useEffect(() => {
const container = containerRef.current;
if (!container) return;

const grid = new MagicGrid({
container,
static: true,
gutter: 12,
maxColumns: 8,
useMin: true,
animate: true,
});

let frame = 0;
const reposition = () => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
grid.positionItems();
});
};

// Card heights change without React state changes: "Show 5 more"
// expanders, loading → data swaps, refreshed countdown text.
const mutation = new MutationObserver(reposition);
mutation.observe(container, { childList: true, subtree: true, characterData: true });

const resize = new ResizeObserver(reposition);
resize.observe(container);

window.addEventListener("resize", reposition);
reposition();

return () => {
if (frame) cancelAnimationFrame(frame);
mutation.disconnect();
resize.disconnect();
window.removeEventListener("resize", reposition);
};
// Instance is per-mount; repositioning is observer-driven.
}, []);

// Compact mode: flat 3-column card grid, across all connections.
if (compact) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-3">
{connections.map(renderCard)}
</div>
);
}

// 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);
if (connections.length === 0) return null;

return (
<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 ref={containerRef} className="relative">
{sorted.map((conn) => (
<div
key={conn.id}
className={CARD_WIDTH_CLASS[compact ? "compact" : "full"]}
>
<QuotaCard
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>
);
Expand Down
16 changes: 8 additions & 8 deletions tests/unit/quota-card-grid-compact-layout-8916.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,14 @@ test("QuotaCardGrid exports a compact prop on its Props interface", () => {
);
});

test("QuotaCardGrid compact mode renders an auto-fill minmax grid", () => {
const classNames = collectDivClassNames(COMPONENTS.quotaCardGrid);
const compactGrid = classNames.find((cn) =>
cn.includes("grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]")
);
assert.ok(
compactGrid,
"compact mode must render grid-cols-[repeat(auto-fill,minmax(17rem,1fr))]"
test("QuotaCardGrid compact mode renders the narrower MagicGrid card track", () => {
// 2026-09 redesign: the compact layout is the same unified MagicGrid board
// with a narrower per-card track (see quota-unified-magic-grid.test.ts).
const code = fs.readFileSync(COMPONENTS.quotaCardGrid, "utf8");
assert.match(
code,
/compact:\s*"w-full sm:w-\[\d+px\]"/,
"compact mode must keep a dedicated narrower card track in CARD_WIDTH_CLASS"
);
});

Expand Down
Loading