Skip to content

Commit 9581fe7

Browse files
authored
feat(accounts): split compact quota row display (#562)
* feat(accounts): split compact quota row display * fix(accounts): select first sorted account by default * fix(accounts): show legacy quota rows in compact list
1 parent 77944c9 commit 9581fe7

13 files changed

Lines changed: 598 additions & 17 deletions

File tree

frontend/src/features/accounts/components/account-list-item.test.tsx

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
11
import { render, screen } from "@testing-library/react";
2-
import { describe, expect, it, vi } from "vitest";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33

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

78
describe("AccountListItem", () => {
9+
beforeEach(() => {
10+
useAccountQuotaDisplayStore.setState({ quotaDisplay: "both" });
11+
vi.useFakeTimers();
12+
vi.setSystemTime(new Date("2026-01-01T12:00:00.000Z"));
13+
});
14+
15+
afterEach(() => {
16+
vi.useRealTimers();
17+
});
18+
819
it("renders neutral quota track when secondary remaining percent is unknown", () => {
920
const account = createAccountSummary({
1021
usage: {
@@ -15,8 +26,85 @@ describe("AccountListItem", () => {
1526

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

18-
expect(screen.getByTestId("mini-quota-track")).toHaveClass("bg-muted");
19-
expect(screen.queryByTestId("mini-quota-fill")).not.toBeInTheDocument();
29+
expect(screen.getByTestId("mini-quota-track-weekly")).toHaveClass("bg-muted");
30+
expect(screen.queryByTestId("mini-quota-track-weekly-fill")).not.toBeInTheDocument();
31+
expect(screen.getByText("5h")).toBeInTheDocument();
32+
expect(screen.getByText("Weekly")).toBeInTheDocument();
33+
expect(screen.getByText("Reset in 1h")).toBeInTheDocument();
34+
expect(screen.getByText("Reset in 1d")).toBeInTheDocument();
35+
});
36+
37+
it("omits the 5h row for weekly-only accounts", () => {
38+
const account = createAccountSummary({
39+
usage: {
40+
primaryRemainingPercent: null,
41+
secondaryRemainingPercent: 73,
42+
},
43+
resetAtPrimary: null,
44+
resetAtSecondary: "2026-01-02T12:00:00.000Z",
45+
windowMinutesPrimary: null,
46+
windowMinutesSecondary: 10_080,
47+
});
48+
49+
render(<AccountListItem account={account} selected={false} onSelect={vi.fn()} />);
50+
51+
expect(screen.queryByText("5h")).not.toBeInTheDocument();
52+
expect(screen.getByText("Weekly")).toBeInTheDocument();
53+
expect(screen.getByText("Reset in 1d")).toBeInTheDocument();
54+
});
55+
56+
it("renders legacy primary quota data without window metadata", () => {
57+
const account = createAccountSummary({
58+
usage: {
59+
primaryRemainingPercent: 64,
60+
secondaryRemainingPercent: null,
61+
},
62+
resetAtPrimary: "2026-01-01T13:00:00.000Z",
63+
resetAtSecondary: null,
64+
windowMinutesPrimary: null,
65+
windowMinutesSecondary: null,
66+
});
67+
68+
render(<AccountListItem account={account} selected={false} onSelect={vi.fn()} />);
69+
70+
expect(screen.getByText("5h")).toBeInTheDocument();
71+
expect(screen.getByTestId("mini-quota-track-5h-fill")).toHaveStyle({ width: "64%" });
72+
expect(screen.getByText("Reset in 1h")).toBeInTheDocument();
73+
expect(screen.queryByText("Weekly")).not.toBeInTheDocument();
74+
});
75+
76+
it("does not duplicate unavailable reset labels", () => {
77+
const account = createAccountSummary({
78+
usage: {
79+
primaryRemainingPercent: 64,
80+
secondaryRemainingPercent: null,
81+
},
82+
resetAtPrimary: null,
83+
resetAtSecondary: null,
84+
windowMinutesPrimary: 300,
85+
windowMinutesSecondary: null,
86+
});
87+
88+
render(<AccountListItem account={account} selected={false} onSelect={vi.fn()} />);
89+
90+
expect(screen.getByText("Reset --")).toBeInTheDocument();
91+
expect(screen.queryByText("Reset Reset unavailable")).not.toBeInTheDocument();
92+
});
93+
94+
it("shows only the 5h row when the account quota preference is 5h", () => {
95+
useAccountQuotaDisplayStore.setState({ quotaDisplay: "5h" });
96+
97+
const account = createAccountSummary({
98+
usage: {
99+
primaryRemainingPercent: 82,
100+
secondaryRemainingPercent: 73,
101+
},
102+
});
103+
104+
render(<AccountListItem account={account} selected={false} onSelect={vi.fn()} />);
105+
106+
expect(screen.getByText("5h")).toBeInTheDocument();
107+
expect(screen.queryByText("Weekly")).not.toBeInTheDocument();
20108
});
21109

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

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

32-
expect(screen.getByTestId("mini-quota-fill")).toHaveStyle({ width: "73%" });
120+
expect(screen.getByTestId("mini-quota-track-weekly-fill")).toHaveStyle({ width: "73%" });
33121
});
34122
});

frontend/src/features/accounts/components/account-list-item.tsx

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { cn } from "@/lib/utils";
22
import { isEmailLabel } from "@/components/blur-email";
33
import { usePrivacyStore } from "@/hooks/use-privacy";
4+
import { useAccountQuotaDisplayStore } from "@/hooks/use-account-quota-display";
45
import { StatusBadge } from "@/components/status-badge";
56
import type { AccountSummary } from "@/features/accounts/schemas";
67
import { normalizeStatus, quotaBarColor, quotaBarTrack } from "@/utils/account-status";
78
import { formatCompactAccountId } from "@/utils/account-identifiers";
8-
import { formatSlug } from "@/utils/formatters";
9+
import { formatPercentNullable, formatQuotaResetLabel, formatSlug } from "@/utils/formatters";
910

1011
export type AccountListItemProps = {
1112
account: AccountSummary;
@@ -14,15 +15,15 @@ export type AccountListItemProps = {
1415
onSelect: (accountId: string) => void;
1516
};
1617

17-
function MiniQuotaBar({ percent }: { percent: number | null }) {
18+
function MiniQuotaBar({ percent, testId }: { percent: number | null; testId: string }) {
1819
if (percent === null) {
19-
return <div data-testid="mini-quota-track" className="h-1 flex-1 overflow-hidden rounded-full bg-muted" />;
20+
return <div data-testid={testId} className="h-1 flex-1 overflow-hidden rounded-full bg-muted" />;
2021
}
2122
const clamped = Math.max(0, Math.min(100, percent));
2223
return (
23-
<div data-testid="mini-quota-track" className={cn("h-1 flex-1 overflow-hidden rounded-full", quotaBarTrack(clamped))}>
24+
<div data-testid={testId} className={cn("h-1 flex-1 overflow-hidden rounded-full", quotaBarTrack(clamped))}>
2425
<div
25-
data-testid="mini-quota-fill"
26+
data-testid={`${testId}-fill`}
2627
className={cn("h-full rounded-full", quotaBarColor(clamped))}
2728
style={{ width: `${clamped}%` }}
2829
/>
@@ -32,6 +33,7 @@ function MiniQuotaBar({ percent }: { percent: number | null }) {
3233

3334
export function AccountListItem({ account, selected, showAccountId = false, onSelect }: AccountListItemProps) {
3435
const blurred = usePrivacyStore((s) => s.blurred);
36+
const quotaDisplay = useAccountQuotaDisplayStore((s) => s.quotaDisplay);
3537
const status = normalizeStatus(account.status);
3638
const title = account.displayName || account.email;
3739
const titleIsEmail = isEmailLabel(title, account.email);
@@ -40,7 +42,13 @@ export function AccountListItem({ account, selected, showAccountId = false, onSe
4042
: null;
4143
const baseSubtitle = emailSubtitle ?? formatSlug(account.planType);
4244
const idSuffix = showAccountId ? ` | ID ${formatCompactAccountId(account.accountId)}` : "";
45+
const primary = account.usage?.primaryRemainingPercent ?? null;
4346
const secondary = account.usage?.secondaryRemainingPercent ?? null;
47+
const hasPrimaryWindow = account.windowMinutesPrimary != null || primary !== null || account.resetAtPrimary != null;
48+
const hasSecondaryWindow = account.windowMinutesSecondary != null || secondary !== null || account.resetAtSecondary != null;
49+
const showPrimaryRow = hasPrimaryWindow && (quotaDisplay !== "weekly" || !hasSecondaryWindow);
50+
const showSecondaryRow = hasSecondaryWindow && (quotaDisplay !== "5h" || !hasPrimaryWindow);
51+
const visibleQuotaRows = Number(showPrimaryRow) + Number(showSecondaryRow);
4452

4553
return (
4654
<button
@@ -64,9 +72,36 @@ export function AccountListItem({ account, selected, showAccountId = false, onSe
6472
</div>
6573
<StatusBadge status={status} />
6674
</div>
67-
<div className="mt-1.5">
68-
<MiniQuotaBar percent={secondary} />
75+
<div className={cn("mt-2 grid gap-2", visibleQuotaRows > 1 ? "grid-cols-2" : "grid-cols-1")}>
76+
{showPrimaryRow ? <MiniQuotaRow label="5h" percent={primary} resetAt={account.resetAtPrimary} /> : null}
77+
{showSecondaryRow ? <MiniQuotaRow label="Weekly" percent={secondary} resetAt={account.resetAtSecondary} /> : null}
6978
</div>
7079
</button>
7180
);
7281
}
82+
83+
function MiniQuotaRow({
84+
label,
85+
percent,
86+
resetAt,
87+
}: {
88+
label: string;
89+
percent: number | null;
90+
resetAt: string | null | undefined;
91+
}) {
92+
return (
93+
<div className="space-y-1">
94+
<div className="flex items-center justify-between text-[11px]">
95+
<span className="text-muted-foreground">{label}</span>
96+
<span className="tabular-nums font-medium">{formatPercentNullable(percent)}</span>
97+
</div>
98+
<MiniQuotaBar percent={percent} testId={`mini-quota-track-${label.toLowerCase()}`} />
99+
<div className="text-[10px] text-muted-foreground">{formatMiniQuotaResetLabel(resetAt ?? null)}</div>
100+
</div>
101+
);
102+
}
103+
104+
function formatMiniQuotaResetLabel(resetAt: string | null): string {
105+
const label = formatQuotaResetLabel(resetAt);
106+
return label.startsWith("Reset ") ? label : `Reset ${label}`;
107+
}

frontend/src/features/accounts/components/account-list.test.tsx

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import { render, screen } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
3-
import { describe, expect, it, vi } from "vitest";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44

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

78
describe("AccountList", () => {
9+
beforeEach(() => {
10+
useAccountQuotaDisplayStore.setState({ quotaDisplay: "both" });
11+
vi.spyOn(Date, "now").mockReturnValue(new Date("2026-01-01T12:00:00.000Z").getTime());
12+
});
13+
14+
afterEach(() => {
15+
vi.restoreAllMocks();
16+
});
17+
818
it("renders items and filters by search", async () => {
919
const user = userEvent.setup();
1020
const onSelect = vi.fn();
@@ -47,6 +57,158 @@ describe("AccountList", () => {
4757
expect(onSelect).toHaveBeenCalledWith("acc-2");
4858
});
4959

60+
it("sorts accounts by the rows actually rendered", () => {
61+
useAccountQuotaDisplayStore.setState({ quotaDisplay: "weekly" });
62+
63+
render(
64+
<AccountList
65+
accounts={[
66+
{
67+
accountId: "acc-hidden-early",
68+
email: "hidden-early@example.com",
69+
displayName: "Hidden Early",
70+
planType: "plus",
71+
status: "active",
72+
usage: {
73+
primaryRemainingPercent: 42,
74+
secondaryRemainingPercent: 18,
75+
},
76+
resetAtPrimary: "2026-01-01T12:05:00.000Z",
77+
resetAtSecondary: "2026-01-01T13:00:00.000Z",
78+
windowMinutesPrimary: 300,
79+
windowMinutesSecondary: 10_080,
80+
additionalQuotas: [],
81+
},
82+
{
83+
accountId: "acc-visible-early",
84+
email: "visible-early@example.com",
85+
displayName: "Visible Early",
86+
planType: "plus",
87+
status: "active",
88+
usage: {
89+
primaryRemainingPercent: 82,
90+
secondaryRemainingPercent: 73,
91+
},
92+
resetAtPrimary: "2026-01-01T12:30:00.000Z",
93+
resetAtSecondary: "2026-01-01T12:10:00.000Z",
94+
windowMinutesPrimary: 300,
95+
windowMinutesSecondary: 10_080,
96+
additionalQuotas: [],
97+
},
98+
]}
99+
selectedAccountId={null}
100+
onSelect={() => {}}
101+
onOpenImport={() => {}}
102+
onOpenOauth={() => {}}
103+
/>,
104+
);
105+
106+
expect(screen.getAllByText(/^(Hidden Early|Visible Early)$/).map((el) => el.textContent)).toEqual([
107+
"Visible Early",
108+
"Hidden Early",
109+
]);
110+
});
111+
112+
it("ignores elapsed reset timestamps when sorting", () => {
113+
render(
114+
<AccountList
115+
accounts={[
116+
{
117+
accountId: "acc-stale",
118+
email: "stale@example.com",
119+
displayName: "Stale",
120+
planType: "plus",
121+
status: "active",
122+
usage: {
123+
primaryRemainingPercent: 42,
124+
secondaryRemainingPercent: 18,
125+
},
126+
resetAtPrimary: "2026-01-01T11:30:00.000Z",
127+
resetAtSecondary: "2026-01-01T11:45:00.000Z",
128+
windowMinutesPrimary: 300,
129+
windowMinutesSecondary: 10_080,
130+
additionalQuotas: [],
131+
},
132+
{
133+
accountId: "acc-fresh",
134+
email: "fresh@example.com",
135+
displayName: "Fresh",
136+
planType: "plus",
137+
status: "active",
138+
usage: {
139+
primaryRemainingPercent: 82,
140+
secondaryRemainingPercent: 73,
141+
},
142+
resetAtPrimary: "2026-01-01T12:30:00.000Z",
143+
resetAtSecondary: "2026-01-01T12:20:00.000Z",
144+
windowMinutesPrimary: 300,
145+
windowMinutesSecondary: 10_080,
146+
additionalQuotas: [],
147+
},
148+
]}
149+
selectedAccountId={null}
150+
onSelect={() => {}}
151+
onOpenImport={() => {}}
152+
onOpenOauth={() => {}}
153+
/>,
154+
);
155+
156+
expect(screen.getAllByText(/^(Fresh|Stale)$/).map((el) => el.textContent)).toEqual([
157+
"Fresh",
158+
"Stale",
159+
]);
160+
});
161+
162+
it("sorts legacy primary quota rows by their reset timestamp", () => {
163+
render(
164+
<AccountList
165+
accounts={[
166+
{
167+
accountId: "acc-late",
168+
email: "late@example.com",
169+
displayName: "Late",
170+
planType: "plus",
171+
status: "active",
172+
usage: {
173+
primaryRemainingPercent: 42,
174+
secondaryRemainingPercent: null,
175+
},
176+
resetAtPrimary: "2026-01-01T13:00:00.000Z",
177+
resetAtSecondary: null,
178+
windowMinutesPrimary: null,
179+
windowMinutesSecondary: null,
180+
additionalQuotas: [],
181+
},
182+
{
183+
accountId: "acc-early",
184+
email: "early@example.com",
185+
displayName: "Early",
186+
planType: "plus",
187+
status: "active",
188+
usage: {
189+
primaryRemainingPercent: 82,
190+
secondaryRemainingPercent: null,
191+
},
192+
resetAtPrimary: "2026-01-01T12:10:00.000Z",
193+
resetAtSecondary: null,
194+
windowMinutesPrimary: null,
195+
windowMinutesSecondary: null,
196+
additionalQuotas: [],
197+
},
198+
]}
199+
selectedAccountId={null}
200+
onSelect={() => {}}
201+
onOpenImport={() => {}}
202+
onOpenOauth={() => {}}
203+
/>,
204+
);
205+
206+
expect(screen.getAllByText(/^(Early|Late)$/).map((el) => el.textContent)).toEqual([
207+
"Early",
208+
"Late",
209+
]);
210+
});
211+
50212
it("shows empty state when no items match filter", async () => {
51213
const user = userEvent.setup();
52214

0 commit comments

Comments
 (0)