From f6db28defa51bd9d8fe932ceae4c3c720aba7eb3 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 27 Sep 2026 11:38:20 +0300 Subject: [PATCH] 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"],