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,10 +1,21 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { AccountListItem } from "@/features/accounts/components/account-list-item";
import { useAccountQuotaDisplayStore } from "@/hooks/use-account-quota-display";
import { createAccountSummary } from "@/test/mocks/factories";

describe("AccountListItem", () => {
beforeEach(() => {
useAccountQuotaDisplayStore.setState({ quotaDisplay: "both" });
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T12:00:00.000Z"));
});

afterEach(() => {
vi.useRealTimers();
});

it("renders neutral quota track when secondary remaining percent is unknown", () => {
const account = createAccountSummary({
usage: {
Expand All @@ -15,8 +26,85 @@ describe("AccountListItem", () => {

render(<AccountListItem account={account} selected={false} onSelect={vi.fn()} />);

expect(screen.getByTestId("mini-quota-track")).toHaveClass("bg-muted");
expect(screen.queryByTestId("mini-quota-fill")).not.toBeInTheDocument();
expect(screen.getByTestId("mini-quota-track-weekly")).toHaveClass("bg-muted");
expect(screen.queryByTestId("mini-quota-track-weekly-fill")).not.toBeInTheDocument();
expect(screen.getByText("5h")).toBeInTheDocument();
expect(screen.getByText("Weekly")).toBeInTheDocument();
expect(screen.getByText("Reset in 1h")).toBeInTheDocument();
expect(screen.getByText("Reset in 1d")).toBeInTheDocument();
});

it("omits the 5h row for weekly-only accounts", () => {
const account = createAccountSummary({
usage: {
primaryRemainingPercent: null,
secondaryRemainingPercent: 73,
},
resetAtPrimary: null,
resetAtSecondary: "2026-01-02T12:00:00.000Z",
windowMinutesPrimary: null,
windowMinutesSecondary: 10_080,
});

render(<AccountListItem account={account} selected={false} onSelect={vi.fn()} />);

expect(screen.queryByText("5h")).not.toBeInTheDocument();
expect(screen.getByText("Weekly")).toBeInTheDocument();
expect(screen.getByText("Reset in 1d")).toBeInTheDocument();
});

it("renders legacy primary quota data without window metadata", () => {
const account = createAccountSummary({
usage: {
primaryRemainingPercent: 64,
secondaryRemainingPercent: null,
},
resetAtPrimary: "2026-01-01T13:00:00.000Z",
resetAtSecondary: null,
windowMinutesPrimary: null,
windowMinutesSecondary: null,
});

render(<AccountListItem account={account} selected={false} onSelect={vi.fn()} />);

expect(screen.getByText("5h")).toBeInTheDocument();
expect(screen.getByTestId("mini-quota-track-5h-fill")).toHaveStyle({ width: "64%" });
expect(screen.getByText("Reset in 1h")).toBeInTheDocument();
expect(screen.queryByText("Weekly")).not.toBeInTheDocument();
});

it("does not duplicate unavailable reset labels", () => {
const account = createAccountSummary({
usage: {
primaryRemainingPercent: 64,
secondaryRemainingPercent: null,
},
resetAtPrimary: null,
resetAtSecondary: null,
windowMinutesPrimary: 300,
windowMinutesSecondary: null,
});

render(<AccountListItem account={account} selected={false} onSelect={vi.fn()} />);

expect(screen.getByText("Reset --")).toBeInTheDocument();
expect(screen.queryByText("Reset Reset unavailable")).not.toBeInTheDocument();
});

it("shows only the 5h row when the account quota preference is 5h", () => {
useAccountQuotaDisplayStore.setState({ quotaDisplay: "5h" });

const account = createAccountSummary({
usage: {
primaryRemainingPercent: 82,
secondaryRemainingPercent: 73,
},
});

render(<AccountListItem account={account} selected={false} onSelect={vi.fn()} />);

expect(screen.getByText("5h")).toBeInTheDocument();
expect(screen.queryByText("Weekly")).not.toBeInTheDocument();
});

it("renders quota fill when secondary remaining percent is available", () => {
Expand All @@ -29,6 +117,6 @@ describe("AccountListItem", () => {

render(<AccountListItem account={account} selected={false} onSelect={vi.fn()} />);

expect(screen.getByTestId("mini-quota-fill")).toHaveStyle({ width: "73%" });
expect(screen.getByTestId("mini-quota-track-weekly-fill")).toHaveStyle({ width: "73%" });
});
});
49 changes: 42 additions & 7 deletions frontend/src/features/accounts/components/account-list-item.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { cn } from "@/lib/utils";
import { isEmailLabel } from "@/components/blur-email";
import { usePrivacyStore } from "@/hooks/use-privacy";
import { useAccountQuotaDisplayStore } from "@/hooks/use-account-quota-display";
import { StatusBadge } from "@/components/status-badge";
import type { AccountSummary } from "@/features/accounts/schemas";
import { normalizeStatus, quotaBarColor, quotaBarTrack } from "@/utils/account-status";
import { formatCompactAccountId } from "@/utils/account-identifiers";
import { formatSlug } from "@/utils/formatters";
import { formatPercentNullable, formatQuotaResetLabel, formatSlug } from "@/utils/formatters";

export type AccountListItemProps = {
account: AccountSummary;
Expand All @@ -14,15 +15,15 @@ export type AccountListItemProps = {
onSelect: (accountId: string) => void;
};

function MiniQuotaBar({ percent }: { percent: number | null }) {
function MiniQuotaBar({ percent, testId }: { percent: number | null; testId: string }) {
if (percent === null) {
return <div data-testid="mini-quota-track" className="h-1 flex-1 overflow-hidden rounded-full bg-muted" />;
return <div data-testid={testId} className="h-1 flex-1 overflow-hidden rounded-full bg-muted" />;
}
const clamped = Math.max(0, Math.min(100, percent));
return (
<div data-testid="mini-quota-track" className={cn("h-1 flex-1 overflow-hidden rounded-full", quotaBarTrack(clamped))}>
<div data-testid={testId} className={cn("h-1 flex-1 overflow-hidden rounded-full", quotaBarTrack(clamped))}>
<div
data-testid="mini-quota-fill"
data-testid={`${testId}-fill`}
className={cn("h-full rounded-full", quotaBarColor(clamped))}
style={{ width: `${clamped}%` }}
/>
Expand All @@ -32,6 +33,7 @@ function MiniQuotaBar({ percent }: { percent: number | null }) {

export function AccountListItem({ account, selected, showAccountId = false, onSelect }: AccountListItemProps) {
const blurred = usePrivacyStore((s) => s.blurred);
const quotaDisplay = useAccountQuotaDisplayStore((s) => s.quotaDisplay);
const status = normalizeStatus(account.status);
const title = account.displayName || account.email;
const titleIsEmail = isEmailLabel(title, account.email);
Expand All @@ -40,7 +42,13 @@ export function AccountListItem({ account, selected, showAccountId = false, onSe
: null;
const baseSubtitle = emailSubtitle ?? formatSlug(account.planType);
const idSuffix = showAccountId ? ` | ID ${formatCompactAccountId(account.accountId)}` : "";
const primary = account.usage?.primaryRemainingPercent ?? null;
const secondary = account.usage?.secondaryRemainingPercent ?? null;
const hasPrimaryWindow = account.windowMinutesPrimary != null || primary !== null || account.resetAtPrimary != null;
const hasSecondaryWindow = account.windowMinutesSecondary != null || secondary !== null || account.resetAtSecondary != null;
const showPrimaryRow = hasPrimaryWindow && (quotaDisplay !== "weekly" || !hasSecondaryWindow);
const showSecondaryRow = hasSecondaryWindow && (quotaDisplay !== "5h" || !hasPrimaryWindow);
const visibleQuotaRows = Number(showPrimaryRow) + Number(showSecondaryRow);

return (
<button
Expand All @@ -64,9 +72,36 @@ export function AccountListItem({ account, selected, showAccountId = false, onSe
</div>
<StatusBadge status={status} />
</div>
<div className="mt-1.5">
<MiniQuotaBar percent={secondary} />
<div className={cn("mt-2 grid gap-2", visibleQuotaRows > 1 ? "grid-cols-2" : "grid-cols-1")}>
{showPrimaryRow ? <MiniQuotaRow label="5h" percent={primary} resetAt={account.resetAtPrimary} /> : null}
{showSecondaryRow ? <MiniQuotaRow label="Weekly" percent={secondary} resetAt={account.resetAtSecondary} /> : null}
</div>
</button>
);
}

function MiniQuotaRow({
label,
percent,
resetAt,
}: {
label: string;
percent: number | null;
resetAt: string | null | undefined;
}) {
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-[11px]">
<span className="text-muted-foreground">{label}</span>
<span className="tabular-nums font-medium">{formatPercentNullable(percent)}</span>
</div>
<MiniQuotaBar percent={percent} testId={`mini-quota-track-${label.toLowerCase()}`} />
<div className="text-[10px] text-muted-foreground">{formatMiniQuotaResetLabel(resetAt ?? null)}</div>
</div>
);
}

function formatMiniQuotaResetLabel(resetAt: string | null): string {
const label = formatQuotaResetLabel(resetAt);
return label.startsWith("Reset ") ? label : `Reset ${label}`;
}
164 changes: 163 additions & 1 deletion frontend/src/features/accounts/components/account-list.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { AccountList } from "@/features/accounts/components/account-list";
import { useAccountQuotaDisplayStore } from "@/hooks/use-account-quota-display";

describe("AccountList", () => {
beforeEach(() => {
useAccountQuotaDisplayStore.setState({ quotaDisplay: "both" });
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-01-01T12:00:00.000Z").getTime());
});

afterEach(() => {
vi.restoreAllMocks();
});

it("renders items and filters by search", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
Expand Down Expand Up @@ -47,6 +57,158 @@ describe("AccountList", () => {
expect(onSelect).toHaveBeenCalledWith("acc-2");
});

it("sorts accounts by the rows actually rendered", () => {
useAccountQuotaDisplayStore.setState({ quotaDisplay: "weekly" });

render(
<AccountList
accounts={[
{
accountId: "acc-hidden-early",
email: "hidden-early@example.com",
displayName: "Hidden Early",
planType: "plus",
status: "active",
usage: {
primaryRemainingPercent: 42,
secondaryRemainingPercent: 18,
},
resetAtPrimary: "2026-01-01T12:05:00.000Z",
resetAtSecondary: "2026-01-01T13:00:00.000Z",
windowMinutesPrimary: 300,
windowMinutesSecondary: 10_080,
additionalQuotas: [],
},
{
accountId: "acc-visible-early",
email: "visible-early@example.com",
displayName: "Visible Early",
planType: "plus",
status: "active",
usage: {
primaryRemainingPercent: 82,
secondaryRemainingPercent: 73,
},
resetAtPrimary: "2026-01-01T12:30:00.000Z",
resetAtSecondary: "2026-01-01T12:10:00.000Z",
windowMinutesPrimary: 300,
windowMinutesSecondary: 10_080,
additionalQuotas: [],
},
]}
selectedAccountId={null}
onSelect={() => {}}
onOpenImport={() => {}}
onOpenOauth={() => {}}
/>,
);

expect(screen.getAllByText(/^(Hidden Early|Visible Early)$/).map((el) => el.textContent)).toEqual([
"Visible Early",
"Hidden Early",
]);
});

it("ignores elapsed reset timestamps when sorting", () => {
render(
<AccountList
accounts={[
{
accountId: "acc-stale",
email: "stale@example.com",
displayName: "Stale",
planType: "plus",
status: "active",
usage: {
primaryRemainingPercent: 42,
secondaryRemainingPercent: 18,
},
resetAtPrimary: "2026-01-01T11:30:00.000Z",
resetAtSecondary: "2026-01-01T11:45:00.000Z",
windowMinutesPrimary: 300,
windowMinutesSecondary: 10_080,
additionalQuotas: [],
},
{
accountId: "acc-fresh",
email: "fresh@example.com",
displayName: "Fresh",
planType: "plus",
status: "active",
usage: {
primaryRemainingPercent: 82,
secondaryRemainingPercent: 73,
},
resetAtPrimary: "2026-01-01T12:30:00.000Z",
resetAtSecondary: "2026-01-01T12:20:00.000Z",
windowMinutesPrimary: 300,
windowMinutesSecondary: 10_080,
additionalQuotas: [],
},
]}
selectedAccountId={null}
onSelect={() => {}}
onOpenImport={() => {}}
onOpenOauth={() => {}}
/>,
);

expect(screen.getAllByText(/^(Fresh|Stale)$/).map((el) => el.textContent)).toEqual([
"Fresh",
"Stale",
]);
});

it("sorts legacy primary quota rows by their reset timestamp", () => {
render(
<AccountList
accounts={[
{
accountId: "acc-late",
email: "late@example.com",
displayName: "Late",
planType: "plus",
status: "active",
usage: {
primaryRemainingPercent: 42,
secondaryRemainingPercent: null,
},
resetAtPrimary: "2026-01-01T13:00:00.000Z",
resetAtSecondary: null,
windowMinutesPrimary: null,
windowMinutesSecondary: null,
additionalQuotas: [],
},
{
accountId: "acc-early",
email: "early@example.com",
displayName: "Early",
planType: "plus",
status: "active",
usage: {
primaryRemainingPercent: 82,
secondaryRemainingPercent: null,
},
resetAtPrimary: "2026-01-01T12:10:00.000Z",
resetAtSecondary: null,
windowMinutesPrimary: null,
windowMinutesSecondary: null,
additionalQuotas: [],
},
]}
selectedAccountId={null}
onSelect={() => {}}
onOpenImport={() => {}}
onOpenOauth={() => {}}
/>,
);

expect(screen.getAllByText(/^(Early|Late)$/).map((el) => el.textContent)).toEqual([
"Early",
"Late",
]);
});

it("shows empty state when no items match filter", async () => {
const user = userEvent.setup();

Expand Down
Loading
Loading