diff --git a/frontend/src/features/dashboard/components/account-list.test.tsx b/frontend/src/features/dashboard/components/account-list.test.tsx index 918b69e3d..bafee2cd6 100644 --- a/frontend/src/features/dashboard/components/account-list.test.tsx +++ b/frontend/src/features/dashboard/components/account-list.test.tsx @@ -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( + , + ); + + 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( diff --git a/frontend/src/features/dashboard/components/account-list.tsx b/frontend/src/features/dashboard/components/account-list.tsx index 43f1a53e1..cafd67bec 100644 --- a/frontend/src/features/dashboard/components/account-list.tsx +++ b/frontend/src/features/dashboard/components/account-list.tsx @@ -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; @@ -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(null); + const [uncontrolledSort, setUncontrolledSort] = useState(null); + const sort = controlledSort === undefined ? uncontrolledSort : controlledSort; const sortedAccounts = useMemo(() => { if (!sort) { return accounts; @@ -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) { diff --git a/frontend/src/features/dashboard/components/dashboard-page.test.tsx b/frontend/src/features/dashboard/components/dashboard-page.test.tsx index ff67e9048..caf33e7f1 100644 --- a/frontend/src/features/dashboard/components/dashboard-page.test.tsx +++ b/frontend/src/features/dashboard/components/dashboard-page.test.tsx @@ -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
List for {accounts.length} accounts
; + 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 ( + + ); }, })); @@ -103,6 +119,7 @@ describe("DashboardPage", () => { useDashboardPreferencesStore.setState({ accountBurnrateEnabled: true, accountViewMode: "cards", + accountListSort: null, initialized: true, }); }); @@ -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(); + + 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" }); + }); }); diff --git a/frontend/src/features/dashboard/components/dashboard-page.tsx b/frontend/src/features/dashboard/components/dashboard-page.tsx index d5864f791..2e4d2a690 100644 --- a/frontend/src/features/dashboard/components/dashboard-page.tsx +++ b/frontend/src/features/dashboard/components/dashboard-page.tsx @@ -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")), @@ -237,7 +239,13 @@ export function DashboardPage() { {accountViewMode === "list" ? ( - + ) : ( )} diff --git a/frontend/src/hooks/use-dashboard-preferences.test.ts b/frontend/src/hooks/use-dashboard-preferences.test.ts index 60ad91462..92d9fcdbb 100644 --- a/frontend/src/hooks/use-dashboard-preferences.test.ts +++ b/frontend/src/hooks/use-dashboard-preferences.test.ts @@ -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 () => { @@ -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(); + }); }); diff --git a/frontend/src/hooks/use-dashboard-preferences.ts b/frontend/src/hooks/use-dashboard-preferences.ts index 9d3c73fca..1d9782022 100644 --- a/frontend/src/hooks/use-dashboard-preferences.ts +++ b/frontend/src/hooks/use-dashboard-preferences.ts @@ -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; @@ -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; @@ -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((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); @@ -69,4 +116,8 @@ export const useDashboardPreferencesStore = create((s persistAccountViewMode(mode); set({ accountViewMode: mode, initialized: true }); }, + setAccountListSort: (sort) => { + persistAccountListSort(sort); + set({ accountListSort: sort, initialized: true }); + }, }));