feat: add portfolio

This commit is contained in:
2026-08-13 22:45:00 +03:00
parent 6e89c1a8f7
commit 9156f29707
11 changed files with 594 additions and 12 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
export type { BrokerageAccount } from "./api/useBrokerageAccounts";
export {
brokerageAccountsQueryKey,
useBrokerageAccounts,
} from "./api/useBrokerageAccounts";
export { errorCode } from "./model/display";
export { accountTypeKey, errorCode } from "./model/display";
export { BrokerageAccountList } from "./ui/BrokerageAccountList";
@@ -0,0 +1,45 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
const { get } = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("../../../shared/api", () => ({ apiClient: { GET: get } }));
import { portfolioQueryPolicy, usePortfolio } from "./usePortfolio";
describe("usePortfolio", () => {
it("uses the account endpoint with a 30 second cache and no retry", async () => {
const client = new QueryClient();
get.mockResolvedValueOnce({
data: {
lastSuccessfulAt: "2026-08-13T10:00:00Z",
isStale: false,
totalsByCurrency: [],
positions: [],
cashBalances: [],
},
});
const wrapper = ({ children }: { children: ReactNode }) => (
<MantineProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</MantineProvider>
);
const { result } = renderHook(() => usePortfolio("account-1", "token"), {
wrapper,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(get).toHaveBeenCalledWith(
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{
params: { path: { accountId: "account-1" } },
headers: { Authorization: "Bearer token" },
},
);
expect(portfolioQueryPolicy).toEqual({ staleTime: 30_000, retry: false });
});
});
@@ -0,0 +1,36 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../../../shared/api";
import type { paths } from "../../../shared/api/generated/schema";
export const portfolioQueryKey = (accountId: string) =>
["portfolio", accountId] as const;
export const portfolioQueryPolicy = {
staleTime: 30_000,
retry: false,
} as const;
export type Portfolio =
paths["/api/v1/brokerage-accounts/{accountId}/portfolio"]["get"]["responses"][200]["content"]["application/json"];
export function usePortfolio(
accountId: string | undefined,
accessToken: string | null,
) {
return useQuery({
queryKey: portfolioQueryKey(accountId ?? ""),
enabled: Boolean(accountId && accessToken),
...portfolioQueryPolicy,
queryFn: async () => {
const { data, error } = await apiClient.GET(
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{
params: { path: { accountId: accountId ?? "" } },
headers: { Authorization: `Bearer ${accessToken}` },
},
);
if (error) throw error;
return data as Portfolio;
},
});
}
+12
View File
@@ -0,0 +1,12 @@
export type { Portfolio } from "./api/usePortfolio";
export {
portfolioQueryKey,
portfolioQueryPolicy,
usePortfolio,
} from "./api/usePortfolio";
export {
formatDecimal,
formatMoney,
formatSnapshotTime,
portfolioErrorCode,
} from "./model/display";
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { formatDecimal, formatMoney } from "./display";
describe("portfolio display helpers", () => {
it("formats decimal strings without financial arithmetic", () => {
expect(formatDecimal("-1234567.89")).toBe("-1 234 567,89");
expect(formatMoney({ value: "10", currency: "RUB" })).toBe("10 ₽");
expect(formatMoney({ value: "0.5", currency: "USD" })).toBe("0,5 USD");
expect(formatMoney(null)).toBe("—");
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { Portfolio } from "../api/usePortfolio";
type Money = Portfolio["totalsByCurrency"][number];
export function formatDecimal(value: string): string {
const [integer, fraction] = value.split(".");
const sign = integer.startsWith("-") ? "-" : "";
const grouped = (sign ? integer.slice(1) : integer).replace(
/\B(?=(\d{3})+(?!\d))/g,
" ",
);
return `${sign}${grouped}${fraction ? `,${fraction}` : ""}`;
}
export function formatMoney(money: Money | null): string {
if (!money) return "—";
return `${formatDecimal(money.value)} ${money.currency === "RUB" ? "₽" : money.currency}`;
}
export function formatSnapshotTime(value: string): string {
return new Intl.DateTimeFormat("ru-RU", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(value));
}
export function portfolioErrorCode(error: unknown): string | undefined {
return typeof error === "object" && error !== null && "code" in error
? String(error.code)
: undefined;
}