Skip to content
Open
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
21 changes: 21 additions & 0 deletions frontend/src/features/dashboard/components/account-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,27 @@ describe("AccountList", () => {
expect(screen.getByRole("button", { name: "Account, sorted descending" })).toBeInTheDocument();
});

it("uses controlled sort state and reports the next header sort", async () => {
const user = userEvent.setup();
const onSortChange = vi.fn();
render(
<AccountList
accounts={[
createAccountSummary({ accountId: "acc-b", displayName: "Beta Account" }),
createAccountSummary({ accountId: "acc-a", displayName: "Alpha Account" }),
]}
sort={{ key: "account", direction: "asc" }}
onSortChange={onSortChange}
/>,
);

expect(rowNames()).toEqual(["Alpha Account", "Beta Account"]);

await user.click(screen.getByRole("button", { name: "Account, sorted ascending" }));

expect(onSortChange).toHaveBeenCalledWith({ key: "account", direction: "desc" });
});

it("sorts quota by the lowest visible remaining quota percent", async () => {
const user = userEvent.setup();
render(
Expand Down
32 changes: 21 additions & 11 deletions frontend/src/features/dashboard/components/account-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ const ACCOUNT_LIST_COLUMNS = "minmax(13rem,1.3fr) 7.75rem 5rem minmax(14rem,1.2f
type AccountListProps = {
accounts: AccountSummary[];
readOnly?: boolean;
sort?: AccountListSort;
onSortChange?: (sort: AccountListSort) => void;
onAction?: (account: AccountSummary, action: AccountAction) => void;
};

type AccountListSortKey = "account" | "status" | "plan" | "quota" | "credits" | "warmup";
type SortDirection = "asc" | "desc";
type AccountListSort = {
export type AccountListSortKey = "account" | "status" | "plan" | "quota" | "credits" | "warmup";
export type SortDirection = "asc" | "desc";
export type AccountListSort = {
key: AccountListSortKey;
direction: SortDirection;
} | null;
Expand Down Expand Up @@ -238,9 +240,16 @@ function QuotaMeter({ percent }: { percent: number | null }) {
);
}

export function AccountList({ accounts, readOnly = false, onAction }: AccountListProps) {
export function AccountList({
accounts,
readOnly = false,
sort: controlledSort,
onSortChange,
onAction,
}: AccountListProps) {
const blurred = usePrivacyStore((s) => s.blurred);
const [sort, setSort] = useState<AccountListSort>(null);
const [uncontrolledSort, setUncontrolledSort] = useState<AccountListSort>(null);
const sort = controlledSort === undefined ? uncontrolledSort : controlledSort;
const sortedAccounts = useMemo(() => {
if (!sort) {
return accounts;
Expand All @@ -252,12 +261,13 @@ export function AccountList({ accounts, readOnly = false, onAction }: AccountLis
}, [accounts, sort]);

const handleSort = (key: AccountListSortKey) => {
setSort((current) => {
if (current?.key !== key) {
return { key, direction: "asc" };
}
return { key, direction: current.direction === "asc" ? "desc" : "asc" };
});
const nextSort: AccountListSort = sort?.key === key
? { key, direction: sort.direction === "asc" ? "desc" : "asc" }
: { key, direction: "asc" };
if (controlledSort === undefined) {
setUncontrolledSort(nextSort);
}
onSortChange?.(nextSort);
};

if (accounts.length === 0) {
Expand Down
48 changes: 44 additions & 4 deletions frontend/src/features/dashboard/components/dashboard-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,25 @@ vi.mock("@/features/dashboard/components/account-cards", () => ({
}));

vi.mock("@/features/dashboard/components/account-list", () => ({
AccountList: ({ accounts }: { accounts: Array<{ accountId: string }> }) => {
accountListSpy(accounts);
return <div data-testid="account-list">List for {accounts.length} accounts</div>;
AccountList: ({
accounts,
sort,
onSortChange,
}: {
accounts: Array<{ accountId: string }>;
sort: { key: string; direction: string } | null;
onSortChange: (sort: { key: string; direction: string }) => void;
}) => {
accountListSpy({ accounts, sort });
return (
<button
type="button"
data-testid="account-list"
onClick={() => onSortChange({ key: "credits", direction: "desc" })}
>
List for {accounts.length} accounts
</button>
);
},
}));

Expand Down Expand Up @@ -103,6 +119,7 @@ describe("DashboardPage", () => {
useDashboardPreferencesStore.setState({
accountBurnrateEnabled: true,
accountViewMode: "cards",
accountListSort: null,
initialized: true,
});
});
Expand Down Expand Up @@ -212,7 +229,30 @@ describe("DashboardPage", () => {

expect(screen.getByTestId("account-list")).toHaveTextContent("List for 2 accounts");
expect(screen.queryByTestId("account-cards")).not.toBeInTheDocument();
expect(accountListSpy).toHaveBeenCalledWith(overview.accounts);
expect(accountListSpy).toHaveBeenCalledWith({ accounts: overview.accounts, sort: null });
expect(useDashboardPreferencesStore.getState().accountViewMode).toBe("list");
});

it("passes persisted account list sort through and updates it from the list", async () => {
const user = userEvent.setup();
const overview = mockReadyDashboard();
useDashboardPreferencesStore.setState({
accountBurnrateEnabled: true,
accountViewMode: "list",
accountListSort: { key: "quota", direction: "asc" },
initialized: true,
});

renderWithProviders(<DashboardPage />);

expect(screen.getByTestId("account-list")).toHaveTextContent("List for 2 accounts");
expect(accountListSpy).toHaveBeenCalledWith({
accounts: overview.accounts,
sort: { key: "quota", direction: "asc" },
});

await user.click(screen.getByTestId("account-list"));

expect(useDashboardPreferencesStore.getState().accountListSort).toEqual({ key: "credits", direction: "desc" });
});
});
10 changes: 9 additions & 1 deletion frontend/src/features/dashboard/components/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ export function DashboardPage() {
const isDark = useThemeStore((s) => s.theme === "dark");
const showAccountBurnrate = useDashboardPreferencesStore((s) => s.accountBurnrateEnabled);
const accountViewMode = useDashboardPreferencesStore((s) => s.accountViewMode);
const accountListSort = useDashboardPreferencesStore((s) => s.accountListSort);
const setAccountViewMode = useDashboardPreferencesStore((s) => s.setAccountViewMode);
const setAccountListSort = useDashboardPreferencesStore((s) => s.setAccountListSort);
const canWrite = useAuthStore((state) => state.canWrite);
const overviewTimeframe = useMemo(
() => parseOverviewTimeframe(searchParams.get("overviewTimeframe")),
Expand Down Expand Up @@ -237,7 +239,13 @@ export function DashboardPage() {
<AccountViewModeToggle value={accountViewMode} onChange={setAccountViewMode} />
</div>
{accountViewMode === "list" ? (
<AccountList accounts={overview?.accounts ?? []} readOnly={!canWrite} onAction={handleAccountAction} />
<AccountList
accounts={overview?.accounts ?? []}
readOnly={!canWrite}
sort={accountListSort}
onSortChange={setAccountListSort}
onAction={handleAccountAction}
/>
) : (
<AccountCards accounts={overview?.accounts ?? []} readOnly={!canWrite} onAction={handleAccountAction} />
)}
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/hooks/use-dashboard-preferences.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ describe("useDashboardPreferencesStore", () => {
useDashboardPreferencesStore.getState().initializePreferences();

expect(useDashboardPreferencesStore.getState().accountViewMode).toBe("cards");
expect(useDashboardPreferencesStore.getState().accountListSort).toBeNull();
expect(window.localStorage.getItem("codex-lb-dashboard-account-view-mode")).toBe("cards");
expect(window.localStorage.getItem("codex-lb-dashboard-account-list-sort")).toBeNull();
});

it("persists account view mode updates", async () => {
Expand All @@ -42,4 +44,43 @@ describe("useDashboardPreferencesStore", () => {
expect(useDashboardPreferencesStore.getState().accountViewMode).toBe("list");
expect(window.localStorage.getItem("codex-lb-dashboard-account-view-mode")).toBe("list");
});

it("persists account list sort updates", async () => {
const { useDashboardPreferencesStore } = await import("@/hooks/use-dashboard-preferences");

useDashboardPreferencesStore.getState().setAccountListSort({ key: "quota", direction: "asc" });

expect(useDashboardPreferencesStore.getState().accountListSort).toEqual({ key: "quota", direction: "asc" });
expect(window.localStorage.getItem("codex-lb-dashboard-account-list-sort")).toBe(
JSON.stringify({ key: "quota", direction: "asc" }),
);
});

it("restores stored account list sort on initialization", async () => {
window.localStorage.setItem(
"codex-lb-dashboard-account-list-sort",
JSON.stringify({ key: "credits", direction: "desc" }),
);
const { useDashboardPreferencesStore } = await import("@/hooks/use-dashboard-preferences");

useDashboardPreferencesStore.getState().initializePreferences();

expect(useDashboardPreferencesStore.getState().accountListSort).toEqual({ key: "credits", direction: "desc" });
expect(window.localStorage.getItem("codex-lb-dashboard-account-list-sort")).toBe(
JSON.stringify({ key: "credits", direction: "desc" }),
);
});

it("ignores invalid stored account list sort", async () => {
window.localStorage.setItem(
"codex-lb-dashboard-account-list-sort",
JSON.stringify({ key: "invalid", direction: "desc" }),
);
const { useDashboardPreferencesStore } = await import("@/hooks/use-dashboard-preferences");

useDashboardPreferencesStore.getState().initializePreferences();

expect(useDashboardPreferencesStore.getState().accountListSort).toBeNull();
expect(window.localStorage.getItem("codex-lb-dashboard-account-list-sort")).toBeNull();
});
});
53 changes: 52 additions & 1 deletion frontend/src/hooks/use-dashboard-preferences.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
import { create } from "zustand";

import type { AccountListSort, AccountListSortKey } from "@/features/dashboard/components/account-list";

const ACCOUNT_BURNRATE_STORAGE_KEY = "codex-lb-account-burnrate-enabled";
const ACCOUNT_VIEW_MODE_STORAGE_KEY = "codex-lb-dashboard-account-view-mode";
const ACCOUNT_LIST_SORT_STORAGE_KEY = "codex-lb-dashboard-account-list-sort";

export type DashboardAccountViewMode = "cards" | "list";

type DashboardPreferencesState = {
accountBurnrateEnabled: boolean;
accountViewMode: DashboardAccountViewMode;
accountListSort: AccountListSort;
initialized: boolean;
initializePreferences: () => void;
setAccountBurnrateEnabled: (enabled: boolean) => void;
setAccountViewMode: (mode: DashboardAccountViewMode) => void;
setAccountListSort: (sort: AccountListSort) => void;
};

const ACCOUNT_LIST_SORT_KEYS: AccountListSortKey[] = ["account", "status", "plan", "quota", "credits", "warmup"];

function isAccountListSortKey(value: unknown): value is AccountListSortKey {
return typeof value === "string" && ACCOUNT_LIST_SORT_KEYS.includes(value as AccountListSortKey);
}

function readStoredAccountBurnrateEnabled(): boolean | null {
if (typeof window === "undefined") {
return null;
Expand All @@ -36,6 +47,28 @@ function readStoredAccountViewMode(): DashboardAccountViewMode | null {
return stored === "cards" || stored === "list" ? stored : null;
}

function readStoredAccountListSort(): AccountListSort {
if (typeof window === "undefined") {
return null;
}
const stored = window.localStorage.getItem(ACCOUNT_LIST_SORT_STORAGE_KEY);
if (!stored) {
return null;
}
try {
const parsed = JSON.parse(stored) as { key?: unknown; direction?: unknown };
if (
isAccountListSortKey(parsed.key) &&
(parsed.direction === "asc" || parsed.direction === "desc")
) {
return { key: parsed.key, direction: parsed.direction };
}
} catch {
return null;
}
return null;
}

function persistAccountBurnrateEnabled(enabled: boolean): void {
if (typeof window === "undefined") {
return;
Expand All @@ -50,16 +83,30 @@ function persistAccountViewMode(mode: DashboardAccountViewMode): void {
window.localStorage.setItem(ACCOUNT_VIEW_MODE_STORAGE_KEY, mode);
}

function persistAccountListSort(sort: AccountListSort): void {
if (typeof window === "undefined") {
return;
}
if (sort === null) {
window.localStorage.removeItem(ACCOUNT_LIST_SORT_STORAGE_KEY);
return;
}
window.localStorage.setItem(ACCOUNT_LIST_SORT_STORAGE_KEY, JSON.stringify(sort));
}

export const useDashboardPreferencesStore = create<DashboardPreferencesState>((set) => ({
accountBurnrateEnabled: true,
accountViewMode: "cards",
accountListSort: null,
initialized: false,
initializePreferences: () => {
const accountBurnrateEnabled = readStoredAccountBurnrateEnabled() ?? true;
const accountViewMode = readStoredAccountViewMode() ?? "cards";
const accountListSort = readStoredAccountListSort();
persistAccountBurnrateEnabled(accountBurnrateEnabled);
persistAccountViewMode(accountViewMode);
set({ accountBurnrateEnabled, accountViewMode, initialized: true });
persistAccountListSort(accountListSort);
set({ accountBurnrateEnabled, accountViewMode, accountListSort, initialized: true });
},
setAccountBurnrateEnabled: (enabled) => {
persistAccountBurnrateEnabled(enabled);
Expand All @@ -69,4 +116,8 @@ export const useDashboardPreferencesStore = create<DashboardPreferencesState>((s
persistAccountViewMode(mode);
set({ accountViewMode: mode, initialized: true });
},
setAccountListSort: (sort) => {
persistAccountListSort(sort);
set({ accountListSort: sort, initialized: true });
},
}));