From f6db28defa51bd9d8fe932ceae4c3c720aba7eb3 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 27 Sep 2026 11:38:20 +0300 Subject: [PATCH 01/36] fix: resolve frontend audit findings Repair investment recovery, draft preservation, date validation, currency presentation, accessibility, and route error recovery. Correct Docker and dependency checks and consume explicit income roles. --- .dockerignore | 17 +- dependency-cruiser.cjs | 12 +- package.json | 2 +- packages/tokens/tokens.json | 2 +- packages/ui/src/EntryCard/EntryCard.test.tsx | 12 +- packages/ui/src/EntryCard/EntryCard.tsx | 6 +- packages/ui/src/theme.test.ts | 3 + packages/ui/src/theme.ts | 4 +- .../RouteErrorBoundary.test.tsx | 72 +++- .../RouteErrorBoundary/RouteErrorBoundary.tsx | 10 +- .../bank-card/model/form-validation.ts | 2 +- src/entities/bank-card/model/period.test.ts | 25 ++ src/entities/bank-card/model/period.ts | 6 +- .../CategoryManager/CategoryManager.test.tsx | 151 ++++++++- .../ui/CategoryManager/CategoryManager.tsx | 12 +- .../model/screen-sync.test.tsx | 205 ++++++++++- .../tbank-connection/model/screen-sync.ts | 40 ++- .../TbankConnectionSection.test.tsx | 126 ++++++- .../TbankConnectionSection.tsx | 2 +- .../TbankConnectionSection.vm.ts | 13 +- .../TbankConnectionSectionLoaded.tsx | 39 ++- .../AccountEventsPage.filters.test.tsx | 200 +++++++++++ .../AccountEventsPage.test.tsx | 3 +- .../AccountEventsPage/AccountEventsPage.tsx | 2 +- .../AccountEventsPage/AccountEventsPage.vm.ts | 51 ++- .../AccountEventsPageError.view.tsx | 13 +- .../AccountIncomePage.test.tsx | 209 ++++++++++-- .../AccountIncomePage/AccountIncomePage.vm.ts | 20 +- .../AccountOperationsPage.test.tsx | 199 ++++++++++- .../AccountOperationsPage.vm.ts | 66 ++-- .../AccountOperationsPageError.view.tsx | 11 +- .../account/ui/AccountPage/AccountPage.vm.ts | 4 +- .../ui/AccountsPage/AccountsPage.test.tsx | 23 +- .../ui/AccountsPage/AccountsPage.vm.ts | 6 +- src/pages/bond/ui/BondPage/BondPage.vm.ts | 16 +- src/pages/bonds/ui/BondsPage/BondsPage.vm.ts | 2 +- src/pages/card/ui/CardPage/CardPage.test.tsx | 25 +- .../stock/ui/StockPage/StockPage.test.tsx | 86 ++++- .../stock/ui/StockPage/StockPage.view.tsx | 10 +- src/pages/stock/ui/StockPage/StockPage.vm.ts | 16 +- .../stocks/ui/StocksPage/StocksPage.vm.ts | 2 +- src/shared/api/generated/schema.d.ts | 111 +++++- src/shared/api/resource-query.ts | 17 +- src/shared/api/spec/openapi.json | 320 ++++++++++++++++-- src/shared/i18n/locales/ru.ts | 7 + src/shared/theme/Foundations.stories.tsx | 27 ++ src/shared/theme/contrast.test.ts | 11 +- tsconfig.dependency-cruiser.json | 12 + tsconfig.node.json | 1 + vitest.config.ts | 1 - 50 files changed, 2017 insertions(+), 215 deletions(-) create mode 100644 src/entities/bank-card/model/period.test.ts create mode 100644 tsconfig.dependency-cruiser.json diff --git a/.dockerignore b/.dockerignore index 0c527df..bdbb86c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,13 +22,7 @@ !packages/ui/vite.config.ts !packages/ui/tsconfig.json !packages/ui/src/ -!packages/ui/src/theme.ts -!packages/ui/src/styles.css -!packages/ui/src/index.ts -!packages/ui/src/StateView.tsx -!packages/ui/src/LoadingSkeleton.tsx -!packages/ui/src/EntryCard.tsx -!packages/ui/src/EntryCard.module.css +!packages/ui/src/** !packages/icons/ !packages/icons/package.json !packages/icons/tsconfig.json @@ -40,3 +34,12 @@ !packages/icons/src/svg/AddIcon.tsx !packages/icons/src/svg/EditIcon.tsx !packages/icons/src/svg/DeleteIcon.tsx + +# Keep local dependencies, outputs and secrets out of allowed source trees. +**/node_modules +**/dist +**/coverage +**/storybook-static +**/.env* +**/*.pem +**/*.key diff --git a/dependency-cruiser.cjs b/dependency-cruiser.cjs index a2d1217..2275c4b 100644 --- a/dependency-cruiser.cjs +++ b/dependency-cruiser.cjs @@ -1,5 +1,11 @@ module.exports = { forbidden: [ + { + name: "no-unresolved", + severity: "error", + from: {}, + to: { couldNotResolve: true }, + }, { name: "packages-must-not-depend-on-app", severity: "error", @@ -57,7 +63,11 @@ module.exports = { options: { exclude: "^packages/[^/]+/dist/", doNotFollow: { path: "node_modules" }, - tsConfig: { fileName: "tsconfig.app.json" }, + tsConfig: { fileName: "tsconfig.dependency-cruiser.json" }, + enhancedResolveOptions: { + exportsFields: ["exports"], + conditionNames: ["import", "require", "node", "default"], + }, tsPreCompilationDeps: true, }, }; diff --git a/package.json b/package.json index b8ec252..275132b 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "openapi:check": "node scripts/check-openapi.mjs", "knip": "npm run build:packages && knip", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", - "verify": "npm run build:packages && npm run openapi:check && npm run typecheck && npm run lint && npm run test:coverage && npm run knip && npm run deps && npm run deps:storybook && npm run build && npm run build:catalog", + "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", "prepare": "husky", "preview": "vite preview", "deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long", diff --git a/packages/tokens/tokens.json b/packages/tokens/tokens.json index 4f5d284..c0b43fc 100644 --- a/packages/tokens/tokens.json +++ b/packages/tokens/tokens.json @@ -131,7 +131,7 @@ }, "muted": { "color": "gray", - "shade": 6, + "shade": 7, "description": "Вторичный текст; проверяйте контраст при выборе фона" }, "border": { diff --git a/packages/ui/src/EntryCard/EntryCard.test.tsx b/packages/ui/src/EntryCard/EntryCard.test.tsx index 213e3d1..2d0bbcf 100644 --- a/packages/ui/src/EntryCard/EntryCard.test.tsx +++ b/packages/ui/src/EntryCard/EntryCard.test.tsx @@ -4,7 +4,7 @@ import { assert, describe, expect, it } from "vitest"; import { EntryCard } from "./EntryCard"; describe("EntryCard", () => { - it("keeps the fallback after an image fails", () => { + it("keeps a failed image fallback until a new source is supplied", () => { const props = { title: "Пример", metadata: "Описание", @@ -31,5 +31,15 @@ describe("EntryCard", () => { expect(container.querySelector("img")).toBeNull(); expect(screen.getByText("У")).toBeInTheDocument(); expect(screen.getByText("Обновлённый пример")).toBeInTheDocument(); + rerender( + + + , + ); + expect(container.querySelector("img")).toHaveAttribute( + "src", + "/replacement-logo.png", + ); + expect(screen.queryByText("У")).not.toBeInTheDocument(); }); }); diff --git a/packages/ui/src/EntryCard/EntryCard.tsx b/packages/ui/src/EntryCard/EntryCard.tsx index 455e2c4..535f0aa 100644 --- a/packages/ui/src/EntryCard/EntryCard.tsx +++ b/packages/ui/src/EntryCard/EntryCard.tsx @@ -1,5 +1,5 @@ import { Avatar, Paper, Stack, Text } from "@mantine/core"; -import { type FC, type ReactNode, useState } from "react"; +import type { FC, ReactNode } from "react"; import classes from "./EntryCard.module.css"; type EntryCardProps = { @@ -17,7 +17,6 @@ type EntryCardProps = { }; const EntryCard: FC = (props) => { - const [imageFailed, setImageFailed] = useState(false); return ( = (props) => { aria-hidden="true" radius="md" size={40} - src={imageFailed ? null : props.imageSrc} - onError={() => setImageFailed(true)} + src={props.imageSrc} > {props.icon ?? props.initial} diff --git a/packages/ui/src/theme.test.ts b/packages/ui/src/theme.test.ts index e689f76..08476de 100644 --- a/packages/ui/src/theme.test.ts +++ b/packages/ui/src/theme.test.ts @@ -25,5 +25,8 @@ describe("design theme adapter", () => { expect(Object.keys(variables)).toHaveLength( Object.keys(source.color.roles).length, ); + expect(cssVariablesResolver(resolved).light["--mantine-color-dimmed"]).toBe( + variables["--wl-color-muted"], + ); }); }); diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index ba8504b..ea1568a 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -79,7 +79,9 @@ const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({ getThemeColor(reference, resolvedTheme), ]), ), - light: {}, + light: { + "--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme), + }, dark: {}, }); diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx index 7631a9f..8f66930 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx @@ -1,6 +1,6 @@ import { MantineProvider } from "@mantine/core"; -import { render, screen, waitFor } from "@testing-library/react"; -import type { FC } from "react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { type FC, lazy, Suspense } from "react"; import { MemoryRouter, useLocation, useNavigate } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { RouteErrorBoundary } from "./RouteErrorBoundary"; @@ -52,6 +52,74 @@ function renderBoundary(path: string) { } describe("RouteErrorBoundary", () => { + it.each(["/accounts", "/login"])( + "recovers a transient failure through the fallback on %s itself", + async (path) => { + authenticated = path === "/accounts"; + let failing = true; + const RecoverableRoute: FC = () => { + if (failing) throw new Error("temporary failure"); + return
recovered route
; + }; + render( + + + + + + + , + ); + const action = screen.getByRole("link", { name: "Продолжить" }); + failing = false; + fireEvent.click(action); + expect(await screen.findByText("recovered route")).toBeInTheDocument(); + }, + ); + + it("keeps a persistent failure recoverable without an automatic reset loop", () => { + authenticated = true; + renderBoundary("/accounts"); + fireEvent.click(screen.getByRole("link", { name: "Продолжить" })); + expect( + screen.getByRole("heading", { name: "Не удалось открыть страницу" }), + ).toBeInTheDocument(); + expect(screen.queryByText("test route failure")).not.toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Перезагрузить страницу" }), + ).toHaveAttribute("href", "/accounts"); + }); + + it("offers document navigation when React.lazy caches a rejected chunk", async () => { + authenticated = false; + const load = vi.fn(() => Promise.reject(new Error("chunk failed"))); + const Chunk = lazy(load); + render( + + + + loading chunk}> + + + + + , + ); + fireEvent.click(await screen.findByRole("link", { name: "Продолжить" })); + expect(load).toHaveBeenCalledTimes(1); + const reload = screen.getByRole("link", { name: "Перезагрузить страницу" }); + expect(reload).toHaveAttribute("href", "/login"); + // Cancel browser navigation in JSDOM only after proving React Router did not intercept it. + let intercepted = true; + const preventNavigation = (event: MouseEvent) => { + intercepted = event.defaultPrevented; + event.preventDefault(); + }; + document.addEventListener("click", preventNavigation, { once: true }); + fireEvent.click(reload); + expect(intercepted).toBe(false); + }); + it("offers login after a public route error without error details", () => { authenticated = false; renderBoundary("/login"); diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx index 450410a..a6a09ab 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx @@ -51,17 +51,23 @@ const RouteErrorFallback: FC = (props) => { + ); }; const RouteErrorBoundary: FC = (props) => { const { children } = props; - const { pathname, search } = useLocation(); + const { pathname, search, key } = useLocation(); const { user } = useAuth(); return ( - + {children} ); diff --git a/src/entities/bank-card/model/form-validation.ts b/src/entities/bank-card/model/form-validation.ts index 2e223e5..93c1864 100644 --- a/src/entities/bank-card/model/form-validation.ts +++ b/src/entities/bank-card/model/form-validation.ts @@ -16,7 +16,7 @@ function bankDateInputValue(value: string) { return value.replace(/^(\d{2})\.(\d{2})\.(\d{4})$/, "$3-$2-$1"); } function bankMonth(value: string | null) { - return value && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) + return value && value >= "0001-01" && /^\d{4}-(0[1-9]|1[0-2])$/.test(value) ? value : bankToday().slice(0, ISO_MONTH_LENGTH); } diff --git a/src/entities/bank-card/model/period.test.ts b/src/entities/bank-card/model/period.test.ts new file mode 100644 index 0000000..01a72e8 --- /dev/null +++ b/src/entities/bank-card/model/period.test.ts @@ -0,0 +1,25 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { bankPeriod } from "./period"; + +afterEach(() => vi.useRealTimers()); + +it.each([ + ["0001-01", "0001-01-01", "0001-01-31"], + ["0099-12", "0099-12-01", "0099-12-31"], + ["2026-09", "2026-09-01", "2026-09-30"], + ["2024-02", "2024-02-01", "2024-02-29"], + ["0004-02", "0004-02-01", "0004-02-29"], +])("keeps the ISO year for legacy month %s", (month, from, to) => { + expect(bankPeriod(new URLSearchParams({ month }))).toEqual({ from, to }); +}); + +it("rejects zero year consistently in legacy and explicit periods", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-27T10:00:00Z")); + for (const query of ["month=0000-01", "from=0000-01-01&to=0000-01-31"]) { + expect(bankPeriod(new URLSearchParams(query))).toEqual({ + from: "2026-09-01", + to: "2026-09-30", + }); + } +}); diff --git a/src/entities/bank-card/model/period.ts b/src/entities/bank-card/model/period.ts index 88ff129..d6af745 100644 --- a/src/entities/bank-card/model/period.ts +++ b/src/entities/bank-card/model/period.ts @@ -1,10 +1,12 @@ -import dayjs from "dayjs"; import { z } from "zod"; import { bankMonth } from "./form-validation"; const calendarDate = z.string().date(); +const ISO_DATE_LENGTH = 10; function bankPeriod(params: URLSearchParams) { const month = bankMonth(params.get("month")); + const monthEnd = new Date(`${month}-01T00:00:00Z`); + monthEnd.setUTCMonth(monthEnd.getUTCMonth() + 1, 0); const from = params.get("from"); const to = params.get("to"); return from && @@ -16,7 +18,7 @@ function bankPeriod(params: URLSearchParams) { ? { from, to } : { from: `${month}-01`, - to: dayjs(`${month}-01`).endOf("month").format("YYYY-MM-DD"), + to: monthEnd.toISOString().slice(0, ISO_DATE_LENGTH), }; } diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx index 4179ef3..d2fc5f8 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx @@ -1,6 +1,12 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { HttpResponse, http } from "msw"; import { describe, expect, it } from "vitest"; import { configureAuthClient } from "@/shared/api"; @@ -9,6 +15,149 @@ import { server } from "@/test/server"; import { CategoryManager } from "./CategoryManager"; describe("CategoryManager", () => { + it("ignores a late reload for A while editing B and submits B's draft and version", async () => { + configureAuthClient({ + accessToken: () => "test", + refresh: async () => false, + clear: () => {}, + }); + const categories = [ + { id: "a", name: "Первая", isDefault: false, version: 1 }, + { id: "b", name: "Вторая", isDefault: false, version: 4 }, + ]; + let reads = 0; + let release = () => {}; + const reloaded = new Promise((resolve) => { + release = resolve; + }); + const patches: { id: unknown; body: unknown }[] = []; + server.use( + http.get("*/api/v1/bank-cards/categories", async () => { + reads++; + if (reads === 3) await reloaded; + return HttpResponse.json( + categories.map((category) => + category.id === "a" && reads > 1 + ? { + ...category, + name: reads >= 3 ? "Первая обновлена" : category.name, + version: 2, + } + : category, + ), + ); + }), + http.patch( + "*/api/v1/bank-cards/categories/:id", + async ({ request, params }) => { + patches.push({ id: params.id, body: await request.json() }); + return patches.length === 1 + ? HttpResponse.json( + { code: "BANK_VERSION_CONFLICT" }, + { status: 409 }, + ) + : HttpResponse.json(categories[1]); + }, + ), + ); + render( + + + {}} /> + + , + ); + fireEvent.click( + (await screen.findAllByRole("button", { name: "Переименовать" }))[0], + ); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + fireEvent.click( + await screen.findByRole("button", { name: "Перечитать запись" }), + ); + await waitFor(() => expect(reads).toBe(3)); + fireEvent.click( + screen.getAllByRole("button", { name: "Переименовать" })[1], + ); + fireEvent.change(screen.getByLabelText(/Название категории/), { + target: { value: "Новая вторая" }, + }); + await act(async () => { + release(); + await reloaded; + }); + await screen.findByText("Первая обновлена"); + await waitFor(() => + expect( + screen.queryByRole("button", { + name: /Использовать актуальную версию/, + }), + ).not.toBeInTheDocument(), + ); + expect(screen.getByLabelText(/Название категории/)).toHaveValue( + "Новая вторая", + ); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + await waitFor(() => + expect(patches).toEqual([ + { id: "a", body: { name: "Первая", version: 1 } }, + { id: "b", body: { name: "Новая вторая", version: 4 } }, + ]), + ); + }); + + it.each(["Отмена", "Сохранить"])( + "clears reloaded recovery after %s", + async (action) => { + configureAuthClient({ + accessToken: () => "test", + refresh: async () => false, + clear: () => {}, + }); + let version = 1; + let writes = 0; + server.use( + http.get("*/api/v1/bank-cards/categories", () => + HttpResponse.json([ + { id: "a", name: "Первая", isDefault: false, version }, + ]), + ), + http.patch("*/api/v1/bank-cards/categories/a", () => { + version = 2; + return ++writes === 1 + ? HttpResponse.json( + { code: "BANK_VERSION_CONFLICT" }, + { status: 409 }, + ) + : HttpResponse.json({}); + }), + ); + render( + + + {}} /> + + , + ); + fireEvent.click( + await screen.findByRole("button", { name: "Переименовать" }), + ); + fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); + fireEvent.click( + await screen.findByRole("button", { name: "Перечитать запись" }), + ); + await screen.findByRole("button", { + name: /Использовать актуальную версию/, + }); + fireEvent.click(screen.getByRole("button", { name: action })); + await screen.findByRole("button", { name: "Добавить категорию" }); + expect( + screen.queryByRole("button", { + name: /Использовать актуальную версию/, + }), + ).not.toBeInTheDocument(); + }, + ); + it("rejects duplicate case-insensitive and too long names then renames default identity", async () => { configureAuthClient({ accessToken: () => "test", diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx index fcd3f9b..6c8dad9 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx @@ -8,7 +8,7 @@ import { Text, TextInput, } from "@mantine/core"; -import { type FC, useState } from "react"; +import { type FC, useRef, useState } from "react"; import { Controller, FormProvider, @@ -60,6 +60,7 @@ const CategoryManager: FC = (props) => { const categories = useBankCategories(); const mutation = useBankMutations(); const [editing, setEditing] = useState(); + const editSession = useRef(0); const [error, setError] = useState(); const [latest, setLatest] = useState(); const methods = useForm({ @@ -114,7 +115,9 @@ const CategoryManager: FC = (props) => { }); if (result) props.onCreated?.(result.id); } + editSession.current++; setEditing(undefined); + setLatest(undefined); methods.reset({ name: "" }); } catch (failure) { setError(failure); @@ -138,6 +141,7 @@ const CategoryManager: FC = (props) => { + + + )}
{ setRequestError(null); @@ -105,7 +130,10 @@ const TbankConnectionSectionLoaded: FC = ( @@ -119,10 +147,15 @@ const TbankConnectionSectionLoaded: FC = ( title={t("tbank.deleteTitle")} closeOnClickOutside={!pending} closeOnEscape={!pending} + closeButtonProps={{ "aria-label": t("tbank.close"), disabled: pending }} > {t("tbank.deleteText")} + {remove.isError && ( + {t("tbank.requestError")} + )} + ); }; describe("Upcoming event date filters", () => { + it("keeps reads and sync disabled without an account scope", async () => { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + const requests: string[] = []; + server.use( + http.all("*/api/v1/*", ({ request }) => { + requests.push(request.url); + return new HttpResponse(null, { status: 503 }); + }), + ); + const { wrapper: QueryWrapper, queryClient } = createQueryWrapper(); + const { result } = renderHook(() => useAccountEventsPageViewModel(), { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + await act(async () => {}); + expect(result.current).toEqual({ state: "loading" }); + expect(queryClient.isFetching()).toBe(0); + expect(requests).toEqual([]); + }); + + it.each([ + "from=2099-02-01&to=2099-02-28", + "from=2099-02-01", + "to=2099-02-28", + ])( + "keeps event calendar dates in GET and source sync requests: %s", + async (search) => { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + const requested: string[] = []; + const syncRequests: unknown[] = []; + server.use( + http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => { + requested.push(new URL(request.url).search); + return HttpResponse.json( + resource(null, { + sync: { + id: null, + state: "failed", + errorCode: "TBANK_UNAVAILABLE", + }, + }), + ); + }), + http.post("*/api/v1/tbank-sync", async ({ request }) => { + syncRequests.push(await request.json()); + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + }), + ); + const period = Object.fromEntries(new URLSearchParams(search)); + const { wrapper: QueryWrapper } = createQueryWrapper(); + const { result } = renderHook( + () => useAccountEventsPageViewModel("all"), + { + wrapper: ({ children }) => ( + + + {children} + + + ), + }, + ); + await waitFor(() => expect(result.current.state).toBe("error")); + expect(requested).toEqual([`?${search}`]); + expect(syncRequests).toEqual([]); + act(() => { + if (result.current.state === "error") result.current.retry(); + }); + await waitFor(() => + expect(syncRequests).toEqual([ + { screen: "events", accountId: "all", ...period }, + ]), + ); + }, + ); + + it.each(["account-1", "all"])( + "normalizes invalid links and history while preserving one-sided future dates for %s", + async (accountId) => { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + const searches: string[] = []; + server.use( + http.get( + "*/api/v1/brokerage-accounts/:accountId/events", + ({ request }) => { + searches.push(new URL(request.url).search); + return HttpResponse.json(resource({ events: [] })); + }, + ), + ); + render( + + + `/accounts/${accountId}/events${search}`)} + > + + + } + /> + + + + , + ); + await screen.findByLabelText("Период"); + for (const expected of ["", "", "?from=2099-02-01", "?to=2099-02-28"]) { + fireEvent.click( + screen.getByRole("button", { name: "History forward" }), + ); + await waitFor(() => + expect(screen.getByLabelText("URL").textContent).toBe(expected), + ); + await screen.findByLabelText("Период"); + } + fireEvent.click(screen.getByRole("button", { name: "History back" })); + await waitFor(() => + expect(screen.getByLabelText("URL")).toHaveTextContent( + "?from=2099-02-01", + ), + ); + expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2099"); + expect(searches).toEqual(["", "?from=2099-02-01", "?to=2099-02-28"]); + }, + ); + + it.each(["account-1", "all"])( + "lets users reset filters after a request failure for %s", + async (accountId) => { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + server.use( + http.get( + "*/api/v1/brokerage-accounts/:accountId/events", + ({ request }) => + new URL(request.url).searchParams.has("from") + ? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }) + : HttpResponse.json(resource({ events: [] })), + ), + ); + render( + + + + + + } + /> + + + + , + ); + await screen.findByRole("button", { name: "Повторить" }); + fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); + await screen.findByLabelText("Период"); + expect(screen.getByLabelText("URL").textContent).toBe(""); + }, + ); + it.each(["account-1", "all"])( "applies dates only on submit, restores URL dates, and resets from empty results for %s", async (accountId) => { diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 5312127..8eb097a 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -9,6 +9,7 @@ const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() })); vi.mock("@/features/tbank-connection", () => ({ useScreenSync: () => ({ refresh, + retry: refresh, refreshing: false, sourceError: false, transportError: false, @@ -78,7 +79,7 @@ describe("AccountEventsPage", () => { refetch: vi.fn(), } as never); renderPage(); - expect(query).toHaveBeenCalledWith(undefined, {}); + expect(query).toHaveBeenCalledWith("", {}); expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( "aria-busy", "true", diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx index 19224e0..19409f2 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx @@ -10,7 +10,7 @@ const AccountEventsPage: FC = (props) => { const viewModel = useAccountEventsPageViewModel(props.accountId); if (viewModel.state === "loading") return ; if (viewModel.state === "error") - return ; + return ; return ; }; diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 1cd4d37..8e93f07 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { useParams, useSearchParams } from "react-router"; +import { z } from "zod"; import { type BrokerageEvent, useBrokerageEvents, @@ -8,7 +9,7 @@ import { useScreenSync } from "@/features/tbank-connection"; type AccountEventsPageViewModel = | { state: "loading" } - | { state: "error"; retry: () => void } + | { state: "error"; retry: () => void; onReset: () => void } | { state: "loaded"; events: BrokerageEvent[]; @@ -31,12 +32,22 @@ function useAccountEventsPageViewModel( scopeId?: string, ): AccountEventsPageViewModel { const { accountId: routeAccountId } = useParams(); - const accountId = scopeId ?? routeAccountId; + const accountId = scopeId ?? routeAccountId ?? ""; const [searchParams, setSearchParams] = useSearchParams(); - const appliedFrom = searchParams.get("from"); - const appliedTo = searchParams.get("to"); + const urlFrom = searchParams.get("from"); + const urlTo = searchParams.get("to"); + const validUrlPeriod = isValidPeriod(urlFrom, urlTo); + const appliedFrom = validUrlPeriod ? urlFrom : null; + const appliedTo = validUrlPeriod ? urlTo : null; const [draftFrom, setDraftFrom] = useState(appliedFrom); const [draftTo, setDraftTo] = useState(appliedTo); + useEffect(() => { + if (validUrlPeriod) return; + const normalized = new URLSearchParams(searchParams); + normalized.delete("from"); + normalized.delete("to"); + setSearchParams(normalized, { replace: true }); + }, [validUrlPeriod, searchParams, setSearchParams]); useEffect(() => { setDraftFrom(appliedFrom); setDraftTo(appliedTo); @@ -54,12 +65,18 @@ function useAccountEventsPageViewModel( }, [events], ); + const onReset = () => { + setDraftFrom(null); + setDraftTo(null); + setSearchParams({}); + }; if (events.isLoading) return { state: "loading" }; if ((events.isError || events.meta?.sync.state === "failed") && !events.data) return { state: "error", + onReset, retry: () => { - void sync.refresh(); + void sync.retry(); }, }; if (!events.data) return { state: "loading" }; @@ -72,26 +89,34 @@ function useAccountEventsPageViewModel( refreshing: events.isFetching || sync.refreshing, refreshError: events.isError || sync.sourceError || sync.transportError, retry: () => { - void sync.refresh(); + void sync.retry(); }, draftFrom, draftTo, canApply: (draftFrom !== appliedFrom || draftTo !== appliedTo) && - (!draftFrom || !draftTo || draftFrom <= draftTo), + isValidPeriod(draftFrom, draftTo), onDraftFromChange: setDraftFrom, onDraftToChange: setDraftTo, - onApply: () => + onApply: () => { + if (!isValidPeriod(draftFrom, draftTo)) return; setSearchParams({ ...(draftFrom ? { from: draftFrom } : {}), ...(draftTo ? { to: draftTo } : {}), - }), - onReset: () => { - setDraftFrom(null); - setDraftTo(null); - setSearchParams({}); + }); }, + onReset, }; } +const calendarDate = z.string().date(); +function isValidPeriod(from: string | null, to: string | null) { + return ( + [from, to].every( + (date) => date === null || calendarDate.safeParse(date).success, + ) && + (!from || !to || from <= to) + ); +} + export { type AccountEventsPageViewModel, useAccountEventsPageViewModel }; diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx index 95d463e..e937b96 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx @@ -1,9 +1,9 @@ -import { Button } from "@mantine/core"; +import { Button, Group } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -type AccountEventsPageErrorProps = { retry: () => void }; +type AccountEventsPageErrorProps = { retry: () => void; onReset: () => void }; const AccountEventsPageError: FC = (props) => { const { t } = useTranslation(); @@ -12,7 +12,14 @@ const AccountEventsPageError: FC = (props) => { error title={t("events.errorTitle")} text={t("events.errorText")} - action={} + action={ + + + + + } /> ); }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 51056cc..b2d6d4c 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -6,6 +6,7 @@ import { act, fireEvent, render, + renderHook, screen, waitFor, within, @@ -26,7 +27,10 @@ import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; import { isDate } from "../../model/period"; import { AccountIncomePage } from "./AccountIncomePage"; -import { toIncomeDisplayModel } from "./AccountIncomePage.vm"; +import { + toIncomeDisplayModel, + useAccountIncomePageViewModel, +} from "./AccountIncomePage.vm"; import { IncomePayments } from "./components/IncomePayments.view"; const analytics = { @@ -148,6 +152,79 @@ function renderPage(search = "from=2026-08-01&to=2026-08-27") { } describe("AccountIncomePage", () => { + it("keeps reads and sync disabled without an account scope", async () => { + configureAuthClient({ + accessToken: () => "token", + refresh: async () => true, + clear: vi.fn(), + }); + const requests: string[] = []; + server.use( + http.all("*/api/v1/*", ({ request }) => { + requests.push(request.url); + return new HttpResponse(null, { status: 503 }); + }), + ); + const { wrapper: QueryWrapper, queryClient } = createQueryWrapper(); + const { result } = renderHook(() => useAccountIncomePageViewModel(), { + wrapper: ({ children }) => ( + + + {children} + + + ), + }); + await act(async () => {}); + expect(result.current).toEqual({ state: "loading" }); + expect(queryClient.isFetching()).toBe(0); + expect(requests).toEqual([]); + }); + + it("keeps income calendar dates in GET and source sync requests", async () => { + const requested: string[] = []; + const syncRequests: unknown[] = []; + server.use( + http.get( + "*/api/v1/brokerage-accounts/:accountId/income-analytics", + ({ request }) => { + requested.push(new URL(request.url).search); + return HttpResponse.json( + resource(null, { + sync: { + id: null, + state: "failed", + errorCode: "TBANK_UNAVAILABLE", + }, + }), + ); + }, + ), + http.post("*/api/v1/tbank-sync", async ({ request }) => { + syncRequests.push(await request.json()); + return HttpResponse.json( + { code: "TBANK_UNAVAILABLE" }, + { status: 503 }, + ); + }), + ); + renderPage("from=2024-02-29&to=2024-03-01"); + const retry = await screen.findByRole("button", { name: "Повторить" }); + expect(requested).toEqual(["?from=2024-02-29&to=2024-03-01"]); + expect(syncRequests).toEqual([]); + fireEvent.click(retry); + await waitFor(() => + expect(syncRequests).toEqual([ + { + screen: "income", + accountId: "account-1", + from: "2024-02-29", + to: "2024-03-01", + }, + ]), + ); + }); + it("requests a multi-year period from the URL without resetting it", async () => { const requested: string[] = []; server.use( @@ -431,20 +508,18 @@ describe("AccountIncomePage", () => { }); it("shows the unavailable year state without falling back to the previous period", async () => { + const dependencies = [ + { ...resource(analytics).meta, role: "selected" as const }, + { ...resource(analytics).meta, role: "previous" as const }, + { + ...resource(null).meta, + role: "year" as const, + unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", + }, + ]; server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json( - resource(analytics, { - dependencies: [ - resource(analytics).meta, - resource(analytics).meta, - { - ...resource(null).meta, - unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", - }, - ], - }), - ), + HttpResponse.json(resource(analytics, { dependencies })), ), ); renderPage(); @@ -829,11 +904,87 @@ describe("income period and snapshot boundary", () => { }); }); +it("maps comparison notices by role regardless of dependency order or resource key", () => { + const selected = { ...resource(analytics).meta, role: "selected" as const }; + const previous = { + ...resource(null).meta, + resourceKey: "opaque/year-looking-key", + role: "previous" as const, + sync: { + id: null, + state: "failed" as const, + errorCode: "TBANK_UNAVAILABLE", + }, + }; + const year = { + ...resource(null).meta, + resourceKey: "opaque/previous-looking-key", + role: "year" as const, + unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", + }; + for (const dependencies of [ + [selected, previous, year], + [year, selected, previous], + ]) { + const display = toIncomeDisplayModel(analytics, { + ...resource(analytics).meta, + dependencies, + }); + expect(display?.previousNotice).toBe("comparisonFailed"); + expect(display?.yearNotice).toBe("yearUnavailable"); + expect(display?.previousComparison).not.toBeNull(); + } +}); + +it.each([false, true])( + "shows unavailable comparisons for absent roles without positional fallback (legacy=%s)", + async (legacy) => { + server.use( + http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => + HttpResponse.json( + resource( + { ...analytics, previous: null, yearComparison: null }, + { + dependencies: legacy + ? [ + resource(null).meta, + resource(null).meta, + resource(null).meta, + ] + : [], + }, + ), + ), + ), + ); + renderPage(); + expect( + await screen.findByText("Сравнение с предыдущим периодом недоступно."), + ).toBeVisible(); + expect( + screen.getByText("Сравнение с годом ранее недоступно."), + ).toBeVisible(); + expect( + screen.queryByText("Период сравнения ещё не загружен полностью."), + ).not.toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Выплаты" })).toBeVisible(); + }, +); + it("shows selected income before comparison coverage without inventing a zero baseline", async () => { + const dependencies = (["year", "selected", "previous"] as const).map( + (role) => ({ + ...resource(role === "selected" ? analytics : null).meta, + role, + }), + ); server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json( - resource({ ...analytics, previous: null, yearComparison: null }), + resource( + { ...analytics, previous: null, yearComparison: null }, + { dependencies }, + ), ), ), ); @@ -856,24 +1007,22 @@ it("shows selected income before comparison coverage without inventing a zero ba }); it("keeps complete previous comparison while year source failed without inventing its amounts", async () => { + const dependencies = [ + { ...resource(analytics).meta, role: "previous" as const }, + { + ...resource(null).meta, + role: "year" as const, + sync: { + id: null, + state: "failed" as const, + errorCode: "TBANK_UNAVAILABLE", + }, + }, + { ...resource(analytics).meta, role: "selected" as const }, + ]; server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => - HttpResponse.json( - resource(analytics, { - dependencies: [ - resource(analytics).meta, - resource(analytics).meta, - { - ...resource(null).meta, - sync: { - id: null, - state: "failed", - errorCode: "TBANK_UNAVAILABLE", - }, - }, - ], - }), - ), + HttpResponse.json(resource(analytics, { dependencies })), ), ); renderPage(); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index 81bab28..e20b3fc 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -22,6 +22,7 @@ type IncomeMeta = ReturnType["meta"]; type ComparisonNotice = | "comparisonPending" | "comparisonFailed" + | "previousUnavailable" | "yearUnavailable"; type IncomeDisplayModel = { @@ -84,7 +85,7 @@ const useAccountIncomePageViewModel = ( scopeId?: string, ): AccountIncomePageViewModel => { const { accountId: routeAccountId } = useParams(); - const accountId = scopeId ?? routeAccountId; + const accountId = scopeId ?? routeAccountId ?? ""; const [searchParams, setSearchParams] = useSearchParams(); const requested = useMemo( () => ({ @@ -130,7 +131,7 @@ const useAccountIncomePageViewModel = ( }; if (analytics.isLoading || !analytics.data) { if (analytics.isError || analytics.meta?.sync.state === "failed") - return { state: "error", retry: sync.refresh, period }; + return { state: "error", retry: sync.retry, period }; return { state: "loading" }; } const display = toIncomeDisplayModel(analytics.data, analytics.meta); @@ -152,7 +153,7 @@ const useAccountIncomePageViewModel = ( partial: analytics.meta?.availability !== "complete", refreshing: analytics.isFetching || sync.refreshing, refreshError: analytics.isError || sync.sourceError || sync.transportError, - retry: sync.refresh, + retry: sync.retry, form, onSubmit, onReset, @@ -236,14 +237,14 @@ function toIncomeDisplayModel( if (instrument.includedCount !== instrument.receiptCount) return null; } - // The income endpoint returns selected, previous and year dependencies in order. - const [, previous, year] = meta?.dependencies ?? []; + const previous = meta?.dependencies?.find(({ role }) => role === "previous"); + const year = meta?.dependencies?.find(({ role }) => role === "year"); return { entries, previousComparison: comparisonDisplay(analytics.period, analytics.previous), comparison: comparisonDisplay(analytics.period, analytics.yearComparison), - previousNotice: comparisonNotice(previous), - yearNotice: comparisonNotice(year), + previousNotice: comparisonNotice(previous, "previousUnavailable"), + yearNotice: comparisonNotice(year, "yearUnavailable"), }; } @@ -251,9 +252,10 @@ function comparisonNotice( dependency: | NonNullable["dependencies"]>[number] | undefined, + unavailable: "previousUnavailable" | "yearUnavailable", ): ComparisonNotice { - if (dependency?.unavailableReason) return "yearUnavailable"; - return dependency?.sync.state === "failed" + if (!dependency || dependency.unavailableReason) return unavailable; + return dependency.sync.state === "failed" ? "comparisonFailed" : "comparisonPending"; } diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx index 77840f7..33170fb 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.test.tsx @@ -2,7 +2,14 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { HttpResponse, http } from "msw"; -import { MemoryRouter, Route, Routes } from "react-router"; +import type { FC } from "react"; +import { + MemoryRouter, + Route, + Routes, + useLocation, + useNavigate, +} from "react-router"; import { describe, expect, it, vi } from "vitest"; import { type BrokerageOperation, @@ -32,6 +39,22 @@ const operation: BrokerageOperation = { commission: { currency: "RUB", value: "1" }, }; +const Navigation: FC = () => { + const location = useLocation(); + const navigate = useNavigate(); + return ( + <> + {location.search} + + + + ); +}; + function configureTestAuth() { configureAuthClient({ accessToken: () => "token", @@ -187,6 +210,133 @@ describe("LedgerEntryRow", () => { }); describe("AccountOperationsPage", () => { + it.each(["account-1", "all"])( + "normalizes invalid periods and history, preserves one-sided UTC dates and resets cursors for %s", + async (accountId) => { + configureTestAuth(); + const searches: URLSearchParams[] = []; + server.use( + http.get( + "*/api/v1/brokerage-accounts/:accountId/operations", + ({ request }) => { + searches.push(new URL(request.url).searchParams); + return HttpResponse.json( + resource({ + items: [operation], + nextCursor: "next", + hasNext: true, + }), + ); + }, + ), + ); + render( + + + `/accounts/${accountId}/operations${search}`)} + > + + + } + /> + + + + , + ); + await screen.findByLabelText("Период"); + expect(searches[0].get("from")).toBeNull(); + expect(searches[0].get("to")).toBeNull(); + for (const expected of [ + "?categories=trade", + "?categories=trade", + "?from=2026-02-01&categories=trade", + ]) { + fireEvent.click( + screen.getByRole("button", { name: "History forward" }), + ); + await waitFor(() => + expect(screen.getByLabelText("URL").textContent).toBe(expected), + ); + await screen.findByLabelText("Период"); + } + expect(searches.at(-1)?.get("from")).toBe("2026-02-01T00:00:00.000Z"); + expect(searches.at(-1)?.get("to")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Далее" })); + await waitFor(() => expect(searches.at(-1)?.get("cursor")).toBe("next")); + await screen.findByLabelText("Период"); + fireEvent.click(screen.getByRole("button", { name: "History forward" })); + await screen.findByLabelText("Период"); + await waitFor(() => + expect(searches.at(-1)?.get("to")).toBe("2026-02-28T23:59:59.999Z"), + ); + expect(searches.at(-1)?.get("from")).toBeNull(); + expect(searches.at(-1)?.get("cursor")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "History back" })); + await waitFor(() => + expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2026"), + ); + expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); + expect(searches.map((query) => query.get("from"))).not.toContain( + "badT00:00:00.000Z", + ); + expect( + searches.every((query) => query.get("categories") === "trade"), + ).toBe(true); + }, + ); + + it.each(["account-1", "all"])( + "allows resetting filters after a request failure for %s", + async (accountId) => { + configureTestAuth(); + server.use( + http.get( + "*/api/v1/brokerage-accounts/:accountId/operations", + ({ request }) => + new URL(request.url).searchParams.has("from") + ? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }) + : HttpResponse.json( + resource({ items: [], hasNext: false, nextCursor: null }), + ), + ), + ); + render( + + + + + + } + /> + + + + , + ); + await screen.findByRole("button", { name: "Повторить" }); + fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); + await screen.findByLabelText("Период"); + expect(screen.getByLabelText("URL").textContent).toBe(""); + }, + ); + it("resets cursor pagination when filters change", async () => { configureTestAuth(); const searches: string[] = []; @@ -311,6 +461,53 @@ describe("AccountOperationsPage", () => { }); }); +it("keeps UTC operation boundaries in GET and source sync requests", async () => { + configureTestAuth(); + const requested: Record[] = []; + const syncRequests: unknown[] = []; + server.use( + http.get( + "*/api/v1/brokerage-accounts/:accountId/operations", + ({ request }) => { + requested.push(Object.fromEntries(new URL(request.url).searchParams)); + return HttpResponse.json( + resource(null, { + sync: { id: null, state: "failed", errorCode: "TBANK_UNAVAILABLE" }, + }), + ); + }, + ), + http.post("*/api/v1/tbank-sync", async ({ request }) => { + syncRequests.push(await request.json()); + return HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); + }), + ); + renderPage("?from=2024-02-29&to=2024-03-01&categories=trade"); + const retry = await screen.findByRole("button", { name: "Повторить" }); + expect(requested).toEqual([ + { + from: "2024-02-29T00:00:00.000Z", + to: "2024-03-01T23:59:59.999Z", + categories: "trade", + limit: "100", + }, + ]); + expect(syncRequests).toEqual([]); + fireEvent.click(retry); + await waitFor(() => + expect(syncRequests).toEqual([ + { + screen: "operations", + accountId: "account-1", + from: "2024-02-29T00:00:00.000Z", + to: "2024-03-01T23:59:59.999Z", + categories: ["trade"], + limit: 100, + }, + ]), + ); +}); + it("does not label incomplete empty operations as a confirmed empty period", async () => { configureTestAuth(); server.use( diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index c168899..ef902bb 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -1,6 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { useParams, useSearchParams } from "react-router"; +import { z } from "zod"; import { type BrokerageOperation, brokerageOperationsQueryOptions, @@ -14,7 +15,7 @@ import { apiErrorCode } from "@/shared/api"; type AccountOperationsPageViewModel = | { state: "loading" } - | { state: "error"; retry: () => void } + | { state: "error"; retry: () => void; onReset: () => void } | { state: "loaded"; sourceError?: boolean; @@ -47,8 +48,11 @@ const useAccountOperationsPageVm = ( const accountId = scopeId ?? routeAccountId; const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); - const appliedFrom = searchParams.get("from"); - const appliedTo = searchParams.get("to"); + const urlFrom = searchParams.get("from"); + const urlTo = searchParams.get("to"); + const validUrlPeriod = isValidPeriod(urlFrom, urlTo); + const appliedFrom = validUrlPeriod ? urlFrom : null; + const appliedTo = validUrlPeriod ? urlTo : null; const appliedCategories = operationCategories.filter((category) => (searchParams.get("categories") ?? "").split(",").includes(category), ); @@ -58,8 +62,8 @@ const useAccountOperationsPageVm = ( useState(appliedCategories); const scopeKey = JSON.stringify([ accountId, - appliedFrom, - appliedTo, + urlFrom, + urlTo, appliedCategories, ]); const [pagination, setPagination] = useState<{ @@ -69,14 +73,26 @@ const useAccountOperationsPageVm = ( const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : []; const cursor = cursors.at(-1); useEffect(() => { - setDraftFrom(searchParams.get("from")); - setDraftTo(searchParams.get("to")); + setPagination((current) => + current.scopeKey === scopeKey ? current : { scopeKey, cursors: [] }, + ); + }, [scopeKey]); + useEffect(() => { + if (validUrlPeriod) return; + const normalized = new URLSearchParams(searchParams); + normalized.delete("from"); + normalized.delete("to"); + setSearchParams(normalized, { replace: true }); + }, [validUrlPeriod, searchParams, setSearchParams]); + useEffect(() => { + setDraftFrom(appliedFrom); + setDraftTo(appliedTo); setDraftCategories( operationCategories.filter((category) => (searchParams.get("categories") ?? "").split(",").includes(category), ), ); - }, [searchParams]); + }, [appliedFrom, appliedTo, searchParams]); const firstPageQuery = { from: dateStart(appliedFrom), to: dateEnd(appliedTo), @@ -104,7 +120,15 @@ const useAccountOperationsPageVm = ( exact: true, }); resetCursors(); - } else void sync.refresh(); + } else void sync.retry(); + }; + const onReset = () => { + setDraftFrom(null); + setDraftTo(null); + setDraftCategories([]); + setSearchParams({}); + resetCursors(); + void queryClient.invalidateQueries({ queryKey: resetQueryKey }); }; if (operations.isLoading) return { state: "loading" }; @@ -113,7 +137,7 @@ const useAccountOperationsPageVm = ( (!operations.data || apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") ) - return { state: "error", retry }; + return { state: "error", retry, onReset }; if (!operations.data) return { state: "loading" }; const nextCursor = operations.data.nextCursor; @@ -139,11 +163,12 @@ const useAccountOperationsPageVm = ( items: operations.data.items, hasNext: Boolean(operations.data.hasNext && nextCursor), hasPrevious: Boolean(cursors.length), - canApply: isDirty, + canApply: isDirty && isValidPeriod(draftFrom, draftTo), onDraftFromChange: setDraftFrom, onDraftToChange: setDraftTo, onDraftCategoriesChange: setDraftCategories, onApply: () => { + if (!isValidPeriod(draftFrom, draftTo)) return; setSearchParams({ ...(draftFrom ? { from: draftFrom } : {}), ...(draftTo ? { to: draftTo } : {}), @@ -153,14 +178,7 @@ const useAccountOperationsPageVm = ( }); resetCursors(); }, - onReset: () => { - setDraftFrom(null); - setDraftTo(null); - setDraftCategories([]); - setSearchParams({}); - resetCursors(); - void queryClient.invalidateQueries({ queryKey: resetQueryKey }); - }, + onReset, onNext: () => { if (nextCursor) setPagination({ scopeKey, cursors: [...cursors, nextCursor] }); @@ -170,6 +188,16 @@ const useAccountOperationsPageVm = ( }; }; +const calendarDate = z.string().date(); +function isValidPeriod(from: string | null, to: string | null) { + return ( + [from, to].every( + (date) => date === null || calendarDate.safeParse(date).success, + ) && + (!from || !to || from <= to) + ); +} + function dateStart(value: string | null) { return value ? `${value}T00:00:00.000Z` : undefined; } diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx index ace026c..aa5aa2c 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx @@ -1,4 +1,4 @@ -import { Button } from "@mantine/core"; +import { Button, Group } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; @@ -20,7 +20,14 @@ const AccountOperationsPageError: FC = ( error title={t("ledger.errorTitle")} text={t("ledger.errorText")} - action={} + action={ + + + + + } /> ); }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 538aa76..2e41552 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -131,7 +131,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { return { state: "error" as const, code: apiErrorCode(portfolio.error), - retry: sync.refresh, + retry: sync.retry, }; return { state: "loading" as const }; } @@ -140,7 +140,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { refreshing: portfolio.isFetching || sync.refreshing, sourceError: sync.sourceError, refreshError: portfolio.isError || sync.transportError || sync.sourceError, - retry: sync.refresh, + retry: sync.retry, }; if ( portfolio.meta?.availability === "complete" && diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 3d27fa3..fefa847 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.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 } from "react-router"; import { describe, expect, it, vi } from "vitest"; @@ -43,6 +43,21 @@ function renderPage(queryClient: QueryClient) { } describe("AccountsPage", () => { + it("loads accounts after retrying an initial HTTP failure", async () => { + const modes: (string | null)[] = []; + server.use( + http.get("*/api/v1/brokerage-accounts", ({ request }) => { + modes.push(new URL(request.url).searchParams.get("sync")); + return modes.length === 1 + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) + : HttpResponse.json(resource([account])); + }), + ); + renderPage(createTestQueryClient()); + fireEvent.click(await screen.findByRole("button", { name: "Повторить" })); + expect(await screen.findByText(account.name)).toBeVisible(); + expect(modes).toEqual([null, "observe"]); + }); it.each([ ["loading", AccountsPageLoading, "status"], ["empty", AccountsPageEmpty, undefined], @@ -143,8 +158,10 @@ describe("AccountsPage", () => { expect(screen.getByText(account.name)).toBeInTheDocument(); screen.getByRole("button", { name: "Повторить" }).click(); - await waitFor(() => expect(requests).toEqual([{ screen: "accounts" }])); - expect(requestCount).toBe(2); + await waitFor(() => expect(requestCount).toBe(3)); + await waitFor(() => expect(warning).not.toBeInTheDocument()); + expect(screen.getByText(account.name)).toBeVisible(); + expect(requests).toEqual([]); }); it("keeps a cached empty account list visible after a refresh error", async () => { diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts index a6629a7..2936738 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.vm.ts @@ -18,7 +18,7 @@ function useAccountsPageViewModel() { return { state: "error" as const, code: apiErrorCode(accounts.error), - retry: sync.refresh, + retry: sync.retry, }; } if (!accounts.data?.length) @@ -28,7 +28,7 @@ function useAccountsPageViewModel() { refreshing: accounts.isFetching || sync.refreshing, sourceError: sync.sourceError, refreshError: accounts.isError || sync.transportError || sync.sourceError, - retry: sync.refresh, + retry: sync.retry, }; return { state: "loaded" as const, @@ -37,7 +37,7 @@ function useAccountsPageViewModel() { refreshing: accounts.isFetching || sync.refreshing, sourceError: sync.sourceError, refreshError: accounts.isError || sync.transportError || sync.sourceError, - retry: sync.refresh, + retry: sync.retry, }; } diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index 2ba6f75..0177c4b 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -82,7 +82,7 @@ function useBondPageVm() { queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, exact: true, }); - } else void sync.refresh(); + } else void sync.retry(); }; const chartData = candles.data?.candles.map((candle) => ({ @@ -99,26 +99,26 @@ function useBondPageVm() { ? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}` : null, bond: bond.data, - bondState: section(bond, uid ? sync.refresh : () => bond.refetch()), + bondState: section(bond, uid ? sync.retry : () => bond.refetch()), code: apiErrorCode(bond.error), logoSrc: instrumentLogoUrl(bond.data?.logoName), metrics: metrics.data, - metricsState: section(metrics, sync.refresh), + metricsState: section(metrics, sync.retry), quote: quote.data, - quoteState: section(quote, sync.refresh), + quoteState: section(quote, sync.retry), candles: candles.data, chartData, priceRows: chartData.toReversed(), - candlesState: section(candles, sync.refresh), + candlesState: section(candles, sync.retry), period, setPeriod: (value: string) => setParam("period", value), averageLine: null, hasIncompleteCandle: candles.data?.candles.some((candle) => !candle.isComplete) ?? false, position: position.data, - positionState: section(position, sync.refresh), + positionState: section(position, sync.retry), coupons: coupons.data, - couponsState: section(coupons, sync.refresh), + couponsState: section(coupons, sync.retry), upcoming: coupons.data?.coupons .filter((event) => event.timing === "upcoming") @@ -137,7 +137,7 @@ function useBondPageVm() { ) ?? [], operations: operations.data, operationsState: { - ...section(operations, sync.refresh), + ...section(operations, sync.retry), retry: retryOperations, }, eventRows: diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts index 2d21c59..6a38412 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts +++ b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts @@ -28,7 +28,7 @@ function useBondsPageVm() { partial: result.meta?.availability === "partial", code: apiErrorCode(result.error), retry: () => { - void sync.refresh(); + void sync.retry(); }, ready: debounced.length >= 2 && !waiting && Boolean(result.data), items: diff --git a/src/pages/card/ui/CardPage/CardPage.test.tsx b/src/pages/card/ui/CardPage/CardPage.test.tsx index cb4581e..b023325 100644 --- a/src/pages/card/ui/CardPage/CardPage.test.tsx +++ b/src/pages/card/ui/CardPage/CardPage.test.tsx @@ -46,6 +46,7 @@ const operation = { }; function setup( options: { + month?: string; missing?: boolean; summaryError?: boolean; cardsError?: boolean; @@ -101,7 +102,9 @@ function setup( render( - + } /> @@ -112,6 +115,26 @@ function setup( return { requests, summaryRequests, client }; } describe("CardPage", () => { + it.each([ + ["0001-01", "0001-01-31"], + ["0099-12", "0099-12-31"], + ["2026-09", "2026-09-30"], + ["2024-02", "2024-02-29"], + ])( + "sends the same legacy month %s range to history and summary", + async (month, to) => { + const { requests, summaryRequests } = setup({ month }); + await screen.findByText("Перевод на «Запасная»"); + await waitFor(() => expect(summaryRequests).toHaveLength(1)); + for (const request of [requests[0], summaryRequests[0]]) { + const query = new URLSearchParams(request); + expect(query.get("from")).toBe(`${month}-01`); + expect(query.get("to")).toBe(to); + expect(query.has("month")).toBe(false); + } + }, + ); + it("shares date range with history, pages the journal and keeps current balance", async () => { const { requests, summaryRequests } = setup(); await screen.findByRole("heading", { name: "Повседневная" }); diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index 4a493e3..fc251ce 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -9,6 +9,7 @@ import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; import { StockPage } from "./StockPage"; +import type { StockPageVm } from "./StockPage.vm"; const LocationProbe = () => { const location = useLocation(); @@ -72,6 +73,7 @@ function setup( path?: string; slug?: string; ambiguousTicker?: boolean; + metrics?: NonNullable["metrics"]; } = {}, ) { const requests: URL[] = []; @@ -118,7 +120,7 @@ function setup( : HttpResponse.json( resource({ description: "Описание компании", - metrics: [ + metrics: options.metrics ?? [ { key: "peRatioTtm", value: 5.6, @@ -215,6 +217,63 @@ function setup( }; } describe("stock page", () => { + it("preserves unknown and unambiguous currencies with compact, ratio and percent formatting", async () => { + setup({ + metrics: [ + { + key: "marketCapitalization", + value: 1234567.89, + unit: "money", + currency: null, + period: null, + }, + { + key: "revenueTtm", + value: 1234567.89, + unit: "money", + currency: "RUB", + period: "TTM", + }, + { + key: "ebitdaTtm", + value: 1234567.89, + unit: "money", + currency: "USD", + period: "TTM", + }, + { + key: "netIncomeTtm", + value: 1234567.89, + unit: "money", + currency: "CAD", + period: "TTM", + }, + { + key: "peRatioTtm", + value: 5.678, + unit: "ratio", + currency: null, + period: "TTM", + }, + { + key: "roe", + value: 12.3456, + unit: "percent", + currency: null, + period: null, + }, + ], + }); + expect(await screen.findByText("1,23 млн валюта неизвестна")).toBeVisible(); + for (const value of [ + "1,23 млн ₽", + "1,23 млн USD", + "1,23 млн CAD", + "5,68 ×", + "12,35 %", + ]) + expect(screen.getByText(value)).toBeVisible(); + }); it("opens a direct ticker URL and uses its resolved UID for market and personal requests", async () => { const { requests } = setup({ path: "/stocks/SBERP", slug: "SBERP" }); await screen.findByText("Зачисление дивидендов"); @@ -290,6 +349,31 @@ describe("stock page", () => { ), ).toHaveAttribute("aria-live", "polite"); }); + it("recovers an initially missing company section without reloading successful sections", async () => { + const { requests } = setup({ failCompany: true }); + const retry = await screen.findByRole("button", { name: "Повторить" }); + await screen.findByText("Зачисление дивидендов"); + const modes: (string | null)[] = []; + server.use( + http.get("*/api/v1/stocks/:uid/company", ({ request }) => { + modes.push(new URL(request.url).searchParams.get("sync")); + return HttpResponse.json( + resource({ + description: "Восстановленное описание", + metrics: [], + unavailable: [], + observedAt: quote.observedAt, + }), + ); + }), + ); + const previousRequests = requests.length; + fireEvent.click(retry); + expect(await screen.findByText("Восстановленное описание")).toBeVisible(); + expect(screen.getByText("Зачисление дивидендов")).toBeVisible(); + expect(requests).toHaveLength(previousRequests); + expect(modes).toEqual(["observe"]); + }); it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => { const { requests } = setup(); await screen.findByText("Зачисление дивидендов"); diff --git a/src/pages/stock/ui/StockPage/StockPage.view.tsx b/src/pages/stock/ui/StockPage/StockPage.view.tsx index 83ea318..0ea3c36 100644 --- a/src/pages/stock/ui/StockPage/StockPage.view.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.view.tsx @@ -190,13 +190,9 @@ const StockPageView: FC = (props) => { metric.unit === "money" ? "compact" : "standard", }).format(metric.value)}{" "} {metric.unit === "money" - ? new Intl.NumberFormat("ru-RU", { - style: "currency", - currency: metric.currency ?? "RUB", - currencyDisplay: "narrowSymbol", - }) - .formatToParts(0) - .find((part) => part.type === "currency")?.value + ? metric.currency === "RUB" + ? "₽" + : (metric.currency ?? t("stock.currencyUnknown")) : metric.unit === "percent" ? "%" : "×"} diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts index 4902cd7..f996827 100644 --- a/src/pages/stock/ui/StockPage/StockPage.vm.ts +++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts @@ -82,7 +82,7 @@ function useStockPageVm() { queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, exact: true, }); - } else void sync.refresh(); + } else void sync.retry(); }; const chartData = candles.data?.candles.map((candle) => ({ @@ -105,15 +105,15 @@ function useStockPageVm() { ? `/stocks/${encodeURIComponent(stock.data.slug)}${params.size ? `?${params.toString()}` : ""}` : null, stock: stock.data, - stockState: section(stock, uid ? sync.refresh : () => stock.refetch()), + stockState: section(stock, uid ? sync.retry : () => stock.refetch()), code: apiErrorCode(stock.error), logoSrc: instrumentLogoUrl(stock.data?.logoName), quote: quote.data, - quoteState: section(quote, sync.refresh), + quoteState: section(quote, sync.retry), candles: candles.data, chartData, priceRows: chartData.toReversed(), - candlesState: section(candles, sync.refresh), + candlesState: section(candles, sync.retry), period, setPeriod: (value: string) => setParam("period", value), averageLine: @@ -121,11 +121,11 @@ function useStockPageVm() { hasIncompleteCandle: candles.data?.candles.some((candle) => !candle.isComplete) ?? false, position: position.data, - positionState: section(position, sync.refresh), + positionState: section(position, sync.retry), company: companyData, - companyState: section(company, sync.refresh), + companyState: section(company, sync.retry), dividends: dividends.data, - dividendsState: section(dividends, sync.refresh), + dividendsState: section(dividends, sync.retry), upcoming: dividends.data?.dividends .filter((event) => event.timing === "upcoming") @@ -144,7 +144,7 @@ function useStockPageVm() { ) ?? [], operations: operations.data, operationsState: { - ...section(operations, sync.refresh), + ...section(operations, sync.retry), retry: retryOperations, }, eventRows: diff --git a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts index c2d7d97..7150862 100644 --- a/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts +++ b/src/pages/stocks/ui/StocksPage/StocksPage.vm.ts @@ -28,7 +28,7 @@ function useStocksPageVm() { partial: result.meta?.availability === "partial", code: apiErrorCode(result.error), retry: () => { - void sync.refresh(); + void sync.retry(); }, ready: debounced.length >= 2 && !waiting && Boolean(result.data), items: diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 8ecf943..13d1c34 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -1124,9 +1124,41 @@ export interface components { instruments: components["schemas"]["IncomeInstrumentDto"][]; entries: components["schemas"]["IncomeAnalyticsEntryDto"][]; }; + IncomeDependencyDto: { + dataRevision: string; + stateVersion: string; + unavailableReason?: string; + coverage?: components["schemas"]["InvestmentCoverageDto"]; + resourceKey: string; + /** @enum {string} */ + availability: "missing" | "partial" | "complete"; + /** @enum {string} */ + freshness: "unknown" | "fresh" | "stale"; + /** Format: date-time */ + lastSuccessfulAt: string | null; + sync: components["schemas"]["InvestmentSyncDto"]; + /** @enum {string} */ + role: "selected" | "previous" | "year"; + }; + IncomeAnalyticsMetaDto: { + dataRevision: string; + stateVersion: string; + unavailableReason?: string; + coverage?: components["schemas"]["InvestmentCoverageDto"]; + resourceKey: string; + /** @enum {string} */ + availability: "missing" | "partial" | "complete"; + /** @enum {string} */ + freshness: "unknown" | "fresh" | "stale"; + /** Format: date-time */ + lastSuccessfulAt: string | null; + sync: components["schemas"]["InvestmentSyncDto"]; + /** @description Dependencies identified by role, not array order or resourceKey. A missing role is unavailable. */ + dependencies: components["schemas"]["IncomeDependencyDto"][]; + }; IncomeAnalyticsResourceDto: { data: components["schemas"]["IncomeAnalyticsDto"] | null; - meta: components["schemas"]["InvestmentResourceMetaDto"]; + meta: components["schemas"]["IncomeAnalyticsMetaDto"]; }; UpcomingEventDto: { account?: components["schemas"]["AccountReferenceDto"]; @@ -1515,20 +1547,50 @@ export interface components { PutTbankConnectionDto: { token: string; }; + IncomeInvestmentScreenDto: { + accountId: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + screen: "income"; + /** + * Format: date + * @description Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month. + */ + from?: string; + /** + * Format: date + * @description Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now. + */ + to?: string; + }; + EventsInvestmentScreenDto: { + accountId: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + screen: "events"; + /** + * Format: date + * @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. + */ + from?: string; + /** + * Format: date + * @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. + */ + to?: string; + }; SyncCandleRangeDto: { /** Format: date-time */ from: string; /** Format: date-time */ to: string; }; - InvestmentScreenDto: { - /** @enum {string} */ - screen: "accounts" | "account" | "operations" | "income" | "events" | "stocks" | "bonds" | "stock" | "bond"; + TimestampInvestmentScreenDto: { accountId?: string; - /** Format: date-time */ - from?: string; - /** Format: date-time */ - to?: string; /** Format: uuid */ instrumentUid?: string; query?: string; @@ -1537,11 +1599,26 @@ export interface components { candleRange?: components["schemas"]["SyncCandleRangeDto"]; categories?: ("trade" | "income" | "tax" | "fee" | "transfer" | "other")[]; limit?: number; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + screen: "accounts" | "account" | "operations" | "stocks" | "bonds" | "stock" | "bond"; + /** + * Format: date-time + * @description Start instant for operations. + */ + from?: string; + /** + * Format: date-time + * @description End instant for operations. + */ + to?: string; }; TbankSyncAcceptedDto: { /** Format: uuid */ id: string; - scope: components["schemas"]["InvestmentScreenDto"]; + scope: components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]; /** Format: date-time */ now: string; }; @@ -1553,7 +1630,7 @@ export interface components { TbankSyncStatusDto: { /** Format: uuid */ id: string; - scope: components["schemas"]["InvestmentScreenDto"] | null; + scope: (components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]) | null; /** Format: date-time */ now: string; /** @enum {string} */ @@ -3525,7 +3602,9 @@ export interface operations { parameters: { query?: { sync?: "observe"; + /** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */ from?: string; + /** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */ to?: string; }; header?: never; @@ -3661,7 +3740,9 @@ export interface operations { parameters: { query?: { sync?: "observe"; + /** @description Inclusive calendar date in Europe/Moscow. Supply both from/to or neither. */ from?: string; + /** @description Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither. */ to?: string; }; header?: never; @@ -3731,9 +3812,9 @@ export interface operations { parameters: { query?: { sync?: "observe"; - /** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */ + /** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */ from?: string; - /** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */ + /** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */ to?: string; }; header?: never; @@ -3801,9 +3882,9 @@ export interface operations { parameters: { query?: { sync?: "observe"; - /** @description Inclusive start date (YYYY-MM-DD). Defaults to today in UTC. */ + /** @description Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC. */ from?: string; - /** @description Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from. */ + /** @description Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end. */ to?: string; }; header?: never; @@ -5160,7 +5241,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["InvestmentScreenDto"]; + "application/json": components["schemas"]["IncomeInvestmentScreenDto"] | components["schemas"]["EventsInvestmentScreenDto"] | components["schemas"]["TimestampInvestmentScreenDto"]; }; }; responses: { diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 3c8616c..1f124e4 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -8,6 +8,7 @@ import { } from "@tanstack/react-query"; import { useEffect } from "react"; import { useAuthenticatedQuery } from "./authenticated-query"; +import { getAuthenticatedSessionSnapshot } from "./client"; import type { components } from "./generated/schema"; import { apiErrorCode } from "./problem-details"; @@ -107,8 +108,14 @@ function useResourceQuery( }, [client, group, query.error]); const observe = async (expectedStateVersion?: string) => { const queryFn = guarded; - if (typeof queryFn !== "function") return; - await client.cancelQueries({ queryKey: options.queryKey, exact: true }); + if ( + !query.isEnabled || + !getAuthenticatedSessionSnapshot() || + typeof queryFn !== "function" + ) + return; + // Cancellation is synchronous; start the replacement in the same boundary. + void client.cancelQueries({ queryKey: options.queryKey, exact: true }); return client.fetchQuery({ ...options, staleTime: 0, @@ -144,7 +151,11 @@ function useResourceQuery( }, }); }; - return { ...unwrapResourceResult(query), observe }; + return { + ...unwrapResourceResult(query), + queryKey: options.queryKey, + observe, + }; } export { invalidateResourceAccess, unwrapResourceResult, useResourceQuery }; diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index 3b3c25e..370fa4e 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -2668,6 +2668,7 @@ "name": "from", "required": false, "in": "query", + "description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.", "schema": { "format": "date", "type": "string" @@ -2677,6 +2678,7 @@ "name": "to", "required": false, "in": "query", + "description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.", "schema": { "format": "date", "type": "string" @@ -2878,6 +2880,7 @@ "name": "from", "required": false, "in": "query", + "description": "Inclusive calendar date in Europe/Moscow. Supply both from/to or neither.", "schema": { "format": "date", "type": "string" @@ -2887,6 +2890,7 @@ "name": "to", "required": false, "in": "query", + "description": "Inclusive calendar date in Europe/Moscow, no later than today. Supply both from/to or neither.", "schema": { "format": "date", "type": "string" @@ -2984,7 +2988,7 @@ "name": "from", "required": false, "in": "query", - "description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.", + "description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.", "schema": { "format": "date", "type": "string" @@ -2994,7 +2998,7 @@ "name": "to", "required": false, "in": "query", - "description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.", + "description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.", "schema": { "format": "date", "type": "string" @@ -3100,7 +3104,7 @@ "name": "from", "required": false, "in": "query", - "description": "Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.", + "description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.", "schema": { "format": "date", "type": "string" @@ -3110,7 +3114,7 @@ "name": "to", "required": false, "in": "query", - "description": "Inclusive end date (YYYY-MM-DD). Defaults to 30 days after from.", + "description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end.", "schema": { "format": "date", "type": "string" @@ -5001,7 +5005,31 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/InvestmentScreenDto" + "oneOf": [ + { + "$ref": "#/components/schemas/IncomeInvestmentScreenDto" + }, + { + "$ref": "#/components/schemas/EventsInvestmentScreenDto" + }, + { + "$ref": "#/components/schemas/TimestampInvestmentScreenDto" + } + ], + "discriminator": { + "propertyName": "screen", + "mapping": { + "income": "#/components/schemas/IncomeInvestmentScreenDto", + "events": "#/components/schemas/EventsInvestmentScreenDto", + "accounts": "#/components/schemas/TimestampInvestmentScreenDto", + "account": "#/components/schemas/TimestampInvestmentScreenDto", + "operations": "#/components/schemas/TimestampInvestmentScreenDto", + "stocks": "#/components/schemas/TimestampInvestmentScreenDto", + "bonds": "#/components/schemas/TimestampInvestmentScreenDto", + "stock": "#/components/schemas/TimestampInvestmentScreenDto", + "bond": "#/components/schemas/TimestampInvestmentScreenDto" + } + } } } } @@ -6878,6 +6906,129 @@ "entries" ] }, + "IncomeDependencyDto": { + "type": "object", + "properties": { + "dataRevision": { + "type": "string" + }, + "stateVersion": { + "type": "string" + }, + "unavailableReason": { + "type": "string" + }, + "coverage": { + "$ref": "#/components/schemas/InvestmentCoverageDto" + }, + "resourceKey": { + "type": "string" + }, + "availability": { + "type": "string", + "enum": [ + "missing", + "partial", + "complete" + ] + }, + "freshness": { + "type": "string", + "enum": [ + "unknown", + "fresh", + "stale" + ] + }, + "lastSuccessfulAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "sync": { + "$ref": "#/components/schemas/InvestmentSyncDto" + }, + "role": { + "type": "string", + "enum": [ + "selected", + "previous", + "year" + ] + } + }, + "required": [ + "dataRevision", + "stateVersion", + "resourceKey", + "availability", + "freshness", + "lastSuccessfulAt", + "sync", + "role" + ] + }, + "IncomeAnalyticsMetaDto": { + "type": "object", + "properties": { + "dataRevision": { + "type": "string" + }, + "stateVersion": { + "type": "string" + }, + "unavailableReason": { + "type": "string" + }, + "coverage": { + "$ref": "#/components/schemas/InvestmentCoverageDto" + }, + "resourceKey": { + "type": "string" + }, + "availability": { + "type": "string", + "enum": [ + "missing", + "partial", + "complete" + ] + }, + "freshness": { + "type": "string", + "enum": [ + "unknown", + "fresh", + "stale" + ] + }, + "lastSuccessfulAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "sync": { + "$ref": "#/components/schemas/InvestmentSyncDto" + }, + "dependencies": { + "description": "Dependencies identified by role, not array order or resourceKey. A missing role is unavailable.", + "type": "array", + "items": { + "$ref": "#/components/schemas/IncomeDependencyDto" + } + } + }, + "required": [ + "dataRevision", + "stateVersion", + "resourceKey", + "availability", + "freshness", + "lastSuccessfulAt", + "sync", + "dependencies" + ] + }, "IncomeAnalyticsResourceDto": { "type": "object", "properties": { @@ -6891,7 +7042,7 @@ ] }, "meta": { - "$ref": "#/components/schemas/InvestmentResourceMetaDto" + "$ref": "#/components/schemas/IncomeAnalyticsMetaDto" } }, "required": [ @@ -8677,6 +8828,62 @@ "token" ] }, + "IncomeInvestmentScreenDto": { + "type": "object", + "properties": { + "accountId": { + "type": "string" + }, + "screen": { + "type": "string", + "enum": [ + "income" + ] + }, + "from": { + "type": "string", + "format": "date", + "description": "Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month." + }, + "to": { + "type": "string", + "format": "date", + "description": "Inclusive end calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither. Timestamp queries use the start of the following Moscow day as the exclusive end, capped at now." + } + }, + "required": [ + "accountId", + "screen" + ] + }, + "EventsInvestmentScreenDto": { + "type": "object", + "properties": { + "accountId": { + "type": "string" + }, + "screen": { + "type": "string", + "enum": [ + "events" + ] + }, + "from": { + "type": "string", + "format": "date", + "description": "Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC." + }, + "to": { + "type": "string", + "format": "date", + "description": "Inclusive UTC calendar end date (YYYY-MM-DD). May be supplied independently; defaults to 30 days after from. Timestamp queries use the start of the following UTC day as the exclusive end." + } + }, + "required": [ + "accountId", + "screen" + ] + }, "SyncCandleRangeDto": { "type": "object", "properties": { @@ -8694,34 +8901,12 @@ "to" ] }, - "InvestmentScreenDto": { + "TimestampInvestmentScreenDto": { "type": "object", "properties": { - "screen": { - "type": "string", - "enum": [ - "accounts", - "account", - "operations", - "income", - "events", - "stocks", - "bonds", - "stock", - "bond" - ] - }, "accountId": { "type": "string" }, - "from": { - "type": "string", - "format": "date-time" - }, - "to": { - "type": "string", - "format": "date-time" - }, "instrumentUid": { "type": "string", "format": "uuid" @@ -8759,6 +8944,28 @@ "type": "number", "minimum": 1, "maximum": 1000 + }, + "screen": { + "type": "string", + "enum": [ + "accounts", + "account", + "operations", + "stocks", + "bonds", + "stock", + "bond" + ] + }, + "from": { + "type": "string", + "format": "date-time", + "description": "Start instant for operations." + }, + "to": { + "type": "string", + "format": "date-time", + "description": "End instant for operations." } }, "required": [ @@ -8773,7 +8980,31 @@ "format": "uuid" }, "scope": { - "$ref": "#/components/schemas/InvestmentScreenDto" + "oneOf": [ + { + "$ref": "#/components/schemas/IncomeInvestmentScreenDto" + }, + { + "$ref": "#/components/schemas/EventsInvestmentScreenDto" + }, + { + "$ref": "#/components/schemas/TimestampInvestmentScreenDto" + } + ], + "discriminator": { + "propertyName": "screen", + "mapping": { + "income": "#/components/schemas/IncomeInvestmentScreenDto", + "events": "#/components/schemas/EventsInvestmentScreenDto", + "accounts": "#/components/schemas/TimestampInvestmentScreenDto", + "account": "#/components/schemas/TimestampInvestmentScreenDto", + "operations": "#/components/schemas/TimestampInvestmentScreenDto", + "stocks": "#/components/schemas/TimestampInvestmentScreenDto", + "bonds": "#/components/schemas/TimestampInvestmentScreenDto", + "stock": "#/components/schemas/TimestampInvestmentScreenDto", + "bond": "#/components/schemas/TimestampInvestmentScreenDto" + } + } }, "now": { "type": "string", @@ -8813,13 +9044,32 @@ "format": "uuid" }, "scope": { - "nullable": true, - "type": "object", - "allOf": [ + "oneOf": [ { - "$ref": "#/components/schemas/InvestmentScreenDto" + "$ref": "#/components/schemas/IncomeInvestmentScreenDto" + }, + { + "$ref": "#/components/schemas/EventsInvestmentScreenDto" + }, + { + "$ref": "#/components/schemas/TimestampInvestmentScreenDto" } - ] + ], + "discriminator": { + "propertyName": "screen", + "mapping": { + "income": "#/components/schemas/IncomeInvestmentScreenDto", + "events": "#/components/schemas/EventsInvestmentScreenDto", + "accounts": "#/components/schemas/TimestampInvestmentScreenDto", + "account": "#/components/schemas/TimestampInvestmentScreenDto", + "operations": "#/components/schemas/TimestampInvestmentScreenDto", + "stocks": "#/components/schemas/TimestampInvestmentScreenDto", + "bonds": "#/components/schemas/TimestampInvestmentScreenDto", + "stock": "#/components/schemas/TimestampInvestmentScreenDto", + "bond": "#/components/schemas/TimestampInvestmentScreenDto" + } + }, + "nullable": true }, "now": { "type": "string", diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index ef1eb68..d6a24dd 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -269,6 +269,7 @@ const ru = { }, }, stock: { + currencyUnknown: "валюта неизвестна", searchTitle: "Акции", searchSubtitle: "Найдите компанию, изучите цену и дивиденды, посмотрите свою историю владения.", @@ -636,6 +637,7 @@ const ru = { comparisonPending: "Период сравнения ещё не загружен полностью.", comparisonFailed: "Не удалось загрузить период сравнения. Повторите обновление.", + previousUnavailable: "Сравнение с предыдущим периодом недоступно.", yearUnavailable: "Сравнение с годом ранее недоступно.", difference: "Разница", total: "Итого", @@ -659,6 +661,10 @@ const ru = { }, settings: { title: "Настройки" }, tbank: { + close: "Закрыть", + refreshError: + "Не удалось обновить статус подключения. Показаны последние полученные данные.", + refreshing: "Обновляем статус подключения…", title: "T-Bank Invest", disconnected: "Не подключено", connected: "Подключено с {{date}}", @@ -683,6 +689,7 @@ const ru = { title: "Не удалось открыть страницу", text: "Перейдите в безопасный раздел и попробуйте снова.", action: "Продолжить", + reload: "Перезагрузить страницу", }, states: { loadingTitle: "Загрузка", diff --git a/src/shared/theme/Foundations.stories.tsx b/src/shared/theme/Foundations.stories.tsx index 570df32..8cd0001 100644 --- a/src/shared/theme/Foundations.stories.tsx +++ b/src/shared/theme/Foundations.stories.tsx @@ -177,6 +177,33 @@ const meta = { export default meta; type Story = StoryObj; export const Colors: Story = { name: "Цвета", render: () => }; +export const SecondaryText: Story = { + name: "Вторичный текст", + render: () => ( + + {[ + "var(--wl-color-surface)", + "var(--wl-color-canvas)", + "var(--wl-color-subtle)", + ...Object.keys(designTokens.color.palettes) + .filter((palette) => palette !== "dark") + .flatMap((palette) => [ + `var(--mantine-color-${palette}-0)`, + `color-mix(in srgb, var(--mantine-color-${palette}-0) 45%, var(--wl-color-surface))`, + ]), + ].map((background) => ( + + + 22.09.2026 · Синтетические данные + + + Не удалось обновить данные. Сохранён предыдущий результат. + + + ))} + + ), +}; export const Typography: Story = { name: "Типографика", render: () => , diff --git a/src/shared/theme/contrast.test.ts b/src/shared/theme/contrast.test.ts index b79220d..aa5f1c6 100644 --- a/src/shared/theme/contrast.test.ts +++ b/src/shared/theme/contrast.test.ts @@ -9,16 +9,21 @@ function luminance(color: string) { ); return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722; } -describe("financial text contrast", () => { - it.each(["positive", "negative"] as const)( - "keeps %s readable on journal, position and tinted card surfaces", +describe("semantic text contrast", () => { + it.each(["positive", "negative", "muted"] as const)( + "keeps %s readable on canvas, white, subtle and tinted surfaces", (tone) => { const role = tokens.color.roles[tone]; const palettes = tokens.color.palettes; const foreground = palettes[role.color as keyof typeof palettes][role.shade]; const surfaces = [ + "#ffffff", palettes.gray[0], + palettes.gray[2], + ...Object.entries(palettes) + .filter(([name]) => name !== "dark") + .map(([, palette]) => palette[0]), ...Object.entries(palettes) .filter(([name]) => name !== "dark") .map( diff --git a/tsconfig.dependency-cruiser.json b/tsconfig.dependency-cruiser.json new file mode 100644 index 0000000..f8b19ca --- /dev/null +++ b/tsconfig.dependency-cruiser.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "paths": { + "@/*": ["src/*"], + "@wealthledger/ui": ["packages/ui/src/index.ts"], + "@wealthledger/ui/theme": ["packages/ui/src/theme.ts"], + "@wealthledger/ui/styles.css": ["packages/ui/src/styles.css"], + "@wealthledger/icons": ["packages/icons/src/index.ts"] + } + } +} diff --git a/tsconfig.node.json b/tsconfig.node.json index 32bf268..6aada0f 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -22,6 +22,7 @@ }, "include": [ "vite.config.ts", + "vitest.config.ts", ".storybook/main.ts", "packages/*/vite.config.ts" ] diff --git a/vitest.config.ts b/vitest.config.ts index 6ce0b7e..3893652 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,7 +14,6 @@ export default defineConfig({ exclude: configDefaults.exclude, setupFiles: "./src/test/setup.ts", coverage: { - all: true, include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"], reporter: ["text", "html", "lcov"], -- 2.54.0 From c1b08735e3a45d565c3836ae37ad76186bf48f26 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Sun, 27 Sep 2026 18:53:12 +0300 Subject: [PATCH 02/36] refactor: simplify resource queries and layout tests --- .gitea/workflows/image.yml | 2 + .gitea/workflows/verify.yml | 2 + .gitignore | 1 + README.md | 7 + package-lock.json | 201 ++++++++++++++++++ package.json | 2 + .../api/useBrokerageOperations.test.tsx | 2 +- .../portfolio/api/resource-lifecycle.test.tsx | 41 ++++ .../portfolio/api/usePortfolio.test.tsx | 2 +- src/features/instrument-details/index.ts | 1 + .../model/useInstrumentOperations.ts | 51 +++++ .../ui/AccountPage/AccountPage.test.tsx | 20 -- .../position-layout.browser.test.tsx | 76 +++++++ src/pages/bond/ui/BondPage/BondPage.vm.ts | 52 +---- .../stock/ui/StockPage/StockPage.test.tsx | 49 ++++- src/pages/stock/ui/StockPage/StockPage.vm.ts | 54 +---- src/shared/api/resource-query.ts | 19 +- vitest.config.ts | 48 ++++- 18 files changed, 508 insertions(+), 122 deletions(-) create mode 100644 src/features/instrument-details/model/useInstrumentOperations.ts create mode 100644 src/pages/account/ui/AccountPage/position-layout.browser.test.tsx diff --git a/.gitea/workflows/image.yml b/.gitea/workflows/image.yml index d802cea..5ad0de3 100644 --- a/.gitea/workflows/image.yml +++ b/.gitea/workflows/image.yml @@ -30,6 +30,8 @@ jobs: - run: npm ci env: HUSKY: '0' + - name: Install Chromium and system dependencies + run: npx --no-install playwright install --with-deps chromium - run: npm run verify env: KNIP_DISABLE_RAW_TRANSFER: '1' diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index bd7f96f..8664042 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -21,6 +21,8 @@ jobs: - run: npm ci env: HUSKY: '0' + - name: Install Chromium and system dependencies + run: npx --no-install playwright install --with-deps chromium - name: Run all frontend gates run: npm run verify env: diff --git a/.gitignore b/.gitignore index 17ca640..82a10c4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist storybook-static dist-ssr coverage +.vitest .scannerwork *.local diff --git a/README.md b/README.md index 94f3292..0097ab0 100644 --- a/README.md +++ b/README.md @@ -75,12 +75,19 @@ Service worker кеширует только статическую оболоч ```bash nvm use --silent +npm ci +npx --no-install playwright install chromium npm run verify ``` Команда проверяет, что generated OpenAPI types соответствуют committed snapshot, затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest, Knip, dependency rules приложения и stories, сборки приложения и Storybook. +Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless +Chromium при ширинах 375 и 1280 px. Browser project не использует JSDOM setup +и его заглушки layout API. Только браузерная проверка: +`npm run test:run -- --project='browser-*'`. После обновления Playwright повторите +установку Chromium; CI устанавливает его с `--with-deps` для Linux. Отдельный CI workflow `Frontend security` на push, pull request и ежедневно проверяет полную доступную историю Gitleaks 8.30.1 и выполняет `npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job; diff --git a/package-lock.json b/package-lock.json index 0ac4bc4..84a3bcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,7 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", + "@vitest/browser-playwright": "5.0.0", "@vitest/coverage-v8": "5.0.0", "babel-plugin-react-compiler": "1.0.0", "conventional-changelog-conventionalcommits": "10.4.0", @@ -59,6 +60,7 @@ "lint-staged": "17.5.1", "msw": "2.15.0", "openapi-typescript": "7.13.0", + "playwright": "1.63.0", "release-it": "21.0.2", "storybook": "10.6.0", "typescript": "5.9.3", @@ -853,6 +855,13 @@ "node": ">=14.21.3" } }, + "node_modules/@blazediff/core": { + "version": "1.10.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@blazediff/core/-/core-1.10.0.tgz", + "integrity": "sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3732,6 +3741,13 @@ "url": "https://github.com/phun-ky/typeof?sponsor=1" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, "node_modules/@redocly/ajv": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", @@ -5376,6 +5392,54 @@ } } }, + "node_modules/@vitest/browser": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/browser/-/browser-5.0.0.tgz", + "integrity": "sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.10.0", + "@vitest/mocker": "5.0.0", + "@vitest/ui": "5.0.0", + "@vitest/utils": "5.0.0", + "magic-string": "^1.2.3", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.1", + "ws": "^8.21.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "5.0.0" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/browser-playwright/-/browser-playwright-5.0.0.tgz", + "integrity": "sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "5.0.0", + "@vitest/mocker": "5.0.0", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "5.0.0" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, "node_modules/@vitest/coverage-v8": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.0.tgz", @@ -5456,6 +5520,19 @@ } } }, + "node_modules/@vitest/pretty-format": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/pretty-format/-/pretty-format-5.0.0.tgz", + "integrity": "sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/spy": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", @@ -5466,6 +5543,42 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/ui": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/ui/-/ui-5.0.0.tgz", + "integrity": "sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "5.0.0", + "fflate": "^0.8.3", + "flatted": "^3.4.4", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "5.0.0" + } + }, + "node_modules/@vitest/utils": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/utils/-/utils-5.0.0.tgz", + "integrity": "sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "5.0.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wealthledger/icons": { "resolved": "packages/icons", "link": true @@ -7606,6 +7719,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -7646,6 +7766,13 @@ "node": ">=10" } }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9946,6 +10073,16 @@ "integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==", "license": "MIT" }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -10749,6 +10886,35 @@ "dev": true, "license": "MIT" }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -10759,6 +10925,16 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -12172,6 +12348,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -13643,6 +13834,16 @@ "dev": true, "license": "MIT" }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/tough-cookie": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", diff --git a/package.json b/package.json index 275132b..ad387de 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.1", + "@vitest/browser-playwright": "5.0.0", "@vitest/coverage-v8": "5.0.0", "babel-plugin-react-compiler": "1.0.0", "conventional-changelog-conventionalcommits": "10.4.0", @@ -82,6 +83,7 @@ "lint-staged": "17.5.1", "msw": "2.15.0", "openapi-typescript": "7.13.0", + "playwright": "1.63.0", "release-it": "21.0.2", "storybook": "10.6.0", "typescript": "5.9.3", diff --git a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx index 800ecf8..fea398b 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx +++ b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx @@ -8,7 +8,7 @@ import { } from "./useBrokerageOperations"; describe("useBrokerageOperations", () => { - it("normalizes query parameters and loads a page without retries", async () => { + it("normalizes categories, preserves the cursor and loads a page", async () => { configureAuthClient({ accessToken: () => "token", refresh: async () => true, diff --git a/src/entities/portfolio/api/resource-lifecycle.test.tsx b/src/entities/portfolio/api/resource-lifecycle.test.tsx index 6bf9d4f..16e8609 100644 --- a/src/entities/portfolio/api/resource-lifecycle.test.tsx +++ b/src/entities/portfolio/api/resource-lifecycle.test.tsx @@ -79,8 +79,49 @@ describe("persistent resource query lifecycle", () => { await waitFor(() => expect(result.current.meta?.sync.state).toBe("failed")); expect(result.current.data).toBeUndefined(); expect(result.current.isSuccess).toBe(false); + expect(result.current.status).toBe("pending"); + expect(result.current.isPending).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.transport).toEqual({ + status: "success", + fetchStatus: "idle", + error: null, + }); expect(result.current.meta?.availability).toBe("missing"); }); + it("keeps the refetch envelope while exposing the refreshed resource payload", async () => { + auth(); + let ready = false; + const refreshed = resource(snapshot, { stateVersion: "ready" }); + server.use( + http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => + HttpResponse.json(ready ? refreshed : resource(null)), + ), + ); + const { result } = renderHook(() => usePortfolio("a"), { + wrapper: createQueryWrapper().wrapper, + }); + await waitFor(() => + expect(result.current.transport.status).toBe("success"), + ); + await act(async () => { + const missing = await result.current.refetch(); + expect(missing.status).toBe("success"); + expect(missing.data).toEqual(resource(null)); + expect(missing.data?.data).toBeNull(); + ready = true; + const loaded = await result.current.refetch(); + expect(loaded.data).toEqual(refreshed); + expect(loaded.data?.data?.totalsByCurrency).toEqual( + snapshot.totalsByCurrency, + ); + }); + await waitFor(() => expect(result.current.data).toEqual(snapshot)); + expect(result.current.meta).toEqual(refreshed.meta); + expect(result.current.status).toBe("success"); + expect(result.current.isSuccess).toBe(true); + expect(result.current.isPending).toBe(false); + }); it("rejects a late ordinary response when an observe response for that resource arrived first", async () => { auth(); let release!: () => void; diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx index efb6e4c..9c580aa 100644 --- a/src/entities/portfolio/api/usePortfolio.test.tsx +++ b/src/entities/portfolio/api/usePortfolio.test.tsx @@ -8,7 +8,7 @@ import { server } from "@/test/server"; import { usePortfolio } from "./usePortfolio"; describe("usePortfolio", () => { - it("uses the account endpoint with a 30 second cache and no retry", async () => { + it("loads the account portfolio", async () => { configureAuthClient({ accessToken: () => "token", refresh: async () => true, diff --git a/src/features/instrument-details/index.ts b/src/features/instrument-details/index.ts index dfaf39b..a8df803 100644 --- a/src/features/instrument-details/index.ts +++ b/src/features/instrument-details/index.ts @@ -1,4 +1,5 @@ export { section } from "./model/section"; +export { useInstrumentOperations } from "./model/useInstrumentOperations"; export { InstrumentChart } from "./ui/InstrumentChart.view"; export { InstrumentEvents } from "./ui/InstrumentEvents.view"; export { InstrumentPosition } from "./ui/InstrumentPosition.view"; diff --git a/src/features/instrument-details/model/useInstrumentOperations.ts b/src/features/instrument-details/model/useInstrumentOperations.ts new file mode 100644 index 0000000..2042dce --- /dev/null +++ b/src/features/instrument-details/model/useInstrumentOperations.ts @@ -0,0 +1,51 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { + brokerageOperationsQueryOptions, + type OperationCategory, + useBrokerageOperations, +} from "@/entities/broker-operation"; +import { apiErrorCode } from "@/shared/api"; + +function useInstrumentOperations( + uid: string, + filter: string, + categories: OperationCategory[], +) { + const scope = `${uid}:${filter}`; + const [pagination, setPagination] = useState<{ + scope: string; + cursors: string[]; + }>({ scope, cursors: [] }); + const cursors = pagination.scope === scope ? pagination.cursors : []; + const client = useQueryClient(); + const firstPage = { instrumentUid: uid, categories, limit: 20 }; + const operations = useBrokerageOperations(uid ? "all" : undefined, { + ...firstPage, + cursor: cursors.at(-1), + }); + const retry = (retryScreen: () => unknown) => { + if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") { + setPagination({ scope, cursors: [] }); + void client.invalidateQueries({ + queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, + exact: true, + }); + } else void retryScreen(); + }; + return { + operations, + retry, + hasPrevious: cursors.length > 0, + onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }), + onNext: () => { + if (operations.data?.nextCursor) + setPagination({ + scope, + cursors: [...cursors, operations.data.nextCursor], + }); + }, + }; +} + +export { useInstrumentOperations }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index 22ede2a..c9b718c 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -660,26 +660,6 @@ describe("AccountPage states", () => { ).toBeInTheDocument(); }); - it("renders the bonds section as a single-column grid on any screen width", () => { - renderPage( - , - ); - const singleColumnGrids = Array.from(document.querySelectorAll("style")) - .map((style) => (style.textContent ?? "").replace(/\s/g, "")) - .filter( - (css) => - css.includes("--sg-cols:1") && - !css.includes("@media") && - !/--sg-cols:[23]/.test(css), - ); - expect(singleColumnGrids).toHaveLength(1); - }); - it("keeps other-position cards compact with labeled return and no logo", () => { renderPage( { + const kinds: AccountPagePositionSection["kind"][] = [ + "stocks", + "bonds", + "other", + ]; + const width = window.innerWidth; + expect([375, 1280]).toContain(width); + for (const kind of kinds) { + const positions: AccountPagePosition[] = [1, 2, 3].map((index) => ({ + figi: null, + displayName: `Инструмент ${index}`, + type: kind === "stocks" ? "STOCK" : kind === "bonds" ? "BOND" : "FUND", + quantity: "10", + currentValue: { currency: "RUB", value: "1000" }, + averagePurchasePrice: null, + absoluteReturn: null, + isin: null, + ticker: null, + logoName: null, + shareText: null, + returnText: null, + logoSrc: null, + })); + const { unmount } = render( + + ({ + key: position.displayName, + positions: [position], + })), + }} + /> + , + ); + const bounds = positions.map((position) => + screen.getByText(position.displayName).getBoundingClientRect(), + ); + const context = `${kind} at ${width}px`; + for (const [index, current] of bounds.entries()) { + expect(current.width, context).toBeGreaterThan(0); + expect(current.height, context).toBeGreaterThan(0); + if (index === 0) continue; + const previous = bounds[index - 1]; + if (kind === "other" && width === 1280) { + expect(current.top, context).toBeCloseTo(previous.top, 0); + expect(current.left, context).toBeGreaterThan(previous.right); + } else { + expect(current.left, context).toBeCloseTo(previous.left, 0); + expect(current.top, context).toBeGreaterThan(previous.bottom); + } + } + unmount(); + } +}); diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index 0177c4b..4c81fef 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -1,5 +1,3 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; import { useParams, useSearchParams } from "react-router"; import { type BondPeriod, @@ -10,12 +8,11 @@ import { useBondPosition, useBondQuote, } from "@/entities/bond"; +import type { OperationCategory } from "@/entities/broker-operation"; import { - brokerageOperationsQueryOptions, - type OperationCategory, - useBrokerageOperations, -} from "@/entities/broker-operation"; -import { section } from "@/features/instrument-details"; + section, + useInstrumentOperations, +} from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { formatDate, instrumentLogoUrl } from "@/shared/lib"; @@ -38,27 +35,13 @@ function useBondPageVm() { periods.find((value) => value === params.get("period")) ?? "month"; const filter = filters.find((value) => value === params.get("events")) ?? "all"; - const scope = `${uid}:${filter}`; - const [pagination, setPagination] = useState<{ - scope: string; - cursors: string[]; - }>({ scope, cursors: [] }); - const cursors = pagination.scope === scope ? pagination.cursors : []; - const client = useQueryClient(); const quote = useBondQuote(uid); const metrics = useBondMetrics(uid); const candles = useBondCandles(uid, period); const position = useBondPosition(uid); const coupons = useBondCoupons(uid); - const firstPage = { - instrumentUid: uid, - categories: categories[filter], - limit: 20, - }; - const operations = useBrokerageOperations(uid ? "all" : undefined, { - ...firstPage, - cursor: cursors.at(-1), - }); + const history = useInstrumentOperations(uid, filter, categories[filter]); + const { operations } = history; const sync = useScreenSync( { screen: "bond", @@ -75,15 +58,6 @@ function useBondPageVm() { next.set(key, value); setParams(next, { replace: true }); }; - const retryOperations = () => { - if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") { - setPagination({ scope, cursors: [] }); - void client.invalidateQueries({ - queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, - exact: true, - }); - } else void sync.retry(); - }; const chartData = candles.data?.candles.map((candle) => ({ ...candle, @@ -138,7 +112,7 @@ function useBondPageVm() { operations: operations.data, operationsState: { ...section(operations, sync.retry), - retry: retryOperations, + retry: () => history.retry(sync.retry), }, eventRows: operations.data?.items.map((item) => ({ @@ -148,15 +122,9 @@ function useBondPageVm() { })) ?? [], filter, setFilter: (value: string) => setParam("events", value), - hasPrevious: cursors.length > 0, - onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }), - onNext: () => { - if (operations.data?.nextCursor) - setPagination({ - scope, - cursors: [...cursors, operations.data.nextCursor], - }); - }, + hasPrevious: history.hasPrevious, + onPrevious: history.onPrevious, + onNext: history.onNext, }; } type BondPageVm = ReturnType; diff --git a/src/pages/stock/ui/StockPage/StockPage.test.tsx b/src/pages/stock/ui/StockPage/StockPage.test.tsx index fc251ce..56175a6 100644 --- a/src/pages/stock/ui/StockPage/StockPage.test.tsx +++ b/src/pages/stock/ui/StockPage/StockPage.test.tsx @@ -70,6 +70,7 @@ function setup( noPosition?: boolean; failCompany?: boolean; failStock?: boolean; + cursorError?: string; path?: string; slug?: string; ambiguousTicker?: boolean; @@ -79,6 +80,8 @@ function setup( const requests: URL[] = []; const resolved = { ...stock, slug: options.slug ?? uid }; let failQuote = false; + let cursorError = options.cursorError; + let operationPayment = "200"; server.use( http.get("*/api/v1/stocks/:uid", () => options.failStock @@ -166,6 +169,8 @@ function setup( const url = new URL(request.url); requests.push(url); const next = url.searchParams.has("cursor"); + if (next && cursorError) + return HttpResponse.json({ code: cursorError }, { status: 400 }); return HttpResponse.json( resource({ accountId: "all", @@ -182,7 +187,7 @@ function setup( category: next ? "trade" : "income", description: "", account: { id: "main", name: "Основной" }, - payment: money("200"), + payment: money(operationPayment), quantity: next ? "2" : null, price: next ? money("100") : null, }, @@ -211,6 +216,10 @@ function setup( ); return { requests, + recoverOperations: () => { + cursorError = undefined; + operationPayment = "201"; + }, failQuote: () => { failQuote = true; }, @@ -374,7 +383,7 @@ describe("stock page", () => { expect(requests).toHaveLength(previousRequests); expect(modes).toEqual(["observe"]); }); - it("sends instrument-filtered continuation, resets it on filter changes and changes chart period", async () => { + it("sends instrument-filtered continuation, restores it on returning to the filter and changes chart period", async () => { const { requests } = setup(); await screen.findByText("Зачисление дивидендов"); fireEvent.click(screen.getByRole("button", { name: "Далее" })); @@ -399,6 +408,14 @@ describe("stock page", () => { ), ).toBe(true), ); + expect(await screen.findByText("Зачисление дивидендов")).toBeVisible(); + expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); + fireEvent.click(screen.getByRole("radio", { name: "Все" })); + expect(await screen.findByText("Продажа")).toBeVisible(); + expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(); + fireEvent.click(screen.getByRole("button", { name: "Назад" })); + expect(await screen.findByText("Зачисление дивидендов")).toBeVisible(); + expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); fireEvent.click(screen.getByRole("radio", { name: "Год" })); await waitFor(() => expect( @@ -406,6 +423,34 @@ describe("stock page", () => { ).toBe(true), ); }); + it.each([ + ["INVALID_OPERATIONS_CURSOR", null, null], + ["TBANK_UNAVAILABLE", "next", "observe"], + ])( + "recovers history after %s with fresh data and the correct cursor", + async (code, cursor, sync) => { + const controls = setup({ cursorError: code }); + await screen.findByText("Зачисление дивидендов"); + fireEvent.click(screen.getByRole("button", { name: "Далее" })); + const retry = await screen.findByRole("button", { name: "Повторить" }); + controls.recoverOperations(); + const previousRequests = controls.requests.length; + fireEvent.click(retry); + expect(await screen.findByText("+201 ₽")).toBeVisible(); + const recovered = controls.requests.slice(previousRequests); + expect(recovered).toHaveLength(1); + expect(recovered[0]?.pathname).toBe( + "/api/v1/brokerage-accounts/all/operations", + ); + expect(recovered[0]?.searchParams.get("instrumentUid")).toBe(uid); + expect(recovered[0]?.searchParams.get("limit")).toBe("20"); + expect(recovered[0]?.searchParams.get("cursor")).toBe(cursor); + expect(recovered[0]?.searchParams.get("sync")).toBe(sync); + if (cursor) + expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(); + else expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); + }, + ); it("keeps the last quote on a refresh failure", async () => { const scopes: unknown[] = []; server.use( diff --git a/src/pages/stock/ui/StockPage/StockPage.vm.ts b/src/pages/stock/ui/StockPage/StockPage.vm.ts index f996827..bab72b7 100644 --- a/src/pages/stock/ui/StockPage/StockPage.vm.ts +++ b/src/pages/stock/ui/StockPage/StockPage.vm.ts @@ -1,11 +1,5 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; import { useParams, useSearchParams } from "react-router"; -import { - brokerageOperationsQueryOptions, - type OperationCategory, - useBrokerageOperations, -} from "@/entities/broker-operation"; +import type { OperationCategory } from "@/entities/broker-operation"; import { type StockPeriod, useStock, @@ -15,7 +9,10 @@ import { useStockPosition, useStockQuote, } from "@/entities/stock"; -import { section } from "@/features/instrument-details"; +import { + section, + useInstrumentOperations, +} from "@/features/instrument-details"; import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; import { formatDate, instrumentLogoUrl } from "@/shared/lib"; @@ -38,27 +35,13 @@ function useStockPageVm() { periods.find((value) => value === params.get("period")) ?? "month"; const filter = filters.find((value) => value === params.get("events")) ?? "all"; - const scope = `${uid}:${filter}`; - const [pagination, setPagination] = useState<{ - scope: string; - cursors: string[]; - }>({ scope, cursors: [] }); - const cursors = pagination.scope === scope ? pagination.cursors : []; - const client = useQueryClient(); const quote = useStockQuote(uid); const candles = useStockCandles(uid, period); const position = useStockPosition(uid); const company = useStockCompany(uid); const dividends = useStockDividends(uid); - const firstPage = { - instrumentUid: uid, - categories: categories[filter], - limit: 20, - }; - const operations = useBrokerageOperations(uid ? "all" : undefined, { - ...firstPage, - cursor: cursors.at(-1), - }); + const history = useInstrumentOperations(uid, filter, categories[filter]); + const { operations } = history; const sync = useScreenSync( { screen: "stock", @@ -75,15 +58,6 @@ function useStockPageVm() { next.set(key, value); setParams(next, { replace: true }); }; - const retryOperations = () => { - if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") { - setPagination({ scope, cursors: [] }); - void client.invalidateQueries({ - queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, - exact: true, - }); - } else void sync.retry(); - }; const chartData = candles.data?.candles.map((candle) => ({ ...candle, @@ -145,7 +119,7 @@ function useStockPageVm() { operations: operations.data, operationsState: { ...section(operations, sync.retry), - retry: retryOperations, + retry: () => history.retry(sync.retry), }, eventRows: operations.data?.items.map((item) => ({ @@ -155,15 +129,9 @@ function useStockPageVm() { })) ?? [], filter, setFilter: (value: string) => setParam("events", value), - hasPrevious: cursors.length > 0, - onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }), - onNext: () => { - if (operations.data?.nextCursor) - setPagination({ - scope, - cursors: [...cursors, operations.data.nextCursor], - }); - }, + hasPrevious: history.hasPrevious, + onPrevious: history.onPrevious, + onNext: history.onNext, }; } type StockPageVm = ReturnType; diff --git a/src/shared/api/resource-query.ts b/src/shared/api/resource-query.ts index 1f124e4..f368ed7 100644 --- a/src/shared/api/resource-query.ts +++ b/src/shared/api/resource-query.ts @@ -22,6 +22,12 @@ function unwrapResourceResult( result: UseQueryResult, ) { const missing = result.isSuccess && result.data.data === null; + const data: T["data"] | undefined = result.data?.data; + const meta: T["meta"] | undefined = result.data?.meta; + const status: UseQueryResult["status"] = missing + ? "pending" + : result.status; + // Project resource fields; refetch still returns the transport envelope. return { ...result, transport: { @@ -29,18 +35,11 @@ function unwrapResourceResult( fetchStatus: result.fetchStatus, error: result.error, }, - data: result.data?.data ?? undefined, - meta: result.data?.meta, + data: data ?? undefined, + meta, isPending: result.isPending || missing, isSuccess: result.isSuccess && !missing, - status: missing ? "pending" : result.status, - } as UseQueryResult, E> & { - meta?: T["meta"]; - transport: { - status: UseQueryResult["status"]; - fetchStatus: UseQueryResult["fetchStatus"]; - error: E | null; - }; + status, }; } diff --git a/vitest.config.ts b/vitest.config.ts index 3893652..fd3e03b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,12 @@ import { fileURLToPath, URL } from "node:url"; import react from "@vitejs/plugin-react"; +import { playwright } from "@vitest/browser-playwright"; import { configDefaults, defineConfig } from "vitest/config"; const CI_TEST_TIMEOUT_MS = 15_000; +const LAYOUT_VIEWPORT_HEIGHT = 900; +const LAYOUT_NARROW_WIDTH = 375; +const LAYOUT_WIDE_WIDTH = 1280; export default defineConfig({ plugins: [react()], @@ -10,9 +14,47 @@ export default defineConfig({ test: { // CI runs the UI scenarios with coverage on a slower runner. testTimeout: process.env.CI ? CI_TEST_TIMEOUT_MS : undefined, - environment: "jsdom", - exclude: configDefaults.exclude, - setupFiles: "./src/test/setup.ts", + projects: [ + { + extends: true, + test: { + name: "unit", + environment: "jsdom", + exclude: [...configDefaults.exclude, "**/*.browser.test.tsx"], + setupFiles: "./src/test/setup.ts", + }, + }, + { + extends: true, + test: { + name: "browser", + include: ["src/**/*.browser.test.tsx"], + browser: { + enabled: true, + provider: playwright(), + headless: true, + instances: [ + { + browser: "chromium", + name: "browser-narrow", + viewport: { + width: LAYOUT_NARROW_WIDTH, + height: LAYOUT_VIEWPORT_HEIGHT, + }, + }, + { + browser: "chromium", + name: "browser-wide", + viewport: { + width: LAYOUT_WIDE_WIDTH, + height: LAYOUT_VIEWPORT_HEIGHT, + }, + }, + ], + }, + }, + }, + ], coverage: { include: ["src/**/*.{ts,tsx}", "packages/*/src/**/*.{ts,tsx}"], exclude: ["src/test/**", "**/*.test.{ts,tsx}", "**/*.stories.tsx"], -- 2.54.0 From 95fdc8759e20f9baa4ac3d00e703752c79010e01 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 14:18:16 +0300 Subject: [PATCH 03/36] ci: reduce duplicate actions and serialize checks --- .gitea/workflows/commits.yml | 26 -------------- .gitea/workflows/security.yml | 33 ------------------ .gitea/workflows/verify.yml | 66 +++++++++++++++++++++++++++++++++-- README.md | 20 +++++++++-- 4 files changed, 82 insertions(+), 63 deletions(-) delete mode 100644 .gitea/workflows/commits.yml delete mode 100644 .gitea/workflows/security.yml diff --git a/.gitea/workflows/commits.yml b/.gitea/workflows/commits.yml deleted file mode 100644 index 2372d2c..0000000 --- a/.gitea/workflows/commits.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Conventional commits - -on: - pull_request: - types: [opened, synchronize, reopened, edited] - -jobs: - commits: - runs-on: ubuntu-latest - steps: - - uses: https://github.com/actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - fetch-depth: 0 - - uses: https://github.com/actions/setup-node@v4 - with: - node-version-file: .nvmrc - - run: npm ci --ignore-scripts - - name: Check PR title and commits - env: - PR_TITLE: ${{ github.event.pull_request.title }} - BASE_SHA: ${{ github.event.pull_request.base.sha }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict - npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA" diff --git a/.gitea/workflows/security.yml b/.gitea/workflows/security.yml deleted file mode 100644 index aab00cb..0000000 --- a/.gitea/workflows/security.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Frontend security - -on: - push: - branches: - - '**' - pull_request: - schedule: - - cron: '17 5 * * *' - -jobs: - security: - runs-on: ubuntu-latest - steps: - - uses: https://github.com/actions/checkout@v4 - with: - fetch-depth: 0 - - name: Record checked revision - run: git rev-parse HEAD - - name: Scan history with Gitleaks 8.30.1 - run: | - curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz - echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check - mkdir -p /tmp/gitleaks-bin - tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks - /tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner - - uses: https://github.com/actions/setup-node@v4 - with: - node-version-file: .nvmrc - - name: Verify pre-commit secret blocking and redaction - run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs - - name: Audit runtime and development dependencies - run: npm audit --include=dev --audit-level=low diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index 8664042..de41d41 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -2,12 +2,74 @@ name: Frontend verify on: push: - branches: - - '**' + branches: [develop, main] pull_request: + types: [opened, synchronize, reopened, edited] + schedule: + - cron: '17 5 * * *' + +# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event. +concurrency: + group: frontend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }} + cancel-in-progress: true jobs: + commits: + if: gitea.event_name == 'pull_request' + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: https://github.com/actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + - uses: https://github.com/actions/setup-node@v4 + with: + node-version-file: .nvmrc + - run: npm ci --ignore-scripts + - name: Check PR title and commits + env: + PR_TITLE: ${{ github.event.pull_request.title }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict + npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA" + + security: + needs: commits + if: >- + !cancelled() && + (needs.commits.result == 'success' || needs.commits.result == 'skipped') + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: https://github.com/actions/checkout@v4 + with: + fetch-depth: 0 + - name: Record checked revision + run: git rev-parse HEAD + - name: Scan history with Gitleaks 8.30.1 + run: | + curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz + echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check + mkdir -p /tmp/gitleaks-bin + tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks + /tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner + - uses: https://github.com/actions/setup-node@v4 + with: + node-version-file: .nvmrc + - name: Verify pre-commit secret blocking and redaction + run: PATH=/tmp/gitleaks-bin:$PATH node --test scripts/test-pre-commit.mjs + - name: Audit runtime and development dependencies + run: npm audit --include=dev --audit-level=low + verify: + needs: [commits, security] + if: >- + !cancelled() && needs.security.result == 'success' && + gitea.event_name != 'schedule' + timeout-minutes: 45 runs-on: ubuntu-latest steps: - uses: https://github.com/actions/checkout@v4 diff --git a/README.md b/README.md index 0097ab0..ddaee74 100644 --- a/README.md +++ b/README.md @@ -88,14 +88,30 @@ Chromium при ширинах 375 и 1280 px. Browser project не исполь и его заглушки layout API. Только браузерная проверка: `npm run test:run -- --project='browser-*'`. После обновления Playwright повторите установку Chromium; CI устанавливает его с `--with-deps` для Linux. -Отдельный CI workflow `Frontend security` на push, pull request и ежедневно -проверяет полную доступную историю Gitleaks 8.30.1 и выполняет +Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify` +последовательно на PR; на push в `develop`/`main` — `security → verify`. +Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска. +Изменение заголовка PR тоже повторяет проверки Conventional Commits. +Новый запуск отменяет устаревший для того же PR/ветки и типа события +(требуется Gitea 1.26+). Ежедневно в 05:17 UTC выполняется только `security`. +Job `security` проверяет полную доступную историю Gitleaks 8.30.1 и выполняет `npm audit --include=dev --audit-level=low`. Любой advisory блокирует security job; исключений frontend нет. Локально можно выполнить те же команды: `gitleaks git --log-opts=--all --redact=100 --no-banner` и `npm audit --include=dev --audit-level=low`. Audit требует доступа к npm registry; проверка секретов выполняется локально и не выводит их значения. +Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя +workflow в статус. При внедрении администратор должен заменить required checks +для защищённых веток на `Frontend verify / commits (pull_request)`, +`Frontend verify / security (pull_request)` и +`Frontend verify / verify (pull_request)`, удалив старые имена и push-статусы, +если они требовались. Все три PR-проверки должны оставаться обязательными. +Публикация образов по тегам остаётся в `image.yml`. +Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте +`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные +runners. Конфигурация runner управляется вне этого репозитория. + TypeScript остаётся на 5.9.3: `openapi-typescript@7.13.0` требует peer `^5.x`. Обновление до TypeScript 7 из Renovate dashboard откладывается до совместимого релиза генератора; `--force`/`--legacy-peer-deps` для обхода этого ограничения -- 2.54.0 From 57630102e4bcc6b555dd546ba373a14a88256eed Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 05:52:29 +0300 Subject: [PATCH 04/36] chore: add typed ESLint checks #18 Refs WealthLedger/main#18. Keep Biome; add no Prettier dependencies. --- AGENTS.md | 2 +- README.md | 28 +- biome.json | 1 + eslint.config.mjs | 131 + package-lock.json | 2444 +++++++++++++++++ package.json | 20 +- packages/ui/src/EntryCard/EntryCard.test.tsx | 16 + packages/ui/src/EntryCard/EntryCard.tsx | 2 +- scripts/test-eslint.mjs | 64 + src/app/routes/AppRoutes/AppRoutes.test.tsx | 32 +- .../RouteErrorBoundary.test.tsx | 6 +- .../ui/AppBreadcrumbs/AccountScopeSwitch.tsx | 2 +- src/entities/bank-card/api/mutations.ts | 1 + src/entities/bank-card/api/queries.ts | 2 + .../ui/CategoryManager/CategoryManager.tsx | 44 +- .../portfolio/api/resource-lifecycle.test.tsx | 1 + src/features/auth/index.ts | 3 +- src/features/auth/model/auth-context.ts | 23 + src/features/auth/model/auth.test.tsx | 3 +- src/features/auth/model/auth.tsx | 34 +- .../auth/ui/AuthForm/AuthForm.test.tsx | 4 +- src/features/auth/ui/LoginForm.tsx | 4 +- src/features/auth/ui/RegisterForm.tsx | 4 +- .../model/screen-sync.test.tsx | 55 +- .../tbank-connection/model/screen-sync.ts | 6 +- .../tbank-connection/model/sync.test.tsx | 6 +- .../TbankConnectionSection.test.tsx | 16 +- .../AccountEventsPage/AccountEventsPage.vm.ts | 9 +- .../AccountIncomePage.test.tsx | 16 +- .../AccountIncomePage/AccountIncomePage.vm.ts | 21 +- .../IncomePeriodForm.view.tsx | 25 +- .../AccountOperationsPage.vm.ts | 14 +- .../account/ui/AccountPage/AccountPage.vm.ts | 6 +- .../card/ui/OperationForm/OperationForm.vm.ts | 6 +- src/pages/settings/ui/SettingsPage.tsx | 2 +- src/shared/api/client.test.ts | 1 + src/shared/api/client.ts | 13 +- src/shared/ui/Button.stories.tsx | 2 +- 38 files changed, 2914 insertions(+), 155 deletions(-) create mode 100644 eslint.config.mjs create mode 100644 scripts/test-eslint.mjs create mode 100644 src/features/auth/model/auth-context.ts diff --git a/AGENTS.md b/AGENTS.md index 7936743..624f07e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,4 +34,4 @@ Testing и без production diff. Перед завершением повто | Money, account/position/income | Entity types, API snapshot, formatters и tests соответствующего slice | | Theme/tokens, UI-каталог | [UI](.ai/conventions/ui.md), [Storybook](README.md#ui-каталог-storybook) | | Поведение области | Source и tests её page/entity/feature; неясное ожидание уточни до изменения | -| Versions/scripts/check scope | Manifest/lockfile, Biome/TypeScript/dependency configs | +| Versions/scripts/check scope | Manifest/lockfile, ESLint/Biome/TypeScript/dependency configs | diff --git a/README.md b/README.md index ddaee74..7c50ea2 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,34 @@ npm run verify ``` Команда проверяет, что generated OpenAPI types соответствуют committed snapshot, -затем запускает strict TypeScript, Biome, один coverage-backed запуск Vitest, +затем запускает strict TypeScript, Biome, ESLint и проверку его конфигурации, +один coverage-backed запуск Vitest, Knip, dependency rules приложения и stories, сборки приложения и Storybook. + +`npm run lint` объединяет `lint:biome` и `lint:eslint`. Biome сохраняет +форматирование, сортировку импортов, magic numbers и accessibility-проверки. +[ESLint](eslint.config.mjs) добавляет typed linting, React Compiler/hooks, +Fast Refresh, TanStack Query, выбранные Unicorn-правила и проверки тестов/stories. +Prettier не добавлен. `npm run test:lint` проверяет правила на ошибочных примерах +и исключение generated/build artifacts. `lint:eslint` сначала собирает private +packages, чтобы анализ типов использовал их актуальные declarations. + +Пересекающиеся hook-диагностики принадлежат официальному `react-hooks`. +Testing Library проверяет async-корректность; DOM structure/geometry tests +сохраняют прямой доступ к узлам. Async doubles допускаются без искусственного +`await`; Vitest допускает второй аргумент сообщения и ветвления фиксированных +`it.each` сценариев. React event handlers допускают Promise, но их тела остаются +под `no-floating-promises`. Локальные исключения объясняют сохранение wire +ProblemDetails, generated query keys и раннее подключение rejection assertions +в race-тестах. Плагин Query не доказывает корректность всех generated/custom +обёрток: transport/cache сценарии по-прежнему проверяют MSW-тесты. + +Дополнительные `no-unnecessary-condition` и `no-deprecated` пока не включены: +первому нужна отдельная оценка индексного доступа и защитных runtime guards, +второму — отдельная миграция API зависимостей. Пакеты второй волны и +`jsx-a11y` без заявленной поддержки ESLint 10 отложены в +[workspace issue #18](https://git.personal.ksv741.ru/WealthLedger/main/issues/18). + Vitest включает JSDOM-тесты и проверку геометрии секций позиций в headless Chromium при ширинах 375 и 1280 px. Browser project не использует JSDOM setup и его заглушки layout API. Только браузерная проверка: diff --git a/biome.json b/biome.json index babf97e..2e7202f 100644 --- a/biome.json +++ b/biome.json @@ -36,6 +36,7 @@ "overrides": [ { "includes": [ + "eslint.config.mjs", "vite.config.ts", "vitest.config.ts", ".storybook/main.ts", diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..2b341d9 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,131 @@ +import js from "@eslint/js"; +import react from "@eslint-react/eslint-plugin"; +import query from "@tanstack/eslint-plugin-query"; +import vitest from "@vitest/eslint-plugin"; +import { defineConfig, globalIgnores } from "eslint/config"; +import { importX } from "eslint-plugin-import-x"; +import hooks from "eslint-plugin-react-hooks"; +import refresh from "eslint-plugin-react-refresh"; +import storybook from "eslint-plugin-storybook"; +import testingLibrary from "eslint-plugin-testing-library"; +import unicorn from "eslint-plugin-unicorn"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default defineConfig( + globalIgnores([ + "**/dist/**", + "dist-ssr/**", + "storybook-static/**", + "coverage/**", + ".vitest/**", + ".scannerwork/**", + "src/shared/api/generated/**", + "src/shared/api/spec/**", + ]), + { + files: ["**/*.{js,mjs,cjs,ts,tsx}"], + extends: [js.configs.recommended], + linterOptions: { reportUnusedDisableDirectives: "error" }, + }, + { + files: ["**/*.{js,mjs,cjs}", "**/*.config.ts", ".storybook/main.ts"], + languageOptions: { globals: globals.node }, + }, + { + files: ["**/*.{ts,tsx}"], + extends: [tseslint.configs.recommendedTypeChecked], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/switch-exhaustiveness-check": "error", + // React ignores handler return values; their bodies still check floating promises. + "@typescript-eslint/no-misused-promises": [ + "error", + { checksVoidReturn: { attributes: false } }, + ], + }, + }, + { + files: [ + "src/**/*.{ts,tsx}", + "packages/*/src/**/*.{ts,tsx}", + ".storybook/preview.tsx", + ], + extends: [ + react.configs["recommended-type-checked"], + react.configs["disable-naming-convention"], + hooks.configs.flat.recommended, + query.configs["flat/recommended"], + ], + languageOptions: { globals: globals.browser }, + plugins: { "import-x": importX, unicorn }, + rules: { + // The official hooks plugin owns Compiler and hook diagnostics. + "@eslint-react/error-boundaries": "off", + "@eslint-react/exhaustive-deps": "off", + "@eslint-react/purity": "off", + "@eslint-react/rules-of-hooks": "off", + "@eslint-react/set-state-in-effect": "off", + "@eslint-react/set-state-in-render": "off", + "@eslint-react/static-components": "off", + "@eslint-react/unsupported-syntax": "off", + "@eslint-react/use-memo": "off", + // useContext remains supported; a lint rollout does not require an API migration. + "@eslint-react/no-use-context": "off", + "import-x/no-default-export": "error", + "import-x/exports-last": "error", + // Biome owns formatting, import sorting and magic numbers during coexistence. + "unicorn/no-array-fill-with-reference-type": "error", + "unicorn/no-await-in-promise-methods": "error", + "unicorn/no-single-promise-in-promise-methods": "error", + "unicorn/prefer-array-some": "error", + "unicorn/prefer-includes": "error", + }, + }, + { + files: ["src/**/*.tsx", "packages/*/src/**/*.tsx"], + ignores: ["**/*.test.tsx", "**/*.stories.tsx", "src/test/**"], + extends: [refresh.configs.vite], + }, + { + files: ["**/*.test.{ts,tsx}"], + extends: [vitest.configs.recommended], + rules: { + "vitest/no-focused-tests": "error", + "vitest/valid-expect": ["error", { maxArgs: 2 }], + // it.each cases select different expected outcomes and recovery setup steps. + "vitest/no-conditional-expect": "off", + // Async test doubles preserve the public Promise contract without artificial awaits. + "@typescript-eslint/require-await": "off", + }, + }, + { + files: ["**/*.test.tsx"], + plugins: { "testing-library": testingLibrary }, + // Start with async correctness. DOM structure/geometry tests need direct node access. + rules: { + "testing-library/await-async-events": "error", + "testing-library/await-async-queries": "error", + "testing-library/await-async-utils": "error", + "testing-library/no-await-sync-events": "error", + "testing-library/no-await-sync-queries": "error", + "testing-library/no-promise-in-fire-event": "error", + "testing-library/no-wait-for-side-effects": "error", + "testing-library/no-wait-for-snapshot": "error", + "testing-library/prefer-find-by": "error", + }, + }, + storybook.configs["flat/recommended"], + { + files: ["**/*.stories.tsx", ".storybook/preview.tsx"], + rules: { + "import-x/no-default-export": "off", + "import-x/exports-last": "off", + }, + }, +); diff --git a/package-lock.json b/package-lock.json index 84a3bcd..1449d78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,10 +39,13 @@ "@biomejs/biome": "2.5.13", "@commitlint/cli": "21.2.2", "@commitlint/config-conventional": "21.2.2", + "@eslint-react/eslint-plugin": "5.21.0", + "@eslint/js": "10.0.1", "@release-it/conventional-changelog": "12.0.0", "@rolldown/plugin-babel": "0.2.4", "@storybook/addon-docs": "10.6.0", "@storybook/react-vite": "10.6.0", + "@tanstack/eslint-plugin-query": "5.104.0", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", "@types/node": "24.13.4", @@ -51,9 +54,18 @@ "@vitejs/plugin-react": "6.1.1", "@vitest/browser-playwright": "5.0.0", "@vitest/coverage-v8": "5.0.0", + "@vitest/eslint-plugin": "1.6.27", "babel-plugin-react-compiler": "1.0.0", "conventional-changelog-conventionalcommits": "10.4.0", "dependency-cruiser": "18.2.0", + "eslint": "10.11.0", + "eslint-plugin-import-x": "4.17.1", + "eslint-plugin-react-hooks": "7.1.1", + "eslint-plugin-react-refresh": "0.5.7", + "eslint-plugin-storybook": "10.6.0", + "eslint-plugin-testing-library": "7.16.2", + "eslint-plugin-unicorn": "76.0.0", + "globals": "17.12.0", "husky": "9.1.7", "jsdom": "30.0.1", "knip": "6.35.1", @@ -64,6 +76,7 @@ "release-it": "21.0.2", "storybook": "10.6.0", "typescript": "5.9.3", + "typescript-eslint": "8.70.1", "vite": "8.3.0", "vite-plugin-pwa": "1.3.0", "vitest": "5.0.0" @@ -875,6 +888,30 @@ "specificity": "bin/cli.js" } }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, "node_modules/@commitlint/cli": { "version": "21.2.2", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-21.2.2.tgz", @@ -1805,6 +1842,327 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint-react/ast": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint-react/ast/-/ast-5.21.0.tgz", + "integrity": "sha512-9x+lQGJaVD7u6NIK6dx1/xZ0mJBcFlSVC9e9AVu4NOvZErFzVSK1tEP38iBFkP14bcqrLp7jbokze5FOC4gQGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/typescript-estree": "^8.70.1", + "@typescript-eslint/utils": "^8.70.1", + "string-ts": "^2.3.1" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint-react/core": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint-react/core/-/core-5.21.0.tgz", + "integrity": "sha512-PAuhwpWxFyFa6YPX771ttuo0W4Ggw/BannJ+4XJmPr0MV1a9s6tBiddMH5qWeWiuuQTT/5fmKzRWGcIMyr6iPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.21.0", + "@eslint-react/eslint": "5.21.0", + "@eslint-react/shared": "5.21.0", + "@eslint-react/var": "5.21.0", + "@typescript-eslint/scope-manager": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/utils": "^8.70.1", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint-react/eslint": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint-react/eslint/-/eslint-5.21.0.tgz", + "integrity": "sha512-OdRp+P4s+d8CRd9rvv+5Ondp050imriYz4HH0qUM7REJpVl5NQewdwgYzQSlkJD6i+drmygt4gBh3hpwMPnBzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/utils": "^8.70.1" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint-react/eslint-plugin": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint-react/eslint-plugin/-/eslint-plugin-5.21.0.tgz", + "integrity": "sha512-H4mlT5BiDu62Ya6QNMWxMka6Hz20SbWeeXhgxvW/sV2KInQv1UU5xvIMh3fWuBMKp8hWMheS5YGL/QmI3v7lGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/shared": "5.21.0", + "eslint-plugin-react-dom": "5.21.0", + "eslint-plugin-react-jsx": "5.21.0", + "eslint-plugin-react-naming-convention": "5.21.0", + "eslint-plugin-react-rsc": "5.21.0", + "eslint-plugin-react-web-api": "5.21.0", + "eslint-plugin-react-x": "5.21.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint-react/jsx": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint-react/jsx/-/jsx-5.21.0.tgz", + "integrity": "sha512-/dyHSVYm46aWt4ABZoZXuiXsU622UsONOHIHLodM8hZkgoL/IJC70LHn9ZYk8Dnj2bzWwAbktKrmNDoZDgkvbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.21.0", + "@eslint-react/eslint": "5.21.0", + "@eslint-react/shared": "5.21.0", + "@eslint-react/var": "5.21.0", + "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/utils": "^8.70.1", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint-react/shared": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint-react/shared/-/shared-5.21.0.tgz", + "integrity": "sha512-IIeMqRpbPMcUGhsMaXqlRfELfNLTAmywC671kag/soW580s+8LKU4DkTIULoorD+XxPenXlB1t0SE3fze23whw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/eslint": "5.21.0", + "@typescript-eslint/utils": "^8.70.1", + "ts-pattern": "^5.9.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint-react/var": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint-react/var/-/var-5.21.0.tgz", + "integrity": "sha512-segy1jD22R5mOP2GEmnwIDh0vdooJtTtsB+bWu3SC2LB6iLtASvDmIGUA7V0r6uz/YF8vyLf7iKTfv2liiMOBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.21.0", + "@eslint-react/eslint": "5.21.0", + "@typescript-eslint/scope-manager": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/utils": "^8.70.1", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/css-tree": { + "version": "4.1.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint/css-tree/-/css-tree-4.1.1.tgz", + "integrity": "sha512-A+s89eP+yDAPkKjnIOfjlkGifg5J7GPn01nWx14UjBVWktfkxOU4975F9ZZ13xNxkGiblNcBaAqgKhnQrW6SWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.35.0", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/css-tree/node_modules/mdn-data": { + "version": "2.35.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/mdn-data/-/mdn-data-2.35.0.tgz", + "integrity": "sha512-k+1+dEIm2z/BEfdvYzT/fIyAGeDBo/1AJNJI4ilFXJRYoxP/3Ds+GP8shAOIq8jYXY9N5nGB3Ag104NsagmLAA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -1986,6 +2344,72 @@ } } }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", @@ -2664,6 +3088,30 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@mantine/charts": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@mantine/charts/-/charts-9.6.1.tgz", @@ -4959,6 +5407,29 @@ } } }, + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.104.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.104.0.tgz", + "integrity": "sha512-WHStlNTKraESaAjeuZ0zJr+t3ZigZgAJ/FQX1sdLGSF+U0vXlHgcAoPqm27VixMRyXaFHCx9xBJiUmPNcxMpjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.70.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "^5.6.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@tanstack/query-core": { "version": "5.102.8", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", @@ -5260,6 +5731,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -5281,6 +5759,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdx": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", @@ -5362,6 +5847,629 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.70.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.70.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.70.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.70.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@vitejs/plugin-react": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", @@ -5469,6 +6577,37 @@ } } }, + "node_modules/@vitest/eslint-plugin": { + "version": "1.6.27", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@vitest/eslint-plugin/-/eslint-plugin-1.6.27.tgz", + "integrity": "sha512-X1RCAfwbatG4GFbJ/1PIHP9MSZMt0JJThqbYID+vS4L783k6QGlLPe421wQE8dHXuGCcenVLsydiM4qzsYWxhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.58.0", + "@typescript-eslint/utils": "^8.58.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "*", + "eslint": ">=8.57.0", + "typescript": ">=5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, "node_modules/@vitest/istanbul-lib-coverage": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz", @@ -5992,6 +7131,19 @@ "dev": true, "license": "MIT" }, + "node_modules/builtin-modules": { + "version": "5.4.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/builtin-modules/-/builtin-modules-5.4.0.tgz", + "integrity": "sha512-JCWSCdun+4Ovd9BhM/Vtlmwb7oD6Wl4YiPRXXwhAuwlPhW1un/MKgXn7GZoFm53ginnoNzus69V8JWx0K393jg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -6037,6 +7189,20 @@ } } }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -6348,6 +7514,16 @@ "node": ">=22.12.0" } }, + "node_modules/comment-parser": { + "version": "1.4.9", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/comment-parser/-/comment-parser-1.4.9.tgz", + "integrity": "sha512-+2AvZKjJaNq9GQljm3tr0utwrig5BL+jgJGvz5rDpGKNIp+ojcRXWUwBbh/sGIi1QCprR6PsKFakub+w1v+4hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/common-tags": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", @@ -6358,6 +7534,13 @@ "node": ">=4.0.0" } }, + "node_modules/compare-versions": { + "version": "6.1.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/compare-versions/-/compare-versions-6.1.1.tgz", + "integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==", + "dev": true, + "license": "MIT" + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -6957,6 +8140,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -7152,6 +8342,19 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-indent": { + "version": "7.0.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/detect-indent/-/detect-indent-7.0.2.tgz", + "integrity": "sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -7531,6 +8734,19 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -7553,6 +8769,677 @@ "source-map": "~0.6.1" } }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.17.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz", + "integrity": "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-import-x" + }, + "peerDependencies": { + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/utils": { + "optional": true + }, + "eslint-import-resolver-node": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-import-x/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-react-dom": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-react-dom/-/eslint-plugin-react-dom-5.21.0.tgz", + "integrity": "sha512-OtaQ73uqxnmjZ7muFthEbnVvJKrQ4MJneO21MFiZ5AR7XfVxew/cMOKJpr42Yvf5P8m7iB/QBRG8BHwXeqinxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.21.0", + "@eslint-react/core": "5.21.0", + "@eslint-react/eslint": "5.21.0", + "@eslint-react/jsx": "5.21.0", + "@eslint-react/shared": "5.21.0", + "@eslint-react/var": "5.21.0", + "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/utils": "^8.70.1", + "compare-versions": "^6.1.1" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/eslint-plugin-react-jsx": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-react-jsx/-/eslint-plugin-react-jsx-5.21.0.tgz", + "integrity": "sha512-ASHUFiPwlWR13LvZcXVXvieFqpb7EXv74T6y1IqrbAj17X36O+G90UXvkxcSF12lZkLHc1sImjsi0fUXPtFtTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.21.0", + "@eslint-react/core": "5.21.0", + "@eslint-react/eslint": "5.21.0", + "@eslint-react/jsx": "5.21.0", + "@eslint-react/shared": "5.21.0", + "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/utils": "^8.70.1" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-naming-convention": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-react-naming-convention/-/eslint-plugin-react-naming-convention-5.21.0.tgz", + "integrity": "sha512-aLE48o2hsDAkrGGBvsQNXAP+y5bnpvnqAipIE/EebiosOoY+3ty7j8Ep6f8q6+0bz6wVwZUmSEnI439p1DRA4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.21.0", + "@eslint-react/core": "5.21.0", + "@eslint-react/eslint": "5.21.0", + "@eslint-react/shared": "5.21.0", + "@eslint-react/var": "5.21.0", + "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/utils": "^8.70.1", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.7", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-plugin-react-rsc": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-react-rsc/-/eslint-plugin-react-rsc-5.21.0.tgz", + "integrity": "sha512-v7Q6H0vHF/EPF2k3pnWcm9W38Nk/fjTRlZkrqjOU7EFNR2vvrzJ8196geyJwYQCG0duXyCQMOv8ZnDI7LYhkYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.21.0", + "@eslint-react/core": "5.21.0", + "@eslint-react/eslint": "5.21.0", + "@eslint-react/shared": "5.21.0", + "@eslint-react/var": "5.21.0", + "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/utils": "^8.70.1" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-web-api": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-react-web-api/-/eslint-plugin-react-web-api-5.21.0.tgz", + "integrity": "sha512-0V05F7DrUWNbeYTZQKF/cp//vFjQzexVByCWqlitkN3hm+d7NXqSLQ/dt2a0gvxSIWaz9gm3BdCmgNN0CEVCmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.21.0", + "@eslint-react/core": "5.21.0", + "@eslint-react/eslint": "5.21.0", + "@eslint-react/shared": "5.21.0", + "@eslint-react/var": "5.21.0", + "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/utils": "^8.70.1", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-x": { + "version": "5.21.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-react-x/-/eslint-plugin-react-x-5.21.0.tgz", + "integrity": "sha512-FEdx6h4Xf0aakOb4p6Pk1LpZCpxF0ArC0AMaLYQQEOOF5Z6OSRIZNWj6yKne64xQ6Ss6jQHicOheqbkg7Q0rPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-react/ast": "5.21.0", + "@eslint-react/core": "5.21.0", + "@eslint-react/eslint": "5.21.0", + "@eslint-react/jsx": "5.21.0", + "@eslint-react/shared": "5.21.0", + "@eslint-react/var": "5.21.0", + "@typescript-eslint/scope-manager": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", + "@typescript-eslint/typescript-estree": "^8.70.1", + "@typescript-eslint/utils": "^8.70.1", + "compare-versions": "^6.1.1", + "string-ts": "^2.3.1", + "ts-api-utils": "^2.5.0", + "ts-pattern": "^5.9.0" + }, + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "eslint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-storybook": { + "version": "10.6.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-storybook/-/eslint-plugin-storybook-10.6.0.tgz", + "integrity": "sha512-HK3yD5Nslv97buJ5JfT1xFkTOoyJgGIENmBoZZI3F8t2UpBzb/dHpKyCh5bQ8AvJWnhoM/WAtDNqyd9nq6xgcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.60.0", + "@typescript-eslint/utils": "^8.60.0" + }, + "peerDependencies": { + "eslint": ">=8" + } + }, + "node_modules/eslint-plugin-testing-library": { + "version": "7.16.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-testing-library/-/eslint-plugin-testing-library-7.16.2.tgz", + "integrity": "sha512-8gleGnQXK2ZA3hHwjCwpYTZvM+9VsrJ+/9kDI8CjqAQGAdMQOdn/rJNu7ZySENuiWlGKQWyZJ4ZjEg2zamaRHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.56.0", + "@typescript-eslint/utils": "^8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-unicorn": { + "version": "76.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-plugin-unicorn/-/eslint-plugin-unicorn-76.0.0.tgz", + "integrity": "sha512-3ywJrCMHKYhcBP5yFzGaOOX3dY7+iR3hvpuoS/0EnhRFgVWLejly+t4fOdJvWQxJdHZl1C+QBW7hybXPDld4Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.10.1", + "@eslint/css-tree": "^4.0.5", + "browserslist": "^4.28.8", + "change-case": "^5.4.4", + "ci-info": "^4.4.0", + "core-js-compat": "^3.50.0", + "detect-indent": "^7.0.2", + "entities": "^8.0.0", + "find-up-simple": "^1.0.1", + "globals": "^17.11.0", + "identifier-regex": "^1.1.0", + "indent-string": "^5.0.0", + "is-builtin-module": "^5.0.0", + "pluralize": "^8.0.0", + "quote-js-string": "^0.1.0", + "regjsparser": "^0.13.2", + "reserved-identifiers": "^1.2.0", + "semver": "^7.8.5", + "strip-indent": "^4.1.1", + "yaml": "^2.9.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/eslint-plugin-unicorn?sponsor=1" + }, + "peerDependencies": { + "eslint": ">=10.4" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint-plugin-unicorn/node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -7567,6 +9454,32 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -7647,6 +9560,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-string-truncated-width": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", @@ -7726,6 +9646,16 @@ "dev": true, "license": "MIT" }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -7766,6 +9696,48 @@ "node": ">=10" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up-simple": { + "version": "1.0.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/find-up-simple/-/find-up-simple-1.0.1.tgz", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, "node_modules/flatted": { "version": "3.4.4", "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/flatted/-/flatted-3.4.4.tgz", @@ -8114,6 +10086,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/global-directory": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/global-directory/-/global-directory-5.0.0.tgz", @@ -8130,6 +10115,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -8258,6 +10256,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -8289,6 +10300,30 @@ "dev": true, "license": "MIT" }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -8418,6 +10453,22 @@ "dev": true, "license": "ISC" }, + "node_modules/identifier-regex": { + "version": "1.1.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/identifier-regex/-/identifier-regex-1.1.0.tgz", + "integrity": "sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA==", + "dev": true, + "license": "MIT", + "dependencies": { + "reserved-identifiers": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ignore": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", @@ -8476,6 +10527,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -8638,6 +10699,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-builtin-module": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/is-builtin-module/-/is-builtin-module-5.0.0.tgz", + "integrity": "sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "builtin-modules": "^5.0.0" + }, + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -8734,6 +10811,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-finalizationregistry": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", @@ -8780,6 +10867,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-in-ssh": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", @@ -9358,6 +11458,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json-with-bigint": { "version": "3.5.12", "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.12.tgz", @@ -9408,6 +11515,16 @@ "node": ">=0.10.0" } }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -9490,6 +11607,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -9805,6 +11936,22 @@ "node": ">=18" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash.capitalize": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz", @@ -10283,6 +12430,29 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/netmask": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", @@ -10481,6 +12651,24 @@ "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", "license": "MIT" }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "9.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-9.4.1.tgz", @@ -10638,6 +12826,38 @@ "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pac-proxy-agent": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-9.1.0.tgz", @@ -10782,6 +13002,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -10987,6 +13217,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-bytes": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", @@ -11136,6 +13376,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, "node_modules/quickjs-wasi": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", @@ -11143,6 +13403,19 @@ "dev": true, "license": "MIT" }, + "node_modules/quote-js-string": { + "version": "0.1.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/quote-js-string/-/quote-js-string-0.1.0.tgz", + "integrity": "sha512-Y3NoRtprEEZQD8RfxMCfS0ZTqc4e+i18OrXEXAvpM6TfC/3y+0L5rNbZiSnbBBEkDfFzbpd8o+cE8q3/anjMGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/quote-js-string?sponsor=1" + } + }, "node_modules/rc9": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.1.0.tgz", @@ -11879,6 +14152,19 @@ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, + "node_modules/reserved-identifiers": { + "version": "1.2.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", + "integrity": "sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -12475,6 +14761,16 @@ "source-map": "^0.6.0" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -13423,6 +15719,13 @@ "node": ">=0.6.19" } }, + "node_modules/string-ts": { + "version": "2.3.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/string-ts/-/string-ts-2.3.1.tgz", + "integrity": "sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", @@ -13870,6 +16173,19 @@ "node": ">=20" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-dedent": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", @@ -13880,6 +16196,13 @@ "node": ">=6.10" } }, + "node_modules/ts-pattern": { + "version": "5.9.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/ts-pattern/-/ts-pattern-5.9.0.tgz", + "integrity": "sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==", + "dev": true, + "license": "MIT" + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -13917,6 +16240,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -14028,6 +16364,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.70.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/unbash": { "version": "4.0.11", "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.11.tgz", @@ -14164,6 +16524,44 @@ "node": ">=18.12.0" } }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, "node_modules/until-async": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", @@ -14216,6 +16614,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/uri-js-replace": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", @@ -14768,6 +17176,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/workbox-background-sync": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", @@ -16597,6 +19015,19 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", @@ -16619,6 +19050,19 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://nexus.personal.ksv741.ru/repository/npm-all/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "packages/icons": { "name": "@wealthledger/icons", "version": "0.3.0", diff --git a/package.json b/package.json index ad387de..7a2390f 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,18 @@ "preview:catalog": "vite preview --outDir storybook-static", "typecheck": "npm run build:packages && tsc -b --noEmit", "build": "npm run build:packages && tsc -b && vite build", - "lint": "biome check .", + "lint": "npm run lint:biome && npm run lint:eslint", + "lint:biome": "biome check .", + "lint:eslint": "npm run build:packages && eslint . --max-warnings=0", "test": "npm run build:packages && vitest", "test:run": "npm run build:packages && vitest run", "test:coverage": "npm run build:packages && vitest run --coverage", + "test:lint": "node --test scripts/test-eslint.mjs", "openapi:generate": "openapi-typescript src/shared/api/spec/openapi.json -o src/shared/api/generated/schema.d.ts", "openapi:check": "node scripts/check-openapi.mjs", "knip": "npm run build:packages && knip", "deps": "npm run build:packages && depcruise src packages --config dependency-cruiser.cjs --output-type err-long", - "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", + "verify": "npm run build:packages && npm run openapi:check && tsc -b --noEmit && npm run lint && npm run test:lint && vitest run --coverage && knip && depcruise src packages --config dependency-cruiser.cjs --output-type err-long && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long && tsc -b && vite build && storybook build --output-dir storybook-static --disable-telemetry", "prepare": "husky", "preview": "vite preview", "deps:storybook": "npm run build:packages && depcruise src packages .storybook/preview.tsx --config .storybook/dependency-cruiser.cjs --output-type err-long", @@ -62,10 +65,13 @@ "@biomejs/biome": "2.5.13", "@commitlint/cli": "21.2.2", "@commitlint/config-conventional": "21.2.2", + "@eslint-react/eslint-plugin": "5.21.0", + "@eslint/js": "10.0.1", "@release-it/conventional-changelog": "12.0.0", "@rolldown/plugin-babel": "0.2.4", "@storybook/addon-docs": "10.6.0", "@storybook/react-vite": "10.6.0", + "@tanstack/eslint-plugin-query": "5.104.0", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", "@types/node": "24.13.4", @@ -74,9 +80,18 @@ "@vitejs/plugin-react": "6.1.1", "@vitest/browser-playwright": "5.0.0", "@vitest/coverage-v8": "5.0.0", + "@vitest/eslint-plugin": "1.6.27", "babel-plugin-react-compiler": "1.0.0", "conventional-changelog-conventionalcommits": "10.4.0", "dependency-cruiser": "18.2.0", + "eslint": "10.11.0", + "eslint-plugin-import-x": "4.17.1", + "eslint-plugin-react-hooks": "7.1.1", + "eslint-plugin-react-refresh": "0.5.7", + "eslint-plugin-storybook": "10.6.0", + "eslint-plugin-testing-library": "7.16.2", + "eslint-plugin-unicorn": "76.0.0", + "globals": "17.12.0", "husky": "9.1.7", "jsdom": "30.0.1", "knip": "6.35.1", @@ -87,6 +102,7 @@ "release-it": "21.0.2", "storybook": "10.6.0", "typescript": "5.9.3", + "typescript-eslint": "8.70.1", "vite": "8.3.0", "vite-plugin-pwa": "1.3.0", "vitest": "5.0.0" diff --git a/packages/ui/src/EntryCard/EntryCard.test.tsx b/packages/ui/src/EntryCard/EntryCard.test.tsx index 2d0bbcf..8d355ae 100644 --- a/packages/ui/src/EntryCard/EntryCard.test.tsx +++ b/packages/ui/src/EntryCard/EntryCard.test.tsx @@ -4,6 +4,22 @@ import { assert, describe, expect, it } from "vitest"; import { EntryCard } from "./EntryCard"; describe("EntryCard", () => { + it("does not leak a numeric zero from an absent action", () => { + render( + + + , + ); + expect(screen.queryByText("0")).not.toBeInTheDocument(); + }); it("keeps a failed image fallback until a new source is supplied", () => { const props = { title: "Пример", diff --git a/packages/ui/src/EntryCard/EntryCard.tsx b/packages/ui/src/EntryCard/EntryCard.tsx index 535f0aa..2ca9127 100644 --- a/packages/ui/src/EntryCard/EntryCard.tsx +++ b/packages/ui/src/EntryCard/EntryCard.tsx @@ -58,7 +58,7 @@ const EntryCard: FC = (props) => { > {props.valueLabel} - {props.actions && ( + {Boolean(props.actions) && (
{props.actions}
)} diff --git a/scripts/test-eslint.mjs b/scripts/test-eslint.mjs new file mode 100644 index 0000000..e53490f --- /dev/null +++ b/scripts/test-eslint.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { ESLint } from "eslint"; + +test("ESLint enforces the frontend profile and excludes generated artifacts", async () => { + const eslint = new ESLint(); + for (const [filePath, code, rule] of [ + [ + "src/shared/api/problem-details.ts", + "export function run() { Promise.resolve(); }", + "@typescript-eslint/no-floating-promises", + ], + [ + "packages/ui/src/EntryCard/EntryCard.tsx", + 'import { useState } from "react"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }', + "react-hooks/rules-of-hooks", + ], + [ + "src/shared/api/problem-details.ts", + "export const values = Array(3).fill({});", + "unicorn/no-array-fill-with-reference-type", + ], + [ + "src/shared/api/problem-details.ts", + "export default 1;", + "import-x/no-default-export", + ], + [ + "src/shared/api/client.test.ts", + 'import { it } from "vitest"; it.only("focused", () => {});', + "vitest/no-focused-tests", + ], + [ + "packages/ui/src/EntryCard/EntryCard.test.tsx", + 'import { screen } from "@testing-library/react"; screen.findByText("ready");', + "testing-library/await-async-queries", + ], + [ + "packages/ui/src/EntryCard/EntryCard.tsx", + 'import { QueryClient } from "@tanstack/react-query"; export function Example() { const client = new QueryClient(); return
{String(client)}
; }', + "@tanstack/query/stable-query-client", + ], + ]) { + const [result] = await eslint.lintText(code, { filePath }); + assert.equal(result.fatalErrorCount, 0, filePath); + assert.ok( + result.messages.some((message) => message.ruleId === rule), + rule, + ); + } + for (const file of [ + "src/shared/api/generated/schema.d.ts", + "packages/ui/dist/index.js", + "storybook-static/index.js", + "coverage/index.js", + ]) + assert.equal(await eslint.isPathIgnored(file), true, file); + const story = await eslint.calculateConfigForFile( + "src/shared/ui/Button.stories.tsx", + ); + assert.equal(story.rules["import-x/no-default-export"][0], 0); + assert.ok(story.rules["storybook/default-exports"][0] > 0); + assert.equal(story.rules["react-refresh/only-export-components"], undefined); +}); diff --git a/src/app/routes/AppRoutes/AppRoutes.test.tsx b/src/app/routes/AppRoutes/AppRoutes.test.tsx index 6900830..8ef2531 100644 --- a/src/app/routes/AppRoutes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.test.tsx @@ -1,6 +1,6 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; import { HttpResponse, http } from "msw"; import { MemoryRouter } from "react-router"; @@ -41,22 +41,18 @@ describe("AppRoutes", () => { ])("renders %s with its page title", async (path, title) => { renderRoutes(path); - await waitFor(() => - expect( - screen.getByRole("heading", { level: 1, name: title }), - ).toBeInTheDocument(), - ); + expect( + await screen.findByRole("heading", { level: 1, name: title }), + ).toBeInTheDocument(); expect(document.title).toBe(`${title} — WealthLedger`); }); it("redirects anonymous users from accounts", async () => { renderRoutes("/accounts"); - await waitFor(() => - expect( - screen.getByRole("heading", { level: 1, name: "Вход" }), - ).toBeInTheDocument(), - ); + expect( + await screen.findByRole("heading", { level: 1, name: "Вход" }), + ).toBeInTheDocument(); }); it("logs in through the public form and reaches the protected landing", async () => { @@ -142,14 +138,12 @@ describe("AppRoutes", () => { ); renderRoutes("/accounts"); - await waitFor(() => - expect( - screen.getByRole("heading", { - level: 2, - name: "Нет инвестиционных счетов", - }), - ).toBeInTheDocument(), - ); + expect( + await screen.findByRole("heading", { + level: 2, + name: "Нет инвестиционных счетов", + }), + ).toBeInTheDocument(); expect( screen.getByRole("navigation", { name: "WealthLedger" }), ).toBeInTheDocument(); diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx index 8f66930..a03237d 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx @@ -1,5 +1,5 @@ import { MantineProvider } from "@mantine/core"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { type FC, lazy, Suspense } from "react"; import { MemoryRouter, useLocation, useNavigate } from "react-router"; import { describe, expect, it, vi } from "vitest"; @@ -135,8 +135,6 @@ describe("RouteErrorBoundary", () => { authenticated = true; renderBoundary("/accounts"); screen.getByRole("button", { name: "go healthy" }).click(); - await waitFor(() => - expect(screen.getByText("healthy route")).toBeInTheDocument(), - ); + expect(await screen.findByText("healthy route")).toBeInTheDocument(); }); }); diff --git a/src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx b/src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx index 69a7630..6d7abd4 100644 --- a/src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx +++ b/src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx @@ -33,7 +33,7 @@ const AccountScopeSwitch: FC = (props) => { if (!value || value === accountId) return; const filters = new URLSearchParams(search); filters.delete("cursor"); - navigate({ + void navigate({ pathname: `/accounts/${encodeURIComponent(value)}${section ? `/${section}` : ""}`, search: filters.toString(), }); diff --git a/src/entities/bank-card/api/mutations.ts b/src/entities/bank-card/api/mutations.ts index 910320d..76fff0e 100644 --- a/src/entities/bank-card/api/mutations.ts +++ b/src/entities/bank-card/api/mutations.ts @@ -13,6 +13,7 @@ async function mutationData( request: Promise<{ data?: T; error?: unknown }>, ) { const { data, error } = await request; + // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. if (error) throw error; return data; } diff --git a/src/entities/bank-card/api/queries.ts b/src/entities/bank-card/api/queries.ts index 3f1b5c5..ab841ec 100644 --- a/src/entities/bank-card/api/queries.ts +++ b/src/entities/bank-card/api/queries.ts @@ -81,6 +81,7 @@ function useBankOperations( "/api/v1/bank-cards/{id}/operations", { params }, ); + // eslint-disable-next-line @tanstack/query/exhaustive-deps -- Generated options.queryKey already contains params; retain the shared cache identity. return useAuthenticatedQuery({ queryKey: all ? ([...options.queryKey, "all"] as const) : options.queryKey, placeholderData: ( @@ -108,6 +109,7 @@ function useBankOperations( signal, }, ); + // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. if (error) throw error; if (!data) throw new Error("Missing bank operations response"); if (!all) return data; diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx index 6c8dad9..10db669 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx @@ -101,28 +101,30 @@ const CategoryManager: FC = (props) => { > { - setError(undefined); - try { - if (editing) - await mutation.updateCategory.mutateAsync({ - id: editing.id, - body: { name: values.name, version: editing.version }, - }); - else { - const result = await mutation.createCategory.mutateAsync({ - name: values.name, - }); - if (result) props.onCreated?.(result.id); + onSubmit={(event) => { + void methods.handleSubmit(async (values) => { + setError(undefined); + try { + if (editing) + await mutation.updateCategory.mutateAsync({ + id: editing.id, + body: { name: values.name, version: editing.version }, + }); + else { + const result = await mutation.createCategory.mutateAsync({ + name: values.name, + }); + if (result) props.onCreated?.(result.id); + } + editSession.current++; + setEditing(undefined); + setLatest(undefined); + methods.reset({ name: "" }); + } catch (failure) { + setError(failure); } - editSession.current++; - setEditing(undefined); - setLatest(undefined); - methods.reset({ name: "" }); - } catch (failure) { - setError(failure); - } - })} + })(event); + }} >
diff --git a/src/entities/portfolio/api/resource-lifecycle.test.tsx b/src/entities/portfolio/api/resource-lifecycle.test.tsx index 16e8609..28b829e 100644 --- a/src/entities/portfolio/api/resource-lifecycle.test.tsx +++ b/src/entities/portfolio/api/resource-lifecycle.test.tsx @@ -141,6 +141,7 @@ describe("persistent resource query lifecycle", () => { "/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId: "a" } } }, ); + // eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below. const rejected = expect(old).rejects.toMatchObject({ name: "AbortError" }); await apiClient.GET("/api/v1/brokerage-accounts/{accountId}/portfolio", { params: { path: { accountId: "a" }, query: { sync: "observe" } }, diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index 9322901..da4ea1a 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -1,4 +1,5 @@ -export { AuthProvider, useAuth } from "./model/auth"; +export { AuthProvider } from "./model/auth"; +export { useAuth } from "./model/auth-context"; export { AuthPageLayout } from "./ui/AuthPageLayout/AuthPageLayout"; export { LoginForm } from "./ui/LoginForm"; export { RegisterForm } from "./ui/RegisterForm"; diff --git a/src/features/auth/model/auth-context.ts b/src/features/auth/model/auth-context.ts new file mode 100644 index 0000000..66f4a5f --- /dev/null +++ b/src/features/auth/model/auth-context.ts @@ -0,0 +1,23 @@ +import { createContext, useContext } from "react"; +import type { paths } from "@/shared/api"; + +type User = + paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"]; +type AuthContextValue = { + user: User | null; + accessToken: string | null; + ready: boolean; + login: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; + logout: () => Promise; + refresh: () => Promise; +}; +const AuthContext = createContext(null); + +function useAuth() { + const context = useContext(AuthContext); + if (!context) throw new Error("AuthProvider is required"); + return context; +} + +export { AuthContext, type User, useAuth }; diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index 972d790..a4e532a 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -7,7 +7,8 @@ import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { AuthProvider, useAuth } from "./auth"; +import { AuthProvider } from "./auth"; +import { useAuth } from "./auth-context"; const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { params: { query: { includeArchived: "false" } }, diff --git a/src/features/auth/model/auth.tsx b/src/features/auth/model/auth.tsx index 3830b6d..4f7c160 100644 --- a/src/features/auth/model/auth.tsx +++ b/src/features/auth/model/auth.tsx @@ -1,32 +1,13 @@ import { useQueryClient } from "@tanstack/react-query"; import type { FC, PropsWithChildren } from "react"; -import { - createContext, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { apiClient, configureAuthClient, type paths } from "@/shared/api"; +import { AuthContext, type User } from "./auth-context"; -type User = - paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"]; type Authentication = paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"]; type Credentials = paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"]; -type AuthContextValue = { - user: User | null; - accessToken: string | null; - ready: boolean; - login: (email: string, password: string) => Promise; - register: (email: string, password: string) => Promise; - logout: () => Promise; - refresh: () => Promise; -}; -const AuthContext = createContext(null); - async function request( path: "/api/v1/auth/login" | "/api/v1/auth/register", email: string, @@ -35,8 +16,9 @@ async function request( const { data, error } = await apiClient.POST(path, { body: { email, password }, } satisfies { body: Credentials }); + // eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers. if (error) throw error; - return data as Authentication; + return data; } const AuthProvider: FC = (props) => { @@ -165,10 +147,4 @@ const AuthProvider: FC = (props) => { ); }; -function useAuth() { - const context = useContext(AuthContext); - if (!context) throw new Error("AuthProvider is required"); - return context; -} - -export { AuthProvider, useAuth }; +export { AuthProvider }; diff --git a/src/features/auth/ui/AuthForm/AuthForm.test.tsx b/src/features/auth/ui/AuthForm/AuthForm.test.tsx index 76d8ff1..e9f22bb 100644 --- a/src/features/auth/ui/AuthForm/AuthForm.test.tsx +++ b/src/features/auth/ui/AuthForm/AuthForm.test.tsx @@ -27,8 +27,6 @@ describe("AuthForm", () => { fireEvent.click(screen.getByRole("button", { name: "Регистрация" })); await waitFor(() => expect(onSubmit).not.toHaveBeenCalled()); - await waitFor(() => - expect(screen.getByText("Пароли не совпадают")).toBeInTheDocument(), - ); + expect(await screen.findByText("Пароли не совпадают")).toBeInTheDocument(); }); }); diff --git a/src/features/auth/ui/LoginForm.tsx b/src/features/auth/ui/LoginForm.tsx index 84813d8..12c80d8 100644 --- a/src/features/auth/ui/LoginForm.tsx +++ b/src/features/auth/ui/LoginForm.tsx @@ -1,6 +1,6 @@ import type { FC } from "react"; import { useLocation, useNavigate } from "react-router"; -import { useAuth } from "../model/auth"; +import { useAuth } from "../model/auth-context"; import { AuthForm } from "./AuthForm/AuthForm"; const LoginForm: FC = () => { @@ -14,7 +14,7 @@ const LoginForm: FC = () => { { await login(email, password); - navigate(returnTo, { replace: true }); + await navigate(returnTo, { replace: true }); }} /> ); diff --git a/src/features/auth/ui/RegisterForm.tsx b/src/features/auth/ui/RegisterForm.tsx index f666da9..14d7149 100644 --- a/src/features/auth/ui/RegisterForm.tsx +++ b/src/features/auth/ui/RegisterForm.tsx @@ -1,6 +1,6 @@ import type { FC } from "react"; import { useLocation, useNavigate } from "react-router"; -import { useAuth } from "../model/auth"; +import { useAuth } from "../model/auth-context"; import { AuthForm } from "./AuthForm/AuthForm"; const RegisterForm: FC = () => { @@ -15,7 +15,7 @@ const RegisterForm: FC = () => { register onSubmit={async (email, password) => { await register(email, password); - navigate(returnTo, { replace: true }); + await navigate(returnTo, { replace: true }); }} /> ); diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx index e73481f..ebf431b 100644 --- a/src/features/tbank-connection/model/screen-sync.test.tsx +++ b/src/features/tbank-connection/model/screen-sync.test.tsx @@ -18,6 +18,57 @@ function auth() { }); } describe("page-owned screen synchronization", () => { + it("ignores a late sync acceptance after the account scope changes", async () => { + auth(); + let release!: () => void; + const blocked = new Promise((resolve) => { + release = resolve; + }); + const posted: string[] = []; + const polled: string[] = []; + server.use( + http.post("*/api/v1/tbank-sync", async ({ request }) => { + const scope = (await request.json()) as { + screen: "account"; + accountId: string; + }; + posted.push(scope.accountId); + await blocked; + return HttpResponse.json( + { id: scope.accountId, scope, now: "2026-09-25T00:00:00Z" }, + { status: 202 }, + ); + }), + http.get("*/api/v1/tbank-sync/:id", ({ params }) => { + polled.push(String(params.id)); + return HttpResponse.json({ + id: params.id, + state: "succeeded", + resources: [], + }); + }), + ); + const { result, rerender } = renderHook( + ({ accountId }) => useScreenSync({ screen: "account", accountId }, []), + { + initialProps: { accountId: "old" }, + wrapper: createQueryWrapper().wrapper, + }, + ); + let pending!: Promise; + act(() => { + pending = result.current.refresh(); + }); + await waitFor(() => expect(posted).toEqual(["old"])); + rerender({ accountId: "new" }); + await act(async () => { + release(); + await pending; + }); + await act(() => result.current.refresh()); + await waitFor(() => expect(polled).toEqual(["new"])); + expect(posted).toEqual(["old", "new"]); + }); it.each([false, true])( "retries a failed GET without forcing the source (cached=%s)", async (cached) => { @@ -155,7 +206,7 @@ describe("page-owned screen synchronization", () => { ); expect(published).not.toContainEqual( expect.objectContaining({ - meta: expect.objectContaining({ stateVersion: "old" }), + meta: expect.objectContaining({ stateVersion: "old" }) as unknown, }), ); unsubscribe(); @@ -354,7 +405,7 @@ describe("page-owned screen synchronization", () => { }); expect(modes).toEqual([null, "observe"]); if (boundary === "leave") unmount(); - else await act(() => configureAuthClient(null)); + else act(() => configureAuthClient(null)); const stopped = polls; await act(async () => { await new Promise((resolve) => setTimeout(resolve, 1100)); diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index e77f469..5ae8c16 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -1,5 +1,5 @@ import { type QueryKey, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { apiErrorCode, apiQueryClient, @@ -33,7 +33,9 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { } | null>(null); const requestInFlight = useRef(null); const currentScope = useRef(scopeKey); - currentScope.current = scopeKey; + useLayoutEffect(() => { + currentScope.current = scopeKey; + }, [scopeKey]); const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey; const observed = useRef(new Map()); const ids = [ diff --git a/src/features/tbank-connection/model/sync.test.tsx b/src/features/tbank-connection/model/sync.test.tsx index 425e36b..d6053f0 100644 --- a/src/features/tbank-connection/model/sync.test.tsx +++ b/src/features/tbank-connection/model/sync.test.tsx @@ -56,7 +56,10 @@ describe("sync connection and session boundaries", () => { const { wrapper, queryClient } = createQueryWrapper(); const published: string[] = []; const unsubscribe = queryClient.getQueryCache().subscribe((event) => { - if (event.query.queryKey[1] === "/api/v1/tbank-sync/{id}") { + if ( + (event.query.queryKey as readonly unknown[])[1] === + "/api/v1/tbank-sync/{id}" + ) { const data = event.query.state.data as { now?: string } | undefined; if (data?.now) published.push(data.now); } @@ -73,6 +76,7 @@ describe("sync connection and session boundaries", () => { act(() => { mutation = result.current.mutation.mutateAsync({ body: screen }); }); + // eslint-disable-next-line vitest/valid-expect -- Attach rejection handling before releasing the request; the assertion is awaited below. const discarded = expect(mutation).rejects.toMatchObject({ name: "AbortError", }); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx index 856d284..60e36f4 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx @@ -163,11 +163,9 @@ describe("TbankConnectionSection", () => { }); fireEvent.click(screen.getByRole("button", { name: "Подключить" })); - await waitFor(() => - expect( - screen.getByRole("button", { name: "Заменить token" }), - ).toBeInTheDocument(), - ); + expect( + await screen.findByRole("button", { name: "Заменить token" }), + ).toBeInTheDocument(); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); expect(client.getQueryData(bankCardsQueryKey)).toEqual([ { id: "bank-card-1" }, @@ -224,11 +222,9 @@ describe("TbankConnectionSection", () => { ); fireEvent.click(await screen.findByRole("button", { name: /^Удалить$/ })); - await waitFor(() => - expect( - screen.getByRole("button", { name: "Подключить" }), - ).toBeInTheDocument(), - ); + expect( + await screen.findByRole("button", { name: "Подключить" }), + ).toBeInTheDocument(); expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); expect(client.getQueryData(bankCardsQueryKey)).toEqual([ { id: "bank-card-1" }, diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index 8e93f07..c620b7e 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -48,10 +48,15 @@ function useAccountEventsPageViewModel( normalized.delete("to"); setSearchParams(normalized, { replace: true }); }, [validUrlPeriod, searchParams, setSearchParams]); - useEffect(() => { + const [previousPeriod, setPreviousPeriod] = useState([ + appliedFrom, + appliedTo, + ]); + if (previousPeriod[0] !== appliedFrom || previousPeriod[1] !== appliedTo) { + setPreviousPeriod([appliedFrom, appliedTo]); setDraftFrom(appliedFrom); setDraftTo(appliedTo); - }, [appliedFrom, appliedTo]); + } const events = useBrokerageEvents(accountId, { ...(appliedFrom ? { from: appliedFrom } : {}), ...(appliedTo ? { to: appliedTo } : {}), diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index b2d6d4c..94faed9 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -462,11 +462,9 @@ describe("AccountIncomePage", () => { http.get("*/api/v1/brokerage-accounts/account-1/events", eventsRequest), ); renderPage(); - await waitFor(() => - expect( - screen.getByRole("heading", { name: "Пассивный доход" }), - ).toBeInTheDocument(), - ); + expect( + await screen.findByRole("heading", { name: "Пассивный доход" }), + ).toBeInTheDocument(); expect( screen.getByText("Налоговые операции не включены в этот результат."), ).toBeInTheDocument(); @@ -542,11 +540,9 @@ describe("AccountIncomePage", () => { ), ); renderPage(); - await waitFor(() => - expect( - screen.getByRole("button", { name: "Повторить" }), - ).toBeInTheDocument(), - ); + expect( + await screen.findByRole("button", { name: "Повторить" }), + ).toBeInTheDocument(); }); }); diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index e20b3fc..b079236 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -43,18 +43,22 @@ type IncomeDisplayModel = { type AccountIncomePageViewModel = | { state: "loading" } - | { state: "error"; retry: () => void; period: IncomePeriod } + | { + state: "error"; + retry: () => undefined | Promise; + period: IncomePeriod; + } | { state: "loaded"; analytics: IncomeAnalytics; display: IncomeDisplayModel; - formRevision: number; + formRevision: string; sourceError?: boolean; lastSuccessfulAt?: string | null; partial?: boolean; refreshing: boolean; refreshError: boolean; - retry: () => void; + retry: () => undefined | Promise; form: ReturnType>; onReset: () => void; onSubmit: (values: IncomeForm) => void; @@ -102,7 +106,7 @@ const useAccountIncomePageViewModel = ( defaultValues: draftPeriod(period), resolver: zodResolver(incomeFormSchema), }); - const [formRevision, setFormRevision] = useState(0); + const [resetRevision, setResetRevision] = useState(0); const analytics = useIncomeAnalytics(accountId, period); const sync = useScreenSync( { screen: "income", accountId, from: period.from, to: period.to }, @@ -114,7 +118,6 @@ const useAccountIncomePageViewModel = ( }, [period, requested, setSearchParams]); useEffect(() => { form.reset(draftPeriod(period)); - setFormRevision((revision) => revision + 1); }, [form, period]); const onSubmit = (values: IncomeForm) => { @@ -126,7 +129,7 @@ const useAccountIncomePageViewModel = ( const next = defaultPeriod(); form.reset(draftPeriod(next)); // Reset the open calendar along with the form. - setFormRevision((revision) => revision + 1); + setResetRevision((revision) => revision + 1); setSearchParams(next, { replace: true }); }; if (analytics.isLoading || !analytics.data) { @@ -138,16 +141,14 @@ const useAccountIncomePageViewModel = ( if (!display) return { state: "error", - retry: () => { - void analytics.observe(); - }, + retry: () => analytics.observe(), period, }; return { state: "loaded", analytics: analytics.data, display, - formRevision, + formRevision: `${period.from}:${period.to}:${resetRevision}`, sourceError: sync.sourceError, lastSuccessfulAt: analytics.meta?.lastSuccessfulAt, partial: analytics.meta?.availability !== "complete", diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx index c527fef..b9ab7aa 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx @@ -22,8 +22,14 @@ const IncomePeriodForm: FC = (props) => { control, formState: { isSubmitting }, } = useFormContext(); - const fromField = useController({ control, name: "from" }); - const toField = useController({ control, name: "to" }); + const { + field: { ref: fromRef, onChange: onFromChange, onBlur: onFromBlur }, + fieldState: { error: fromError }, + } = useController({ control, name: "from" }); + const { + field: { onChange: onToChange, onBlur: onToBlur }, + fieldState: { error: toError }, + } = useController({ control, name: "to" }); const [from, to] = useWatch({ control, name: ["from", "to"] }); const today = moscowDate(new Date()); const start = parseDraftDate(from); @@ -44,23 +50,20 @@ const IncomePeriodForm: FC = (props) => { allowSingleDateInRange clearable clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} - ref={fromField.field.ref} + ref={fromRef} label={t("income.period")} valueFormat="DD.MM.YYYY" value={[start, end]} onChange={([from, to]) => { - fromField.field.onChange(from ? formatDraftDate(from) : ""); - toField.field.onChange(to ? formatDraftDate(to) : ""); + onFromChange(from ? formatDraftDate(from) : ""); + onToChange(to ? formatDraftDate(to) : ""); }} onBlur={() => { - fromField.field.onBlur(); - toField.field.onBlur(); + onFromBlur(); + onToBlur(); }} maxDate={today} - error={ - fromField.fieldState.error?.message ?? - toField.fieldState.error?.message - } + error={fromError?.message ?? toError?.message} disabled={isSubmitting} popoverProps={{ returnFocus: true, diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index ef902bb..17549a5 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -72,11 +72,9 @@ const useAccountOperationsPageVm = ( }>({ scopeKey, cursors: [] }); const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : []; const cursor = cursors.at(-1); - useEffect(() => { - setPagination((current) => - current.scopeKey === scopeKey ? current : { scopeKey, cursors: [] }, - ); - }, [scopeKey]); + if (pagination.scopeKey !== scopeKey) { + setPagination({ scopeKey, cursors: [] }); + } useEffect(() => { if (validUrlPeriod) return; const normalized = new URLSearchParams(searchParams); @@ -84,7 +82,9 @@ const useAccountOperationsPageVm = ( normalized.delete("to"); setSearchParams(normalized, { replace: true }); }, [validUrlPeriod, searchParams, setSearchParams]); - useEffect(() => { + const [previousSearch, setPreviousSearch] = useState(searchParams); + if (previousSearch !== searchParams) { + setPreviousSearch(searchParams); setDraftFrom(appliedFrom); setDraftTo(appliedTo); setDraftCategories( @@ -92,7 +92,7 @@ const useAccountOperationsPageVm = ( (searchParams.get("categories") ?? "").split(",").includes(category), ), ); - }, [appliedFrom, appliedTo, searchParams]); + } const firstPageQuery = { from: dateStart(appliedFrom), to: dateEnd(appliedTo), diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index 2e41552..a03d11a 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -58,7 +58,7 @@ type AccountPageRecentEvents = BrokerageEventsPreviewState; type AccountPageIncomeSummary = | { state: "loading" | "partial" | "empty"; to: string } - | { state: "error"; to: string; retry: () => void } + | { state: "error"; to: string; retry: () => undefined | Promise } | { state: "loaded"; to: string; @@ -75,7 +75,7 @@ type AccountPageContent = { accountId?: string; refreshing: boolean; refreshError: boolean; - retry: () => void; + retry: () => undefined | Promise; recentOperations: AccountPageRecentOperations; recentEvents: AccountPageRecentEvents; incomeSummary: AccountPageIncomeSummary; @@ -93,7 +93,7 @@ type AccountPageEmptyViewModel = AccountPageContent & { type AccountPageViewModel = | { state: "loading" } - | { state: "error"; code?: string; retry: () => void } + | { state: "error"; code?: string; retry: () => undefined | Promise } | AccountPageEmptyViewModel | AccountPageLoadedViewModel; diff --git a/src/pages/card/ui/OperationForm/OperationForm.vm.ts b/src/pages/card/ui/OperationForm/OperationForm.vm.ts index b9493ac..4d9693f 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.vm.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.vm.ts @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useState } from "react"; -import { useForm } from "react-hook-form"; +import { useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; import { @@ -157,12 +157,12 @@ const useOperationFormVm = (props: OperationFormProps) => { description: props.operation?.description ?? "", }, }); - const kind = methods.watch("kind"); + const kind = useWatch({ control: methods.control, name: "kind" }); const expenses = useBankExpenses( kind === "refund" ? props.card.id : undefined, { page: sourcePage }, ); - const source = methods.watch("cardId"); + const source = useWatch({ control: methods.control, name: "cardId" }); const pending = !opened || methods.formState.isSubmitting; const options = props.cards .filter((c) => !c.archivedAt) diff --git a/src/pages/settings/ui/SettingsPage.tsx b/src/pages/settings/ui/SettingsPage.tsx index b7c97f4..a5f672e 100644 --- a/src/pages/settings/ui/SettingsPage.tsx +++ b/src/pages/settings/ui/SettingsPage.tsx @@ -17,7 +17,7 @@ const SettingsPage: FC = () => { - - @@ -36,10 +37,10 @@ export const Decorative: Story = { export const Meaningful: Story = { render: () => ( -
- - - +
+ + +
), }; diff --git a/packages/icons/src/Icons.test.tsx b/packages/icons/src/Icons.test.tsx index 31c48ef..7d52460 100644 --- a/packages/icons/src/Icons.test.tsx +++ b/packages/icons/src/Icons.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; + import { AddIcon, DeleteIcon, EditIcon } from "./index"; describe("icon accessibility", () => { @@ -7,19 +8,20 @@ describe("icon accessibility", () => { "keeps decoration hidden and exposes explicitly named images: %s", (Icon) => { const { rerender } = render( - , ); expect(screen.queryByRole("img")).not.toBeInTheDocument(); expect( + // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. screen.getByRole("button", { name: "Действие" }).querySelector("svg"), ).toHaveAttribute("focusable", "false"); rerender( , diff --git a/packages/icons/src/svg/AddIcon.tsx b/packages/icons/src/svg/AddIcon.tsx index cbd7ff5..fa04cbe 100644 --- a/packages/icons/src/svg/AddIcon.tsx +++ b/packages/icons/src/svg/AddIcon.tsx @@ -1,19 +1,20 @@ import type { FC } from "react"; + import type { IconProps } from "../types"; const AddIcon: FC = (props) => { const { size = "24", ...svgProps } = props; return (
+
( + render: arguments_ => ( - + ), + tags: ["autodocs"], + title: "Design System/Components/LoadingSkeleton", } satisfies Meta; export default meta; diff --git a/packages/ui/src/LoadingSkeleton/LoadingSkeleton.tsx b/packages/ui/src/LoadingSkeleton/LoadingSkeleton.tsx index f2991a7..7ea4cae 100644 --- a/packages/ui/src/LoadingSkeleton/LoadingSkeleton.tsx +++ b/packages/ui/src/LoadingSkeleton/LoadingSkeleton.tsx @@ -1,6 +1,7 @@ -import { Stack } from "@mantine/core"; import type { FC, PropsWithChildren } from "react"; +import { Stack } from "@mantine/core"; + type LoadingSkeletonProps = PropsWithChildren<{ label: string; }>; diff --git a/packages/ui/src/StateView/StateView.stories.tsx b/packages/ui/src/StateView/StateView.stories.tsx index 29f7398..5ab38c3 100644 --- a/packages/ui/src/StateView/StateView.stories.tsx +++ b/packages/ui/src/StateView/StateView.stories.tsx @@ -1,36 +1,36 @@ -import { Button } from "@mantine/core"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ComponentProps } from "react"; + +import { Button } from "@mantine/core"; import { fn } from "storybook/test"; + import { StateView } from "./StateView"; -type StateStoryArgs = ComponentProps & { - withAction: boolean; +type StateStoryArguments = ComponentProps & { onAction: () => void; + withAction: boolean; }; const meta = { - title: "Design System/Components/StateView", - component: StateView, - tags: ["autodocs"], args: { - title: "Нет учебных данных", - text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.", error: false, - withAction: false, onAction: fn(), + text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.", + title: "Нет учебных данных", + withAction: false, }, argTypes: { - title: { control: "text" }, - text: { control: "text" }, - error: { control: "boolean" }, action: { table: { disable: true } }, + error: { control: "boolean" }, + onAction: { control: false }, + text: { control: "text" }, + title: { control: "text" }, withAction: { control: "boolean", description: "Показать необязательное действие StateView", }, - onAction: { control: false }, }, + component: StateView, parameters: { docs: { description: { @@ -39,40 +39,44 @@ const meta = { }, }, }, - render: (args) => { - const { withAction, onAction, ...stateProps } = args; + render: (arguments_) => { + const { onAction, withAction, ...stateProps } = arguments_; return ( - {args.error ? "Повторить локально" : "Выполнить пример"} - - ) : undefined + (withAction) + ? (( + + )) + : undefined } /> ); }, -} satisfies Meta; + tags: ["autodocs"], + title: "Design System/Components/StateView", +} satisfies Meta; export default meta; type Story = StoryObj; export const Empty: Story = { name: "Пустое состояние" }; export const EmptyWithAction: Story = { - name: "Пустое состояние с действием", args: { withAction: true }, + name: "Пустое состояние с действием", }; export const Failed: Story = { - name: "Ошибка", args: { error: true, - title: "Учебная ошибка", text: "Демонстрация недоступных данных без вызова API.", + title: "Учебная ошибка", }, + name: "Ошибка", }; export const ErrorWithAction: Story = { - name: "Ошибка с retry", args: { ...Failed.args, withAction: true }, + name: "Ошибка с retry", }; diff --git a/packages/ui/src/StateView/StateView.test.tsx b/packages/ui/src/StateView/StateView.test.tsx index 352e5c0..0f732fd 100644 --- a/packages/ui/src/StateView/StateView.test.tsx +++ b/packages/ui/src/StateView/StateView.test.tsx @@ -1,6 +1,7 @@ import { MantineProvider } from "@mantine/core"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; + import { LoadingSkeleton } from "../LoadingSkeleton/LoadingSkeleton"; import { StateView } from "./StateView"; diff --git a/packages/ui/src/StateView/StateView.tsx b/packages/ui/src/StateView/StateView.tsx index 91574dd..a41d0f6 100644 --- a/packages/ui/src/StateView/StateView.tsx +++ b/packages/ui/src/StateView/StateView.tsx @@ -1,19 +1,20 @@ -import { Paper, Stack, Text, Title } from "@mantine/core"; import type { FC, ReactNode } from "react"; +import { Paper, Stack, Text, Title } from "@mantine/core"; + type StateViewProps = { - title: string; - text: string; action?: ReactNode; error?: boolean; + text: string; + title: string; }; const StateView: FC = (props) => { - const { title, text, action, error = false } = props; + const { action, error = false, text, title } = props; const role = error ? "alert" : undefined; return ( - + {title} {text} diff --git a/packages/ui/src/theme.test.ts b/packages/ui/src/theme.test.ts index 08476de..5721403 100644 --- a/packages/ui/src/theme.test.ts +++ b/packages/ui/src/theme.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core"; import source from "@wealthledger/tokens/tokens.json"; import { describe, expect, it } from "vitest"; + import { colorRoles, cssVariablesResolver, theme } from "./theme"; describe("design theme adapter", () => { diff --git a/packages/ui/src/theme.ts b/packages/ui/src/theme.ts index ea1568a..7bdac58 100644 --- a/packages/ui/src/theme.ts +++ b/packages/ui/src/theme.ts @@ -1,8 +1,8 @@ import "./styles.css"; import { - type CSSVariablesResolver, colorsTuple, createTheme, + type CSSVariablesResolver, getThemeColor, type MantinePrimaryShade, Modal, @@ -11,78 +11,80 @@ import { } from "@mantine/core"; import designTokens from "@wealthledger/tokens/tokens.json"; -const { color, typography, spacing, radius, shadows, breakpoints } = - designTokens; +const { breakpoints, color, radius, shadows, spacing, typography } + = designTokens; const mapValues = (values: Record, convert: (value: T) => U) => Object.fromEntries( Object.entries(values).map(([name, value]) => [name, convert(value)]), ); -const colorRoles = mapValues(color.roles, (role) => +const colorRoles = mapValues(color.roles, role => role.shade === null ? role.color : `${role.color}.${role.shade}`, ); const theme = createTheme({ - respectReducedMotion: true, + black: color.base.black, + breakpoints: mapValues( + breakpoints.values, + value => `${value}${breakpoints.unit}`, + ), + colors: mapValues(color.palettes, colorsTuple), components: { Modal: Modal.extend({ defaultProps: { overlayProps: { blur: 3 }, transitionProps: { - transition: "fade-down", duration: 220, exitDuration: 180, timingFunction: "ease-out", + transition: "fade-down", }, }, }), }, - primaryColor: color.roles.primary.color, - primaryShade: color.primaryShade as MantinePrimaryShade, defaultRadius: radius.default, - white: color.base.white, - black: color.base.black, - colors: mapValues(color.palettes, colorsTuple), fontFamily: typography.fontFamily, fontFamilyMonospace: typography.fontFamilyMonospace, - fontSizes: mapValues(typography.fontSizes, (value) => rem(value)), + fontSizes: mapValues(typography.fontSizes, value => rem(value)), fontWeights: mapValues(typography.fontWeights, String), - lineHeights: mapValues(typography.lineHeights, String), headings: { fontFamily: typography.fontFamily, fontWeight: String(typography.fontWeights.bold), - sizes: mapValues(typography.headings, (value) => ({ + sizes: mapValues(typography.headings, value => ({ fontSize: rem(value.fontSize), lineHeight: String(value.lineHeight), })), }, - spacing: mapValues(spacing.values, (value) => rem(value)), - radius: mapValues(radius.values, (value) => rem(value)), - shadows: mapValues(shadows.values, (layers) => + lineHeights: mapValues(typography.lineHeights, String), + primaryColor: color.roles.primary.color, + primaryShade: color.primaryShade as MantinePrimaryShade, + radius: mapValues(radius.values, value => rem(value)), + respectReducedMotion: true, + shadows: mapValues(shadows.values, layers => layers .map( - (layer) => + layer => `${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`, ) .join(", "), ), - breakpoints: mapValues( - breakpoints.values, - (value) => `${value}${breakpoints.unit}`, - ), + spacing: mapValues(spacing.values, value => rem(value)), + white: color.base.white, }); -const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({ +const cssVariablesResolver: CSSVariablesResolver = resolvedTheme => ({ + dark: {}, + light: { + "--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme), + }, variables: Object.fromEntries( Object.entries(colorRoles).map(([role, reference]) => [ `--wl-color-${role}`, getThemeColor(reference, resolvedTheme), ]), ), - light: { - "--mantine-color-dimmed": getThemeColor(colorRoles.muted, resolvedTheme), - }, - dark: {}, }); -export { colorRoles, cssVariablesResolver, designTokens, theme }; +export { colorRoles, cssVariablesResolver, theme }; + +export { default as designTokens } from "@wealthledger/tokens/tokens.json"; diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 5057d03..e18020b 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -3,9 +3,9 @@ import { defineConfig } from "vite"; export default defineConfig({ build: { lib: { + cssFileName: "styles", entry: { index: "src/index.ts", theme: "src/theme.ts" }, formats: ["es"], - cssFileName: "styles", }, rolldownOptions: { external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/, diff --git a/scripts/check-image-publication.cjs b/scripts/check-image-publication.cjs index d0fda81..25788d5 100644 --- a/scripts/check-image-publication.cjs +++ b/scripts/check-image-publication.cjs @@ -1,31 +1,31 @@ -const { test } = require("node:test"); const assert = require("node:assert/strict"); const { + chmodSync, mkdtempSync, readFileSync, - writeFileSync, - chmodSync, rmSync, + writeFileSync, } = require("node:fs"); const { tmpdir } = require("node:os"); -const { join, resolve } = require("node:path"); +const nodePath = require("node:path"); +const { test } = require("node:test"); const EXECUTABLE_MODE = 0o755; const { spawnSync } = require("node:child_process"); test("immutable publication resumes exact digest checks after post-push failure", () => { - const directory = mkdtempSync(join(tmpdir(), "wl-image-retry-")); + const directory = mkdtempSync(nodePath.join(tmpdir(), "wl-image-retry-")); const workflow = readFileSync( - resolve(__dirname, "../.gitea/workflows/image.yml"), + nodePath.resolve(__dirname, "../.gitea/workflows/image.yml"), "utf8", ); const block = workflow - .split(" - name: Publish immutable release tag\n run: |\n")[1] - .split(" - name:")[0] - .replace(/^ {10}/gm, ""); - const double = `#!${process.execPath} + .split(" - name: Publish immutable release tag\n run: |\n", 2)[1] + .split(" - name:", 1)[0] + .replaceAll(/^ {10}/gm, ""); + const double = String.raw`#!${process.execPath} const fs = require('node:fs'); const args = process.argv.slice(2).join(' '); -fs.appendFileSync('calls', args + '\\n'); +fs.appendFileSync('calls', args + '\n'); if (args.startsWith('manifest inspect')) { if (!fs.existsSync('published')) { console.error('manifest unknown'); process.exit(1); } } else if (args.startsWith('buildx imagetools inspect')) { @@ -41,8 +41,10 @@ if (args.startsWith('manifest inspect')) { `; try { for (const name of ["docker", "npm"]) { - writeFileSync(join(directory, name), double); - chmodSync(join(directory, name), EXECUTABLE_MODE); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + writeFileSync(nodePath.join(directory, name), double); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + chmodSync(nodePath.join(directory, name), EXECUTABLE_MODE); } const run = (extra = {}) => spawnSync("/bin/sh", ["-ec", block], { @@ -50,8 +52,8 @@ if (args.startsWith('manifest inspect')) { encoding: "utf8", env: { ...process.env, - PATH: `${directory}:${process.env.PATH}`, IMAGE: "synthetic/image", + PATH: `${directory}:${process.env.PATH}`, RELEASE_TAG: "v1.2.3", REVISION: "commit", ...extra, @@ -62,14 +64,16 @@ if (args.startsWith('manifest inspect')) { assert.equal(retry.status, 0, retry.stdout + retry.stderr); assert.notEqual(run({ BAD_REVISION: "1" }).status, 0); assert.notEqual(run({ BAD_SMOKE: "1" }).status, 0); - const calls = readFileSync(join(directory, "calls"), "utf8"); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + const calls = readFileSync(nodePath.join(directory, "calls"), "utf8"); assert.equal( - calls.split("\n").filter((line) => line.startsWith("buildx build")) + calls.split("\n").filter(line => line.startsWith("buildx build")) .length, 1, ); assert.ok(calls.includes("synthetic/image@sha256:exact")); - } finally { - rmSync(directory, { recursive: true, force: true }); + } + finally { + rmSync(directory, { force: true, recursive: true }); } }); diff --git a/scripts/check-openapi.mjs b/scripts/check-openapi.mjs index 28a3c97..a440dfa 100644 --- a/scripts/check-openapi.mjs +++ b/scripts/check-openapi.mjs @@ -1,14 +1,14 @@ import { spawnSync } from "node:child_process"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import nodePath from "node:path"; const temporaryDirectory = await mkdtemp( - join(tmpdir(), "wealth-ledger-openapi-"), + nodePath.join(tmpdir(), "wealth-ledger-openapi-"), ); -const generatedPath = join(temporaryDirectory, "schema.d.ts"); -const committedPath = resolve("src/shared/api/generated/schema.d.ts"); -const cliPath = resolve("node_modules/openapi-typescript/bin/cli.js"); +const generatedPath = nodePath.join(temporaryDirectory, "schema.d.ts"); +const committedPath = nodePath.resolve("src/shared/api/generated/schema.d.ts"); +const cliPath = nodePath.resolve("node_modules/openapi-typescript/bin/cli.js"); let exitCode = 0; try { @@ -18,12 +18,10 @@ try { { encoding: "utf8" }, ); - if (generated.status !== 0) { - process.stderr.write(generated.stderr || generated.stdout); - exitCode = generated.status ?? 1; - } else { + if (generated.status === 0) { const [committed, current] = await Promise.all([ readFile(committedPath, "utf8"), + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. readFile(generatedPath, "utf8"), ]); if (committed !== current) { @@ -33,8 +31,13 @@ try { exitCode = 1; } } -} finally { - await rm(temporaryDirectory, { recursive: true, force: true }); + else { + process.stderr.write(generated.stderr || generated.stdout); + exitCode = generated.status ?? 1; + } +} +finally { + await rm(temporaryDirectory, { force: true, recursive: true }); } process.exitCode = exitCode; diff --git a/scripts/check-release.cjs b/scripts/check-release.cjs index 3953c63..f5ed10e 100644 --- a/scripts/check-release.cjs +++ b/scripts/check-release.cjs @@ -1,14 +1,16 @@ const assert = require("node:assert/strict"); const { execFileSync } = require("node:child_process"); + const { version } = require("../package.json"); -const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim(); +// eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell. +const git = (...arguments_) => execFileSync("git", arguments_, { encoding: "utf8" }).trim(); assert.equal( git("status", "--porcelain"), "", "Commit all changes before preparing a release", ); -const tags = git( +const tag_ = git( "tag", "--merged", "HEAD", @@ -17,9 +19,9 @@ const tags = git( "--sort=-version:refname", ) .split("\n") - .filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag)); + .find(tag => /^v\d+\.\d+\.\d+$/.test(tag)); assert.equal( version, - tags[0]?.slice(1) ?? "0.0.0", + tag_?.slice(1) ?? "0.0.0", "Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.", ); diff --git a/scripts/test-eslint.mjs b/scripts/test-eslint.mjs index e53490f..4d45046 100644 --- a/scripts/test-eslint.mjs +++ b/scripts/test-eslint.mjs @@ -1,6 +1,22 @@ +import react from "@eslint-react/eslint-plugin"; +import stylistic from "@stylistic/eslint-plugin"; +import query from "@tanstack/eslint-plugin-query"; +import vitest from "@vitest/eslint-plugin"; +import { ESLint } from "eslint"; +import { importX } from "eslint-plugin-import-x"; +import jestDom from "eslint-plugin-jest-dom"; +import perfectionist from "eslint-plugin-perfectionist"; +import hooks from "eslint-plugin-react-hooks"; +import refresh from "eslint-plugin-react-refresh"; +import regexp from "eslint-plugin-regexp"; +import security from "eslint-plugin-security"; +import sonarjs from "eslint-plugin-sonarjs"; +import storybook from "eslint-plugin-storybook"; +import testingLibrary from "eslint-plugin-testing-library"; +import unicorn from "eslint-plugin-unicorn"; import assert from "node:assert/strict"; import { test } from "node:test"; -import { ESLint } from "eslint"; +import tseslint from "typescript-eslint"; test("ESLint enforces the frontend profile and excludes generated artifacts", async () => { const eslint = new ESLint(); @@ -12,7 +28,7 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as ], [ "packages/ui/src/EntryCard/EntryCard.tsx", - 'import { useState } from "react"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }', + "import { useState } from \"react\"; export function Example({ active }: { active: boolean }) { if (active) useState(0); return null; }", "react-hooks/rules-of-hooks", ], [ @@ -25,26 +41,61 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as "export default 1;", "import-x/no-default-export", ], + [ + "src/shared/api/problem-details.ts", + "new Error(\"forgot to throw\");", + "sonarjs/no-unthrown-error", + ], + [ + "src/shared/api/problem-details.ts", + "export const pattern = /[aab]/;", + "regexp/no-dupe-characters-character-class", + ], + [ + "src/shared/api/problem-details.ts", + "export type Status = \"z\" | \"a\";", + "perfectionist/sort-union-types", + ], + [ + "src/shared/api/problem-details.ts", + "//missing space\nexport const ready = true;", + "@stylistic/spaced-comment", + ], + [ + "src/shared/api/problem-details.ts", + "export function pattern(input: string) { return new RegExp(input); }", + "security/detect-non-literal-regexp", + ], + [ + "scripts/check-openapi.mjs", + "import { exec } from \"node:child_process\"; exec(process.argv[2]);", + "security/detect-child-process", + ], + [ + "packages/ui/src/EntryCard/EntryCard.test.tsx", + "import { expect } from \"vitest\"; expect(document.activeElement).toBe(document.body);", + "jest-dom/prefer-focus", + ], [ "src/shared/api/client.test.ts", - 'import { it } from "vitest"; it.only("focused", () => {});', + "import { it } from \"vitest\"; it.only(\"focused\", () => {});", "vitest/no-focused-tests", ], [ "packages/ui/src/EntryCard/EntryCard.test.tsx", - 'import { screen } from "@testing-library/react"; screen.findByText("ready");', + "import { screen } from \"@testing-library/react\"; screen.findByText(\"ready\");", "testing-library/await-async-queries", ], [ "packages/ui/src/EntryCard/EntryCard.tsx", - 'import { QueryClient } from "@tanstack/react-query"; export function Example() { const client = new QueryClient(); return
{String(client)}
; }', + "import { QueryClient } from \"@tanstack/react-query\"; export function Example() { const client = new QueryClient(); return
{String(client)}
; }", "@tanstack/query/stable-query-client", ], ]) { const [result] = await eslint.lintText(code, { filePath }); assert.equal(result.fatalErrorCount, 0, filePath); assert.ok( - result.messages.some((message) => message.ruleId === rule), + result.messages.some(message => message.ruleId === rule), rule, ); } @@ -62,3 +113,63 @@ test("ESLint enforces the frontend profile and excludes generated artifacts", as assert.ok(story.rules["storybook/default-exports"][0] > 0); assert.equal(story.rules["react-refresh/only-export-components"], undefined); }); + +test("all recommended plugin rules remain enabled in their intended scopes", async () => { + const eslint = new ESLint(); + const source = "src/shared/api/problem-details.ts"; + const component = "packages/ui/src/EntryCard/EntryCard.tsx"; + const unit = "packages/ui/src/EntryCard/EntryCard.test.tsx"; + for (const [preset, filePath] of [ + [tseslint.configs.recommendedTypeChecked, source], + [[importX.flatConfigs.recommended, importX.flatConfigs.typescript], source], + [unicorn.configs.recommended, source], + [sonarjs.configs.recommended, source], + [regexp.configs.recommended, source], + [security.configs.recommended, source], + [perfectionist.configs["recommended-natural"], source], + [stylistic.configs.recommended, component], + [react.configs["recommended-type-checked"], component], + [hooks.configs.flat.recommended, component], + [refresh.configs.vite, component], + [query.configs["flat/recommended"], component], + [vitest.configs.recommended, unit], + [testingLibrary.configs["flat/react"], unit], + [jestDom.configs["flat/recommended"], unit], + ...storybook.configs["flat/recommended"].filter(config => config.rules).map(config => [config, config.name.endsWith("main-rules") ? ".storybook/main.ts" : "src/shared/ui/Button.stories.tsx"]), + ]) { + const configs = [preset].flat(); + const expected = Object.assign({}, ...configs.map(config => config.rules)); + const config = await eslint.calculateConfigForFile(filePath); + const actual = new Map(Object.entries(config.rules)); + for (const [rule, setting] of Object.entries(expected)) { + const severity = Array.isArray(setting) ? setting[0] : setting; + if (severity === "off" || severity === 0 || !rule.includes("/")) continue; + assert.ok(actual.get(rule)?.[0] > 0, `${filePath}: ${rule}`); + } + } +}); + +test("ESLint formatting is idempotent and preserves TSX generic syntax", async () => { + const eslint = new ESLint({ fix: true }); + const filePath = "packages/ui/src/EntryCard/EntryCard.tsx"; + const [fixed] = await eslint.lintText( + "//identity\nconst identity = (value: T): T => value;\nexport {identity};", + { filePath }, + ); + assert.equal(fixed.fatalErrorCount, 0); + assert.equal(fixed.errorCount, 0); + assert.ok(fixed.output); + const [again] = await eslint.lintText(fixed.output, { filePath }); + assert.equal(again.errorCount, 0); + assert.equal(again.output, undefined); +}); + +test("ESLint formatting preserves spaces around JSX text and interpolations", async () => { + const eslint = new ESLint({ fix: true }); + const [fixed] = await eslint.lintText( + "const Example = () => {\nconst value = 'value';\nreturn Amount: {value} / {value};\n};\nexport { Example };", + { filePath: "packages/ui/src/EntryCard/EntryCard.tsx" }, + ); + assert.equal(fixed.errorCount, 0); + assert.ok(fixed.output?.includes("Amount: {value} / {value}")); +}); diff --git a/scripts/test-pre-commit.mjs b/scripts/test-pre-commit.mjs index 4d5009c..3b79d8f 100644 --- a/scripts/test-pre-commit.mjs +++ b/scripts/test-pre-commit.mjs @@ -9,53 +9,62 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { delimiter, join, resolve } from "node:path"; +import nodePath from "node:path"; import { test } from "node:test"; const GITHUB_TOKEN_RANDOM_BYTES = 18; test("pre-commit scans the index, redacts findings and fails closed", () => { - const hook = resolve(".husky/pre-commit"); - const cwd = mkdtempSync(join(tmpdir(), "frontend-hook-")); - const bin = join(cwd, "bin"); - const marker = join(cwd, "downstream"); + const hook = nodePath.resolve(".husky/pre-commit"); + const cwd = mkdtempSync(nodePath.join(tmpdir(), "frontend-hook-")); + const bin = nodePath.join(cwd, "bin"); + const marker = nodePath.join(cwd, "downstream"); const token = `ghp_${randomBytes(GITHUB_TOKEN_RANDOM_BYTES).toString("hex")}`; - const git = (...args) => execFileSync("git", args, { cwd, stdio: "pipe" }); - const run = (path = `${bin}${delimiter}${process.env.PATH}`) => + // eslint-disable-next-line sonarjs/no-os-command-from-path -- Repository checks use the developer/CI Git installation; arguments never invoke a shell. + const git = (...arguments_) => execFileSync("git", arguments_, { cwd, stdio: "pipe" }); + const run = (path = `${bin}${nodePath.delimiter}${process.env.PATH}`) => spawnSync("/bin/sh", ["-e", hook], { cwd, - env: { ...process.env, PATH: path }, encoding: "utf8", + env: { ...process.env, PATH: path }, }); try { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. mkdirSync(bin); for (const command of ["npx", "npm"]) { - writeFileSync(join(bin, command), "#!/bin/sh\necho ran >> downstream\n", { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + writeFileSync(nodePath.join(bin, command), "#!/bin/sh\necho ran >> downstream\n", { mode: 0o755, }); } git("init", "--quiet"); - writeFileSync(join(cwd, "sample.txt"), "safe staged content\n"); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + writeFileSync(nodePath.join(cwd, "sample.txt"), "safe staged content\n"); git("add", "sample.txt"); // An unstaged secret must not be mistaken for the index being committed. - writeFileSync(join(cwd, "sample.txt"), `token=${token}\n`); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. + writeFileSync(nodePath.join(cwd, "sample.txt"), `token=${token}\n`); let result = run(); assert.equal( result.status, 0, "clean index must pass; install Gitleaks first", ); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. assert.ok(existsSync(marker)); rmSync(marker); git("add", "sample.txt"); result = run(); assert.equal(result.status, 1, "staged secret must block commit"); assert.ok(!`${result.stdout}${result.stderr}`.includes(token)); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. assert.ok(!existsSync(marker), "formatting/dependency checks must not run"); result = run(bin); assert.notEqual(result.status, 0, "missing Gitleaks must block commit"); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is built from this check’s private mkdtemp directory and fixed filenames. assert.ok(!existsSync(marker)); - } finally { - rmSync(cwd, { recursive: true, force: true }); + } + finally { + rmSync(cwd, { force: true, recursive: true }); } }); diff --git a/src/app/routes/AppRoutes/AllAccounts.test.tsx b/src/app/routes/AppRoutes/AllAccounts.test.tsx index f493d0a..247d002 100644 --- a/src/app/routes/AppRoutes/AllAccounts.test.tsx +++ b/src/app/routes/AppRoutes/AllAccounts.test.tsx @@ -1,3 +1,5 @@ +import type { FC } from "react"; + import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { @@ -7,96 +9,104 @@ import { screen, waitFor, } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; -import type { FC } from "react"; +import { http, HttpResponse } from "msw"; import { MemoryRouter, useLocation } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; + import type { BrokerageOperation } from "@/entities/broker-operation"; import type { IncomeAnalytics } from "@/entities/income"; import type { Portfolio } from "@/entities/portfolio"; + import { AuthProvider } from "@/features/auth"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; + import { AppRoutes } from "./AppRoutes"; const accounts = [ { id: "a", name: "Основной" }, { id: "b", name: "ИИС" }, ]; -const position: Portfolio["positions"][number] = { - figi: "SBER", - displayName: "Сбербанк", - type: "STOCK", - quantity: "2", - currentValue: { currency: "RUB", value: "600" }, - absoluteReturn: null, - averagePurchasePrice: null, - isin: null, - ticker: "SBER", - logoName: null, -}; +const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + absoluteReturn: null, + averagePurchasePrice: null, + currentValue: { currency: "RUB", value: "600" }, + displayName: "Сбербанк", + figi: "SBER", + isin: null, + logoName: null, + quantity: "2", + ticker: "SBER", + type: "STOCK", + } +/* eslint-enable unicorn/no-null */); const portfolio: Portfolio = { - lastSuccessfulAt: "2026-08-15T12:00:00Z", - isStale: false, - totalsByCurrency: [{ currency: "RUB", value: "1200" }], - structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [], - positions: accounts.map((account) => ({ ...position, account })), + isStale: false, + lastSuccessfulAt: "2026-08-15T12:00:00Z", + positions: accounts.map(account => ({ ...position, account })), + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "RUB", value: "1200" }], }; -const receipt: BrokerageOperation = { - id: "same", - occurredAt: "2026-08-15T12:00:00Z", - type: "OPERATION_TYPE_COUPON", - category: "income", - description: "Выплата купона", - figi: null, - instrumentUid: null, - ticker: "BOND", - instrumentType: "bond", - logoName: null, - payment: { currency: "RUB", value: "1" }, - quantity: null, - price: null, - accruedInterest: null, - commission: null, -}; -const items = accounts.map((account) => ({ ...receipt, account })); -const analytics: IncomeAnalytics = { - accountId: "all", - period: { from: "2026-08-01", to: "2026-08-31" }, - calculatedAt: "2026-09-01T12:00:00Z", - totalsByCurrency: [ - { - currency: "RUB", - coupon: { currency: "RUB", value: "2" }, - dividend: { currency: "RUB", value: "0" }, - total: { currency: "RUB", value: "2" }, +const receipt: BrokerageOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + accruedInterest: null, + category: "income", + commission: null, + description: "Выплата купона", + figi: null, + id: "same", + instrumentType: "bond", + instrumentUid: null, + logoName: null, + occurredAt: "2026-08-15T12:00:00Z", + payment: { currency: "RUB", value: "1" }, + price: null, + quantity: null, + ticker: "BOND", + type: "OPERATION_TYPE_COUPON", + } +/* eslint-enable unicorn/no-null */); +const items = accounts.map(account => ({ ...receipt, account })); +const analytics: IncomeAnalytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + accountId: "all", + bucketGranularity: "day", + buckets: [], + calculatedAt: "2026-09-01T12:00:00Z", + entries: items.map(entry => ({ ...entry, instrumentKey: "bond:RUB" })), + instruments: [ + { + coupon: { currency: "RUB", value: "2" }, + displayName: "Облигация", + dividend: { currency: "RUB", value: "0" }, + figi: null, + instrumentType: "bond", + instrumentUid: null, + key: "bond:RUB", + receiptCount: 2, + ticker: "BOND", + total: { currency: "RUB", value: "2" }, + }, + ], + period: { from: "2026-08-01", to: "2026-08-31" }, + previous: { + period: { from: "2026-07-01", to: "2026-07-31" }, + totalsByCurrency: [], }, - ], - previous: { - period: { from: "2026-07-01", to: "2026-07-31" }, - totalsByCurrency: [], - }, - yearComparison: null, - bucketGranularity: "day", - buckets: [], - instruments: [ - { - key: "bond:RUB", - figi: null, - instrumentUid: null, - ticker: "BOND", - instrumentType: "bond", - displayName: "Облигация", - coupon: { currency: "RUB", value: "2" }, - dividend: { currency: "RUB", value: "0" }, - total: { currency: "RUB", value: "2" }, - receiptCount: 2, - }, - ], - entries: items.map((entry) => ({ ...entry, instrumentKey: "bond:RUB" })), -}; + totalsByCurrency: [ + { + coupon: { currency: "RUB", value: "2" }, + currency: "RUB", + dividend: { currency: "RUB", value: "0" }, + total: { currency: "RUB", value: "2" }, + }, + ], + yearComparison: null, + } +/* eslint-enable unicorn/no-null */); const CurrentLocation: FC = () => { const location = useLocation(); return ( @@ -128,18 +138,20 @@ beforeEach(() => { http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: "test", - user: { id: "user", email: "user@example.com" }, + user: { email: "user@example.com", id: "user" }, }), ), http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json( resource( - accounts.map((account) => ({ - ...account, - type: "BROKERAGE", - openedOn: null, - portfolioSummary: null, - })), + accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...account, + openedOn: null, + portfolioSummary: null, + type: "BROKERAGE", + } + /* eslint-enable unicorn/no-null */))), ), ), ), @@ -150,13 +162,15 @@ beforeEach(() => { "*/api/v1/brokerage-accounts/:accountId/operations", ({ params }) => HttpResponse.json( - resource({ - accountId: params.accountId, - items, - hasNext: false, - nextCursor: null, - observedAt: receipt.occurredAt, - }), + resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + accountId: params.accountId, + hasNext: false, + items, + nextCursor: null, + observedAt: receipt.occurredAt, + } + /* eslint-enable unicorn/no-null */)), ), ), http.get("*/api/v1/brokerage-accounts/:accountId/income-analytics", () => @@ -167,20 +181,22 @@ beforeEach(() => { resource({ accountId: "all", calculatedAt: receipt.occurredAt, + events: accounts.map(account => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + account, + date: "2026-09-15", + dateKind: "event", + displayName: "Облигация", + figi: null, + instrumentUid: null, + isin: null, + logoName: null, + projectedAmount: { currency: "RUB", value: "2" }, + ticker: "BOND", + type: "coupon", + } + /* eslint-enable unicorn/no-null */))), incomeTotalsByCurrency: [], - events: accounts.map((account) => ({ - account, - type: "coupon", - date: "2026-09-15", - dateKind: "event", - figi: null, - instrumentUid: null, - ticker: "BOND", - displayName: "Облигация", - isin: null, - logoName: null, - projectedAmount: { currency: "RUB", value: "2" }, - })), }), ), ), @@ -219,7 +235,7 @@ describe("All accounts routes", () => { ); for (const suffix of ["operations", "income", "events"]) expect( - document.querySelector(`a[href="/accounts/all/${suffix}"]`), + screen.getAllByRole("link").find(link => link.getAttribute("href") === `/accounts/all/${suffix}`), ).toBeInTheDocument(); }); @@ -228,24 +244,26 @@ describe("All accounts routes", () => { server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/operations", - ({ request, params }) => { + ({ params, request }) => { const url = new URL(request.url); requests.push(url); return HttpResponse.json( - resource({ - accountId: params.accountId, - items: [ - { - ...receipt, - description: url.searchParams.has("cursor") - ? "Вторая страница" - : "Первая страница", - }, - ], - hasNext: !url.searchParams.has("cursor"), - nextCursor: url.searchParams.has("cursor") ? null : "next", - observedAt: receipt.occurredAt, - }), + resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + accountId: params.accountId, + hasNext: !url.searchParams.has("cursor"), + items: [ + { + ...receipt, + description: url.searchParams.has("cursor") + ? "Вторая страница" + : "Первая страница", + }, + ], + nextCursor: url.searchParams.has("cursor") ? null : "next", + observedAt: receipt.occurredAt, + } + /* eslint-enable unicorn/no-null */)), ); }, ), @@ -260,10 +278,10 @@ describe("All accounts routes", () => { fireEvent.click(await screen.findByRole("option", { name: "ИИС" })); await waitFor(() => expect( - requests.some((url) => url.pathname.includes("/b/operations")), + requests.some(url => url.pathname.includes("/b/operations")), ).toBe(true), ); - const selected = requests.find((url) => + const selected = requests.find(url => url.pathname.includes("/b/operations"), ); expect(selected?.searchParams.get("from")).toBe("2026-08-01T00:00:00.000Z"); @@ -286,8 +304,8 @@ describe("All accounts routes", () => { : HttpResponse.json( resource({ accountId: "all", - items, hasNext: true, + items, nextCursor: "expired", observedAt: receipt.occurredAt, }), @@ -305,7 +323,7 @@ describe("All accounts routes", () => { }); it("retains the complete portfolio during a failed refresh", async () => { - const client = renderPage("/accounts/all"); + const view = renderPage("/accounts/all"); await screen.findAllByText("Сбербанк"); server.use( http.get("*/api/v1/brokerage-accounts/all/portfolio", () => @@ -313,7 +331,7 @@ describe("All accounts routes", () => { ), ); await act(() => - client.invalidateQueries({ + view.invalidateQueries({ predicate: ({ queryKey }) => String(queryKey[1]).endsWith("/portfolio"), }), ); diff --git a/src/app/routes/AppRoutes/AppRoutes.test.tsx b/src/app/routes/AppRoutes/AppRoutes.test.tsx index 8ef2531..54ed5a0 100644 --- a/src/app/routes/AppRoutes/AppRoutes.test.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.test.tsx @@ -2,32 +2,34 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen } from "@testing-library/react"; import { cssVariablesResolver, theme } from "@wealthledger/ui/theme"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; + import { AuthProvider } from "@/features/auth"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; + import { AppRoutes } from "./AppRoutes"; -describe("AppRoutes", () => { - function renderRoutes(path: string) { - return render( - - - - - - - - - , - ); - } +function renderRoutes(path: string) { + return render( + + + + + + + + + , + ); +} +describe("AppRoutes", () => { it.each([ ["/", "Вход"], ["/login", "Вход"], @@ -62,7 +64,7 @@ describe("AppRoutes", () => { credentials = await request.json(); return HttpResponse.json({ accessToken: "login-token", - user: { id: "user-1", email: "user@example.com" }, + user: { email: "user@example.com", id: "user-1" }, }); }), ); @@ -96,19 +98,21 @@ describe("AppRoutes", () => { http.post("*/api/v1/auth/login", () => HttpResponse.json({ accessToken: "test", - user: { id: "user-1", email: "user@example.com" }, + user: { email: "user@example.com", id: "user-1" }, }), ), http.get("*/api/v1/bank-cards", () => HttpResponse.json([])), http.get("*/api/v1/bank-cards/summary", () => - HttpResponse.json({ - month: "2026-08", - cardId: null, - expenses: { currency: "RUB", value: "0" }, - refunds: { currency: "RUB", value: "0" }, - net: { currency: "RUB", value: "0" }, - categories: [], - }), + HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + cardId: null, + categories: [], + expenses: { currency: "RUB", value: "0" }, + month: "2026-08", + net: { currency: "RUB", value: "0" }, + refunds: { currency: "RUB", value: "0" }, + } + /* eslint-enable unicorn/no-null */)), ), ); renderRoutes("/cards?month=2026-08"); @@ -132,7 +136,7 @@ describe("AppRoutes", () => { http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: "test-token", - user: { id: "user-1", email: "user@example.com" }, + user: { email: "user@example.com", id: "user-1" }, }), ), ); diff --git a/src/app/routes/AppRoutes/AppRoutes.tsx b/src/app/routes/AppRoutes/AppRoutes.tsx index 2ebd2be..848aeb2 100644 --- a/src/app/routes/AppRoutes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes/AppRoutes.tsx @@ -1,6 +1,8 @@ import type { FC } from "react"; + import { lazy } from "react"; import { Navigate, Route, Routes } from "react-router"; + import { LazyPage } from "../RouteFallback"; import { Protected, Public } from "../RouteGuards"; @@ -71,172 +73,172 @@ const ProtectedLayout = lazy(async () => { const AppRoutes: FC = () => ( - } /> + } path="/" /> - } + )} + path="/login" /> - } + )} + path="/register" /> - } + )} > - } + )} + path="/cards/:cardId" /> - } + )} + path="/cards" /> - } + )} + path="/bonds" /> - } + )} + path="/bonds/:bondId" /> - } + )} + path="/stocks" /> - } + )} + path="/stocks/:stockId" /> - } + )} + path="/accounts" /> - } + )} + path="/accounts/all" /> - } + )} + path="/accounts/all/operations" /> - } + )} + path="/accounts/all/income" /> - } + )} + path="/accounts/all/events" /> - } + )} + path="/accounts/:accountId" /> - } + )} + path="/accounts/:accountId/events" /> - } + )} + path="/accounts/:accountId/income" /> - } + )} + path="/accounts/:accountId/operations" /> - } + )} + path="/settings" /> - } + )} + path="*" /> ); diff --git a/src/app/routes/DocumentTitle/DocumentTitle.test.tsx b/src/app/routes/DocumentTitle/DocumentTitle.test.tsx index 353cb03..a53748d 100644 --- a/src/app/routes/DocumentTitle/DocumentTitle.test.tsx +++ b/src/app/routes/DocumentTitle/DocumentTitle.test.tsx @@ -1,3 +1,5 @@ +import type { FC } from "react"; + import { QueryClientProvider } from "@tanstack/react-query"; import { act, @@ -6,24 +8,25 @@ import { screen, waitFor, } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; -import type { FC } from "react"; +import { http, HttpResponse } from "msw"; import { MemoryRouter, useNavigate } from "react-router"; import { beforeEach, describe, expect, it } from "vitest"; + import { AuthProvider } from "@/features/auth"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; + import { ProtectedDocumentTitle } from "./DocumentTitle"; const Navigation: FC = () => { const navigate = useNavigate(); return ( <> - - @@ -48,7 +51,7 @@ beforeEach(() => { http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: "test-token", - user: { id: "user-1", email: "user@example.com" }, + user: { email: "user@example.com", id: "user-1" }, }), ), ); @@ -56,10 +59,7 @@ beforeEach(() => { describe("document titles", () => { it("updates a loading account name and follows account scope and history", async () => { - let resolveAccounts!: () => void; - const accountsReady = new Promise((resolve) => { - resolveAccounts = resolve; - }); + const { promise: accountsReady, resolve: resolveAccounts } = Promise.withResolvers(); server.use( http.get("*/api/v1/brokerage-accounts", async () => { await accountsReady; @@ -68,7 +68,7 @@ describe("document titles", () => { ); renderTitle("/accounts/a/income?from=2026-01-01&to=2026-08-31"); expect(document.title).toBe("Пассивный доход — Счет — WealthLedger"); - await act(async () => resolveAccounts()); + await act(() => Promise.resolve(resolveAccounts())); await waitFor(() => expect(document.title).toBe("Пассивный доход — ИИС — WealthLedger"), ); @@ -98,15 +98,15 @@ describe("document titles", () => { ); it("keeps a useful title when the instrument is unavailable", async () => { - let requested = false; + let isRequested = false; server.use( http.get("*/api/v1/stocks/by-ticker/:ticker", () => { - requested = true; + isRequested = true; return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }); }), ); renderTitle("/stocks/missing"); - await waitFor(() => expect(requested).toBe(true)); + await waitFor(() => expect(isRequested).toBe(true)); expect(document.title).toBe("Акция — WealthLedger"); }); }); diff --git a/src/app/routes/DocumentTitle/DocumentTitle.tsx b/src/app/routes/DocumentTitle/DocumentTitle.tsx index 494f430..cb616e5 100644 --- a/src/app/routes/DocumentTitle/DocumentTitle.tsx +++ b/src/app/routes/DocumentTitle/DocumentTitle.tsx @@ -1,7 +1,9 @@ -import { useDocumentTitle } from "@mantine/hooks"; import type { FC } from "react"; + +import { useDocumentTitle } from "@mantine/hooks"; import { useTranslation } from "react-i18next"; import { useMatch } from "react-router"; + import { useBankCard } from "@/entities/bank-card"; import { useBond } from "@/entities/bond"; import { useBrokerageAccounts } from "@/entities/brokerage-account"; @@ -9,10 +11,9 @@ import { useStock } from "@/entities/stock"; type DocumentTitleProps = { title: string }; -const DocumentTitle: FC = (props) => { +const DocumentTitle: FC = (props): undefined => { const { t } = useTranslation(); useDocumentTitle(`${props.title} — ${t("appName")}`); - return null; }; type AccountDocumentTitleProps = { @@ -23,19 +24,29 @@ type AccountDocumentTitleProps = { const AccountDocumentTitle: FC = (props) => { const { t } = useTranslation(); const { data } = useBrokerageAccounts(); - const accountName = - props.accountId === "all" + const accountName + = props.accountId === "all" ? t("accounts.all") - : (data?.find((account) => account.id === props.accountId)?.name ?? - t("account.title")); - const section = - props.section === "income" - ? t("income.title") - : props.section === "operations" - ? t("ledger.title") - : props.section === "events" - ? t("events.title") - : t("portfolio.title"); + : (data?.find(account => account.id === props.accountId)?.name + ?? t("account.title")); + let section = t("portfolio.title"); + switch (props.section ?? "") { + case "income": { + section = t("income.title"); + break; + } + case "operations": { + section = t("ledger.title"); + break; + } + case "events": { + section = t("events.title"); + break; + } + default: { + break; + } + } return ; }; @@ -80,21 +91,10 @@ const ProtectedDocumentTitle: FC = () => { if (bond) return ; if (stock) return ; - return ( - - ); + if (cards) return ; + if (bonds) return ; + if (stocks) return ; + return settings ? : ; }; export { ProtectedDocumentTitle }; diff --git a/src/app/routes/ProtectedLayout.tsx b/src/app/routes/ProtectedLayout.tsx index f0b9e12..821b496 100644 --- a/src/app/routes/ProtectedLayout.tsx +++ b/src/app/routes/ProtectedLayout.tsx @@ -1,5 +1,7 @@ import type { FC } from "react"; + import { AppBreadcrumbs, AppShell } from "@/app/ui"; + import { ProtectedDocumentTitle } from "./DocumentTitle/DocumentTitle"; const ProtectedLayout: FC = () => { diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx index a03237d..134afb0 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.test.tsx @@ -3,11 +3,14 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { type FC, lazy, Suspense } from "react"; import { MemoryRouter, useLocation, useNavigate } from "react-router"; import { describe, expect, it, vi } from "vitest"; + import { RouteErrorBoundary } from "./RouteErrorBoundary"; -let authenticated = false; +const session = { isAuthenticated: false }; vi.mock("@/features/auth", () => ({ - useAuth: () => ({ user: authenticated ? {} : null }), + useAuth: () => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { user: session.isAuthenticated ? {} : null } + /* eslint-enable unicorn/no-null */)), })); const ThrowingRoute: FC = () => { @@ -17,11 +20,13 @@ const ThrowingRoute: FC = () => { const HealthyRoute: FC = () => { return (
- {useLocation().pathname === "/healthy" ? ( - "healthy route" - ) : ( - - )} + {useLocation().pathname === "/healthy" + ? ( + "healthy route" + ) + : ( + + )}
); }; @@ -31,7 +36,7 @@ const RouteHarness: FC = () => { return ( <> - @@ -55,10 +60,10 @@ describe("RouteErrorBoundary", () => { it.each(["/accounts", "/login"])( "recovers a transient failure through the fallback on %s itself", async (path) => { - authenticated = path === "/accounts"; - let failing = true; + session.isAuthenticated = path === "/accounts"; + let isFailing = true; const RecoverableRoute: FC = () => { - if (failing) throw new Error("temporary failure"); + if (isFailing) throw new Error("temporary failure"); return
recovered route
; }; render( @@ -71,14 +76,14 @@ describe("RouteErrorBoundary", () => { , ); const action = screen.getByRole("link", { name: "Продолжить" }); - failing = false; + isFailing = false; fireEvent.click(action); expect(await screen.findByText("recovered route")).toBeInTheDocument(); }, ); it("keeps a persistent failure recoverable without an automatic reset loop", () => { - authenticated = true; + session.isAuthenticated = true; renderBoundary("/accounts"); fireEvent.click(screen.getByRole("link", { name: "Продолжить" })); expect( @@ -91,7 +96,7 @@ describe("RouteErrorBoundary", () => { }); it("offers document navigation when React.lazy caches a rejected chunk", async () => { - authenticated = false; + session.isAuthenticated = false; const load = vi.fn(() => Promise.reject(new Error("chunk failed"))); const Chunk = lazy(load); render( @@ -110,18 +115,18 @@ describe("RouteErrorBoundary", () => { const reload = screen.getByRole("link", { name: "Перезагрузить страницу" }); expect(reload).toHaveAttribute("href", "/login"); // Cancel browser navigation in JSDOM only after proving React Router did not intercept it. - let intercepted = true; + let isIntercepted = true; const preventNavigation = (event: MouseEvent) => { - intercepted = event.defaultPrevented; + isIntercepted = event.defaultPrevented; event.preventDefault(); }; document.addEventListener("click", preventNavigation, { once: true }); fireEvent.click(reload); - expect(intercepted).toBe(false); + expect(isIntercepted).toBe(false); }); it("offers login after a public route error without error details", () => { - authenticated = false; + session.isAuthenticated = false; renderBoundary("/login"); expect(document.title).toBe("Не удалось открыть страницу — WealthLedger"); expect(screen.getByRole("link", { name: "Продолжить" })).toHaveAttribute( @@ -132,9 +137,9 @@ describe("RouteErrorBoundary", () => { }); it("offers accounts after a protected route error and resets on navigation", async () => { - authenticated = true; + session.isAuthenticated = true; renderBoundary("/accounts"); - screen.getByRole("button", { name: "go healthy" }).click(); + fireEvent.click(screen.getByRole("button", { name: "go healthy" })); expect(await screen.findByText("healthy route")).toBeInTheDocument(); }); }); diff --git a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx index a6a09ab..809c239 100644 --- a/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx +++ b/src/app/routes/RouteErrorBoundary/RouteErrorBoundary.tsx @@ -1,14 +1,16 @@ +import type { FC, PropsWithChildren } from "react"; + import { Button, Stack, Text, Title } from "@mantine/core"; import { useDocumentTitle } from "@mantine/hooks"; -import type { FC, PropsWithChildren } from "react"; import { Component } from "react"; import { useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router"; + import { useAuth } from "@/features/auth"; type BoundaryProps = PropsWithChildren<{ - routeKey: string; authenticated: boolean; + routeKey: string; }>; type RouteErrorFallbackProps = { @@ -18,12 +20,12 @@ type RouteErrorFallbackProps = { type State = { hasError: boolean }; class Boundary extends Component { - state: State = { hasError: false }; - static getDerivedStateFromError(): State { return { hasError: true }; } + state: State = { hasError: false }; + componentDidUpdate(previousProps: BoundaryProps) { if (previousProps.routeKey !== this.props.routeKey && this.state.hasError) { this.setState({ hasError: false }); @@ -31,10 +33,7 @@ class Boundary extends Component { } render() { - if (this.state.hasError) { - return ; - } - return this.props.children; + return this.state.hasError ? : this.props.children; } } @@ -60,7 +59,7 @@ const RouteErrorFallback: FC = (props) => { const RouteErrorBoundary: FC = (props) => { const { children } = props; - const { pathname, search, key } = useLocation(); + const { key, pathname, search } = useLocation(); const { user } = useAuth(); return ( diff --git a/src/app/routes/RouteFallback.tsx b/src/app/routes/RouteFallback.tsx index a676042..d6f4e1d 100644 --- a/src/app/routes/RouteFallback.tsx +++ b/src/app/routes/RouteFallback.tsx @@ -1,11 +1,13 @@ -import { Container, Group, Skeleton, Stack } from "@mantine/core"; import type { FC, PropsWithChildren } from "react"; + +import { Container, Group, Skeleton, Stack } from "@mantine/core"; import { Suspense } from "react"; import { useTranslation } from "react-i18next"; + import { RouteTransition } from "./RouteTransition"; -type RouteSkeletonVariant = "auth" | "content" | "shell"; type LazyPageProps = PropsWithChildren<{ variant: RouteSkeletonVariant }>; +type RouteSkeletonVariant = "auth" | "content" | "shell"; const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { const { variant } = props; @@ -13,7 +15,7 @@ const RouteSkeleton: FC<{ variant: RouteSkeletonVariant }> = (props) => { if (variant === "auth") return ( - + = (props) => { const { children, variant } = props; return ( }> - {variant === "shell" ? ( - children - ) : ( - {children} - )} + {variant === "shell" + ? ( + children + ) + : ( + {children} + )} ); }; diff --git a/src/app/routes/RouteGuards.tsx b/src/app/routes/RouteGuards.tsx index 4f34f8c..3a3cf4f 100644 --- a/src/app/routes/RouteGuards.tsx +++ b/src/app/routes/RouteGuards.tsx @@ -1,37 +1,43 @@ import type { FC, PropsWithChildren } from "react"; + import { Navigate, useLocation } from "react-router"; + import { useAuth } from "@/features/auth"; + import { RouteSkeleton } from "./RouteFallback"; const Protected: FC = (props) => { const { children } = props; - const { user, ready } = useAuth(); + const { ready, user } = useAuth(); const location = useLocation(); if (!ready) return ; - return user ? ( - children - ) : ( - - ); + return user + ? ( + <>{children} + ) + : ( + + ); }; const Public: FC = (props) => { const { children } = props; - const { user, ready } = useAuth(); + const { ready, user } = useAuth(); const location = useLocation(); - const requested = (location.state as { returnTo?: string } | null)?.returnTo; - const returnTo = - typeof requested === "string" && - requested.startsWith("/") && - !requested.startsWith("//") + + if (!ready) return ; + const requested = (location.state as null | { returnTo?: string })?.returnTo; + const returnTo + = typeof requested === "string" + && requested.startsWith("/") + && !requested.startsWith("//") ? requested : "/accounts"; - if (!ready) return ; - return user ? : children; + return user ? : <>{children}; }; export { Protected, Public }; diff --git a/src/app/routes/RouteTransition.tsx b/src/app/routes/RouteTransition.tsx index 3fdaac8..84895a9 100644 --- a/src/app/routes/RouteTransition.tsx +++ b/src/app/routes/RouteTransition.tsx @@ -1,6 +1,7 @@ +import type { FC, PropsWithChildren } from "react"; + import { domAnimation, LazyMotion, useReducedMotion } from "motion/react"; import * as m from "motion/react-m"; -import type { FC, PropsWithChildren } from "react"; import { useLocation } from "react-router"; const TRANSITION_DURATION_SECONDS = 0.2; @@ -13,9 +14,9 @@ const RouteTransition: FC = (props) => { return ( { return ( diff --git a/src/app/ui/AppBreadcrumbs/AccountBreadcrumbs.tsx b/src/app/ui/AppBreadcrumbs/AccountBreadcrumbs.tsx index fb386ec..8a7952d 100644 --- a/src/app/ui/AppBreadcrumbs/AccountBreadcrumbs.tsx +++ b/src/app/ui/AppBreadcrumbs/AccountBreadcrumbs.tsx @@ -1,6 +1,9 @@ import type { FC } from "react"; + import { useTranslation } from "react-i18next"; + import { useBrokerageAccounts } from "@/entities/brokerage-account"; + import { BreadcrumbTrail } from "./BreadcrumbTrail"; type AccountBreadcrumbsProps = { @@ -12,11 +15,11 @@ const AccountBreadcrumbs: FC = (props) => { const { accountId, section } = props; const { data: accounts } = useBrokerageAccounts(); const { t } = useTranslation(); - const accountName = - accountId === "all" + const accountName + = accountId === "all" ? t("accounts.all") - : (accounts?.find((account) => account.id === accountId)?.name ?? - t("breadcrumbs.accountFallback")); + : (accounts?.find(account => account.id === accountId)?.name + ?? t("breadcrumbs.accountFallback")); const accountPath = `/accounts/${accountId}`; const items = [ { label: t("breadcrumbs.accounts"), to: "/accounts" }, diff --git a/src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx b/src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx index 6d7abd4..ef30107 100644 --- a/src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx +++ b/src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx @@ -1,7 +1,9 @@ -import { Select } from "@mantine/core"; import type { FC } from "react"; + +import { Select } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router"; + import { useBrokerageAccounts } from "@/entities/brokerage-account"; type AccountScopeSwitchProps = { accountId: string; section?: string }; @@ -13,31 +15,31 @@ const AccountScopeSwitch: FC = (props) => { const navigate = useNavigate(); const { search } = useLocation(); const data = [ - { value: "all", label: t("accounts.all") }, - ...(accounts ?? []).map((account) => ({ - value: account.id, + { label: t("accounts.all"), value: "all" }, + ...(accounts ?? []).map(account => ({ label: account.name, + value: account.id, })), ]; - if (!data.some((item) => item.value === accountId)) - data.push({ value: accountId, label: t("breadcrumbs.accountFallback") }); + if (data.every(item => item.value !== accountId)) + data.push({ label: t("breadcrumbs.accountFallback"), value: accountId }); return ( field.onChange(value ?? "")} - label={t("bank.paymentSystem")} - placeholder={t("bank.paymentSystemUnknown")} clearable - data={["mir", "visa", "mastercard"].map((value) => ({ - value, - label: t(`bank.paymentSystems.${value}`), - }))} comboboxProps={{ withinPortal: false }} + data={["mir", "visa", "mastercard"].map(value => ({ + label: t(`bank.paymentSystems.${value}`), + value, + }))} error={errors.paymentSystem?.message} + label={t("bank.paymentSystem")} + onChange={value => field.onChange(value ?? "")} + placeholder={t("bank.paymentSystemUnknown")} + value={field.value || /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null} /> )} /> @@ -71,37 +73,35 @@ const BankCardFields: FC = (props) => { render={({ field }) => ( + dateParser={value => bankDateSchema.safeParse(bankDateInputValue(value)).success ? bankDateInputValue(value) - : null - } + : /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null} + error={errors.openingDate?.message} fixOnBlur={false} - onInput={(event) => - field.onChange(bankDateInputValue(event.currentTarget.value)) - } - onChange={(value) => field.onChange(value ?? "")} + label={t("bank.openingDate")} + maxDate={bankToday()} onBlur={(event) => { field.onChange(bankDateInputValue(event.currentTarget.value)); field.onBlur(); }} - maxDate={bankToday()} + onChange={value => field.onChange(value ?? "")} + onInput={event => + field.onChange(bankDateInputValue(event.currentTarget.value))} popoverProps={{ - withinPortal: false, middlewares: { shift: { crossAxis: true } }, + withinPortal: false, }} readOnly={props.archived} - error={errors.openingDate?.message} + required + value={ + (bankDateSchema.safeParse(field.value).success) ? (field.value) : undefined + } + valueFormat="DD.MM.YYYY" /> )} /> - + {t("bank.openingHint")} {props.archived && {t("bank.archiveHint")}} diff --git a/src/entities/bank-card/ui/BankFeedback.tsx b/src/entities/bank-card/ui/BankFeedback.tsx index 0b2ef22..d252712 100644 --- a/src/entities/bank-card/ui/BankFeedback.tsx +++ b/src/entities/bank-card/ui/BankFeedback.tsx @@ -1,13 +1,15 @@ -import { Alert, Button, Group, Text } from "@mantine/core"; import type { FC } from "react"; + +import { Alert, Button, Group, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; + import { apiErrorCode } from "@/shared/api"; type BankFeedbackProps = { - error: unknown; - retry?: () => void; cached?: boolean; + error: unknown; loadingError?: boolean; + retry?: () => void; }; const BankFeedback: FC = (props) => { const { t } = useTranslation(); @@ -25,7 +27,7 @@ const BankFeedback: FC = (props) => { })} {props.retry && ( - )} diff --git a/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.stories.tsx b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.stories.tsx index 377c320..eb844d6 100644 --- a/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.stories.tsx +++ b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.stories.tsx @@ -1,13 +1,15 @@ -import { Stack } from "@mantine/core"; import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Stack } from "@mantine/core"; import { EntryCard } from "@wealthledger/ui"; + import { BankOperationIcon } from "./BankOperationIcon"; const meta = { - title: "Product/BankCards/OperationIcons", + args: { categoryName: "Продукты", kind: "expense" }, component: BankOperationIcon, tags: ["autodocs"], - args: { kind: "expense", categoryName: "Продукты" }, + title: "Product/BankCards/OperationIcons", } satisfies Meta; export default meta; type Story = StoryObj; @@ -40,28 +42,28 @@ export const Categories: Story = { "Хостинг и ИИ-сервисы", "Цифровые покупки", "Моя категория", - ].map((name) => ( + ].map(name => ( } + icon={} + initial="Р" + key={name} + metadata={`12.09.2026 · ${name}`} + title="Расход" + tone="negative" value="−1 250,00 ₽" valueLabel="Расход" - tone="negative" /> ))} } + initial="П" + metadata="12.09.2026" + title="Пополнение" + tone="positive" value="+15 000,00 ₽" valueLabel="Пополнение" - tone="positive" /> ), diff --git a/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.test.tsx b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.test.tsx index 2fac74f..5084795 100644 --- a/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.test.tsx +++ b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.test.tsx @@ -1,36 +1,40 @@ import { render } from "@testing-library/react"; import { expect, it } from "vitest"; + import { BankOperationIcon } from "./BankOperationIcon"; it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => { const { container, rerender } = render( - , + , ); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. const path = () => container.querySelector("path")?.getAttribute("d"); const housing = path(); - rerender(); + rerender(); expect(path()).toBe(housing); - rerender(); + rerender(); const fallback = path(); expect(fallback).not.toBe(housing); - for (const categoryName of ["Моя категория", "constructor", null]) { - rerender(); + for (const categoryName of ["Моя категория", "constructor", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null]) { + rerender(); expect(path()).toBe(fallback); } const paths = [fallback, housing]; for (const kind of ["topup", "transfer", "refund"] as const) { - rerender(); + rerender(); expect(path()).toBeTruthy(); paths.push(path()); } expect(new Set(paths).size).toBe(5); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true"); }); it("provides a distinct icon for every existing named category", () => { const { container, rerender } = render( - , + , ); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. const paths = new Set([container.querySelector("path")?.getAttribute("d")]); for (const categoryName of [ "Алкоголь", @@ -57,7 +61,8 @@ it("provides a distinct icon for every existing named category", () => { "Хостинг и ИИ-сервисы", "Цифровые покупки", ]) { - rerender(); + rerender(); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. const path = container.querySelector("path")?.getAttribute("d"); expect(path).toBeTruthy(); expect(paths.has(path), categoryName).toBe(false); diff --git a/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx index ef02c96..5010587 100644 --- a/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx +++ b/src/entities/bank-card/ui/BankOperationIcon/BankOperationIcon.tsx @@ -1,34 +1,36 @@ import type { FC } from "react"; + import { bindIconAnimation } from "@/shared/lib"; + import type { BankOperation } from "../../api/queries"; import { bankOperationAppearance } from "../../model/operation-appearance"; import classes from "./BankOperationIcon.module.css"; type BankOperationIconProps = { + categoryName?: null | string; kind: BankOperation["kind"]; - categoryName?: string | null; }; const BankOperationIcon: FC = (props) => { const icon = bankOperationAppearance(props.kind, props.categoryName); return ( ); diff --git a/src/entities/bank-card/ui/BankPeriodPicker.tsx b/src/entities/bank-card/ui/BankPeriodPicker.tsx index 288e605..8bb1403 100644 --- a/src/entities/bank-card/ui/BankPeriodPicker.tsx +++ b/src/entities/bank-card/ui/BankPeriodPicker.tsx @@ -3,34 +3,34 @@ import { type FC, useState } from "react"; import { useTranslation } from "react-i18next"; type BankPeriodPickerProps = { + onChange: (value: [null | string, null | string]) => void; period: { from: string; to: string }; - onChange: (value: [string | null, string | null]) => void; }; const BankPeriodPicker: FC = (props) => { - const { period, onChange } = props; + const { onChange, period } = props; const { t } = useTranslation(); const scope = `${period.from}-${period.to}`; const [draft, setDraft] = useState<{ scope: string; - value: [string | null, string | null]; + value: [null | string, null | string]; }>(); - const value: [string | null, string | null] = - draft?.scope === scope ? draft.value : [period.from, period.to]; + const value: [null | string, null | string] + = draft?.scope === scope ? draft.value : [period.from, period.to]; return ( { setDraft({ scope, value: next }); onChange(next); }} popoverProps={{ - returnFocus: true, middlewares: { shift: { crossAxis: true } }, + returnFocus: true, }} + type="range" + value={value} + valueFormat="DD.MM.YYYY" /> ); }; diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx index d41279d..518e6b4 100644 --- a/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx +++ b/src/entities/bank-card/ui/BankSummary/BankSummary.stories.tsx @@ -1,11 +1,54 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; + import { BankSummaryView } from "./BankSummary.view"; const money = (value: string) => ({ currency: "RUB", value }); -const meta = { - title: "Product/BankCards/Summary", +const meta = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ +{ + args: { + data: { + cardId: null, + categories: [ + { + categoryId: "food", + categoryName: "Продукты", + expenses: money("3500"), + net: money("2500"), + refunds: money("1000"), + }, + { + categoryId: "cafe", + categoryName: "Кафе", + expenses: money("2200"), + net: money("2200"), + refunds: money("0"), + }, + { + categoryId: "taxi", + categoryName: "Такси", + expenses: money("1800"), + net: money("1800"), + refunds: money("0"), + }, + { + categoryId: "health", + categoryName: "Здоровье", + expenses: money("1000"), + net: money("1000"), + refunds: money("0"), + }, + ], + expenses: money("8500"), + month: "2026-09", + net: money("7500"), + refunds: money("1000"), + }, + error: false, + fetching: false, + loading: false, + retry: () => {}, + }, component: BankSummaryView, - tags: ["autodocs"], parameters: { docs: { description: { @@ -14,118 +57,84 @@ const meta = { }, }, }, - args: { - loading: false, - error: false, - fetching: false, - retry: () => {}, - data: { - month: "2026-09", - cardId: null, - expenses: money("8500"), - refunds: money("1000"), - net: money("7500"), - categories: [ - { - categoryId: "food", - categoryName: "Продукты", - expenses: money("3500"), - refunds: money("1000"), - net: money("2500"), - }, - { - categoryId: "cafe", - categoryName: "Кафе", - expenses: money("2200"), - refunds: money("0"), - net: money("2200"), - }, - { - categoryId: "taxi", - categoryName: "Такси", - expenses: money("1800"), - refunds: money("0"), - net: money("1800"), - }, - { - categoryId: "health", - categoryName: "Здоровье", - expenses: money("1000"), - refunds: money("0"), - net: money("1000"), - }, - ], - }, - }, -} satisfies Meta; + tags: ["autodocs"], + title: "Product/BankCards/Summary", +} +/* eslint-enable unicorn/no-null */) satisfies Meta; export default meta; type Story = StoryObj; export const Loaded: Story = { name: "Расходы по категориям" }; -export const RefundMonth: Story = { - name: "Возврат покупки прошлого месяца", - args: { - data: { - month: "2026-09", - cardId: null, - expenses: money("0"), - refunds: money("600"), - net: money("-600"), - categories: [ - { - categoryId: "shopping", - categoryName: +export const RefundMonth: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + args: { + data: { + cardId: null, + categories: [ + { + categoryId: "shopping", + categoryName: "Покупки с длинным пользовательским названием категории", - expenses: money("0"), - refunds: money("600"), - net: money("-600"), - }, - ], + expenses: money("0"), + net: money("-600"), + refunds: money("600"), + }, + ], + expenses: money("0"), + month: "2026-09", + net: money("-600"), + refunds: money("600"), + }, }, - }, -}; -export const Empty: Story = { - name: "Известные нули", - args: { - data: { - month: "2026-09", - cardId: null, - expenses: money("0"), - refunds: money("0"), - net: money("0"), - categories: [], + name: "Возврат покупки прошлого месяца", + } +/* eslint-enable unicorn/no-null */); +export const Empty: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + args: { + data: { + cardId: null, + categories: [], + expenses: money("0"), + month: "2026-09", + net: money("0"), + refunds: money("0"), + }, }, - }, -}; + name: "Известные нули", + } +/* eslint-enable unicorn/no-null */); export const Loading: Story = { - name: "Начальная загрузка", args: { data: undefined, loading: true }, + name: "Начальная загрузка", }; export const Failed: Story = { - name: "Ошибка без данных", args: { data: undefined, error: true }, + name: "Ошибка без данных", }; export const CachedError: Story = { - name: "Сохранённые суммы при ошибке обновления", args: { error: true }, + name: "Сохранённые суммы при ошибке обновления", }; -export const LongAmounts: Story = { - name: "Точный итог длиннее предела отдельного ввода", - args: { - data: { - month: "2026-09", - cardId: null, - expenses: money("184467440737095516.14"), - refunds: money("0"), - net: money("184467440737095516.14"), - categories: [ - { - categoryId: "shopping", - categoryName: "Покупки", - expenses: money("184467440737095516.14"), - refunds: money("0"), - net: money("184467440737095516.14"), - }, - ], +export const LongAmounts: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + args: { + data: { + cardId: null, + categories: [ + { + categoryId: "shopping", + categoryName: "Покупки", + expenses: money("184467440737095516.14"), + net: money("184467440737095516.14"), + refunds: money("0"), + }, + ], + expenses: money("184467440737095516.14"), + month: "2026-09", + net: money("184467440737095516.14"), + refunds: money("0"), + }, }, - }, -}; + name: "Точный итог длиннее предела отдельного ввода", + } +/* eslint-enable unicorn/no-null */); diff --git a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx index 6208490..86df7ca 100644 --- a/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx +++ b/src/entities/bank-card/ui/BankSummary/BankSummary.view.tsx @@ -1,24 +1,78 @@ +import type { CSSProperties, FC } from "react"; + import { Button, Group, Skeleton, Stack, Text, Title } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; -import type { CSSProperties, FC } from "react"; import { useTranslation } from "react-i18next"; + import { formatMoney } from "@/shared/lib"; + import type { BankSummary } from "../../api/queries"; + import { BankOperationIcon } from "../BankOperationIcon/BankOperationIcon"; import classes from "./BankSummary.module.css"; +const CATEGORY_COLUMNS = 2; + type BankSummaryViewProps = { + all?: boolean; data?: BankSummary; - loading: boolean; error: boolean; fetching: boolean; + loading: boolean; retry: () => void; - all?: boolean; }; const BankSummaryView: FC = (props) => { const { t } = useTranslation(); + + function renderCategories() { + if (props.data) { + if (props.data.categories.length > 0) { + return ( +
+ {props.data.categories.map(category => ( +
+
+ + {category.categoryName} +
+
+ {formatMoney(category.net, { withZeroCents: true })} +
+
+ ))} +
+ ); + } + return ( + + {t("bank.noExpenses")} + + ); + } + if (props.loading) { + return ( + + + + + + + + ); + } + } return ( - +
{t(props.all ? "bank.allSummary" : "bank.summary")} @@ -29,49 +83,11 @@ const BankSummaryView: FC<BankSummaryViewProps> = (props) => { </Text> )} </div> - {props.data ? ( - props.data.categories.length ? ( - <dl - className={classes.categories} - style={ - { - "--category-rows": Math.ceil(props.data.categories.length / 2), - } as CSSProperties - } - > - {props.data.categories.map((category) => ( - <div className={classes.category} key={category.categoryId}> - <dt className={classes.label}> - <BankOperationIcon - kind="expense" - categoryName={category.categoryName} - /> - <span>{category.categoryName}</span> - </dt> - <dd className={classes.amount}> - {formatMoney(category.net, { withZeroCents: true })} - </dd> - </div> - ))} - </dl> - ) : ( - <Text size="sm" c="dimmed"> - {t("bank.noExpenses")} - </Text> - ) - ) : props.loading ? ( - <LoadingSkeleton label={t("bank.loading")}> - <Stack gap="sm"> - <Skeleton h={32} w="35%" /> - <Skeleton h={40} /> - <Skeleton h={40} /> - </Stack> - </LoadingSkeleton> - ) : null} + {renderCategories()} {props.error && ( <Group role="alert"> <Text>{t(props.data ? "bank.refreshError" : "bank.error")}</Text> - <Button variant="default" onClick={props.retry}> + <Button onClick={props.retry} variant="default"> {t("bank.retry")} </Button> </Group> diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx index d2fc5f8..dde6ec3 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.test.tsx @@ -7,36 +7,36 @@ import { screen, waitFor, } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { describe, expect, it } from "vitest"; + import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; + import { CategoryManager } from "./CategoryManager"; describe("CategoryManager", () => { it("ignores a late reload for A while editing B and submits B's draft and version", async () => { configureAuthClient({ accessToken: () => "test", - refresh: async () => false, clear: () => {}, + refresh: () => Promise.resolve(false), }); const categories = [ - { id: "a", name: "Первая", isDefault: false, version: 1 }, - { id: "b", name: "Вторая", isDefault: false, version: 4 }, + { id: "a", isDefault: false, name: "Первая", version: 1 }, + { id: "b", isDefault: false, name: "Вторая", version: 4 }, ]; let reads = 0; - let release = () => {}; - const reloaded = new Promise<void>((resolve) => { - release = resolve; - }); - const patches: { id: unknown; body: unknown }[] = []; + + const { promise: reloaded, resolve: release } = Promise.withResolvers<void>(); + const patches: { body: unknown; id: unknown }[] = []; server.use( http.get("*/api/v1/bank-cards/categories", async () => { reads++; if (reads === 3) await reloaded; return HttpResponse.json( - categories.map((category) => + categories.map(category => category.id === "a" && reads > 1 ? { ...category, @@ -49,8 +49,8 @@ describe("CategoryManager", () => { }), http.patch( "*/api/v1/bank-cards/categories/:id", - async ({ request, params }) => { - patches.push({ id: params.id, body: await request.json() }); + async ({ params, request }) => { + patches.push({ body: await request.json(), id: params.id }); return patches.length === 1 ? HttpResponse.json( { code: "BANK_VERSION_CONFLICT" }, @@ -67,9 +67,8 @@ describe("CategoryManager", () => { </MantineProvider> </QueryClientProvider>, ); - fireEvent.click( - (await screen.findAllByRole("button", { name: "Переименовать" }))[0], - ); + const renameButtons = await screen.findAllByRole("button", { name: "Переименовать" }); + fireEvent.click(renameButtons[0]); fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); fireEvent.click( await screen.findByRole("button", { name: "Перечитать запись" }), @@ -99,8 +98,8 @@ describe("CategoryManager", () => { fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(patches).toEqual([ - { id: "a", body: { name: "Первая", version: 1 } }, - { id: "b", body: { name: "Новая вторая", version: 4 } }, + { body: { name: "Первая", version: 1 }, id: "a" }, + { body: { name: "Новая вторая", version: 4 }, id: "b" }, ]), ); }); @@ -110,15 +109,15 @@ describe("CategoryManager", () => { async (action) => { configureAuthClient({ accessToken: () => "test", - refresh: async () => false, clear: () => {}, + refresh: () => Promise.resolve(false), }); let version = 1; let writes = 0; server.use( http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([ - { id: "a", name: "Первая", isDefault: false, version }, + { id: "a", isDefault: false, name: "Первая", version }, ]), ), http.patch("*/api/v1/bank-cards/categories/a", () => { @@ -161,13 +160,13 @@ describe("CategoryManager", () => { it("rejects duplicate case-insensitive and too long names then renames default identity", async () => { configureAuthClient({ accessToken: () => "test", - refresh: async () => false, clear: () => {}, + refresh: () => Promise.resolve(false), }); let category = { id: "default", - name: "Другое", isDefault: true, + name: "Другое", version: 1, }; let creates = 0; diff --git a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx index 10db669..ba2ab23 100644 --- a/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx +++ b/src/entities/bank-card/ui/CategoryManager/CategoryManager.tsx @@ -17,7 +17,9 @@ import { } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; + import { validationMessage } from "@/shared/i18n"; + import { useBankMutations } from "../../api/mutations"; import { type ExpenseCategory, useBankCategories } from "../../api/queries"; import { BankFeedback } from "../BankFeedback"; @@ -60,44 +62,40 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => { const categories = useBankCategories(); const mutation = useBankMutations(); const [editing, setEditing] = useState<ExpenseCategory>(); - const editSession = useRef(0); + const editSessionRef = useRef(0); const [error, setError] = useState<unknown>(); const [latest, setLatest] = useState<ExpenseCategory>(); - const methods = useForm<CategoryValues>({ - resolver: zodResolver( - z.object({ - name: z - .string() - .trim() - .refine( - (value) => - value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH, - { error: validationMessage("bank.validation.category") }, - ) - .refine( - (value) => - !categories.data?.some( - (c) => - c.id !== editing?.id && - c.name.toLocaleLowerCase("ru-RU") === - value.toLocaleLowerCase("ru-RU"), - ), - { error: validationMessage("bank.validation.duplicate") }, + const schema = z.object({ + name: z + .string() + .trim() + .refine( + value => + value.length > 0 && value.length <= MAX_CATEGORY_NAME_LENGTH, + { error: validationMessage("bank.validation.category") }, + ) + .refine( + value => + !categories.data?.some( + c => + c.id !== editing?.id + && c.name.toLocaleLowerCase("ru-RU") + === value.toLocaleLowerCase("ru-RU"), ), - }), - ), - defaultValues: { name: "" }, + { error: validationMessage("bank.validation.duplicate") }, + ), }); - const pending = !opened || methods.formState.isSubmitting; + const methods = useForm<CategoryValues>({ defaultValues: { name: "" }, resolver: zodResolver(schema) }); + const isPending = !opened || methods.formState.isSubmitting; return ( <Modal - opened={opened} + closeButtonProps={{ "aria-label": t("bank.close"), "disabled": isPending }} + closeOnClickOutside={!isPending} + closeOnEscape={!isPending} + onClose={() => !isPending && setOpened(false)} onExitTransitionEnd={props.onClose} - onClose={() => !pending && setOpened(false)} + opened={opened} title={t("bank.categories")} - closeOnClickOutside={!pending} - closeOnEscape={!pending} - closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} > <FormProvider {...methods}> <form @@ -107,8 +105,8 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => { try { if (editing) await mutation.updateCategory.mutateAsync({ - id: editing.id, body: { name: values.name, version: editing.version }, + id: editing.id, }); else { const result = await mutation.createCategory.mutateAsync({ @@ -116,24 +114,25 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => { }); if (result) props.onCreated?.(result.id); } - editSession.current++; + editSessionRef.current++; setEditing(undefined); setLatest(undefined); methods.reset({ name: "" }); - } catch (failure) { - setError(failure); + } + catch (error_) { + setError(error_); } })(event); }} > - <fieldset disabled={pending} className={classes.fieldset}> + <fieldset className={classes.fieldset} disabled={isPending}> <Stack> - {categories.data?.map((category) => ( + {categories.data?.map(category => ( <Group justify="space-between" key={category.id}> <Group gap="xs"> <BankOperationIcon - kind="expense" categoryName={category.name} + kind="expense" /> <Text>{category.name}</Text> {category.isDefault && ( @@ -141,14 +140,14 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => { )} </Group> <Button - variant="subtle" onClick={() => { - editSession.current++; + editSessionRef.current++; setEditing(category); methods.reset({ name: category.name }); setError(undefined); setLatest(undefined); }} + variant="subtle" > {t("bank.rename")} </Button> @@ -159,35 +158,35 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => { )} {categories.isError && ( <BankFeedback - loadingError - error={categories.error} cached={Boolean(categories.data)} + error={categories.error} + loadingError retry={() => void categories.refetch()} /> )} <CategoryFields /> - {error ? ( + {!!(error) && (( <> <BankFeedback error={error} /> {editing && ( <Button - variant="default" onClick={async () => { - const session = editSession.current; + const session = editSessionRef.current; const response = await categories.refetch(); - if (session !== editSession.current) return; + if (session !== editSessionRef.current) return; if (response.error) setError(response.error); else setLatest( - response.data?.find((c) => c.id === editing.id), + response.data?.find(c => c.id === editing.id), ); }} + variant="default" > {t("bank.reloadRecord")} </Button> )} </> - ) : null} + ))} {latest && latest.id === editing?.id && ( <> <Text>{t("bank.versionHint", { details: latest.name })}</Text> @@ -203,24 +202,24 @@ const CategoryManager: FC<CategoryManagerProps> = (props) => { </> )} <Group> - <Button type="submit" loading={pending}> + <Button loading={isPending} type="submit"> {t(editing ? "bank.save" : "bank.addCategory")} </Button> {editing && ( <Button - variant="default" onClick={() => { - editSession.current++; + editSessionRef.current++; setEditing(undefined); setLatest(undefined); methods.reset({ name: "" }); setError(undefined); }} + variant="default" > {t("bank.cancel")} </Button> )} - <Button variant="default" onClick={() => setOpened(false)}> + <Button onClick={() => setOpened(false)} variant="default"> {t("bank.close")} </Button> </Group> diff --git a/src/entities/bond/api/queries.ts b/src/entities/bond/api/queries.ts index d69b753..61c0b27 100644 --- a/src/entities/bond/api/queries.ts +++ b/src/entities/bond/api/queries.ts @@ -1,5 +1,7 @@ import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; +const MIN_BOND_SEARCH_LENGTH = 2; + type BondPeriod = NonNullable< NonNullable< paths["/api/v1/bonds/{uid}/candles"]["get"]["parameters"]["query"] @@ -8,15 +10,6 @@ type BondPeriod = NonNullable< const BOND_STALE_TIME_MS = 30_000; -function useBondSearch(query: string) { - return useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/bonds", { - params: { query: { query } }, - }), - enabled: query.length >= 2, - staleTime: BOND_STALE_TIME_MS, - }); -} function bondQueryOptions(uid: string) { return { ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", { @@ -25,10 +18,9 @@ function bondQueryOptions(uid: string) { staleTime: BOND_STALE_TIME_MS, }; } - function useBond(identifier: string) { - const isUid = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + const isUid + = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( identifier, ); @@ -45,24 +37,7 @@ function useBond(identifier: string) { }); return isUid ? byUid : byTicker; } -function useBondMetrics(uid: string) { - return useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", { - params: { path: { uid } }, - }), - enabled: Boolean(uid), - staleTime: BOND_STALE_TIME_MS, - }); -} -function useBondQuote(uid: string) { - return useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", { - params: { path: { uid } }, - }), - enabled: Boolean(uid), - staleTime: BOND_STALE_TIME_MS, - }); -} + function useBondCandles(uid: string, period: BondPeriod) { return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", { @@ -72,16 +47,6 @@ function useBondCandles(uid: string, period: BondPeriod) { staleTime: BOND_STALE_TIME_MS, }); } -function useBondPosition(uid: string) { - return useResourceQuery({ - meta: { accessGroup: "brokerage" }, - ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", { - params: { path: { uid } }, - }), - enabled: Boolean(uid), - staleTime: BOND_STALE_TIME_MS, - }); -} function useBondCoupons(uid: string) { return useResourceQuery({ meta: { accessGroup: "brokerage" }, @@ -92,9 +57,46 @@ function useBondCoupons(uid: string) { staleTime: BOND_STALE_TIME_MS, }); } +function useBondMetrics(uid: string) { + return useResourceQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: BOND_STALE_TIME_MS, + }); +} +function useBondPosition(uid: string) { + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, + ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: BOND_STALE_TIME_MS, + }); +} +function useBondQuote(uid: string) { + return useResourceQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: BOND_STALE_TIME_MS, + }); +} +function useBondSearch(query: string) { + return useResourceQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bonds", { + params: { query: { query } }, + }), + enabled: query.length >= MIN_BOND_SEARCH_LENGTH, + staleTime: BOND_STALE_TIME_MS, + }); +} -export { - type BondPeriod, +export { type BondPeriod, + MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, useBondCoupons, diff --git a/src/entities/bond/index.ts b/src/entities/bond/index.ts index f03ebed..1f0a2a2 100644 --- a/src/entities/bond/index.ts +++ b/src/entities/bond/index.ts @@ -1,5 +1,5 @@ -export { - type BondPeriod, +export { type BondPeriod, + MIN_BOND_SEARCH_LENGTH, useBond, useBondCandles, useBondCoupons, diff --git a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx index fea398b..aaafda3 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.test.tsx +++ b/src/entities/broker-operation/api/useBrokerageOperations.test.tsx @@ -1,9 +1,11 @@ import { renderHook, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; + import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; + import { - brokerageOperationsQueryParams, + brokerageOperationsQueryParams as brokerageOperationsQueryParameters, useBrokerageOperations, } from "./useBrokerageOperations"; @@ -11,11 +13,11 @@ describe("useBrokerageOperations", () => { it("normalizes categories, preserves the cursor and loads a page", async () => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); expect( - brokerageOperationsQueryParams({ + brokerageOperationsQueryParameters({ categories: ["tax", "income"], cursor: "next", }), diff --git a/src/entities/broker-operation/api/useBrokerageOperations.ts b/src/entities/broker-operation/api/useBrokerageOperations.ts index 5fc15ca..80eaf39 100644 --- a/src/entities/broker-operation/api/useBrokerageOperations.ts +++ b/src/entities/broker-operation/api/useBrokerageOperations.ts @@ -1,16 +1,16 @@ import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; -type OperationsEndpoint = - paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"]; - type BrokerageOperation = NonNullable< OperationsEndpoint["responses"][200]["content"]["application/json"]["data"] >["items"][number]; + type BrokerageOperationsQuery = Omit< NonNullable<OperationsEndpoint["parameters"]["query"]>, "sync" >; type OperationCategory = BrokerageOperation["category"]; +type OperationsEndpoint + = paths["/api/v1/brokerage-accounts/{accountId}/operations"]["get"]; const operationCategories = [ "trade", @@ -21,19 +21,6 @@ const operationCategories = [ "other", ] as const satisfies readonly OperationCategory[]; -function brokerageOperationsQueryParams(query: BrokerageOperationsQuery) { - return { - ...(query.instrumentUid ? { instrumentUid: query.instrumentUid } : {}), - ...(query.from ? { from: query.from } : {}), - ...(query.to ? { to: query.to } : {}), - ...(query.categories?.length - ? { categories: [...query.categories].sort() } - : {}), - ...(query.cursor ? { cursor: query.cursor } : {}), - ...(query.limit ? { limit: query.limit } : {}), - }; -} - function brokerageOperationsQueryOptions( accountId: string | undefined, query: BrokerageOperationsQuery = {}, @@ -44,12 +31,23 @@ function brokerageOperationsQueryOptions( { params: { path: { accountId: accountId ?? "" }, - query: brokerageOperationsQueryParams(query), + query: brokerageOperationsQueryParameters(query), }, }, ); } +function brokerageOperationsQueryParameters(query: BrokerageOperationsQuery) { + return { + ...(query.instrumentUid && { instrumentUid: query.instrumentUid }), + ...(query.from && { from: query.from }), + ...(query.to && { to: query.to }), + ...(query.categories?.length && { categories: query.categories.toSorted((left, right) => left.localeCompare(right)) }), + ...(query.cursor && { cursor: query.cursor }), + ...(query.limit && { limit: query.limit }), + }; +} + function useBrokerageOperations( accountId: string | undefined, query: BrokerageOperationsQuery = {}, @@ -65,8 +63,8 @@ function useBrokerageOperations( export { type BrokerageOperation, brokerageOperationsQueryOptions, - brokerageOperationsQueryParams, - type OperationCategory, + brokerageOperationsQueryParameters as brokerageOperationsQueryParams, operationCategories, + type OperationCategory, useBrokerageOperations, }; diff --git a/src/entities/broker-operation/index.ts b/src/entities/broker-operation/index.ts index f293b53..fda82ba 100644 --- a/src/entities/broker-operation/index.ts +++ b/src/entities/broker-operation/index.ts @@ -1,8 +1,8 @@ export { type BrokerageOperation, brokerageOperationsQueryOptions, - type OperationCategory, operationCategories, + type OperationCategory, useBrokerageOperations, } from "./api/useBrokerageOperations"; export { LedgerEntryRow } from "./ui/LedgerEntryRow"; diff --git a/src/entities/broker-operation/model/display.ts b/src/entities/broker-operation/model/display.ts index 13381ac..494613a 100644 --- a/src/entities/broker-operation/model/display.ts +++ b/src/entities/broker-operation/model/display.ts @@ -1,10 +1,11 @@ import { formatMoney } from "@/shared/lib"; + import type { BrokerageOperation } from "../api/useBrokerageOperations"; type PaymentPresentation = { amount: string; label: string; - tone: "positive" | "negative" | "neutral"; + tone: "negative" | "neutral" | "positive"; }; function entryLabelKey(operation: BrokerageOperation): string { @@ -15,6 +16,44 @@ function entryLabelKey(operation: BrokerageOperation): string { return `ledger.category.${operation.category}`; } +function isZeroDecimal(value: string): boolean { + return /^-?0$|^-?0\.0+$/.test(value); +} + +function operationLabelKey(type: string): string { + if ( + [ + "OPERATION_TYPE_BUY", + "OPERATION_TYPE_BUY_CARD", + "OPERATION_TYPE_BUY_MARGIN", + ].includes(type) + ) + return "ledger.operationType.buy"; + if ( + [ + "OPERATION_TYPE_SELL", + "OPERATION_TYPE_SELL_CARD", + "OPERATION_TYPE_SELL_MARGIN", + ].includes(type) + ) + return "ledger.operationType.sell"; + if ( + [ + "OPERATION_TYPE_DIV_EXT", + "OPERATION_TYPE_DIVIDEND", + "OPERATION_TYPE_DIVIDEND_TRANSFER", + ].includes(type) + ) + return "ledger.operationType.dividend"; + if (type.includes("TAX")) return "ledger.operationType.tax"; + if (type.includes("FEE")) return "ledger.operationType.fee"; + if (type === "OPERATION_TYPE_INPUT_SECURITIES") + return "ledger.operationType.input"; + if (type === "OPERATION_TYPE_OUTPUT_SECURITIES") + return "ledger.operationType.output"; + return type.startsWith("OPERATION_TYPE_TRANS_") ? "ledger.operationType.transfer" : "ledger.operationType.other"; +} + function paymentPresentation( operation: BrokerageOperation, ): PaymentPresentation { @@ -46,44 +85,4 @@ function paymentPresentation( }; } -function isZeroDecimal(value: string): boolean { - return /^-?0(?:\.0+)?$/.test(value); -} - -function operationLabelKey(type: string): string { - if ( - [ - "OPERATION_TYPE_BUY", - "OPERATION_TYPE_BUY_CARD", - "OPERATION_TYPE_BUY_MARGIN", - ].includes(type) - ) - return "ledger.operationType.buy"; - if ( - [ - "OPERATION_TYPE_SELL", - "OPERATION_TYPE_SELL_CARD", - "OPERATION_TYPE_SELL_MARGIN", - ].includes(type) - ) - return "ledger.operationType.sell"; - if ( - [ - "OPERATION_TYPE_DIVIDEND", - "OPERATION_TYPE_DIV_EXT", - "OPERATION_TYPE_DIVIDEND_TRANSFER", - ].includes(type) - ) - return "ledger.operationType.dividend"; - if (type.includes("TAX")) return "ledger.operationType.tax"; - if (type.includes("FEE")) return "ledger.operationType.fee"; - if (type === "OPERATION_TYPE_INPUT_SECURITIES") - return "ledger.operationType.input"; - if (type === "OPERATION_TYPE_OUTPUT_SECURITIES") - return "ledger.operationType.output"; - if (type.startsWith("OPERATION_TYPE_TRANS_")) - return "ledger.operationType.transfer"; - return "ledger.operationType.other"; -} - export { operationLabelKey, paymentPresentation }; diff --git a/src/entities/broker-operation/ui/LedgerEntryRow.tsx b/src/entities/broker-operation/ui/LedgerEntryRow.tsx index 8aaab15..c9efaf3 100644 --- a/src/entities/broker-operation/ui/LedgerEntryRow.tsx +++ b/src/entities/broker-operation/ui/LedgerEntryRow.tsx @@ -1,15 +1,19 @@ +import type { FC } from "react"; + import { Anchor, Group, Text } from "@mantine/core"; import { EntryCard } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; + import { formatDecimal, formatMoney, formatSnapshotTime, instrumentLogoUrl, } from "@/shared/lib"; + import type { BrokerageOperation } from "../api/useBrokerageOperations"; + import { operationLabelKey, paymentPresentation } from "../model/display"; type LedgerEntryRowProps = { @@ -20,55 +24,57 @@ type LedgerEntryRowProps = { const LedgerEntryRow: FC<LedgerEntryRowProps> = (props) => { const { operation } = props; const { t } = useTranslation(); - const instrument = - operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument"); + const instrument + = operation.ticker ?? operation.instrumentType ?? t("ledger.noInstrument"); const payment = paymentPresentation(operation); const metadata = [ t("ledger.occurredAt", { date: formatSnapshotTime(operation.occurredAt) }), instrument, - props.withDetails ? null : operation.account?.name, + props.withDetails ? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null : operation.account?.name, ] .filter(Boolean) .join(" · "); return ( <EntryCard - title={operation.description || t(operationLabelKey(operation.type))} - metadata={metadata} details={ - props.withDetails ? ( - <> - <Group gap="sm"> - {operation.quantity != null ? ( - <Text size="xs" c="dimmed"> - {t("ledger.quantity", { - value: formatDecimal(operation.quantity), - })} - </Text> - ) : null} - {operation.price ? ( - <Text size="xs" c="dimmed"> - {t("ledger.price", { value: formatMoney(operation.price) })} - </Text> - ) : null} - </Group> - {operation.account ? ( - <Anchor - component={Link} - size="sm" - to={`/accounts/${encodeURIComponent(operation.account.id)}`} - > - {operation.account.name} - </Anchor> - ) : null} - </> - ) : undefined + (props.withDetails) + ? (( + <> + <Group gap="sm"> + {operation.quantity != undefined && (( + <Text c="dimmed" size="xs"> + {t("ledger.quantity", { + value: formatDecimal(operation.quantity), + })} + </Text> + ))} + {!!(operation.price) && (( + <Text c="dimmed" size="xs"> + {t("ledger.price", { value: formatMoney(operation.price) })} + </Text> + ))} + </Group> + {!!(operation.account) && (( + <Anchor + component={Link} + size="sm" + to={`/accounts/${encodeURIComponent(operation.account.id)}`} + > + {operation.account.name} + </Anchor> + ))} + </> + )) + : undefined } - initial={(instrument.trim()[0] ?? "?").toUpperCase()} imageSrc={instrumentLogoUrl(operation.logoName)} + initial={(instrument.trim()[0] ?? "?").toUpperCase()} + metadata={metadata} + title={operation.description || t(operationLabelKey(operation.type))} + tone={payment.tone} value={payment.amount} valueLabel={t(payment.label)} - tone={payment.tone} /> ); }; diff --git a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx index 795a5c7..ecb44b1 100644 --- a/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx +++ b/src/entities/brokerage-account/api/useBrokerageAccounts.test.tsx @@ -1,18 +1,20 @@ import { renderHook, waitFor } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; + import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; + import { useBrokerageAccounts } from "./useBrokerageAccounts"; describe("useBrokerageAccounts", () => { it("loads accounts through the authenticated query seam", async () => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); server.use( http.get("*/api/v1/brokerage-accounts", () => @@ -21,8 +23,8 @@ describe("useBrokerageAccounts", () => { { id: "account-1", name: "Основной", - type: "BROKERAGE", openedOn: "2024-08-13", + type: "BROKERAGE", }, ]), ), @@ -38,8 +40,8 @@ describe("useBrokerageAccounts", () => { { id: "account-1", name: "Основной", - type: "BROKERAGE", openedOn: "2024-08-13", + type: "BROKERAGE", }, ]); }); @@ -47,8 +49,8 @@ describe("useBrokerageAccounts", () => { it("keeps the current connection snapshot after a background refetch error", async () => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); let calls = 0; server.use( @@ -60,8 +62,8 @@ describe("useBrokerageAccounts", () => { { id: "account-1", name: "Основной", - type: "BROKERAGE", openedOn: "2024-08-13", + type: "BROKERAGE", }, ]), ) diff --git a/src/entities/brokerage-account/model/display.ts b/src/entities/brokerage-account/model/display.ts index 21982a3..7cf64ea 100644 --- a/src/entities/brokerage-account/model/display.ts +++ b/src/entities/brokerage-account/model/display.ts @@ -3,24 +3,26 @@ import type { useBrokerageAccounts, } from "../api/useBrokerageAccounts"; -type Money = NonNullable< - BrokerageAccount["portfolioSummary"] ->["totalsByCurrency"][number]; +const VISIBLE_CURRENCY_LIMIT = 2; type BrokerageAccountCard = { + account: BrokerageAccount; + positionCounts: null | { bonds: number; stocks: number }; source?: NonNullable< ReturnType<typeof useBrokerageAccounts>["meta"] >["dependencies"] extends (infer T)[] | undefined ? T : never; - account: BrokerageAccount; - positionCounts: { stocks: number; bonds: number } | null; summary: - | { state: "unavailable" } + | { remainingCurrencyCount: number; state: "available"; totals: Money[] } | { state: "empty" } - | { state: "available"; totals: Money[]; remainingCurrencyCount: number }; + | { state: "unavailable" }; }; +type Money = NonNullable< + BrokerageAccount["portfolioSummary"] +>["totalsByCurrency"][number]; + function accountTypeKey(type: BrokerageAccount["type"]) { return `accounts.types.${type}` as const; } @@ -33,34 +35,34 @@ function brokerageAccountCards( ): BrokerageAccountCard[] { return accounts.map((account) => { const source = dependencies.find( - (item) => + item => item.resourceKey === `portfolio/${encodeURIComponent(account.id)}`, ); const summary = account.portfolioSummary; if (!summary) return { - source, account, - positionCounts: null, + positionCounts: /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null, + source, summary: { state: "unavailable" }, }; const positionCounts = { - stocks: summary.stockPositionCount, bonds: summary.bondPositionCount, + stocks: summary.stockPositionCount, }; - if (!summary.totalsByCurrency.length) - return { source, account, positionCounts, summary: { state: "empty" } }; + if (summary.totalsByCurrency.length === 0) + return { account, positionCounts, source, summary: { state: "empty" } }; return { - source, account, positionCounts, + source, summary: { - state: "available", - totals: summary.totalsByCurrency.slice(0, 2), remainingCurrencyCount: Math.max( - summary.totalsByCurrency.length - 2, + summary.totalsByCurrency.length - VISIBLE_CURRENCY_LIMIT, 0, ), + state: "available", + totals: summary.totalsByCurrency.slice(0, VISIBLE_CURRENCY_LIMIT), }, }; }); diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx index 050a2c9..731a4db 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.stories.tsx @@ -1,34 +1,36 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; + import { MemoryRouter } from "react-router"; + import type { BrokerageAccount } from "../../api/useBrokerageAccounts"; + import { brokerageAccountCards } from "../../model/display"; import { BrokerageAccountList } from "./BrokerageAccountList"; const syntheticAccount = { id: "storybook-account", name: "Учебный брокерский счёт", - type: "BROKERAGE", openedOn: "2025-03-15", portfolioSummary: { - stockPositionCount: 5, bondPositionCount: 12, + stockPositionCount: 5, totalsByCurrency: [ { currency: "RUB", value: "125000.50" }, { currency: "USD", value: "320" }, { currency: "EUR", value: "210" }, ], }, + type: "BROKERAGE", } satisfies BrokerageAccount; const meta = { - title: "Product/BrokerageAccountList", - component: BrokerageAccountList, - tags: ["autodocs"], + args: { accounts: brokerageAccountCards([syntheticAccount]) }, argTypes: { accounts: { control: false }, }, + component: BrokerageAccountList, decorators: [ - (Story) => ( + Story => ( <MemoryRouter initialEntries={["/accounts"]}> <Story /> </MemoryRouter> @@ -42,7 +44,8 @@ const meta = { }, }, }, - args: { accounts: brokerageAccountCards([syntheticAccount]) }, + tags: ["autodocs"], + title: "Product/BrokerageAccountList", } satisfies Meta<typeof BrokerageAccountList>; export default meta; @@ -61,37 +64,39 @@ export const Available: Story = { }, }; -export const Unavailable: Story = { - name: "Сводка недоступна", - args: { - accounts: brokerageAccountCards([ - { ...syntheticAccount, portfolioSummary: null, openedOn: null }, - ]), - }, - parameters: { - docs: { - description: { - story: +export const Unavailable: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + args: { + accounts: brokerageAccountCards([ + { ...syntheticAccount, openedOn: null, portfolioSummary: null }, + ]), + }, + name: "Сводка недоступна", + parameters: { + docs: { + description: { + story: "Счёт остаётся доступен для открытия, даже если сводка временно недоступна. Неизвестные суммы не заменяются нулём; отсутствующая дата открытия не отображается.", + }, }, }, - }, -}; + } +/* eslint-enable unicorn/no-null */); export const Empty: Story = { - name: "Нет текущих сумм", args: { accounts: brokerageAccountCards([ { ...syntheticAccount, portfolioSummary: { - totalsByCurrency: [], - stockPositionCount: 0, bondPositionCount: 0, + stockPositionCount: 0, + totalsByCurrency: [], }, }, ]), }, + name: "Нет текущих сумм", parameters: { docs: { description: { @@ -103,7 +108,6 @@ export const Empty: Story = { }; export const LongName: Story = { - name: "Длинное название счёта", args: { accounts: brokerageAccountCards([ { @@ -113,6 +117,7 @@ export const LongName: Story = { }, ]), }, + name: "Длинное название счёта", parameters: { docs: { description: { @@ -124,105 +129,107 @@ export const LongName: Story = { }; export const AccountGrid: Story = { - name: "Несколько счетов", args: { accounts: brokerageAccountCards([ { ...syntheticAccount, name: "Основной", portfolioSummary: { - totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }], - stockPositionCount: 8, bondPositionCount: 14, + stockPositionCount: 8, + totalsByCurrency: [{ currency: "RUB", value: "1256430.50" }], }, }, { ...syntheticAccount, id: "storybook-iis", name: "Долгосрочные инвестиции", - type: "INDIVIDUAL_INVESTMENT", portfolioSummary: { - totalsByCurrency: [{ currency: "RUB", value: "480250" }], - stockPositionCount: 0, bondPositionCount: 6, + stockPositionCount: 0, + totalsByCurrency: [{ currency: "RUB", value: "480250" }], }, + type: "INDIVIDUAL_INVESTMENT", }, ]), }, + name: "Несколько счетов", }; export const UnvaluedPositions: Story = { - name: "Бумаги без текущей оценки", args: { accounts: brokerageAccountCards([ { ...syntheticAccount, portfolioSummary: { - totalsByCurrency: [], - stockPositionCount: 2, bondPositionCount: 1, + stockPositionCount: 2, + totalsByCurrency: [], }, }, ]), }, + name: "Бумаги без текущей оценки", }; export const LargeValues: Story = { - name: "Длинные суммы", args: { accounts: brokerageAccountCards([ { ...syntheticAccount, portfolioSummary: { + bondPositionCount: 105, + stockPositionCount: 21, totalsByCurrency: [ { currency: "RUB", value: "1234567890123456789.50" }, { currency: "USD", value: "-9876543210987654321.50" }, ], - stockPositionCount: 21, - bondPositionCount: 105, }, }, ]), }, + name: "Длинные суммы", }; -export const IndependentFreshness: Story = { - name: "Независимая свежесть и ошибка сводок", - args: { - accounts: brokerageAccountCards( - [ - syntheticAccount, - { - ...syntheticAccount, - id: "waiting", - name: "Новый счёт", - portfolioSummary: null, - }, - ], - [ - { - resourceKey: "portfolio/storybook-account", - dataRevision: "1", - stateVersion: "failed", - availability: "complete", - freshness: "stale", - lastSuccessfulAt: "2026-09-24T10:30:00Z", - sync: { - id: "failed", - state: "failed", - errorCode: "TBANK_UNAVAILABLE", +export const IndependentFreshness: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + args: { + accounts: brokerageAccountCards( + [ + syntheticAccount, + { + ...syntheticAccount, + id: "waiting", + name: "Новый счёт", + portfolioSummary: null, }, - }, - { - resourceKey: "portfolio/waiting", - dataRevision: "0", - stateVersion: "waiting", - availability: "missing", - freshness: "unknown", - lastSuccessfulAt: null, - sync: { id: "waiting", state: "running", errorCode: null }, - }, - ], - ), - }, -}; + ], + [ + { + availability: "complete", + dataRevision: "1", + freshness: "stale", + lastSuccessfulAt: "2026-09-24T10:30:00Z", + resourceKey: "portfolio/storybook-account", + stateVersion: "failed", + sync: { + errorCode: "TBANK_UNAVAILABLE", + id: "failed", + state: "failed", + }, + }, + { + availability: "missing", + dataRevision: "0", + freshness: "unknown", + lastSuccessfulAt: null, + resourceKey: "portfolio/waiting", + stateVersion: "waiting", + sync: { errorCode: null, id: "waiting", state: "running" }, + }, + ], + ), + }, + name: "Независимая свежесть и ошибка сводок", + } +/* eslint-enable unicorn/no-null */); diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx index ef5aa02..844e889 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.test.tsx @@ -2,7 +2,9 @@ import { MantineProvider } from "@mantine/core"; import { render, screen, within } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { describe, expect, it } from "vitest"; + import { resource } from "@/test/resource"; + import { brokerageAccountCards } from "../../model/display"; import { BrokerageAccountList } from "./BrokerageAccountList"; @@ -16,36 +18,40 @@ describe("BrokerageAccountList", () => { { id: "account-1", name: "Основной", - type: "BROKERAGE", openedOn: "2024-08-13", portfolioSummary: { - stockPositionCount: 5, bondPositionCount: 12, + stockPositionCount: 5, totalsByCurrency: [ { currency: "RUB", value: "1500" }, { currency: "USD", value: "20" }, { currency: "EUR", value: "5" }, ], }, - }, - { - id: "account-2", - name: "Недоступный", type: "BROKERAGE", - openedOn: null, - portfolioSummary: null, - }, - { - id: "account-3", - name: "Пустой", - type: "BROKERAGE", - openedOn: null, - portfolioSummary: { - totalsByCurrency: [], - stockPositionCount: 2, - bondPositionCount: 0, - }, }, + (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + id: "account-2", + name: "Недоступный", + openedOn: null, + portfolioSummary: null, + type: "BROKERAGE", + } + /* eslint-enable unicorn/no-null */), + (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + id: "account-3", + name: "Пустой", + openedOn: null, + portfolioSummary: { + bondPositionCount: 0, + stockPositionCount: 2, + totalsByCurrency: [], + }, + type: "BROKERAGE", + } + /* eslint-enable unicorn/no-null */), ])} /> </MemoryRouter> @@ -65,18 +71,18 @@ describe("BrokerageAccountList", () => { expect(screen.getByText("Нет текущих сумм")).toBeInTheDocument(); expect(screen.getAllByText("Открыть счет")).toHaveLength(3); const available = within(screen.getByRole("link", { name: /Основной/ })); - expect(available.getByText("Акции").nextElementSibling).toHaveTextContent( + expect(available.getAllByRole("definition")[0]).toHaveTextContent( "5", ); expect( - available.getByText("Облигации").nextElementSibling, + available.getAllByRole("definition")[1], ).toHaveTextContent("12"); const unvalued = within(screen.getByRole("link", { name: /Пустой/ })); - expect(unvalued.getByText("Акции").nextElementSibling).toHaveTextContent( + expect(unvalued.getAllByRole("definition")[0]).toHaveTextContent( "2", ); expect( - unvalued.getByText("Облигации").nextElementSibling, + unvalued.getAllByRole("definition")[1], ).toHaveTextContent("0"); expect( within(screen.getByRole("link", { name: /Недоступный/ })).queryByText( @@ -88,21 +94,23 @@ describe("BrokerageAccountList", () => { it("shows portfolio summary freshness independently for each account", () => { const accounts = brokerageAccountCards( - ["fresh", "stale"].map((id) => ({ - id, - name: id, - type: "BROKERAGE" as const, - openedOn: null, - portfolioSummary: { - stockPositionCount: 1, - bondPositionCount: 0, - totalsByCurrency: [{ currency: "RUB", value: "10" }], - }, - })), + ["fresh", "stale"].map(id => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + id, + name: id, + openedOn: null, + portfolioSummary: { + bondPositionCount: 0, + stockPositionCount: 1, + totalsByCurrency: [{ currency: "RUB", value: "10" }], + }, + type: "BROKERAGE" as const, + } + /* eslint-enable unicorn/no-null */))), [ - resource(null, { resourceKey: "portfolio/fresh", freshness: "fresh" }) + resource(null, { freshness: "fresh", resourceKey: "portfolio/fresh" }) .meta, - resource(null, { resourceKey: "portfolio/stale", freshness: "stale" }) + resource(null, { freshness: "stale", resourceKey: "portfolio/stale" }) .meta, ], ); diff --git a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx index 6519be2..dd27da7 100644 --- a/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx +++ b/src/entities/brokerage-account/ui/BrokerageAccountList/BrokerageAccountList.tsx @@ -1,9 +1,13 @@ -import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import type { FC } from "react"; + +import { Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; + import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; + import type { BrokerageAccountCard } from "../../model/display"; + import { accountTypeKey } from "../../model/display"; import classes from "./BrokerageAccountList.module.css"; @@ -31,18 +35,18 @@ const BrokerageAccountSummary: FC<BrokerageAccountSummaryProps> = (props) => { return ( <Stack className={classes.summary} gap={4}> - {summary.totals.map((total) => ( + {summary.totals.map(total => ( <Text className={classes.total} fw={700} key={total.currency}> {formatMoney(total)} </Text> ))} - {summary.remainingCurrencyCount ? ( + {!!(summary.remainingCurrencyCount) && (( <Text c="dimmed" size="sm"> {t("accounts.remainingCurrencies", { count: summary.remainingCurrencyCount, })} </Text> - ) : null} + ))} </Stack> ); }; @@ -53,37 +57,37 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => { return ( <SimpleGrid className={classes.list} cols={1} spacing="lg"> - {accounts.map(({ account, summary, positionCounts, source }) => ( + {accounts.map(({ account, positionCounts, source, summary }) => ( <Card - component={Link} - to={`/accounts/${account.id}`} - key={account.id} className={classes.card} + component={Link} + key={account.id} padding="xl" radius="lg" + to={`/accounts/${account.id}`} withBorder > <Stack className={classes.identity} gap="sm"> - <Group justify="space-between" wrap="nowrap" gap="sm"> - <Text className={classes.kicker} size="xs" fw={700}> + <Group gap="sm" justify="space-between" wrap="nowrap"> + <Text className={classes.kicker} fw={700} size="xs"> {t(accountTypeKey(account.type))} </Text> <svg + aria-hidden="true" className={classes.accountIcon} - viewBox="0 0 24 24" fill="none" + focusable="false" stroke="currentColor" - strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" - aria-hidden="true" - focusable="false" + strokeWidth="1.5" + viewBox="0 0 24 24" > - <rect x="3" y="5" width="18" height="15" rx="3" /> + <rect height="15" rx="3" width="18" x="3" y="5" /> <path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" /> </svg> </Group> - <Text component="h2" className={classes.name} fw={700}> + <Text className={classes.name} component="h2" fw={700}> {account.name} </Text> </Stack> @@ -92,27 +96,27 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => { {t("accounts.currentValue")} </Text> <BrokerageAccountSummary summary={summary} /> - {source?.lastSuccessfulAt ? ( - <Text size="xs" c="dimmed"> + {!!(source?.lastSuccessfulAt) && (( + <Text c="dimmed" size="xs"> {t("investment.sourceTime", { date: formatSnapshotTime(source.lastSuccessfulAt), })} </Text> - ) : null} - {source?.sync.state === "running" || - source?.sync.state === "queued" ? ( - <Text size="xs" role="status"> + ))} + {(source?.sync.state === "running" + || source?.sync.state === "queued") && (( + <Text role="status" size="xs"> {t("accounts.refreshing")} </Text> - ) : null} - {source?.sync.state === "failed" ? ( + ))} + {(source?.sync.state === "failed") && (( <Text size="xs">{t("accounts.refreshError")}</Text> - ) : null} - {source?.freshness === "stale" ? ( + ))} + {(source?.freshness === "stale") && (( <Text size="xs">{t("investment.stale")}</Text> - ) : null} + ))} </Stack> - {positionCounts ? ( + {!!(positionCounts) && (( <dl className={classes.positions}> <div className={classes.stocks}> <dt>{t("accounts.stocks")}</dt> @@ -127,18 +131,18 @@ const BrokerageAccountList: FC<BrokerageAccountListProps> = (props) => { </dd> </div> </dl> - ) : null} - <Group className={classes.footer} justify="space-between" gap="sm"> - {account.openedOn ? ( + ))} + <Group className={classes.footer} gap="sm" justify="space-between"> + {!!(account.openedOn) && (( <Text c="dimmed" size="xs"> {t("accounts.openedOn", { date: formatDate(account.openedOn), })} </Text> - ) : null} - <Text component="span" className={classes.open} size="sm" fw={600}> + ))} + <Text className={classes.open} component="span" fw={600} size="sm"> {t("accounts.openAccount")} - <span className={classes.arrow} aria-hidden="true"> + <span aria-hidden="true" className={classes.arrow}> → </span> </Text> diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx index 3208167..868dcc4 100644 --- a/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx +++ b/src/entities/brokerage-event/api/useBrokerageEvents.test.tsx @@ -1,18 +1,20 @@ import { renderHook, waitFor } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; + import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; + import { useBrokerageEvents } from "./useBrokerageEvents"; describe("useBrokerageEvents", () => { it("does not fetch events without an account ID", async () => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); const requested = vi.fn(() => HttpResponse.json({ events: [] })); server.use( @@ -27,29 +29,31 @@ describe("useBrokerageEvents", () => { it("loads future cash events through the authenticated query seam", async () => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); server.use( http.get("*/api/v1/brokerage-accounts/account-1/events", () => HttpResponse.json( - resource({ - accountId: "account-1", - calculatedAt: "2026-08-14T00:00:00Z", - events: [ - { - type: "dividend", - date: "2026-09-01", - dateKind: "registry", - figi: "figi-1", - instrumentUid: null, - ticker: "SBER", - displayName: "Сбер", - logoName: "sber3.png", - projectedAmount: { currency: "RUB", value: "12.34" }, - }, - ], - }), + resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + accountId: "account-1", + calculatedAt: "2026-08-14T00:00:00Z", + events: [ + { + date: "2026-09-01", + dateKind: "registry", + displayName: "Сбер", + figi: "figi-1", + instrumentUid: null, + logoName: "sber3.png", + projectedAmount: { currency: "RUB", value: "12.34" }, + ticker: "SBER", + type: "dividend", + }, + ], + } + /* eslint-enable unicorn/no-null */)), ), ), ); @@ -60,17 +64,19 @@ describe("useBrokerageEvents", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(result.current.data?.events).toEqual([ - { - type: "dividend", - date: "2026-09-01", - dateKind: "registry", - figi: "figi-1", - instrumentUid: null, - ticker: "SBER", - displayName: "Сбер", - logoName: "sber3.png", - projectedAmount: { currency: "RUB", value: "12.34" }, - }, + (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + date: "2026-09-01", + dateKind: "registry", + displayName: "Сбер", + figi: "figi-1", + instrumentUid: null, + logoName: "sber3.png", + projectedAmount: { currency: "RUB", value: "12.34" }, + ticker: "SBER", + type: "dividend", + } + /* eslint-enable unicorn/no-null */), ]); }); }); diff --git a/src/entities/brokerage-event/api/useBrokerageEvents.ts b/src/entities/brokerage-event/api/useBrokerageEvents.ts index 7a2a304..ba80d6d 100644 --- a/src/entities/brokerage-event/api/useBrokerageEvents.ts +++ b/src/entities/brokerage-event/api/useBrokerageEvents.ts @@ -1,10 +1,10 @@ import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; -type EventsEndpoint = - paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"]; type BrokerageEvent = NonNullable< EventsEndpoint["responses"][200]["content"]["application/json"]["data"] >["events"][number]; +type EventsEndpoint + = paths["/api/v1/brokerage-accounts/{accountId}/events"]["get"]; function useBrokerageEvents( accountId: string | undefined, diff --git a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx index 18f61f7..8c15780 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventCard.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventCard.tsx @@ -1,7 +1,10 @@ -import { EntryCard } from "@wealthledger/ui"; import type { FC } from "react"; + +import { EntryCard } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; + import { formatDate, formatMoney, instrumentLogoUrl } from "@/shared/lib"; + import type { BrokerageEvent } from "../api/useBrokerageEvents"; type BrokerageEventCardProps = { event: BrokerageEvent }; @@ -14,7 +17,7 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => { const metadata = [ date, - event.dateKind === "registry" ? t("events.dateKind.registry") : null, + event.dateKind === "registry" ? t("events.dateKind.registry") : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, event.isin, event.account?.name, ] @@ -23,19 +26,19 @@ const BrokerageEventCard: FC<BrokerageEventCardProps> = (props) => { return ( <EntryCard - title={`${t(`events.type.${event.type}`)} · ${event.displayName}`} - metadata={metadata} + imageSrc={instrumentLogoUrl(event.logoName)} initial={( (event.ticker ?? event.displayName).trim()[0] ?? "?" ).toUpperCase()} - imageSrc={instrumentLogoUrl(event.logoName)} + metadata={metadata} + title={[t(`events.type.${event.type}`), event.displayName].join(" · ")} + tone="neutral" value={ event.projectedAmount ? formatMoney(event.projectedAmount) : t("events.noAmount") } valueLabel={t("events.projectedAmount")} - tone="neutral" /> ); }; diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories.tsx index 0f8f4dc..cee9b4e 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.stories.tsx @@ -1,32 +1,53 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; + import { MemoryRouter } from "react-router"; + import type { BrokerageEvent } from "../../api/useBrokerageEvents"; + import { BrokerageEventsPreview } from "./BrokerageEventsPreview"; -const syntheticEvent = { - type: "coupon", +const syntheticEvent = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ +{ date: "2026-10-15", dateKind: "event", + displayName: "Учебные облигации", figi: null, instrumentUid: null, - ticker: "DEMO", - displayName: "Учебные облигации", - logoName: null, isin: "RU000A0JX0J2", + logoName: null, projectedAmount: { currency: "RUB", value: "850.50" }, -} satisfies BrokerageEvent; + ticker: "DEMO", + type: "coupon", +} +/* eslint-enable unicorn/no-null */) satisfies BrokerageEvent; const eventsPath = "/accounts/storybook-account/events"; const meta = { - title: "Product/RecentEvents", - component: BrokerageEventsPreview, - tags: ["autodocs"], + args: { + events: { + items: [ + syntheticEvent, + { + ...syntheticEvent, + date: "2026-11-01", + dateKind: "registry", + displayName: "Учебные технологии", + projectedAmount: { currency: "RUB", value: "1200" }, + ticker: "EXAMPLE", + type: "dividend", + }, + ], + state: "loaded", + to: eventsPath, + }, + }, argTypes: { events: { control: false }, }, + component: BrokerageEventsPreview, decorators: [ - (Story) => ( + Story => ( <MemoryRouter initialEntries={["/accounts/storybook-account"]}> <Story /> </MemoryRouter> @@ -40,24 +61,8 @@ const meta = { }, }, }, - args: { - events: { - state: "loaded", - to: eventsPath, - items: [ - syntheticEvent, - { - ...syntheticEvent, - type: "dividend", - date: "2026-11-01", - dateKind: "registry", - ticker: "EXAMPLE", - displayName: "Учебные технологии", - projectedAmount: { currency: "RUB", value: "1200" }, - }, - ], - }, - }, + tags: ["autodocs"], + title: "Product/RecentEvents", } satisfies Meta<typeof BrokerageEventsPreview>; export default meta; @@ -77,8 +82,8 @@ export const Loaded: Story = { }; export const Loading: Story = { - name: "Загрузка раздела", args: { events: { state: "loading", to: eventsPath } }, + name: "Загрузка раздела", parameters: { docs: { description: { @@ -90,8 +95,8 @@ export const Loading: Story = { }; export const Failed: Story = { - name: "Ошибка получения событий", args: { events: { state: "error", to: eventsPath } }, + name: "Ошибка получения событий", parameters: { docs: { description: { @@ -103,8 +108,8 @@ export const Failed: Story = { }; export const Empty: Story = { - name: "Нет ближайших событий", args: { events: { state: "empty", to: eventsPath } }, + name: "Нет ближайших событий", parameters: { docs: { description: { @@ -115,40 +120,44 @@ export const Empty: Story = { }, }; -export const MissingAmount: Story = { - name: "Событие без ожидаемой суммы", - args: { - events: { - state: "loaded", - to: eventsPath, - items: [{ ...syntheticEvent, projectedAmount: null }], - }, - }, - parameters: { - docs: { - description: { - story: - "Событие остаётся видимым при неизвестной ожидаемой сумме. Отсутствие оценки не подменяется нулевой выплатой.", +export const MissingAmount: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + args: { + events: { + items: [{ ...syntheticEvent, projectedAmount: null }], + state: "loaded", + to: eventsPath, }, }, - }, -}; - -export const LongEvent: Story = { - name: "Длинное название на узком экране", - args: { - events: { - state: "loaded", - to: eventsPath, - items: [ - { - ...syntheticEvent, - type: "offer", - displayName: - "Учебные облигации компании с очень длинным названием инструмента", - projectedAmount: null, + name: "Событие без ожидаемой суммы", + parameters: { + docs: { + description: { + story: + "Событие остаётся видимым при неизвестной ожидаемой сумме. Отсутствие оценки не подменяется нулевой выплатой.", }, - ], + }, }, - }, -}; + } +/* eslint-enable unicorn/no-null */); + +export const LongEvent: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + args: { + events: { + items: [ + { + ...syntheticEvent, + displayName: + "Учебные облигации компании с очень длинным названием инструмента", + projectedAmount: null, + type: "offer", + }, + ], + state: "loaded", + to: eventsPath, + }, + }, + name: "Длинное название на узком экране", + } +/* eslint-enable unicorn/no-null */); diff --git a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx index b6623f3..2f4e58c 100644 --- a/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx +++ b/src/entities/brokerage-event/ui/BrokerageEventsPreview/BrokerageEventsPreview.tsx @@ -1,26 +1,30 @@ +import type { FC } from "react"; + import { Alert, Button, Skeleton, Stack, Text } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; + import { formatSnapshotTime } from "@/shared/lib"; + import type { BrokerageEvent } from "../../api/useBrokerageEvents"; + import { BrokerageEventCard } from "../BrokerageEventCard"; type BrokerageEventsPreviewProps = { events: BrokerageEventsPreviewState; }; -type BrokerageEventsPreviewState = - | { state: "loading" | "error" | "empty"; to: string } - | { - state: "loaded" | "partial"; - to: string; - lastSuccessfulAt?: string | null; - stale?: boolean; - refreshError?: boolean; - items: BrokerageEvent[]; - }; +type BrokerageEventsPreviewState + = | { + items: BrokerageEvent[]; + lastSuccessfulAt?: null | string; + refreshError?: boolean; + stale?: boolean; + state: "loaded" | "partial"; + to: string; + } + | { state: "empty" | "error" | "loading"; to: string }; const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => { const { events } = props; @@ -28,53 +32,51 @@ const BrokerageEventsPreview: FC<BrokerageEventsPreviewProps> = (props) => { return ( <Stack gap="sm"> - {events.state === "loaded" ? ( + {(events.state === "loaded") && (( <> - {events.lastSuccessfulAt ? ( - <Text size="xs" c="dimmed"> + {!!(events.lastSuccessfulAt) && (( + <Text c="dimmed" size="xs"> {t("investment.sourceTime", { date: formatSnapshotTime(events.lastSuccessfulAt), })} </Text> - ) : null} - {events.stale ? <Text size="xs">{t("investment.stale")}</Text> : null} - {events.refreshError ? ( + ))} + {!!(events.stale) && (<Text size="xs">{t("investment.stale")}</Text>)} + {!!(events.refreshError) && (( <Text role="status">{t("accounts.refreshError")}</Text> - ) : null} + ))} </> - ) : null} - {events.state === "loading" ? ( + ))} + {(events.state === "loading") && (( <LoadingSkeleton label={t("events.recentLoading")}> - {[0, 1, 2].map((index) => ( - <Skeleton key={index} height={48} radius="sm" /> + {["first", "second", "third"].map(placeholder => ( + <Skeleton height={48} key={placeholder} radius="sm" /> ))} </LoadingSkeleton> - ) : null} - {events.state === "error" ? ( + ))} + {(events.state === "error") && (( <Alert color="red">{t("events.recentError")}</Alert> - ) : null} - {events.state === "empty" ? ( + ))} + {(events.state === "empty") && (( <Text c="dimmed">{t("events.recentEmpty")}</Text> - ) : null} - {events.state === "partial" ? ( + ))} + {(events.state === "partial") && (( <Text role="status">{t("investment.partial")}</Text> - ) : null} - {events.state === "loaded" || events.state === "partial" - ? events.items.map((event) => ( - <BrokerageEventCard - key={JSON.stringify([ - event.account?.id, - event.type, - event.date, - event.instrumentUid, - event.figi, - event.ticker, - event.logoName, - ])} - event={event} - /> - )) - : null} + ))} + {(events.state === "loaded" || events.state === "partial") && (events.items.map(event => ( + <BrokerageEventCard + event={event} + key={JSON.stringify([ + event.account?.id, + event.type, + event.date, + event.instrumentUid, + event.figi, + event.ticker, + event.logoName, + ])} + /> + )))} <Button component={Link} to={events.to} variant="subtle"> {t("events.open")} </Button> diff --git a/src/entities/income/api/useIncome.ts b/src/entities/income/api/useIncome.ts index 57f5260..96db67b 100644 --- a/src/entities/income/api/useIncome.ts +++ b/src/entities/income/api/useIncome.ts @@ -1,24 +1,11 @@ import { apiQueryClient, type paths, useResourceQuery } from "@/shared/api"; -type IncomeSummary = NonNullable< - paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] ->; type IncomeAnalytics = NonNullable< paths["/api/v1/brokerage-accounts/{accountId}/income-analytics"]["get"]["responses"][200]["content"]["application/json"]["data"] >; - -function useIncomeSummary(accountId: string | undefined) { - return useResourceQuery({ - meta: { accessGroup: "brokerage" }, - ...apiQueryClient.queryOptions( - "get", - "/api/v1/brokerage-accounts/{accountId}/income-summary", - { params: { path: { accountId: accountId ?? "" } } }, - ), - enabled: Boolean(accountId), - staleTime: 30_000, - }); -} +type IncomeSummary = NonNullable< + paths["/api/v1/brokerage-accounts/{accountId}/income-summary"]["get"]["responses"][200]["content"]["application/json"]["data"] +>; function useIncomeAnalytics( accountId: string | undefined, @@ -41,6 +28,19 @@ function useIncomeAnalytics( }); } +function useIncomeSummary(accountId: string | undefined) { + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, + ...apiQueryClient.queryOptions( + "get", + "/api/v1/brokerage-accounts/{accountId}/income-summary", + { params: { path: { accountId: accountId ?? "" } } }, + ), + enabled: Boolean(accountId), + staleTime: 30_000, + }); +} + export { type IncomeAnalytics, type IncomeSummary, diff --git a/src/entities/portfolio/api/resource-lifecycle.test.tsx b/src/entities/portfolio/api/resource-lifecycle.test.tsx index 28b829e..bc64aff 100644 --- a/src/entities/portfolio/api/resource-lifecycle.test.tsx +++ b/src/entities/portfolio/api/resource-lifecycle.test.tsx @@ -1,10 +1,12 @@ import { act, renderHook, waitFor } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; + import { apiClient, configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; + import { type Portfolio, portfolioQueryOptions, @@ -12,25 +14,25 @@ import { } from "./usePortfolio"; const snapshot: Portfolio = { - positions: [], - totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }], cashBalances: [], - structure: { currencies: [], unvaluedCategories: [] }, - lastSuccessfulAt: "2026-08-14T00:00:00Z", isStale: false, + lastSuccessfulAt: "2026-08-14T00:00:00Z", + positions: [], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "RUB", value: "123.000000001" }], }; function auth() { configureAuthClient({ accessToken: () => "synthetic", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); } describe("persistent resource query lifecycle", () => { it("observes into the canonical cache and rejects an old notified state before accepting the current one", async () => { auth(); - const modes: (string | null)[] = []; + const modes: (null | string)[] = []; let observations = 0; server.use( http.get( @@ -47,16 +49,17 @@ describe("persistent resource query lifecycle", () => { }, ), ); - const { wrapper, queryClient } = createQueryWrapper(); + const { queryClient, wrapper } = createQueryWrapper(); const { result } = renderHook(() => usePortfolio("a"), { wrapper }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); await act(() => result.current.observe("new")); await waitFor(() => expect(result.current.meta?.stateVersion).toBe("new")); + // eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response. expect(modes).toEqual([null, "observe", "observe"]); expect(queryClient.getQueryCache().getAll()).toHaveLength(1); expect( queryClient.getQueryData(portfolioQueryOptions("a").queryKey), - ).toMatchObject({ meta: { stateVersion: "new" }, data: snapshot }); + ).toMatchObject({ data: snapshot, meta: { stateVersion: "new" } }); }); it("does not convert missing or failed source data to empty financial success", async () => { auth(); @@ -65,9 +68,9 @@ describe("persistent resource query lifecycle", () => { HttpResponse.json( resource(null, { sync: { + errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed", - errorCode: "TBANK_UNAVAILABLE", }, }), ), @@ -82,20 +85,22 @@ describe("persistent resource query lifecycle", () => { expect(result.current.status).toBe("pending"); expect(result.current.isPending).toBe(true); expect(result.current.isLoading).toBe(false); - expect(result.current.transport).toEqual({ - status: "success", - fetchStatus: "idle", - error: null, - }); + expect(result.current.transport).toEqual((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + error: null, + fetchStatus: "idle", + status: "success", + } + /* eslint-enable unicorn/no-null */)); expect(result.current.meta?.availability).toBe("missing"); }); it("keeps the refetch envelope while exposing the refreshed resource payload", async () => { auth(); - let ready = false; + let isReady = false; const refreshed = resource(snapshot, { stateVersion: "ready" }); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => - HttpResponse.json(ready ? refreshed : resource(null)), + HttpResponse.json(isReady ? refreshed : resource(null)), ), ); const { result } = renderHook(() => usePortfolio("a"), { @@ -109,7 +114,7 @@ describe("persistent resource query lifecycle", () => { expect(missing.status).toBe("success"); expect(missing.data).toEqual(resource(null)); expect(missing.data?.data).toBeNull(); - ready = true; + isReady = true; const loaded = await result.current.refetch(); expect(loaded.data).toEqual(refreshed); expect(loaded.data?.data?.totalsByCurrency).toEqual( @@ -124,10 +129,8 @@ describe("persistent resource query lifecycle", () => { }); it("rejects a late ordinary response when an observe response for that resource arrived first", async () => { auth(); - let release!: () => void; - const blocked = new Promise<void>((resolve) => { - release = resolve; - }); + + const { promise: blocked, resolve: release } = Promise.withResolvers<void>(); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/portfolio", @@ -151,19 +154,15 @@ describe("persistent resource query lifecycle", () => { }); it("clears private snapshots on confirmed access loss and fences another pending success", async () => { auth(); - let denied = false; - let release!: () => void; - const blocked = new Promise<void>((resolve) => { - release = resolve; - }); + let isDenied = false; + + const { promise: blocked, resolve: release } = Promise.withResolvers<void>(); server.use( http.get( "*/api/v1/brokerage-accounts/:accountId/portfolio", async ({ params }) => { if (params.accountId === "b") await blocked; - if (params.accountId === "a" && denied) - return HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }); - return HttpResponse.json(resource(snapshot)); + return isDenied && params.accountId === "a" ? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 }) : HttpResponse.json(resource(snapshot)); }, ), ); @@ -173,7 +172,7 @@ describe("persistent resource query lifecycle", () => { { wrapper }, ); await waitFor(() => expect(result.current.a.data).toEqual(snapshot)); - denied = true; + isDenied = true; await act(() => result.current.a.refetch()); await waitFor(() => expect(result.current.a.data).toBeUndefined()); await act(async () => { diff --git a/src/entities/portfolio/api/usePortfolio.test.tsx b/src/entities/portfolio/api/usePortfolio.test.tsx index 9c580aa..6f21b24 100644 --- a/src/entities/portfolio/api/usePortfolio.test.tsx +++ b/src/entities/portfolio/api/usePortfolio.test.tsx @@ -1,18 +1,20 @@ import { renderHook, waitFor } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; + import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; + import { usePortfolio } from "./usePortfolio"; describe("usePortfolio", () => { it("loads the account portfolio", async () => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); const { result } = renderHook(() => usePortfolio("account-1"), { wrapper: createQueryWrapper().wrapper, @@ -25,8 +27,8 @@ describe("usePortfolio", () => { it("keeps the current connection snapshot after a background refetch error", async () => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); let calls = 0; server.use( @@ -35,12 +37,12 @@ describe("usePortfolio", () => { return calls === 1 ? HttpResponse.json( resource({ - lastSuccessfulAt: "2026-08-14T00:00:00Z", + cashBalances: [], isStale: false, - totalsByCurrency: [], + lastSuccessfulAt: "2026-08-14T00:00:00Z", positions: [], structure: { currencies: [], unvaluedCategories: [] }, - cashBalances: [], + totalsByCurrency: [], }), ) : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); diff --git a/src/entities/portfolio/model/display.test.ts b/src/entities/portfolio/model/display.test.ts index 045c4cf..1e609b7 100644 --- a/src/entities/portfolio/model/display.test.ts +++ b/src/entities/portfolio/model/display.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; + import { formatShare } from "./display"; describe("portfolio display helpers", () => { diff --git a/src/entities/portfolio/model/display.ts b/src/entities/portfolio/model/display.ts index f4938e8..c84b1b8 100644 --- a/src/entities/portfolio/model/display.ts +++ b/src/entities/portfolio/model/display.ts @@ -1,17 +1,18 @@ import type { Portfolio } from "../api/usePortfolio"; +const PERCENT_SCALE = 100; + type Money = Portfolio["totalsByCurrency"][number]; function formatShare( numerator: Money | null, denominator: Money | null, -): string | null { - if (!numerator || !denominator) return null; +): null | string { + if (!numerator || !denominator) return /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null; const total = Number(denominator.value); - if (!Number.isFinite(total) || total <= 0) return null; - // biome-ignore lint/style/noMagicNumbers: obvious conversion of a ratio to percent. - const percent = (Number(numerator.value) / total) * 100; - if (!Number.isFinite(percent)) return null; + if (!Number.isFinite(total) || total <= 0) return /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null; + const percent = (Number(numerator.value) / total) * PERCENT_SCALE; + if (!Number.isFinite(percent)) return /* eslint-disable-line unicorn/no-null -- The existing formatter contract uses null for unavailable data. */ null; const rounded = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 1, }).format(percent); diff --git a/src/entities/stock/api/queries.ts b/src/entities/stock/api/queries.ts index 26081ab..fa52bbc 100644 --- a/src/entities/stock/api/queries.ts +++ b/src/entities/stock/api/queries.ts @@ -6,6 +6,8 @@ import { useResourceQuery, } from "@/shared/api"; +const MIN_STOCK_SEARCH_LENGTH = 2; + type StockPeriod = NonNullable< NonNullable< paths["/api/v1/stocks/{uid}/candles"]["get"]["parameters"]["query"] @@ -14,15 +16,6 @@ type StockPeriod = NonNullable< const STOCK_STALE_TIME_MS = 30_000; -function useStockSearch(query: string) { - return useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/stocks", { - params: { query: { query } }, - }), - enabled: query.length >= 2, - staleTime: STOCK_STALE_TIME_MS, - }); -} function stockQueryOptions(uid: string) { return { ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}", { @@ -31,21 +24,9 @@ function stockQueryOptions(uid: string) { staleTime: STOCK_STALE_TIME_MS, }; } - -function useStocks(uids: string[]) { - const uniqueUids = [...new Set(uids)]; - const queries = useAuthenticatedQueries(uniqueUids.map(stockQueryOptions)); - return new Map( - queries.map((query, index) => [ - uniqueUids[index], - unwrapResourceResult(query).data, - ]), - ); -} - function useStock(identifier: string) { - const isUid = - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + const isUid + = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( identifier, ); @@ -62,15 +43,7 @@ function useStock(identifier: string) { }); return isUid ? byUid : byTicker; } -function useStockQuote(uid: string) { - return useResourceQuery({ - ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", { - params: { path: { uid } }, - }), - enabled: Boolean(uid), - staleTime: STOCK_STALE_TIME_MS, - }); -} + function useStockCandles(uid: string, period: StockPeriod) { return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/candles", { @@ -80,16 +53,7 @@ function useStockCandles(uid: string, period: StockPeriod) { staleTime: STOCK_STALE_TIME_MS, }); } -function useStockPosition(uid: string) { - return useResourceQuery({ - meta: { accessGroup: "brokerage" }, - ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", { - params: { path: { uid } }, - }), - enabled: Boolean(uid), - staleTime: STOCK_STALE_TIME_MS, - }); -} + function useStockCompany(uid: string) { return useResourceQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/company", { @@ -109,8 +73,46 @@ function useStockDividends(uid: string) { staleTime: STOCK_STALE_TIME_MS, }); } +function useStockPosition(uid: string) { + return useResourceQuery({ + meta: { accessGroup: "brokerage" }, + ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/position", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: STOCK_STALE_TIME_MS, + }); +} +function useStockQuote(uid: string) { + return useResourceQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/stocks/{uid}/quote", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: STOCK_STALE_TIME_MS, + }); +} +function useStocks(uids: string[]) { + const uniqueUids = [...new Set(uids)]; + const queries = useAuthenticatedQueries(uniqueUids.map(uniqueUid => stockQueryOptions(uniqueUid))); + return new Map( + queries.map((query, index) => [ + /* eslint-disable-line security/detect-object-injection -- This is a positional index into the local array, not a user-supplied object key. */ uniqueUids[index], + unwrapResourceResult(query).data, + ]), + ); +} +function useStockSearch(query: string) { + return useResourceQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/stocks", { + params: { query: { query } }, + }), + enabled: query.length >= MIN_STOCK_SEARCH_LENGTH, + staleTime: STOCK_STALE_TIME_MS, + }); +} -export { +export { MIN_STOCK_SEARCH_LENGTH, type StockPeriod, useStock, useStockCandles, @@ -118,6 +120,6 @@ export { useStockDividends, useStockPosition, useStockQuote, - useStockSearch, useStocks, + useStockSearch, }; diff --git a/src/entities/stock/index.ts b/src/entities/stock/index.ts index 4c4483d..f4ddf46 100644 --- a/src/entities/stock/index.ts +++ b/src/entities/stock/index.ts @@ -1,4 +1,4 @@ -export { +export { MIN_STOCK_SEARCH_LENGTH, type StockPeriod, useStock, useStockCandles, @@ -6,6 +6,6 @@ export { useStockDividends, useStockPosition, useStockQuote, - useStockSearch, useStocks, + useStockSearch, } from "./api/queries"; diff --git a/src/features/auth/model/auth-context.ts b/src/features/auth/model/auth-context.ts index 66f4a5f..5446045 100644 --- a/src/features/auth/model/auth-context.ts +++ b/src/features/auth/model/auth-context.ts @@ -1,23 +1,24 @@ -import { createContext, useContext } from "react"; +import { createContext, use } from "react"; + import type { paths } from "@/shared/api"; -type User = - paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"]; type AuthContextValue = { - user: User | null; - accessToken: string | null; - ready: boolean; + accessToken: string | undefined; login: (email: string, password: string) => Promise<void>; - register: (email: string, password: string) => Promise<void>; logout: () => Promise<void>; + ready: boolean; refresh: () => Promise<boolean>; + register: (email: string, password: string) => Promise<void>; + user: undefined | User; }; -const AuthContext = createContext<AuthContextValue | null>(null); +type User + = paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"]; +const AuthContext = createContext<AuthContextValue | undefined>(undefined); function useAuth() { - const context = useContext(AuthContext); + const context = use(AuthContext); if (!context) throw new Error("AuthProvider is required"); return context; } -export { AuthContext, type User, useAuth }; +export { AuthContext, useAuth, type User }; diff --git a/src/features/auth/model/auth.test.tsx b/src/features/auth/model/auth.test.tsx index a4e532a..d026447 100644 --- a/src/features/auth/model/auth.test.tsx +++ b/src/features/auth/model/auth.test.tsx @@ -1,12 +1,15 @@ +import type { FC, PropsWithChildren } from "react"; + import { QueryClientProvider, useQueryClient } from "@tanstack/react-query"; import { act, renderHook, waitFor } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; -import type { FC, PropsWithChildren } from "react"; +import { http, HttpResponse } from "msw"; import { describe, expect, it } from "vitest"; + import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; + import { AuthProvider } from "./auth"; import { useAuth } from "./auth-context"; @@ -14,14 +17,22 @@ const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", { params: { query: { includeArchived: "false" } }, }).queryKey; +function createAuthWrapper(queryClient: ReturnType<typeof createTestQueryClient>): FC<PropsWithChildren> { + return props => ( + <QueryClientProvider client={queryClient}> + <AuthProvider>{props.children}</AuthProvider> + </QueryClientProvider> + ); +} + describe("AuthProvider", () => { it("authorizes requests after restoring the initial session", async () => { - let authorization: string | null = null; + let authorization: null | string = /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null; server.use( http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: "restored-token", - user: { id: "user-1", email: "user@example.com" }, + user: { email: "user@example.com", id: "user-1" }, }), ), http.get("*/api/v1/brokerage-accounts", ({ request }) => { @@ -30,16 +41,8 @@ describe("AuthProvider", () => { }), ); const queryClient = createTestQueryClient(); - const wrapper: FC<PropsWithChildren> = (props) => { - const { children } = props; - - return ( - <QueryClientProvider client={queryClient}> - <AuthProvider>{children}</AuthProvider> - </QueryClientProvider> - ); - }; - const { result, rerender } = renderHook( + const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient); + const { rerender, result } = renderHook( () => ({ ...useAuth(), queryClient: useQueryClient() }), { wrapper }, ); @@ -61,21 +64,13 @@ describe("AuthProvider", () => { const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "stale" }); queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); - const wrapper: FC<PropsWithChildren> = (props) => { - const { children } = props; - - return ( - <QueryClientProvider client={queryClient}> - <AuthProvider>{children}</AuthProvider> - </QueryClientProvider> - ); - }; + const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); - expect(result.current.user).toBeNull(); - expect(result.current.accessToken).toBeNull(); + expect(result.current.user).toBeUndefined(); + expect(result.current.accessToken).toBeUndefined(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); @@ -88,7 +83,7 @@ describe("AuthProvider", () => { return refreshCalls === 1 ? HttpResponse.json({ accessToken: "restored-token", - user: { id: "user-1", email: "user@example.com" }, + user: { email: "user@example.com", id: "user-1" }, }) : HttpResponse.error(); }), @@ -99,15 +94,7 @@ describe("AuthProvider", () => { const queryClient = createTestQueryClient(); queryClient.setQueryData(["cached-query"], { value: "stale" }); queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); - const wrapper: FC<PropsWithChildren> = (props) => { - const { children } = props; - - return ( - <QueryClientProvider client={queryClient}> - <AuthProvider>{children}</AuthProvider> - </QueryClientProvider> - ); - }; + const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => @@ -116,8 +103,8 @@ describe("AuthProvider", () => { const response = await apiClient.GET("/api/v1/brokerage-accounts"); expect(response.response.status).toBe(401); - await waitFor(() => expect(result.current.user).toBeNull()); - expect(result.current.accessToken).toBeNull(); + await waitFor(() => expect(result.current.user).toBeUndefined()); + expect(result.current.accessToken).toBeUndefined(); expect(queryClient.getQueryData(["cached-query"])).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); }); @@ -127,7 +114,7 @@ describe("AuthProvider", () => { http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: "test-token", - user: { id: "user-1", email: "user@example.com" }, + user: { email: "user@example.com", id: "user-1" }, }), ), ); @@ -147,14 +134,7 @@ describe("AuthProvider", () => { }); queryClient.setQueryData(portfolioKey, { source: "current-connection" }); queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]); - const wrapper: FC<PropsWithChildren> = (props) => { - const { children } = props; - return ( - <QueryClientProvider client={queryClient}> - <AuthProvider>{children}</AuthProvider> - </QueryClientProvider> - ); - }; + const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.accessToken).toBe("test-token")); @@ -163,8 +143,8 @@ describe("AuthProvider", () => { ]); await act(() => result.current.logout()); - expect(result.current.user).toBeNull(); - expect(result.current.accessToken).toBeNull(); + expect(result.current.user).toBeUndefined(); + expect(result.current.accessToken).toBeUndefined(); expect(queryClient.getQueryData(statusKey)).toBeUndefined(); expect(queryClient.getQueryData(portfolioKey)).toBeUndefined(); expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined(); @@ -177,10 +157,7 @@ describe("AuthProvider", () => { ])( "serializes late refresh (failure=%s, login=%s)", async (failure, loginAfterLogout) => { - let release!: () => void; - const pending = new Promise<void>((resolve) => { - release = resolve; - }); + const { promise: pending, resolve: release } = Promise.withResolvers<void>(); let calls = 0; const order: string[] = []; server.use( @@ -194,7 +171,7 @@ describe("AuthProvider", () => { ? HttpResponse.error() : HttpResponse.json({ accessToken: "a", - user: { id: "a", email: "a@example.com" }, + user: { email: "a@example.com", id: "a" }, }); }), http.post("*/api/v1/auth/logout", () => { @@ -205,7 +182,7 @@ describe("AuthProvider", () => { order.push("login-cookie"); return HttpResponse.json({ accessToken: "b", - user: { id: "b", email: "b@example.com" }, + user: { email: "b@example.com", id: "b" }, }); }), http.get("*/api/v1/brokerage-accounts", () => @@ -213,22 +190,20 @@ describe("AuthProvider", () => { ), ); const queryClient = createTestQueryClient(); - const wrapper: FC<PropsWithChildren> = ({ children }) => ( - <QueryClientProvider client={queryClient}> - <AuthProvider>{children}</AuthProvider> - </QueryClientProvider> - ); + const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); const oldRequest = apiClient .GET("/api/v1/brokerage-accounts") - .catch(() => null); + + // eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query. + .catch(() => {}); await waitFor(() => expect(order).toContain("refresh-start")); let logout!: Promise<void>; act(() => { logout = result.current.logout(); }); - await waitFor(() => expect(result.current.user).toBeNull()); + await waitFor(() => expect(result.current.user).toBeUndefined()); let login!: Promise<void>; act(() => { login = loginAfterLogout @@ -239,8 +214,8 @@ describe("AuthProvider", () => { release(); await Promise.all([oldRequest, logout, login]); }); - expect(result.current.user?.id ?? null).toBe( - loginAfterLogout ? "b" : null, + expect(result.current.user?.id ?? /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null).toBe( + loginAfterLogout ? "b" : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, ); expect(order).toEqual([ "refresh-start", @@ -257,16 +232,12 @@ describe("AuthProvider", () => { http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: identity, - user: { id: identity, email: `${identity}@example.com` }, + user: { email: `${identity}@example.com`, id: identity }, }), ), ); const queryClient = createTestQueryClient(); - const wrapper: FC<PropsWithChildren> = ({ children }) => ( - <QueryClientProvider client={queryClient}> - <AuthProvider>{children}</AuthProvider> - </QueryClientProvider> - ); + const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient); const { result } = renderHook(() => useAuth(), { wrapper }); await waitFor(() => expect(result.current.ready).toBe(true)); queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]); @@ -274,14 +245,16 @@ describe("AuthProvider", () => { expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]); let release!: (value: string) => void; const pending = queryClient - .fetchQuery({ + .query({ + queryFn: () => { + const deferred = Promise.withResolvers<string>(); + release = deferred.resolve; + return deferred.promise; + }, queryKey: ["pending-finance"], - queryFn: () => - new Promise<string>((resolve) => { - release = resolve; - }), }) - .catch(() => null); + // eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query. + .catch(() => {}); identity = "b"; await act(() => result.current.refresh()); release("a-data"); @@ -296,7 +269,7 @@ describe("AuthProvider", () => { http.post("*/api/v1/auth/refresh", () => HttpResponse.json({ accessToken: identity, - user: { id: identity, email: `${identity}@example.com` }, + user: { email: `${identity}@example.com`, id: identity }, }), ), http.get("*/api/v1/bank-cards", () => @@ -304,11 +277,7 @@ describe("AuthProvider", () => { ), ); const queryClient = createTestQueryClient(); - const wrapper: FC<PropsWithChildren> = (props) => ( - <QueryClientProvider client={queryClient}> - <AuthProvider>{props.children}</AuthProvider> - </QueryClientProvider> - ); + const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient); const { result } = renderHook( () => ({ auth: useAuth(), diff --git a/src/features/auth/model/auth.tsx b/src/features/auth/model/auth.tsx index 4f7c160..da42a57 100644 --- a/src/features/auth/model/auth.tsx +++ b/src/features/auth/model/auth.tsx @@ -1,13 +1,16 @@ -import { useQueryClient } from "@tanstack/react-query"; import type { FC, PropsWithChildren } from "react"; + +import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; + import { apiClient, configureAuthClient, type paths } from "@/shared/api"; + import { AuthContext, type User } from "./auth-context"; -type Authentication = - paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"]; -type Credentials = - paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"]; +type Authentication + = paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"]; +type Credentials + = paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"]; async function request( path: "/api/v1/auth/login" | "/api/v1/auth/register", email: string, @@ -24,32 +27,34 @@ async function request( const AuthProvider: FC<PropsWithChildren> = (props) => { const { children } = props; const queryClient = useQueryClient(); - const [user, setUser] = useState<User | null>(null); - const [accessToken, setAccessToken] = useState<string | null>(null); - const accessTokenRef = useRef<string | null>(null); + const [user, setUser] = useState<undefined | User>(undefined); + const [accessToken, setAccessToken] = useState<string | undefined>(undefined); + const accessTokenRef = useRef<string | undefined>(undefined); const [ready, setReady] = useState(false); - const generation = useRef(0); - const userRef = useRef<User | null>(null); - const authQueue = useRef<Promise<unknown>>(Promise.resolve()); - const refreshPromise = useRef<Promise<boolean> | null>(null); + const generationRef = useRef(0); + const userRef = useRef<undefined | User>(undefined); + const authQueueRef = useRef<Promise<unknown>>(Promise.resolve()); + const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined); // Cookie-writing requests share ordering, including across tabs with Web Locks. const serialize = useCallback(<T,>(run: () => Promise<T>): Promise<T> => { - const next = authQueue.current.then(async () => + // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. + const next = authQueueRef.current.then(async () => "locks" in navigator ? navigator.locks.request("wealth-ledger-refresh", run) : run(), ); - authQueue.current = next.catch(() => undefined); + // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. + authQueueRef.current = next.catch(() => {}); return next; }, []); const applyAuthentication = useCallback( (authentication: Authentication) => { - const changedIdentity = - userRef.current && userRef.current.id !== authentication.user.id; + const changedIdentity + = userRef.current && userRef.current.id !== authentication.user.id; accessTokenRef.current = authentication.accessToken; if (changedIdentity) { - generation.current += 1; + generationRef.current += 1; queryClient.removeQueries({ type: "inactive" }); queryClient.getMutationCache().clear(); void queryClient.resetQueries(); @@ -61,36 +66,38 @@ const AuthProvider: FC<PropsWithChildren> = (props) => { [queryClient], ); const clearSession = useCallback(() => { - generation.current += 1; - userRef.current = null; - setUser(null); - setAccessToken(null); - accessTokenRef.current = null; + generationRef.current += 1; + userRef.current = undefined; + setUser(undefined); + setAccessToken(undefined); + accessTokenRef.current = undefined; queryClient.clear(); }, [queryClient]); const refresh = useCallback((): Promise<boolean> => { - if (refreshPromise.current) return refreshPromise.current; - const started = generation.current; + if (refreshPromiseRef.current) return refreshPromiseRef.current; + const started = generationRef.current; const pending = serialize(async () => { - if (started !== generation.current) return false; + if (started !== generationRef.current) return false; try { const { data } = await apiClient.POST("/api/v1/auth/refresh"); - if (started !== generation.current) return false; + if (started !== generationRef.current) return false; if (!data) { clearSession(); return false; } applyAuthentication(data); return true; - } catch { - if (started === generation.current) clearSession(); + } + catch { + if (started === generationRef.current) clearSession(); return false; } + // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. }).finally(() => { - if (refreshPromise.current === pending) refreshPromise.current = null; + if (refreshPromiseRef.current === pending) refreshPromiseRef.current = undefined; }); - refreshPromise.current = pending; + refreshPromiseRef.current = pending; return pending; }, [applyAuthentication, clearSession, serialize]); @@ -104,10 +111,10 @@ const AuthProvider: FC<PropsWithChildren> = (props) => { password: string, ) { clearSession(); - const started = generation.current; + const started = generationRef.current; await serialize(async () => { const authentication = await request(path, email, password); - if (started === generation.current) applyAuthentication(authentication); + if (started === generationRef.current) applyAuthentication(authentication); }); } @@ -121,25 +128,25 @@ const AuthProvider: FC<PropsWithChildren> = (props) => { useEffect(() => { configureAuthClient({ accessToken: () => accessTokenRef.current, - generation: () => generation.current, - refresh, clear: clearSession, + generation: () => generationRef.current, + refresh, }); }); - useEffect(() => () => configureAuthClient(null), []); + useEffect(() => () => configureAuthClient(), []); return ( <AuthContext value={{ - user, accessToken, - ready, login: (email, password) => authenticate("/api/v1/auth/login", email, password), + logout, + ready, + refresh, register: (email, password) => authenticate("/api/v1/auth/register", email, password), - logout, - refresh, + user, }} > {children} diff --git a/src/features/auth/model/validation.ts b/src/features/auth/model/validation.ts index 6b7877d..77d6675 100644 --- a/src/features/auth/model/validation.ts +++ b/src/features/auth/model/validation.ts @@ -1,6 +1,9 @@ import { z } from "zod"; + import { validationMessage } from "@/shared/i18n"; +const EMAIL_PART_COUNT = 2; + const ASCII_SPACE = 32; const ASCII_DELETE = 127; const MAX_EMAIL_LENGTH = 254; @@ -8,10 +11,11 @@ const MIN_PASSWORD_BYTES = 12; const MAX_PASSWORD_BYTES = 72; function isEmailCharacterInvalid(character: string): boolean { + const codePoint = character.codePointAt(0); return ( - /\s/.test(character) || - character.charCodeAt(0) < ASCII_SPACE || - character.charCodeAt(0) === ASCII_DELETE + /\s/.test(character) + || (codePoint !== undefined && codePoint < ASCII_SPACE) + || codePoint === ASCII_DELETE ); } @@ -21,18 +25,18 @@ const email = z .toLowerCase() .max(MAX_EMAIL_LENGTH) .refine( - (value) => - value.split("@").length === 2 && - value.split("@").every(Boolean) && - !value.split("").some(isEmailCharacterInvalid), + value => + value.split("@").length === EMAIL_PART_COUNT + && value.split("@").every(Boolean) + && value.split("").every(element => !isEmailCharacterInvalid(element)), { error: validationMessage("auth.validation.invalidEmail") }, ); const password = z .string() .refine( - (value) => - new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES && - new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES, + value => + new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES + && new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES, { error: validationMessage("auth.validation.invalidPasswordLength") }, ); @@ -42,10 +46,10 @@ const loginSchema = z.object({ passwordConfirmation: z.string().optional(), }); const registerSchema = loginSchema.refine( - (value) => value.password === value.passwordConfirmation, + value => value.password === value.passwordConfirmation, { - path: ["passwordConfirmation"], error: validationMessage("auth.validation.passwordsDoNotMatch"), + path: ["passwordConfirmation"], }, ); diff --git a/src/features/auth/ui/AuthForm/AuthForm.test.tsx b/src/features/auth/ui/AuthForm/AuthForm.test.tsx index e9f22bb..2e0cd61 100644 --- a/src/features/auth/ui/AuthForm/AuthForm.test.tsx +++ b/src/features/auth/ui/AuthForm/AuthForm.test.tsx @@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import { describe, expect, it, vi } from "vitest"; + import { AuthForm } from "./AuthForm"; describe("AuthForm", () => { @@ -10,7 +11,7 @@ describe("AuthForm", () => { render( <MantineProvider> <MemoryRouter> - <AuthForm register onSubmit={onSubmit} /> + <AuthForm onSubmit={onSubmit} register /> </MemoryRouter> </MantineProvider>, ); diff --git a/src/features/auth/ui/AuthForm/AuthForm.tsx b/src/features/auth/ui/AuthForm/AuthForm.tsx index aee8192..4b1c560 100644 --- a/src/features/auth/ui/AuthForm/AuthForm.tsx +++ b/src/features/auth/ui/AuthForm/AuthForm.tsx @@ -1,10 +1,12 @@ +import type { FC } from "react"; + import { zodResolver } from "@hookform/resolvers/zod"; import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core"; -import type { FC } from "react"; import { useState } from "react"; import { FormProvider, type Resolver, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; + import { type AuthFormValues, loginSchema, @@ -13,12 +15,12 @@ import { import classes from "./AuthForm.module.css"; type AuthFormProps = { - register?: true; onSubmit: (email: string, password: string) => Promise<void>; + register?: true; }; const AuthForm: FC<AuthFormProps> = (props) => { - const { register, onSubmit } = props; + const { onSubmit, register } = props; const { t } = useTranslation(); const schema = register ? registerSchema : loginSchema; const methods = useForm<AuthFormValues>({ @@ -27,7 +29,7 @@ const AuthForm: FC<AuthFormProps> = (props) => { const { formState: { errors, isSubmitting }, } = methods; - const [submitError, setSubmitError] = useState<string | null>(null); + const [submitError, setSubmitError] = useState<null | string>(null); return ( <FormProvider {...methods}> <form @@ -35,7 +37,8 @@ const AuthForm: FC<AuthFormProps> = (props) => { setSubmitError(null); try { await onSubmit(values.email, values.password); - } catch { + } + catch { setSubmitError(t("auth.requestError")); } })} @@ -60,7 +63,7 @@ const AuthForm: FC<AuthFormProps> = (props) => { /> )} {submitError && <Alert color="red">{submitError}</Alert>} - <Button type="submit" loading={isSubmitting}> + <Button loading={isSubmitting} type="submit"> {t(register ? "auth.register" : "auth.login")} </Button> <Button diff --git a/src/features/auth/ui/AuthPageLayout/AuthPageLayout.tsx b/src/features/auth/ui/AuthPageLayout/AuthPageLayout.tsx index 69439ad..d96773f 100644 --- a/src/features/auth/ui/AuthPageLayout/AuthPageLayout.tsx +++ b/src/features/auth/ui/AuthPageLayout/AuthPageLayout.tsx @@ -1,5 +1,7 @@ -import { Paper, Stack, Title } from "@mantine/core"; import type { FC, PropsWithChildren } from "react"; + +import { Paper, Stack, Title } from "@mantine/core"; + import classes from "./AuthPageLayout.module.css"; type AuthPageLayoutProps = PropsWithChildren<{ title: string }>; diff --git a/src/features/auth/ui/LoginForm.tsx b/src/features/auth/ui/LoginForm.tsx index 12c80d8..0a55834 100644 --- a/src/features/auth/ui/LoginForm.tsx +++ b/src/features/auth/ui/LoginForm.tsx @@ -1,5 +1,7 @@ import type { FC } from "react"; + import { useLocation, useNavigate } from "react-router"; + import { useAuth } from "../model/auth-context"; import { AuthForm } from "./AuthForm/AuthForm"; @@ -7,8 +9,8 @@ const LoginForm: FC = () => { const { login } = useAuth(); const navigate = useNavigate(); const location = useLocation(); - const returnTo = - (location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts"; + const returnTo + = (location.state as null | { returnTo?: string })?.returnTo ?? "/accounts"; return ( <AuthForm diff --git a/src/features/auth/ui/RegisterForm.tsx b/src/features/auth/ui/RegisterForm.tsx index 14d7149..61afca4 100644 --- a/src/features/auth/ui/RegisterForm.tsx +++ b/src/features/auth/ui/RegisterForm.tsx @@ -1,5 +1,7 @@ import type { FC } from "react"; + import { useLocation, useNavigate } from "react-router"; + import { useAuth } from "../model/auth-context"; import { AuthForm } from "./AuthForm/AuthForm"; @@ -7,16 +9,16 @@ const RegisterForm: FC = () => { const { register } = useAuth(); const navigate = useNavigate(); const location = useLocation(); - const returnTo = - (location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts"; + const returnTo + = (location.state as null | { returnTo?: string })?.returnTo ?? "/accounts"; return ( <AuthForm - register onSubmit={async (email, password) => { await register(email, password); await navigate(returnTo, { replace: true }); }} + register /> ); }; diff --git a/src/features/instrument-details/model/section.test.ts b/src/features/instrument-details/model/section.test.ts index e241987..d32f363 100644 --- a/src/features/instrument-details/model/section.test.ts +++ b/src/features/instrument-details/model/section.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; + import { resource } from "@/test/resource"; + import { section } from "./section"; describe("instrument source and transport presentation", () => { @@ -9,9 +11,9 @@ describe("instrument source and transport presentation", () => { const data = availability === "missing" ? undefined : { rows: [] }; const state = section({ data, - meta: resource(data, { availability }).meta, isError: false, isFetching: false, + meta: resource(data, { availability }).meta, refetch: vi.fn(), }); expect(state.loading).toBe(availability === "missing"); @@ -23,37 +25,39 @@ describe("instrument source and transport presentation", () => { const retry = vi.fn(); const refetch = vi.fn(); const meta = resource([], { - sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" }, + sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, }).meta; const state = section( - { data: [], meta, isError: false, isFetching: false, refetch }, + { data: [], isError: false, isFetching: false, meta, refetch }, retry, ); expect(state).toMatchObject({ - hasData: true, - loading: false, error: true, - refreshing: false, + hasData: true, lastSuccessfulAt: meta.lastSuccessfulAt, + loading: false, + refreshing: false, }); state.retry(); expect(retry).toHaveBeenCalledOnce(); expect(refetch).not.toHaveBeenCalled(); }); it("shows source failure without a false loading or empty result", () => { - const state = section({ - data: undefined, - meta: resource(null, { - sync: { id: "work", state: "failed", errorCode: "TBANK_UNAVAILABLE" }, - }).meta, - isError: false, - isFetching: false, - refetch: vi.fn(), - }); + const state = section(( + { + data: undefined, + isError: false, + isFetching: false, + meta: resource(null, { + sync: { errorCode: "TBANK_UNAVAILABLE", id: "work", state: "failed" }, + }).meta, + refetch: vi.fn(), + } + )); expect(state).toMatchObject({ + error: true, hasData: false, loading: false, - error: true, }); }); }); diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts index 109acb9..a1a9161 100644 --- a/src/features/instrument-details/model/section.ts +++ b/src/features/instrument-details/model/section.ts @@ -1,46 +1,46 @@ import type { paths } from "@/shared/api"; -type Meta = - paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; +type Meta + = paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; type SectionState = { - stale?: boolean; - sourceError?: boolean; - partial?: boolean; - lastSuccessfulAt?: string | null; - hasData: boolean; - loading: boolean; error: boolean; + hasData: boolean; + lastSuccessfulAt?: null | string; + loading: boolean; + partial?: boolean; refreshing: boolean; retry: () => void; + sourceError?: boolean; + stale?: boolean; }; function section( query: { data: unknown; - meta?: Meta; isError: boolean; isFetching: boolean; + meta?: Meta; refetch: () => unknown; }, retry?: () => unknown, ): SectionState { - const error = query.isError || query.meta?.sync.state === "failed"; + const isError = query.isError || query.meta?.sync.state === "failed"; return { - stale: query.meta?.freshness === "stale", - sourceError: query.meta?.sync.state === "failed", - partial: query.meta?.availability === "partial", - lastSuccessfulAt: query.meta?.lastSuccessfulAt, + error: isError, hasData: Boolean(query.data), - loading: !query.data && !error, - error, + lastSuccessfulAt: query.meta?.lastSuccessfulAt, + loading: !query.data && !isError, + partial: query.meta?.availability === "partial", refreshing: - Boolean(query.data) && - (query.isFetching || - query.meta?.sync.state === "running" || - query.meta?.sync.state === "queued"), + Boolean(query.data) + && (query.isFetching + || query.meta?.sync.state === "running" + || query.meta?.sync.state === "queued"), retry: () => { void (retry ?? query.refetch)(); }, + sourceError: query.meta?.sync.state === "failed", + stale: query.meta?.freshness === "stale", }; } -export { type SectionState, section }; +export { section, type SectionState }; diff --git a/src/features/instrument-details/model/types.ts b/src/features/instrument-details/model/types.ts index f3dbd2d..959561f 100644 --- a/src/features/instrument-details/model/types.ts +++ b/src/features/instrument-details/model/types.ts @@ -1,38 +1,39 @@ import type { BrokerageOperation } from "@/entities/broker-operation"; import type { paths } from "@/shared/api"; + import type { SectionState } from "./section"; -type Position = NonNullable< - paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"] ->; type Candles = NonNullable< paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]["data"] >; type ChartRow = Candles["candles"][number] & { date: string; price: number }; +type InstrumentChartVm = { + averageLine: null | number; + candles?: Candles; + candlesState: SectionState; + chartData: ChartRow[]; + hasIncompleteCandle: boolean; + period: string; + position?: Position; + priceRows: ChartRow[]; + setPeriod: (value: string) => void; +}; +type InstrumentEventsVm = { + eventRows: BrokerageOperation[]; + filter: string; + hasPrevious: boolean; + onNext: () => void; + onPrevious: () => void; + operations?: { hasNext: boolean }; + operationsState: SectionState; + setFilter: (value: string) => void; +}; type InstrumentPositionVm = { position?: Position; positionState: SectionState; }; -type InstrumentChartVm = { - candles?: Candles; - candlesState: SectionState; - chartData: ChartRow[]; - priceRows: ChartRow[]; - period: string; - setPeriod: (value: string) => void; - averageLine: number | null; - hasIncompleteCandle: boolean; - position?: Position; -}; -type InstrumentEventsVm = { - operations?: { hasNext: boolean }; - operationsState: SectionState; - filter: string; - setFilter: (value: string) => void; - eventRows: BrokerageOperation[]; - hasPrevious: boolean; - onPrevious: () => void; - onNext: () => void; -}; +type Position = NonNullable< + paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]["data"] +>; export type { InstrumentChartVm, InstrumentEventsVm, InstrumentPositionVm }; diff --git a/src/features/instrument-details/model/useInstrumentOperations.ts b/src/features/instrument-details/model/useInstrumentOperations.ts index 2042dce..bb60525 100644 --- a/src/features/instrument-details/model/useInstrumentOperations.ts +++ b/src/features/instrument-details/model/useInstrumentOperations.ts @@ -1,5 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; + import { brokerageOperationsQueryOptions, type OperationCategory, @@ -14,37 +15,38 @@ function useInstrumentOperations( ) { const scope = `${uid}:${filter}`; const [pagination, setPagination] = useState<{ - scope: string; cursors: string[]; - }>({ scope, cursors: [] }); + scope: string; + }>({ cursors: [], scope }); const cursors = pagination.scope === scope ? pagination.cursors : []; const client = useQueryClient(); - const firstPage = { instrumentUid: uid, categories, limit: 20 }; + const firstPage = { categories, instrumentUid: uid, limit: 20 }; const operations = useBrokerageOperations(uid ? "all" : undefined, { ...firstPage, cursor: cursors.at(-1), }); const retry = (retryScreen: () => unknown) => { if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") { - setPagination({ scope, cursors: [] }); + setPagination({ cursors: [], scope }); void client.invalidateQueries({ - queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, exact: true, + queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey, }); - } else void retryScreen(); + } + else void retryScreen(); }; return { - operations, - retry, hasPrevious: cursors.length > 0, - onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }), onNext: () => { if (operations.data?.nextCursor) setPagination({ - scope, cursors: [...cursors, operations.data.nextCursor], + scope, }); }, + onPrevious: () => setPagination({ cursors: cursors.slice(0, -1), scope }), + operations, + retry, }; } diff --git a/src/features/instrument-details/ui/InstrumentChart.view.tsx b/src/features/instrument-details/ui/InstrumentChart.view.tsx index 100050e..de6036c 100644 --- a/src/features/instrument-details/ui/InstrumentChart.view.tsx +++ b/src/features/instrument-details/ui/InstrumentChart.view.tsx @@ -1,3 +1,5 @@ +import type { FC } from "react"; + import { AreaChart } from "@mantine/charts"; import { Accordion, @@ -7,185 +9,187 @@ import { Table, Text, } from "@mantine/core"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; + import { formatDecimal, formatMoney } from "@/shared/lib"; + import type { InstrumentChartVm } from "../model/types"; + import classes from "./InstrumentDetails.module.css"; import { InstrumentSection } from "./InstrumentSection.view"; -type InstrumentChartProps = { vm: InstrumentChartVm; priceUnit?: "percent" }; +type InstrumentChartProps = { priceUnit?: "percent"; vm: InstrumentChartVm }; const InstrumentChart: FC<InstrumentChartProps> = (props) => { const { vm } = props; const { t } = useTranslation(); + const priceLabel = (value: string) => { + if (props.priceUnit === "percent") return `${formatDecimal(value)} %`; + const currency = vm.candles?.currency; + return formatMoney(currency ? { currency, value } : undefined); + }; + return ( <InstrumentSection - title={t("stock.chart")} - state={vm.candlesState} - large - controls={ + controls={( <div className={classes.controls}> <SegmentedControl aria-label={t("stock.period")} - value={vm.period} - onChange={vm.setPeriod} - data={["month", "halfYear", "year", "all"].map((value) => ({ - value, + data={["month", "halfYear", "year", "all"].map(value => ({ label: t(`stock.periods.${value}`), + value, }))} + onChange={vm.setPeriod} + value={vm.period} /> </div> - } + )} + large + state={vm.candlesState} + title={t("stock.chart")} > - {vm.candles ? ( - vm.chartData.length ? ( - <> - <AreaChart - h={310} - data={vm.chartData} - dataKey="date" - series={[ - { name: "price", color: "blue.6", label: t("stock.price") }, - ]} - curveType="linear" - withDots={vm.chartData.length === 1} - withGradient - gridAxis="y" - tickLine="none" - tooltipAnimationDuration={0} - areaProps={{ isAnimationActive: false }} - yAxisProps={{ domain: ["auto", "auto"], width: 64 }} - referenceLines={ - vm.averageLine === null - ? [] - : [ - { - y: vm.averageLine, - ifOverflow: "extendDomain", - color: "orange.7", - label: t("stock.averageLine"), - }, - ] - } - valueFormatter={(value) => - new Intl.NumberFormat("ru-RU", { - maximumFractionDigits: 4, - }).format(value) - } - aria-label={t( - props.priceUnit === "percent" - ? "bond.chartLabel" - : "stock.chartLabel", - { - currency: + {!!(vm.candles) && (( + vm.chartData.length > 0 + ? ( + <> + <AreaChart + areaProps={{ isAnimationActive: false }} + aria-label={t( + props.priceUnit === "percent" + ? "bond.chartLabel" + : "stock.chartLabel", + { + currency: props.priceUnit === "percent" ? t("bond.percentOfNominal") : (vm.candles.currency ?? ""), - }, - )} - /> - <Text size="xs" c="dimmed" mt="sm"> - {t( - props.priceUnit === "percent" - ? "bond.chartNote" - : "stock.chartNote", - )} - </Text> - {vm.hasIncompleteCandle ? ( - <Text size="xs" c="dimmed"> - {t("stock.incomplete")} - </Text> - ) : null} - {vm.averageLine !== null ? ( - <Text size="sm" mt="xs"> - {t("stock.averageLine")}:{" "} - {formatMoney(vm.position?.averagePurchasePrice ?? null)} - </Text> - ) : null} - <Accordion - className={classes.priceTable} - variant="contained" - radius="md" - > - <Accordion.Item value="prices"> - <Accordion.Control> - <Text fw={600}>{t("stock.priceTable")}</Text> - <Text size="xs" c="dimmed"> - {t("stock.priceTableHint", { count: vm.chartData.length })} + }, + )} + curveType="linear" + data={vm.chartData} + dataKey="date" + gridAxis="y" + h={310} + referenceLines={ + vm.averageLine === null + ? [] + : [ + { + color: "orange.7", + ifOverflow: "extendDomain", + label: t("stock.averageLine"), + y: vm.averageLine, + }, + ] + } + series={[ + { color: "blue.6", label: t("stock.price"), name: "price" }, + ]} + tickLine="none" + tooltipAnimationDuration={0} + valueFormatter={value => + new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 4, + }).format(value)} + withDots={vm.chartData.length === 1} + withGradient + yAxisProps={{ domain: ["auto", "auto"], width: 64 }} + /> + <Text c="dimmed" mt="sm" size="xs"> + {t( + props.priceUnit === "percent" + ? "bond.chartNote" + : "stock.chartNote", + )} + </Text> + {!!(vm.hasIncompleteCandle) && (( + <Text c="dimmed" size="xs"> + {t("stock.incomplete")} </Text> - </Accordion.Control> - <Accordion.Panel> - <ScrollArea.Autosize - mah={360} - type="auto" - viewportProps={{ - tabIndex: 0, - role: "region", - "aria-label": t("stock.priceTable"), - }} - > - <Table - striped - highlightOnHover - stickyHeader - verticalSpacing="sm" - horizontalSpacing="md" - > - <Table.Thead> - <Table.Tr> - <Table.Th>{t("stock.date")}</Table.Th> - <Table.Th>{t("stock.price")}</Table.Th> - <Table.Th>{t("stock.candle")}</Table.Th> - </Table.Tr> - </Table.Thead> - <Table.Tbody> - {vm.priceRows.map((candle) => ( - <Table.Tr key={candle.time}> - <Table.Td>{candle.date}</Table.Td> - <Table.Td> - {props.priceUnit === "percent" - ? `${formatDecimal(candle.close)} %` - : formatMoney( - vm.candles?.currency - ? { - value: candle.close, - currency: vm.candles.currency, - } - : null, - )} - </Table.Td> - <Table.Td> - <Badge - variant="light" - color={candle.isComplete ? "gray" : "blue"} - size="sm" - > - {t( - candle.isComplete - ? "stock.complete" - : "stock.forming", - )} - </Badge> - </Table.Td> - </Table.Tr> - ))} - </Table.Tbody> - </Table> - </ScrollArea.Autosize> - </Accordion.Panel> - </Accordion.Item> - </Accordion> - </> - ) : ( - <Text c="dimmed"> - {t( - vm.candlesState.partial - ? "investment.partial" - : "stock.noCandles", - )} - </Text> - ) - ) : null} + ))} + {vm.averageLine !== null && (( + <Text mt="xs" size="sm"> + {t("stock.averageLine")} + : + {" "} + {formatMoney(vm.position?.averagePurchasePrice)} + </Text> + ))} + <Accordion + className={classes.priceTable} + radius="md" + variant="contained" + > + <Accordion.Item value="prices"> + <Accordion.Control> + <Text fw={600}>{t("stock.priceTable")}</Text> + <Text c="dimmed" size="xs"> + {t("stock.priceTableHint", { count: vm.chartData.length })} + </Text> + </Accordion.Control> + <Accordion.Panel> + <ScrollArea.Autosize + mah={360} + type="auto" + viewportProps={{ + "aria-label": t("stock.priceTable"), + "role": "region", + "tabIndex": 0, + }} + > + <Table + highlightOnHover + horizontalSpacing="md" + stickyHeader + striped + verticalSpacing="sm" + > + <Table.Thead> + <Table.Tr> + <Table.Th>{t("stock.date")}</Table.Th> + <Table.Th>{t("stock.price")}</Table.Th> + <Table.Th>{t("stock.candle")}</Table.Th> + </Table.Tr> + </Table.Thead> + <Table.Tbody> + {vm.priceRows.map(candle => ( + <Table.Tr key={candle.time}> + <Table.Td>{candle.date}</Table.Td> + <Table.Td> + {priceLabel(candle.close)} + </Table.Td> + <Table.Td> + <Badge + color={candle.isComplete ? "gray" : "blue"} + size="sm" + variant="light" + > + {t( + candle.isComplete + ? "stock.complete" + : "stock.forming", + )} + </Badge> + </Table.Td> + </Table.Tr> + ))} + </Table.Tbody> + </Table> + </ScrollArea.Autosize> + </Accordion.Panel> + </Accordion.Item> + </Accordion> + </> + ) + : ( + <Text c="dimmed"> + {t( + vm.candlesState.partial + ? "investment.partial" + : "stock.noCandles", + )} + </Text> + ) + ))} </InstrumentSection> ); }; diff --git a/src/features/instrument-details/ui/InstrumentEvents.view.tsx b/src/features/instrument-details/ui/InstrumentEvents.view.tsx index 53195c0..df9de31 100644 --- a/src/features/instrument-details/ui/InstrumentEvents.view.tsx +++ b/src/features/instrument-details/ui/InstrumentEvents.view.tsx @@ -1,16 +1,20 @@ -import { Button, Group, SegmentedControl, Stack, Text } from "@mantine/core"; import type { FC } from "react"; + +import { Button, Group, SegmentedControl, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; + import { LedgerEntryRow } from "@/entities/broker-operation"; + import type { InstrumentEventsVm } from "../model/types"; + import classes from "./InstrumentDetails.module.css"; import { InstrumentSection } from "./InstrumentSection.view"; type InstrumentEventsProps = { - vm: InstrumentEventsVm; - incomeLabel?: string; - incomeFilter?: string; historyNote?: string; + incomeFilter?: string; + incomeLabel?: string; + vm: InstrumentEventsVm; }; const InstrumentEvents: FC<InstrumentEventsProps> = (props) => { @@ -18,73 +22,75 @@ const InstrumentEvents: FC<InstrumentEventsProps> = (props) => { const { t } = useTranslation(); return ( <InstrumentSection - title={t("stock.history")} - state={vm.operationsState} - controls={ + controls={( <div className={classes.controls}> <SegmentedControl aria-label={t("stock.eventFilter")} - value={vm.filter} - onChange={vm.setFilter} data={[ "all", "trades", props.incomeFilter ?? "dividends", "other", - ].map((value) => ({ - value, + ].map(value => ({ label: - value === (props.incomeFilter ?? "dividends") && - props.incomeLabel + value === (props.incomeFilter ?? "dividends") + && props.incomeLabel ? props.incomeLabel : t(`stock.filters.${value}`), + value, }))} + onChange={vm.setFilter} + value={vm.filter} /> </div> - } + )} + state={vm.operationsState} + title={t("stock.history")} > - {vm.operations ? ( + {!!(vm.operations) && (( <> - <Text size="xs" c="dimmed"> + <Text c="dimmed" size="xs"> {props.historyNote ?? t("stock.historyNote")} </Text> - {vm.eventRows.length ? ( - <Stack gap="sm" mt="md"> - {vm.eventRows.map((event) => ( - <LedgerEntryRow - key={`${event.account?.id}:${event.id}`} - operation={event} - withDetails - /> - ))} - </Stack> - ) : ( - <Text c="dimmed" py="lg"> - {t( - vm.operationsState.partial - ? "investment.partial" - : "stock.noOperations", + {vm.eventRows.length > 0 + ? ( + <Stack gap="sm" mt="md"> + {vm.eventRows.map(event => ( + <LedgerEntryRow + key={`${event.account?.id}:${event.id}`} + operation={event} + withDetails + /> + ))} + </Stack> + ) + : ( + <Text c="dimmed" py="lg"> + {t( + vm.operationsState.partial + ? "investment.partial" + : "stock.noOperations", + )} + </Text> )} - </Text> - )} <Group justify="space-between" mt="md"> <Button - variant="default" disabled={!vm.hasPrevious || vm.operationsState.refreshing} onClick={vm.onPrevious} + variant="default" > {t("stock.previous")} </Button> <Button - variant="default" disabled={!vm.operations.hasNext || vm.operationsState.refreshing} onClick={vm.onNext} + variant="default" > {t("stock.next")} </Button> </Group> </> - ) : null} + ))} </InstrumentSection> ); }; diff --git a/src/features/instrument-details/ui/InstrumentPosition.view.tsx b/src/features/instrument-details/ui/InstrumentPosition.view.tsx index ee23cd5..f4abd37 100644 --- a/src/features/instrument-details/ui/InstrumentPosition.view.tsx +++ b/src/features/instrument-details/ui/InstrumentPosition.view.tsx @@ -1,31 +1,35 @@ -import { Alert, Anchor, Stack, Text } from "@mantine/core"; import type { FC } from "react"; + +import { Alert, Anchor, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; + import { formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib"; + import type { InstrumentPositionVm } from "../model/types"; + import classes from "./InstrumentDetails.module.css"; import { InstrumentSection } from "./InstrumentSection.view"; type InstrumentPositionProps = { - vm: InstrumentPositionVm; - returnNote?: string; quantityLabel?: string; + returnNote?: string; + vm: InstrumentPositionVm; }; const InstrumentPosition: FC<InstrumentPositionProps> = (props) => { const { vm } = props; const { t } = useTranslation(); - if (vm.position && !vm.position.accounts.length) return null; + if (vm.position && vm.position.accounts.length === 0) return; return ( - <InstrumentSection title={t("stock.position")} state={vm.positionState}> - {vm.position ? ( + <InstrumentSection state={vm.positionState} title={t("stock.position")}> + {!!(vm.position) && (( <Stack gap="md"> - {vm.position.isStale ? ( + {!!(vm.position.isStale) && (( <Alert color="orange">{t("portfolio.staleWarning")}</Alert> - ) : null} + ))} <div className={`${classes.metrics} ${classes.holding}`}> <div> - <Text size="sm" c="dimmed"> + <Text c="dimmed" size="sm"> {props.quantityLabel ?? t("stock.quantity")} </Text> <Text className={classes.metricValue}> @@ -33,7 +37,7 @@ const InstrumentPosition: FC<InstrumentPositionProps> = (props) => { </Text> </div> <div> - <Text size="sm" c="dimmed"> + <Text c="dimmed" size="sm"> {t("stock.value")} </Text> <Text className={classes.metricValue}> @@ -41,7 +45,7 @@ const InstrumentPosition: FC<InstrumentPositionProps> = (props) => { </Text> </div> <div> - <Text size="sm" c="dimmed"> + <Text c="dimmed" size="sm"> {t("stock.average")} </Text> <Text className={classes.metricValue}> @@ -49,89 +53,93 @@ const InstrumentPosition: FC<InstrumentPositionProps> = (props) => { </Text> </div> <div> - <Text size="sm" c="dimmed"> + <Text c="dimmed" size="sm"> {t("stock.openReturn")} </Text> <Text - className={classes.metricValue} c={ vm.position.absoluteReturn?.value.startsWith("-") ? "red.8" : "green.8" } + className={classes.metricValue} > {formatMoney(vm.position.absoluteReturn)} </Text> - {vm.position.returnPercent !== null ? ( + {vm.position.returnPercent !== null && (( <Text size="sm"> - {formatDecimal(vm.position.returnPercent)} % + {formatDecimal(vm.position.returnPercent)} + {" "} + % </Text> - ) : null} + ))} </div> </div> - <Text size="xs" c="dimmed"> + <Text c="dimmed" size="xs"> {props.returnNote ?? t("stock.openReturnNote")} </Text> <div> - {vm.position.accounts.map((account) => ( + {vm.position.accounts.map(account => ( <div className={classes.accountRow} key={account.account?.id}> - {account.account ? ( - <Anchor - component={Link} - to={`/accounts/${encodeURIComponent(account.account.id)}`} - > - {account.account.name} - </Anchor> - ) : ( - <Text>{t("stock.portfolio")}</Text> - )} + {account.account + ? ( + <Anchor + component={Link} + to={`/accounts/${encodeURIComponent(account.account.id)}`} + > + {account.account.name} + </Anchor> + ) + : ( + <Text>{t("stock.portfolio")}</Text> + )} <div> - <Text size="xs" c="dimmed"> + <Text c="dimmed" size="xs"> {props.quantityLabel ?? t("stock.quantity")} </Text> <Text>{formatDecimal(account.quantity)}</Text> </div> <div> - <Text size="xs" c="dimmed"> + <Text c="dimmed" size="xs"> {t("stock.average")} </Text> <Text>{formatMoney(account.averagePurchasePrice)}</Text> </div> <div> - <Text size="xs" c="dimmed"> + <Text c="dimmed" size="xs"> {t("stock.value")} </Text> <Text>{formatMoney(account.currentValue)}</Text> </div> <div> - <Text size="xs" c="dimmed"> + <Text c="dimmed" size="xs"> {t("stock.openReturn")} </Text> <Text c={ - account.absoluteReturn?.value.startsWith("-") - ? "red.8" - : undefined + (account.absoluteReturn?.value.startsWith("-")) ? ("red.8") : undefined } > {formatMoney(account.absoluteReturn)} </Text> - {account.returnPercent !== null ? ( + {account.returnPercent !== null && (( <Text size="xs"> - {formatDecimal(account.returnPercent)} % + {formatDecimal(account.returnPercent)} + {" "} + % </Text> - ) : null} + ))} </div> </div> ))} </div> - <Text size="xs" c="dimmed"> + <Text c="dimmed" size="xs"> {t("stock.snapshotAt", { date: formatSnapshotTime(vm.position.observedAt), })} </Text> </Stack> - ) : null} + ))} </InstrumentSection> ); }; diff --git a/src/features/instrument-details/ui/InstrumentSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx index ff8fea3..e021b77 100644 --- a/src/features/instrument-details/ui/InstrumentSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -1,3 +1,5 @@ +import type { FC, PropsWithChildren, ReactNode } from "react"; + import { Alert, Button, @@ -9,28 +11,31 @@ import { Title, } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; -import type { FC, PropsWithChildren, ReactNode } from "react"; import { useTranslation } from "react-i18next"; + import { formatSnapshotTime } from "@/shared/lib"; + import type { SectionState } from "../model/section"; + import classes from "./InstrumentDetails.module.css"; const CHART_SKELETON_HEIGHT_PX = 280; const SECTION_SKELETON_HEIGHT_PX = 65; type InstrumentSectionProps = PropsWithChildren<{ - title: string; - state: SectionState; controls?: ReactNode; - large?: boolean; embedded?: boolean; + large?: boolean; + state: SectionState; + title: string; }>; const InstrumentSection: FC<InstrumentSectionProps> = (props) => { const { t } = useTranslation(); + const readError = t(props.state.hasData ? "accounts.refreshError" : "stock.sectionError"); return ( <Paper - component="section" className={props.embedded ? classes.embeddedSection : classes.section} + component="section" withBorder={!props.embedded} > <Group justify="space-between" mb="md"> @@ -39,63 +44,63 @@ const InstrumentSection: FC<InstrumentSectionProps> = (props) => { {props.controls} - {props.state.stale ? ( + {!!(props.state.stale) && (( {t("investment.stale")} - ) : null} - {props.state.partial ? ( + ))} + {!!(props.state.partial) && (( {t("investment.partial")} - ) : null} - {props.state.lastSuccessfulAt ? ( - + ))} + {!!(props.state.lastSuccessfulAt) && (( + {t("investment.sourceTime", { date: formatSnapshotTime(props.state.lastSuccessfulAt), })} - ) : null} - {props.state.refreshing ? ( - + ))} + {!!(props.state.refreshing) && (( + {t("accounts.refreshing")} - ) : null} - {props.state.error ? ( - + ))} + {!!(props.state.error) && (( + {props.state.sourceError ? t("investment.syncFailed") - : props.state.hasData - ? t("accounts.refreshError") - : t("stock.sectionError")} + : readError} - ) : null} - {props.state.loading ? ( - - - - - - - ) : ( - props.children - )} + ))} + {props.state.loading + ? ( + + + + + + + ) + : ( + props.children + )} ); }; diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index 14670ba..5e23015 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -1,12 +1,13 @@ import type { QueryClient } from "@tanstack/react-query"; + import { apiQueryClient, invalidateApiRequests, type paths, } from "@/shared/api"; -type TbankConnectionStatus = - paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; +type TbankConnectionStatus + = paths["/api/v1/tbank-connection"]["get"]["responses"][200]["content"]["application/json"]; const brokerageAccountsPath = "/api/v1/brokerage-accounts"; @@ -20,26 +21,26 @@ function commitTbankConnectionBoundary( status: TbankConnectionStatus, ) { invalidateApiRequests( - (path) => - path.startsWith("/api/v1/brokerage-accounts") || - path.startsWith("/api/v1/stocks") || - path.startsWith("/api/v1/bonds") || - path.startsWith("/api/v1/tbank-sync"), + path => + path.startsWith("/api/v1/brokerage-accounts") + || path.startsWith("/api/v1/stocks") + || path.startsWith("/api/v1/bonds") + || path.startsWith("/api/v1/tbank-sync"), ); queryClient.setQueryData(tbankConnectionQueryKey, status); void queryClient.resetQueries({ predicate: ({ queryKey }) => { const [method, path] = queryKey; return ( - method === "get" && - typeof path === "string" && - (path.startsWith("/api/v1/tbank-sync") || - path === "/api/v1/bonds" || - path.startsWith("/api/v1/bonds/") || - path === "/api/v1/stocks" || - path.startsWith("/api/v1/stocks/") || - path === brokerageAccountsPath || - path.startsWith(`${brokerageAccountsPath}/`)) + method === "get" + && typeof path === "string" + && (path.startsWith("/api/v1/tbank-sync") + || path === "/api/v1/bonds" + || path.startsWith("/api/v1/bonds/") + || path === "/api/v1/stocks" + || path.startsWith("/api/v1/stocks/") + || path === brokerageAccountsPath + || path.startsWith(`${brokerageAccountsPath}/`)) ); }, }); @@ -47,6 +48,6 @@ function commitTbankConnectionBoundary( export { commitTbankConnectionBoundary, - type TbankConnectionStatus, tbankConnectionQueryOptions, + type TbankConnectionStatus, }; diff --git a/src/features/tbank-connection/model/screen-sync.test.tsx b/src/features/tbank-connection/model/screen-sync.test.tsx index ebf431b..fec1d07 100644 --- a/src/features/tbank-connection/model/screen-sync.test.tsx +++ b/src/features/tbank-connection/model/screen-sync.test.tsx @@ -1,41 +1,41 @@ import { act, renderHook, waitFor } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; + import { useBrokerageAccounts } from "@/entities/brokerage-account"; import { useStockQuote } from "@/entities/stock"; import { configureAuthClient, invalidateResourceAccess } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; + import { useScreenSync } from "../index"; import { commitTbankConnectionBoundary } from "./connection-boundary"; function auth() { configureAuthClient({ accessToken: () => "synthetic", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); } describe("page-owned screen synchronization", () => { it("ignores a late sync acceptance after the account scope changes", async () => { auth(); - let release!: () => void; - const blocked = new Promise((resolve) => { - release = resolve; - }); + + const { promise: blocked, resolve: release } = Promise.withResolvers(); const posted: string[] = []; const polled: string[] = []; server.use( http.post("*/api/v1/tbank-sync", async ({ request }) => { const scope = (await request.json()) as { - screen: "account"; accountId: string; + screen: "account"; }; posted.push(scope.accountId); await blocked; return HttpResponse.json( - { id: scope.accountId, scope, now: "2026-09-25T00:00:00Z" }, + { id: scope.accountId, now: "2026-09-25T00:00:00Z", scope }, { status: 202 }, ); }), @@ -43,13 +43,13 @@ describe("page-owned screen synchronization", () => { polled.push(String(params.id)); return HttpResponse.json({ id: params.id, - state: "succeeded", resources: [], + state: "succeeded", }); }), ); - const { result, rerender } = renderHook( - ({ accountId }) => useScreenSync({ screen: "account", accountId }, []), + const { rerender, result } = renderHook( + ({ accountId }) => useScreenSync({ accountId, screen: "account" }, []), { initialProps: { accountId: "old" }, wrapper: createQueryWrapper().wrapper, @@ -73,13 +73,13 @@ describe("page-owned screen synchronization", () => { "retries a failed GET without forcing the source (cached=%s)", async (cached) => { auth(); - const modes: (string | null)[] = []; - let unavailable = !cached; + const modes: (null | string)[] = []; + let isUnavailable = !cached; let posts = 0; server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { modes.push(new URL(request.url).searchParams.get("sync")); - return unavailable + return isUnavailable ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) : HttpResponse.json( resource([], { stateVersion: String(modes.length) }), @@ -90,7 +90,7 @@ describe("page-owned screen synchronization", () => { return new HttpResponse(null, { status: 500 }); }), ); - const { wrapper, queryClient } = createQueryWrapper(); + const { queryClient, wrapper } = createQueryWrapper(); const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); @@ -105,20 +105,21 @@ describe("page-owned screen synchronization", () => { ); if (cached) { await waitFor(() => + // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(result.current.accounts.isSuccess).toBe(true), ); - unavailable = true; + isUnavailable = true; await act(() => result.current.accounts.refetch()); } await waitFor(() => expect(result.current.accounts.isError).toBe(true)); - unavailable = false; + isUnavailable = false; await act(() => result.current.sync.retry()); await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true)); expect(result.current.accounts.meta?.stateVersion).toBe( cached ? "3" : "2", ); expect(modes).toEqual( - cached ? [null, null, "observe"] : [null, "observe"], + cached ? [null, /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, "observe"] : [null, "observe"], ); expect(posts).toBe(0); expect(result.current.disabled.data).toBeUndefined(); @@ -139,26 +140,24 @@ describe("page-owned screen synchronization", () => { "discards recovery after %s changes (HTTP already pending=%s)", async (boundary, pending) => { auth(); - let release!: () => void; - const blocked = new Promise((resolve) => { - release = resolve; - }); - let recovering = false; - let changed = false; + + const { promise: blocked, resolve: release } = Promise.withResolvers(); + let isRecovering = false; + let isChanged = false; server.use( http.get("*/api/v1/brokerage-accounts", async ({ request }) => { - if (boundary === "connection" && changed && !request.signal.aborted) + if (boundary === "connection" && isChanged && !request.signal.aborted) return HttpResponse.json(resource([], { stateVersion: "current" })); if (!new URL(request.url).searchParams.has("sync")) - return changed + return isChanged ? HttpResponse.json(resource([], { stateVersion: "current" })) : HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }); - recovering = true; + isRecovering = true; await blocked; return HttpResponse.json(resource([], { stateVersion: "old" })); }), ); - const { wrapper, queryClient } = createQueryWrapper(); + const { queryClient, wrapper } = createQueryWrapper(); const published: unknown[] = []; const unsubscribe = queryClient.getQueryCache().subscribe((event) => { if (event.type === "updated" && event.action.type === "success") @@ -179,29 +178,42 @@ describe("page-owned screen synchronization", () => { act(() => { recovery = result.current.sync.retry(); }); - if (pending) await waitFor(() => expect(recovering).toBe(true)); + // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. + if (pending) await waitFor(() => expect(isRecovering).toBe(true)); await act(async () => { - changed = true; - if (boundary === "logout") { - configureAuthClient(null); - queryClient.clear(); - } else if (boundary === "session") { - auth(); - queryClient.clear(); - } else if (boundary === "access") { - invalidateResourceAccess(queryClient, "brokerage", { - code: "FORBIDDEN", - }); - } else - commitTbankConnectionBoundary(queryClient, { + isChanged = true; + switch (boundary) { + case "access": { + invalidateResourceAccess(queryClient, "brokerage", { + code: "FORBIDDEN", + }); + + break; + } + case "logout": { + configureAuthClient(); + queryClient.clear(); + + break; + } + case "session": { + auth(); + queryClient.clear(); + + break; + } + case "connection": { commitTbankConnectionBoundary(queryClient, { connected: true, connectedAt: "2026-09-27T00:00:00Z", }); + } + } release(); await recovery; }); if (boundary === "connection") await waitFor(() => + // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(result.current.accounts.meta?.stateVersion).toBe("current"), ); expect(published).not.toContainEqual( @@ -216,7 +228,7 @@ describe("page-owned screen synchronization", () => { "recovers an initial GET failure after forced sync without metadata (status retry=%s)", async (retryStatus) => { auth(); - const modes: (string | null)[] = []; + const modes: (null | string)[] = []; let polls = 0; server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { @@ -234,12 +246,12 @@ describe("page-owned screen synchronization", () => { ? HttpResponse.json({ code: "UNAVAILABLE" }, { status: 503 }) : HttpResponse.json({ id: "recover", - state: "succeeded", resources: [resource([]).meta], + state: "succeeded", }); }), ); - const { wrapper, queryClient } = createQueryWrapper(); + const { queryClient, wrapper } = createQueryWrapper(); const { result } = renderHook( () => { const accounts = useBrokerageAccounts(); @@ -256,12 +268,14 @@ describe("page-owned screen synchronization", () => { await act(() => result.current.sync.refresh()); if (retryStatus) { await waitFor(() => + // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(result.current.sync.transportError).toBe(true), ); await act(() => result.current.sync.retry()); } await waitFor(() => expect(result.current.accounts.isSuccess).toBe(true)); expect(result.current.accounts.data).toEqual([]); + // eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response. expect(modes).toEqual([null, "observe"]); expect( queryClient @@ -272,12 +286,12 @@ describe("page-owned screen synchronization", () => { ); it("posts only its scope once and observes completion without importing again", async () => { auth(); - const modes: (string | null)[] = []; + const modes: (null | string)[] = []; const bodies: unknown[] = []; - let completed = false; + let isCompleted = false; let polls = 0; const meta = () => - resource([], { stateVersion: completed ? "2" : "1" }).meta; + resource([], { stateVersion: isCompleted ? "2" : "1" }).meta; server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { modes.push(new URL(request.url).searchParams.get("sync")); @@ -285,27 +299,29 @@ describe("page-owned screen synchronization", () => { }), http.post("*/api/v1/tbank-sync", async ({ request }) => { bodies.push(await request.json()); - completed = true; + isCompleted = true; return HttpResponse.json( { id: "screen", - scope: { screen: "accounts" }, now: "2026-09-25T00:00:00Z", + scope: { screen: "accounts" }, }, { status: 202 }, ); }), http.get("*/api/v1/tbank-sync/:id", () => { polls++; - return HttpResponse.json({ - id: "screen", - scope: { screen: "accounts" }, - now: "2026-09-25T00:00:00Z", - state: "succeeded", - errorCode: null, - progress: { total: 1, completed: 1, discovering: false }, - resources: [meta()], - }); + return HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + errorCode: null, + id: "screen", + now: "2026-09-25T00:00:00Z", + progress: { completed: 1, discovering: false, total: 1 }, + resources: [meta()], + scope: { screen: "accounts" }, + state: "succeeded", + } + /* eslint-enable unicorn/no-null */)); }), ); const { result, unmount } = renderHook( @@ -329,16 +345,19 @@ describe("page-owned screen synchronization", () => { expect(result.current.accounts.meta?.stateVersion).toBe("2"), ); expect(bodies).toEqual([{ screen: "accounts" }]); + // eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response. expect(modes).toEqual([null, "observe"]); vi.useFakeTimers(); try { - await act(() => vi.advanceTimersByTimeAsync(120000)); + await act(() => vi.advanceTimersByTimeAsync(120_000)); expect(polls).toBe(1); + // eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response. expect(modes).toEqual([null, "observe"]); unmount(); - await vi.advanceTimersByTimeAsync(120000); + await vi.advanceTimersByTimeAsync(120_000); expect(polls).toBe(1); - } finally { + } + finally { vi.useRealTimers(); } }); @@ -348,14 +367,16 @@ describe("page-owned screen synchronization", () => { vi.useFakeTimers({ toFake: ["Date"] }); try { auth(); - let completed = false; + let isCompleted = false; let polls = 0; - const modes: (string | null)[] = []; + const modes: (null | string)[] = []; const snapshot = () => - resource(completed ? [] : null, { - stateVersion: completed ? "covered" : "initial", - sync: { id: "history", state: "running", errorCode: null }, - }); + resource(isCompleted ? [] : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + stateVersion: isCompleted ? "covered" : "initial", + sync: { errorCode: null, id: "history", state: "running" }, + } + /* eslint-enable unicorn/no-null */)); server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { modes.push(new URL(request.url).searchParams.get("sync")); @@ -363,19 +384,21 @@ describe("page-owned screen synchronization", () => { }), http.get("*/api/v1/tbank-sync/:id", () => { polls++; - return HttpResponse.json({ - id: "history", - scope: null, - now: "2026-09-25T00:00:00Z", - state: "running", - errorCode: null, - progress: { - total: 2, - completed: completed ? 1 : 0, - discovering: true, - }, - resources: [snapshot().meta], - }); + return HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + errorCode: null, + id: "history", + now: "2026-09-25T00:00:00Z", + progress: { + completed: isCompleted ? 1 : 0, + discovering: true, + total: 2, + }, + resources: [snapshot().meta], + scope: null, + state: "running", + } + /* eslint-enable unicorn/no-null */)); }), ); const { result, unmount } = renderHook( @@ -394,24 +417,26 @@ describe("page-owned screen synchronization", () => { await waitFor(() => expect(polls).toBeGreaterThan(0)); expect(result.current.sync.refreshing).toBe(true); expect(result.current.accounts.data).toBeUndefined(); - completed = true; - vi.setSystemTime(Date.now() + 61000); + isCompleted = true; + vi.setSystemTime(Date.now() + 61_000); await waitFor(() => expect(result.current.accounts.data).toEqual([]), { timeout: 3000, }); expect(result.current.sync.refreshing).toBe(true); await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 1100)); + await new Promise(resolve => setTimeout(resolve, 1100)); }); + // eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response. expect(modes).toEqual([null, "observe"]); if (boundary === "leave") unmount(); - else act(() => configureAuthClient(null)); + else act(() => configureAuthClient()); const stopped = polls; await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 1100)); + await new Promise(resolve => setTimeout(resolve, 1100)); }); expect(polls).toBe(stopped); - } finally { + } + finally { vi.useRealTimers(); } }, @@ -422,9 +447,11 @@ describe("page-owned screen synchronization", () => { server.use( http.get("*/api/v1/brokerage-accounts", () => HttpResponse.json( - resource([], { - sync: { id: "revoked", state: "running", errorCode: null }, - }), + resource([], (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + sync: { errorCode: null, id: "revoked", state: "running" }, + } + /* eslint-enable unicorn/no-null */)), ), ), http.get("*/api/v1/tbank-sync/:id", () => @@ -446,41 +473,45 @@ describe("page-owned screen synchronization", () => { }); it("observes a projection when only its dependency reports new coverage", async () => { auth(); - let observed = false; - const modes: (string | null)[] = []; - const dependency = resource([], { - resourceKey: "operations", - stateVersion: observed ? "covered" : "initial", - sync: { - id: "dependency", - state: observed ? "idle" : "running", - errorCode: null, - }, - }).meta; + let isObserved = false; + const modes: (null | string)[] = []; + const dependency = resource([], (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + resourceKey: "operations", + stateVersion: isObserved ? "covered" : "initial", + sync: { + errorCode: null, + id: "dependency", + state: isObserved ? "idle" : "running", + }, + } + /* eslint-enable unicorn/no-null */)).meta; server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { const mode = new URL(request.url).searchParams.get("sync"); modes.push(mode); - if (mode) observed = true; + if (mode) isObserved = true; return HttpResponse.json( - resource(observed ? [] : null, { - resourceKey: "projection", + resource(isObserved ? [] : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null, { dependencies: [ - { ...dependency, stateVersion: observed ? "covered" : "initial" }, + { ...dependency, stateVersion: isObserved ? "covered" : "initial" }, ], + resourceKey: "projection", }), ); }), http.get("*/api/v1/tbank-sync/:id", () => - HttpResponse.json({ - id: "dependency", - scope: null, - now: "2026-09-25T00:00:00Z", - state: "succeeded", - errorCode: null, - progress: { total: 1, completed: 1, discovering: false }, - resources: [{ ...dependency, stateVersion: "covered" }], - }), + HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + errorCode: null, + id: "dependency", + now: "2026-09-25T00:00:00Z", + progress: { completed: 1, discovering: false, total: 1 }, + resources: [{ ...dependency, stateVersion: "covered" }], + scope: null, + state: "succeeded", + } + /* eslint-enable unicorn/no-null */)), ), ); const { result } = renderHook( @@ -492,6 +523,7 @@ describe("page-owned screen synchronization", () => { { wrapper: createQueryWrapper().wrapper }, ); await waitFor(() => expect(result.current.data).toEqual([])); + // eslint-disable-next-line unicorn/no-null -- Preserve the API/DOM null sentinel in the expected response. expect(modes).toEqual([null, "observe"]); }); diff --git a/src/features/tbank-connection/model/screen-sync.ts b/src/features/tbank-connection/model/screen-sync.ts index 5ae8c16..af0a223 100644 --- a/src/features/tbank-connection/model/screen-sync.ts +++ b/src/features/tbank-connection/model/screen-sync.ts @@ -1,5 +1,6 @@ import { type QueryKey, useQueryClient } from "@tanstack/react-query"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; + import { apiErrorCode, apiQueryClient, @@ -7,37 +8,74 @@ import { type paths, useAuthenticatedQueries, } from "@/shared/api"; + import { type InvestmentScreen, useTbankSync } from "./sync"; -type ResourceMeta = - paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; +type ResourceMeta + = paths["/api/v1/tbank-sync/{id}"]["get"]["responses"][200]["content"]["application/json"]["resources"][number]; type ScreenResource = { - queryKey: QueryKey; meta?: ResourceMeta; - transport: { - status: "pending" | "success" | "error"; - fetchStatus: "idle" | "fetching" | "paused"; - }; observe: (version?: string) => Promise; + queryKey: QueryKey; + transport: { + fetchStatus: "fetching" | "idle" | "paused"; + status: "error" | "pending" | "success"; + }; }; const STATUS_INTERVAL_MS = 1000; +function observeResource( + resource: ScreenResource, + latest: Map, + completedId: string | undefined, + observed: Map, +) { + if (!resource.meta) { + if (!completedId || resource.transport.fetchStatus !== "idle") return; + const key = `query:${JSON.stringify(resource.queryKey)}`; + if (observed.get(key) === completedId) return; + observed.set(key, completedId); + void resource.observe().catch(() => {}); + return; + } + const changed = [ + resource.meta, + ...(resource.meta.dependencies ?? []), + ].flatMap((current) => { + const notified = latest.get(current.resourceKey); + return notified && notified.stateVersion !== current.stateVersion + ? [notified] + : []; + }); + if (changed.length === 0) return; + const notification = JSON.stringify( + changed.map(meta => [meta.resourceKey, meta.stateVersion]), + ); + if (observed.get(resource.meta.resourceKey) === notification) + return; + observed.set(resource.meta.resourceKey, notification); + const own = changed.find( + meta => meta.resourceKey === resource.meta?.resourceKey, + ); + void resource.observe(own?.stateVersion).catch(() => {}); +} + function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { const mutation = useTbankSync(); const client = useQueryClient(); - const denied = useRef(new Set()); + const deniedRef = useRef(new Set()); const scopeKey = JSON.stringify(scope); - const [requested, setRequested] = useState<{ - scope: string; + const [requested, setRequested] = useState(null); - const requestInFlight = useRef(null); - const currentScope = useRef(scopeKey); + scope: string; + }>(null); + const requestInFlightRef = useRef(null); + const currentScopeRef = useRef(scopeKey); useLayoutEffect(() => { - currentScope.current = scopeKey; + currentScopeRef.current = scopeKey; }, [scopeKey]); - const currentMutation = JSON.stringify(mutation.variables?.body) === scopeKey; - const observed = useRef(new Map()); + const isCurrentMutation = JSON.stringify(mutation.variables?.body) === scopeKey; + const observedRef = useRef(new Map()); const ids = [ ...new Set([ ...(requested?.scope === scopeKey ? [requested.id] : []), @@ -45,139 +83,111 @@ function useScreenSync(scope: InvestmentScreen, resources: ScreenResource[]) { .flatMap(({ meta }) => meta ? [meta, ...(meta.dependencies ?? [])] : [], ) - .flatMap((meta) => - meta?.sync.id && - (meta.sync.state === "running" || meta.sync.state === "queued") + .flatMap(meta => + meta?.sync.id + && (meta.sync.state === "running" || meta.sync.state === "queued") ? [meta.sync.id] : [], ), ]), ]; const statuses = useAuthenticatedQueries( - ids.map((id) => ({ + ids.map(id => ({ ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", { params: { path: { id } }, }), - retry: false, + refetchInterval: (query: { + state: { data?: { state: string }; error: unknown }; + }) => + !(query.state.error || (query.state.data && query.state.data.state !== "running")) && STATUS_INTERVAL_MS, refetchOnMount: false, refetchOnWindowFocus: false, - refetchInterval: (query: { - state: { error: unknown; data?: { state: string } }; - }) => - query.state.error || - (query.state.data && query.state.data.state !== "running") - ? false - : STATUS_INTERVAL_MS, + retry: false, })), ); useEffect(() => { for (const status of statuses) { - if (status.error && !denied.current.has(status.error)) { - denied.current.add(status.error); - if (apiErrorCode(status.error) !== "NOT_FOUND") - invalidateResourceAccess(client, "brokerage", status.error); + if (!status.error || deniedRef.current.has(status.error)) { + continue; } + + deniedRef.current.add(status.error); + if (apiErrorCode(status.error) !== "NOT_FOUND") + invalidateResourceAccess(client, "brokerage", status.error); } const latest = new Map(); - for (const status of [...statuses].sort( - (a, b) => a.dataUpdatedAt - b.dataUpdatedAt, - )) - for (const meta of status.data?.resources ?? []) { + const orderedStatuses = statuses.toSorted((a, b) => a.dataUpdatedAt - b.dataUpdatedAt); + for (const status of orderedStatuses) { + const statusResources = status.data?.resources ?? []; + for (const meta of statusResources) { latest.set(meta.resourceKey, meta); - for (const dependency of meta.dependencies ?? []) + const dependencies = meta.dependencies ?? []; + for (const dependency of dependencies) latest.set(dependency.resourceKey, dependency); } - for (const resource of resources) { - if (!resource.meta) { - if (resource.transport.fetchStatus !== "idle") continue; - const completed = statuses.find( - (status) => - requested?.scope === scopeKey && - status.data?.id === requested.id && - status.data.state !== "running", - )?.data; - const key = `query:${JSON.stringify(resource.queryKey)}`; - if (completed && observed.current.get(key) !== completed.id) { - observed.current.set(key, completed.id); - void resource.observe().catch(() => undefined); - } - continue; - } - const changed = [ - resource.meta, - ...(resource.meta.dependencies ?? []), - ].flatMap((current) => { - const notified = latest.get(current.resourceKey); - return notified && notified.stateVersion !== current.stateVersion - ? [notified] - : []; - }); - if (!changed.length) continue; - const notification = JSON.stringify( - changed.map((meta) => [meta.resourceKey, meta.stateVersion]), - ); - if (observed.current.get(resource.meta.resourceKey) === notification) - continue; - observed.current.set(resource.meta.resourceKey, notification); - const own = changed.find( - (meta) => meta.resourceKey === resource.meta?.resourceKey, - ); - void resource.observe(own?.stateVersion).catch(() => undefined); } + const completed = statuses.find(status => requested?.scope === scopeKey + && status.data?.id === requested.id && status.data.state !== "running")?.data; + for (const resource of resources) observeResource(resource, latest, completed?.id, observedRef.current); }, [client, statuses, resources, requested, scopeKey]); - const refreshing = - (currentMutation && mutation.isPending) || - statuses.some( - (status) => !status.isError && status.data?.state === "running", - ) || - resources.some(({ meta }) => { - const status = statuses[ids.indexOf(meta?.sync.id ?? "")]; - return ( - !status?.isError && - (!status?.data || status.data.state === "running") && - (meta?.sync.state === "running" || meta?.sync.state === "queued") - ); - }); + const isRefreshing + = (isCurrentMutation && mutation.isPending) + || statuses.some( + status => !status.isError && status.data?.state === "running", + ) + || resources.some(({ meta }) => { + const status = statuses[ids.indexOf(meta?.sync.id ?? "")]; + return ( + !status?.isError + && (!status?.data || status.data.state === "running") + && (meta?.sync.state === "running" || meta?.sync.state === "queued") + ); + }); const refresh = async () => { - if (requestInFlight.current === scopeKey || refreshing) return; - requestInFlight.current = scopeKey; + if (isRefreshing || requestInFlightRef.current === scopeKey) return; + requestInFlightRef.current = scopeKey; try { const accepted = await mutation.mutateAsync({ body: scope }); if ( - currentScope.current === scopeKey && - requestInFlight.current === scopeKey + currentScopeRef.current === scopeKey + && requestInFlightRef.current === scopeKey ) - setRequested({ scope: scopeKey, id: accepted.id }); - } catch { - /* The mutation retains the transport error for presentation. */ - } finally { - if (requestInFlight.current === scopeKey) requestInFlight.current = null; + setRequested({ id: accepted.id, scope: scopeKey }); + } + catch { + /* + The mutation retains the transport error for presentation. + */ + } + finally { + if (requestInFlightRef.current === scopeKey) requestInFlightRef.current = /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; } }; // Retry failed HTTP reads without asking the source to import again. // A source failure (or explicit refresh with no read error) still needs a sync. const retry = async () => { const failed = resources.filter( - (resource) => resource.transport.status === "error", + resource => resource.transport.status === "error", ); - const failedStatuses = statuses.filter((status) => status.isError); - if (failed.length || failedStatuses.length) { + const failedStatuses = statuses.filter(status => status.isError); + if (failed.length > 0 || failedStatuses.length > 0) { await Promise.allSettled([ - ...failed.map((resource) => resource.observe()), - ...failedStatuses.map((status) => status.refetch()), + ...failed.map(resource => resource.observe()), + ...failedStatuses.map(status => status.refetch()), ]); - } else await refresh(); + } + else await refresh(); }; return { refresh, + refreshing: isRefreshing, retry, - refreshing, - transportError: - (currentMutation && mutation.isError) || - statuses.some((status) => status.isError), sourceError: - statuses.some((status) => status.data?.state === "failed") || - resources.some(({ meta }) => meta?.sync.state === "failed"), + statuses.some(status => status.data?.state === "failed") + || resources.some(({ meta }) => meta?.sync.state === "failed"), + transportError: + (isCurrentMutation && mutation.isError) + || statuses.some(status => status.isError), }; } diff --git a/src/features/tbank-connection/model/sync.test.tsx b/src/features/tbank-connection/model/sync.test.tsx index d6053f0..7f90b16 100644 --- a/src/features/tbank-connection/model/sync.test.tsx +++ b/src/features/tbank-connection/model/sync.test.tsx @@ -1,9 +1,11 @@ import { act, renderHook, waitFor } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; + import { apiQueryClient, configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { server } from "@/test/server"; + import { type InvestmentScreen, useTbankSync, @@ -17,22 +19,20 @@ describe("sync connection and session boundaries", () => { async (boundary) => { let generation = 0; configureAuthClient({ - generation: () => generation, accessToken: () => "synthetic", - refresh: async () => true, clear: vi.fn(), + generation: () => generation, + refresh: () => Promise.resolve(true), }); - let release!: () => void; - const blocked = new Promise((resolve) => { - release = resolve; - }); + + const { promise: blocked, resolve: release } = Promise.withResolvers(); const accepted = { id: "work", - scope: { screen: "accounts" }, now: "2026-09-25T12:00:00Z", + scope: { screen: "accounts" }, }; let started = 0; - let changed = false; + let isChanged = false; server.use( http.post("*/api/v1/tbank-sync", async () => { started += 1; @@ -40,29 +40,31 @@ describe("sync connection and session boundaries", () => { return HttpResponse.json(accepted, { status: 202 }); }), http.get("*/api/v1/tbank-sync/:id", async () => { - const now = changed ? "2026-09-25T12:01:00Z" : accepted.now; + const now = isChanged ? "2026-09-25T12:01:00Z" : accepted.now; started += 1; await blocked; - return HttpResponse.json({ - ...accepted, - now, - state: "succeeded", - errorCode: null, - progress: { total: 1, completed: 1, discovering: false }, - resources: [], - }); + return HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...accepted, + errorCode: null, + now, + progress: { completed: 1, discovering: false, total: 1 }, + resources: [], + state: "succeeded", + } + /* eslint-enable unicorn/no-null */)); }), ); - const { wrapper, queryClient } = createQueryWrapper(); + const { queryClient, wrapper } = createQueryWrapper(); const published: string[] = []; const unsubscribe = queryClient.getQueryCache().subscribe((event) => { - if ( - (event.query.queryKey as readonly unknown[])[1] === - "/api/v1/tbank-sync/{id}" - ) { - const data = event.query.state.data as { now?: string } | undefined; - if (data?.now) published.push(data.now); + if ((event.query.queryKey as readonly unknown[])[1] + !== "/api/v1/tbank-sync/{id}") { + return; } + + const data = event.query.state.data as undefined | { now?: string }; + if (data?.now) published.push(data.now); }); const { result } = renderHook( () => ({ @@ -82,12 +84,14 @@ describe("sync connection and session boundaries", () => { }); await waitFor(() => expect(started).toBe(2)); await act(async () => { - changed = true; + isChanged = true; if (boundary === "connection") - commitTbankConnectionBoundary(queryClient, { - connected: false, - connectedAt: null, - }); + commitTbankConnectionBoundary(queryClient, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + connected: false, + connectedAt: null, + } + /* eslint-enable unicorn/no-null */)); else { generation += 1; queryClient.clear(); @@ -104,9 +108,11 @@ describe("sync connection and session boundaries", () => { ).queryKey; // A new request may start after a successful connection boundary; an old result cannot win it. if (boundary === "session") + // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(queryClient.getQueryData(key)).toBeUndefined(); else await waitFor(() => + // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(queryClient.getQueryData(key)).toMatchObject({ now: "2026-09-25T12:01:00Z", }), diff --git a/src/features/tbank-connection/model/sync.ts b/src/features/tbank-connection/model/sync.ts index 594c5e3..578b213 100644 --- a/src/features/tbank-connection/model/sync.ts +++ b/src/features/tbank-connection/model/sync.ts @@ -4,14 +4,14 @@ import { useAuthenticatedQuery, } from "@/shared/api"; -type InvestmentScreen = - paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"]; +type InvestmentScreen + = paths["/api/v1/tbank-sync"]["post"]["requestBody"]["content"]["application/json"]; function useTbankSync() { return apiQueryClient.useMutation("post", "/api/v1/tbank-sync", { retry: false, }); } -function useTbankSyncStatus(id: string | null) { +function useTbankSyncStatus(id: null | string) { return useAuthenticatedQuery({ ...apiQueryClient.queryOptions("get", "/api/v1/tbank-sync/{id}", { params: { path: { id: id ?? "" } }, diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx index 60e36f4..8cd01eb 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.test.tsx @@ -7,11 +7,13 @@ import { waitFor, within, } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; + import { apiQueryClient, configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; + import { tbankConnectionQueryOptions } from "../../model/connection-boundary"; import { TbankConnectionSection } from "./TbankConnectionSection"; @@ -54,8 +56,8 @@ const unrelatedQueryKey = apiQueryClient.queryOptions( function renderSection(client: QueryClient) { configureAuthClient({ accessToken: () => "test-token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); return render( @@ -69,12 +71,14 @@ function renderSection(client: QueryClient) { function seededClient() { const client = createTestQueryClient(); client.setQueryData(brokerageAccountsQueryKey, [ - { - id: "account-1", - name: "Основной счет", - type: "BROKERAGE", - openedOn: null, - }, + (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + id: "account-1", + name: "Основной счет", + openedOn: null, + type: "BROKERAGE", + } + /* eslint-enable unicorn/no-null */), ]); client.setQueryData(portfolioQueryKey, { source: "previous-connection" }); client.setQueryData(stockQueryKey, { source: "previous-connection" }); @@ -87,10 +91,8 @@ function seededClient() { describe("TbankConnectionSection", () => { it("preserves cached status and a draft entered during a delayed failed refetch", async () => { const status = { connected: true, connectedAt: "2026-08-14T00:00:00Z" }; - let finishRefetch: (() => void) | undefined; - const response = new Promise((resolve) => { - finishRefetch = resolve; - }); + + const { promise: response, resolve: finishRefetch } = Promise.withResolvers(); let reads = 0; server.use( http.get("*/api/v1/tbank-connection", async () => { @@ -141,7 +143,9 @@ describe("TbankConnectionSection", () => { reads += 1; return reads === 1 ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) - : HttpResponse.json({ connected: false, connectedAt: null }); + : HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { connected: false, connectedAt: null } + /* eslint-enable unicorn/no-null */)); }), ); renderSection(createTestQueryClient()); @@ -238,22 +242,21 @@ describe("TbankConnectionSection", () => { it("cancels an in-flight previous-boundary query and ignores its late response", async () => { const client = seededClient(); - let finishPrevious: ((value: { source: string }) => void) | undefined; - let aborted = false; - const previousResponse = new Promise<{ source: string }>((resolve) => { - finishPrevious = resolve; - }); + + let isAborted = false; + const { promise: previousResponse, resolve: finishPrevious } = Promise.withResolvers<{ source: string }>(); const previousRequest = client - .fetchQuery({ - queryKey: operationsQueryKey, + .query({ queryFn: ({ signal }) => { signal.addEventListener("abort", () => { - aborted = true; + isAborted = true; }); return previousResponse; }, + queryKey: operationsQueryKey, }) - .catch(() => undefined); + // eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering. + .catch(() => {}); renderSection(client); await screen.findByText("Не подключено"); @@ -262,7 +265,7 @@ describe("TbankConnectionSection", () => { }); fireEvent.click(screen.getByRole("button", { name: "Подключить" })); - await waitFor(() => expect(aborted).toBe(true)); + await waitFor(() => expect(isAborted).toBe(true)); await previousRequest; finishPrevious?.({ source: "previous-connection" }); await Promise.resolve(); @@ -298,10 +301,8 @@ describe("TbankConnectionSection", () => { it("keeps DELETE recovery inside the dialog and preserves cache until successful retry", async () => { let deletes = 0; - let finishDelete: (() => void) | undefined; - const response = new Promise((resolve) => { - finishDelete = resolve; - }); + + const { promise: response, resolve: finishDelete } = Promise.withResolvers(); server.use( http.get("*/api/v1/tbank-connection", () => HttpResponse.json({ @@ -327,6 +328,7 @@ describe("TbankConnectionSection", () => { name: "Удалить подключение?", }); await waitFor(() => + // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. expect(dialog.contains(document.activeElement)).toBe(true), ); const confirm = within(dialog).getByRole("button", { name: /^Удалить$/ }); @@ -340,7 +342,7 @@ describe("TbankConnectionSection", () => { document.body.tabIndex = -1; document.body.focus(); document.body.removeAttribute("tabindex"); - expect(document.activeElement).toBe(document.body); + expect(document.body).toHaveFocus(); finishDelete?.(); expect(await within(dialog).findByRole("alert")).toHaveTextContent( diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx index f1c8b7a..205fd1b 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.tsx @@ -1,4 +1,5 @@ import type { FC } from "react"; + import { TbankConnectionError, TbankConnectionLoading, @@ -9,9 +10,7 @@ import { TbankConnectionSectionLoaded } from "./TbankConnectionSectionLoaded"; const TbankConnectionSection: FC = () => { const viewModel = useTbankConnectionViewModel(); if (viewModel.state === "loading") return ; - if (viewModel.state === "error") - return ; - return ; + return viewModel.state === "error" ? : ; }; export { TbankConnectionSection }; diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx index a5ce20a..2029b30 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.test.tsx @@ -1,6 +1,7 @@ import { MantineProvider } from "@mantine/core"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; + import { TbankConnectionError, TbankConnectionLoading, @@ -23,7 +24,7 @@ describe("TbankConnectionSection state views", () => { , ); - screen.getByRole("button", { name: "Повторить" }).click(); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); expect(retry).toHaveBeenCalledOnce(); }); }); diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx index d090d3c..9fb619c 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.view.tsx @@ -1,6 +1,7 @@ +import type { FC } from "react"; + import { Alert, Button, Skeleton } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; type TbankConnectionErrorProps = { @@ -13,7 +14,7 @@ const TbankConnectionLoading: FC = () => { return ( - + ); }; @@ -24,7 +25,7 @@ const TbankConnectionError: FC = (props) => { return ( - diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts index a9a67a1..aa1a587 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSection.vm.ts @@ -1,4 +1,5 @@ import { useAuthenticatedQuery } from "@/shared/api"; + import { tbankConnectionQueryOptions } from "../../model/connection-boundary"; function useTbankConnectionViewModel() { @@ -6,15 +7,14 @@ function useTbankConnectionViewModel() { ...tbankConnectionQueryOptions(), }); if (!status.data) { - if (status.isPending) return { state: "loading" as const }; - return { state: "error" as const, retry: () => status.refetch() }; + return status.isPending ? { state: "loading" as const } : { retry: () => status.refetch(), state: "error" as const }; } return { + refreshError: status.isError, + refreshing: status.isFetching, + retry: () => status.refetch(), state: "loaded" as const, status: status.data, - refreshing: status.isFetching, - refreshError: status.isError, - retry: () => status.refetch(), }; } diff --git a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx index 4bf99fa..21b9004 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection/TbankConnectionSectionLoaded.tsx @@ -1,3 +1,5 @@ +import type { FC } from "react"; + import { Alert, Button, @@ -7,21 +9,22 @@ import { Text, } from "@mantine/core"; import { useQueryClient } from "@tanstack/react-query"; -import type { FC } from "react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; + import { apiErrorCode, apiQueryClient } from "@/shared/api"; + import { commitTbankConnectionBoundary, type TbankConnectionStatus, } from "../../model/connection-boundary"; type TbankConnectionSectionLoadedProps = { - status: TbankConnectionStatus; - refreshing: boolean; refreshError: boolean; + refreshing: boolean; retry: () => void; + status: TbankConnectionStatus; }; const TbankConnectionSectionLoaded: FC = ( @@ -30,21 +33,21 @@ const TbankConnectionSectionLoaded: FC = ( const { status } = props; const { t } = useTranslation(); const queryClient = useQueryClient(); - const [deleteOpened, setDeleteOpened] = useState(false); - const deleteButton = useRef(null); - const [requestError, setRequestError] = useState(null); + const [isDeleteOpened, setIsDeleteOpened] = useState(false); + const removalButtonRef = useRef(null); + const [requestError, setRequestError] = useState(null); const { - register, - handleSubmit, - reset, formState: { errors }, + handleSubmit, + register, + reset, } = useForm<{ token: string }>(); const update = apiQueryClient.useMutation("put", "/api/v1/tbank-connection", { + onError: error => setRequestError(apiErrorCode(error) ?? "REQUEST_ERROR"), onSuccess: (data) => { commitTbankConnectionBoundary(queryClient, data); reset(); }, - onError: (error) => setRequestError(apiErrorCode(error) ?? "REQUEST_ERROR"), }); const remove = apiQueryClient.useMutation( "delete", @@ -53,23 +56,24 @@ const TbankConnectionSectionLoaded: FC = ( onSuccess: () => { commitTbankConnectionBoundary(queryClient, { connected: false, - connectedAt: null, + connectedAt: /* eslint-disable-line unicorn/no-null -- The disconnected API snapshot requires a null connection timestamp. */ null, }); - setDeleteOpened(false); + setIsDeleteOpened(false); }, }, ); - const pending = update.isPending || remove.isPending; - const connected = status.connected; + const unavailableMessage = requestError === "TBANK_UNAVAILABLE" ? "tbank.unavailable" : "tbank.requestError"; + const isPending = update.isPending || remove.isPending; + const isConnected = status.connected; useEffect(() => { - if (deleteOpened && remove.isError) deleteButton.current?.focus(); - }, [deleteOpened, remove.isError]); + if (isDeleteOpened && remove.isError) removalButtonRef.current?.focus(); + }, [isDeleteOpened, remove.isError]); return ( {t("tbank.title")} - {connected + {isConnected ? t("tbank.connected", { date: new Date(status.connectedAt ?? "").toLocaleDateString( "ru-RU", @@ -77,22 +81,22 @@ const TbankConnectionSectionLoaded: FC = ( }) : t("tbank.disconnected")} - + {t("tbank.hint")} {props.refreshing && ( - + {t("tbank.refreshing")} )} {props.refreshError && ( - + {t("tbank.refreshError")} @@ -105,7 +109,7 @@ const TbankConnectionSectionLoaded: FC = ( update.mutate({ body: { token } }); })} > -
+
= ( {t( requestError === "INVALID_TBANK_TOKEN" ? "tbank.invalidToken" - : requestError === "TBANK_UNAVAILABLE" - ? "tbank.unavailable" - : "tbank.requestError", + : unavailableMessage, )} )} - - {connected && ( + {isConnected && ( @@ -142,12 +144,12 @@ const TbankConnectionSectionLoaded: FC = (
!pending && setDeleteOpened(false)} + closeButtonProps={{ "aria-label": t("tbank.close"), "disabled": isPending }} + closeOnClickOutside={!isPending} + closeOnEscape={!isPending} + onClose={() => !isPending && setIsDeleteOpened(false)} + opened={isDeleteOpened} title={t("tbank.deleteTitle")} - closeOnClickOutside={!pending} - closeOnEscape={!pending} - closeButtonProps={{ "aria-label": t("tbank.close"), disabled: pending }} > {t("tbank.deleteText")} @@ -155,11 +157,11 @@ const TbankConnectionSectionLoaded: FC = ( {t("tbank.requestError")} )} diff --git a/src/main.tsx b/src/main.tsx index 3a2c079..32dfbf5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,10 +5,11 @@ import "@mantine/charts/styles.css"; import "@mantine/dates/styles.css"; import "@wealthledger/ui/styles.css"; import "dayjs/locale/ru"; + import "./shared/i18n"; import { App } from "./app"; -const root = document.getElementById("root"); +const root = document.querySelector("#root"); if (!root) { throw new Error("Root element not found"); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx index f35613d..0148255 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.filters.test.tsx @@ -1,3 +1,5 @@ +import type { FC } from "react"; + import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { @@ -8,8 +10,7 @@ import { screen, waitFor, } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; -import type { FC } from "react"; +import { http, HttpResponse } from "msw"; import { MemoryRouter, Route, @@ -18,10 +19,12 @@ import { useNavigate, } from "react-router"; import { describe, expect, it, vi } from "vitest"; + import { configureAuthClient } from "@/shared/api"; -import { createQueryWrapper, createTestQueryClient } from "@/test/query"; +import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; + import { AccountEventsPage } from "./AccountEventsPage"; import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm"; @@ -31,10 +34,10 @@ const Navigation: FC = () => { return ( <> {location.search} - - @@ -45,8 +48,8 @@ describe("Upcoming event date filters", () => { it("keeps reads and sync disabled without an account scope", async () => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); const requests: string[] = []; server.use( @@ -55,17 +58,17 @@ describe("Upcoming event date filters", () => { return new HttpResponse(null, { status: 503 }); }), ); - const { wrapper: QueryWrapper, queryClient } = createQueryWrapper(); + const queryClient = createTestQueryClient(); const { result } = renderHook(() => useAccountEventsPageViewModel(), { wrapper: ({ children }) => ( - + {children} - + ), }); - await act(async () => {}); + await new Promise(resolve => setTimeout(resolve, 0)); expect(result.current).toEqual({ state: "loading" }); expect(queryClient.isFetching()).toBe(0); expect(requests).toEqual([]); @@ -80,8 +83,8 @@ describe("Upcoming event date filters", () => { async (search) => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); const requested: string[] = []; const syncRequests: unknown[] = []; @@ -89,13 +92,15 @@ describe("Upcoming event date filters", () => { http.get("*/api/v1/brokerage-accounts/all/events", ({ request }) => { requested.push(new URL(request.url).search); return HttpResponse.json( - resource(null, { - sync: { - id: null, - state: "failed", - errorCode: "TBANK_UNAVAILABLE", - }, - }), + resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + sync: { + errorCode: "TBANK_UNAVAILABLE", + id: null, + state: "failed", + }, + } + /* eslint-enable unicorn/no-null */)), ); }), http.post("*/api/v1/tbank-sync", async ({ request }) => { @@ -107,16 +112,16 @@ describe("Upcoming event date filters", () => { }), ); const period = Object.fromEntries(new URLSearchParams(search)); - const { wrapper: QueryWrapper } = createQueryWrapper(); + const queryClient = createTestQueryClient(); const { result } = renderHook( () => useAccountEventsPageViewModel("all"), { wrapper: ({ children }) => ( - + {children} - + ), }, ); @@ -128,7 +133,7 @@ describe("Upcoming event date filters", () => { }); await waitFor(() => expect(syncRequests).toEqual([ - { screen: "events", accountId: "all", ...period }, + { accountId: "all", screen: "events", ...period }, ]), ); }, @@ -139,8 +144,8 @@ describe("Upcoming event date filters", () => { async (accountId) => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); const searches: string[] = []; server.use( @@ -156,20 +161,20 @@ describe("Upcoming event date filters", () => { `/accounts/${accountId}/events${search}`)} + ].map(search => `/accounts/${accountId}/events${search}`)} + initialIndex={0} > } + path="/accounts/:accountId/events" /> @@ -182,6 +187,7 @@ describe("Upcoming event date filters", () => { screen.getByRole("button", { name: "History forward" }), ); await waitFor(() => + // eslint-disable-next-line jest-dom/prefer-to-have-text-content -- URL history requires exact equality, including empty search. expect(screen.getByLabelText("URL").textContent).toBe(expected), ); await screen.findByLabelText("Период"); @@ -202,8 +208,8 @@ describe("Upcoming event date filters", () => { async (accountId) => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); server.use( http.get( @@ -223,8 +229,8 @@ describe("Upcoming event date filters", () => { } + path="/accounts/:accountId/events" /> @@ -234,7 +240,7 @@ describe("Upcoming event date filters", () => { await screen.findByRole("button", { name: "Повторить" }); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); await screen.findByLabelText("Период"); - expect(screen.getByLabelText("URL").textContent).toBe(""); + expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); }, ); @@ -243,8 +249,8 @@ describe("Upcoming event date filters", () => { async (accountId) => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); const searches: string[] = []; server.use( @@ -274,8 +280,8 @@ describe("Upcoming event date filters", () => { } + path="/accounts/:accountId/events" /> @@ -324,41 +330,41 @@ describe("Upcoming event date filters", () => { it("retains the previous complete forecast and timestamp when its source refresh fails", async () => { configureAuthClient({ accessToken: () => "synthetic", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); const events = [ { date: "2026-10-01", displayName: "Synthetic coupon", - type: "coupon", projectedAmount: { currency: "RUB", value: "123.45" }, + type: "coupon", }, ]; const snapshot = resource( { events, incomeTotalsByCurrency: [{ currency: "RUB", value: "123.45" }] }, - { sync: { id: "failed", state: "failed", errorCode: "TBANK_UNAVAILABLE" } }, + { sync: { errorCode: "TBANK_UNAVAILABLE", id: "failed", state: "failed" } }, ); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/events", () => HttpResponse.json(snapshot), ), ); - const { wrapper: QueryWrapper } = createQueryWrapper(); + const queryClient = createTestQueryClient(); const { result } = renderHook(() => useAccountEventsPageViewModel("all"), { wrapper: ({ children }) => ( - + {children} - + ), }); await waitFor(() => expect(result.current).toMatchObject({ - state: "loaded", events, - sourceError: true, - refreshError: true, lastSuccessfulAt: snapshot.meta.lastSuccessfulAt, + refreshError: true, + sourceError: true, + state: "loaded", }), ); }); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx index 8eb097a..1532319 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.test.tsx @@ -1,21 +1,23 @@ import { MantineProvider } from "@mantine/core"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router"; import { beforeEach, describe, expect, it, vi } from "vitest"; + import { useBrokerageEvents } from "@/entities/brokerage-event"; + import { AccountEventsPage } from "./AccountEventsPage"; const { refresh } = vi.hoisted(() => ({ refresh: vi.fn() })); vi.mock("@/features/tbank-connection", () => ({ useScreenSync: () => ({ refresh, - retry: refresh, refreshing: false, + retry: refresh, sourceError: false, transportError: false, }), })); -vi.mock("@/entities/brokerage-event", async (importOriginal) => ({ +vi.mock("@/entities/brokerage-event", async importOriginal => ({ ...(await importOriginal()), useBrokerageEvents: vi.fn(), })); @@ -35,20 +37,22 @@ function renderPage() { describe("AccountEventsPage", () => { beforeEach(() => { refresh.mockClear(); - query.mockReturnValue({ - data: { - accountId: "account", - calculatedAt: "2026-08-14T00:00:00Z", - events: [], - }, - meta: { - availability: "complete", - sync: { state: "idle", id: null, errorCode: null }, - }, - isError: false, - isLoading: false, - refetch: vi.fn(), - } as never); + query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + data: { + accountId: "account", + calculatedAt: "2026-08-14T00:00:00Z", + events: [], + }, + isError: false, + isLoading: false, + meta: { + availability: "complete", + sync: { errorCode: null, id: null, state: "idle" }, + }, + refetch: vi.fn(), + } + /* eslint-enable unicorn/no-null */) as never); }); it("passes the selected route account to the query", () => { @@ -57,8 +61,8 @@ describe("AccountEventsPage", () => { } + path="/accounts/:accountId/events" /> @@ -68,16 +72,18 @@ describe("AccountEventsPage", () => { }); it("keeps the loading fallback when no result or account is available", () => { - query.mockReturnValue({ - data: undefined, - meta: { - availability: "complete", - sync: { state: "idle", id: null, errorCode: null }, - }, - isError: false, - isLoading: false, - refetch: vi.fn(), - } as never); + query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + data: undefined, + isError: false, + isLoading: false, + meta: { + availability: "complete", + sync: { errorCode: null, id: null, state: "idle" }, + }, + refetch: vi.fn(), + } + /* eslint-enable unicorn/no-null */) as never); renderPage(); expect(query).toHaveBeenCalledWith("", {}); expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute( @@ -87,46 +93,48 @@ describe("AccountEventsPage", () => { }); it("renders events and marks an offer without an amount", () => { - query.mockReturnValue({ - data: { - accountId: "account", - calculatedAt: "2026-08-14T00:00:00Z", - events: [ - { - date: "2026-08-20", - dateKind: "event", - displayName: "ОФЗ 26238", - figi: "FIGI", - instrumentUid: "UID", - projectedAmount: { currency: "RUB", value: "1250" }, - ticker: "OFZ", - type: "coupon", - logoName: "ofz.png", - isin: "RU000A0JX0J2", - }, - { - date: "2026-08-25", - dateKind: "event", - displayName: "ОФЗ 26238", - figi: "FIGI", - instrumentUid: "UID", - projectedAmount: null, - ticker: "OFZ", - type: "offer", - logoName: null, - isin: null, - }, - ], - }, - meta: { - availability: "complete", - sync: { state: "idle", id: null, errorCode: null }, - }, - isError: false, - isLoading: false, - refetch: vi.fn(), - } as never); - const { container } = renderPage(); + query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + data: { + accountId: "account", + calculatedAt: "2026-08-14T00:00:00Z", + events: [ + { + date: "2026-08-20", + dateKind: "event", + displayName: "ОФЗ 26238", + figi: "FIGI", + instrumentUid: "UID", + isin: "RU000A0JX0J2", + logoName: "ofz.png", + projectedAmount: { currency: "RUB", value: "1250" }, + ticker: "OFZ", + type: "coupon", + }, + { + date: "2026-08-25", + dateKind: "event", + displayName: "ОФЗ 26238", + figi: "FIGI", + instrumentUid: "UID", + isin: null, + logoName: null, + projectedAmount: null, + ticker: "OFZ", + type: "offer", + }, + ], + }, + isError: false, + isLoading: false, + meta: { + availability: "complete", + sync: { errorCode: null, id: null, state: "idle" }, + }, + refetch: vi.fn(), + } + /* eslint-enable unicorn/no-null */) as never); + renderPage(); expect(screen.getByText("1 250 ₽")).toBeInTheDocument(); expect(screen.getByText("Сумма неизвестна")).toBeInTheDocument(); expect(screen.getByText("Купон · ОФЗ 26238")).toBeVisible(); @@ -136,7 +144,7 @@ describe("AccountEventsPage", () => { expect(screen.getByText("25.08.2026")).toBeVisible(); expect(screen.queryByText(/Дата события/)).not.toBeInTheDocument(); expect(screen.getAllByText("Ожидаемая сумма")).toHaveLength(2); - const logo = container.querySelector("img"); + const logo = screen.queryByAltText(""); expect(logo).toHaveAttribute( "src", "https://invest-brands.cdn-tinkoff.ru/ofzx160.png", @@ -144,7 +152,7 @@ describe("AccountEventsPage", () => { expect(screen.getAllByText("O")).toHaveLength(1); if (!logo) throw new Error("Expected the event logo"); fireEvent.error(logo); - expect(container.querySelector("img")).toBeNull(); + expect(screen.queryByAltText("")).not.toBeInTheDocument(); expect(screen.getAllByText("O")).toHaveLength(2); }); @@ -167,26 +175,28 @@ describe("AccountEventsPage", () => { , ); - screen.getByRole("button", { name: "Повторить" }).click(); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); expect(refresh).toHaveBeenCalledOnce(); }); it("shows a structural busy state before events are available", () => { - query.mockReturnValue({ - data: undefined, - meta: { - availability: "complete", - sync: { state: "idle", id: null, errorCode: null }, - }, - isError: false, - isLoading: true, - refetch: vi.fn(), - } as never); + query.mockReturnValue((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + data: undefined, + isError: false, + isLoading: true, + meta: { + availability: "complete", + sync: { errorCode: null, id: null, state: "idle" }, + }, + refetch: vi.fn(), + } + /* eslint-enable unicorn/no-null */) as never); renderPage(); const status = screen.getByRole("status", { name: "Загрузка" }); expect(status).toHaveAttribute("aria-busy", "true"); - expect(status.querySelector("button")).toBeNull(); + expect(within(status).queryByRole("button")).not.toBeInTheDocument(); }); }); diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx index 19409f2..247e587 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.tsx @@ -1,4 +1,5 @@ import type { FC } from "react"; + import { useAccountEventsPageViewModel } from "./AccountEventsPage.vm"; import { AccountEventsPageError } from "./components/AccountEventsPageError.view"; import { AccountEventsPageLoaded } from "./components/AccountEventsPageLoaded.view"; @@ -9,9 +10,7 @@ type AccountEventsPageProps = { accountId?: string }; const AccountEventsPage: FC = (props) => { const viewModel = useAccountEventsPageViewModel(props.accountId); if (viewModel.state === "loading") return ; - if (viewModel.state === "error") - return ; - return ; + return viewModel.state === "error" ? : ; }; export { AccountEventsPage }; diff --git a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts index c620b7e..0fd1c23 100644 --- a/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts +++ b/src/pages/account-events/ui/AccountEventsPage/AccountEventsPage.vm.ts @@ -1,53 +1,54 @@ import { useEffect, useState } from "react"; import { useParams, useSearchParams } from "react-router"; import { z } from "zod"; + import { type BrokerageEvent, useBrokerageEvents, } from "@/entities/brokerage-event"; import { useScreenSync } from "@/features/tbank-connection"; -type AccountEventsPageViewModel = - | { state: "loading" } - | { state: "error"; retry: () => void; onReset: () => void } - | { - state: "loaded"; - events: BrokerageEvent[]; - sourceError?: boolean; - lastSuccessfulAt?: string | null; - partial?: boolean; - refreshing: boolean; - refreshError: boolean; - retry: () => void; - draftFrom: string | null; - draftTo: string | null; - canApply: boolean; - onDraftFromChange: (value: string | null) => void; - onDraftToChange: (value: string | null) => void; - onApply: () => void; - onReset: () => void; - }; +type AccountEventsPageViewModel + = | { + canApply: boolean; + draftFrom: null | string; + draftTo: null | string; + events: BrokerageEvent[]; + lastSuccessfulAt?: null | string; + onApply: () => void; + onDraftFromChange: (value: null | string) => void; + onDraftToChange: (value: null | string) => void; + onReset: () => void; + partial?: boolean; + refreshError: boolean; + refreshing: boolean; + retry: () => void; + sourceError?: boolean; + state: "loaded"; + } + | { onReset: () => void; retry: () => void; state: "error" } + | { state: "loading" }; function useAccountEventsPageViewModel( scopeId?: string, ): AccountEventsPageViewModel { const { accountId: routeAccountId } = useParams(); const accountId = scopeId ?? routeAccountId ?? ""; - const [searchParams, setSearchParams] = useSearchParams(); - const urlFrom = searchParams.get("from"); - const urlTo = searchParams.get("to"); - const validUrlPeriod = isValidPeriod(urlFrom, urlTo); - const appliedFrom = validUrlPeriod ? urlFrom : null; - const appliedTo = validUrlPeriod ? urlTo : null; - const [draftFrom, setDraftFrom] = useState(appliedFrom); - const [draftTo, setDraftTo] = useState(appliedTo); + const [searchParameters, setSearchParameters] = useSearchParams(); + const urlFrom = searchParameters.get("from"); + const urlTo = searchParameters.get("to"); + const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo); + const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + const [draftFrom, setDraftFrom] = useState(appliedFrom); + const [draftTo, setDraftTo] = useState(appliedTo); useEffect(() => { - if (validUrlPeriod) return; - const normalized = new URLSearchParams(searchParams); + if (isValidUrlPeriod) return; + const normalized = new URLSearchParams(searchParameters); normalized.delete("from"); normalized.delete("to"); - setSearchParams(normalized, { replace: true }); - }, [validUrlPeriod, searchParams, setSearchParams]); + setSearchParameters(normalized, { replace: true }); + }, [isValidUrlPeriod, searchParameters, setSearchParameters]); const [previousPeriod, setPreviousPeriod] = useState([ appliedFrom, appliedTo, @@ -58,69 +59,69 @@ function useAccountEventsPageViewModel( setDraftTo(appliedTo); } const events = useBrokerageEvents(accountId, { - ...(appliedFrom ? { from: appliedFrom } : {}), - ...(appliedTo ? { to: appliedTo } : {}), + ...(appliedFrom && { from: appliedFrom }), + ...(appliedTo && { to: appliedTo }), }); const sync = useScreenSync( { - screen: "events", accountId, - ...(appliedFrom ? { from: appliedFrom } : {}), - ...(appliedTo ? { to: appliedTo } : {}), + screen: "events", + ...(appliedFrom && { from: appliedFrom }), + ...(appliedTo && { to: appliedTo }), }, [events], ); + if (events.isLoading) return { state: "loading" }; const onReset = () => { setDraftFrom(null); setDraftTo(null); - setSearchParams({}); + setSearchParameters({}); }; - if (events.isLoading) return { state: "loading" }; if ((events.isError || events.meta?.sync.state === "failed") && !events.data) return { - state: "error", onReset, retry: () => { void sync.retry(); }, + state: "error", }; if (!events.data) return { state: "loading" }; return { - state: "loaded", + canApply: + (draftFrom !== appliedFrom || draftTo !== appliedTo) + && isValidPeriod(draftFrom, draftTo), + draftFrom, + draftTo, events: events.data.events, - sourceError: sync.sourceError, lastSuccessfulAt: events.meta?.lastSuccessfulAt, + onApply: () => { + if (!isValidPeriod(draftFrom, draftTo)) return; + setSearchParameters({ + ...(draftFrom && { from: draftFrom }), + ...(draftTo && { to: draftTo }), + }); + }, + onDraftFromChange: setDraftFrom, + onDraftToChange: setDraftTo, + onReset, partial: events.meta?.availability !== "complete", - refreshing: events.isFetching || sync.refreshing, refreshError: events.isError || sync.sourceError || sync.transportError, + refreshing: events.isFetching || sync.refreshing, retry: () => { void sync.retry(); }, - draftFrom, - draftTo, - canApply: - (draftFrom !== appliedFrom || draftTo !== appliedTo) && - isValidPeriod(draftFrom, draftTo), - onDraftFromChange: setDraftFrom, - onDraftToChange: setDraftTo, - onApply: () => { - if (!isValidPeriod(draftFrom, draftTo)) return; - setSearchParams({ - ...(draftFrom ? { from: draftFrom } : {}), - ...(draftTo ? { to: draftTo } : {}), - }); - }, - onReset, + sourceError: sync.sourceError, + state: "loaded", }; } -const calendarDate = z.string().date(); -function isValidPeriod(from: string | null, to: string | null) { +const calendarDate = z.iso.date(); +function isValidPeriod(from: null | string, to: null | string) { return ( [from, to].every( - (date) => date === null || calendarDate.safeParse(date).success, - ) && - (!from || !to || from <= to) + date => date === null || calendarDate.safeParse(date).success, + ) + && (!from || !to || from <= to) ); } diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx index f3cec28..160df64 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageEmpty.view.tsx @@ -1,11 +1,12 @@ -import { StateView } from "@wealthledger/ui"; import type { FC } from "react"; + +import { StateView } from "@wealthledger/ui"; import { useTranslation } from "react-i18next"; const AccountEventsPageEmpty: FC = () => { const { t } = useTranslation(); return ( - + ); }; diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx index e937b96..dd0156a 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageError.view.tsx @@ -1,25 +1,26 @@ +import type { FC } from "react"; + import { Button, Group } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; -type AccountEventsPageErrorProps = { retry: () => void; onReset: () => void }; +type AccountEventsPageErrorProps = { onReset: () => void; retry: () => void }; const AccountEventsPageError: FC = (props) => { const { t } = useTranslation(); return ( - - } + )} + error + text={t("events.errorText")} + title={t("events.errorTitle")} /> ); }; diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx index 5d82878..0415a3f 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoaded.view.tsx @@ -1,3 +1,5 @@ +import type { FC } from "react"; + import { Alert, Button, @@ -8,11 +10,13 @@ import { Title, } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; + import { BrokerageEventCard } from "@/entities/brokerage-event"; import { formatSnapshotTime } from "@/shared/lib"; + import type { AccountEventsPageViewModel } from "../AccountEventsPage.vm"; + import { AccountEventsPageEmpty } from "./AccountEventsPageEmpty.view"; type AccountEventsPageLoadedProps = Extract< @@ -22,68 +26,11 @@ type AccountEventsPageLoadedProps = Extract< const AccountEventsPageLoaded: FC = (props) => { const { t } = useTranslation(); - return ( - - {props.lastSuccessfulAt ? ( - - {t("investment.sourceTime", { - date: formatSnapshotTime(props.lastSuccessfulAt), - })} - - ) : null} - {t("events.title")} - - {props.partial ? ( - {t("investment.partial")} - ) : null} - {props.refreshing ? ( - - ) : null} - {props.refreshError ? ( - - {t( - props.sourceError - ? "investment.syncFailed" - : "accounts.refreshError", - )} - - - ) : null} - {t("events.hint")} - - { - props.onDraftFromChange(from); - props.onDraftToChange(to); - }} - valueFormat="DD.MM.YYYY" - popoverProps={{ - returnFocus: true, - middlewares: { shift: { crossAxis: true } }, - }} - /> - - - - - - {props.events.length ? ( + function renderEvents() { + if (props.events.length > 0) { + return ( - {props.events.map((event) => ( + {props.events.map(event => ( = (props) => { /> ))} - ) : !props.partial ? ( - - ) : null} + ); + } + if (props.partial) { + return; + } + return ; + } + return ( + + {!!(props.lastSuccessfulAt) && (( + + {t("investment.sourceTime", { + date: formatSnapshotTime(props.lastSuccessfulAt), + })} + + ))} + {t("events.title")} + + {!!(props.partial) && (( + {t("investment.partial")} + ))} + {!!(props.refreshing) && (( + + ))} + {!!(props.refreshError) && (( + + {t( + props.sourceError + ? "investment.syncFailed" + : "accounts.refreshError", + )} + + + ))} + {t("events.hint")} + + { + props.onDraftFromChange(from); + props.onDraftToChange(to); + }} + popoverProps={{ + middlewares: { shift: { crossAxis: true } }, + returnFocus: true, + }} + type="range" + value={[props.draftFrom, props.draftTo]} + valueFormat="DD.MM.YYYY" + /> + + + + + + {renderEvents()} ); }; diff --git a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx index 0f04cca..a9de6bd 100644 --- a/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx +++ b/src/pages/account-events/ui/AccountEventsPage/components/AccountEventsPageLoading.view.tsx @@ -1,6 +1,7 @@ +import type { FC } from "react"; + import { Group, Skeleton } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; const skeletonRowIds = ["first", "second", "third", "fourth"]; @@ -19,8 +20,8 @@ const AccountEventsPageLoading: FC = () => { - {skeletonRowIds.map((id) => ( - + {skeletonRowIds.map(id => ( + ))} ); diff --git a/src/pages/account-income/model/period.ts b/src/pages/account-income/model/period.ts index 1513a50..110e056 100644 --- a/src/pages/account-income/model/period.ts +++ b/src/pages/account-income/model/period.ts @@ -1,46 +1,48 @@ +const ISO_DATE_LENGTH = 10; +const DATE_PART_COUNT = 3; + type IncomePeriod = { from: string; to: string }; -type IncomeForm = IncomePeriod; const datePattern = /^\d{4}-\d{2}-\d{2}$/; function formatDraftDate(value: string): string { - const [year, month, day] = value.split("-"); + const [year, month, day] = value.split("-", DATE_PART_COUNT); return `${day}.${month}.${year}`; } -function parseDraftDate(value: string): string | null { - if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return null; - const [day, month, year] = value.split("."); - const date = `${year}-${month}-${day}`; - return isDate(date) ? date : null; -} - function isDate(value: string): boolean { if (!datePattern.test(value)) return false; const parsed = new Date(`${value}T00:00:00Z`); return ( - Number.isFinite(parsed.valueOf()) && - parsed.toISOString().slice(0, 10) === value + Number.isFinite(parsed.valueOf()) + && parsed.toISOString().slice(0, ISO_DATE_LENGTH) === value ); } function moscowDate(value: Date): string { const parts = new Intl.DateTimeFormat("en-CA", { + day: "2-digit", + month: "2-digit", timeZone: "Europe/Moscow", year: "numeric", - month: "2-digit", - day: "2-digit", }).formatToParts(value); const part = (type: string) => - parts.find((item) => item.type === type)?.value; + parts.find(item => item.type === type)?.value; return `${part("year")}-${part("month")}-${part("day")}`; } -export { - formatDraftDate, - type IncomeForm, +function parseDraftDate(value: string): null | string { + if (!/^\d{2}\.\d{2}\.\d{4}$/.test(value)) return /* eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. */ null; + const [day, month, year] = value.split(".", DATE_PART_COUNT); + const date = `${year}-${month}-${day}`; + return isDate(date) ? date : /* eslint-disable-line unicorn/no-null -- Mantine date fields use null for an incomplete or invalid date. */ null; +} + +export { formatDraftDate, + type IncomePeriod as IncomeForm, type IncomePeriod, isDate, + ISO_DATE_LENGTH, moscowDate, parseDraftDate, }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx index 94faed9..fe6240e 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.test.tsx @@ -1,7 +1,9 @@ -import { resource } from "@/test/resource"; +import type { FC } from "react"; + import "dayjs/locale/ru"; import { MantineProvider } from "@mantine/core"; import { DatesProvider } from "@mantine/dates"; +import { QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, @@ -11,8 +13,7 @@ import { waitFor, within, } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; -import type { FC } from "react"; +import { http, HttpResponse } from "msw"; import { MemoryRouter, Route, @@ -21,10 +22,14 @@ import { useNavigate, } from "react-router"; import { afterEach, describe, expect, it, vi } from "vitest"; + import type { IncomeAnalytics } from "@/entities/income"; + import { configureAuthClient } from "@/shared/api"; -import { createQueryWrapper } from "@/test/query"; +import { createTestQueryClient } from "@/test/query"; +import { resource } from "@/test/resource"; import { server } from "@/test/server"; + import { isDate } from "../../model/period"; import { AccountIncomePage } from "./AccountIncomePage"; import { @@ -33,16 +38,9 @@ import { } from "./AccountIncomePage.vm"; import { IncomePayments } from "./components/IncomePayments.view"; -const analytics = { +const analytics = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ +{ accountId: "account-1", - period: { from: "2026-08-01", to: "2026-08-27" }, - calculatedAt: "2026-08-27T00:00:00Z", - totalsByCurrency: [summary("RUB", "10", "2")], - previous: { - period: { from: "2026-07-01", to: "2026-07-31" }, - totalsByCurrency: [comparison("RUB")], - }, - yearComparison: null, bucketGranularity: "day", buckets: [ { @@ -50,113 +48,124 @@ const analytics = { totalsByCurrency: [summary("RUB", "10", "2")], }, ], - instruments: [ - { - key: "income-group-opaque-v2", - figi: "FIGI", - instrumentUid: null, - ticker: "BOND", - instrumentType: "bond", - displayName: "BOND", - coupon: money("RUB", "10"), - dividend: money("RUB", "2"), - total: money("RUB", "12"), - receiptCount: 2, - }, - ], + calculatedAt: "2026-08-27T00:00:00Z", entries: [ entry(), { ...entry(), id: "dividend-1", - type: "OPERATION_TYPE_DIVIDEND", payment: money("RUB", "2"), + type: "OPERATION_TYPE_DIVIDEND", }, ], -} satisfies IncomeAnalytics; + instruments: [ + { + coupon: money("RUB", "10"), + displayName: "BOND", + dividend: money("RUB", "2"), + figi: "FIGI", + instrumentType: "bond", + instrumentUid: null, + key: "income-group-opaque-v2", + receiptCount: 2, + ticker: "BOND", + total: money("RUB", "12"), + }, + ], + period: { from: "2026-08-01", to: "2026-08-27" }, + previous: { + period: { from: "2026-07-01", to: "2026-07-31" }, + totalsByCurrency: [comparison("RUB")], + }, + totalsByCurrency: [summary("RUB", "10", "2")], + yearComparison: null, +} +/* eslint-enable unicorn/no-null */) satisfies IncomeAnalytics; + +function comparison(currency: string) { + return { + compared: summary(currency, "1", "0"), + currency, + difference: summary(currency, "9", "2"), + selected: summary(currency, "10", "2"), + }; +} + +function entry(): IncomeAnalytics["entries"][number] { + return (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + accruedInterest: null, + category: "income", + commission: null, + description: "Купон", + figi: "FIGI", + id: "coupon-1", + instrumentKey: "income-group-opaque-v2", + instrumentType: "bond", + instrumentUid: null, + logoName: null, + occurredAt: "2026-08-01T12:00:00Z", + payment: money("RUB", "10"), + price: null, + quantity: null, + ticker: "BOND", + type: "OPERATION_TYPE_COUPON", + } + /* eslint-enable unicorn/no-null */); +} function money(currency: string, value: string) { return { currency, value }; } -function summary(currency: string, coupon: string, dividend: string) { - return { - currency, - coupon: money(currency, coupon), - dividend: money(currency, dividend), - total: money(currency, String(Number(coupon) + Number(dividend))), - }; -} - -function comparison(currency: string) { - return { - currency, - selected: summary(currency, "10", "2"), - compared: summary(currency, "1", "0"), - difference: summary(currency, "9", "2"), - }; -} - -function entry(): IncomeAnalytics["entries"][number] { - return { - id: "coupon-1", - instrumentKey: "income-group-opaque-v2", - occurredAt: "2026-08-01T12:00:00Z", - type: "OPERATION_TYPE_COUPON", - category: "income", - description: "Купон", - figi: "FIGI", - instrumentUid: null, - ticker: "BOND", - instrumentType: "bond", - logoName: null, - payment: money("RUB", "10"), - quantity: null, - price: null, - accruedInterest: null, - commission: null, - }; -} - function renderPage(search = "from=2026-08-01&to=2026-08-27") { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); - const { wrapper: QueryWrapper, queryClient } = createQueryWrapper(); - const result = render( + const queryClient = createTestQueryClient(); + const view = render( - + } + path="/accounts/:accountId/income" /> Events} + path="/accounts/:accountId/events" /> - + , ); - return { ...result, queryClient }; + return { ...view, queryClient }; +} + +function summary(currency: string, coupon: string, dividend: string) { + return { + coupon: money(currency, coupon), + currency, + dividend: money(currency, dividend), + total: money(currency, String(Number(coupon) + Number(dividend))), + }; } describe("AccountIncomePage", () => { it("keeps reads and sync disabled without an account scope", async () => { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); const requests: string[] = []; server.use( @@ -165,17 +174,17 @@ describe("AccountIncomePage", () => { return new HttpResponse(null, { status: 503 }); }), ); - const { wrapper: QueryWrapper, queryClient } = createQueryWrapper(); + const queryClient = createTestQueryClient(); const { result } = renderHook(() => useAccountIncomePageViewModel(), { wrapper: ({ children }) => ( - + {children} - + ), }); - await act(async () => {}); + await new Promise(resolve => setTimeout(resolve, 0)); expect(result.current).toEqual({ state: "loading" }); expect(queryClient.isFetching()).toBe(0); expect(requests).toEqual([]); @@ -190,13 +199,15 @@ describe("AccountIncomePage", () => { ({ request }) => { requested.push(new URL(request.url).search); return HttpResponse.json( - resource(null, { - sync: { - id: null, - state: "failed", - errorCode: "TBANK_UNAVAILABLE", - }, - }), + resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + sync: { + errorCode: "TBANK_UNAVAILABLE", + id: null, + state: "failed", + }, + } + /* eslint-enable unicorn/no-null */)), ); }, ), @@ -216,9 +227,9 @@ describe("AccountIncomePage", () => { await waitFor(() => expect(syncRequests).toEqual([ { - screen: "income", accountId: "account-1", from: "2024-02-29", + screen: "income", to: "2024-03-01", }, ]), @@ -243,32 +254,32 @@ describe("AccountIncomePage", () => { }); it("preserves every repeated-ID payment when an open card updates", async () => { - const first = { ...entry(), id: "same-id", description: "Выплата A" }; + const first = { ...entry(), description: "Выплата A", id: "same-id" }; const second = { ...entry(), - id: "same-id", description: "Выплата B", + id: "same-id", payment: money("RUB", "-2"), }; const third = { ...entry(), - id: "other-id", description: "Выплата C", + id: "other-id", payment: money("RUB", "1"), }; function breakdown(entries: IncomeAnalytics["entries"], total: string) { const display = toIncomeDisplayModel({ ...analytics, + entries, instruments: [ { ...analytics.instruments[0], coupon: money("RUB", total), dividend: money("RUB", "0"), - total: money("RUB", total), receiptCount: entries.length, + total: money("RUB", total), }, ], - entries, }); expect(display).not.toBeNull(); return ( @@ -282,7 +293,7 @@ describe("AccountIncomePage", () => { const payments = () => within(panel) .getAllByText(/^Выплата [ABC]$/) - .map((element) => element.textContent); + .map(element => element.textContent); expect(payments()).toEqual(["Выплата A", "Выплата B", "Выплата C"]); rerender(breakdown([third, first, second], "9")); @@ -380,10 +391,10 @@ describe("AccountIncomePage", () => { HttpResponse.json( resource({ ...analytics, - totalsByCurrency: [], buckets: [], - instruments: [], entries: [], + instruments: [], + totalsByCurrency: [], }), ), ), @@ -398,33 +409,35 @@ describe("AccountIncomePage", () => { }); it("renders the signed drill-down using opaque membership rather than instrument identity", async () => { - const response = { - ...analytics, - instruments: [ - { - ...analytics.instruments[0], - key: "server-only-key", - total: money("RUB", "8"), - dividend: money("RUB", "-2"), - }, - ], - entries: [ - { - ...analytics.entries[0], - instrumentKey: "server-only-key", - figi: null, - ticker: null, - description: "Исходная выплата", - }, - { - ...analytics.entries[1], - instrumentKey: "server-only-key", - instrumentUid: "unrelated-uid", - payment: money("RUB", "-2"), - description: "Отрицательная корректировка", - }, - ], - }; + const response = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...analytics, + entries: [ + { + ...analytics.entries[0], + description: "Исходная выплата", + figi: null, + instrumentKey: "server-only-key", + ticker: null, + }, + { + ...analytics.entries[1], + description: "Отрицательная корректировка", + instrumentKey: "server-only-key", + instrumentUid: "unrelated-uid", + payment: money("RUB", "-2"), + }, + ], + instruments: [ + { + ...analytics.instruments[0], + dividend: money("RUB", "-2"), + key: "server-only-key", + total: money("RUB", "8"), + }, + ], + } + /* eslint-enable unicorn/no-null */); server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json(resource(response)), @@ -485,7 +498,7 @@ describe("AccountIncomePage", () => { expect(screen.queryByText("Выбранный период")).not.toBeInTheDocument(); expect(screen.queryByText("Сравниваемый период")).not.toBeInTheDocument(); expect( - screen.getByRole("heading", { name: "Годом ранее", level: 2 }), + screen.getByRole("heading", { level: 2, name: "Годом ранее" }), ).toBeInTheDocument(); expect(screen.queryByText("Сравнения")).not.toBeInTheDocument(); expect(screen.queryByText("RUB", { exact: true })).not.toBeInTheDocument(); @@ -501,6 +514,7 @@ describe("AccountIncomePage", () => { expect(within(difference).queryByText("нет базы")).not.toBeInTheDocument(); expect(within(difference).getAllByText(/%$/)).toHaveLength(2); expect( + // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. within(difference).getByText("+2 ₽").closest("dd"), ).toHaveTextContent(/^\+2 ₽$/); }); @@ -509,11 +523,13 @@ describe("AccountIncomePage", () => { const dependencies = [ { ...resource(analytics).meta, role: "selected" as const }, { ...resource(analytics).meta, role: "previous" as const }, - { - ...resource(null).meta, - role: "year" as const, - unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", - }, + ( + { + ...resource(null).meta, + role: "year" as const, + unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", + } + ), ]; server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => @@ -556,12 +572,12 @@ describe("income comparison display", () => { ["1", "3", "+33,3 %"], ["0.01", "0.04", "+25 %"], ["123456789012345678.123456789", "123456789012345678.123456789", "+100 %"], - ["10", "0", null], - ["0", "0", null], - ["10", "-0.00", null], - ["10", "-100", null], - ["9".repeat(400), "1", null], - ["1", "9".repeat(400), null], + ["10", "0", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], + ["0", "0", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], + ["10", "-0.00", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], + ["10", "-100", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], + ["9".repeat(400), "1", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], + ["1", "9".repeat(400), /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null], ])("formats change %s against base %s", (difference, base, expected) => { const value = comparison("RUB"); value.difference.coupon = money("RUB", difference); @@ -574,7 +590,7 @@ describe("income comparison display", () => { }, }); const result = display?.comparison?.values[0]; - expect(result?.percentages.coupon?.replaceAll("\u00a0", " ") ?? null).toBe( + expect(result?.percentages.coupon?.replaceAll("\u{A0}", " ") ?? /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null).toBe( expected, ); expect(result?.difference.coupon.value).toBe(difference); @@ -596,7 +612,7 @@ describe("income comparison display", () => { expect(display?.comparison?.comparedPeriod.replaceAll(/\s/g, " ")).toBe( "01.12.2024 – 09.01.2025", ); - expect(display?.comparison?.values.map((value) => value.currency)).toEqual([ + expect(display?.comparison?.values.map(value => value.currency)).toEqual([ "RUB", "USD", ]); @@ -609,19 +625,13 @@ const LocationProbe: FC = () => { return ( <> {location.search} - ); }; -function selectPeriod() { - fireEvent.click(screen.getByLabelText("Период")); - fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i })); - fireEvent.click(screen.getByRole("button", { name: /^27 августа 2026$/i })); -} - function respondWithRequestedPeriod(requests: string[]) { server.use( http.get( @@ -643,6 +653,12 @@ function respondWithRequestedPeriod(requests: string[]) { ); } +function selectPeriod() { + fireEvent.click(screen.getByLabelText("Период")); + fireEvent.click(screen.getByRole("button", { name: /^2 августа 2026$/i })); + fireEvent.click(screen.getByRole("button", { name: /^27 августа 2026$/i })); +} + describe("income period and snapshot boundary", () => { afterEach(() => vi.useRealTimers()); @@ -664,6 +680,7 @@ describe("income period and snapshot boundary", () => { fireEvent.submit( screen .getByRole("button", { name: "Применить" }) + // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. .closest("form") as HTMLFormElement, ); await waitFor(() => expect(requests).toHaveLength(2)); @@ -735,10 +752,8 @@ describe("income period and snapshot boundary", () => { const { queryClient } = renderPage(); await screen.findByLabelText("Период"); selectPeriod(); - let release!: () => void; - const pending = new Promise((resolve) => { - release = resolve; - }); + + const { promise: pending, resolve: release } = Promise.withResolvers(); server.use( http.get( "*/api/v1/brokerage-accounts/account-1/income-analytics", @@ -768,22 +783,24 @@ describe("income period and snapshot boundary", () => { HttpResponse.json( { id: "income", - scope: { screen: "income", accountId: "account-1" }, now: "2026-09-25T00:00:00Z", + scope: { accountId: "account-1", screen: "income" }, }, { status: 202 }, ), ), http.get("*/api/v1/tbank-sync/:id", () => - HttpResponse.json({ - id: "income", - scope: null, - now: "2026-09-25T00:00:00Z", - state: "succeeded", - errorCode: null, - progress: { total: 1, completed: 1, discovering: false }, - resources: [resource(null, { stateVersion: "2" }).meta], - }), + HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + errorCode: null, + id: "income", + now: "2026-09-25T00:00:00Z", + progress: { completed: 1, discovering: false, total: 1 }, + resources: [resource(null, { stateVersion: "2" }).meta], + scope: null, + state: "succeeded", + } + /* eslint-enable unicorn/no-null */)), ), http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json(resource(analytics, { stateVersion: "2" })), @@ -804,10 +821,8 @@ describe("income period and snapshot boundary", () => { respondWithRequestedPeriod([]); renderPage(); await screen.findByLabelText("Период"); - let release!: () => void; - const pending = new Promise((resolve) => { - release = resolve; - }); + + const { promise: pending, resolve: release } = Promise.withResolvers(); server.use( http.get( "*/api/v1/brokerage-accounts/account-1/income-analytics", @@ -843,39 +858,39 @@ describe("income period and snapshot boundary", () => { const instruments = [ { ...analytics.instruments[0], - key: "usd", - total: money("USD", "10"), coupon: money("USD", "10"), dividend: money("USD", "0"), + key: "usd", receiptCount: 1, + total: money("USD", "10"), }, { ...analytics.instruments[0], - key: "rub", - total: money("RUB", "-2"), coupon: money("RUB", "-2"), dividend: money("RUB", "0"), + key: "rub", receiptCount: 1, + total: money("RUB", "-2"), }, { ...analytics.instruments[0], - key: "rub-negative", - displayName: "Другой инструмент", - total: money("RUB", "-3"), coupon: money("RUB", "-3"), + displayName: "Другой инструмент", dividend: money("RUB", "0"), + key: "rub-negative", receiptCount: 1, + total: money("RUB", "-3"), }, ]; const display = toIncomeDisplayModel({ ...analytics, - totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")], - instruments, - entries: instruments.map((instrument) => ({ + entries: instruments.map(instrument => ({ ...entry(), instrumentKey: instrument.key, payment: instrument.total, })), + instruments, + totalsByCurrency: [summary("RUB", "-5", "0"), summary("USD", "10", "0")], }); render( @@ -902,22 +917,26 @@ describe("income period and snapshot boundary", () => { it("maps comparison notices by role regardless of dependency order or resource key", () => { const selected = { ...resource(analytics).meta, role: "selected" as const }; - const previous = { - ...resource(null).meta, - resourceKey: "opaque/year-looking-key", - role: "previous" as const, - sync: { - id: null, - state: "failed" as const, - errorCode: "TBANK_UNAVAILABLE", - }, - }; - const year = { - ...resource(null).meta, - resourceKey: "opaque/previous-looking-key", - role: "year" as const, - unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", - }; + const previous = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...resource(null).meta, + resourceKey: "opaque/year-looking-key", + role: "previous" as const, + sync: { + errorCode: "TBANK_UNAVAILABLE", + id: null, + state: "failed" as const, + }, + } + /* eslint-enable unicorn/no-null */); + const year = ( + { + ...resource(null).meta, + resourceKey: "opaque/previous-looking-key", + role: "year" as const, + unavailableReason: "CALENDAR_OR_ACCOUNT_HISTORY", + } + ); for (const dependencies of [ [selected, previous, year], [year, selected, previous], @@ -939,16 +958,20 @@ it.each([false, true])( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json( resource( - { ...analytics, previous: null, yearComparison: null }, - { - dependencies: legacy - ? [ - resource(null).meta, - resource(null).meta, - resource(null).meta, - ] - : [], - }, + (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { ...analytics, previous: null, yearComparison: null } + /* eslint-enable unicorn/no-null */), + ( + { + dependencies: legacy + ? [ + resource(null).meta, + resource(null).meta, + resource(null).meta, + ] + : [], + } + ), ), ), ), @@ -969,16 +992,20 @@ it.each([false, true])( it("shows selected income before comparison coverage without inventing a zero baseline", async () => { const dependencies = (["year", "selected", "previous"] as const).map( - (role) => ({ - ...resource(role === "selected" ? analytics : null).meta, - role, - }), + role => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...resource(role === "selected" ? analytics : null).meta, + role, + } + /* eslint-enable unicorn/no-null */)), ); server.use( http.get("*/api/v1/brokerage-accounts/account-1/income-analytics", () => HttpResponse.json( resource( - { ...analytics, previous: null, yearComparison: null }, + (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { ...analytics, previous: null, yearComparison: null } + /* eslint-enable unicorn/no-null */), { dependencies }, ), ), @@ -1005,15 +1032,17 @@ it("shows selected income before comparison coverage without inventing a zero ba it("keeps complete previous comparison while year source failed without inventing its amounts", async () => { const dependencies = [ { ...resource(analytics).meta, role: "previous" as const }, - { - ...resource(null).meta, - role: "year" as const, - sync: { - id: null, - state: "failed" as const, - errorCode: "TBANK_UNAVAILABLE", - }, - }, + (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...resource(null).meta, + role: "year" as const, + sync: { + errorCode: "TBANK_UNAVAILABLE", + id: null, + state: "failed" as const, + }, + } + /* eslint-enable unicorn/no-null */), { ...resource(analytics).meta, role: "selected" as const }, ]; server.use( diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx index 7e991ce..bdbfab6 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.tsx @@ -1,4 +1,5 @@ import type { FC } from "react"; + import { useAccountIncomePageViewModel } from "./AccountIncomePage.vm"; import { AccountIncomePageError } from "./components/AccountIncomePageError.view"; import { AccountIncomePageLoaded } from "./components/AccountIncomePageLoaded.view"; @@ -9,9 +10,7 @@ type AccountIncomePageProps = { accountId?: string }; const AccountIncomePage: FC = (props) => { const viewModel = useAccountIncomePageViewModel(props.accountId); if (viewModel.state === "loading") return ; - if (viewModel.state === "error") - return ; - return ; + return viewModel.state === "error" ? : ; }; export { AccountIncomePage }; diff --git a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts index b079236..ef00e28 100644 --- a/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts +++ b/src/pages/account-income/ui/AccountIncomePage/AccountIncomePage.vm.ts @@ -3,70 +3,73 @@ import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useParams, useSearchParams } from "react-router"; import { z } from "zod"; + import { type IncomeAnalytics, useIncomeAnalytics } from "@/entities/income"; import { useScreenSync } from "@/features/tbank-connection"; import { validationMessage } from "@/shared/i18n"; import { formatDate } from "@/shared/lib"; + import { formatDraftDate, type IncomeForm, type IncomePeriod, isDate, + ISO_DATE_LENGTH, moscowDate, parseDraftDate, } from "../../model/period"; const ISO_MONTH_LENGTH = 7; -type IncomeMeta = ReturnType["meta"]; -type ComparisonNotice = - | "comparisonPending" - | "comparisonFailed" - | "previousUnavailable" - | "yearUnavailable"; +type AccountIncomePageViewModel + = | { + analytics: IncomeAnalytics; + display: IncomeDisplayModel; + form: ReturnType>; + formRevision: string; + lastSuccessfulAt?: null | string; + onReset: () => void; + onSubmit: (values: IncomeForm) => void; + partial?: boolean; + refreshError: boolean; + refreshing: boolean; + retry: () => Promise | undefined; + sourceError?: boolean; + state: "loaded"; + } + | { + period: IncomePeriod; + retry: () => Promise | undefined; + state: "error"; + } + | { state: "loading" }; +type ComparisonNotice + = | "comparisonFailed" + | "comparisonPending" + | "previousUnavailable" + | "yearUnavailable"; type IncomeDisplayModel = { - previousComparison: IncomeDisplayModel["comparison"]; - previousNotice: ComparisonNotice; - yearNotice: ComparisonNotice; - entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; - comparison: { - selectedPeriod: string; + comparison: null | { comparedPeriod: string; + selectedPeriod: string; values: (NonNullable< IncomeAnalytics["yearComparison"] >["totalsByCurrency"][number] & { - percentages: Record<"total" | "coupon" | "dividend", string | null>; + percentages: Record<"coupon" | "dividend" | "total", null | string>; })[]; - } | null; + }; + entries: { key: string; operation: IncomeAnalytics["entries"][number] }[]; + previousComparison: IncomeDisplayModel["comparison"]; + previousNotice: ComparisonNotice; + yearNotice: ComparisonNotice; }; -type AccountIncomePageViewModel = - | { state: "loading" } - | { - state: "error"; - retry: () => undefined | Promise; - period: IncomePeriod; - } - | { - state: "loaded"; - analytics: IncomeAnalytics; - display: IncomeDisplayModel; - formRevision: string; - sourceError?: boolean; - lastSuccessfulAt?: string | null; - partial?: boolean; - refreshing: boolean; - refreshError: boolean; - retry: () => undefined | Promise; - form: ReturnType>; - onReset: () => void; - onSubmit: (values: IncomeForm) => void; - }; +type IncomeMeta = ReturnType["meta"]; const draftDate = z .string() - .refine((value) => parseDraftDate(value) !== null, { + .refine(value => parseDraftDate(value) !== null, { error: validationMessage("income.validation.date"), }) .refine( @@ -90,16 +93,16 @@ const useAccountIncomePageViewModel = ( ): AccountIncomePageViewModel => { const { accountId: routeAccountId } = useParams(); const accountId = scopeId ?? routeAccountId ?? ""; - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParameters, setSearchParameters] = useSearchParams(); const requested = useMemo( () => ({ - from: searchParams.get("from") ?? "", - to: searchParams.get("to") ?? "", + from: searchParameters.get("from") ?? "", + to: searchParameters.get("to") ?? "", }), - [searchParams], + [searchParameters], ); const period = useMemo( - () => (validPeriod(requested) ? requested : defaultPeriod()), + () => (isValidPeriod(requested) ? requested : defaultPeriod()), [requested], ); const form = useForm({ @@ -109,60 +112,94 @@ const useAccountIncomePageViewModel = ( const [resetRevision, setResetRevision] = useState(0); const analytics = useIncomeAnalytics(accountId, period); const sync = useScreenSync( - { screen: "income", accountId, from: period.from, to: period.to }, + { accountId, from: period.from, screen: "income", to: period.to }, [analytics], ); useEffect(() => { - if (!validPeriod(requested)) setSearchParams(period, { replace: true }); - }, [period, requested, setSearchParams]); + if (!isValidPeriod(requested)) setSearchParameters(period, { replace: true }); + }, [period, requested, setSearchParameters]); useEffect(() => { form.reset(draftPeriod(period)); }, [form, period]); + if (analytics.isLoading || !analytics.data) { + return analytics.isError || analytics.meta?.sync.state === "failed" ? { period, retry: sync.retry, state: "error" } : { state: "loading" }; + } + const display = toIncomeDisplayModel(analytics.data, analytics.meta); + if (!display) + return { + period, + retry: () => analytics.observe(), + state: "error", + }; const onSubmit = (values: IncomeForm) => { const from = parseDraftDate(values.from); const to = parseDraftDate(values.to); - if (from && to) setSearchParams({ from, to }); + if (from && to) setSearchParameters({ from, to }); }; const onReset = () => { const next = defaultPeriod(); form.reset(draftPeriod(next)); // Reset the open calendar along with the form. - setResetRevision((revision) => revision + 1); - setSearchParams(next, { replace: true }); + setResetRevision(revision => revision + 1); + setSearchParameters(next, { replace: true }); }; - if (analytics.isLoading || !analytics.data) { - if (analytics.isError || analytics.meta?.sync.state === "failed") - return { state: "error", retry: sync.retry, period }; - return { state: "loading" }; - } - const display = toIncomeDisplayModel(analytics.data, analytics.meta); - if (!display) - return { - state: "error", - retry: () => analytics.observe(), - period, - }; return { - state: "loaded", analytics: analytics.data, display, - formRevision: `${period.from}:${period.to}:${resetRevision}`, - sourceError: sync.sourceError, - lastSuccessfulAt: analytics.meta?.lastSuccessfulAt, - partial: analytics.meta?.availability !== "complete", - refreshing: analytics.isFetching || sync.refreshing, - refreshError: analytics.isError || sync.sourceError || sync.transportError, - retry: sync.retry, form, - onSubmit, + formRevision: `${period.from}:${period.to}:${resetRevision}`, + lastSuccessfulAt: analytics.meta?.lastSuccessfulAt, onReset, + onSubmit, + partial: analytics.meta?.availability !== "complete", + refreshError: analytics.isError || sync.sourceError || sync.transportError, + refreshing: analytics.isFetching || sync.refreshing, + retry: sync.retry, + sourceError: sync.sourceError, + state: "loaded", }; }; -function draftPeriod(period: IncomePeriod): IncomeForm { - return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) }; +function comparisonDisplay( + period: IncomePeriod, + comparison: IncomeAnalytics["yearComparison"], +): IncomeDisplayModel["comparison"] { + if (!comparison) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + return { + comparedPeriod: formatComparisonPeriod(comparison.period), + selectedPeriod: formatComparisonPeriod(period), + values: comparison.totalsByCurrency.map(value => ({ + ...value, + percentages: { + coupon: formatIncomeChange( + value.difference.coupon.value, + value.compared.coupon.value, + ), + dividend: formatIncomeChange( + value.difference.dividend.value, + value.compared.dividend.value, + ), + total: formatIncomeChange( + value.difference.total.value, + value.compared.total.value, + ), + }, + })), + }; +} + +function comparisonNotice( + dependency: + | NonNullable["dependencies"]>[number] + | undefined, + unavailable: "previousUnavailable" | "yearUnavailable", +): ComparisonNotice { + if (!dependency || dependency.unavailableReason) return unavailable; + return dependency.sync.state === "failed" + ? "comparisonFailed" + : "comparisonPending"; } function defaultPeriod(): IncomePeriod { @@ -170,16 +207,40 @@ function defaultPeriod(): IncomePeriod { return { from: `${today.slice(0, ISO_MONTH_LENGTH)}-01`, to: today }; } -function validPeriod(period: IncomePeriod): period is IncomePeriod { +function draftPeriod(period: IncomePeriod): IncomeForm { + return { from: formatDraftDate(period.from), to: formatDraftDate(period.to) }; +} + +function formatComparisonPeriod(period: IncomePeriod): string { + return `${formatDate(period.from)} – ${formatDate(period.to)}`; +} + +function formatIncomeChange( + difference: string, + compared: string, +): null | string { + // Display-only ratio; server-provided Money values remain exact decimal strings. + const base = Number(compared); + if (!Number.isFinite(base) || base <= 0) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + const ratio = Number(difference) / base; + if (!Number.isFinite(ratio)) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + return new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 1, + signDisplay: "exceptZero", + style: "percent", + }).format(ratio); +} + +function isValidPeriod(period: IncomePeriod): period is IncomePeriod { if (!isDate(period.from) || !isDate(period.to)) return false; const from = new Date(`${period.from}T00:00:00Z`); const to = new Date(`${period.to}T00:00:00Z`); const today = new Date(`${moscowDate(new Date())}T00:00:00Z`); return ( - from.toISOString().slice(0, 10) === period.from && - to.toISOString().slice(0, 10) === period.to && - from <= to && - to <= today + from.toISOString().slice(0, ISO_DATE_LENGTH) === period.from + && to.toISOString().slice(0, ISO_DATE_LENGTH) === period.to + && from <= to + && to <= today ); } @@ -191,8 +252,8 @@ function toIncomeDisplayModel( string, { currency: string; - receiptCount: number; includedCount: number; + receiptCount: number; } >(); const currencies = new Set( @@ -200,25 +261,28 @@ function toIncomeDisplayModel( ); for (const instrument of analytics.instruments) { if ( - typeof instrument.key !== "string" || - !instrument.key.trim() || - instruments.has(instrument.key) || - !currencies.has(instrument.total.currency) + typeof instrument.key !== "string" + || !instrument.key.trim() + || instruments.has(instrument.key) + || !currencies.has(instrument.total.currency) ) + // eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract. return null; instruments.set(instrument.key, { currency: instrument.total.currency, - receiptCount: instrument.receiptCount, includedCount: 0, + receiptCount: instrument.receiptCount, }); } const entries: IncomeDisplayModel["entries"] = []; const occurrences = new Map(); for (const entry of analytics.entries) { if (typeof entry.instrumentKey !== "string" || !entry.instrumentKey.trim()) + // eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract. return null; const instrument = instruments.get(entry.instrumentKey); if (!instrument || entry.payment?.currency !== instrument.currency) + // eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract. return null; const occurrenceKey = JSON.stringify([entry.instrumentKey, entry.id]); const occurrence = occurrences.get(occurrenceKey) ?? 0; @@ -235,80 +299,20 @@ function toIncomeDisplayModel( }); } for (const instrument of instruments.values()) { - if (instrument.includedCount !== instrument.receiptCount) return null; + if (instrument.includedCount !== instrument.receiptCount) return /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; } const previous = meta?.dependencies?.find(({ role }) => role === "previous"); const year = meta?.dependencies?.find(({ role }) => role === "year"); return { + comparison: comparisonDisplay(analytics.period, analytics.yearComparison), entries, previousComparison: comparisonDisplay(analytics.period, analytics.previous), - comparison: comparisonDisplay(analytics.period, analytics.yearComparison), previousNotice: comparisonNotice(previous, "previousUnavailable"), yearNotice: comparisonNotice(year, "yearUnavailable"), }; } -function comparisonNotice( - dependency: - | NonNullable["dependencies"]>[number] - | undefined, - unavailable: "previousUnavailable" | "yearUnavailable", -): ComparisonNotice { - if (!dependency || dependency.unavailableReason) return unavailable; - return dependency.sync.state === "failed" - ? "comparisonFailed" - : "comparisonPending"; -} - -function comparisonDisplay( - period: IncomePeriod, - comparison: IncomeAnalytics["yearComparison"], -): IncomeDisplayModel["comparison"] { - if (!comparison) return null; - return { - selectedPeriod: formatComparisonPeriod(period), - comparedPeriod: formatComparisonPeriod(comparison.period), - values: comparison.totalsByCurrency.map((value) => ({ - ...value, - percentages: { - total: formatIncomeChange( - value.difference.total.value, - value.compared.total.value, - ), - coupon: formatIncomeChange( - value.difference.coupon.value, - value.compared.coupon.value, - ), - dividend: formatIncomeChange( - value.difference.dividend.value, - value.compared.dividend.value, - ), - }, - })), - }; -} - -function formatComparisonPeriod(period: IncomePeriod): string { - return `${formatDate(period.from)} – ${formatDate(period.to)}`; -} - -function formatIncomeChange( - difference: string, - compared: string, -): string | null { - // Display-only ratio; server-provided Money values remain exact decimal strings. - const base = Number(compared); - if (!Number.isFinite(base) || base <= 0) return null; - const ratio = Number(difference) / base; - if (!Number.isFinite(ratio)) return null; - return new Intl.NumberFormat("ru-RU", { - style: "percent", - maximumFractionDigits: 1, - signDisplay: "exceptZero", - }).format(ratio); -} - export { type AccountIncomePageViewModel, type IncomeDisplayModel, diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx index e125d81..51ad1a6 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageError.view.tsx @@ -1,7 +1,9 @@ +import type { FC } from "react"; + import { Button } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; + import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; type ErrorProps = Extract; @@ -10,10 +12,10 @@ const AccountIncomePageError: FC = (props) => { const { t } = useTranslation(); return ( {t("income.retry")}} + error + text={t("income.errorText")} + title={t("income.errorTitle")} /> ); }; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx index 9d2da9f..cb5cfcd 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoaded.view.tsx @@ -1,3 +1,5 @@ +import type { FC } from "react"; + import { Alert, Button, @@ -8,12 +10,14 @@ import { Title, } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; -import type { FC } from "react"; import { FormProvider } from "react-hook-form"; import { useTranslation } from "react-i18next"; + import { formatSnapshotTime } from "@/shared/lib"; -import classes from "../AccountIncomePage.module.css"; + import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; + +import classes from "../AccountIncomePage.module.css"; import { IncomeComparisons } from "./IncomeComparisons.view"; import { IncomePayments } from "./IncomePayments.view"; import { IncomePeriodForm } from "./IncomePeriodForm/IncomePeriodForm.view"; @@ -26,41 +30,55 @@ const AccountIncomePageLoaded: FC = (props) => { analytics, display, form, - refreshing, - refreshError, - retry, - onSubmit, onReset, + onSubmit, + refreshError, + refreshing, + retry, } = props; const { t } = useTranslation(); + function renderSummary() { + if (props.partial) { + return {t("investment.partial")}; + } + if (analytics.totalsByCurrency.length > 0) { + return ; + } + return ( + + ); + } return ( - - {props.lastSuccessfulAt ? ( - + + {!!(props.lastSuccessfulAt) && (( + {t("investment.sourceTime", { date: formatSnapshotTime(props.lastSuccessfulAt), })} - ) : null} + ))} {t("income.title")} - - {refreshing ? ( - + {!!(refreshing) && (( + {t("income.refreshing")} - ) : null} - {refreshError ? ( - + ))} + {!!(refreshError) && (( + {t( @@ -74,27 +92,18 @@ const AccountIncomePageLoaded: FC = (props) => { - ) : null} - {props.partial ? ( - {t("investment.partial")} - ) : analytics.totalsByCurrency.length ? ( - - ) : ( - - )} + ))} + {renderSummary()} {t("income.taxBoundary")} diff --git a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx index f465dbb..b51136c 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/AccountIncomePageLoading.view.tsx @@ -1,7 +1,9 @@ +import type { FC } from "react"; + import { Paper, Skeleton, Stack } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; + import classes from "../AccountIncomePage.module.css"; const AccountIncomePageLoading: FC = () => { diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx index 8e32d4e..9acf384 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomeComparisons.view.tsx @@ -1,38 +1,44 @@ -import { Paper, Stack, Text, Title } from "@mantine/core"; import type { FC } from "react"; + +import { Paper, Stack, Text, Title } from "@mantine/core"; import { useTranslation } from "react-i18next"; + import { formatMoney } from "@/shared/lib"; -import classes from "../AccountIncomePage.module.css"; + import type { IncomeDisplayModel } from "../AccountIncomePage.vm"; -type IncomeComparisonsProps = { - comparison: IncomeDisplayModel["comparison"]; - title: string; - notice: string; -}; +import classes from "../AccountIncomePage.module.css"; + type ComparisonProps = NonNullable & { title: string; }; type ComparisonValuesProps = { - kind: "selected" | "compared" | "difference"; + kind: "compared" | "difference" | "selected"; label: string; - values: ComparisonProps["values"][number]["selected"]; percentages?: ComparisonProps["values"][number]["percentages"]; + values: ComparisonProps["values"][number]["selected"]; +}; +type IncomeComparisonsProps = { + comparison: IncomeDisplayModel["comparison"]; + notice: string; + title: string; }; const IncomeComparisons: FC = (props) => { return ( {props.title} - {props.comparison ? ( - - ) : ( - - - {props.notice} - - - )} + {props.comparison + ? ( + + ) + : ( + + + {props.notice} + + + )} ); }; @@ -42,12 +48,12 @@ const Comparison: FC = (props) => { return (
- {props.values.map((value) => ( + {props.values.map(value => (
= (props) => {
))} - {props.values.length === 0 ? ( - + {props.values.length === 0 && (( + {t("income.comparisonEmpty")} - ) : null} + ))}
); @@ -87,30 +93,28 @@ const ComparisonValues: FC = (props) => { className={classes.comparisonPanel} data-kind={props.kind} data-tone={ - props.kind === "difference" - ? differenceTone(props.values.total.value) - : undefined + (props.kind === "difference") ? (differenceTone(props.values.total.value)) : undefined } > - + {props.label}
{(["total", "coupon", "dividend"] as const).map((key) => { - const money = props.values[key]; - const percentage = props.percentages?.[key]; - const tone = - props.kind === "difference" + const money = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.values[key]; + const percentage = /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ props.percentages?.[key]; + const tone + = props.kind === "difference" ? differenceTone(money.value) : undefined; return (
{t(`income.${key}`)}
@@ -118,11 +122,11 @@ const ComparisonValues: FC = (props) => { {tone === "positive" ? "+" : ""} {formatMoney(money)} - {percentage ? ( + {!!(percentage) && (( {percentage} - ) : null} + ))}
); @@ -132,7 +136,7 @@ const ComparisonValues: FC = (props) => { ); }; -function differenceTone(value: string): "positive" | "negative" | "neutral" { +function differenceTone(value: string): "negative" | "neutral" | "positive" { if (!/[1-9]/.test(value)) return "neutral"; return value.startsWith("-") ? "negative" : "positive"; } diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx index 92499e8..cf418ed 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePayments.view.tsx @@ -1,19 +1,23 @@ -import { Stack, Title } from "@mantine/core"; import type { FC } from "react"; + +import { Stack, Title } from "@mantine/core"; import { useTranslation } from "react-i18next"; + import { LedgerEntryRow } from "@/entities/broker-operation"; -import classes from "../AccountIncomePage.module.css"; + import type { AccountIncomePageViewModel } from "../AccountIncomePage.vm"; -type LoadedProps = Extract; +import classes from "../AccountIncomePage.module.css"; + type IncomePaymentsProps = { entries: LoadedProps["display"]["entries"] }; +type LoadedProps = Extract; const IncomePayments: FC = (props) => { const { t } = useTranslation(); return ( - + {t("income.payments")} - + {props.entries.map(({ key, operation }) => (
  • diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx index 9d246ca..8109871 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.stories.tsx @@ -1,12 +1,15 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import type { FC } from "react"; + import { FormProvider, useForm, useFormState } from "react-hook-form"; + import type { IncomeForm } from "../../../../model/period"; + import { IncomePeriodForm } from "./IncomePeriodForm.view"; const period = { from: "01.08.2026", to: "27.08.2026" }; const errors = { - to: { type: "manual", message: "Синтетическая ошибка периода" }, + to: { message: "Синтетическая ошибка периода", type: "manual" }, }; type PeriodExampleProps = { invalid?: boolean }; @@ -17,14 +20,14 @@ const PeriodExample: FC = (props) => { errors: props.invalid ? errors : undefined, }); const { isSubmitSuccessful } = useFormState({ control: methods.control }); - const submitted = isSubmitSuccessful ? methods.getValues() : null; + const submitted = isSubmitSuccessful ? methods.getValues() : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null; return ( undefined)} onReset={() => { methods.reset(period); }} + onSubmit={methods.handleSubmit(() => {})} /> {submitted @@ -36,9 +39,7 @@ const PeriodExample: FC = (props) => { }; const meta = { - title: "Product/IncomePeriodForm", component: PeriodExample, - tags: ["autodocs"], parameters: { docs: { description: { @@ -48,6 +49,8 @@ const meta = { }, }, render: () => , + tags: ["autodocs"], + title: "Product/IncomePeriodForm", } satisfies Meta; export default meta; diff --git a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx index b9ab7aa..61d9953 100644 --- a/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx +++ b/src/pages/account-income/ui/AccountIncomePage/components/IncomePeriodForm/IncomePeriodForm.view.tsx @@ -1,8 +1,11 @@ +import type { FC, SubmitEventHandler } from "react"; + import { Button, Paper, Stack, Text } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; -import type { FC, FormEventHandler } from "react"; +import { useState } from "react"; import { useController, useFormContext, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; + import { formatDraftDate, type IncomeForm, @@ -12,8 +15,8 @@ import { import classes from "../../AccountIncomePage.module.css"; type IncomePeriodFormProps = { - onSubmit: FormEventHandler; onReset: () => void; + onSubmit: SubmitEventHandler; }; const IncomePeriodForm: FC = (props) => { @@ -23,15 +26,15 @@ const IncomePeriodForm: FC = (props) => { formState: { isSubmitting }, } = useFormContext(); const { - field: { ref: fromRef, onChange: onFromChange, onBlur: onFromBlur }, + field: { onBlur: onFromBlur, onChange: onFromChange, ref: fromRef }, fieldState: { error: fromError }, } = useController({ control, name: "from" }); const { - field: { onChange: onToChange, onBlur: onToBlur }, + field: { onBlur: onToBlur, onChange: onToChange }, fieldState: { error: toError }, } = useController({ control, name: "to" }); const [from, to] = useWatch({ control, name: ["from", "to"] }); - const today = moscowDate(new Date()); + const [today, setToday] = useState(() => moscowDate(new Date())); const start = parseDraftDate(from); const end = parseDraftDate(to); return ( @@ -46,39 +49,40 @@ const IncomePeriodForm: FC = (props) => { {t("income.period")}
    { - onFromChange(from ? formatDraftDate(from) : ""); - onToChange(to ? formatDraftDate(to) : ""); - }} + maxDate={today} onBlur={() => { onFromBlur(); onToBlur(); }} - maxDate={today} - error={fromError?.message ?? toError?.message} - disabled={isSubmitting} - popoverProps={{ - returnFocus: true, - middlewares: { shift: { crossAxis: true } }, + onChange={([from, to]) => { + onFromChange(from ? formatDraftDate(from) : ""); + onToChange(to ? formatDraftDate(to) : ""); }} + popoverProps={{ + middlewares: { shift: { crossAxis: true } }, + onOpen: () => setToday(moscowDate(new Date())), + returnFocus: true, + }} + ref={fromRef} + type="range" + value={[start, end]} + valueFormat="DD.MM.YYYY" />
    - - @@ -58,8 +63,8 @@ const Navigation: FC = () => { function configureTestAuth() { configureAuthClient({ accessToken: () => "token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); } @@ -72,8 +77,8 @@ function renderPage(search = "") { > } + path="/accounts/:accountId/operations" /> @@ -95,9 +100,9 @@ describe("LedgerEntryRow", () => { expect(screen.getByText("Без движения денег")).toBeInTheDocument(); expect( screen.queryByRole("button", { name: "Показать детали" }), - ).toBeNull(); - expect(screen.queryByText("Цена: 100 ₽")).toBeNull(); - expect(screen.queryByText("Количество: 2")).toBeNull(); + ).not.toBeInTheDocument(); + expect(screen.queryByText("Цена: 100 ₽")).not.toBeInTheDocument(); + expect(screen.queryByText("Количество: 2")).not.toBeInTheDocument(); }); it("labels a positive payment with its category type", () => { @@ -125,9 +130,9 @@ describe("LedgerEntryRow", () => { @@ -145,9 +150,9 @@ describe("LedgerEntryRow", () => { @@ -165,9 +170,9 @@ describe("LedgerEntryRow", () => { @@ -193,7 +198,7 @@ describe("LedgerEntryRow", () => { ); expect(screen.getByText("Без движения денег")).toBeInTheDocument(); - expect(screen.queryByText("Сделка")).toBeNull(); + expect(screen.queryByText("Сделка")).not.toBeInTheDocument(); }); it("uses an instrument initial when the ledger entry has no logo", () => { @@ -222,9 +227,9 @@ describe("AccountOperationsPage", () => { searches.push(new URL(request.url).searchParams); return HttpResponse.json( resource({ + hasNext: true, items: [operation], nextCursor: "next", - hasNext: true, }), ); }, @@ -234,20 +239,20 @@ describe("AccountOperationsPage", () => { `/accounts/${accountId}/operations${search}`)} + ].map(search => `/accounts/${accountId}/operations${search}`)} + initialIndex={0} > } + path="/accounts/:accountId/operations" /> @@ -266,6 +271,7 @@ describe("AccountOperationsPage", () => { screen.getByRole("button", { name: "History forward" }), ); await waitFor(() => + // eslint-disable-next-line jest-dom/prefer-to-have-text-content -- URL history requires exact equality, including empty search. expect(screen.getByLabelText("URL").textContent).toBe(expected), ); await screen.findByLabelText("Период"); @@ -287,11 +293,11 @@ describe("AccountOperationsPage", () => { expect(screen.getByLabelText("Период")).toHaveTextContent("01.02.2026"), ); expect(screen.getByRole("button", { name: "Назад" })).toBeDisabled(); - expect(searches.map((query) => query.get("from"))).not.toContain( + expect(searches.map(query => query.get("from"))).not.toContain( "badT00:00:00.000Z", ); expect( - searches.every((query) => query.get("categories") === "trade"), + searches.every(query => query.get("categories") === "trade"), ).toBe(true); }, ); @@ -307,7 +313,9 @@ describe("AccountOperationsPage", () => { new URL(request.url).searchParams.has("from") ? HttpResponse.json({ code: "VALIDATION_ERROR" }, { status: 400 }) : HttpResponse.json( - resource({ items: [], hasNext: false, nextCursor: null }), + resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { hasNext: false, items: [], nextCursor: null } + /* eslint-enable unicorn/no-null */)), ), ), ); @@ -322,8 +330,8 @@ describe("AccountOperationsPage", () => { } + path="/accounts/:accountId/operations" /> @@ -333,7 +341,7 @@ describe("AccountOperationsPage", () => { await screen.findByRole("button", { name: "Повторить" }); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); await screen.findByLabelText("Период"); - expect(screen.getByLabelText("URL").textContent).toBe(""); + expect(screen.getByLabelText("URL")).toBeEmptyDOMElement(); }, ); @@ -347,13 +355,15 @@ describe("AccountOperationsPage", () => { const url = new URL(request.url); searches.push(url.search); return HttpResponse.json( - resource({ - accountId: "account-1", - items: [operation], - nextCursor: url.searchParams.get("cursor") ? null : "next-1", - hasNext: !url.searchParams.get("cursor"), - observedAt: "2026-08-14T00:00:00Z", - }), + resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + accountId: "account-1", + hasNext: !url.searchParams.get("cursor"), + items: [operation], + nextCursor: url.searchParams.get("cursor") ? null : "next-1", + observedAt: "2026-08-14T00:00:00Z", + } + /* eslint-enable unicorn/no-null */)), ); }, ), @@ -385,13 +395,15 @@ describe("AccountOperationsPage", () => { const url = new URL(request.url); searches.push(url.search); return HttpResponse.json( - resource({ - accountId: "account-1", - items: [operation], - nextCursor: null, - hasNext: false, - observedAt: "2026-08-14T00:00:00Z", - }), + resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + accountId: "account-1", + hasNext: false, + items: [operation], + nextCursor: null, + observedAt: "2026-08-14T00:00:00Z", + } + /* eslint-enable unicorn/no-null */)), ); }, ), @@ -405,9 +417,9 @@ describe("AccountOperationsPage", () => { const countBefore = searches.length; fireEvent.click(screen.getByRole("combobox", { name: "Категории" })); fireEvent.click(await screen.findByText("Налог")); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(searches.length).toBe(countBefore); - expect(searches.some((s) => s.includes("categories=tax"))).toBe(false); + await new Promise(resolve => setTimeout(resolve, 100)); + expect(searches).toHaveLength(countBefore); + expect(searches.some(s => s.includes("categories=tax"))).toBe(false); }); it("reset clears filters and refetches unfiltered", async () => { @@ -420,13 +432,15 @@ describe("AccountOperationsPage", () => { const url = new URL(request.url); searches.push(url.search); return HttpResponse.json( - resource({ - accountId: "account-1", - items: [operation], - nextCursor: null, - hasNext: false, - observedAt: "2026-08-14T00:00:00Z", - }), + resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + accountId: "account-1", + hasNext: false, + items: [operation], + nextCursor: null, + observedAt: "2026-08-14T00:00:00Z", + } + /* eslint-enable unicorn/no-null */)), ); }, ), @@ -440,7 +454,7 @@ describe("AccountOperationsPage", () => { fireEvent.click(await screen.findByText("Налог")); fireEvent.click(screen.getByRole("button", { name: "Применить" })); await waitFor(() => - expect(searches.some((s) => s.includes("categories=tax"))).toBe(true), + expect(searches.some(s => s.includes("categories=tax"))).toBe(true), ); fireEvent.click(screen.getByRole("button", { name: "Сбросить" })); await waitFor(() => expect(searches.at(-1)).not.toContain("categories=")); @@ -471,9 +485,11 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => ({ request }) => { requested.push(Object.fromEntries(new URL(request.url).searchParams)); return HttpResponse.json( - resource(null, { - sync: { id: null, state: "failed", errorCode: "TBANK_UNAVAILABLE" }, - }), + resource(null, (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + sync: { errorCode: "TBANK_UNAVAILABLE", id: null, state: "failed" }, + } + /* eslint-enable unicorn/no-null */)), ); }, ), @@ -486,10 +502,10 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => const retry = await screen.findByRole("button", { name: "Повторить" }); expect(requested).toEqual([ { - from: "2024-02-29T00:00:00.000Z", - to: "2024-03-01T23:59:59.999Z", categories: "trade", + from: "2024-02-29T00:00:00.000Z", limit: "100", + to: "2024-03-01T23:59:59.999Z", }, ]); expect(syncRequests).toEqual([]); @@ -497,12 +513,12 @@ it("keeps UTC operation boundaries in GET and source sync requests", async () => await waitFor(() => expect(syncRequests).toEqual([ { - screen: "operations", accountId: "account-1", - from: "2024-02-29T00:00:00.000Z", - to: "2024-03-01T23:59:59.999Z", categories: ["trade"], + from: "2024-02-29T00:00:00.000Z", limit: 100, + screen: "operations", + to: "2024-03-01T23:59:59.999Z", }, ]), ); @@ -514,12 +530,14 @@ it("does not label incomplete empty operations as a confirmed empty period", asy http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( resource( - { - accountId: "account-1", - items: [], - hasNext: false, - nextCursor: null, - }, + (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + accountId: "account-1", + hasNext: false, + items: [], + nextCursor: null, + } + /* eslint-enable unicorn/no-null */), { availability: "partial" }, ), ), @@ -537,17 +555,17 @@ it("recovers an invalid cursor at the first page without clearing applied filter http.get( "*/api/v1/brokerage-accounts/:accountId/operations", ({ request }) => { - const params = new URL(request.url).searchParams; - searches.push(params); - if (params.has("cursor")) + const parameters = new URL(request.url).searchParams; + searches.push(parameters); + if (parameters.has("cursor")) return HttpResponse.json( { code: "INVALID_OPERATIONS_CURSOR" }, { status: 400 }, ); return HttpResponse.json( resource({ - items: [operation], hasNext: true, + items: [operation], nextCursor: "old-cursor", }), ); @@ -557,7 +575,7 @@ it("recovers an invalid cursor at the first page without clearing applied filter renderPage("?from=2026-08-01&to=2026-08-14&categories=trade"); fireEvent.click(await screen.findByRole("button", { name: "Далее" })); fireEvent.click(await screen.findByRole("button", { name: "Повторить" })); - await waitFor(() => expect(searches.length).toBe(3)); + await waitFor(() => expect(searches).toHaveLength(3)); expect(searches[2].get("cursor")).toBeNull(); for (const key of ["from", "to", "categories"]) expect(searches[2].get(key)).toBe(searches[0].get(key)); diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx index 153ee24..1ed7045 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.tsx @@ -1,4 +1,5 @@ import type { FC } from "react"; + import { useAccountOperationsPageVm } from "./AccountOperationsPage.vm"; import { AccountOperationsPageError } from "./components/AccountOperationsPageError.view"; import { AccountOperationsPageLoaded } from "./components/AccountOperationsPageLoaded.view"; @@ -13,11 +14,7 @@ const AccountOperationsPage: FC = (props) => { return ; } - if (viewModel.state === "error") { - return ; - } - - return ; + return viewModel.state === "error" ? : ; }; export { AccountOperationsPage }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts index 17549a5..daa423b 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts +++ b/src/pages/account-operations/ui/AccountOperationsPage/AccountOperationsPage.vm.ts @@ -2,44 +2,44 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useState } from "react"; import { useParams, useSearchParams } from "react-router"; import { z } from "zod"; + import { type BrokerageOperation, brokerageOperationsQueryOptions, - type OperationCategory, operationCategories, + type OperationCategory, useBrokerageOperations, } from "@/entities/broker-operation"; - import { useScreenSync } from "@/features/tbank-connection"; import { apiErrorCode } from "@/shared/api"; -type AccountOperationsPageViewModel = - | { state: "loading" } - | { state: "error"; retry: () => void; onReset: () => void } - | { - state: "loaded"; - sourceError?: boolean; - lastSuccessfulAt?: string | null; - partial?: boolean; - historyLoading?: boolean; - refreshing?: boolean; - refreshError?: boolean; - retry?: () => void; - draftFrom: string | null; - draftTo: string | null; - draftCategories: OperationCategory[]; - items: BrokerageOperation[]; - hasNext: boolean; - hasPrevious: boolean; - canApply: boolean; - onDraftFromChange: (value: string | null) => void; - onDraftToChange: (value: string | null) => void; - onDraftCategoriesChange: (value: OperationCategory[]) => void; - onApply: () => void; - onReset: () => void; - onNext: () => void; - onPrevious: () => void; - }; +type AccountOperationsPageViewModel + = | { + canApply: boolean; + draftCategories: OperationCategory[]; + draftFrom: null | string; + draftTo: null | string; + hasNext: boolean; + hasPrevious: boolean; + historyLoading?: boolean; + items: BrokerageOperation[]; + lastSuccessfulAt?: null | string; + onApply: () => void; + onDraftCategoriesChange: (value: OperationCategory[]) => void; + onDraftFromChange: (value: null | string) => void; + onDraftToChange: (value: null | string) => void; + onNext: () => void; + onPrevious: () => void; + onReset: () => void; + partial?: boolean; + refreshError?: boolean; + refreshing?: boolean; + retry?: () => void; + sourceError?: boolean; + state: "loaded"; + } + | { onReset: () => void; retry: () => void; state: "error" } + | { state: "loading" }; const useAccountOperationsPageVm = ( scopeId?: string, @@ -47,19 +47,19 @@ const useAccountOperationsPageVm = ( const { accountId: routeAccountId } = useParams(); const accountId = scopeId ?? routeAccountId; const queryClient = useQueryClient(); - const [searchParams, setSearchParams] = useSearchParams(); - const urlFrom = searchParams.get("from"); - const urlTo = searchParams.get("to"); - const validUrlPeriod = isValidPeriod(urlFrom, urlTo); - const appliedFrom = validUrlPeriod ? urlFrom : null; - const appliedTo = validUrlPeriod ? urlTo : null; - const appliedCategories = operationCategories.filter((category) => - (searchParams.get("categories") ?? "").split(",").includes(category), + const [searchParameters, setSearchParameters] = useSearchParams(); + const urlFrom = searchParameters.get("from"); + const urlTo = searchParameters.get("to"); + const isValidUrlPeriod = isValidPeriod(urlFrom, urlTo); + const appliedFrom = isValidUrlPeriod ? urlFrom : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + const appliedTo = isValidUrlPeriod ? urlTo : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + const appliedCategories = operationCategories.filter(category => + (searchParameters.get("categories") ?? "").split(",").includes(category), ); - const [draftFrom, setDraftFrom] = useState(appliedFrom); - const [draftTo, setDraftTo] = useState(appliedTo); - const [draftCategories, setDraftCategories] = - useState(appliedCategories); + const [draftFrom, setDraftFrom] = useState(appliedFrom); + const [draftTo, setDraftTo] = useState(appliedTo); + const [draftCategories, setDraftCategories] + = useState(appliedCategories); const scopeKey = JSON.stringify([ accountId, urlFrom, @@ -67,147 +67,146 @@ const useAccountOperationsPageVm = ( appliedCategories, ]); const [pagination, setPagination] = useState<{ - scopeKey: string; cursors: string[]; - }>({ scopeKey, cursors: [] }); + scopeKey: string; + }>({ cursors: [], scopeKey }); const cursors = pagination.scopeKey === scopeKey ? pagination.cursors : []; const cursor = cursors.at(-1); if (pagination.scopeKey !== scopeKey) { - setPagination({ scopeKey, cursors: [] }); + setPagination({ cursors: [], scopeKey }); } useEffect(() => { - if (validUrlPeriod) return; - const normalized = new URLSearchParams(searchParams); + if (isValidUrlPeriod) return; + const normalized = new URLSearchParams(searchParameters); normalized.delete("from"); normalized.delete("to"); - setSearchParams(normalized, { replace: true }); - }, [validUrlPeriod, searchParams, setSearchParams]); - const [previousSearch, setPreviousSearch] = useState(searchParams); - if (previousSearch !== searchParams) { - setPreviousSearch(searchParams); + setSearchParameters(normalized, { replace: true }); + }, [isValidUrlPeriod, searchParameters, setSearchParameters]); + const [previousSearch, setPreviousSearch] = useState(searchParameters); + if (previousSearch !== searchParameters) { + setPreviousSearch(searchParameters); setDraftFrom(appliedFrom); setDraftTo(appliedTo); setDraftCategories( - operationCategories.filter((category) => - (searchParams.get("categories") ?? "").split(",").includes(category), + operationCategories.filter(category => + (searchParameters.get("categories") ?? "").split(",").includes(category), ), ); } const firstPageQuery = { from: dateStart(appliedFrom), to: dateEnd(appliedTo), - ...(appliedCategories.length ? { categories: appliedCategories } : {}), + ...((appliedCategories.length > 0) && { categories: appliedCategories }), limit: 100, }; const operations = useBrokerageOperations(accountId, { ...firstPageQuery, - ...(cursor ? { cursor } : {}), + ...(cursor && { cursor }), }); const sync = useScreenSync( - { screen: "operations", accountId, ...firstPageQuery }, + { accountId, screen: "operations", ...firstPageQuery }, [operations], ); + if (operations.isLoading) return { state: "loading" }; const resetQueryKey = brokerageOperationsQueryOptions(accountId, { limit: 100, }).queryKey; - const resetCursors = () => setPagination({ scopeKey, cursors: [] }); + const resetCursors = () => setPagination({ cursors: [], scopeKey }); const retry = () => { if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") { void queryClient.invalidateQueries({ + exact: true, queryKey: brokerageOperationsQueryOptions(accountId, firstPageQuery) .queryKey, - exact: true, }); resetCursors(); - } else void sync.retry(); + } + else void sync.retry(); }; const onReset = () => { setDraftFrom(null); setDraftTo(null); setDraftCategories([]); - setSearchParams({}); + setSearchParameters({}); resetCursors(); void queryClient.invalidateQueries({ queryKey: resetQueryKey }); }; - if (operations.isLoading) return { state: "loading" }; if ( - (operations.isError || operations.meta?.sync.state === "failed") && - (!operations.data || - apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") + (operations.isError || operations.meta?.sync.state === "failed") + && (!operations.data + || apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") ) - return { state: "error", retry, onReset }; + return { onReset, retry, state: "error" }; if (!operations.data) return { state: "loading" }; const nextCursor = operations.data.nextCursor; - const isDirty = - draftFrom !== appliedFrom || - draftTo !== appliedTo || - !sameCategories(draftCategories, appliedCategories); + const isDirty + = draftFrom !== appliedFrom + || draftTo !== appliedTo + || !isSameCategories(draftCategories, appliedCategories); return { - state: "loaded", - sourceError: sync.sourceError, - lastSuccessfulAt: operations.meta?.lastSuccessfulAt, - partial: operations.meta?.availability !== "complete", - historyLoading: - operations.meta?.coverage?.historyState === "loading" || - operations.meta?.coverage?.historyState === "partial", - refreshing: operations.isFetching || sync.refreshing, - refreshError: operations.isError || sync.sourceError || sync.transportError, - retry, + canApply: isDirty && isValidPeriod(draftFrom, draftTo), + draftCategories, draftFrom, draftTo, - draftCategories, + hasNext: Boolean(nextCursor && operations.data.hasNext), + hasPrevious: cursors.length > 0, + historyLoading: + operations.meta?.coverage?.historyState === "loading" + || operations.meta?.coverage?.historyState === "partial", items: operations.data.items, - hasNext: Boolean(operations.data.hasNext && nextCursor), - hasPrevious: Boolean(cursors.length), - canApply: isDirty && isValidPeriod(draftFrom, draftTo), - onDraftFromChange: setDraftFrom, - onDraftToChange: setDraftTo, - onDraftCategoriesChange: setDraftCategories, + lastSuccessfulAt: operations.meta?.lastSuccessfulAt, onApply: () => { if (!isValidPeriod(draftFrom, draftTo)) return; - setSearchParams({ - ...(draftFrom ? { from: draftFrom } : {}), - ...(draftTo ? { to: draftTo } : {}), - ...(draftCategories.length - ? { categories: draftCategories.join(",") } - : {}), + setSearchParameters({ + ...(draftFrom && { from: draftFrom }), + ...(draftTo && { to: draftTo }), + ...((draftCategories.length > 0) && { categories: draftCategories.join(",") }), }); resetCursors(); }, - onReset, + onDraftCategoriesChange: setDraftCategories, + onDraftFromChange: setDraftFrom, + onDraftToChange: setDraftTo, onNext: () => { if (nextCursor) - setPagination({ scopeKey, cursors: [...cursors, nextCursor] }); + setPagination({ cursors: [...cursors, nextCursor], scopeKey }); }, onPrevious: () => - setPagination({ scopeKey, cursors: cursors.slice(0, -1) }), + setPagination({ cursors: cursors.slice(0, -1), scopeKey }), + onReset, + partial: operations.meta?.availability !== "complete", + refreshError: operations.isError || sync.sourceError || sync.transportError, + refreshing: operations.isFetching || sync.refreshing, + retry, + sourceError: sync.sourceError, + state: "loaded", }; }; -const calendarDate = z.string().date(); -function isValidPeriod(from: string | null, to: string | null) { - return ( - [from, to].every( - (date) => date === null || calendarDate.safeParse(date).success, - ) && - (!from || !to || from <= to) - ); -} - -function dateStart(value: string | null) { - return value ? `${value}T00:00:00.000Z` : undefined; -} - -function dateEnd(value: string | null) { +const calendarDate = z.iso.date(); +function dateEnd(value: null | string) { return value ? `${value}T23:59:59.999Z` : undefined; } -function sameCategories(a: OperationCategory[], b: OperationCategory[]) { - return a.length === b.length && a.every((value) => b.includes(value)); +function dateStart(value: null | string) { + return value ? `${value}T00:00:00.000Z` : undefined; +} + +function isSameCategories(a: OperationCategory[], b: OperationCategory[]) { + return a.length === b.length && a.every(value => b.includes(value)); +} + +function isValidPeriod(from: null | string, to: null | string) { + return ( + [from, to].every( + date => date === null || calendarDate.safeParse(date).success, + ) + && (!from || !to || from <= to) + ); } export { type AccountOperationsPageViewModel, useAccountOperationsPageVm }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx index aa5aa2c..3a8e4cb 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageError.view.tsx @@ -1,7 +1,9 @@ +import type { FC } from "react"; + import { Button, Group } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; + import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; type AccountOperationsPageErrorProps = Extract< @@ -17,17 +19,17 @@ const AccountOperationsPageError: FC = ( return ( - - } + )} + error + text={t("ledger.errorText")} + title={t("ledger.errorTitle")} /> ); }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx index 077f6ac..84d7fa1 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoaded.view.tsx @@ -1,3 +1,5 @@ +import type { FC, ReactNode } from "react"; + import { Alert, Button, @@ -10,14 +12,15 @@ import { } from "@mantine/core"; import { DatePickerInput } from "@mantine/dates"; import { StateView } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; + import { LedgerEntryRow, - type OperationCategory, operationCategories, + type OperationCategory, } from "@/entities/broker-operation"; import { formatSnapshotTime } from "@/shared/lib"; + import type { AccountOperationsPageViewModel } from "../AccountOperationsPage.vm"; type AccountOperationsPageLoadedProps = Extract< @@ -54,99 +57,106 @@ const AccountOperationsPageLoaded: FC = ( } = props; const { t } = useTranslation(); - const categoryData = operationCategories.map((category) => ({ - value: category, + const categoryData = operationCategories.map(category => ({ label: t(`ledger.category.${category}`), + value: category, })); + // eslint-disable-next-line sonarjs/function-return-type -- ReactNode deliberately includes lists, elements and empty output. + function renderOperations(): ReactNode { + if (items.length > 0) { + return items.map(operation => ( + + )); + } + if (props.partial) { + return; + } + return ( + + ); + } return ( - {props.lastSuccessfulAt ? ( - + {!!(props.lastSuccessfulAt) && (( + {t("investment.sourceTime", { date: formatSnapshotTime(props.lastSuccessfulAt), })} - ) : null} + ))} {t("ledger.title")} - - {props.partial ? ( + {!!(props.partial) && (( {t("investment.partial")} - ) : null} - {props.historyLoading ? {t("investment.history")} : null} - {props.refreshing ? ( - - ) : null} - {props.refreshError ? ( - + ))} + {!!(props.historyLoading) && ({t("investment.history")})} + {!!(props.refreshing) && (( + + ))} + {!!(props.refreshError) && (( + {t( props.sourceError ? "investment.syncFailed" : "accounts.refreshError", )} - - ) : null} + ))} { onDraftFromChange(from); onDraftToChange(to); }} - valueFormat="DD.MM.YYYY" popoverProps={{ - returnFocus: true, middlewares: { shift: { crossAxis: true } }, + returnFocus: true, }} + type="range" + value={[draftFrom, draftTo]} + valueFormat="DD.MM.YYYY" /> - onDraftCategoriesChange(fromMultiSelectValue(value)) - } clearable clearButtonProps={{ "aria-label": t("ledger.clearPeriod") }} + data={categoryData} + label={t("ledger.categories")} + onChange={value => + onDraftCategoriesChange(fromMultiSelectValue(value))} + placeholder={t("ledger.categoriesPlaceholder")} + value={draftCategories} /> - - - {items.length ? ( - items.map((operation) => ( - - )) - ) : !props.partial ? ( - - ) : null} - {hasNext || hasPrevious ? ( + {renderOperations()} + {!!(hasNext || hasPrevious) && (( - ) : null} + ))} ); }; diff --git a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoading.view.tsx b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoading.view.tsx index 63b8310..fbbcc98 100644 --- a/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoading.view.tsx +++ b/src/pages/account-operations/ui/AccountOperationsPage/components/AccountOperationsPageLoading.view.tsx @@ -1,6 +1,7 @@ +import type { FC } from "react"; + import { Group, Skeleton, Stack } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; const skeletonRowIds = ["first", "second", "third", "fourth"]; @@ -17,12 +18,12 @@ const AccountOperationsPageLoading: FC = () => { - - + + - {skeletonRowIds.map((id) => ( - + {skeletonRowIds.map(id => ( + ))} diff --git a/src/pages/account/ui/AccountPage/AccountPage.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.test.tsx index c9b718c..ab19dbf 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.test.tsx @@ -7,26 +7,53 @@ import { waitFor, within, } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { describe, expect, it, vi } from "vitest"; + import type { BrokerageEvent } from "@/entities/brokerage-event"; + import { type Portfolio, portfolioQueryOptions } from "@/entities/portfolio"; import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; -import { AccountPage } from "./AccountPage"; + import type { AccountPageRecentEvents, AccountPageRecentOperations, } from "./AccountPage.vm"; + +import { AccountPage } from "./AccountPage"; import { toAccountPagePositionSections } from "./AccountPage.vm"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageError } from "./components/AccountPageError.view"; import { AccountPageLoaded } from "./components/AccountPageLoaded.view"; import { AccountPageLoading } from "./components/AccountPageLoading/AccountPageLoading.view"; +function configureTestAuth() { + configureAuthClient({ + accessToken: () => "test-token", + clear: vi.fn(), + refresh: () => Promise.resolve(true), + }); +} + +function renderAccountRoute(queryClient: QueryClient) { + configureTestAuth(); + return render( + + + + + } path="/accounts/:accountId" /> + + + + , + ); +} + function renderPage(ui: React.ReactNode) { const queryClient = createTestQueryClient(); return render( @@ -38,62 +65,41 @@ function renderPage(ui: React.ReactNode) { ); } -function configureTestAuth() { - configureAuthClient({ - accessToken: () => "test-token", - refresh: async () => true, - clear: vi.fn(), - }); -} - -function renderAccountRoute(queryClient: QueryClient) { - configureTestAuth(); - return render( - - - - - } /> - - - - , - ); -} - -const portfolio: Portfolio = { - lastSuccessfulAt: "2026-08-13T10:00:00Z", - isStale: true, - totalsByCurrency: [{ currency: "RUB", value: "1234.5" }], - positions: [ - { - figi: "FIGI-2", - displayName: "Вторая", - type: "STOCK", - quantity: "2", - currentValue: null, - averagePurchasePrice: null, - absoluteReturn: null, - isin: null, - ticker: null, - logoName: "vtoraya.png", - }, - { - figi: "FIGI-1", - displayName: "Первая", - type: "BOND", - quantity: "1", - currentValue: { currency: "RUB", value: "10" }, - averagePurchasePrice: { currency: "RUB", value: "11" }, - absoluteReturn: { currency: "RUB", value: "-1" }, - isin: "RU000A0JX0J2", - ticker: null, - logoName: null, - }, - ], - structure: { currencies: [], unvaluedCategories: [] }, - cashBalances: [{ currency: "USD", value: "0.5" }], -}; +const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + cashBalances: [{ currency: "USD", value: "0.5" }], + isStale: true, + lastSuccessfulAt: "2026-08-13T10:00:00Z", + positions: [ + { + absoluteReturn: null, + averagePurchasePrice: null, + currentValue: null, + displayName: "Вторая", + figi: "FIGI-2", + isin: null, + logoName: "vtoraya.png", + quantity: "2", + ticker: null, + type: "STOCK", + }, + { + absoluteReturn: { currency: "RUB", value: "-1" }, + averagePurchasePrice: { currency: "RUB", value: "11" }, + currentValue: { currency: "RUB", value: "10" }, + displayName: "Первая", + figi: "FIGI-1", + isin: "RU000A0JX0J2", + logoName: null, + quantity: "1", + ticker: null, + type: "BOND", + }, + ], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "RUB", value: "1234.5" }], + } +/* eslint-enable unicorn/no-null */); const emptyRecentOperations: AccountPageRecentOperations = { state: "empty", @@ -105,44 +111,46 @@ const emptyRecentEvents: AccountPageRecentEvents = { to: "/accounts/account-1/events", }; +function emptyProps() { + return { + recentEvents: emptyRecentEvents, + recentOperations: emptyRecentOperations, + }; +} + function loadedProps(nextPortfolio: Portfolio = portfolio) { return { portfolio: nextPortfolio, positionSections: toAccountPagePositionSections(nextPortfolio), - recentOperations: emptyRecentOperations, recentEvents: emptyRecentEvents, - }; -} - -function emptyProps() { - return { recentOperations: emptyRecentOperations, - recentEvents: emptyRecentEvents, }; } describe("AccountPage states", () => { it.each( - ["account-1", "all"].flatMap((accountId) => - ["STOCK", "BOND"].flatMap((type) => - [true, false].map((hasMetadata) => ({ accountId, type, hasMetadata })), + ["account-1", "all"].flatMap(accountId => + ["STOCK", "BOND"].flatMap(type => + [true, false].map(hasMetadata => ({ accountId, hasMetadata, type })), ), ), )( "keeps portfolio metadata authoritative and links the exact issue in $accountId for $type (metadata: $hasMetadata)", - async ({ accountId, type, hasMetadata }) => { + async ({ accountId, hasMetadata, type }) => { const collection = type === "STOCK" ? "stocks" : "bonds"; const slug = type === "STOCK" ? "SBERP" : "SU26238RMFS4"; const uid = "c190ff1f-1447-4227-b543-316332699ca5"; const requests = vi.fn(); - const position = { - ...portfolio.positions[0], - type, - instrumentUid: uid, - ticker: hasMetadata ? slug : null, - displayName: hasMetadata ? "Сбербанк привилегированные" : "FIGI-2", - logoName: hasMetadata ? "vtoraya.png" : null, - }; + const position = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...portfolio.positions[0], + displayName: hasMetadata ? "Сбербанк привилегированные" : "FIGI-2", + instrumentUid: uid, + logoName: hasMetadata ? "vtoraya.png" : null, + ticker: hasMetadata ? slug : null, + type, + } + /* eslint-enable unicorn/no-null */); server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => HttpResponse.json( @@ -150,7 +158,7 @@ describe("AccountPage states", () => { ...portfolio, positions: accountId === "all" - ? ["account-1", "account-2"].map((id) => ({ + ? ["account-1", "account-2"].map(id => ({ ...position, account: { id, name: id }, })) @@ -163,11 +171,11 @@ describe("AccountPage states", () => { return HttpResponse.json( resource({ instrumentUid: uid, - slug, - name: "Название из карточки инструмента", - ticker: "OTHER-TICKER", isin: "OTHER-ISIN", logoName: "other.png", + name: "Название из карточки инструмента", + slug, + ticker: "OTHER-TICKER", }), ); }), @@ -194,10 +202,12 @@ describe("AccountPage states", () => { ).not.toBeInTheDocument(); const logo = screen.queryByRole("img", { name: position.displayName }); if (hasMetadata) + // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(logo).toHaveAttribute( "src", "https://invest-brands.cdn-tinkoff.ru/vtorayax160.png", ); + // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. else expect(logo).not.toBeInTheDocument(); }, ); @@ -250,15 +260,16 @@ describe("AccountPage states", () => { screen.getByRole("region", { name: "Структура портфеля" }), ).toHaveTextContent("Данные о структуре портфеля пока недоступны."); expect( - screen.getAllByRole("definition").map((node) => node.textContent), + screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽", "0,5 $"]); }); 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(); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. expect(container.querySelector("[tabindex], button, a, input")).toBeNull(); - expect(container).not.toHaveTextContent(/₽|\$|€|0,00/); + expect(container).not.toHaveTextContent(/[₽$€]|0,00/); }); it.each(["NOT_FOUND", "TBANK_NOT_CONNECTED", "INVALID_TBANK_TOKEN"])( @@ -277,7 +288,7 @@ describe("AccountPage states", () => { it.each(["TBANK_UNAVAILABLE", "UNKNOWN"])( "allows retry for %s", - async (code) => { + (code) => { const retry = vi.fn(); renderPage(); fireEvent.click(screen.getByRole("button", { name: "Повторить" })); @@ -290,22 +301,22 @@ describe("AccountPage states", () => { , ); @@ -313,10 +324,10 @@ describe("AccountPage states", () => { screen.getByRole("region", { name: "Денежные остатки" }), ); expect( - balances.getAllByRole("definition").map((node) => node.textContent), + balances.getAllByRole("definition").map(node => node.textContent), ).toEqual(["234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]); expect( - screen.getAllByRole("definition").map((node) => node.textContent), + screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽", "234,500000000000000001 ₽", "-0,5 $", "3,00 XYZ"]); expect(screen.queryByText("Торгуемая валюта")).not.toBeInTheDocument(); expect( @@ -332,14 +343,14 @@ describe("AccountPage states", () => { HttpResponse.json( resource({ ...portfolio, - totalsByCurrency: [], positions: hasPositions ? portfolio.positions : [], + totalsByCurrency: [], }), ), ), ); renderAccountRoute(createTestQueryClient()); - await screen.findByRole("heading", { name: "Портфель", level: 1 }); + await screen.findByRole("heading", { level: 1, name: "Портфель" }); expect(screen.getByRole("definition")).toHaveTextContent("0,5 $"); expect(screen.queryByText("Портфель пока пуст")).not.toBeInTheDocument(); }, @@ -353,17 +364,17 @@ describe("AccountPage states", () => { async ({ totalsByCurrency }) => { const snapshot: Portfolio = { ...portfolio, - totalsByCurrency, + cashBalances: [], positions: [ { ...portfolio.positions[0], + currentValue: { currency: "RUB", value: "100" }, displayName: "Торгуемая валюта", type: "CURRENCY", - currentValue: { currency: "RUB", value: "100" }, }, ], structure: { currencies: [], unvaluedCategories: [] }, - cashBalances: [], + totalsByCurrency, }; server.use( http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => @@ -373,7 +384,7 @@ describe("AccountPage states", () => { const queryClient = createTestQueryClient(); renderAccountRoute(queryClient); - await screen.findByRole("heading", { name: "Портфель", level: 1 }); + await screen.findByRole("heading", { level: 1, name: "Портфель" }); expect( screen.queryByRole("button", { name: "Другие позиции" }), ).not.toBeInTheDocument(); @@ -385,8 +396,8 @@ describe("AccountPage states", () => { ).queryByRole("definition"), ).not.toBeInTheDocument(); expect( - screen.queryAllByRole("definition").map((node) => node.textContent), - ).toEqual(totalsByCurrency.length ? ["100 ₽"] : []); + screen.queryAllByRole("definition").map(node => node.textContent), + ).toEqual(totalsByCurrency.length > 0 ? ["100 ₽"] : []); expect( queryClient.getQueryData(portfolioQueryOptions("account-1").queryKey), ).toEqual(resource(snapshot)); @@ -417,7 +428,7 @@ describe("AccountPage states", () => { ).toHaveAttribute("href", "/settings"); renderPage(); - screen.getByRole("button", { name: "Повторить" }).click(); + fireEvent.click(screen.getByRole("button", { name: "Повторить" })); expect(retry).toHaveBeenCalledOnce(); }); @@ -438,7 +449,7 @@ describe("AccountPage states", () => { ).toBeInTheDocument(); const positions = screen .getAllByText(/Вторая|Первая/) - .map((element) => element.textContent); + .map(element => element.textContent); expect(positions).toEqual(["Вторая", "Первая"]); expect(screen.getByText("10 ₽")).toBeInTheDocument(); expect(screen.getByText("-1 ₽")).toBeInTheDocument(); @@ -459,35 +470,37 @@ describe("AccountPage states", () => { it("renders stock cards with logo fallback and signed unlabeled return", () => { renderPage( , ); expect(screen.getByText("А")).toBeInTheDocument(); @@ -500,23 +513,25 @@ describe("AccountPage states", () => { it("groups stock supporting metrics into quiet single lines", () => { renderPage( , ); @@ -531,47 +546,49 @@ describe("AccountPage states", () => { it("uses the ticker only for stock cards and otherwise keeps the ISIN", () => { renderPage( , ); @@ -587,35 +604,37 @@ describe("AccountPage states", () => { it("renders bond cards with logo, fallback, and signed unlabeled return like stocks", () => { renderPage( , ); expect(screen.getByRole("img", { name: "Долг" })).toHaveAttribute( @@ -634,23 +653,25 @@ describe("AccountPage states", () => { it("renders bond cards in expanded layout with bond-specific classes", () => { renderPage( , ); expect(screen.getByText("О")).toBeInTheDocument(); @@ -663,23 +684,25 @@ describe("AccountPage states", () => { it("keeps other-position cards compact with labeled return and no logo", () => { renderPage( , ); expect(screen.getByText("Доход: -5 ₽")).toBeInTheDocument(); @@ -690,13 +713,15 @@ describe("AccountPage states", () => { renderPage( , ); expect( @@ -725,29 +750,31 @@ describe("AccountPage states", () => { renderPage( , ); @@ -756,48 +783,52 @@ describe("AccountPage states", () => { }); it("keeps future events ordered with calendar dates and projected rather than posted amounts", () => { - const event: BrokerageEvent = { - type: "coupon", - date: "2026-09-10", - dateKind: "event", - figi: null, - instrumentUid: null, - ticker: "DEMO", - displayName: "Облигация", - logoName: null, - isin: "RU000A0JX0J2", - projectedAmount: { currency: "RUB", value: "850.50" }, - }; + const event: BrokerageEvent = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + date: "2026-09-10", + dateKind: "event", + displayName: "Облигация", + figi: null, + instrumentUid: null, + isin: "RU000A0JX0J2", + logoName: null, + projectedAmount: { currency: "RUB", value: "850.50" }, + ticker: "DEMO", + type: "coupon", + } + /* eslint-enable unicorn/no-null */); renderPage( , ); @@ -811,8 +842,8 @@ describe("AccountPage states", () => { "Оферта · Облигация", ]; for (const title of titles) expect(content.getByText(title)).toBeVisible(); - expect(titles.map((title) => text.indexOf(title))).toEqual( - titles.map((title) => text.indexOf(title)).sort((a, b) => a - b), + expect(titles.map(title => text.indexOf(title))).toEqual( + titles.map(title => text.indexOf(title)).toSorted((a, b) => a - b), ); expect(content.getByText("10.09.2026 · RU000A0JX0J2")).toBeVisible(); expect(content.getAllByText(/RU000A0JX0J2$/)).toHaveLength(4); @@ -846,9 +877,13 @@ describe("AccountPage states", () => { const skeleton = content.getByRole("status", { name: "Загрузка будущих событий", }); + // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(skeleton).toHaveAttribute("aria-busy", "true"); + // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(within(skeleton).queryByRole("link")).not.toBeInTheDocument(); - } else { + } + else { + // eslint-disable-next-line vitest/no-conditional-expect -- The table case deterministically selects the empty state. expect( content.getByText("На ближайшие 30 дней событий не найдено."), ).toBeVisible(); @@ -879,20 +914,22 @@ describe("AccountPage states", () => { it.each([503])( "preserves totals and UTC snapshot through refresh error %i, retry and success", async (status) => { - const initialPortfolio: Portfolio = { - ...portfolio, - structure: { - unvaluedCategories: [{ type: "STOCK", count: 1 }], - currencies: [ - { - currency: "RUB", - totalValue: "10", - hasNegativeValues: false, - categories: [{ type: "BOND", value: "10", sharePercent: null }], - }, - ], - }, - }; + const initialPortfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...portfolio, + structure: { + currencies: [ + { + categories: [{ sharePercent: null, type: "BOND", value: "10" }], + currency: "RUB", + hasNegativeValues: false, + totalValue: "10", + }, + ], + unvaluedCategories: [{ count: 1, type: "STOCK" }], + }, + } + /* eslint-enable unicorn/no-null */); let requestCount = 0; let resolveRetry: ((response: Response) => void) | undefined; server.use( @@ -900,25 +937,27 @@ describe("AccountPage states", () => { HttpResponse.json( { id: "overview", - scope: { screen: "account", accountId: "account-1" }, now: "2026-09-25T00:00:00Z", + scope: { accountId: "account-1", screen: "account" }, }, { status: 202 }, ), ), http.get("*/api/v1/tbank-sync/:id", () => - HttpResponse.json({ - id: "overview", - scope: { screen: "account", accountId: "account-1" }, - now: "2026-09-25T00:00:00Z", - state: "succeeded", - errorCode: null, - progress: { total: 1, completed: 1, discovering: false }, - resources: [ - resource(null, { resourceKey: "portfolio", stateVersion: "2" }) - .meta, - ], - }), + HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + errorCode: null, + id: "overview", + now: "2026-09-25T00:00:00Z", + progress: { completed: 1, discovering: false, total: 1 }, + resources: [ + resource(null, { resourceKey: "portfolio", stateVersion: "2" }) + .meta, + ], + scope: { accountId: "account-1", screen: "account" }, + state: "succeeded", + } + /* eslint-enable unicorn/no-null */)), ), http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requestCount += 1; @@ -926,10 +965,11 @@ describe("AccountPage states", () => { return HttpResponse.json( resource(initialPortfolio, { resourceKey: "portfolio" }), ); - if (requestCount === 3) - return new Promise((resolve) => { - resolveRetry = resolve; - }); + if (requestCount === 3) { + const deferred = Promise.withResolvers(); + resolveRetry = deferred.resolve; + return deferred.promise; + } return HttpResponse.json( { code: status === 404 ? "NOT_FOUND" : "TBANK_UNAVAILABLE" }, { status }, @@ -962,8 +1002,9 @@ describe("AccountPage states", () => { ).toBeInTheDocument(); expect(screen.getByText("Вторая")).toBeInTheDocument(); expect( - screen.getAllByRole("definition").map((node) => node.textContent), + screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽", "0,5 $"]); + // eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged. expect(screen.getByText(/Данные получены/).textContent).toBe( snapshotText, ); @@ -971,8 +1012,9 @@ describe("AccountPage states", () => { await waitFor(() => expect(requestCount).toBe(3)); expect(structure).toHaveTextContent("10 ₽"); expect( - screen.getAllByRole("definition").map((node) => node.textContent), + screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽", "0,5 $"]); + // eslint-disable-next-line jest-dom/prefer-to-have-text-content -- The cached timestamp must remain exactly unchanged. expect(screen.getByText(/Данные получены/).textContent).toBe( snapshotText, ); @@ -981,11 +1023,11 @@ describe("AccountPage states", () => { resource( { ...portfolio, + cashBalances: [{ currency: "EUR", value: "2.50" }], isStale: false, lastSuccessfulAt: "2026-08-14T11:30:00Z", - totalsByCurrency: [{ currency: "USD", value: "-0.5" }], structure: { currencies: [], unvaluedCategories: [] }, - cashBalances: [{ currency: "EUR", value: "2.50" }], + totalsByCurrency: [{ currency: "USD", value: "-0.5" }], }, { resourceKey: "portfolio", stateVersion: "2" }, ), @@ -993,7 +1035,7 @@ describe("AccountPage states", () => { ); await waitFor(() => expect( - screen.getAllByRole("definition").map((node) => node.textContent), + screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["-0,5 $", "2,50 €"]), ); expect(screen.getByText(/Данные получены/)).toHaveTextContent("11:30"); @@ -1015,9 +1057,11 @@ describe("AccountPage states", () => { http.get("*/api/v1/brokerage-accounts/:accountId/portfolio", () => { requestCount += 1; if (requestCount === 1) return HttpResponse.json(resource(portfolio)); - return new Promise((resolve) => { - resolveRefresh = resolve; - }); + { + const deferred = Promise.withResolvers(); + resolveRefresh = deferred.resolve; + return deferred.promise; + } }), ); const queryClient = createTestQueryClient(); @@ -1031,7 +1075,7 @@ describe("AccountPage states", () => { await waitFor(() => expect(requestCount).toBe(2)); expect(screen.getByText("Вторая")).toBeInTheDocument(); expect( - screen.getAllByRole("definition").map((node) => node.textContent), + screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1 234,5 ₽", "0,5 $"]); expect(screen.getByText(/Данные получены/)).toHaveTextContent("10:00"); expect( diff --git a/src/pages/account/ui/AccountPage/AccountPage.tsx b/src/pages/account/ui/AccountPage/AccountPage.tsx index 0b3e294..6a9f1b4 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.tsx @@ -1,4 +1,5 @@ import type { FC } from "react"; + import { useAccountPageViewModel } from "./AccountPage.vm"; import { AccountPageEmpty } from "./components/AccountPageEmpty.view"; import { AccountPageError } from "./components/AccountPageError.view"; @@ -16,29 +17,29 @@ const AccountPage: FC = (props) => { ); return ( ); }; diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx index 31aaf08..b3eff24 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.test.tsx @@ -1,81 +1,87 @@ +import type { FC, PropsWithChildren } from "react"; + import { MantineProvider } from "@mantine/core"; import { act, renderHook, waitFor } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; -import type { FC, PropsWithChildren } from "react"; +import { http, HttpResponse } from "msw"; import { MemoryRouter, Route, Routes } from "react-router"; import { describe, expect, it, vi } from "vitest"; + import type { Portfolio } from "@/entities/portfolio"; + import { configureAuthClient } from "@/shared/api"; import { createQueryWrapper } from "@/test/query"; import { resource } from "@/test/resource"; import { server } from "@/test/server"; + import { toAccountPagePositionSections, useAccountPageViewModel, } from "./AccountPage.vm"; -const portfolio: Portfolio = { - lastSuccessfulAt: "2026-08-13T10:00:00Z", - isStale: true, - totalsByCurrency: [{ currency: "RUB", value: "150" }], - positions: [ - { - figi: "FIGI-STOCK", - displayName: "Акция", - type: "STOCK", - quantity: "2", - currentValue: { currency: "RUB", value: "100" }, - averagePurchasePrice: null, - absoluteReturn: { currency: "RUB", value: "10" }, - isin: null, - ticker: null, - logoName: "akciya.png", - }, - { - figi: "FIGI-BOND", - displayName: "Облигация", - type: "BOND", - quantity: "1", - currentValue: { currency: "RUB", value: "50" }, - averagePurchasePrice: { currency: "RUB", value: "45" }, - absoluteReturn: { currency: "RUB", value: "-5" }, - isin: "RU000A0JX0J2", - ticker: null, - logoName: null, - }, - { - figi: "FIGI-FUND", - displayName: "Фонд", - type: "FUND", - quantity: "3", - currentValue: null, - averagePurchasePrice: null, - absoluteReturn: null, - isin: null, - ticker: null, - logoName: "fond.png", - }, - ], - structure: { currencies: [], unvaluedCategories: [] }, - cashBalances: [{ currency: "USD", value: "1" }], -}; +const portfolio: Portfolio = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + cashBalances: [{ currency: "USD", value: "1" }], + isStale: true, + lastSuccessfulAt: "2026-08-13T10:00:00Z", + positions: [ + { + absoluteReturn: { currency: "RUB", value: "10" }, + averagePurchasePrice: null, + currentValue: { currency: "RUB", value: "100" }, + displayName: "Акция", + figi: "FIGI-STOCK", + isin: null, + logoName: "akciya.png", + quantity: "2", + ticker: null, + type: "STOCK", + }, + { + absoluteReturn: { currency: "RUB", value: "-5" }, + averagePurchasePrice: { currency: "RUB", value: "45" }, + currentValue: { currency: "RUB", value: "50" }, + displayName: "Облигация", + figi: "FIGI-BOND", + isin: "RU000A0JX0J2", + logoName: null, + quantity: "1", + ticker: null, + type: "BOND", + }, + { + absoluteReturn: null, + averagePurchasePrice: null, + currentValue: null, + displayName: "Фонд", + figi: "FIGI-FUND", + isin: null, + logoName: "fond.png", + quantity: "3", + ticker: null, + type: "FUND", + }, + ], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "RUB", value: "150" }], + } +/* eslint-enable unicorn/no-null */); function configureTestAuth() { configureAuthClient({ accessToken: () => "test-token", - refresh: async () => true, clear: vi.fn(), + refresh: () => Promise.resolve(true), }); } function createWrapper() { const { wrapper: QueryWrapper } = createQueryWrapper(); - const Wrapper: FC = (props) => ( + const Wrapper: FC = props => ( - + @@ -91,30 +97,32 @@ function mockLoadedAccountPage() { ), http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( - resource({ - accountId: "account-1", - items: [ - { - id: "operation-1", - occurredAt: "2026-08-13T10:00:00Z", - type: "Buy", - category: "trade", - description: "Покупка акции", - figi: "FIGI-STOCK", - instrumentUid: null, - ticker: "STK", - instrumentType: "share", - payment: { currency: "RUB", value: "-100" }, - quantity: "1", - price: null, - accruedInterest: null, - commission: null, - }, - ], - nextCursor: null, - hasNext: false, - observedAt: "2026-08-13T10:00:00Z", - }), + resource((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + accountId: "account-1", + hasNext: false, + items: [ + { + accruedInterest: null, + category: "trade", + commission: null, + description: "Покупка акции", + figi: "FIGI-STOCK", + id: "operation-1", + instrumentType: "share", + instrumentUid: null, + occurredAt: "2026-08-13T10:00:00Z", + payment: { currency: "RUB", value: "-100" }, + price: null, + quantity: "1", + ticker: "STK", + type: "Buy", + }, + ], + nextCursor: null, + observedAt: "2026-08-13T10:00:00Z", + } + /* eslint-enable unicorn/no-null */)), ), ), http.get("*/api/v1/brokerage-accounts/:accountId/events", () => @@ -122,16 +130,18 @@ function mockLoadedAccountPage() { resource({ accountId: "account-1", calculatedAt: "2026-08-13T10:00:00Z", - events: Array.from({ length: 6 }, (_, index) => ({ - type: "coupon", - date: `2026-09-${String(index + 1).padStart(2, "0")}`, - dateKind: "event", - figi: `FIGI-EVENT-${index}`, - instrumentUid: null, - ticker: `BND${index}`, - displayName: `Купон ${index}`, - projectedAmount: { currency: "RUB", value: String(index + 1) }, - })), + events: Array.from({ length: 6 }, (_, index) => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + date: `2026-09-${String(index + 1).padStart(2, "0")}`, + dateKind: "event", + displayName: `Купон ${index}`, + figi: `FIGI-EVENT-${index}`, + instrumentUid: null, + projectedAmount: { currency: "RUB", value: String(index + 1) }, + ticker: `BND${index}`, + type: "coupon", + } + /* eslint-enable unicorn/no-null */))), }), ), ), @@ -150,38 +160,40 @@ describe("useAccountPageViewModel", () => { ] as const; const snapshot: Portfolio = { ...portfolio, - totalsByCurrency: [{ currency: "RUB", value: "1250" }], - structure: { currencies: [], unvaluedCategories: [] }, cashBalances: [{ currency: "RUB", value: "1000" }], positions: [ ...portfolio.positions, { ...portfolio.positions[0], + currentValue: { currency: "RUB", value: "50" }, figi: "FIGI-CURRENCY", type: "CURRENCY", - currentValue: { currency: "RUB", value: "50" }, }, - ...otherTypes.map((type) => ({ - ...portfolio.positions[2], - figi: `FIGI-${type}-NEXT`, - type, - currentValue: + ...otherTypes.map(type => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...portfolio.positions[2], + currentValue: type === "OPTION" ? { currency: "RUB", value: "50" } : null, - })), + figi: `FIGI-${type}-NEXT`, + type, + } + /* eslint-enable unicorn/no-null */))), ], + structure: { currencies: [], unvaluedCategories: [] }, + totalsByCurrency: [{ currency: "RUB", value: "1250" }], }; const original = structuredClone(snapshot); const sections = toAccountPagePositionSections(snapshot); - expect(sections.map((section) => section.kind)).toEqual([ + expect(sections.map(section => section.kind)).toEqual([ "stocks", "bonds", "other", ]); - expect(sections[2].groups.map((group) => group.positions[0].figi)).toEqual([ + expect(sections[2].groups.map(group => group.positions[0].figi)).toEqual([ "FIGI-FUND", - ...otherTypes.map((type) => `FIGI-${type}-NEXT`), + ...otherTypes.map(type => `FIGI-${type}-NEXT`), ]); expect(sections[0].groups[0].positions[0].shareText).toBe("40 %"); expect(sections[1].groups[0].positions[0].shareText).toBe("20 %"); @@ -203,9 +215,9 @@ describe("useAccountPageViewModel", () => { { state: "loaded" } >; expect( - loaded.positionSections.map(({ kind, groups }) => ({ + loaded.positionSections.map(({ groups, kind }) => ({ kind, - positions: groups.flatMap((group) => group.positions), + positions: groups.flatMap(group => group.positions), })), ).toMatchObject([ { @@ -213,37 +225,41 @@ describe("useAccountPageViewModel", () => { positions: [ { displayName: "Акция", - shareText: "66,7 %", + logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", returnText: "+10 ₽", returnTone: "positive", - logoSrc: "https://invest-brands.cdn-tinkoff.ru/akciyax160.png", - }, - ], - }, - { - kind: "bonds", - positions: [ - { - displayName: "Облигация", - shareText: "33,3 %", - returnText: "-5 ₽", - returnTone: "negative", - logoSrc: null, - }, - ], - }, - { - kind: "other", - positions: [ - { - displayName: "Фонд", - shareText: null, - returnText: null, - returnTone: undefined, - logoSrc: null, + shareText: "66,7 %", }, ], }, + (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + kind: "bonds", + positions: [ + { + displayName: "Облигация", + logoSrc: null, + returnText: "-5 ₽", + returnTone: "negative", + shareText: "33,3 %", + }, + ], + } + /* eslint-enable unicorn/no-null */), + (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + kind: "other", + positions: [ + { + displayName: "Фонд", + logoSrc: null, + returnText: null, + returnTone: undefined, + shareText: null, + }, + ], + } + /* eslint-enable unicorn/no-null */), ]); }); @@ -265,13 +281,11 @@ describe("useAccountPageViewModel", () => { { state: "loaded" } >; expect(loaded.recentOperations).toMatchObject({ + items: [{ description: "Покупка акции", id: "operation-1" }], state: "loaded", to: "/accounts/account-1/operations", - items: [{ id: "operation-1", description: "Покупка акции" }], }); expect(loaded.recentEvents).toMatchObject({ - state: "loaded", - to: "/accounts/account-1/events", items: [ { ticker: "BND0" }, { ticker: "BND1" }, @@ -279,6 +293,8 @@ describe("useAccountPageViewModel", () => { { ticker: "BND3" }, { ticker: "BND4" }, ], + state: "loaded", + to: "/accounts/account-1/events", }); }); @@ -302,9 +318,9 @@ describe("useAccountPageViewModel", () => { await waitFor(() => expect(result.current).toMatchObject({ - state: "loaded", - recentOperations: { state: "error" }, recentEvents: { state: "error" }, + recentOperations: { state: "error" }, + state: "loaded", }), ); }); @@ -320,7 +336,9 @@ it("keeps overview sections independent when their source data is incomplete", a http.get("*/api/v1/brokerage-accounts/:accountId/operations", () => HttpResponse.json( resource( - { items: [], hasNext: false, nextCursor: null }, + (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { hasNext: false, items: [], nextCursor: null } + /* eslint-enable unicorn/no-null */), { availability: "partial" }, ), ), @@ -341,20 +359,22 @@ it("keeps overview sections independent when their source data is incomplete", a await waitFor(() => expect(result.current).toMatchObject({ partial: true, - recentOperations: { state: "partial", items: [] }, - recentEvents: { state: "partial", items: [] }, + recentEvents: { items: [], state: "partial" }, + recentOperations: { items: [], state: "partial" }, }), ); }); it("refreshes every overview section through one account descriptor and observational reads", async () => { configureTestAuth(); - const sections: Record = { - portfolio, - operations: { items: [], hasNext: false, nextCursor: null }, - events: { events: [], incomeTotalsByCurrency: [] }, - "income-summary": { totalsByCurrency: [] }, - }; + const sections: Record = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + "events": { events: [], incomeTotalsByCurrency: [] }, + "income-summary": { totalsByCurrency: [] }, + "operations": { hasNext: false, items: [], nextCursor: null }, + portfolio, + } + /* eslint-enable unicorn/no-null */); const observed: string[] = []; const scopes: unknown[] = []; let version = "1"; @@ -377,25 +397,27 @@ it("refreshes every overview section through one account descriptor and observat return HttpResponse.json( { id: "overview", - scope: { screen: "account", accountId: "account-1" }, now: "2026-09-25T00:00:00Z", + scope: { accountId: "account-1", screen: "account" }, }, { status: 202 }, ); }), http.get("*/api/v1/tbank-sync/:id", () => - HttpResponse.json({ - id: "overview", - scope: null, - now: "2026-09-25T00:00:00Z", - state: "succeeded", - errorCode: null, - progress: { total: 4, completed: 4, discovering: false }, - resources: Object.keys(sections).map( - (resourceKey) => - resource(null, { resourceKey, stateVersion: version }).meta, - ), - }), + HttpResponse.json((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + errorCode: null, + id: "overview", + now: "2026-09-25T00:00:00Z", + progress: { completed: 4, discovering: false, total: 4 }, + resources: Object.keys(sections).map( + resourceKey => + resource(null, { resourceKey, stateVersion: version }).meta, + ), + scope: null, + state: "succeeded", + } + /* eslint-enable unicorn/no-null */)), ), ); const { result } = renderHook(() => useAccountPageViewModel(), { @@ -403,10 +425,10 @@ it("refreshes every overview section through one account descriptor and observat }); await waitFor(() => expect(result.current).toMatchObject({ - state: "loaded", - recentOperations: { state: "empty" }, - recentEvents: { state: "empty" }, incomeSummary: { state: "empty" }, + recentEvents: { state: "empty" }, + recentOperations: { state: "empty" }, + state: "loaded", }), ); await act(async () => { @@ -415,5 +437,5 @@ it("refreshes every overview section through one account descriptor and observat await waitFor(() => expect(new Set(observed)).toEqual(new Set(Object.keys(sections))), ); - expect(scopes).toEqual([{ screen: "account", accountId: "account-1" }]); + expect(scopes).toEqual([{ accountId: "account-1", screen: "account" }]); }); diff --git a/src/pages/account/ui/AccountPage/AccountPage.vm.ts b/src/pages/account/ui/AccountPage/AccountPage.vm.ts index a03d11a..8bfa236 100644 --- a/src/pages/account/ui/AccountPage/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage/AccountPage.vm.ts @@ -1,5 +1,8 @@ import { useParams } from "react-router"; + import type { BrokerageOperation } from "@/entities/broker-operation"; +import type { Portfolio } from "@/entities/portfolio"; + import { useBrokerageOperations } from "@/entities/broker-operation"; import { type BrokerageAccount, @@ -10,7 +13,6 @@ import { useBrokerageEvents, } from "@/entities/brokerage-event"; import { type IncomeSummary, useIncomeSummary } from "@/entities/income"; -import type { Portfolio } from "@/entities/portfolio"; import { formatShare, usePortfolio } from "@/entities/portfolio"; import { useStocks } from "@/entities/stock"; import { useScreenSync } from "@/features/tbank-connection"; @@ -19,15 +21,46 @@ import { formatMoney, instrumentLogoUrl } from "@/shared/lib"; const RECENT_EVENTS_LIMIT = 5; -type PositionSectionKind = "stocks" | "bonds" | "other"; -type AccountPageReturnTone = "positive" | "negative"; +type AccountPageContent = { + account?: BrokerageAccount; + accountId?: string; + incomeSummary: AccountPageIncomeSummary; + partial?: boolean; + recentEvents: AccountPageRecentEvents; + recentOperations: AccountPageRecentOperations; + refreshError: boolean; + refreshing: boolean; + retry: () => Promise | undefined; + sourceError?: boolean; +}; +type AccountPageEmptyViewModel = AccountPageContent & { + state: "empty"; +}; + +type AccountPageIncomeSummary + = | { + lastSuccessfulAt?: null | string; + refreshError?: boolean; + stale?: boolean; + state: "loaded"; + summary: IncomeSummary; + to: string; + } + | { retry: () => Promise | undefined; state: "error"; to: string } + | { state: "empty" | "loading" | "partial"; to: string }; + +type AccountPageLoadedViewModel = AccountPageContent & { + portfolio: Portfolio; + positionSections: AccountPagePositionSection[]; + state: "loaded"; +}; type AccountPagePosition = Portfolio["positions"][number] & { - shareText: string | null; - returnText: string | null; - returnTone?: AccountPageReturnTone; - logoSrc: string | null; instrumentTo?: string; + logoSrc: null | string; + returnText: null | string; + returnTone?: AccountPageReturnTone; + shareText: null | string; }; type AccountPagePositionGroup = { @@ -36,223 +69,72 @@ type AccountPagePositionGroup = { }; type AccountPagePositionSection = { - kind: PositionSectionKind; groups: AccountPagePositionGroup[]; + kind: PositionSectionKind; }; -type AccountPageRecentOperations = - | { - state: "loading" | "error" | "empty"; - to: string; - } - | { - state: "loaded" | "partial"; - to: string; - items: BrokerageOperation[]; - lastSuccessfulAt?: string | null; - stale?: boolean; - refreshError?: boolean; - }; - type AccountPageRecentEvents = BrokerageEventsPreviewState; -type AccountPageIncomeSummary = - | { state: "loading" | "partial" | "empty"; to: string } - | { state: "error"; to: string; retry: () => undefined | Promise } - | { - state: "loaded"; - to: string; - summary: IncomeSummary; - lastSuccessfulAt?: string | null; - stale?: boolean; - refreshError?: boolean; - }; - -type AccountPageContent = { - sourceError?: boolean; - partial?: boolean; - account?: BrokerageAccount; - accountId?: string; - refreshing: boolean; - refreshError: boolean; - retry: () => undefined | Promise; - recentOperations: AccountPageRecentOperations; - recentEvents: AccountPageRecentEvents; - incomeSummary: AccountPageIncomeSummary; -}; - -type AccountPageLoadedViewModel = AccountPageContent & { - state: "loaded"; - portfolio: Portfolio; - positionSections: AccountPagePositionSection[]; -}; - -type AccountPageEmptyViewModel = AccountPageContent & { - state: "empty"; -}; - -type AccountPageViewModel = - | { state: "loading" } - | { state: "error"; code?: string; retry: () => undefined | Promise } - | AccountPageEmptyViewModel - | AccountPageLoadedViewModel; - -function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { - const { accountId: routeAccountId } = useParams(); - const accountId = scopeId ?? routeAccountId; - const portfolio = usePortfolio(accountId); - const stocks = useStocks( - portfolio.data?.positions.flatMap((position) => - position.type === "STOCK" && position.instrumentUid - ? [position.instrumentUid] - : [], - ) ?? [], - ); - const instrumentDetails = stocks; - const operations = useBrokerageOperations(accountId, { - limit: 5, - }); - const events = useBrokerageEvents(accountId); - const income = useIncomeSummary(accountId); - const accounts = useBrokerageAccounts(); - const sync = useScreenSync({ screen: "account", accountId }, [ - accounts, - portfolio, - operations, - events, - income, - ]); - const account = accounts.data?.find((item) => item.id === accountId); - const recentOperations = toRecentOperations(accountId, operations); - const recentEvents = toRecentEvents(accountId, events); - const incomeSummary = toIncomeSummary(accountId, income); - if (!portfolio.data) { - if (portfolio.isError || portfolio.meta?.sync.state === "failed") - return { - state: "error" as const, - code: apiErrorCode(portfolio.error), - retry: sync.retry, - }; - return { state: "loading" as const }; +type AccountPageRecentOperations + = | { + items: BrokerageOperation[]; + lastSuccessfulAt?: null | string; + refreshError?: boolean; + stale?: boolean; + state: "loaded" | "partial"; + to: string; } - const refresh = { - partial: portfolio.meta?.availability === "partial", - refreshing: portfolio.isFetching || sync.refreshing, - sourceError: sync.sourceError, - refreshError: portfolio.isError || sync.transportError || sync.sourceError, - retry: sync.retry, - }; - if ( - portfolio.meta?.availability === "complete" && - !portfolio.data.positions.length && - !portfolio.data.cashBalances.length - ) - return { - state: "empty" as const, - account, - accountId, - recentOperations, - recentEvents, - incomeSummary, - ...refresh, - }; - return { - state: "loaded" as const, - account, - accountId, - portfolio: portfolio.data, - positionSections: toAccountPagePositionSections( - portfolio.data, - instrumentDetails, - ), - recentOperations, - recentEvents, - incomeSummary, - ...refresh, + | { + state: "empty" | "error" | "loading"; + to: string; }; + +type AccountPageReturnTone = "negative" | "positive"; + +type AccountPageViewModel + = | AccountPageEmptyViewModel + | AccountPageLoadedViewModel + | { code?: string; retry: () => Promise | undefined; state: "error" } + | { state: "loading" }; + +type PositionSectionKind = "bonds" | "other" | "stocks"; + +function groupPositions( + positions: AccountPagePosition[], +): AccountPagePositionGroup[] { + const groups = new Map(); + for (const [index, position] of positions.entries()) { + const identity = positionIdentity(position); + const rowKey = JSON.stringify(["row", position.account?.id, index]); + const key + = identity && position.account + ? JSON.stringify([position.type, ...identity]) + : rowKey; + const group = groups.get(key); + if ( + group + && group.positions.every(item => item.account?.id !== position.account?.id) + ) { + group.positions.push(position); + } + else { + const uniqueKey = group ? rowKey : key; + groups.set(uniqueKey, { key: uniqueKey, positions: [position] }); + } + } + // eslint-disable-next-line unicorn/prefer-iterator-to-array -- Production targets ES2023; Iterator helpers require a newer browser baseline. + return [...groups.values()]; } -function toIncomeSummary( - accountId: string | undefined, - income: ReturnType, -): AccountPageIncomeSummary { - const to = `/accounts/${accountId}/income`; - if (!income.data && !income.isError && income.meta?.sync.state !== "failed") - return { state: "loading", to }; - if (income.meta?.availability === "partial") return { state: "partial", to }; - if (!income.data && (income.isError || income.meta?.sync.state === "failed")) - return { state: "error", to, retry: () => income.refetch() }; - if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; - return { - state: "loaded", - to, - summary: income.data, - lastSuccessfulAt: income.meta?.lastSuccessfulAt, - stale: income.meta?.freshness === "stale", - refreshError: income.isError || income.meta?.sync.state === "failed", - }; -} - -function toRecentOperations( - accountId: string | undefined, - operations: ReturnType, -): AccountPageRecentOperations { - const to = `/accounts/${accountId}/operations`; - if ( - !operations.data && - !operations.isError && - operations.meta?.sync.state !== "failed" - ) - return { state: "loading", to }; - if (operations.meta?.availability === "partial") - return { state: "partial", to, items: operations.data?.items ?? [] }; - if ( - !operations.data && - (operations.isError || operations.meta?.sync.state === "failed") - ) - return { state: "error", to }; - if (!operations.data?.items.length) return { state: "empty", to }; - return { - state: "loaded", - to, - items: operations.data.items, - lastSuccessfulAt: operations.meta?.lastSuccessfulAt, - stale: operations.meta?.freshness === "stale", - refreshError: - operations.isError || operations.meta?.sync.state === "failed", - }; -} - -function toRecentEvents( - accountId: string | undefined, - events: ReturnType, -): AccountPageRecentEvents { - const to = `/accounts/${accountId}/events`; - if (!events.data && !events.isError && events.meta?.sync.state !== "failed") - return { state: "loading", to }; - if (events.meta?.availability === "partial") - return { - state: "partial", - to, - items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], - }; - if (!events.data && (events.isError || events.meta?.sync.state === "failed")) - return { state: "error", to }; - const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; - if (!items.length) return { state: "empty", to }; - return { - state: "loaded", - to, - items, - lastSuccessfulAt: events.meta?.lastSuccessfulAt, - stale: events.meta?.freshness === "stale", - refreshError: events.isError || events.meta?.sync.state === "failed", - }; +function positionIdentity(position: AccountPagePosition) { + if (position.instrumentUid?.trim()) return ["uid", position.instrumentUid.trim()]; + if (position.figi?.trim()) return ["figi", position.figi.trim()]; + return position.isin?.trim() ? ["isin", position.isin.trim()] : undefined; } function toAccountPagePositionSections( portfolio: Portfolio, - instrumentDetails: Map = new Map(), + instrumentDetails: Map = new Map(), ): AccountPagePositionSection[] { const denominators = new Map(); for (const position of portfolio.positions) { @@ -271,104 +153,225 @@ function toAccountPagePositionSections( ); const bonds = toSection("bonds", portfolio, denominators, instrumentDetails); const other = toSection("other", portfolio, denominators, instrumentDetails); - return [stocks, bonds, other].filter((section) => section.groups.length > 0); + return [stocks, bonds, other].filter(section => section.groups.length > 0); } -function toSection( - kind: PositionSectionKind, - portfolio: Portfolio, - denominators: Map, - instrumentDetails: Map, -): AccountPagePositionSection { +function toIncomeSummary( + accountId: string | undefined, + income: ReturnType, +): AccountPageIncomeSummary { + const to = `/accounts/${accountId}/income`; + if (!income.data && !income.isError && income.meta?.sync.state !== "failed") + return { state: "loading", to }; + if (income.meta?.availability === "partial") return { state: "partial", to }; + if (!income.data && (income.isError || income.meta?.sync.state === "failed")) + return { retry: () => income.refetch(), state: "error", to }; + if (!income.data?.totalsByCurrency.length) return { state: "empty", to }; return { - kind, - groups: groupPositions( - portfolio.positions - .filter((position) => { - if (kind === "stocks") return position.type === "STOCK"; - if (kind === "bonds") return position.type === "BOND"; - return ( - position.type !== "STOCK" && - position.type !== "BOND" && - position.type !== "CURRENCY" - ); - }) - .map((position) => - toPosition( - position, - denominators, - kind, - instrumentDetails.get(position.instrumentUid ?? "")?.slug, - ), - ), - ), + lastSuccessfulAt: income.meta?.lastSuccessfulAt, + refreshError: income.isError || income.meta?.sync.state === "failed", + stale: income.meta?.freshness === "stale", + state: "loaded", + summary: income.data, + to, }; } -function groupPositions( - positions: AccountPagePosition[], -): AccountPagePositionGroup[] { - const groups = new Map(); - for (const [index, position] of positions.entries()) { - const identity = position.instrumentUid?.trim() - ? ["uid", position.instrumentUid.trim()] - : position.figi?.trim() - ? ["figi", position.figi.trim()] - : position.isin?.trim() - ? ["isin", position.isin.trim()] - : null; - const rowKey = JSON.stringify(["row", position.account?.id, index]); - const key = - position.account && identity - ? JSON.stringify([position.type, ...identity]) - : rowKey; - const group = groups.get(key); - if ( - group && - !group.positions.some((item) => item.account?.id === position.account?.id) - ) { - group.positions.push(position); - } else { - const uniqueKey = group ? rowKey : key; - groups.set(uniqueKey, { key: uniqueKey, positions: [position] }); - } - } - return [...groups.values()]; -} - function toPosition( position: Portfolio["positions"][number], denominators: Map, kind: PositionSectionKind, instrumentSlug: string | undefined, ): AccountPagePosition { - const expanded = kind !== "other"; + const isExpanded = kind !== "other"; const returnValue = position.absoluteReturn ? Number(position.absoluteReturn.value) - : null; - const returnTone = - returnValue === null || returnValue === 0 - ? undefined - : returnValue > 0 - ? "positive" - : "negative"; + : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null; + let returnTone: AccountPageReturnTone | undefined; + if (returnValue !== null && returnValue !== 0) returnTone = returnValue > 0 ? "positive" : "negative"; + const returnPrefix = isExpanded && returnValue !== null && returnValue > 0 ? "+" : ""; return { ...position, instrumentTo: (kind === "stocks" || kind === "bonds") && position.instrumentUid ? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}` : undefined, + logoSrc: isExpanded ? instrumentLogoUrl(position.logoName) : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + returnText: position.absoluteReturn + ? `${returnPrefix}${formatMoney(position.absoluteReturn)}` + : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + returnTone, shareText: position.currentValue?.currency ? formatShare(position.currentValue, { currency: position.currentValue.currency, value: String(denominators.get(position.currentValue.currency) ?? 0), }) - : null, - returnText: position.absoluteReturn - ? `${expanded && returnValue !== null && returnValue > 0 ? "+" : ""}${formatMoney(position.absoluteReturn)}` - : null, - returnTone, - logoSrc: expanded ? instrumentLogoUrl(position.logoName) : null, + : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + }; +} + +function toRecentEvents( + accountId: string | undefined, + events: ReturnType, +): AccountPageRecentEvents { + const to = `/accounts/${accountId}/events`; + if (!events.data && !events.isError && events.meta?.sync.state !== "failed") + return { state: "loading", to }; + if (events.meta?.availability === "partial") + return { + items: events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? [], + state: "partial", + to, + }; + if (!events.data && (events.isError || events.meta?.sync.state === "failed")) + return { state: "error", to }; + const items = events.data?.events.slice(0, RECENT_EVENTS_LIMIT) ?? []; + if (items.length === 0) return { state: "empty", to }; + return { + items, + lastSuccessfulAt: events.meta?.lastSuccessfulAt, + refreshError: events.isError || events.meta?.sync.state === "failed", + stale: events.meta?.freshness === "stale", + state: "loaded", + to, + }; +} + +function toRecentOperations( + accountId: string | undefined, + operations: ReturnType, +): AccountPageRecentOperations { + const to = `/accounts/${accountId}/operations`; + if ( + !operations.data + && !operations.isError + && operations.meta?.sync.state !== "failed" + ) + return { state: "loading", to }; + if (operations.meta?.availability === "partial") + return { items: operations.data?.items ?? [], state: "partial", to }; + if ( + !operations.data + && (operations.isError || operations.meta?.sync.state === "failed") + ) + return { state: "error", to }; + if (!operations.data?.items.length) return { state: "empty", to }; + return { + items: operations.data.items, + lastSuccessfulAt: operations.meta?.lastSuccessfulAt, + refreshError: + operations.isError || operations.meta?.sync.state === "failed", + stale: operations.meta?.freshness === "stale", + state: "loaded", + to, + }; +} + +function toSection( + kind: PositionSectionKind, + portfolio: Portfolio, + denominators: Map, + instrumentDetails: Map, +): AccountPagePositionSection { + return { + groups: groupPositions( + portfolio.positions + .filter((position) => { + if (kind === "stocks") return position.type === "STOCK"; + return kind === "bonds" + ? position.type === "BOND" + : ( + position.type !== "STOCK" + && position.type !== "BOND" + && position.type !== "CURRENCY" + ); + }) + .map(position => + toPosition( + position, + denominators, + kind, + instrumentDetails.get(position.instrumentUid ?? "")?.slug, + ), + ), + ), + kind, + }; +} + +function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { + const { accountId: routeAccountId } = useParams(); + const accountId = scopeId ?? routeAccountId; + const portfolio = usePortfolio(accountId); + const stocks = useStocks( + portfolio.data?.positions.flatMap(position => + position.type === "STOCK" && position.instrumentUid + ? [position.instrumentUid] + : [], + ) ?? [], + ); + const instrumentDetails = stocks; + const operations = useBrokerageOperations(accountId, { + limit: 5, + }); + const events = useBrokerageEvents(accountId); + const income = useIncomeSummary(accountId); + const accounts = useBrokerageAccounts(); + const sync = useScreenSync({ accountId, screen: "account" }, [ + accounts, + portfolio, + operations, + events, + income, + ]); + + if (!portfolio.data) { + if (portfolio.isError || portfolio.meta?.sync.state === "failed") + return { + code: apiErrorCode(portfolio.error), + retry: sync.retry, + state: "error" as const, + }; + return { state: "loading" as const }; + } + const account = accounts.data?.find(item => item.id === accountId); + const recentOperations = toRecentOperations(accountId, operations); + const recentEvents = toRecentEvents(accountId, events); + const incomeSummary = toIncomeSummary(accountId, income); + const refresh = { + partial: portfolio.meta?.availability === "partial", + refreshError: portfolio.isError || sync.transportError || sync.sourceError, + refreshing: portfolio.isFetching || sync.refreshing, + retry: sync.retry, + sourceError: sync.sourceError, + }; + if ( + portfolio.meta?.availability === "complete" + && portfolio.data.positions.length === 0 + && portfolio.data.cashBalances.length === 0 + ) + return { + account, + accountId, + incomeSummary, + recentEvents, + recentOperations, + state: "empty" as const, + ...refresh, + }; + return { + account, + accountId, + incomeSummary, + portfolio: portfolio.data, + positionSections: toAccountPagePositionSections( + portfolio.data, + instrumentDetails, + ), + recentEvents, + recentOperations, + state: "loaded" as const, + ...refresh, }; } diff --git a/src/pages/account/ui/AccountPage/components/AccountHeading.view.tsx b/src/pages/account/ui/AccountPage/components/AccountHeading.view.tsx index 3d89f4d..3cc11ea 100644 --- a/src/pages/account/ui/AccountPage/components/AccountHeading.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountHeading.view.tsx @@ -1,7 +1,10 @@ -import { Stack, Text, Title } from "@mantine/core"; import type { FC } from "react"; + +import { Stack, Text, Title } from "@mantine/core"; import { useTranslation } from "react-i18next"; + import type { BrokerageAccount } from "@/entities/brokerage-account"; + import { accountTypeKey } from "@/entities/brokerage-account"; type AccountHeadingProps = { @@ -13,6 +16,10 @@ const AccountHeading: FC = (props) => { const { account } = props; const { t } = useTranslation(); + let description: string | undefined; + if (props.accountId === "all") description = t("accounts.allDescription"); + else if (account) description = t(accountTypeKey(account.type)); + return ( @@ -20,15 +27,7 @@ const AccountHeading: FC<AccountHeadingProps> = (props) => { ? t("accounts.all") : (account?.name ?? t("portfolio.title"))} - {props.accountId === "all" ? ( - - {t("accounts.allDescription")} - - ) : account ? ( - - {t(accountTypeKey(account.type))} - - ) : null} + {description && {description}} ); }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx index 6c6b076..c3c83a2 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageEmpty.view.tsx @@ -1,14 +1,19 @@ +import type { FC } from "react"; + import { Stack } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; + import type { BrokerageAccount } from "@/entities/brokerage-account"; + import { BrokerageEventsPreview } from "@/entities/brokerage-event"; + import type { AccountPageIncomeSummary, AccountPageRecentEvents, AccountPageRecentOperations, } from "../AccountPage.vm"; + import { AccountHeading } from "./AccountHeading.view"; import { IncomeSummary } from "./IncomeSummary.view"; import { PortfolioRefreshState } from "./PortfolioRefreshState/PortfolioRefreshState.view"; @@ -18,13 +23,13 @@ import { RecentOperations } from "./RecentOperations.view"; type AccountPageEmptyProps = { account?: BrokerageAccount; accountId?: string; - refreshing?: boolean; - sourceError?: boolean; - refreshError?: boolean; - retry?: () => void; - recentOperations: AccountPageRecentOperations; - recentEvents: AccountPageRecentEvents; incomeSummary?: AccountPageIncomeSummary; + recentEvents: AccountPageRecentEvents; + recentOperations: AccountPageRecentOperations; + refreshError?: boolean; + refreshing?: boolean; + retry?: () => void; + sourceError?: boolean; }; const AccountPageEmpty: FC = (props) => { @@ -33,25 +38,25 @@ const AccountPageEmpty: FC = (props) => { - {props.incomeSummary ? : null} + {!!(props.incomeSummary) && ()} ); }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx index 4d85c56..381335b 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageError.view.tsx @@ -1,6 +1,7 @@ +import type { FC } from "react"; + import { Button } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; @@ -12,44 +13,44 @@ const AccountPageError: FC = (props) => { if (code === "NOT_FOUND") return ( {t("portfolio.back")} - } + )} + error + text={t("portfolio.accountUnavailableText")} + title={t("portfolio.accountUnavailableTitle")} /> ); if (code === "TBANK_NOT_CONNECTED" || code === "INVALID_TBANK_TOKEN") return ( {t("accounts.openSettings")} - } + )} + error + text={t("portfolio.connectionErrorText")} + title={t("portfolio.connectionErrorTitle")} /> ); if (code === "TBANK_UNAVAILABLE") return ( {t("accounts.retry")}} + error + text={t("accounts.unavailableText")} + title={t("accounts.unavailableTitle")} /> ); return ( {t("accounts.retry")}} + error + text={t("states.errorText")} + title={t("states.errorTitle")} /> ); }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx index 9bce2ff..f085fca 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoaded.view.tsx @@ -1,17 +1,22 @@ -import { Accordion, Alert, Stack, Text } from "@mantine/core"; import type { FC } from "react"; + +import { Accordion, Alert, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; + import type { BrokerageAccount } from "@/entities/brokerage-account"; -import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import type { Portfolio } from "@/entities/portfolio"; + +import { BrokerageEventsPreview } from "@/entities/brokerage-event"; import { formatSnapshotTime } from "@/shared/lib"; -import classes from "../AccountPage.module.css"; + import type { AccountPageIncomeSummary, AccountPagePositionSection, AccountPageRecentEvents, AccountPageRecentOperations, } from "../AccountPage.vm"; + +import classes from "../AccountPage.module.css"; import { AccountHeading } from "./AccountHeading.view"; import { CurrencyCards } from "./CurrencyCards/CurrencyCards.view"; import { IncomeSummary } from "./IncomeSummary.view"; @@ -23,16 +28,16 @@ import { RecentOperations } from "./RecentOperations.view"; type AccountPageLoadedProps = { account?: BrokerageAccount; accountId?: string; + incomeSummary?: AccountPageIncomeSummary; partial?: boolean; portfolio: Portfolio; positionSections: AccountPagePositionSection[]; - refreshing?: boolean; - sourceError?: boolean; - refreshError?: boolean; - retry?: () => void; - recentOperations: AccountPageRecentOperations; recentEvents: AccountPageRecentEvents; - incomeSummary?: AccountPageIncomeSummary; + recentOperations: AccountPageRecentOperations; + refreshError?: boolean; + refreshing?: boolean; + retry?: () => void; + sourceError?: boolean; }; const AccountPageLoaded: FC = (props) => { @@ -41,14 +46,14 @@ const AccountPageLoaded: FC = (props) => { - {props.portfolio.isStale ? ( + {!!(props.portfolio.isStale) && (( {t("portfolio.staleWarning")} - ) : null} + ))} {t( props.accountId === "all" @@ -59,34 +64,36 @@ const AccountPageLoaded: FC = (props) => { }, )} - {props.partial ? ( - {t("investment.partial")} - ) : ( - - )} - {!props.partial ? ( + {props.partial + ? ( + {t("investment.partial")} + ) + : ( + + )} + {!(props.partial) && (( - ) : null} + ))} section.kind), + ...props.positionSections.map(section => section.kind), "cash", "recentOperations", "recentEvents", "incomeSummary", ]} - classNames={{ - control: classes.accordionControl, - label: classes.accordionLabel, - chevron: classes.accordionChevron, - panel: classes.accordionPanel, - }} + multiple > - {props.positionSections.map((section) => ( + {props.positionSections.map(section => ( {t(positionSectionTitleKey(section.kind))} @@ -117,9 +124,9 @@ const AccountPageLoaded: FC = (props) => { {t("income.summaryTitle")} - {props.incomeSummary ? ( + {!!(props.incomeSummary) && (( - ) : null} + ))} @@ -129,8 +136,7 @@ const AccountPageLoaded: FC = (props) => { function positionSectionTitleKey(kind: AccountPagePositionSection["kind"]) { if (kind === "stocks") return "portfolio.stocks"; - if (kind === "bonds") return "portfolio.bonds"; - return "portfolio.otherPositions"; + return kind === "bonds" ? "portfolio.bonds" : "portfolio.otherPositions"; } export { AccountPageLoaded }; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories.tsx index 4bad647..ab07983 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.stories.tsx @@ -1,10 +1,9 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; + import { AccountPageLoading } from "./AccountPageLoading.view"; const meta = { - title: "Product/AccountPageLoading", component: AccountPageLoading, - tags: ["autodocs"], parameters: { docs: { description: { @@ -13,6 +12,8 @@ const meta = { }, }, }, + tags: ["autodocs"], + title: "Product/AccountPageLoading", } satisfies Meta; export default meta; diff --git a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx index c5e6553..8f96f4d 100644 --- a/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx +++ b/src/pages/account/ui/AccountPage/components/AccountPageLoading/AccountPageLoading.view.tsx @@ -1,7 +1,9 @@ +import type { FC } from "react"; + import { Paper, SimpleGrid, Skeleton } from "@mantine/core"; import { LoadingSkeleton } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; + import classes from "../../AccountPage.module.css"; const AccountPageLoading: FC = () => { @@ -10,25 +12,25 @@ const AccountPageLoading: FC = () => { - {[0, 1, 2].map((index) => ( - + {["first", "second", "third"].map(placeholder => ( + ))} ); diff --git a/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx index 1366e63..39482af 100644 --- a/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx +++ b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.test.tsx @@ -1,6 +1,7 @@ import { MantineProvider } from "@mantine/core"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; + import { CurrencyCards } from "./CurrencyCards.view"; describe("CurrencyCards currency presentation", () => { @@ -20,12 +21,14 @@ describe("CurrencyCards currency presentation", () => { , ); expect( - screen.queryAllByRole("definition").map((node) => node.textContent), + screen.queryAllByRole("definition").map(node => node.textContent), ).toEqual( ["3,00 €", "2,00 $", "1,00 ₽", "4,00 CAD", "5,00 XYZ"].slice(0, count), ); + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. expect(container.querySelectorAll("dl")).toHaveLength(count); expect( + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. container.querySelector("[tabindex], button, a, input"), ).toBeNull(); }, @@ -57,11 +60,14 @@ describe("CurrencyCards currency presentation", () => { ); const term = screen.getByRole("term"); expect(term).toHaveTextContent(name); - expect(term.querySelector('[aria-hidden="true"]')).toHaveTextContent( + // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. + expect(term.querySelector("[aria-hidden=\"true\"]")).toHaveTextContent( symbol, ); expect(screen.getByRole("definition")).toHaveTextContent(expected); + // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. expect(term.parentElement).toBe( + // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. screen.getByRole("definition").parentElement, ); }, @@ -79,7 +85,7 @@ describe("CurrencyCards currency presentation", () => { , ); expect( - screen.getAllByRole("definition").map((node) => node.textContent), + screen.getAllByRole("definition").map(node => node.textContent), ).toEqual(["1,00 $", "2,00 CAD"]); }); }); diff --git a/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx index 63f2395..2e2536d 100644 --- a/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx +++ b/src/pages/account/ui/AccountPage/components/CurrencyCards/CurrencyCards.view.tsx @@ -1,10 +1,16 @@ -import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core"; import type { FC } from "react"; + +import { Paper, SimpleGrid, Text, VisuallyHidden } from "@mantine/core"; import { useTranslation } from "react-i18next"; + import type { Portfolio } from "@/entities/portfolio"; + import { formatDecimal } from "@/shared/lib"; + import classes from "../../AccountPage.module.css"; +const SIGN_PREFIX_LENGTH = 2; + type CurrencyCardsProps = { amounts: Portfolio["totalsByCurrency"]; }; @@ -12,25 +18,25 @@ type CurrencyCardsProps = { const CurrencyCards: FC = (props) => { const { amounts } = 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") }, + const currencies: Record = { + EUR: { name: t("portfolio.currencies.EUR"), symbol: "€" }, + RUB: { name: t("portfolio.currencies.RUB"), symbol: "₽" }, + USD: { name: t("portfolio.currencies.USD"), symbol: "$" }, }; return ( - + {amounts.map((money) => { const amount = formatDecimal(money.value); return ( - + @@ -38,17 +44,20 @@ const CurrencyCards: FC = (props) => { {currencies[money.currency]?.name ?? money.currency} - - {amount.startsWith("-") ? ( - <> - - {amount.slice(0, 2)} - - {amount.slice(2)} - - ) : ( - amount - )}{" "} + + {amount.startsWith("-") + ? ( + <> + + {amount.slice(0, SIGN_PREFIX_LENGTH)} + + {amount.slice(SIGN_PREFIX_LENGTH)} + + ) + : ( + amount + )} + {" "} {currencies[money.currency]?.symbol ?? money.currency} diff --git a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx index 1ca3cd2..c73cf1c 100644 --- a/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx +++ b/src/pages/account/ui/AccountPage/components/IncomeSummary.view.tsx @@ -1,30 +1,33 @@ +import type { FC } from "react"; + import { Button, Paper, Stack, Text } from "@mantine/core"; import { StateView } from "@wealthledger/ui"; -import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; + import type { IncomeSummary as IncomeSummaryData } from "@/entities/income"; + import { formatMoney, formatSnapshotTime } from "@/shared/lib"; -type IncomeSummaryProps = - | { state: "loading"; to: string } - | { state: "partial"; to: string } - | { state: "error"; to: string; retry: () => void } +type IncomeSummaryProps + = | { + lastSuccessfulAt?: null | string; + refreshError?: boolean; + stale?: boolean; + state: "loaded"; + summary: IncomeSummaryData; + to: string; + } + | { retry: () => void; state: "error"; to: string } | { state: "empty"; to: string } - | { - state: "loaded"; - to: string; - summary: IncomeSummaryData; - lastSuccessfulAt?: string | null; - stale?: boolean; - refreshError?: boolean; - }; + | { state: "loading"; to: string } + | { state: "partial"; to: string }; const IncomeSummary: FC = (props) => { const { t } = useTranslation(); if (props.state === "loading" || props.state === "partial") return ( - + {t( props.state === "partial" ? "investment.partial" @@ -35,12 +38,12 @@ const IncomeSummary: FC = (props) => { if (props.state === "error") return ( {t("income.summaryRetry")} } + error + text={t("income.errorText")} + title={t("income.summaryError")} /> ); if (props.state === "empty") @@ -54,23 +57,32 @@ const IncomeSummary: FC = (props) => { ); return ( - {props.lastSuccessfulAt ? ( - + {!!(props.lastSuccessfulAt) && (( + {t("investment.sourceTime", { date: formatSnapshotTime(props.lastSuccessfulAt), })} - ) : null} - {props.stale ? {t("investment.stale")} : null} - {props.refreshError ? ( + ))} + {!!(props.stale) && ({t("investment.stale")})} + {!!(props.refreshError) && (( {t("income.refreshError")} - ) : null} - {props.summary.totalsByCurrency.map((total) => ( + ))} + {props.summary.totalsByCurrency.map(total => ( {formatMoney(total.total)} - {t("income.coupon")}: {formatMoney(total.coupon)} ·{" "} - {t("income.dividend")}: {formatMoney(total.dividend)} + {t("income.coupon")} + : + {" "} + {formatMoney(total.coupon)} + {" "} + · + {" "} + {t("income.dividend")} + : + {" "} + {formatMoney(total.dividend)} ))} diff --git a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx index 28f7d9a..95af73c 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.stories.tsx @@ -1,19 +1,19 @@ -import { Stack } from "@mantine/core"; import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Stack } from "@mantine/core"; import { fn } from "storybook/test"; + import { CurrencyCards } from "../CurrencyCards/CurrencyCards.view"; import { PortfolioRefreshState } from "./PortfolioRefreshState.view"; const meta = { - title: "Product/PortfolioRefreshState", - component: PortfolioRefreshState, - tags: ["autodocs"], - args: { refreshing: false, refreshError: false, retry: fn() }, + args: { refreshError: false, refreshing: false, retry: fn() }, argTypes: { - refreshing: { control: "boolean" }, refreshError: { control: "boolean" }, + refreshing: { control: "boolean" }, retry: { control: false }, }, + component: PortfolioRefreshState, parameters: { docs: { description: { @@ -22,7 +22,7 @@ const meta = { }, }, }, - render: (args) => ( + render: arguments_ => ( - + ), + tags: ["autodocs"], + title: "Product/PortfolioRefreshState", } satisfies Meta; export default meta; @@ -40,15 +42,15 @@ export default meta; type Story = StoryObj; export const Idle: Story = { name: "Сохранённые данные" }; export const Refreshing: Story = { - name: "Фоновое обновление", args: { refreshing: true }, + name: "Фоновое обновление", }; export const RefreshError: Story = { - name: "Ошибка обновления с retry", args: { refreshError: true }, + name: "Ошибка обновления с retry", }; export const SourceFailure: Story = { - name: "Ошибка синхронизации сохраняет суммы", args: { refreshError: true, sourceError: true }, + name: "Ошибка синхронизации сохраняет суммы", }; diff --git a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx index 514dd6f..89eab6f 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioRefreshState/PortfolioRefreshState.view.tsx @@ -1,38 +1,39 @@ -import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core"; import type { FC } from "react"; + +import { Alert, Button, Group, Loader, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; type PortfolioRefreshStateProps = { - sourceError?: boolean; - refreshing: boolean; refreshError: boolean; + refreshing: boolean; retry?: () => void; + sourceError?: boolean; }; const PortfolioRefreshState: FC = (props) => { - const { refreshing, refreshError, retry } = props; + const { refreshError, refreshing, retry } = props; const { t } = useTranslation(); return ( <> - {refreshing ? ( - + {!!(refreshing) && (( + {t("portfolio.refreshing")} - ) : null} - {refreshError ? ( - + ))} + {!!(refreshError) && (( + {t( @@ -43,7 +44,7 @@ const PortfolioRefreshState: FC = (props) => { - ) : null} + ))} ); }; diff --git a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx index 469de63..6d83480 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.stories.tsx @@ -1,23 +1,23 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; + import type { Portfolio } from "@/entities/portfolio"; + import { PortfolioStructure } from "./PortfolioStructure"; const rub: Portfolio["structure"]["currencies"][number] = { - currency: "RUB", - totalValue: "1250000", - hasNegativeValues: false, categories: [ - { type: "STOCK", value: "650000", sharePercent: "52" }, - { type: "BOND", value: "375000", sharePercent: "30" }, - { type: "OTHER", value: "125000", sharePercent: "10" }, - { type: "CASH", value: "100000", sharePercent: "8" }, + { sharePercent: "52", type: "STOCK", value: "650000" }, + { sharePercent: "30", type: "BOND", value: "375000" }, + { sharePercent: "10", type: "OTHER", value: "125000" }, + { sharePercent: "8", type: "CASH", value: "100000" }, ], + currency: "RUB", + hasNegativeValues: false, + totalValue: "1250000", }; const meta = { - title: "Product/PortfolioStructure", - component: PortfolioStructure, - tags: ["autodocs"], args: { structure: { currencies: [rub], unvaluedCategories: [] } }, + component: PortfolioStructure, parameters: { docs: { description: { @@ -26,6 +26,8 @@ const meta = { }, }, }, + tags: ["autodocs"], + title: "Product/PortfolioStructure", } satisfies Meta; export default meta; @@ -37,10 +39,10 @@ export const MultipleCurrencies: Story = { currencies: [ rub, { + categories: [{ sharePercent: "100", type: "CASH", value: "500" }], currency: "USD", - totalValue: "500", hasNegativeValues: false, - categories: [{ type: "CASH", value: "500", sharePercent: "100" }], + totalValue: "500", }, ], unvaluedCategories: [], @@ -52,14 +54,14 @@ export const CashOnly: Story = { structure: { currencies: [ { - currency: "RUB", - totalValue: "100000", - hasNegativeValues: false, - categories: rub.categories.map((row) => ({ + categories: rub.categories.map(row => ({ ...row, - value: row.type === "CASH" ? "100000" : "0", sharePercent: row.type === "CASH" ? "100" : "0", + value: row.type === "CASH" ? "100000" : "0", })), + currency: "RUB", + hasNegativeValues: false, + totalValue: "100000", }, ], unvaluedCategories: [], @@ -75,13 +77,15 @@ export const Incomplete: Story = { currencies: [ { ...rub, - categories: rub.categories.map((row) => ({ - ...row, - sharePercent: null, - })), + categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...row, + sharePercent: null, + } + /* eslint-enable unicorn/no-null */))), }, ], - unvaluedCategories: [{ type: "STOCK", count: 2 }], + unvaluedCategories: [{ count: 2, type: "STOCK" }], }, }, }; @@ -89,7 +93,7 @@ export const UnknownOnly: Story = { args: { structure: { currencies: [], - unvaluedCategories: [{ type: "BOND", count: 1 }], + unvaluedCategories: [{ count: 1, type: "BOND" }], }, }, }; @@ -99,13 +103,15 @@ export const Negative: Story = { currencies: [ { ...rub, + categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...row, + sharePercent: null, + value: row.type === "OTHER" ? "25000" : row.value, + } + /* eslint-enable unicorn/no-null */))), hasNegativeValues: true, totalValue: "1150000", - categories: rub.categories.map((row) => ({ - ...row, - value: row.type === "OTHER" ? "25000" : row.value, - sharePercent: null, - })), }, ], unvaluedCategories: [], diff --git a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx index 1b8f51b..6551b9f 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.test.tsx @@ -1,28 +1,30 @@ import { MantineProvider } from "@mantine/core"; import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it } from "vitest"; + import type { Portfolio } from "@/entities/portfolio"; + import { PortfolioStructure } from "./PortfolioStructure"; const rub: Portfolio["structure"]["currencies"][number] = { - currency: "RUB", - totalValue: "100", - hasNegativeValues: false, categories: [ - { type: "STOCK", value: "60", sharePercent: "60" }, - { type: "BOND", value: "20", sharePercent: "20" }, - { type: "OTHER", value: "0", sharePercent: "0" }, - { type: "CASH", value: "20", sharePercent: "20" }, + { sharePercent: "60", type: "STOCK", value: "60" }, + { sharePercent: "20", type: "BOND", value: "20" }, + { sharePercent: "0", type: "OTHER", value: "0" }, + { sharePercent: "20", type: "CASH", value: "20" }, ], + currency: "RUB", + hasNegativeValues: false, + totalValue: "100", }; -const ui = (structure: Portfolio["structure"] | null | undefined) => ( +const ui = (structure: null | Portfolio["structure"] | undefined) => ( ); describe("portfolio structure", () => { - it.each([undefined, null])( + it.each([undefined, /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null])( "keeps an older snapshot without structure available (%s) and recovers on refresh", (structure) => { const view = render(ui(structure)); @@ -66,15 +68,17 @@ describe("portfolio structure", () => { currencies: [ { ...rub, - categories: rub.categories.map((row) => ({ - ...row, - sharePercent: null, - })), + categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...row, + sharePercent: null, + } + /* eslint-enable unicorn/no-null */))), }, ], unvaluedCategories: [ - { type: "OTHER", count: 2 }, - { type: "STOCK", count: 1 }, + { count: 2, type: "OTHER" }, + { count: 1, type: "STOCK" }, ], }), ); @@ -93,12 +97,14 @@ describe("portfolio structure", () => { currencies: [ { ...rub, + categories: rub.categories.map(row => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + ...row, + sharePercent: null, + value: row.type === "STOCK" ? "-60" : row.value, + } + /* eslint-enable unicorn/no-null */))), hasNegativeValues: true, - categories: rub.categories.map((row) => ({ - ...row, - value: row.type === "STOCK" ? "-60" : row.value, - sharePercent: null, - })), }, ], unvaluedCategories: [], @@ -115,7 +121,7 @@ describe("portfolio structure", () => { }); it("handles unknown-only and empty compositions", () => { const view = render( - ui({ currencies: [], unvaluedCategories: [{ type: "BOND", count: 1 }] }), + ui({ currencies: [], unvaluedCategories: [{ count: 1, type: "BOND" }] }), ); expect(screen.getByText("Облигации: без оценки — 1")).toBeInTheDocument(); expect(screen.queryByRole("list")).not.toBeInTheDocument(); @@ -130,9 +136,9 @@ describe("portfolio structure", () => { ...rub, categories: [ { + sharePercent: "0.000001", type: "CASH", value: "9007199254740993.000000001", - sharePercent: "0.000001", }, ], }, diff --git a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx index cd28b97..bcd3372 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.tsx @@ -1,72 +1,72 @@ import { type FC, useState } from "react"; import { useTranslation } from "react-i18next"; + import type { Portfolio } from "@/entities/portfolio"; + import { formatMoney } from "@/shared/lib"; + import { PortfolioStructureView } from "./PortfolioStructure.view"; const MIN_VISIBLE_SHARE_PERCENT = 0.01; // A cached snapshot or an older running server can predate this response field. -type PortfolioStructureProps = { structure?: Portfolio["structure"] | null }; +type PortfolioStructureProps = { structure?: null | Portfolio["structure"] }; const categoryPresentation = { - STOCK: { label: "portfolio.stocks", color: "blue.6" }, - BOND: { label: "portfolio.bonds", color: "grape.6" }, - OTHER: { label: "portfolio.structure.other", color: "orange.6" }, - CASH: { label: "portfolio.structure.cash", color: "green.6" }, + BOND: { color: "grape.6", label: "portfolio.bonds" }, + CASH: { color: "green.6", label: "portfolio.structure.cash" }, + OTHER: { color: "orange.6", label: "portfolio.structure.other" }, + STOCK: { color: "blue.6", label: "portfolio.stocks" }, } as const; const PortfolioStructure: FC = (props) => { const { structure } = props; const { t } = useTranslation(); - const [currency, setCurrency] = useState(null); - const selected = - structure?.currencies.find((item) => item.currency === currency) ?? - structure?.currencies[0]; - const incomplete = (structure?.unvaluedCategories.length ?? 0) > 0; + const [currency, setCurrency] = useState(null); + const selected + = structure?.currencies.find(item => item.currency === currency) + ?? structure?.currencies[0]; + const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0; const rows = selected ? selected.categories.map((category) => { const presentation = categoryPresentation[category.type]; - const percentage = - category.sharePercent === null ? null : Number(category.sharePercent); + const percentage + = category.sharePercent === null ? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null : Number(category.sharePercent); const unknown = structure?.unvaluedCategories.some( - (item) => item.type === category.type, + item => item.type === category.type, ); const amount = formatMoney({ currency: selected.currency, value: category.value, }); + let displayedAmount = amount; + if (unknown) displayedAmount = category.value === "0" ? t("portfolio.structure.unvalued") : t("portfolio.structure.knownAmount", { amount }); + let share = t("portfolio.structure.shareUnavailable"); + if (percentage !== null) share = percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT + ? t("portfolio.structure.smallShare") + : `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`; return { - name: t(presentation.label), + amount: displayedAmount, color: presentation.color, - amount: unknown - ? category.value === "0" - ? t("portfolio.structure.unvalued") - : t("portfolio.structure.knownAmount", { amount }) - : amount, - share: - percentage === null - ? t("portfolio.structure.shareUnavailable") - : percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT - ? t("portfolio.structure.smallShare") - : `${new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 2 }).format(percentage)} %`, + name: t(presentation.label), + share, value: percentage ?? 0, }; }) : []; - const chartData = rows.filter((row) => row.value > 0); + const chartData = rows.filter(row => row.value > 0); return ( item.currency) ?? []} - currency={selected?.currency ?? null} + chartData={chartData} + currencies={structure?.currencies.map(item => item.currency) ?? []} + currency={selected?.currency ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null} + incomplete={isIncomplete} + negative={selected?.hasNegativeValues ?? false} onCurrencyChange={setCurrency} rows={rows} - chartData={chartData} - incomplete={incomplete} - negative={selected?.hasNegativeValues ?? false} + unavailable={!structure} unvalued={ - structure?.unvaluedCategories.map((category) => + structure?.unvaluedCategories.map(category => t("portfolio.structure.unvaluedCategory", { category: t(categoryPresentation[category.type].label), count: category.count, diff --git a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx index fc0395f..468791e 100644 --- a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.view.tsx @@ -2,123 +2,130 @@ import { DonutChart } from "@mantine/charts"; import { Box, Group, Paper, Select, Stack, Text, Title } from "@mantine/core"; import { type FC, useId } from "react"; import { useTranslation } from "react-i18next"; + import classes from "./PortfolioStructure.module.css"; -type StructureRow = { - name: string; - color: string; - amount: string; - share: string; - value: number; -}; type PortfolioStructureViewProps = { - unavailable: boolean; - currencies: string[]; - currency: string | null; - onCurrencyChange: (currency: string | null) => void; - rows: StructureRow[]; chartData: StructureRow[]; + currencies: string[]; + currency: null | string; incomplete: boolean; negative: boolean; + onCurrencyChange: (currency: null | string) => void; + rows: StructureRow[]; + unavailable: boolean; unvalued: string[]; }; +type StructureRow = { + amount: string; + color: string; + name: string; + share: string; + value: number; +}; const PortfolioStructureView: FC = (props) => { const { t } = useTranslation(); const titleId = useId(); return ( - - + <Group align="center" justify="space-between"> + <Title id={titleId} order={2} size="h4"> {t("portfolio.structure.title")} - {props.currencies.length > 1 ? ( + {(props.currencies.length > 1) && (( - {vm.rows ? ( - vm.rows.length ? ( - vm.rows.map((row) => ( -
    - - } - value={row.value} - valueLabel={t(`bank.kinds.${row.item.kind}`)} - tone={row.tone} - details={ - row.otherCard ? ( - - {t("bank.card")} - - ) : null - } - actions={ - - vm.setOperation(row.item)} - > - - - vm.setDeleting(row.item)} - > - - - - } - /> -
    - )) - ) : ( - {t("bank.noOperations")} - ) - ) : vm.history.isLoading ? ( - - - - - - - ) : null} + {renderHistory()}
    {vm.history.isError && ( @@ -299,34 +315,34 @@ const CardPageView: FC = (props) => { )} {vm.history.data && vm.pageSize !== "all" && ( { - if (paginationRef.current) - pendingPage.current = { - scope, - page, - top: paginationRef.current.getBoundingClientRect().top, - }; - vm.setPage(page); - }} disabled={vm.history.isFetching} - siblings={0} - getItemProps={(page) => ({ - "aria-label": t("bank.page", { page }), - })} - getControlProps={(control) => ({ + getControlProps={control => ({ "aria-label": t( control === "previous" ? "bank.previous" : "bank.next", ), })} + getItemProps={page => ({ + "aria-label": t("bank.page", { page }), + })} + onChange={(page) => { + if (paginationRef.current) + pendingPageRef.current = { + page, + scope, + top: paginationRef.current.getBoundingClientRect().top, + }; + vm.setPage(page); + }} + siblings={0} + total={vm.totalPages} + value={vm.page} /> )} diff --git a/src/pages/card/ui/CardPage/CardPage.vm.ts b/src/pages/card/ui/CardPage/CardPage.vm.ts index 3bdb67d..9919403 100644 --- a/src/pages/card/ui/CardPage/CardPage.vm.ts +++ b/src/pages/card/ui/CardPage/CardPage.vm.ts @@ -1,6 +1,7 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useParams, useSearchParams } from "react-router"; + import { type BankOperation, bankPeriod, @@ -13,18 +14,19 @@ import { import { apiErrorCode } from "@/shared/api"; import { formatDate, formatMoney } from "@/shared/lib"; -const PAGE_SIZES = ["5", "10", "20", "50", "100", "all"]; +const PAGE_SIZES = new Set(["5", "10", "20", "50", "100", "all"]); +type CardPageVm = ReturnType; function useCardPageVm() { const { cardId } = useParams(); - const [params, setParams] = useSearchParams(); + const [parameters, setParameters] = useSearchParams(); const { t } = useTranslation(); - const period = bankPeriod(params); - const requestedSize = params.get("limit") ?? "10"; - const pageSize = PAGE_SIZES.includes(requestedSize) ? requestedSize : "10"; - const rawPage = Number(params.get("page") ?? 1); - const page = - pageSize !== "all" && Number.isSafeInteger(rawPage) && rawPage > 0 + const period = bankPeriod(parameters); + const requestedSize = parameters.get("limit") ?? "10"; + const pageSize = PAGE_SIZES.has(requestedSize) ? requestedSize : "10"; + const rawPage = Number(parameters.get("page") ?? 1); + const page + = pageSize !== "all" && Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1; const card = useBankCard(cardId); @@ -33,94 +35,90 @@ function useCardPageVm() { const cards = useBankCards(true); const history = useBankOperations(cardId, { ...period, - page, limit: pageSize === "all" ? "all" : Number(pageSize), + page, }); const summary = useBankSummary(period, cardId); const [editingCard, setEditingCard] = useState(false); - const [operation, setOperation] = useState( + const [operation, setOperation] = useState<"new" | BankOperation | null>( null, ); const [deleting, setDeleting] = useState(null); const rows = history.data?.items.map((item) => { - const outgoing = - item.kind === "expense" || - (item.kind === "transfer" && item.cardId === cardId); + const isOutgoing + = item.kind === "expense" + || (item.kind === "transfer" && item.cardId === cardId); + const otherCardId = isOutgoing ? item.targetCardId : item.cardId; + let title = t(`bank.kinds.${item.kind}`); + if (item.kind === "transfer") title = t(isOutgoing ? "bank.transferTo" : "bank.transferFrom", { name: isOutgoing ? item.targetCardName : item.cardName }); return { + color: isOutgoing ? "red" : "green", + editable: + Boolean(cards.data) + && !cards.data?.some( + c => + (c.id === item.cardId || c.id === item.targetCardId) + && c.archivedAt, + ), item, - color: outgoing ? "red" : "green", - title: - item.kind === "transfer" - ? t(outgoing ? "bank.transferTo" : "bank.transferFrom", { - name: outgoing ? item.targetCardName : item.cardName, - }) - : t(`bank.kinds.${item.kind}`), metadata: [formatDate(item.date), item.categoryName, item.description] .filter(Boolean) .join(" · "), - value: `${outgoing ? "−" : "+"}${formatMoney(item.amount)}`, - tone: outgoing ? ("negative" as const) : ("positive" as const), otherCard: item.kind === "transfer" - ? outgoing - ? item.targetCardId - : item.cardId - : null, - editable: - Boolean(cards.data) && - !cards.data?.some( - (c) => - (c.id === item.cardId || c.id === item.targetCardId) && - c.archivedAt, - ), + ? otherCardId + : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + title, + tone: isOutgoing ? ("negative" as const) : ("positive" as const), + value: `${isOutgoing ? "−" : "+"}${formatMoney(item.amount)}`, }; }); return { - card, - cards, archiveError, archivePending: updateCard.isPending, + card, + cards, + deleting, + editingCard, + history, + notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND", + operation, + page, + pageSize, + period, + rows, + setDeleting, + setEditingCard, + setOperation, + setPage: (value: number) => + setParameters({ ...period, limit: pageSize, page: String(value) }), + setPageSize: (value: null | string) => { + if (value && PAGE_SIZES.has(value)) + setParameters({ ...period, limit: value }); + }, + setPeriod: (value: [null | string, null | string]) => { + if (value[0] && value[1]) + setParameters({ from: value[0], limit: pageSize, to: value[1] }); + }, + summary, toggleArchive: async () => { if (!card.data) return; setArchiveError(undefined); try { await updateCard.mutateAsync({ + body: { archived: !card.data.archivedAt, version: card.data.version }, id: card.data.id, - body: { version: card.data.version, archived: !card.data.archivedAt }, }); - } catch (failure) { - setArchiveError(failure); + } + catch (error) { + setArchiveError(error); } }, - history, - summary, - period, - page, - pageSize, totalPages: pageSize === "all" ? 1 : Math.max(1, Math.ceil((history.data?.total ?? 0) / Number(pageSize))), - rows, - editingCard, - setEditingCard, - operation, - setOperation, - deleting, - setDeleting, - notFound: apiErrorCode(card.error) === "BANK_NOT_FOUND", - setPeriod: (value: [string | null, string | null]) => { - if (value[0] && value[1]) - setParams({ from: value[0], to: value[1], limit: pageSize }); - }, - setPageSize: (value: string | null) => { - if (value && PAGE_SIZES.includes(value)) - setParams({ ...period, limit: value }); - }, - setPage: (value: number) => - setParams({ ...period, page: String(value), limit: pageSize }), }; } -type CardPageVm = ReturnType; export { type CardPageVm, useCardPageVm }; diff --git a/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx b/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx index 914bb96..dcc2f1e 100644 --- a/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx +++ b/src/pages/card/ui/DeleteOperation/DeleteOperation.test.tsx @@ -1,37 +1,42 @@ import { MantineProvider } from "@mantine/core"; import { QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; + import type { BankOperation } from "@/entities/bank-card"; + import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; + import { DeleteOperation } from "./DeleteOperation"; -const transfer: BankOperation = { - id: "transfer", - kind: "transfer", - cardId: "a", - cardName: "Основная", - targetCardId: "b", - targetCardName: "Запасная", - categoryId: null, - categoryName: null, - originalExpenseId: null, - amount: { currency: "RUB", value: "100" }, - availableRefund: null, - date: "2026-08-01", - description: null, - version: 1, - createdAt: "2026-08-01T00:00:00Z", -}; +const transfer: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + amount: { currency: "RUB", value: "100" }, + availableRefund: null, + cardId: "a", + cardName: "Основная", + categoryId: null, + categoryName: null, + createdAt: "2026-08-01T00:00:00Z", + date: "2026-08-01", + description: null, + id: "transfer", + kind: "transfer", + originalExpenseId: null, + targetCardId: "b", + targetCardName: "Запасная", + version: 1, + } +/* eslint-enable unicorn/no-null */); describe("DeleteOperation", () => { it("names both sides and requires a deliberate fresh version after conflict", async () => { configureAuthClient({ accessToken: () => "test", - refresh: async () => false, clear: () => {}, + refresh: () => Promise.resolve(false), }); const versions: string[] = []; server.use( @@ -53,7 +58,7 @@ describe("DeleteOperation", () => { render( - + , ); diff --git a/src/pages/card/ui/DeleteOperation/DeleteOperation.tsx b/src/pages/card/ui/DeleteOperation/DeleteOperation.tsx index 4e59973..ab9c8d4 100644 --- a/src/pages/card/ui/DeleteOperation/DeleteOperation.tsx +++ b/src/pages/card/ui/DeleteOperation/DeleteOperation.tsx @@ -1,6 +1,7 @@ import { Button, Group, Modal, Stack, Text } from "@mantine/core"; import { type FC, useState } from "react"; import { useTranslation } from "react-i18next"; + import { BankFeedback, type BankOperation, @@ -10,7 +11,7 @@ import { } from "@/entities/bank-card"; import { formatDate, formatMoney } from "@/shared/lib"; -type DeleteOperationProps = { operation: BankOperation; onClose: () => void }; +type DeleteOperationProps = { onClose: () => void; operation: BankOperation }; const DeleteOperation: FC = (props) => { useBankFormFocus(); const [opened, setOpened] = useState(true); @@ -19,20 +20,20 @@ const DeleteOperation: FC = (props) => { const [latest, setLatest] = useState(); const [error, setError] = useState(); const query = useBankOperation(record.id); - const { deleteOperation } = useBankMutations(); - const pending = - !opened || - deleteOperation.isPending || - (query.isFetching && Boolean(error)); + const { deleteOperation: operationDeletion } = useBankMutations(); + const isPending + = !opened + || operationDeletion.isPending + || (query.isFetching && Boolean(error)); return ( !isPending && setOpened(false)} onExitTransitionEnd={props.onClose} - onClose={() => !pending && setOpened(false)} + opened={opened} title={t("bank.deleteTitle")} - closeOnEscape={!pending} - closeOnClickOutside={!pending} - closeButtonProps={{ "aria-label": t("bank.close"), disabled: pending }} > @@ -43,27 +44,27 @@ const DeleteOperation: FC = (props) => { target: record.targetCardName, }) : t("bank.deleteHint", { - kind: t(`bank.kinds.${record.kind}`), amount: formatMoney(record.amount), card: record.cardName, + kind: t(`bank.kinds.${record.kind}`), })} - {error ? ( + {!!(error) && (( <> - ) : null} + ))} {latest && ( <> @@ -72,13 +73,13 @@ const DeleteOperation: FC = (props) => { })} @@ -87,26 +88,27 @@ const DeleteOperation: FC = (props) => { diff --git a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx index 181a5b0..8ecbfea 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.stories.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.stories.tsx @@ -1,74 +1,36 @@ -import { Alert } from "@mantine/core"; import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { Alert } from "@mantine/core"; import { type FC, useState } from "react"; import { FormProvider, useForm, useWatch } from "react-hook-form"; + import type { OperationValues } from "./OperationForm.types"; + import { OperationFormView } from "./OperationForm.view"; -type ExampleProps = { pending?: boolean; conflict?: boolean }; +type ExampleProps = { conflict?: boolean; pending?: boolean }; const OperationExample: FC = (props) => { const [opened, setOpened] = useState(true); const [action, setAction] = useState(""); const methods = useForm({ defaultValues: { - kind: "refund", - cardId: "demo", - targetCardId: "", - categoryId: "", - originalExpenseId: "purchase", amount: "500", + cardId: "demo", + categoryId: "", date: "2026-08-01", description: "Синтетический возврат", + kind: "refund", + originalExpenseId: "purchase", + targetCardId: "", }, }); const kind = useWatch({ control: methods.control, name: "kind" }); return ( !props.pending && setOpened(false)} - onCancel={() => setOpened(false)} - onExitTransitionEnd={() => setAction("Форма закрыта")} - onSubmit={(event) => { - event.preventDefault(); - setAction("Локальный submit"); - }} - fields={{ - editing: true, - refundCardId: "demo", - kind, - cards: [{ value: "demo", label: "Учебная карта" }], - targets: [], - categories: [], - defaultCategory: "", - kinds: [{ value: "refund", label: "Возврат" }], - dateInputValue: () => "2026-08-01", - validDate: (value) => value === "2026-08-01", - maxDate: () => "2026-08-27", - }} - refund={{ - editing: true, - expenses: [ - { - value: "purchase", - label: "01.07.2026 · Учебная покупка · 500 ₽", - }, - ], - onSelect: () => undefined, - limit: { currency: "RUB", value: "500" }, - }} - loadingExpenses={false} + editing emptyExpenses={false} - sourcePage={1} - previousDisabled - nextDisabled - onPrevious={() => undefined} - onNext={() => undefined} - onAddCategory={() => undefined} - saveDisabled={false} feedback={ props.conflict && ( @@ -76,27 +38,66 @@ const OperationExample: FC = (props) => { ) } - onReload={ - props.conflict ? () => setAction("Локальный reload") : undefined - } + fields={{ + cards: [{ label: "Учебная карта", value: "demo" }], + categories: [], + dateInputValue: () => "2026-08-01", + defaultCategory: "", + editing: true, + kind, + kinds: [{ label: "Возврат", value: "refund" }], + maxDate: () => "2026-08-27", + refundCardId: "demo", + targets: [], + validDate: value => value === "2026-08-01", + }} latest={ - props.conflict - ? { - version: 2, + (props.conflict) + ? ({ details: "Учебная версия", onAdopt: () => setAction("Локальное принятие версии"), - } + version: 2, + }) : undefined } + loadingExpenses={false} + nextDisabled + onAddCategory={() => {}} + onCancel={() => setOpened(false)} + onClose={() => !props.pending && setOpened(false)} + onExitTransitionEnd={() => setAction("Форма закрыта")} + onNext={() => {}} + onPrevious={() => {}} + onReload={ + (props.conflict) ? () => setAction("Локальный reload") : undefined + } + onSubmit={(event) => { + event.preventDefault(); + setAction("Локальный submit"); + }} + opened={opened} + pending={Boolean(props.pending)} + previousDisabled + refund={{ + editing: true, + expenses: [ + { + label: "01.07.2026 · Учебная покупка · 500 ₽", + value: "purchase", + }, + ], + limit: { currency: "RUB", value: "500" }, + onSelect: () => {}, + }} + saveDisabled={false} + sourcePage={1} /> {action} ); }; const meta = { - title: "Product/OperationForm", component: OperationExample, - tags: ["autodocs"], parameters: { docs: { description: { @@ -105,6 +106,8 @@ const meta = { }, }, }, + tags: ["autodocs"], + title: "Product/OperationForm", } satisfies Meta; export default meta; type Story = StoryObj; diff --git a/src/pages/card/ui/OperationForm/OperationForm.test.tsx b/src/pages/card/ui/OperationForm/OperationForm.test.tsx index 342fecf..332ac9f 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.test.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.test.tsx @@ -7,37 +7,46 @@ import { waitFor, within, } from "@testing-library/react"; -import { HttpResponse, http } from "msw"; +import { http, HttpResponse } from "msw"; import { describe, expect, it, vi } from "vitest"; + import type { BankCard, BankOperation } from "@/entities/bank-card"; + import { configureAuthClient } from "@/shared/api"; import { createTestQueryClient } from "@/test/query"; import { server } from "@/test/server"; + import { OperationForm } from "./OperationForm"; const money = (value: string) => ({ currency: "RUB", value }); -const card: BankCard = { - id: "a", - name: "Повседневная", - bankName: null, - lastFour: null, - paymentSystem: null, - balance: money("0"), - openingBalance: money("0"), - openingDate: "2026-01-01", - archivedAt: null, - version: 1, -}; +const card: BankCard = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + archivedAt: null, + balance: money("0"), + bankName: null, + id: "a", + lastFour: null, + name: "Повседневная", + openingBalance: money("0"), + openingDate: "2026-01-01", + paymentSystem: null, + version: 1, + } +/* eslint-enable unicorn/no-null */); +async function select(label: RegExp, value: RegExp | string) { + fireEvent.click(screen.getByRole("combobox", { name: label })); + fireEvent.click(await screen.findByRole("option", { name: value })); +} function setup(operation?: BankOperation) { configureAuthClient({ accessToken: () => "test", - refresh: async () => false, clear: () => {}, + refresh: () => Promise.resolve(false), }); server.use( http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([ - { id: "default", name: "Остальное", isDefault: true, version: 2 }, + { id: "default", isDefault: true, name: "Остальное", version: 2 }, ]), ), ); @@ -46,35 +55,28 @@ function setup(operation?: BankOperation) { , ); return close; } -async function select(label: string, value: string | RegExp) { - fireEvent.click(screen.getByRole("combobox", { name: new RegExp(label) })); - fireEvent.click(await screen.findByRole("option", { name: value })); -} describe("OperationForm", () => { it("blocks closing and editing while save is pending, then closes after success", async () => { - let finish: () => void = () => {}; - const response = new Promise((resolve) => { - finish = resolve; - }); + const { promise: response, resolve: finish } = Promise.withResolvers(); const received = vi.fn(); server.use( http.post("*/api/v1/bank-cards/operations", async () => { @@ -101,7 +103,8 @@ describe("OperationForm", () => { fireEvent.keyDown(document, { key: "Escape" }); expect(close).not.toHaveBeenCalled(); expect(screen.getByRole("dialog")).toBeInTheDocument(); - } finally { + } + finally { finish(); } await waitFor(() => expect(close).toHaveBeenCalledOnce()); @@ -132,16 +135,16 @@ describe("OperationForm", () => { fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(close).toHaveBeenCalled()); expect(body).toMatchObject({ - kind: "expense", + amount: money("92233720368547758.07"), categoryId: "default", date: "2026-08-07", - amount: money("92233720368547758.07"), + kind: "expense", }); }); it("checks both transfer opening dates, excludes archive and reuses intent after a lost response", async () => { setup(); - await select("Вид операции", "Перевод"); - await select("Карта-получатель", "Запасная"); + await select(/Вид операции/, "Перевод"); + await select(/Карта-получатель/, "Запасная"); fireEvent.change(screen.getByLabelText(/Сумма/), { target: { value: "0" }, }); @@ -176,15 +179,15 @@ describe("OperationForm", () => { await waitFor(() => expect(bodies).toHaveLength(2)); expect(bodies[0]).toEqual(bodies[1]); expect(bodies[0]).toMatchObject({ - kind: "transfer", - cardId: "a", - targetCardId: "b", amount: money("150.01"), + cardId: "a", + kind: "transfer", + targetCardId: "b", }); }); it("topup omits categories and preserves entered description", async () => { setup(); - await select("Вид операции", "Пополнение"); + await select(/Вид операции/, "Пополнение"); expect( screen.queryByRole("combobox", { name: /Категория/ }), ).not.toBeInTheDocument(); @@ -204,40 +207,42 @@ describe("OperationForm", () => { fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(body).toBeDefined()); expect(body).toMatchObject({ - kind: "topup", amount: money("0.10"), description: "Внесение", + kind: "topup", }); expect(body).not.toHaveProperty("categoryId"); }); it("finds a previous-month purchase across pages, checks remaining refund and retains the complete-refund intent", async () => { - const purchase: BankOperation = { - id: "purchase", - kind: "expense", - cardId: "a", - cardName: card.name, - targetCardId: null, - targetCardName: null, - categoryId: "default", - categoryName: "Остальное", - originalExpenseId: null, - amount: money("500"), - availableRefund: money("500"), - date: "2026-07-01", - description: "Прошлая покупка", - version: 1, - createdAt: "2026-07-01T00:00:00Z", - }; + const purchase: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + amount: money("500"), + availableRefund: money("500"), + cardId: "a", + cardName: card.name, + categoryId: "default", + categoryName: "Остальное", + createdAt: "2026-07-01T00:00:00Z", + date: "2026-07-01", + description: "Прошлая покупка", + id: "purchase", + kind: "expense", + originalExpenseId: null, + targetCardId: null, + targetCardName: null, + version: 1, + } + /* eslint-enable unicorn/no-null */); server.use( http.get("*/api/v1/bank-cards/a/expenses", ({ request }) => { return HttpResponse.json({ + hasNext: new URL(request.url).searchParams.get("page") !== "2", items: new URL(request.url).searchParams.get("page") === "2" ? [purchase] : [], page: 1, total: 1, - hasNext: new URL(request.url).searchParams.get("page") !== "2", }); }), http.get("*/api/v1/bank-cards/operations/purchase", () => @@ -245,7 +250,7 @@ describe("OperationForm", () => { ), ); const close = setup(); - await select("Вид операции", "Возврат"); + await select(/Вид операции/, "Возврат"); await waitFor(() => expect(screen.getByRole("button", { name: "Далее" })).toBeEnabled(), ); @@ -253,7 +258,7 @@ describe("OperationForm", () => { await waitFor(() => expect(screen.getByRole("button", { name: "Назад" })).toBeEnabled(), ); - await select("Исходная покупка", /Прошлая покупка/); + await select(/Исходная покупка/, /Прошлая покупка/); await screen.findByText("Доступно к возврату: 500 ₽"); fireEvent.change(screen.getByLabelText(/Сумма/), { target: { value: "500.01" }, @@ -284,43 +289,45 @@ describe("OperationForm", () => { await waitFor(() => expect(close).toHaveBeenCalled()); expect(bodies[0]).toEqual(bodies[1]); expect(bodies[0]).toMatchObject({ + date: "2026-08-01", kind: "refund", originalExpenseId: "purchase", - date: "2026-08-01", }); }); it("edits a full refund with its own amount excluded from the limit and immutable purchase", async () => { - const refund: BankOperation = { - id: "refund", - kind: "refund", - cardId: "a", - cardName: card.name, - targetCardId: null, - targetCardName: null, - categoryId: "default", - categoryName: "Остальное", - originalExpenseId: "purchase", - amount: money("500"), - availableRefund: money("500"), - date: "2026-08-01", - description: "", - version: 2, - createdAt: "2026-08-01T00:00:00Z", - }; + const refund: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + amount: money("500"), + availableRefund: money("500"), + cardId: "a", + cardName: card.name, + categoryId: "default", + categoryName: "Остальное", + createdAt: "2026-08-01T00:00:00Z", + date: "2026-08-01", + description: "", + id: "refund", + kind: "refund", + originalExpenseId: "purchase", + targetCardId: null, + targetCardName: null, + version: 2, + } + /* eslint-enable unicorn/no-null */); const purchase = { ...refund, + availableRefund: money("0"), + date: "2026-07-01", id: "purchase", kind: "expense", - date: "2026-07-01", - availableRefund: money("0"), }; server.use( http.get("*/api/v1/bank-cards/a/expenses", () => HttpResponse.json({ + hasNext: false, items: [purchase], page: 1, total: 1, - hasNext: false, }), ), http.get("*/api/v1/bank-cards/operations/purchase", () => @@ -357,39 +364,41 @@ describe("OperationForm", () => { fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(close).toHaveBeenCalled()); expect(body).toMatchObject({ - version: 2, amount: money("500"), - originalExpenseId: "purchase", description: "Полностью возвращено", + originalExpenseId: "purchase", + version: 2, }); }); it("keeps edit draft and stale version until explicit adoption after conflict", async () => { - const operation: BankOperation = { - id: "expense", - kind: "expense", - cardId: "a", - cardName: card.name, - targetCardId: null, - targetCardName: null, - categoryId: "default", - categoryName: "Остальное", - originalExpenseId: null, - amount: money("100"), - availableRefund: money("100"), - date: "2026-08-01", - description: "Первое", - version: 1, - createdAt: "2026-08-01T00:00:00Z", - }; + const operation: BankOperation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + { + amount: money("100"), + availableRefund: money("100"), + cardId: "a", + cardName: card.name, + categoryId: "default", + categoryName: "Остальное", + createdAt: "2026-08-01T00:00:00Z", + date: "2026-08-01", + description: "Первое", + id: "expense", + kind: "expense", + originalExpenseId: null, + targetCardId: null, + targetCardName: null, + version: 1, + } + /* eslint-enable unicorn/no-null */); let readVersion = 1; const bodies: Record[] = []; server.use( http.get("*/api/v1/bank-cards/operations/expense", () => HttpResponse.json({ ...operation, - version: readVersion, description: readVersion === 1 ? "Первое" : "Другое исправление", + version: readVersion, }), ), http.patch( @@ -434,9 +443,9 @@ describe("OperationForm", () => { fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(close).toHaveBeenCalled()); expect(bodies[2]).toMatchObject({ - version: 2, - description: "Мой черновик", amount: money("150"), + description: "Мой черновик", + version: 2, }); }); it("creates a category inside expense without losing operation fields", async () => { @@ -452,20 +461,20 @@ describe("OperationForm", () => { fireEvent.change(within(dialog).getByLabelText(/Название категории/), { target: { value: " кафе " }, }); - let created = false; + let isCreated = false; server.use( http.post("*/api/v1/bank-cards/categories", () => { - created = true; + isCreated = true; return HttpResponse.json( - { id: "new", name: "кафе", version: 1, isDefault: false }, + { id: "new", isDefault: false, name: "кафе", version: 1 }, { status: 201 }, ); }), http.get("*/api/v1/bank-cards/categories", () => HttpResponse.json([ - { id: "default", name: "Остальное", isDefault: true, version: 2 }, - ...(created - ? [{ id: "new", name: "кафе", isDefault: false, version: 1 }] + { id: "default", isDefault: true, name: "Остальное", version: 2 }, + ...(isCreated + ? [{ id: "new", isDefault: false, name: "кафе", version: 1 }] : []), ]), ), @@ -495,9 +504,9 @@ describe("OperationForm", () => { fireEvent.click(screen.getByRole("button", { name: "Сохранить" })); await waitFor(() => expect(body).toBeDefined()); expect(body).toMatchObject({ + amount: money("1.20"), categoryId: "new", description: "Черновик расхода", - amount: money("1.20"), }); }); }); diff --git a/src/pages/card/ui/OperationForm/OperationForm.tsx b/src/pages/card/ui/OperationForm/OperationForm.tsx index 44adeb0..251e6c2 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.tsx @@ -1,7 +1,11 @@ import type { FC } from "react"; + import { FormProvider } from "react-hook-form"; + import { BankFeedback, CategoryManager } from "@/entities/bank-card"; + import type { OperationFormProps } from "./OperationForm.types"; + import { OperationFormView } from "./OperationForm.view"; import { useOperationFormVm } from "./OperationForm.vm"; @@ -12,34 +16,35 @@ const OperationForm: FC = (props) => { - {vm.expensesError && ( - - )}{" "} - {vm.sourceError && ( - - )} - - } categoriesFeedback={ vm.categoriesError && ( ) } - feedback={vm.error ? : null} + feedback={(vm.error) ? () : undefined} + refundFeedback={( + <> + {vm.expensesError && ( + + )} + {" "} + {vm.sourceError && ( + + )} + + )} /> {vm.categoriesOpen && ( diff --git a/src/pages/card/ui/OperationForm/OperationForm.types.ts b/src/pages/card/ui/OperationForm/OperationForm.types.ts index 6cadc82..09ba82e 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.types.ts +++ b/src/pages/card/ui/OperationForm/OperationForm.types.ts @@ -1,21 +1,21 @@ import type { BankCard, BankOperation } from "@/entities/bank-card"; -type OperationValues = { - kind: BankOperation["kind"]; - cardId: string; - targetCardId: string; - categoryId: string; - originalExpenseId: string; - amount: string; - date: string; - description: string; -}; - type OperationFormProps = { card: BankCard; cards: BankCard[]; - operation?: BankOperation; onClose: () => void; + operation?: BankOperation; +}; + +type OperationValues = { + amount: string; + cardId: string; + categoryId: string; + date: string; + description: string; + kind: BankOperation["kind"]; + originalExpenseId: string; + targetCardId: string; }; export type { OperationFormProps, OperationValues }; diff --git a/src/pages/card/ui/OperationForm/OperationForm.view.tsx b/src/pages/card/ui/OperationForm/OperationForm.view.tsx index 28cb5d0..dcf3e46 100644 --- a/src/pages/card/ui/OperationForm/OperationForm.view.tsx +++ b/src/pages/card/ui/OperationForm/OperationForm.view.tsx @@ -1,3 +1,5 @@ +import type { FC, ReactNode, SubmitEventHandler } from "react"; + import { Button, Group, @@ -9,35 +11,38 @@ import { TextInput, } from "@mantine/core"; import { DateInput } from "@mantine/dates"; -import type { FC, FormEventHandler, ReactNode } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; + import type { BankOperation } from "@/entities/bank-card"; + import { formatMoney } from "@/shared/lib"; -import classes from "./OperationForm.module.css"; + import type { OperationValues } from "./OperationForm.types"; -type Choice = { value: string; label: string }; +import classes from "./OperationForm.module.css"; + +type Choice = { label: string; value: string }; type OperationFieldsProps = { - editing: boolean; - refundCardId: string; - kind: OperationValues["kind"]; cards: Choice[]; - targets: Choice[]; categories: Choice[]; - defaultCategory: string; - kinds: Choice[]; dateInputValue: (value: string) => string; - validDate: (value: string) => boolean; + defaultCategory: string; + editing: boolean; + kind: OperationValues["kind"]; + kinds: Choice[]; maxDate: () => string; + refundCardId: string; + targets: Choice[]; + validDate: (value: string) => boolean; }; const OperationFields: FC = (props) => { const { t } = useTranslation(); const { control, + formState: { errors, isSubmitting }, register, setValue, - formState: { errors, isSubmitting }, } = useFormContext(); return ( @@ -46,9 +51,9 @@ const OperationFields: FC = (props) => { name="kind" render={({ field }) => ( = (props) => { name="targetCardId" render={({ field }) => ( )} /> @@ -158,8 +161,8 @@ const OperationFields: FC = (props) => { type RefundFieldsProps = { editing: boolean; expenses: Choice[]; - onSelect: (id: string) => void; limit: BankOperation["availableRefund"] | undefined; + onSelect: (id: string) => void; }; const RefundFields: FC = (props) => { const { t } = useTranslation(); @@ -174,16 +177,16 @@ const RefundFields: FC = (props) => { name="originalExpenseId" render={({ field }) => ( + ); +}; + +export { AccountScopeSwitch }; diff --git a/src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx b/src/app/ui/AppBreadcrumbs/AccountScopeSwitch/AccountScopeSwitch.vm.ts similarity index 53% rename from src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx rename to src/app/ui/AppBreadcrumbs/AccountScopeSwitch/AccountScopeSwitch.vm.ts index ef30107..3c003f1 100644 --- a/src/app/ui/AppBreadcrumbs/AccountScopeSwitch.tsx +++ b/src/app/ui/AppBreadcrumbs/AccountScopeSwitch/AccountScopeSwitch.vm.ts @@ -1,6 +1,3 @@ -import type { FC } from "react"; - -import { Select } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router"; @@ -8,12 +5,13 @@ import { useBrokerageAccounts } from "@/entities/brokerage-account"; type AccountScopeSwitchProps = { accountId: string; section?: string }; -const AccountScopeSwitch: FC = (props) => { +function useAccountScopeSwitchVm(props: AccountScopeSwitchProps) { const { accountId, section } = props; const { data: accounts } = useBrokerageAccounts(); const { t } = useTranslation(); const navigate = useNavigate(); const { search } = useLocation(); + const data = [ { label: t("accounts.all"), value: "all" }, ...(accounts ?? []).map(account => ({ @@ -21,27 +19,23 @@ const AccountScopeSwitch: FC = (props) => { value: account.id, })), ]; + if (data.every(item => item.value !== accountId)) data.push({ label: t("breadcrumbs.accountFallback"), value: accountId }); - return ( - ({ - label: t(`bank.paymentSystems.${value}`), - value, - }))} - error={errors.paymentSystem?.message} - label={t("bank.paymentSystem")} - onChange={value => field.onChange(value ?? "")} - placeholder={t("bank.paymentSystemUnknown")} - value={field.value || /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null} - /> - )} + + = (props) => { value={props.currency} w={112} /> - ))} + )} + {t( props.unavailable @@ -56,32 +84,36 @@ const PortfolioStructureView: FC = (props) => { : "portfolio.structure.basis", )} - {!!(props.incomplete) && (( + + {!!props.incomplete && ( {t("portfolio.structure.incomplete")} + {props.unvalued.map(message => ( {message} ))} - ))} - {!!(props.negative) && (( + )} + + {!!props.negative && ( {t("portfolio.structure.negative")} - ))} - {!!(!props.unavailable + )} + + {!!( + !props.unavailable && props.chartData.length === 0 && !props.incomplete - && !props.negative) && (( + && !props.negative + ) && ( {t("portfolio.structure.empty")} - ))} -
    0 ? classes.layout : ""}`} - key={props.currency} - > - {props.chartData.length > 0 && (( + )} + +
    + {props.chartData.length > 0 && ( = (props) => { strokeWidth={2} thickness={32} tooltipDataSource="segment" - tooltipProps={{ - content: ({ active, payload }) => { - const row = props.chartData.find( - item => item.name === payload?.[0]?.name, - ); - return active && row - ? ( - - - {row.name} - - - {row.amount} - {" "} - · - {" "} - {row.share} - - - ) - : undefined; - }, - }} + tooltipProps={tooltipProps} /> - ))} - {props.rows.length > 0 && (( + )} + + {props.rows.length > 0 && (
      = (props) => { bg={row.color} className={classes.marker} /> + {row.name} +
      {row.amount} + {row.share} @@ -145,7 +159,7 @@ const PortfolioStructureView: FC = (props) => { ))}
    - ))} + )}
    ); diff --git a/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.vm.ts b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.vm.ts new file mode 100644 index 0000000..4b92c67 --- /dev/null +++ b/src/pages/account/ui/AccountPage/components/PortfolioStructure/PortfolioStructure.vm.ts @@ -0,0 +1,108 @@ +import type { TFunction } from "i18next"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { Portfolio } from "@/entities/portfolio"; + +import { formatMoney } from "@/shared/lib"; + +const MIN_VISIBLE_SHARE_PERCENT = 0.01; + +// A cached snapshot or an older running server can predate this response field. +type PortfolioStructureProps = { structure?: null | Portfolio["structure"] }; + +const categoryPresentation = { + BOND: { color: "grape.6", label: "portfolio.bonds" }, + CASH: { color: "green.6", label: "portfolio.structure.cash" }, + OTHER: { color: "orange.6", label: "portfolio.structure.other" }, + STOCK: { color: "blue.6", label: "portfolio.stocks" }, +} as const; + +const usePortfolioStructureVm = (props: PortfolioStructureProps) => { + const { structure } = props; + const { t } = useTranslation(); + const [currency, setCurrency] = useState(null); + + const selected + = structure?.currencies.find(item => item.currency === currency) + ?? structure?.currencies[0]; + + const isIncomplete = (structure?.unvaluedCategories.length ?? 0) > 0; + const rows = portfolioRows(selected, structure, t); + const chartData = rows.filter(row => row.value > 0); + + return { + chartData, + currencies: structure?.currencies.map(item => item.currency) ?? [], + // eslint-disable-next-line unicorn/no-null -- Mantine clears the controlled currency with null. + currency: selected?.currency ?? null, + incomplete: isIncomplete, + negative: selected?.hasNegativeValues ?? false, + onCurrencyChange: setCurrency, + rows, + unavailable: !structure, + unvalued: + structure?.unvaluedCategories.map(category => + t("portfolio.structure.unvaluedCategory", { + category: t(categoryPresentation[category.type].label), + count: category.count, + }), + ) ?? [], + }; +}; + +function portfolioRows( + selected: + NonNullable["currencies"][number] | undefined, + structure: PortfolioStructureProps["structure"], + t: TFunction, +) { + if (!selected) return []; + + return selected.categories.map((category) => { + const presentation = categoryPresentation[category.type]; + + const percentage + = category.sharePercent === null + ? undefined + : Number(category.sharePercent); + + const unknown = structure?.unvaluedCategories.some( + item => item.type === category.type, + ); + + let amount = formatMoney({ + currency: selected.currency, + value: category.value, + }); + + if (unknown) { + amount + = category.value === "0" + ? t("portfolio.structure.unvalued") + : t("portfolio.structure.knownAmount", { amount }); + } + + let share = t("portfolio.structure.shareUnavailable"); + + if (percentage !== undefined) { + if (percentage > 0 && percentage < MIN_VISIBLE_SHARE_PERCENT) + share = t("portfolio.structure.smallShare"); + else + share = `${new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: 2, + }).format(percentage)} %`; + } + + return { + amount, + color: presentation.color, + name: t(presentation.label), + share, + value: percentage ?? 0, + }; + }); +} + +export { type PortfolioStructureProps, usePortfolioStructureVm }; diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx index dffb2c9..c3ed223 100644 --- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.stories.tsx @@ -4,24 +4,25 @@ import type { AccountPagePosition } from "../../AccountPage.vm"; import { PositionCard } from "./PositionCard.view"; -const syntheticPosition = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ -{ - absoluteReturn: { currency: "RUB", value: "5000" }, - averagePurchasePrice: { currency: "RUB", value: "200" }, - currentValue: { currency: "RUB", value: "25000" }, - displayName: "Учебные технологии", - figi: null, - isin: null, - logoName: null, - logoSrc: null, - quantity: "100", - returnText: "+5 000 ₽", - returnTone: "positive", - shareText: "20%", - ticker: "DEMO", - type: "STOCK", -} -/* eslint-enable unicorn/no-null */) satisfies AccountPagePosition; +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +const syntheticPosition + = { + absoluteReturn: { currency: "RUB", value: "5000" }, + averagePurchasePrice: { currency: "RUB", value: "200" }, + currentValue: { currency: "RUB", value: "25000" }, + displayName: "Учебные технологии", + figi: null, + isin: null, + logoName: null, + logoSrc: null, + quantity: "100", + returnText: "+5 000 ₽", + returnTone: "positive", + shareText: "20%", + ticker: "DEMO", + type: "STOCK", + } satisfies /* eslint-enable unicorn/no-null */ AccountPagePosition; const meta = { args: { kind: "stocks", position: syntheticPosition }, @@ -61,8 +62,10 @@ export const StockGain: Story = { }, }; -export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +export const BondLoss: Story + = { args: { kind: "bonds", position: { @@ -70,7 +73,8 @@ export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture pre absoluteReturn: { currency: "RUB", value: "-500" }, averagePurchasePrice: { currency: "RUB", value: "1000" }, currentValue: { currency: "RUB", value: "9500" }, - displayName: "Учебные облигации для долгосрочных накоплений, выпуск 2030", + displayName: + "Учебные облигации для долгосрочных накоплений, выпуск 2030", isin: "DEMO-BOND-2030", quantity: "10", returnText: "−500 ₽", @@ -85,15 +89,17 @@ export const BondLoss: Story = (/* eslint-disable unicorn/no-null -- Fixture pre docs: { description: { story: - "Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.", + "Для облигации используется идентификатор выпуска. Длинное имя и отрицательная сумма позволяют проверить компактное представление на узком экране.", }, }, }, - } -/* eslint-enable unicorn/no-null */); + }; +/* eslint-enable unicorn/no-null */ -export const Other: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +export const Other: Story + = { args: { kind: "other", position: { @@ -109,15 +115,17 @@ export const Other: Story = (/* eslint-disable unicorn/no-null -- Fixture preser docs: { description: { story: - "Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.", + "Компактный вариант для остальных инструментов сохраняет количество, стоимость и пояснение результата без оформления акции или облигации.", }, }, }, - } -/* eslint-enable unicorn/no-null */); + }; +/* eslint-enable unicorn/no-null */ -export const MissingValuation: Story = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +export const MissingValuation: Story + = { args: { position: { ...syntheticPosition, @@ -136,9 +144,9 @@ export const MissingValuation: Story = (/* eslint-disable unicorn/no-null -- Fix docs: { description: { story: - "При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.", + "При отсутствии оценки остаются название и количество. Неизвестная стоимость, доля и результат не изображаются нулевыми значениями.", }, }, }, - } -/* eslint-enable unicorn/no-null */); + }; +/* eslint-enable unicorn/no-null */ diff --git a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx index 3087567..ec8beab 100644 --- a/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionCard/PositionCard.view.tsx @@ -23,20 +23,26 @@ const PositionCard: FC = (props) => { const { kind, position } = props; const { t } = useTranslation(); const isExpanded = kind !== "other"; + const returnClass = position.returnTone ? classes[position.returnTone] : undefined; + let returnText = position.returnText; - if (returnText && !isExpanded) returnText = t("portfolio.return", { value: returnText }); + if (returnText && !isExpanded) + returnText = t("portfolio.return", { value: returnText }); const instrumentTo = position.instrumentTo; + const identifier = kind === "stocks" ? (position.ticker ?? position.isin) : position.isin; + const identifierText = [ t("portfolio.quantity", { value: position.quantity }), identifier, ] .filter(Boolean) .join(" · "); + const accountLink = position.account ? ( = (props) => { ) : undefined; + const secondaryText = [ position.averagePurchasePrice ? t("portfolio.avgPrice", { value: formatMoney(position.averagePurchasePrice), }) - : /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null, + : null, // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. position.shareText ? t("portfolio.share", { value: position.shareText }) - : /* eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. */ null, + : null, // eslint-disable-line unicorn/no-null -- Mantine represents an empty controlled field as null. ] .filter(Boolean) .join(" · "); @@ -71,6 +78,7 @@ const PositionCard: FC = (props) => { src={position.logoSrc} to={instrumentTo} /> + {instrumentTo ? ( @@ -83,31 +91,37 @@ const PositionCard: FC = (props) => { {position.displayName} )} + {accountLink} + {identifierText} + - {!!(position.currentValue) && (( + {!!position.currentValue && ( {formatMoney(position.currentValue)} - ))} - {!!(returnText) && (( + )} + + {!!returnText && ( {returnText} - ))} + )}
    - {!!(secondaryText) && (( + + {!!secondaryText && ( {secondaryText} - ))} + )} ); + return ( @@ -115,45 +129,52 @@ const PositionCard: FC = (props) => { {position.displayName} + {accountLink} - {!!(position.isin) && (( + + {!!position.isin && ( {position.isin} - ))} + )} + {t("portfolio.quantity", { value: position.quantity })}
    + - {!!(position.currentValue) && (( + {!!position.currentValue && ( {formatMoney(position.currentValue)} - ))} - {!!(returnText) && (( + )} + + {!!returnText && ( {returnText} - ))} + )}
    - {!!(position.averagePurchasePrice || position.shareText) && (( + + {!!(position.averagePurchasePrice || position.shareText) && ( - {!!(position.averagePurchasePrice) && (( + {!!position.averagePurchasePrice && ( {t("portfolio.avgPrice", { value: formatMoney(position.averagePurchasePrice), })} - ))} - {!!(position.shareText) && (( + )} + + {!!position.shareText && ( {t("portfolio.share", { value: position.shareText })} - ))} + )} - ))} + )} ); }; diff --git a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx index 076f503..bbccb48 100644 --- a/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionGroupCard.view.tsx @@ -24,17 +24,19 @@ const PositionGroupCard: FC = (props) => { const { t } = useTranslation(); const identity = group.positions[0]; const instrumentTo = identity.instrumentTo; + return ( - {kind !== "other" && (( + {kind !== "other" && ( - ))} + )} + {instrumentTo ? ( @@ -45,72 +47,80 @@ const PositionGroupCard: FC = (props) => { : ( {identity.displayName} )} + {identity.ticker ?? identity.isin ?? identity.figi} + - {group.positions.map(position => ( - - - {!!(position.account) && (( - - {position.account.name} - - ))} - - {t("portfolio.quantity", { value: position.quantity })} - - - - {!!(position.currentValue) && (( - {formatMoney(position.currentValue)} - ))} - {!!(position.returnText) && (( - - {kind === "other" - ? t("portfolio.return", { value: position.returnText }) - : position.returnText} + {group.positions.map((position) => { + const appearanceClass = position.returnTone + ? classes[position.returnTone] + : undefined; + + return ( + + + {!!position.account && ( + + {position.account.name} + + )} + + + {t("portfolio.quantity", { value: position.quantity })} - ))} - - - {!!(position.averagePurchasePrice) && (( - - {t("portfolio.avgPrice", { - value: formatMoney(position.averagePurchasePrice), - })} - - ))} - {!!(position.shareText) && (( - - {t("portfolio.share", { value: position.shareText })} - - ))} - - - ))} + + + + {!!position.currentValue && ( + {formatMoney(position.currentValue)} + )} + + {!!position.returnText && ( + + {kind === "other" + ? t("portfolio.return", { value: position.returnText }) + : position.returnText} + + )} + + + + {!!position.averagePurchasePrice && ( + + {t("portfolio.avgPrice", { + value: formatMoney(position.averagePurchasePrice), + })} + + )} + + {!!position.shareText && ( + + {t("portfolio.share", { value: position.shareText })} + + )} + + + ); + })} ); diff --git a/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx b/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx index 19cbdce..3ac1b0c 100644 --- a/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionLogo.view.tsx @@ -1,9 +1,10 @@ import type { FC } from "react"; import { Anchor, Avatar } from "@mantine/core"; -import { useState } from "react"; import { Link } from "react-router"; +import { useImageFallback } from "@/shared/lib"; + type PositionLogoProps = { label: string; src: null | string; @@ -12,41 +13,36 @@ type PositionLogoProps = { const PositionLogo: FC = (props) => { const { label, src } = props; - const [failed, setFailed] = useState(false); + const { failed, onError } = useImageFallback(); const logo = src && !failed ? ( - setFailed(true)} - radius="md" - size={40} - src={src} - /> + ) : ( {(label.trim()[0] ?? "?").toUpperCase()} ); - return props.to - ? ( - - {logo} - - ) - : ( - logo - ); + + if (props.to) return ( + + {logo} + + ); + + return ( + logo + ); }; export { PositionLogo }; diff --git a/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx b/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx index abac7cd..af13558 100644 --- a/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx +++ b/src/pages/account/ui/AccountPage/components/PositionSection.view.tsx @@ -15,24 +15,27 @@ const PositionSection: FC = (props) => { const { section } = props; const isExpanded = section.kind !== "other"; + const cols = isExpanded ? 1 : { base: 1, md: 3, sm: 2 }; + return ( - - {section.groups.map(group => - group.positions.length > 1 - ? ( - - ) - : ( - - ), + + {section.groups.map((group) => { + if (group.positions.length > 1) return ( + + ); + + return ( + + ); + }, )} ); diff --git a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx index 874f91c..e4e2af9 100644 --- a/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx +++ b/src/pages/account/ui/AccountPage/components/RecentOperations.view.tsx @@ -15,54 +15,61 @@ type RecentOperationsProps = { }; const RecentOperations: FC = (props) => { - const { operations } = props; const { t } = useTranslation(); + const { operations } = props; return ( - {(operations.state === "loaded") && (( + {operations.state === "loaded" && ( <> - {!!(operations.lastSuccessfulAt) && (( + {!!operations.lastSuccessfulAt && ( {t("investment.sourceTime", { date: formatSnapshotTime(operations.lastSuccessfulAt), })} - ))} - {!!(operations.stale) && (( - {t("investment.stale")} - ))} - {!!(operations.refreshError) && (( + )} + + {!!operations.stale && {t("investment.stale")}} + + {!!operations.refreshError && ( {t("accounts.refreshError")} - ))} + )} - ))} - {(operations.state === "loading") && (( + )} + + {operations.state === "loading" && ( {["first", "second", "third"].map(placeholder => ( ))} - ))} - {(operations.state === "error") && (( + )} + + {operations.state === "error" && ( {t("ledger.recentError")} - ))} - {(operations.state === "empty") && (( + )} + + {operations.state === "empty" && ( {t("ledger.recentEmpty")} - ))} - {(operations.state === "partial") && (( + )} + + {operations.state === "partial" && ( {t("investment.partial")} - ))} - {(operations.state === "loaded" || operations.state === "partial") && (operations.items.map(operation => ( - - )))} + )} + + {(operations.state === "loaded" || operations.state === "partial") + && operations.items.map(operation => ( + + ))} + diff --git a/src/pages/account/ui/AccountPage/position-groups.test.tsx b/src/pages/account/ui/AccountPage/position-groups.test.tsx index 725732c..f6debb6 100644 --- a/src/pages/account/ui/AccountPage/position-groups.test.tsx +++ b/src/pages/account/ui/AccountPage/position-groups.test.tsx @@ -8,8 +8,10 @@ import type { Portfolio } from "@/entities/portfolio"; import { toAccountPagePositionSections } from "./AccountPage.vm"; import { PositionSection } from "./components/PositionSection.view"; -const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +const position: Portfolio["positions"][number] + = { absoluteReturn: { currency: "RUB", value: "25" }, account: { id: "a", name: "Основной" }, averagePurchasePrice: { currency: "RUB", value: "280" }, @@ -21,18 +23,21 @@ const position: Portfolio["positions"][number] = (/* eslint-disable unicorn/no-n quantity: "1.25", ticker: "SBER", type: "STOCK", - } -/* eslint-enable unicorn/no-null */); -const second = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + }; + +/* eslint-enable unicorn/no-null */ +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ +const second + = { ...position, absoluteReturn: null, account: { id: "b", name: "ИИС" }, averagePurchasePrice: null, currentValue: null, quantity: "-2", - } -/* eslint-enable unicorn/no-null */); + }; + +/* eslint-enable unicorn/no-null */ function sections(positions: Portfolio["positions"]) { return toAccountPagePositionSections({ cashBalances: [], @@ -55,9 +60,11 @@ describe("Positions grouped by security", () => { , ); expect(screen.getAllByText("Сбербанк")).toHaveLength(1); + const rows = within( screen.getByRole("list", { name: "Позиции по счетам" }), ).getAllByRole("listitem"); + expect(rows).toHaveLength(2); expect( within(rows[0]).getByRole("link", { name: "Основной" }), @@ -77,26 +84,31 @@ describe("Positions grouped by security", () => { it("uses ISIN only when FIGI is absent, never merging by name or ticker", () => { expect( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ sections([ - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { ...position, figi: null } - /* eslint-enable unicorn/no-null */), - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { ...second, figi: null } - /* eslint-enable unicorn/no-null */), + { + ...position, + figi: null, + }, + /* eslint-enable unicorn/no-null */ { ...second, figi: null }, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture. ])[0].groups, ).toHaveLength(1); expect( sections([position, { ...second, figi: "OTHER-FIGI" }])[0].groups, ).toHaveLength(2); expect( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ sections([ - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { ...position, figi: null, isin: null } - /* eslint-enable unicorn/no-null */), - (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { ...second, figi: null, isin: null } - /* eslint-enable unicorn/no-null */), + { + ...position, + figi: null, + isin: null, + }, + /* eslint-enable unicorn/no-null */ { + ...second, + figi: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture. + isin: null, // eslint-disable-line unicorn/no-null -- Preserve explicit null in the API fixture. + }, ])[0].groups, ).toHaveLength(2); }); @@ -122,6 +134,7 @@ describe("Positions grouped by security", () => { currentValue: { currency: "USD", value: "1.01" }, }, ]; + const before = structuredClone(source); const [group] = sections(source)[0].groups; expect( diff --git a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx index 2329738..7ed0c69 100644 --- a/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx +++ b/src/pages/account/ui/AccountPage/position-layout.browser.test.tsx @@ -23,27 +23,34 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor "bonds", "other", ]; + const width = window.innerWidth; expect([375, 1280]).toContain(width); + for (const kind of kinds) { const otherType = kind === "bonds" ? "BOND" : "FUND"; - const positions: AccountPagePosition[] = [1, 2, 3].map(index => ((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { - absoluteReturn: null, - averagePurchasePrice: null, - currentValue: { currency: "RUB", value: "1000" }, - displayName: `Инструмент ${index}`, - figi: null, - isin: null, - logoName: null, - logoSrc: null, - quantity: "10", - returnText: null, - shareText: null, - ticker: null, - type: kind === "stocks" ? "STOCK" : otherType, - } - /* eslint-enable unicorn/no-null */))); + + const positions: AccountPagePosition[] = [1, 2, 3].map( + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + index => + ({ + absoluteReturn: null, + averagePurchasePrice: null, + currentValue: { currency: "RUB", value: "1000" }, + displayName: `Инструмент ${index}`, + figi: null, + isin: null, + logoName: null, + logoSrc: null, + quantity: "10", + returnText: null, + shareText: null, + ticker: null, + type: kind === "stocks" ? "STOCK" : otherType, + }), + /* eslint-enable unicorn/no-null */ + ); + const { unmount } = render( , ); + const bounds = positions.map(position => screen.getByText(position.displayName).getBoundingClientRect(), ); + const context = `${kind} at ${width}px`; + for (const [index, current] of bounds.entries()) { expect(current.width, context).toBeGreaterThan(0); expect(current.height, context).toBeGreaterThan(0); if (index === 0) continue; const previous = bounds[index - 1]; + if (kind === "other" && width === 1280) { // eslint-disable-next-line vitest/no-conditional-expect -- The fixed table case selects the expected outcome; every case asserts its own branch. expect(current.top, context).toBeCloseTo(previous.top, 0); @@ -83,6 +94,7 @@ it("keeps expanded positions vertical while other positions adapt to the viewpor expect(current.top, context).toBeGreaterThan(previous.bottom); } } + unmount(); } }); diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx index 11b40a8..c91e09b 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.test.tsx @@ -33,6 +33,7 @@ function configureTestAuth() { function renderPage(queryClient: QueryClient) { configureTestAuth(); + return render( @@ -50,6 +51,7 @@ describe("AccountsPage", () => { server.use( http.get("*/api/v1/brokerage-accounts", ({ request }) => { modes.push(new URL(request.url).searchParams.get("sync")); + return modes.length === 1 ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json(resource([account])); @@ -102,9 +104,11 @@ describe("AccountsPage", () => { http.get("*/api/v1/brokerage-accounts", () => { requestCount += 1; if (requestCount === 1) return HttpResponse.json(resource([account])); + { const deferred = Promise.withResolvers(); resolveRefresh = deferred.resolve; + return deferred.promise; } }), @@ -131,6 +135,7 @@ describe("AccountsPage", () => { server.use( http.post("*/api/v1/tbank-sync", async ({ request }) => { requests.push(await request.json()); + return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, @@ -142,11 +147,9 @@ describe("AccountsPage", () => { http.get("*/api/v1/brokerage-accounts", () => { requestCount += 1; if (requestCount === 1) return HttpResponse.json(resource([account])); + return requestCount === 2 - ? HttpResponse.json( - { code: "TBANK_UNAVAILABLE" }, - { status: 503 }, - ) + ? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }) : HttpResponse.json(resource([account])); }), ); @@ -161,6 +164,7 @@ describe("AccountsPage", () => { const warning = await screen.findByText( "Не удалось обновить данные. Показаны последние полученные.", ); + expect(warning).toBeInTheDocument(); expect(screen.getByText(account.name)).toBeInTheDocument(); @@ -176,12 +180,10 @@ describe("AccountsPage", () => { server.use( http.get("*/api/v1/brokerage-accounts", () => { requestCount += 1; + return requestCount === 1 ? HttpResponse.json(resource([])) - : HttpResponse.json( - { code: "TBANK_UNAVAILABLE" }, - { status: 503 }, - ); + : HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 }); }), ); const queryClient = createTestQueryClient(); @@ -210,11 +212,13 @@ it("keeps unknown membership loading instead of declaring no accounts", async () const client = createTestQueryClient(); renderPage(client); await waitFor(() => - expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject((/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + expect(client.getQueryData(brokerageAccountsQueryKey)).toMatchObject( { data: null, - } - /* eslint-enable unicorn/no-null */)), + }, + /* eslint-enable unicorn/no-null */ + ), ); expect( screen.queryByText("Нет инвестиционных счетов"), diff --git a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx index d11dc2e..ef6828f 100644 --- a/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx +++ b/src/pages/accounts/ui/AccountsPage/AccountsPage.tsx @@ -20,6 +20,7 @@ const AccountsPage: FC = () => { sourceError={viewModel.sourceError} /> ); + return ( = (props) => { const { t } = useTranslation(); + return ( + void }; const AccountsPageError: FC = (props) => { const { code, retry } = props; const { t } = useTranslation(); + const settingsAction = ( ); + if (isApiErrorCode(code, ApiErrorCode.TbankNotConnected)) return ( = (props) => { action={( + {settingsAction} )} @@ -49,6 +52,7 @@ const AccountsPageError: FC = (props) => { title={t("accounts.unavailableTitle")} /> ); + return ( {t("accounts.retry")}} diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx index ea83233..de6c354 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoaded.view.tsx @@ -21,15 +21,19 @@ type AccountsPageLoadedProps = AccountsRefreshStateProps & { const AccountsPageLoaded: FC = (props) => { const { accounts, ...refresh } = props; const { t } = useTranslation(); + return ( {t("accounts.title")} + {t("accounts.subtitle")} + + + ); diff --git a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoading.view.tsx b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoading.view.tsx index 807c88f..af84473 100644 --- a/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoading.view.tsx +++ b/src/pages/accounts/ui/AccountsPage/components/AccountsPageLoading.view.tsx @@ -6,26 +6,35 @@ import { useTranslation } from "react-i18next"; const AccountsPageLoading: FC = () => { const { t } = useTranslation(); + return ( + + , ); + return { failQuote: () => { isFailQuote = true; @@ -276,6 +300,7 @@ function setup( requests, }; } + describe("bond page", () => { it("opens a direct ticker, keeps percentages distinct from money, and filters personal history by UID", async () => { const { requests } = setup({ path: "/bonds/TEST", slug: "TEST" }); @@ -339,9 +364,7 @@ describe("bond page", () => { const message = await screen.findByText(/Не удалось загрузить этот раздел/); expect( // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. - message.closest( - "[role=\"status\"]", - ), + message.closest("[role=\"status\"]"), ).toHaveAttribute("aria-live", "polite"); expect( screen.queryByRole("heading", { name: "Моя позиция" }), @@ -355,6 +378,7 @@ describe("bond page", () => { server.use( http.post("*/api/v1/tbank-sync", async ({ request }) => { scopes.push(await request.json()); + return HttpResponse.json( { code: "TBANK_UNAVAILABLE" }, { status: 503 }, @@ -368,9 +392,7 @@ describe("bond page", () => { const message = await screen.findByText(/последние полученные/); expect( // eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish. - message.closest( - "[role=\"status\"]", - ), + message.closest("[role=\"status\"]"), ).toHaveAttribute("aria-live", "polite"); expect(screen.getByText("98,125 %")).toBeVisible(); expect(scopes).toEqual([ diff --git a/src/pages/bond/ui/BondPage/BondPage.tsx b/src/pages/bond/ui/BondPage/BondPage.tsx index fe20af4..b7225d3 100644 --- a/src/pages/bond/ui/BondPage/BondPage.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.tsx @@ -19,16 +19,26 @@ const BondPage: FC = () => { + + ); - const unavailableMessage = isApiErrorCode(vm.code, ApiErrorCode.BondTickerAmbiguous) ? "bond.ambiguousTicker" : "bond.unavailableText"; + + const unavailableMessage = isApiErrorCode( + vm.code, + ApiErrorCode.BondTickerAmbiguous, + ) + ? "bond.ambiguousTicker" + : "bond.unavailableText"; + if (!vm.bond) return ( {t("bond.unavailable")} + {t( isApiErrorCode(vm.code, ApiErrorCode.TbankNotConnected) @@ -37,17 +47,21 @@ const BondPage: FC = () => { : unavailableMessage, )} + {t("bond.backToSearch")} + {t("accounts.openSettings")} + ); + return ; }; diff --git a/src/pages/bond/ui/BondPage/BondPage.view.tsx b/src/pages/bond/ui/BondPage/BondPage.view.tsx index da0b2fb..7e37b1f 100644 --- a/src/pages/bond/ui/BondPage/BondPage.view.tsx +++ b/src/pages/bond/ui/BondPage/BondPage.view.tsx @@ -35,12 +35,113 @@ import type { BondPageVm } from "./BondPage.vm"; import classes from "./BondPage.module.css"; type BondPageViewProps = { vm: BondPageVm }; + const BondPageView: FC = (props) => { const { vm } = props; const { t } = useTranslation(); const bond = vm.bond; if (!bond) return; - const maturity = bond.maturityDate ? formatDate(bond.maturityDate) : t("bond.unknown"); + + const maturity = bond.maturityDate + ? formatDate(bond.maturityDate) + : t("bond.unknown"); + + const maturityDate = bond.perpetual ? t("bond.perpetual") : maturity; + + const callDate = bond.callDate + ? formatDate(bond.callDate) + : t("bond.unknown"); + + const couponFrequency + = bond.couponQuantityPerYear == undefined + ? t("bond.unknown") + : String(bond.couponQuantityPerYear); + + const lotSize = bond.lot == undefined ? t("bond.unknown") : String(bond.lot); + + let quoteContent; + + if (vm.quoteState.loading) { + quoteContent = ( + + + + ); + } + else { + quoteContent = ( + + + {t("bond.percentOfNominal")} + + + + {vm.quote?.pricePercent == undefined + ? t("bond.unknown") + : `${formatDecimal(vm.quote.pricePercent)} %`} + + + {vm.quote?.changePercentagePoints != undefined && ( + + {formatDecimal(vm.quote.changePercentagePoints)}{" "} + {t("bond.percentagePoints")} + + )} + + + {vm.quote?.previousCloseDate + ? t("stock.changeBase", { + date: formatDate(vm.quote.previousCloseDate), + }) + : t("stock.noComparison")} + + + + + + + + + + + + {t("bond.indicativePrice")} + + + + + {t(`stock.trading.${vm.quote?.tradingStatus}`, { + defaultValue: t("stock.trading.unknown"), + })} + + + {!!vm.quote?.priceAt && ( + + {t("stock.priceAt", { + date: formatSnapshotTime(vm.quote.priceAt), + })} + + )} + + + {!!vm.quote?.unavailable.length && ( + + {t("stock.partialQuote")} + + )} + + ); + } + return ( @@ -49,19 +150,18 @@ const BondPageView: FC = (props) => { {bond.name[0]} +
    {bond.name} + - {bond.ticker} - {" "} - · - {" "} - {bond.isin} + {bond.ticker} · {bond.isin}
    + + - {!!(bond.floatingCoupon) && (( + {!!bond.floatingCoupon && ( {t("bond.floatingCoupon")} - ))} - {!!(bond.amortization) && (( + )} + + {!!bond.amortization && ( {t("bond.amortization")} - ))} - {!!(bond.perpetual) && (( + )} + + {!!bond.perpetual && ( {t("bond.perpetual")} - ))} - {!!(bond.subordinated) && (( + )} + + {!!bond.subordinated && ( {t("bond.subordinated")} - ))} + )} - {!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && (( + + {!!(vm.refreshError || vm.bondState.error || vm.quoteState.error) && ( {t(vm.quote ? "accounts.refreshError" : "stock.sectionError")} - ))} - {vm.quoteState.loading - ? ( - - - - ) - : ( - - - {t("bond.percentOfNominal")} - - - {vm.quote?.pricePercent == undefined - ? t("bond.unknown") - : `${formatDecimal(vm.quote.pricePercent)} %`} - - {vm.quote?.changePercentagePoints != undefined && (( - - {formatDecimal(vm.quote.changePercentagePoints)} - {" "} - {t("bond.percentagePoints")} - - ))} - - {vm.quote?.previousCloseDate - ? t("stock.changeBase", { - date: formatDate(vm.quote.previousCloseDate), - }) - : t("stock.noComparison")} - - - - - - - - {t("bond.indicativePrice")} - - - - {t(`stock.trading.${vm.quote?.tradingStatus}`, { - defaultValue: t("stock.trading.unknown"), - })} - - {!!(vm.quote?.priceAt) && (( - - {t("stock.priceAt", { - date: formatSnapshotTime(vm.quote.priceAt), - })} - - ))} - - {!!(vm.quote?.unavailable.length) && (( - - {t("stock.partialQuote")} - - ))} - - )} - {!!(vm.quoteState.refreshing || vm.bondState.refreshing) && (( + )} + + {quoteContent} + + {!!(vm.quoteState.refreshing || vm.bondState.refreshing) && ( {t("accounts.refreshing")} - ))} + )} +
    {t("bond.terms")} +
    + - - - - + + + + + + + +
    + + + + + = (props) => { const { vm } = props; const { t } = useTranslation(); const creditRating = vm.metrics?.creditRating; - let rating = t(vm.metrics?.unavailable.includes("rating") ? "bond.ratingUnavailable" : "bond.ratingMissing"); - if (creditRating) rating = creditRating.status === "withdrawn" ? t("bond.ratingWithdrawn") : (creditRating.value ?? t("bond.unknown")); + + let rating = t( + vm.metrics?.unavailable.includes("rating") + ? "bond.ratingUnavailable" + : "bond.ratingMissing", + ); + + if (creditRating) { + rating + = creditRating.status === "withdrawn" + ? t("bond.ratingWithdrawn") + : (creditRating.value ?? t("bond.unknown")); + } + + const couponDate = vm.metrics?.couponDate + ? formatDate(vm.metrics?.couponDate) + : t("bond.unknown"); + + const effectiveYield = vm.metrics?.effectiveYield + ? `${formatDecimal(vm.metrics?.effectiveYield.value)} %` + : t("bond.unknown"); + + const couponYield + = vm.metrics?.couponYieldPercent == undefined + ? t("bond.unknown") + : `${formatDecimal(vm.metrics?.couponYieldPercent)} %`; + + let yieldContent; + + if (vm.metrics?.effectiveYield) { + yieldContent = ( + + {t("bond.yieldSource", { + at: formatSnapshotTime(vm.metrics?.effectiveYield.tradeAt), + date: formatDate(vm.metrics?.effectiveYield.date), + price: formatDecimal( + vm.metrics?.effectiveYield.pricePercent, + ), + target: t( + `bond.yieldTargets.${vm.metrics?.effectiveYield.dateType}`, + { defaultValue: t("bond.yieldTargets.unknown") }, + ), + })} + + ); + } + else { + yieldContent = ( + + {t("bond.yieldUnavailable")} + + ); + } + return ( - {!!(vm.metrics) && (( + {!!vm.metrics && (
    - + + + - - - + + + + + +
    - {!!(vm.metrics.creditRating) && (( + + {!!vm.metrics.creditRating && ( = (props) => { date: formatDate(vm.metrics.creditRating.date), })} + {t("bond.ratingChecked", { - date: formatSnapshotTime( - vm.metrics.creditRating.observedAt, - ), + date: formatSnapshotTime(vm.metrics.creditRating.observedAt), })} - ))} + )} + {t("bond.ratingCoverage")} - {vm.metrics.effectiveYield - ? ( - - {t("bond.yieldSource", { - at: formatSnapshotTime(vm.metrics.effectiveYield.tradeAt), - date: formatDate(vm.metrics.effectiveYield.date), - price: formatDecimal(vm.metrics.effectiveYield.pricePercent), - target: t( - `bond.yieldTargets.${vm.metrics.effectiveYield.dateType}`, - { defaultValue: t("bond.yieldTargets.unknown") }, - ), - })} - - ) - : ( - - {t("bond.yieldUnavailable")} - - )} + + {yieldContent} + {t("bond.couponYieldNote")} - {!!(vm.metrics.couponYieldPriceAt) && (( + + {!!vm.metrics?.couponYieldPriceAt && ( {t("bond.couponYieldPriceAt", { - date: formatSnapshotTime(vm.metrics.couponYieldPriceAt), + date: formatSnapshotTime(vm.metrics?.couponYieldPriceAt), })} - ))} - {vm.metrics.unavailable.length > 0 && (( + )} + + {vm.metrics.unavailable.length > 0 && ( {t("bond.metricsPartial")} - ))} + )}
    - ))} + )}
    ); }; @@ -343,18 +391,20 @@ const BondMetrics: FC = (props) => { const BondCoupons: FC = (props) => { const { vm } = props; const { t } = useTranslation(); + return ( - {!!(vm.coupons) && (( + {!!vm.coupons && ( - {!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && (( + {!!(vm.coupons.estimateUnavailable || vm.coupons.positionIsStale) && ( {t("stock.estimateUnavailable")} - ))} + )} + {vm.upcoming.length > 0 ? ( vm.upcoming.map(coupon => ( @@ -367,25 +417,30 @@ const BondCoupons: FC = (props) => { : ( {t("bond.noCoupons")} )} + {t("bond.couponWindow", { from: formatDate(vm.coupons.from), to: formatDate(vm.coupons.to), })} + {t("bond.couponNote")} - {!!(vm.coupons.positionAt) && (( + + {!!vm.coupons.positionAt && ( {t("stock.snapshotAt", { date: formatSnapshotTime(vm.coupons.positionAt), })} - ))} + )} + {t("bond.couponHistory")} + {vm.pastCoupons.length > 0 @@ -405,28 +460,33 @@ const BondCoupons: FC = (props) => { - ))} + )} ); }; type MetricProps = { label: string; value: string }; + const Metric: FC = props => (
    {props.label} + {props.value}
    ); + type CouponProps = { coupon: NonNullable["coupons"][number]; }; + const Coupon: FC = (props) => { const { coupon } = props; const { t } = useTranslation(); + return ( @@ -436,39 +496,42 @@ const Coupon: FC = (props) => { ? formatDate(coupon.paymentDate) : t("stock.dateUnknown")} + {t("bond.couponNumber", { number: coupon.number ?? t("bond.unknown"), })}
  • + - {!!(coupon.estimatedAmount) && (( + + {!!coupon.estimatedAmount && ( - ))} + )} + {t(`bond.couponTypes.${coupon.couponType}`, { defaultValue: t("bond.unknown"), })} - {coupon.periodDays != undefined && (( + + {coupon.periodDays != undefined && ( {t("bond.couponDays", { count: coupon.periodDays })} - ))} - {!!(coupon.recordDate) && (( + )} + + {!!coupon.recordDate && ( - {t("stock.record")} - : - {" "} - {formatDate(coupon.recordDate)} + {t("stock.record")}: {formatDate(coupon.recordDate)} - ))} + )}
    ); diff --git a/src/pages/bond/ui/BondPage/BondPage.vm.ts b/src/pages/bond/ui/BondPage/BondPage.vm.ts index 65f94c7..69bcc41 100644 --- a/src/pages/bond/ui/BondPage/BondPage.vm.ts +++ b/src/pages/bond/ui/BondPage/BondPage.vm.ts @@ -21,55 +21,78 @@ import { formatDate, instrumentLogoUrl } from "@/shared/lib"; const periods: BondPeriod[] = ["month", "halfYear", "year", "all"]; const filters = ["all", "trades", "coupons", "other"] as const; + type BondEventFilter = (typeof filters)[number]; + const categories: Record = { all: [], coupons: ["income"], other: ["tax", "fee", "transfer", "other"], trades: ["trade"], }; + type BondPageVm = ReturnType; + function useBondPageVm() { const { bondId = "" } = useParams(); const bond = useBond(bondId); const uid = bond.data?.instrumentUid ?? ""; const [parameters, setParameters] = useSearchParams(); + const period = periods.find(value => value === parameters.get("period")) ?? "month"; + const filter = filters.find(value => value === parameters.get("events")) ?? "all"; + const quote = useBondQuote(uid); const metrics = useBondMetrics(uid); const candles = useBondCandles(uid, period); const position = useBondPosition(uid); const coupons = useBondCoupons(uid); - const history = useInstrumentOperations(uid, filter, /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter]); + + const history = useInstrumentOperations( + uid, + filter, + // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. + categories[filter], + ); + const { operations } = history; + const sync = useScreenSync( { accountId: "all", candlePeriod: period, - categories: /* eslint-disable-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. */ categories[filter], + categories: + // eslint-disable-next-line security/detect-object-injection -- This lookup uses a fixed typed key set, not an arbitrary external property name. + categories[filter], instrumentUid: uid, limit: 20, screen: "bond", }, [bond, quote, candles, position, metrics, coupons, operations], ); + const setParameter = (key: string, value: string) => { const next = new URLSearchParams(parameters); next.set(key, value); setParameters(next, { replace: true }); }; + const chartData = candles.data?.candles.map(candle => ({ ...candle, date: formatDate(candle.time), price: Number(candle.close), })) ?? []; + const search = parameters.size > 0 ? `?${parameters.toString()}` : ""; + return { - averageLine: /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + averageLine: + // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + null, bond: bond.data, bondState: section(bond, uid ? sync.retry : () => bond.refetch()), candles: candles.data, @@ -77,7 +100,7 @@ function useBondPageVm() { canonicalTo: bond.data?.slug && bond.data.slug !== bondId ? `/bonds/${encodeURIComponent(bond.data.slug)}${search}` - : /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + : null, // eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. chartData, code: apiErrorCode(bond.error), coupons: coupons.data, @@ -85,8 +108,16 @@ function useBondPageVm() { eventRows: operations.data?.items.map(item => ({ ...item, - logoName: item.logoName ?? bond.data?.logoName ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, - ticker: item.ticker ?? bond.data?.ticker ?? /* eslint-disable-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. */ null, + logoName: + item.logoName + ?? bond.data?.logoName + // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + ?? null, + ticker: + item.ticker + ?? bond.data?.ticker + // eslint-disable-next-line unicorn/no-null -- Preserve the nullable domain value consumed by this view model. + ?? null, })) ?? [], filter, hasIncompleteCandle: diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx index c9af0b5..98767c2 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx +++ b/src/pages/bonds/ui/BondsPage/BondsPage.test.tsx @@ -18,6 +18,7 @@ describe("bond search", () => { server.use( http.get("*/api/v1/bonds", ({ request }) => { query(new URL(request.url).searchParams.get("query")); + return HttpResponse.json( resource([ { diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx index 3a6077c..362a961 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx +++ b/src/pages/bonds/ui/BondsPage/BondsPage.view.tsx @@ -29,140 +29,155 @@ import { import type { BondsPageVm } from "./BondsPage.vm"; import classes from "./BondsPage.module.css"; +import { useBondsPageViewVm } from "./BondsPageView.vm"; type BondsPageViewProps = { vm: BondsPageVm }; -const BondsPageView: FC = (props) => { - const { vm } = props; + +const BondsPageViewResults: FC = (props) => { const { t } = useTranslation(); - function renderResults() { - if (vm.loading) { - return ( - - - - - - - - ); - } - if (!vm.ready) return; - if (vm.items.length > 0) { - return ( - - - {t("bond.searchVariants")} - - {vm.items.map(bond => ( - - - {bond.name[0]} - -
    - - {bond.name} - - - {bond.ticker} - {" "} - · - {" "} - {bond.perpetual - ? t("bond.perpetual") - : t("bond.matures", { - date: bond.maturityDate - ? formatDate(bond.maturityDate) - : t("bond.unknown"), - })} - - - - {t(`stock.exchanges.${bond.realExchange}`, { - defaultValue: t("stock.exchangeUnknown"), - })} - - {!!(bond.classCode) && (( - - {t("stock.classCode", { code: bond.classCode })} - - ))} - - {!!(bond.isin) && (( - - {t("stock.isin", { value: bond.isin })} - - ))} - - {t("bond.nominal")} - : - {" "} - {formatMoney(bond.nominal)} - {" "} - · - {" "} - {t("bond.couponFrequency")} - : - {" "} - {bond.couponQuantityPerYear ?? t("bond.unknown")} - - {!!(bond.floatingCoupon) && (( - - {t("bond.floatingCoupon")} - - ))} - {(bond.apiTradeAvailable === false) && (( - - {t("stock.apiUnavailable")} - - ))} -
    -
    - - {t("bond.percentOfNominal")} - - - {bond.pricePercent === null - ? t("bond.unknown") - : `${formatDecimal(bond.pricePercent)} %`} - - - {bond.pricePercent !== null && bond.priceAt - ? t("stock.priceAt", { - date: formatSnapshotTime(bond.priceAt), - }) - : t("stock.priceUnavailable")} - - {(bond.apiTradeAvailable === true) && (( - - {t("stock.apiAvailable")} - - ))} -
    -
    - ))} -
    - ); - } + const { vm } = props; + + if (vm.loading) { return ( - - {t(vm.partial ? "investment.partial" : "bond.noSearchResults")} - + + + + + + + + + ); } + + if (!vm.ready) return; + + if (vm.items.length > 0) { + return ( + + + {t("bond.searchVariants")} + + + {vm.items.map(bond => ( + + + {bond.name[0]} + + +
    + + {bond.name} + + + + {bond.ticker} ·{" "} + {bond.perpetual + ? t("bond.perpetual") + : t("bond.matures", { + date: bond.maturityDate + ? formatDate(bond.maturityDate) + : t("bond.unknown"), + })} + + + + + {t(`stock.exchanges.${bond.realExchange}`, { + defaultValue: t("stock.exchangeUnknown"), + })} + + + {!!bond.classCode && ( + + {t("stock.classCode", { code: bond.classCode })} + + )} + + + {!!bond.isin && ( + + {t("stock.isin", { value: bond.isin })} + + )} + + + {t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "} + {t("bond.couponFrequency")}:{" "} + {bond.couponQuantityPerYear ?? t("bond.unknown")} + + + {!!bond.floatingCoupon && ( + + {t("bond.floatingCoupon")} + + )} + + {bond.apiTradeAvailable === false && ( + + {t("stock.apiUnavailable")} + + )} +
    + +
    + + {t("bond.percentOfNominal")} + + + + {bond.pricePercent === null + ? t("bond.unknown") + : `${formatDecimal(bond.pricePercent)} %`} + + + + {bond.pricePercent !== null && bond.priceAt + ? t("stock.priceAt", { + date: formatSnapshotTime(bond.priceAt), + }) + : t("stock.priceUnavailable")} + + + {bond.apiTradeAvailable === true && ( + + {t("stock.apiAvailable")} + + )} +
    +
    + ))} +
    + ); + } + + return ( + + {t(vm.partial ? "investment.partial" : "bond.noSearchResults")} + + ); +}; + +const BondsPageView: FC = (props) => { + const { onChange, t, vm } = useBondsPageViewVm(props); + return (
    {t("bond.searchTitle")} + {t("bond.searchSubtitle")}
    + - {!!(vm.partial) && (( - {t("investment.partial")} - ))} + + {!!vm.partial && {t("investment.partial")}} + vm.setQuery(event.currentTarget.value)} + onChange={onChange} placeholder={t("bond.searchPlaceholder")} size="md" value={vm.query} /> - {!(vm.canSearch) && (( - {t("stock.searchHint")} - ))} - {!!(vm.error) && (( + + {!vm.canSearch && {t("stock.searchHint")}} + + {!!vm.error && ( @@ -195,16 +210,19 @@ const BondsPageView: FC = (props) => { : "stock.sectionError", )} + {t("accounts.openSettings")} + - ))} - {renderResults()} + )} + +
    ); }; diff --git a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts index de6b8b6..8e36f09 100644 --- a/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts +++ b/src/pages/bonds/ui/BondsPage/BondsPage.vm.ts @@ -9,6 +9,7 @@ import { instrumentLogoUrl } from "@/shared/lib"; const SEARCH_DEBOUNCE_MS = 300; type BondsPageVm = ReturnType; + function useBondsPageVm() { const [parameters, setParameters] = useSearchParams(); const query = parameters.get("q") ?? ""; @@ -17,6 +18,7 @@ function useBondsPageVm() { const sync = useScreenSync({ query: debounced, screen: "bonds" }, [result]); const canSearch = query.trim().length >= MIN_BOND_SEARCH_LENGTH; const isWaiting = query.trim() !== debounced; + return { canSearch, code: apiErrorCode(result.error), @@ -35,7 +37,10 @@ function useBondsPageVm() { && result.meta?.sync.state !== "failed")), partial: result.meta?.availability === "partial", query, - ready: debounced.length >= MIN_BOND_SEARCH_LENGTH && !isWaiting && Boolean(result.data), + ready: + debounced.length >= MIN_BOND_SEARCH_LENGTH + && !isWaiting + && Boolean(result.data), refreshing: sync.refreshing, retry: () => { void sync.retry(); diff --git a/src/pages/bonds/ui/BondsPage/BondsPageView.vm.ts b/src/pages/bonds/ui/BondsPage/BondsPageView.vm.ts new file mode 100644 index 0000000..6871bb2 --- /dev/null +++ b/src/pages/bonds/ui/BondsPage/BondsPageView.vm.ts @@ -0,0 +1,20 @@ +import type { ComponentProps } from "react"; + +import { TextInput } from "@mantine/core"; +import { useTranslation } from "react-i18next"; + +import type { BondsPageVm } from "./BondsPage.vm"; + +type BondsPageViewProps = { vm: BondsPageVm }; + +function useBondsPageViewVm(props: BondsPageViewProps) { + const { vm } = props; + const { t } = useTranslation(); + + const onChange: ComponentProps["onChange"] = event => + vm.setQuery(event.currentTarget.value); + + return { onChange, t, vm }; +} + +export { useBondsPageViewVm }; diff --git a/src/pages/card/ui/CardPage/CardPage.test.tsx b/src/pages/card/ui/CardPage/CardPage.test.tsx index 9d039e7..b4a9022 100644 --- a/src/pages/card/ui/CardPage/CardPage.test.tsx +++ b/src/pages/card/ui/CardPage/CardPage.test.tsx @@ -18,8 +18,11 @@ import { server } from "@/test/server"; import { CardPage } from "./CardPage"; const money = (value: string) => ({ currency: "RUB", value }); -const card = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + +const card + = { archivedAt: null, balance: money("10000"), bankName: null, @@ -29,10 +32,12 @@ const card = (/* eslint-disable unicorn/no-null -- Fixture preserves the product openingBalance: money("10000"), openingDate: "2026-01-01", version: 1, - } -/* eslint-enable unicorn/no-null */); -const operation = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + }; + +/* eslint-enable unicorn/no-null */ +/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ +const operation + = { amount: money("50.01"), availableRefund: null, cardId: "a", @@ -48,8 +53,9 @@ const operation = (/* eslint-disable unicorn/no-null -- Fixture preserves the pr targetCardId: "b", targetCardName: "Запасная", version: 1, - } -/* eslint-enable unicorn/no-null */); + }; + +/* eslint-enable unicorn/no-null */ function setup( options: { cardsError?: boolean; @@ -78,6 +84,7 @@ function setup( ), http.get("*/api/v1/bank-cards/a/operations", ({ request }) => { requests.push(new URL(request.url).search); + return HttpResponse.json({ hasNext: Number(new URL(request.url).searchParams.get("page")) < 3, items: [ @@ -92,6 +99,7 @@ function setup( }), http.get("*/api/v1/bank-cards/summary", ({ request }) => { summaryRequests.push(new URL(request.url).search); + return options.summaryError ? HttpResponse.error() : HttpResponse.json({ @@ -118,8 +126,10 @@ function setup( , ); + return { client, requests, summaryRequests }; } + describe("CardPage", () => { it.each([ ["0001-01", "0001-01-31"], @@ -132,6 +142,7 @@ describe("CardPage", () => { const { requests, summaryRequests } = setup({ month }); await screen.findByText("Перевод на «Запасная»"); await waitFor(() => expect(summaryRequests).toHaveLength(1)); + for (const request of [requests[0], summaryRequests[0]]) { const query = new URLSearchParams(request); expect(query.get("from")).toBe(`${month}-01`); @@ -193,20 +204,28 @@ describe("CardPage", () => { expect(screen.getByText(/10\s000 ₽/)).toBeInTheDocument(); }); it("keeps the journal in place while the next page loads", async () => { - const scroll = vi.spyOn(globalThis, "scrollBy").mockImplementation(() => {}); + const scroll = vi + .spyOn(globalThis, "scrollBy") + .mockImplementation(() => {}); + setup(); await screen.findByText("Перевод на «Запасная»"); + const navigation = screen.getByRole("navigation", { name: "Страницы операций", }); + const measure = vi .spyOn(navigation, "getBoundingClientRect") .mockReturnValue({ top: 600 } as DOMRect); - const { promise: pending, resolve: release } = Promise.withResolvers(); + const { promise: pending, resolve: release } + = Promise.withResolvers(); + server.use( http.get("*/api/v1/bank-cards/a/operations", async () => { await pending; + return HttpResponse.json({ hasNext: true, items: [{ ...operation, description: "Новая страница", id: "next" }], @@ -256,7 +275,7 @@ describe("CardPage", () => { await waitFor(() => { expect(requests.at(-1)).toContain("limit=20"); }); - expect(requests.at(-1)).toContain("page=1"); ; + expect(requests.at(-1)).toContain("page=1"); await waitFor(() => expect(screen.getByRole("button", { name: "Страница 1" })).toBeEnabled(), ); @@ -321,14 +340,16 @@ describe("CardPage", () => { archived: boolean; version: number; }; + bodies.push(body); - current = (/* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ - { + /* eslint-disable unicorn/no-null -- Fixture preserves the production contract’s explicit null values. */ + current + = { ...current, archivedAt: body.archived ? "2026-09-01" : null, version: current.version + 1, - } - /* eslint-enable unicorn/no-null */) as typeof card; + } as /* eslint-enable unicorn/no-null */ typeof card; + return HttpResponse.json(current); }), ); diff --git a/src/pages/card/ui/CardPage/CardPage.tsx b/src/pages/card/ui/CardPage/CardPage.tsx index 1199b39..10552dc 100644 --- a/src/pages/card/ui/CardPage/CardPage.tsx +++ b/src/pages/card/ui/CardPage/CardPage.tsx @@ -9,28 +9,26 @@ import { useCardPageVm } from "./CardPage.vm"; const CardPage: FC = () => { const vm = useCardPageVm(); + return ( <> + {vm.editingCard && vm.card.data && ( - vm.setEditingCard(false)} - /> + )} + {vm.operation && vm.card.data && vm.cards.data && ( vm.setOperation(null)} - operation={(vm.operation === "new") ? undefined : (vm.operation)} + onClose={vm.closeOperation} + operation={vm.editableOperation} /> )} + {vm.deleting && ( - vm.setDeleting(null)} - operation={vm.deleting} - /> + )} ); diff --git a/src/pages/card/ui/CardPage/CardPage.view.tsx b/src/pages/card/ui/CardPage/CardPage.view.tsx index 3529c92..6c4ebad 100644 --- a/src/pages/card/ui/CardPage/CardPage.view.tsx +++ b/src/pages/card/ui/CardPage/CardPage.view.tsx @@ -10,16 +10,22 @@ import { Text, Title, } from "@mantine/core"; -import { AddIcon, DeleteIcon, EditIcon } from "@wealthledger/icons"; +import { + AddIcon, + DeleteIcon, + EditIcon, + OperationIcon, + RestoreIcon, +} from "@wealthledger/icons"; import { EntryCard, LoadingSkeleton, StateView } from "@wealthledger/ui"; -import { type FC, type ReactNode, useLayoutEffect, useRef } from "react"; +import { type FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { BankCardFace, BankFeedback, - BankOperationIcon, + bankOperationAppearance, BankPeriodPicker, BankSummaryView, } from "@/entities/bank-card"; @@ -27,80 +33,33 @@ import { import type { CardPageVm } from "./CardPage.vm"; import classes from "./CardPage.module.css"; +import { useCardPagePresentationVm } from "./CardPagePresentation.vm"; type CardPageViewProps = { vm: CardPageVm }; -const CardPageView: FC = (props) => { + +// eslint-disable-next-line sonarjs/function-return-type -- React renders elements, lists and empty output. +const CardPageViewHistory: FC = (props) => { const { vm } = props; const { t } = useTranslation(); const card = vm.card.data; - const paginationRef = useRef(null); - const pendingPageRef = useRef(null); - const scope = `${card?.id}-${vm.period.from}-${vm.period.to}-${vm.pageSize}`; - useLayoutEffect(() => { - const pending = pendingPageRef.current; - if (!pending) return; - if (pending.scope !== scope || vm.history.isError) { - pendingPageRef.current = /* eslint-disable-line unicorn/no-null -- Reset the pending-pagination ref to its initial null sentinel. */ null; - return; - } - if (vm.history.isPlaceholderData || vm.history.data?.page !== pending.page) - return; - const pagination = paginationRef.current; - if (pagination) { - const delta = pagination.getBoundingClientRect().top - pending.top; - if (delta) window.scrollBy({ behavior: "instant", top: delta }); - } - // eslint-disable-next-line unicorn/no-null -- Preserve the existing nullable validation/ref contract. - pendingPageRef.current = null; - }, [ - scope, - vm.history.data, - vm.history.isPlaceholderData, - vm.history.isError, - ]); - if (!card) { - if (vm.card.isLoading) - return ( - - - - - - - - - ); - return ( - - - {!vm.notFound && ( - - )} - - )} - error - text={t("bank.back")} - title={t(vm.notFound ? "bank.notFound" : "bank.error")} - /> - ); - } - // eslint-disable-next-line sonarjs/function-return-type -- ReactNode deliberately includes lists, elements and empty output. - function renderHistory(): ReactNode { - if (!card) return; - if (vm.rows) { - if (vm.rows.length > 0) { - return vm.rows.map(row => ( + if (!card) return; + + if (vm.rows) { + if (vm.rows.length > 0) { + return vm.rows.map((row) => { + const details = row.otherCard + ? ( + + {t("bank.card")} + + ) + : undefined; + + return (
    = (props) => { || !row.editable || Boolean(card.archivedAt) } - onClick={() => vm.setOperation(row.item)} + onClick={row.onEdit} size="sm" title={t("bank.edit")} variant="subtle" > + = (props) => { || !row.editable || Boolean(card.archivedAt) } - onClick={() => vm.setDeleting(row.item)} + onClick={row.onDelete} size="sm" title={t("bank.delete")} variant="subtle" @@ -141,22 +101,13 @@ const CardPageView: FC = (props) => { )} color={row.color} - details={ - (row.otherCard) - ? (( - - {t("bank.card")} - - )) - : undefined - } + details={details} icon={( - )} initial={row.title[0]} @@ -167,33 +118,96 @@ const CardPageView: FC = (props) => { valueLabel={t(`bank.kinds.${row.item.kind}`)} />
    - )); - } - return {t("bank.noOperations")}; + ); + }); } - if (vm.history.isLoading) { + + return {t("bank.noOperations")}; + } + + if (vm.history.isLoading) { + return ( + + + + + + + + ); + } +}; + +const CardPageView: FC = (props) => { + const { vm } = props; + + const { + archiveActionLabel, + card, + getControlProps, + getItemProps, + onAddOperation, + onEditCard, + onPageChange, + onToggleArchive, + paginationRef, + retryCard, + retryCards, + retryHistory, + retrySummary, + t, + } = useCardPagePresentationVm(vm); + + if (!card) { + if (vm.card.isLoading) return ( - + + + + + + ); - } + + const errorTitle = t(vm.notFound ? "bank.notFound" : "bank.error"); + + return ( + + + + {!vm.notFound && } + + )} + error + text={t("bank.back")} + title={errorTitle} + /> + ); } + return ( {card.name} +
    + vm.setEditingCard(true)} + onClick={onEditCard} radius="md" size="md" title={t("bank.editCard")} @@ -201,31 +215,20 @@ const CardPageView: FC = (props) => { > + void vm.toggleArchive()} + onClick={onToggleArchive} radius="md" size="md" - title={t(card.archivedAt ? "bank.restore" : "bank.archive")} + title={archiveActionLabel} variant="subtle" > {card.archivedAt ? ( - + ) : ( @@ -233,46 +236,51 @@ const CardPageView: FC = (props) => {
    - {!!(vm.archiveError) && (( - void vm.card.refetch()} - /> - ))} + + {!!vm.archiveError && ( + + )} + {card.archivedAt && {t("bank.archiveHint")}}
    + {vm.card.isError && ( {t("bank.refreshError")} - + + )} + {vm.card.isFetching && {t("bank.refreshing")}} + {vm.cards.isError && ( void vm.cards.refetch()} + retry={retryCards} /> )} + + void vm.summary.refetch()} + retry={retrySummary} /> + {t("bank.operations")} + vm.setOperation("new")} + onClick={onAddOperation} size="lg" title={t("bank.addOperation")} variant="subtle" @@ -280,6 +288,7 @@ const CardPageView: FC = (props) => { + { - field.onChange(value ?? "expense"); - if (value === "refund") setValue("cardId", props.refundCardId); - }} - /> - )} + - )} + + - )} + - )} +