From 47fbc212831058f1a0f1226dd262bf5c37870e34 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 9 Sep 2026 10:31:04 +0300 Subject: [PATCH] feat: compact portfolio currency totals --- src/pages/account/ui/AccountPage.module.css | 48 ++++- src/pages/account/ui/AccountPage.test.tsx | 168 ++++++++++++++---- .../ui/components/AccountPageLoading.view.tsx | 21 ++- .../ui/components/PortfolioTotals.test.tsx | 85 +++++++++ .../ui/components/PortfolioTotals.view.tsx | 57 ++++-- src/shared/i18n/locales/ru.ts | 5 + src/shared/lib/display.ts | 2 +- src/shared/lib/index.ts | 1 + 8 files changed, 335 insertions(+), 52 deletions(-) create mode 100644 src/pages/account/ui/components/PortfolioTotals.test.tsx diff --git a/src/pages/account/ui/AccountPage.module.css b/src/pages/account/ui/AccountPage.module.css index 18aadb2..c215a8f 100644 --- a/src/pages/account/ui/AccountPage.module.css +++ b/src/pages/account/ui/AccountPage.module.css @@ -2,7 +2,6 @@ max-width: 960px; } -.total, .tile, .cash { border-color: var(--mantine-color-gray-3); @@ -10,8 +9,51 @@ } .total { - padding: var(--mantine-spacing-md); - border-top: 3px solid var(--mantine-color-blue-6); + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; + padding: 12px; + border-color: var(--mantine-color-gray-3); + border-radius: 8px; + background: var(--mantine-color-white); +} + +.totalBadge { + min-width: 40px; + min-height: 24px; + max-width: 100%; + padding-inline: 8px; + border-radius: 4px; + background: var(--mantine-color-blue-0); + color: var(--mantine-color-blue-6); + font-size: 18px; + font-weight: 600; + line-height: 24px; + text-align: center; + overflow-wrap: anywhere; +} + +.totalAmount { + flex-basis: max-content; + min-width: 0; + max-width: 100%; + color: var(--mantine-color-dark-7); + font-size: 20px; + font-weight: 600; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.totalLeadingDigits { + white-space: nowrap; +} + +.totalAmountSkeleton { + max-width: calc(100% - 50px); + margin-block: 5px; } .tile, diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index 70ae459..9f053b5 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -1,6 +1,6 @@ import { MantineProvider } from "@mantine/core"; import { type QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { describe, expect, it, vi } from "vitest"; @@ -113,6 +113,67 @@ function emptyProps() { } describe("AccountPage states", () => { + it("keeps initial placeholders decorative without amounts or focus targets", () => { + const { container } = renderPage(); + expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true"); + expect(screen.queryByRole("definition")).not.toBeInTheDocument(); + expect(container.querySelector("[tabindex], button, a, input")).toBeNull(); + expect(container).not.toHaveTextContent(/₽|\$|€|0,00/); + }); + + it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])( + "does not offer retry without cached data for %s", + (code) => { + renderPage(); + expect( + screen.queryByRole("button", { name: "Повторить" }), + ).not.toBeInTheDocument(); + expect(screen.getByRole("link")).toHaveAttribute( + "href", + code === "NOT_FOUND" ? "/accounts" : "/settings", + ); + }, + ); + + it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])( + "allows retry for %s", + async (code) => { + const retry = vi.fn(); + renderPage(); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + expect(retry).toHaveBeenCalledOnce(); + }, + ); + + it("shows only supplied totals despite cash and positions with unknown value", () => { + renderPage(); + expect( + screen.getAllByRole("definition").map((node) => node.textContent), + ).toEqual(["1 234,5 ₽"]); + expect( + screen.queryByText(/полная оценка|общая стоимость/i), + ).not.toBeInTheDocument(); + }); + + it.each([true, false])( + "keeps a nonempty portfolio without totals (positions: %s)", + async (hasPositions) => { + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json({ + ...portfolio, + totalsByCurrency: [], + positions: hasPositions ? portfolio.positions : [], + }), + ), + ); + renderAccountRoute(createTestQueryClient()); + await screen.findByRole("heading", { name: "Портфель", level: 1 }); + expect(screen.queryByRole("definition")).not.toBeInTheDocument(); + expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); + }, + ); + it("renders loading and empty states", () => { renderPage(); expect(screen.getByRole("status")).toBeInTheDocument(); @@ -512,40 +573,76 @@ describe("AccountPage states", () => { expect(screen.getByText("Вторая")).toBeInTheDocument(); }); - it("keeps the cached portfolio visible with distinct server and refresh warnings", async () => { - let requestCount = 0; - server.use( - http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { - requestCount += 1; - if (requestCount === 1) return HttpResponse.json(portfolio); - return HttpResponse.json( - { code: "TBANK_UNAVAILABLE" }, - { status: 503 }, - ); - }), - ); - const queryClient = createTestQueryClient(); - renderAccountRoute(queryClient); + it.each([503, 404])( + "preserves totals and UTC snapshot through refresh error %i, retry and success", + async (status) => { + let requestCount = 0; + let resolveRetry: ((response: Response) => void) | undefined; + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { + requestCount += 1; + if (requestCount === 1) return HttpResponse.json(portfolio); + if (requestCount === 3) + return new Promise((resolve) => { + resolveRetry = resolve; + }); + return HttpResponse.json( + { code: status === 404 ? "NOT_FOUND" : "TBANK_UNAVAILABLE" }, + { status }, + ); + }), + ); + const queryClient = createTestQueryClient(); + renderAccountRoute(queryClient); - await screen.findByText("Вторая"); - void queryClient.invalidateQueries({ - queryKey: portfolioQueryOptions("account-1").queryKey, - }); + await screen.findByText("Вторая"); + const snapshotText = screen.getByText(/Данные получены/).textContent; + expect(snapshotText).toContain("10:00"); + void queryClient.invalidateQueries({ + queryKey: portfolioQueryOptions("account-1").queryKey, + }); - expect( - await screen.findByText( - "Не удалось обновить портфель. Показан последний полученный снимок.", - ), - ).toBeInTheDocument(); - expect( - screen.getByText( - "T-Bank временно недоступен. Показан последний успешный снимок.", - ), - ).toBeInTheDocument(); - expect(screen.getByText("Вторая")).toBeInTheDocument(); - screen.getByRole("button", { name: "Повторить" }).click(); - await waitFor(() => expect(requestCount).toBe(3)); - }); + expect( + await screen.findByText( + "Не удалось обновить портфель. Показан последний полученный снимок.", + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + "T-Bank временно недоступен. Показан последний успешный снимок.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Вторая")).toBeInTheDocument(); + expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽"); + expect(screen.getByText(/Данные получены/).textContent).toBe( + snapshotText, + ); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); + await waitFor(() => expect(requestCount).toBe(3)); + expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽"); + expect(screen.getByText(/Данные получены/).textContent).toBe( + snapshotText, + ); + resolveRetry?.( + HttpResponse.json({ + ...portfolio, + isStale: false, + lastSuccessfulAt: "2026-08-14T11:30:00Z", + totalsByCurrency: [{ currency: "USD", value: "-0.5" }], + }), + ); + await waitFor(() => + expect(screen.getByRole("definition")).toHaveTextContent("-0,5 $"), + ); + expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30"); + expect( + screen.queryByText(/Не удалось обновить портфель/), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/T-Bank временно недоступен/), + ).not.toBeInTheDocument(); + }, + ); it("keeps the cached portfolio visible during a background refresh", async () => { let requestCount = 0; @@ -569,6 +666,11 @@ describe("AccountPage states", () => { await waitFor(() => expect(requestCount).toBe(2)); expect(screen.getByText("Вторая")).toBeInTheDocument(); + expect(screen.getByRole("definition")).toHaveTextContent("1 234,5 ₽"); + expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00"); + expect( + screen.queryByRole("status", { name: "Загрузка" }), + ).not.toBeInTheDocument(); expect( screen.getByRole("status", { name: "Обновляем портфель" }), ).toBeInTheDocument(); diff --git a/src/pages/account/ui/components/AccountPageLoading.view.tsx b/src/pages/account/ui/components/AccountPageLoading.view.tsx index bb1ee89..43483b3 100644 --- a/src/pages/account/ui/components/AccountPageLoading.view.tsx +++ b/src/pages/account/ui/components/AccountPageLoading.view.tsx @@ -1,14 +1,31 @@ -import { Skeleton } from "@mantine/core"; +import { Paper, SimpleGrid, Skeleton } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { LoadingSkeleton } from "@/shared/ui"; +import classes from "../AccountPage.module.css"; const AccountPageLoading: FC = () => { const { t } = useTranslation(); return ( - + {[0, 1, 2].map((index) => ( ))} diff --git a/src/pages/account/ui/components/PortfolioTotals.test.tsx b/src/pages/account/ui/components/PortfolioTotals.test.tsx new file mode 100644 index 0000000..f66ca1a --- /dev/null +++ b/src/pages/account/ui/components/PortfolioTotals.test.tsx @@ -0,0 +1,85 @@ +import { MantineProvider } from "@mantine/core"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { PortfolioTotals } from "./PortfolioTotals.view"; + +describe("PortfolioTotals currency presentation", () => { + it.each([0, 1, 3, 5])( + "renders only the %i supplied totals in upstream order", + (count) => { + const totals = [ + { currency: "EUR", value: "3.00" }, + { currency: "USD", value: "2.00" }, + { currency: "RUB", value: "1.00" }, + { currency: "CAD", value: "4.00" }, + { currency: "XYZ", value: "5.00" }, + ].slice(0, count); + const { container } = render( + + + , + ); + expect( + screen.queryAllByRole("definition").map((node) => node.textContent), + ).toEqual( + ["3,00 €", "2,00 $", "1,00 ₽", "4,00 CAD", "5,00 XYZ"].slice(0, count), + ); + expect(container.querySelectorAll("dl")).toHaveLength(count); + expect( + container.querySelector("[tabindex], button, a, input"), + ).toBeNull(); + }, + ); + + it.each([ + ["RUB", "125000.50", "125 000,50 ₽", "Российский рубль", "₽"], + ["USD", "320.00", "320,00 $", "Доллар США", "$"], + ["EUR", "9876.54321", "9 876,54321 €", "Евро", "€"], + [ + "RUB", + "123456789012.345678901", + "123 456 789 012,345678901 ₽", + "Российский рубль", + "₽", + ], + ["USD", "-1234567.890123", "-1 234 567,890123 $", "Доллар США", "$"], + ["EUR", "987654321.000001", "987 654 321,000001 €", "Евро", "€"], + ["USD", "-0.5", "-0,5 $", "Доллар США", "$"], + ["CAD", "20.00", "20,00 CAD", "CAD", "CAD"], + ["XYZ", "1.000", "1,000 XYZ", "XYZ", "XYZ"], + ])( + "preserves %s %s and its currency identity", + (currency, value, expected, name, symbol) => { + render( + + + , + ); + const term = screen.getByRole("term"); + expect(term).toHaveTextContent(name); + expect(term.querySelector('[aria-hidden="true"]')).toHaveTextContent( + symbol, + ); + expect(screen.getByRole("definition")).toHaveTextContent(expected); + expect(term.parentElement).toBe( + screen.getByRole("definition").parentElement, + ); + }, + ); + + it("keeps USD and CAD as distinct supplied values", () => { + render( + + + , + ); + expect( + screen.getAllByRole("definition").map((node) => node.textContent), + ).toEqual(["1,00 $", "2,00 CAD"]); + }); +}); diff --git a/src/pages/account/ui/components/PortfolioTotals.view.tsx b/src/pages/account/ui/components/PortfolioTotals.view.tsx index e0f6b83..f261307 100644 --- a/src/pages/account/ui/components/PortfolioTotals.view.tsx +++ b/src/pages/account/ui/components/PortfolioTotals.view.tsx @@ -1,7 +1,8 @@ -import { Paper, SimpleGrid, Text } from "@mantine/core"; +import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core"; import type { FC } from "react"; +import { useTranslation } from "react-i18next"; import type { Portfolio } from "@/entities/portfolio"; -import { formatMoney } from "@/shared/lib"; +import { formatDecimal } from "@/shared/lib"; import classes from "../AccountPage.module.css"; type PortfolioTotalsProps = { @@ -10,19 +11,49 @@ type PortfolioTotalsProps = { const PortfolioTotals: FC = (props) => { const { totals } = props; + const { t } = useTranslation(); + const currencies: Record = { + RUB: { symbol: "₽", name: t("portfolio.currencies.RUB") }, + USD: { symbol: "$", name: t("portfolio.currencies.USD") }, + EUR: { symbol: "€", name: t("portfolio.currencies.EUR") }, + }; return ( - - {totals.map((total) => ( - - - {total.currency} - - - {formatMoney(total)} - - - ))} + + {totals.map((total) => { + const amount = formatDecimal(total.value); + return ( + + + + + {currencies[total.currency]?.name ?? total.currency} + + + + {amount.startsWith("-") ? ( + <> + + {amount.slice(0, 2)} + + {amount.slice(2)} + + ) : ( + amount + )}{" "} + {currencies[total.currency]?.symbol ?? total.currency} + + + ); + })} ); }; diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index 6764df3..0e3eb4e 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -62,6 +62,11 @@ const ru = { }, account: { title: "Счет" }, portfolio: { + currencies: { + RUB: "Российский рубль", + USD: "Доллар США", + EUR: "Евро", + }, title: "Портфель", emptyTitle: "Портфель пока пуст", emptyText: "На счете нет позиций и доступных денежных остатков.", diff --git a/src/shared/lib/display.ts b/src/shared/lib/display.ts index 33f7b73..d5518ca 100644 --- a/src/shared/lib/display.ts +++ b/src/shared/lib/display.ts @@ -32,4 +32,4 @@ function instrumentLogoUrl(logoName: string | null | undefined): string | null { : null; } -export { formatMoney, formatSnapshotTime, instrumentLogoUrl }; +export { formatDecimal, formatMoney, formatSnapshotTime, instrumentLogoUrl }; diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index 4363ab8..796a25d 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,4 +1,5 @@ export { + formatDecimal, formatMoney, formatSnapshotTime, instrumentLogoUrl,