fix(account-flow): restore grouped quota hover details

This commit is contained in:
Tam Nhu Tran
2026-04-01 16:29:27 -04:00
parent 08045500ed
commit e8520133f9
2 changed files with 182 additions and 25 deletions
@@ -3,9 +3,16 @@
*/ */
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card'; import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { formatQuotaPercent, getProviderMinQuota, getQuotaFailureInfo, cn } from '@/lib/utils'; import {
cn,
formatQuotaPercent,
getProviderMinQuota,
getProviderResetTime,
getQuotaFailureInfo,
} from '@/lib/utils';
import { GripVertical, Loader2, Pause, Play } from 'lucide-react'; import { GripVertical, Loader2, Pause, Play } from 'lucide-react';
import { import {
useAccountQuota, useAccountQuota,
@@ -207,36 +214,45 @@ export function AccountCard({
hasGroupedVariants && showQuota hasGroupedVariants && showQuota
? (account.variants ?? []).map((variant, index) => { ? (account.variants ?? []).map((variant, index) => {
const quotaQuery = variantQuotaQueries[index]; const quotaQuery = variantQuotaQueries[index];
const minQuota = getProviderMinQuota(account.provider, quotaQuery?.data); const quota = quotaQuery?.data;
const minQuota = getProviderMinQuota(account.provider, quota);
const resetTime = getProviderResetTime(account.provider, quota);
const quotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; const quotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const quotaValue = quotaLabel !== null ? Number(quotaLabel) : null; const quotaValue = quotaLabel !== null ? Number(quotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quotaQuery?.data); const failureInfo = getQuotaFailureInfo(quota);
const label = variant.audienceLabel ?? variant.detailLabel ?? cleanEmail(variant.email); const label = variant.audienceLabel ?? variant.detailLabel ?? cleanEmail(variant.email);
return ( return (
<div key={variant.id} className="space-y-0.5"> <Tooltip key={variant.id}>
<div className="flex items-center justify-between gap-2 text-[8px]"> <TooltipTrigger asChild>
<span className="text-muted-foreground/80 truncate">{label}</span> <div className="space-y-0.5 cursor-help">
<span className="font-mono text-foreground/80 shrink-0"> <div className="flex items-center justify-between gap-2 text-[8px]">
{quotaQuery?.isLoading <span className="text-muted-foreground/80 truncate">{label}</span>
? t('accountCard.quotaLoading') <span className="font-mono text-foreground/80 shrink-0">
: quotaValue !== null {quotaQuery?.isLoading
? `${quotaLabel}%` ? t('accountCard.quotaLoading')
: failureInfo?.label || t('accountCard.quotaUnavailable')} : quotaValue !== null
</span> ? `${quotaLabel}%`
</div> : failureInfo?.label || t('accountCard.quotaUnavailable')}
{quotaValue !== null && ( </span>
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden"> </div>
<div {quotaValue !== null && (
className={cn( <div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
'h-full rounded-full transition-all', <div
getCompactQuotaColor(quotaValue) className={cn(
)} 'h-full rounded-full transition-all',
style={{ width: `${quotaValue}%` }} getCompactQuotaColor(quotaValue)
/> )}
style={{ width: `${quotaValue}%` }}
/>
</div>
)}
</div> </div>
)} </TooltipTrigger>
</div> <TooltipContent side="top" className="max-w-xs">
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
); );
}) })
: null; : null;
@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
import { AccountCard } from '@/components/account/flow-viz/account-card';
import type { AccountData } from '@/components/account/flow-viz/types';
import type { CodexQuotaResult } from '@/lib/api-client';
import { useAccountQuota, useAccountQuotas } from '@/hooks/use-cliproxy-stats';
vi.mock('@/hooks/use-cliproxy-stats', async () => {
const actual = await vi.importActual<typeof import('@/hooks/use-cliproxy-stats')>(
'@/hooks/use-cliproxy-stats'
);
return {
...actual,
useAccountQuota: vi.fn(),
useAccountQuotas: vi.fn(),
};
});
const mockedUseAccountQuota = vi.mocked(useAccountQuota);
const mockedUseAccountQuotas = vi.mocked(useAccountQuotas);
function makeCodexQuota(planType: 'plus' | 'team', fiveHour: number, weekly: number) {
return {
success: true,
planType,
lastUpdated: Date.now(),
windows: [
{
label: 'Primary',
usedPercent: 100 - fiveHour,
remainingPercent: fiveHour,
resetAfterSeconds: 60 * 60,
resetAt: '2026-04-04T08:44:00Z',
},
{
label: 'Secondary',
usedPercent: 100 - weekly,
remainingPercent: weekly,
resetAfterSeconds: 7 * 24 * 60 * 60,
resetAt: '2026-04-08T10:20:00Z',
},
],
coreUsage: {
fiveHour: {
label: 'Primary',
remainingPercent: fiveHour,
resetAfterSeconds: 60 * 60,
resetAt: '2026-04-04T08:44:00Z',
},
weekly: {
label: 'Secondary',
remainingPercent: weekly,
resetAfterSeconds: 7 * 24 * 60 * 60,
resetAt: '2026-04-08T10:20:00Z',
},
},
} satisfies CodexQuotaResult;
}
const groupedAccount: AccountData = {
id: 'codex:user@example.com',
email: 'user@example.com',
tokenFile: 'codex-user.json',
provider: 'codex',
successCount: 9,
failureCount: 1,
color: '#1e6091',
variants: [
{
id: 'business@example.com',
email: 'user@example.com',
tokenFile: 'codex-business.json',
isDefault: false,
successCount: 5,
failureCount: 0,
audience: 'business',
audienceLabel: 'Business',
detailLabel: null,
},
{
id: 'personal@example.com',
email: 'user@example.com',
tokenFile: 'codex-personal.json',
isDefault: true,
successCount: 4,
failureCount: 1,
audience: 'personal',
audienceLabel: 'Personal',
detailLabel: null,
},
],
};
describe('AccountCard grouped quota tooltip', () => {
beforeEach(() => {
mockedUseAccountQuota.mockReturnValue({
data: undefined,
isLoading: false,
} as ReturnType<typeof useAccountQuota>);
mockedUseAccountQuotas.mockReturnValue([
{
data: makeCodexQuota('team', 95, 81),
isLoading: false,
},
{
data: makeCodexQuota('plus', 64, 42),
isLoading: false,
},
] as ReturnType<typeof useAccountQuotas>);
});
it('shows provider quota tooltip content for each grouped personal/business row on hover', async () => {
render(
<AccountCard
account={groupedAccount}
zone="left"
originalIndex={0}
isHovered={false}
isDragging={false}
offset={{ x: 0, y: 0 }}
showDetails={false}
privacyMode={false}
onMouseEnter={() => undefined}
onMouseLeave={() => undefined}
onPointerDown={() => undefined}
onPointerMove={() => undefined}
onPointerUp={() => undefined}
/>
);
await userEvent.hover(screen.getByText('Business'));
expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0);
expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0);
await userEvent.hover(screen.getByText('Personal'));
expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0);
expect(screen.getAllByText('Weekly usage limit').length).toBeGreaterThan(0);
});
});